@adamosuiteservices/ui 2.17.1 → 2.18.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.
@@ -0,0 +1,1113 @@
1
+ # File Upload V2 Component
2
+
3
+ ## Description
4
+
5
+ Enhanced file upload component with **unique file identification**, **metadata support**, and **server integration** capabilities. Built on the FileUpload component foundation, FileUploadV2 adds automatic ID generation for each file, making it ideal for scenarios requiring server-side file tracking, asynchronous uploads, and delete operations.
6
+
7
+ ## Features
8
+
9
+ - ✅ All features from FileUpload component
10
+ - ✅ Automatic unique ID generation for each file
11
+ - ✅ Custom ID generator support
12
+ - ✅ Metadata support for storing additional file information
13
+ - ✅ Server-side file tracking with IDs
14
+ - ✅ Pre-loaded files with server IDs
15
+ - ✅ Callbacks with file references for API integration
16
+ - ✅ Full TypeScript with FileWithMetadata type
17
+ - ✅ Perfect for async upload/delete operations
18
+
19
+ ## When to use
20
+
21
+ Use **FileUploadV2** when you need:
22
+
23
+ - Server-side file tracking with unique identifiers
24
+ - Asynchronous file uploads with progress tracking
25
+ - Delete operations requiring file IDs
26
+ - Pre-loading existing files from server
27
+ - Storing metadata alongside files
28
+ - Complex file management workflows
29
+
30
+ Use **FileUpload** (original) when you need:
31
+
32
+ - Simple file selection without server integration
33
+ - One-time form submission with files
34
+ - No need for file identification or tracking
35
+
36
+ ## Import
37
+
38
+ ```typescript
39
+ import { FileUploadV2 } from "@adamosuiteservices/ui/file-upload";
40
+ import type {
41
+ FileUploadV2Props,
42
+ FileWithMetadata,
43
+ FileUploadV2Labels,
44
+ } from "@adamosuiteservices/ui/file-upload";
45
+ ```
46
+
47
+ ## Basic usage
48
+
49
+ ### Single file with automatic IDs
50
+
51
+ ```tsx
52
+ import { useState } from "react";
53
+ import {
54
+ FileUploadV2,
55
+ FileWithMetadata,
56
+ } from "@adamosuiteservices/ui/file-upload";
57
+
58
+ function MyForm() {
59
+ const [file, setFile] = useState<FileWithMetadata | null>(null);
60
+
61
+ return <FileUploadV2 selectedFile={file} onFileSelect={setFile} />;
62
+ }
63
+ ```
64
+
65
+ ### Multiple files with automatic IDs
66
+
67
+ ```tsx
68
+ import { useState } from "react";
69
+ import {
70
+ FileUploadV2,
71
+ FileWithMetadata,
72
+ } from "@adamosuiteservices/ui/file-upload";
73
+
74
+ function MyForm() {
75
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
76
+
77
+ return (
78
+ <FileUploadV2 selectedFiles={files} onFilesSelect={setFiles} multiple />
79
+ );
80
+ }
81
+ ```
82
+
83
+ ### With server files (pre-loaded)
84
+
85
+ ```tsx
86
+ import { useState, useEffect } from "react";
87
+ import {
88
+ FileUploadV2,
89
+ FileWithMetadata,
90
+ } from "@adamosuiteservices/ui/file-upload";
91
+
92
+ function MyForm() {
93
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
94
+
95
+ useEffect(() => {
96
+ // Load existing files from server
97
+ const serverFiles: FileWithMetadata[] = [
98
+ {
99
+ id: "server-file-123",
100
+ file: new File(["content"], "report.xlsx", {
101
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
102
+ }),
103
+ metadata: {
104
+ uploadedAt: "2024-01-15T10:30:00Z",
105
+ uploadedBy: "user@example.com",
106
+ serverUrl: "https://api.example.com/files/server-file-123",
107
+ },
108
+ },
109
+ ];
110
+ setFiles(serverFiles);
111
+ }, []);
112
+
113
+ return (
114
+ <FileUploadV2 selectedFiles={files} onFilesSelect={setFiles} multiple />
115
+ );
116
+ }
117
+ ```
118
+
119
+ ## Props
120
+
121
+ ### FileWithMetadata type
122
+
123
+ ```tsx
124
+ type FileWithMetadata = {
125
+ id: string;
126
+ file: File;
127
+ metadata?: Record<string, unknown>;
128
+ };
129
+ ```
130
+
131
+ - **id**: Unique identifier for the file (auto-generated or provided)
132
+ - **file**: Native browser File object
133
+ - **metadata**: Optional additional data (upload timestamp, server URL, etc.)
134
+
135
+ ### Simple mode (single file)
136
+
137
+ #### selectedFile
138
+
139
+ ```tsx
140
+ selectedFile?: FileWithMetadata | null
141
+ ```
142
+
143
+ State of the currently selected file with ID and metadata. Use `null` when there is no file.
144
+
145
+ #### onFileSelect
146
+
147
+ ```tsx
148
+ onFileSelect?: (file: FileWithMetadata | null) => void
149
+ ```
150
+
151
+ Callback invoked when the user selects or removes a file. Receives the file with ID and metadata, or `null` when removed.
152
+
153
+ #### onFileAdd
154
+
155
+ ```tsx
156
+ onFileAdd?: (file: FileWithMetadata) => void
157
+ ```
158
+
159
+ Callback invoked when a new file is added. Receives the file with auto-generated ID. This fires in addition to `onFileSelect`.
160
+
161
+ **Use case**: Start upload immediately when file is added.
162
+
163
+ **Example**:
164
+
165
+ ```tsx
166
+ const handleFileAdd = async (fileWithMetadata: FileWithMetadata) => {
167
+ console.log("File added with ID:", fileWithMetadata.id);
168
+
169
+ const formData = new FormData();
170
+ formData.append("file", fileWithMetadata.file);
171
+ formData.append("fileId", fileWithMetadata.id);
172
+
173
+ await fetch("/api/upload", {
174
+ method: "POST",
175
+ body: formData,
176
+ });
177
+ };
178
+
179
+ <FileUploadV2
180
+ selectedFile={file}
181
+ onFileSelect={setFile}
182
+ onFileAdd={handleFileAdd}
183
+ />;
184
+ ```
185
+
186
+ #### onFileRemove
187
+
188
+ ```tsx
189
+ onFileRemove?: (file: FileWithMetadata) => void
190
+ ```
191
+
192
+ Callback invoked when a file is removed. Receives the file with its ID, enabling server-side deletion.
193
+
194
+ **Use case**: Call API to delete file from server using the file ID.
195
+
196
+ **Example**:
197
+
198
+ ```tsx
199
+ const handleFileRemove = async (fileWithMetadata: FileWithMetadata) => {
200
+ console.log("File removed with ID:", fileWithMetadata.id);
201
+
202
+ // Only delete from server if it has been uploaded
203
+ if (fileWithMetadata.metadata?.serverUrl) {
204
+ await fetch(`/api/files/${fileWithMetadata.id}`, {
205
+ method: "DELETE",
206
+ });
207
+ }
208
+ };
209
+
210
+ <FileUploadV2
211
+ selectedFile={file}
212
+ onFileSelect={setFile}
213
+ onFileRemove={handleFileRemove}
214
+ />;
215
+ ```
216
+
217
+ ### Multiple mode
218
+
219
+ #### selectedFiles
220
+
221
+ ```tsx
222
+ selectedFiles?: FileWithMetadata[]
223
+ ```
224
+
225
+ Array of selected files with IDs and metadata.
226
+
227
+ #### onFilesSelect
228
+
229
+ ```tsx
230
+ onFilesSelect?: (files: FileWithMetadata[]) => void
231
+ ```
232
+
233
+ Callback invoked when the user selects, adds, or removes files.
234
+
235
+ #### onFilesAdd
236
+
237
+ ```tsx
238
+ onFilesAdd?: (files: FileWithMetadata[]) => void
239
+ ```
240
+
241
+ Callback invoked when new files are added. Receives array of files with auto-generated IDs. This fires in addition to `onFilesSelect`.
242
+
243
+ **Example**:
244
+
245
+ ```tsx
246
+ const handleFilesAdd = async (filesWithMetadata: FileWithMetadata[]) => {
247
+ console.log(
248
+ "Files added:",
249
+ filesWithMetadata.map((f) => f.id),
250
+ );
251
+
252
+ // Upload each file
253
+ await Promise.all(
254
+ filesWithMetadata.map(async (fwm) => {
255
+ const formData = new FormData();
256
+ formData.append("file", fwm.file);
257
+ formData.append("fileId", fwm.id);
258
+
259
+ return fetch("/api/upload", {
260
+ method: "POST",
261
+ body: formData,
262
+ });
263
+ }),
264
+ );
265
+ };
266
+
267
+ <FileUploadV2
268
+ selectedFiles={files}
269
+ onFilesSelect={setFiles}
270
+ onFilesAdd={handleFilesAdd}
271
+ multiple
272
+ />;
273
+ ```
274
+
275
+ #### onFilesRemove
276
+
277
+ ```tsx
278
+ onFilesRemove?: (files: FileWithMetadata[]) => void
279
+ ```
280
+
281
+ Callback invoked when files are removed. Receives array of removed files with their IDs.
282
+
283
+ **Example**:
284
+
285
+ ```tsx
286
+ const handleFilesRemove = async (removedFiles: FileWithMetadata[]) => {
287
+ console.log(
288
+ "Files removed:",
289
+ removedFiles.map((f) => f.id),
290
+ );
291
+
292
+ // Delete from server
293
+ await Promise.all(
294
+ removedFiles
295
+ .filter((f) => f.metadata?.serverUrl)
296
+ .map((f) => fetch(`/api/files/${f.id}`, { method: "DELETE" })),
297
+ );
298
+ };
299
+
300
+ <FileUploadV2
301
+ selectedFiles={files}
302
+ onFilesSelect={setFiles}
303
+ onFilesRemove={handleFilesRemove}
304
+ multiple
305
+ />;
306
+ ```
307
+
308
+ #### multiple
309
+
310
+ ```tsx
311
+ multiple?: boolean
312
+ ```
313
+
314
+ Enables multiple file mode. Default: `false`
315
+
316
+ #### maxFiles
317
+
318
+ ```tsx
319
+ maxFiles?: number
320
+ ```
321
+
322
+ Maximum number of files allowed in multiple mode. Default: `10`
323
+
324
+ #### filesPosition
325
+
326
+ ```tsx
327
+ filesPosition?: "above" | "below"
328
+ ```
329
+
330
+ Position where selected files appear in multiple mode. Default: `"below"`
331
+
332
+ ### ID generation
333
+
334
+ #### generateId
335
+
336
+ ```tsx
337
+ generateId?: (file: File) => string
338
+ ```
339
+
340
+ Custom function to generate unique IDs for files. If not provided, uses default generator.
341
+
342
+ **Default generator format**: `{timestamp}-{random}-{filename}`
343
+
344
+ Example: `1704892800000-x7k2p9q3m-report.xlsx`
345
+
346
+ **Use case**: Use custom ID format for integration with existing systems.
347
+
348
+ **Example**:
349
+
350
+ ```tsx
351
+ const customIdGenerator = (file: File) => {
352
+ return `custom-${Date.now()}-${file.name}`;
353
+ };
354
+
355
+ <FileUploadV2
356
+ selectedFile={file}
357
+ onFileSelect={setFile}
358
+ generateId={customIdGenerator}
359
+ />;
360
+ ```
361
+
362
+ **Example with UUID**:
363
+
364
+ ```tsx
365
+ import { v4 as uuidv4 } from "uuid";
366
+
367
+ <FileUploadV2
368
+ selectedFile={file}
369
+ onFileSelect={setFile}
370
+ generateId={(file) => uuidv4()}
371
+ />;
372
+ ```
373
+
374
+ ### Common props
375
+
376
+ All props from the original FileUpload component are supported:
377
+
378
+ #### acceptedExtensions
379
+
380
+ ```tsx
381
+ acceptedExtensions?: string[]
382
+ ```
383
+
384
+ Array of allowed file extensions. Default: `[".xls", ".xlsx", ".numbers"]`
385
+
386
+ #### maxSizeInMB
387
+
388
+ ```tsx
389
+ maxSizeInMB?: number
390
+ ```
391
+
392
+ Maximum file size in megabytes. Default: `50`
393
+
394
+ #### labels
395
+
396
+ ```tsx
397
+ labels?: FileUploadV2Labels
398
+
399
+ type FileUploadV2Labels = {
400
+ dragDrop?: string
401
+ selectFile?: string
402
+ fileRequirements?: string
403
+ filesSelected?: (count: number) => string
404
+ }
405
+ ```
406
+
407
+ Customizable labels for internationalization.
408
+
409
+ #### aria-invalid / invalid
410
+
411
+ ```tsx
412
+ "aria-invalid"?: boolean
413
+ invalid?: boolean
414
+ ```
415
+
416
+ Marks the component as invalid. Default: `false`
417
+
418
+ #### disabled
419
+
420
+ ```tsx
421
+ disabled?: boolean
422
+ ```
423
+
424
+ Disables the component. Default: `false`
425
+
426
+ #### input
427
+
428
+ ```tsx
429
+ input?: ComponentProps<"input">
430
+ ```
431
+
432
+ Custom props for the internal `<input type="file">` element.
433
+
434
+ #### onInvalidFile
435
+
436
+ ```tsx
437
+ onInvalidFile?: (file: FileWithMetadata, reason: "extension" | "size") => void
438
+ ```
439
+
440
+ Callback invoked when a file does not pass validation.
441
+
442
+ ## Examples
443
+
444
+ ### With Field components
445
+
446
+ ```tsx
447
+ import {
448
+ Field,
449
+ FieldLabel,
450
+ FieldDescription,
451
+ FieldError,
452
+ FieldGroup,
453
+ } from "@adamosuiteservices/ui/field";
454
+ import {
455
+ FileUploadV2,
456
+ FileWithMetadata,
457
+ } from "@adamosuiteservices/ui/file-upload";
458
+
459
+ function FileUploadForm() {
460
+ const [file, setFile] = useState<FileWithMetadata | null>(null);
461
+ const [error, setError] = useState<string>("");
462
+
463
+ const handleInvalidFile = (
464
+ file: FileWithMetadata,
465
+ reason: "extension" | "size",
466
+ ) => {
467
+ if (reason === "extension") {
468
+ setError(`File "${file.file.name}" has an invalid extension.`);
469
+ } else if (reason === "size") {
470
+ setError(`File "${file.file.name}" exceeds the maximum size limit.`);
471
+ }
472
+ };
473
+
474
+ return (
475
+ <FieldGroup className="adm:max-w-xl">
476
+ <Field>
477
+ <FieldLabel>Upload document (PDF or DOCX only, max 2MB)</FieldLabel>
478
+ <FileUploadV2
479
+ selectedFile={file}
480
+ onFileSelect={(newFile) => {
481
+ setFile(newFile);
482
+ if (newFile) setError("");
483
+ }}
484
+ onInvalidFile={handleInvalidFile}
485
+ aria-invalid={!!error}
486
+ acceptedExtensions={[".pdf", ".docx"]}
487
+ maxSizeInMB={2}
488
+ />
489
+ {error ? (
490
+ <FieldError>{error}</FieldError>
491
+ ) : (
492
+ <FieldDescription>
493
+ Supported formats: PDF, DOCX. Maximum 2MB.
494
+ </FieldDescription>
495
+ )}
496
+ </Field>
497
+ </FieldGroup>
498
+ );
499
+ }
500
+ ```
501
+
502
+ ### With API integration (async upload)
503
+
504
+ ```tsx
505
+ import { useState } from "react";
506
+ import {
507
+ FileUploadV2,
508
+ FileWithMetadata,
509
+ } from "@adamosuiteservices/ui/file-upload";
510
+ import { Button } from "@adamosuiteservices/ui/button";
511
+ import { Typography } from "@adamosuiteservices/ui/typography";
512
+
513
+ function AsyncUploadExample() {
514
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
515
+ const [uploadStatus, setUploadStatus] = useState<
516
+ Record<string, "uploading" | "success" | "error">
517
+ >({});
518
+
519
+ const handleFilesAdd = async (addedFiles: FileWithMetadata[]) => {
520
+ // Mark files as uploading
521
+ const newStatus = { ...uploadStatus };
522
+ addedFiles.forEach((f) => {
523
+ newStatus[f.id] = "uploading";
524
+ });
525
+ setUploadStatus(newStatus);
526
+
527
+ // Upload each file
528
+ await Promise.all(
529
+ addedFiles.map(async (fileWithMetadata) => {
530
+ try {
531
+ const formData = new FormData();
532
+ formData.append("file", fileWithMetadata.file);
533
+ formData.append("fileId", fileWithMetadata.id);
534
+
535
+ const response = await fetch("/api/upload", {
536
+ method: "POST",
537
+ body: formData,
538
+ });
539
+
540
+ if (response.ok) {
541
+ const data = await response.json();
542
+
543
+ // Update file with server metadata
544
+ setFiles((prev) =>
545
+ prev.map((f) =>
546
+ f.id === fileWithMetadata.id
547
+ ? {
548
+ ...f,
549
+ metadata: {
550
+ ...f.metadata,
551
+ serverUrl: data.url,
552
+ uploadedAt: new Date().toISOString(),
553
+ },
554
+ }
555
+ : f,
556
+ ),
557
+ );
558
+
559
+ setUploadStatus((prev) => ({
560
+ ...prev,
561
+ [fileWithMetadata.id]: "success",
562
+ }));
563
+ } else {
564
+ setUploadStatus((prev) => ({
565
+ ...prev,
566
+ [fileWithMetadata.id]: "error",
567
+ }));
568
+ }
569
+ } catch (error) {
570
+ console.error("Upload failed:", error);
571
+ setUploadStatus((prev) => ({
572
+ ...prev,
573
+ [fileWithMetadata.id]: "error",
574
+ }));
575
+ }
576
+ }),
577
+ );
578
+ };
579
+
580
+ const handleFilesRemove = async (removedFiles: FileWithMetadata[]) => {
581
+ // Delete from server if they were uploaded
582
+ await Promise.all(
583
+ removedFiles
584
+ .filter((f) => f.metadata?.serverUrl)
585
+ .map(async (f) => {
586
+ try {
587
+ await fetch(`/api/files/${f.id}`, { method: "DELETE" });
588
+ } catch (error) {
589
+ console.error("Delete failed:", error);
590
+ }
591
+ }),
592
+ );
593
+
594
+ // Clean up upload status
595
+ setUploadStatus((prev) => {
596
+ const newStatus = { ...prev };
597
+ removedFiles.forEach((f) => delete newStatus[f.id]);
598
+ return newStatus;
599
+ });
600
+ };
601
+
602
+ return (
603
+ <div className="adm:max-w-xl adm:space-y-4">
604
+ <FileUploadV2
605
+ selectedFiles={files}
606
+ onFilesSelect={setFiles}
607
+ onFilesAdd={handleFilesAdd}
608
+ onFilesRemove={handleFilesRemove}
609
+ multiple
610
+ />
611
+
612
+ {files.length > 0 && (
613
+ <div className="adm:space-y-2">
614
+ <Typography className="adm:font-medium">Upload status:</Typography>
615
+ {files.map((f) => (
616
+ <div key={f.id} className="adm:flex adm:items-center adm:gap-2">
617
+ <Typography className="adm:text-sm">{f.file.name}</Typography>
618
+ <Typography
619
+ className="adm:text-sm"
620
+ color={
621
+ uploadStatus[f.id] === "success"
622
+ ? "success"
623
+ : uploadStatus[f.id] === "error"
624
+ ? "destructive"
625
+ : "muted"
626
+ }
627
+ >
628
+ {uploadStatus[f.id] === "uploading" && "Uploading..."}
629
+ {uploadStatus[f.id] === "success" && "✓ Uploaded"}
630
+ {uploadStatus[f.id] === "error" && "✗ Failed"}
631
+ </Typography>
632
+ </div>
633
+ ))}
634
+ </div>
635
+ )}
636
+ </div>
637
+ );
638
+ }
639
+ ```
640
+
641
+ ### With pre-loaded server files
642
+
643
+ ```tsx
644
+ import { useState, useEffect } from "react";
645
+ import {
646
+ FileUploadV2,
647
+ FileWithMetadata,
648
+ } from "@adamosuiteservices/ui/file-upload";
649
+
650
+ function PreloadedFilesExample() {
651
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
652
+
653
+ useEffect(() => {
654
+ // Simulate fetching files from server
655
+ const loadServerFiles = async () => {
656
+ const response = await fetch("/api/files");
657
+ const serverFiles = await response.json();
658
+
659
+ // Convert server files to FileWithMetadata
660
+ const filesWithMetadata: FileWithMetadata[] = serverFiles.map(
661
+ (sf: any) => ({
662
+ id: sf.id,
663
+ file: new File([""], sf.name, { type: sf.mimeType }),
664
+ metadata: {
665
+ serverUrl: sf.url,
666
+ uploadedAt: sf.uploadedAt,
667
+ size: sf.size,
668
+ },
669
+ }),
670
+ );
671
+
672
+ setFiles(filesWithMetadata);
673
+ };
674
+
675
+ loadServerFiles();
676
+ }, []);
677
+
678
+ const handleFilesRemove = async (removedFiles: FileWithMetadata[]) => {
679
+ // Delete from server
680
+ await Promise.all(
681
+ removedFiles.map((f) =>
682
+ fetch(`/api/files/${f.id}`, { method: "DELETE" }),
683
+ ),
684
+ );
685
+ };
686
+
687
+ return (
688
+ <FileUploadV2
689
+ selectedFiles={files}
690
+ onFilesSelect={setFiles}
691
+ onFilesRemove={handleFilesRemove}
692
+ multiple
693
+ className="adm:max-w-xl"
694
+ />
695
+ );
696
+ }
697
+ ```
698
+
699
+ ### With custom ID generator
700
+
701
+ ```tsx
702
+ import { useState } from "react";
703
+ import {
704
+ FileUploadV2,
705
+ FileWithMetadata,
706
+ } from "@adamosuiteservices/ui/file-upload";
707
+ import { v4 as uuidv4 } from "uuid";
708
+
709
+ function CustomIdExample() {
710
+ const [file, setFile] = useState<FileWithMetadata | null>(null);
711
+
712
+ // Use UUID for file IDs
713
+ const generateUUID = () => uuidv4();
714
+
715
+ return (
716
+ <FileUploadV2
717
+ selectedFile={file}
718
+ onFileSelect={setFile}
719
+ generateId={generateUUID}
720
+ className="adm:max-w-xl"
721
+ />
722
+ );
723
+ }
724
+ ```
725
+
726
+ ### With form integration and validation
727
+
728
+ ```tsx
729
+ import { useState } from "react";
730
+ import {
731
+ FileUploadV2,
732
+ FileWithMetadata,
733
+ } from "@adamosuiteservices/ui/file-upload";
734
+ import { Field, FieldLabel, FieldError } from "@adamosuiteservices/ui/field";
735
+ import { Button } from "@adamosuiteservices/ui/button";
736
+
737
+ function FormIntegrationExample() {
738
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
739
+ const [error, setError] = useState<string>("");
740
+ const [uploadedFileIds, setUploadedFileIds] = useState<string[]>([]);
741
+
742
+ const handleSubmit = async (e: React.FormEvent) => {
743
+ e.preventDefault();
744
+
745
+ if (files.length === 0) {
746
+ setError("Please select at least one file");
747
+ return;
748
+ }
749
+
750
+ setError("");
751
+
752
+ // Upload files that haven't been uploaded yet
753
+ const filesToUpload = files.filter((f) => !uploadedFileIds.includes(f.id));
754
+
755
+ if (filesToUpload.length > 0) {
756
+ try {
757
+ await Promise.all(
758
+ filesToUpload.map(async (f) => {
759
+ const formData = new FormData();
760
+ formData.append("file", f.file);
761
+ formData.append("fileId", f.id);
762
+
763
+ await fetch("/api/upload", {
764
+ method: "POST",
765
+ body: formData,
766
+ });
767
+ }),
768
+ );
769
+
770
+ setUploadedFileIds((prev) => [
771
+ ...prev,
772
+ ...filesToUpload.map((f) => f.id),
773
+ ]);
774
+ alert("Files uploaded successfully!");
775
+ } catch (error) {
776
+ setError("Upload failed. Please try again.");
777
+ }
778
+ } else {
779
+ alert("All files already uploaded!");
780
+ }
781
+ };
782
+
783
+ return (
784
+ <form onSubmit={handleSubmit} className="adm:max-w-xl adm:space-y-4">
785
+ <Field>
786
+ <FieldLabel>Upload documents</FieldLabel>
787
+ <FileUploadV2
788
+ selectedFiles={files}
789
+ onFilesSelect={(newFiles) => {
790
+ setFiles(newFiles);
791
+ setError("");
792
+ }}
793
+ aria-invalid={!!error}
794
+ multiple
795
+ />
796
+ {error && <FieldError>{error}</FieldError>}
797
+ </Field>
798
+
799
+ <Button type="submit">Submit form</Button>
800
+ </form>
801
+ );
802
+ }
803
+ ```
804
+
805
+ ### With event tracking
806
+
807
+ ```tsx
808
+ import { useState } from "react";
809
+ import {
810
+ FileUploadV2,
811
+ FileWithMetadata,
812
+ } from "@adamosuiteservices/ui/file-upload";
813
+ import { Typography } from "@adamosuiteservices/ui/typography";
814
+
815
+ function EventTrackingExample() {
816
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
817
+ const [events, setEvents] = useState<string[]>([]);
818
+
819
+ const logEvent = (message: string) => {
820
+ setEvents((prev) => [
821
+ ...prev,
822
+ `[${new Date().toLocaleTimeString()}] ${message}`,
823
+ ]);
824
+ };
825
+
826
+ const handleFilesAdd = (addedFiles: FileWithMetadata[]) => {
827
+ logEvent(
828
+ `Added ${addedFiles.length} file(s): ${addedFiles.map((f) => `${f.file.name} (ID: ${f.id})`).join(", ")}`,
829
+ );
830
+ };
831
+
832
+ const handleFilesRemove = (removedFiles: FileWithMetadata[]) => {
833
+ logEvent(
834
+ `Removed ${removedFiles.length} file(s): ${removedFiles.map((f) => `${f.file.name} (ID: ${f.id})`).join(", ")}`,
835
+ );
836
+ };
837
+
838
+ return (
839
+ <div className="adm:max-w-xl adm:space-y-4">
840
+ <FileUploadV2
841
+ selectedFiles={files}
842
+ onFilesSelect={setFiles}
843
+ onFilesAdd={handleFilesAdd}
844
+ onFilesRemove={handleFilesRemove}
845
+ multiple
846
+ />
847
+
848
+ {events.length > 0 && (
849
+ <div className="adm:space-y-2">
850
+ <Typography className="adm:font-medium">Event log:</Typography>
851
+ <div className="adm:max-h-40 adm:overflow-y-auto adm:rounded-lg adm:border adm:bg-muted adm:p-3">
852
+ {events.map((event, index) => (
853
+ <Typography
854
+ key={index}
855
+ className="adm:text-sm adm:font-mono"
856
+ color="muted"
857
+ >
858
+ {event}
859
+ </Typography>
860
+ ))}
861
+ </div>
862
+ </div>
863
+ )}
864
+ </div>
865
+ );
866
+ }
867
+ ```
868
+
869
+ ## Comparison with FileUpload
870
+
871
+ | Feature | FileUpload (original) | FileUploadV2 |
872
+ | ---------------------- | --------------------- | ---------------------- |
873
+ | File type | `File` | `FileWithMetadata` |
874
+ | Unique IDs | ❌ No | ✅ Yes (auto) |
875
+ | Custom ID generator | ❌ No | ✅ Yes (generateId) |
876
+ | Metadata support | ❌ No | ✅ Yes (optional) |
877
+ | Server integration | ⚠️ Manual | ✅ Built-in with IDs |
878
+ | Pre-loaded files | ⚠️ No IDs | ✅ With server IDs |
879
+ | Delete API calls | ⚠️ No file reference | ✅ Uses file.id |
880
+ | Async upload tracking | ⚠️ Manual tracking | ✅ Track by file.id |
881
+ | Callbacks with IDs | ❌ No | ✅ Yes |
882
+ | Use case | Simple forms | Server integration |
883
+ | Backward compatibility | ✅ Original | ❌ Different file type |
884
+
885
+ ## TypeScript types
886
+
887
+ ```typescript
888
+ export type FileWithMetadata = {
889
+ id: string;
890
+ file: File;
891
+ metadata?: Record<string, unknown>;
892
+ };
893
+
894
+ export type FileUploadV2Labels = {
895
+ dragDrop?: string;
896
+ selectFile?: string;
897
+ fileRequirements?: string;
898
+ filesSelected?: (count: number) => string;
899
+ };
900
+
901
+ export type FileUploadV2Props = ComponentProps<"div"> &
902
+ Readonly<{
903
+ // Simple mode
904
+ selectedFile?: FileWithMetadata | null;
905
+ onFileSelect?: (file: FileWithMetadata | null) => void;
906
+
907
+ // Multiple mode
908
+ selectedFiles?: FileWithMetadata[];
909
+ onFilesSelect?: (files: FileWithMetadata[]) => void;
910
+ multiple?: boolean;
911
+ maxFiles?: number;
912
+ filesPosition?: "above" | "below";
913
+
914
+ // Callbacks
915
+ onFileAdd?: (file: FileWithMetadata) => void;
916
+ onFileRemove?: (file: FileWithMetadata) => void;
917
+ onFilesAdd?: (files: FileWithMetadata[]) => void;
918
+ onFilesRemove?: (files: FileWithMetadata[]) => void;
919
+
920
+ // ID generation
921
+ generateId?: (file: File) => string;
922
+
923
+ // Validation
924
+ onInvalidFile?: (
925
+ file: FileWithMetadata,
926
+ reason: "extension" | "size",
927
+ ) => void;
928
+ invalid?: boolean;
929
+ "aria-invalid"?: boolean;
930
+
931
+ // States
932
+ disabled?: boolean;
933
+
934
+ // Common
935
+ acceptedExtensions?: string[];
936
+ maxSizeInMB?: number;
937
+ labels?: FileUploadV2Labels;
938
+ input?: ComponentProps<"input">;
939
+ }>;
940
+ ```
941
+
942
+ ## Default ID generation
943
+
944
+ The default ID generator creates unique IDs using the following format:
945
+
946
+ ```typescript
947
+ const defaultGenerateId = (file: File) => {
948
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}-${file.name}`;
949
+ };
950
+ ```
951
+
952
+ **Format**: `{timestamp}-{random}-{filename}`
953
+
954
+ **Example**: `1704892800000-x7k2p9q3m-report.xlsx`
955
+
956
+ **Components**:
957
+
958
+ - **timestamp**: Current timestamp in milliseconds
959
+ - **random**: Random alphanumeric string (9 characters)
960
+ - **filename**: Original file name
961
+
962
+ This ensures uniqueness even when uploading the same file multiple times.
963
+
964
+ ## Backend integration
965
+
966
+ ### Upload with file ID
967
+
968
+ ```tsx
969
+ const uploadFile = async (fileWithMetadata: FileWithMetadata) => {
970
+ const formData = new FormData();
971
+ formData.append("file", fileWithMetadata.file);
972
+ formData.append("fileId", fileWithMetadata.id);
973
+ formData.append(
974
+ "metadata",
975
+ JSON.stringify({
976
+ uploadedAt: new Date().toISOString(),
977
+ originalName: fileWithMetadata.file.name,
978
+ }),
979
+ );
980
+
981
+ const response = await fetch("/api/upload", {
982
+ method: "POST",
983
+ body: formData,
984
+ });
985
+
986
+ return response.json();
987
+ };
988
+ ```
989
+
990
+ ### Delete from server
991
+
992
+ ```tsx
993
+ const deleteFile = async (fileWithMetadata: FileWithMetadata) => {
994
+ await fetch(`/api/files/${fileWithMetadata.id}`, {
995
+ method: "DELETE",
996
+ });
997
+ };
998
+ ```
999
+
1000
+ ### Update file metadata after upload
1001
+
1002
+ ```tsx
1003
+ const handleFileAdd = async (fileWithMetadata: FileWithMetadata) => {
1004
+ const response = await uploadFile(fileWithMetadata);
1005
+
1006
+ // Update file with server metadata
1007
+ setFiles((prev) =>
1008
+ prev.map((f) =>
1009
+ f.id === fileWithMetadata.id
1010
+ ? {
1011
+ ...f,
1012
+ metadata: {
1013
+ serverUrl: response.url,
1014
+ uploadedAt: response.uploadedAt,
1015
+ },
1016
+ }
1017
+ : f,
1018
+ ),
1019
+ );
1020
+ };
1021
+ ```
1022
+
1023
+ ## Best practices
1024
+
1025
+ ### Do
1026
+
1027
+ ```tsx
1028
+ // Use FileUploadV2 when you need server-side tracking
1029
+ <FileUploadV2 selectedFile={file} onFileSelect={setFile} />;
1030
+
1031
+ // Always handle file removal for uploaded files
1032
+ const handleFileRemove = async (file: FileWithMetadata) => {
1033
+ if (file.metadata?.serverUrl) {
1034
+ await deleteFromServer(file.id);
1035
+ }
1036
+ };
1037
+
1038
+ // Store server metadata after successful upload
1039
+ const handleFileAdd = async (file: FileWithMetadata) => {
1040
+ const response = await uploadFile(file);
1041
+ updateFileMetadata(file.id, { serverUrl: response.url });
1042
+ };
1043
+
1044
+ // Use custom ID generator for specific requirements
1045
+ <FileUploadV2
1046
+ selectedFile={file}
1047
+ onFileSelect={setFile}
1048
+ generateId={(file) => uuidv4()}
1049
+ />;
1050
+ ```
1051
+
1052
+ ### Don't
1053
+
1054
+ ```tsx
1055
+ // Don't use FileUploadV2 for simple forms without server integration
1056
+ // Use FileUpload instead
1057
+ <FileUploadV2 selectedFile={file} onFileSelect={setFile} /> // Overkill
1058
+
1059
+ // Don't modify file.id after creation
1060
+ file.id = "new-id"; // ❌ IDs should be immutable
1061
+
1062
+ // Don't forget to clean up server files on remove
1063
+ const handleFileRemove = (file: FileWithMetadata) => {
1064
+ // ❌ Missing server deletion
1065
+ console.log("Removed:", file.id);
1066
+ };
1067
+
1068
+ // Don't store sensitive data in metadata (it's client-side)
1069
+ metadata: {
1070
+ password: "secret123", // ❌ Insecure
1071
+ apiKey: "key", // ❌ Insecure
1072
+ }
1073
+ ```
1074
+
1075
+ ## Notes
1076
+
1077
+ - The component uses the same UI as FileUpload but works with `FileWithMetadata` instead of `File`
1078
+ - IDs are generated automatically when files are added (drag & drop or file selection)
1079
+ - IDs are guaranteed unique within the component instance
1080
+ - For server integration, use the file ID to track uploads and deletions
1081
+ - Metadata is optional and can store any additional information
1082
+ - Pre-loaded files from server should include their server-assigned IDs
1083
+ - The component is fully controlled - the parent manages file state
1084
+ - Use `generateId` prop for custom ID generation strategies
1085
+ - All validation features from FileUpload are preserved
1086
+
1087
+ ## Migration from FileUpload
1088
+
1089
+ If you're using FileUpload and need file IDs:
1090
+
1091
+ **Before** (FileUpload):
1092
+
1093
+ ```tsx
1094
+ const [files, setFiles] = useState<File[]>([]);
1095
+
1096
+ <FileUpload selectedFiles={files} onFilesSelect={setFiles} multiple />;
1097
+ ```
1098
+
1099
+ **After** (FileUploadV2):
1100
+
1101
+ ```tsx
1102
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
1103
+
1104
+ <FileUploadV2 selectedFiles={files} onFilesSelect={setFiles} multiple />;
1105
+ ```
1106
+
1107
+ **Key changes**:
1108
+
1109
+ 1. Change type from `File[]` to `FileWithMetadata[]`
1110
+ 2. Update component from `FileUpload` to `FileUploadV2`
1111
+ 3. Access the native File object via `fileWithMetadata.file`
1112
+ 4. Use `fileWithMetadata.id` for server operations
1113
+ 5. Store server data in `fileWithMetadata.metadata`