@thangnqdev/ui 0.2.1 → 0.2.2
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/LICENSE +21 -21
- package/README.md +144 -142
- package/bin/cli.js +125 -125
- package/dist/bento/index.cjs.map +1 -1
- package/dist/bento/index.js +2 -2
- package/dist/buttons/index.cjs.map +1 -1
- package/dist/buttons/index.js +3 -3
- package/dist/{chunk-77KTYOMQ.js → chunk-2OXZK6IP.js} +3 -3
- package/dist/chunk-2OXZK6IP.js.map +1 -0
- package/dist/{chunk-4CRFNALF.js → chunk-4GNFT2ZJ.js} +7 -7
- package/dist/chunk-4GNFT2ZJ.js.map +1 -0
- package/dist/{chunk-2YJBW4E2.js → chunk-4S6L5V7X.js} +2 -2
- package/dist/chunk-4S6L5V7X.js.map +1 -0
- package/dist/{chunk-I2FQBMVW.js → chunk-66VWUJEI.js} +3 -3
- package/dist/chunk-66VWUJEI.js.map +1 -0
- package/dist/{chunk-YOCGBORV.js → chunk-7NMB2Y64.js} +4 -4
- package/dist/chunk-7NMB2Y64.js.map +1 -0
- package/dist/{chunk-V52JOSDF.js → chunk-A7P3O5Z5.js} +3 -3
- package/dist/chunk-A7P3O5Z5.js.map +1 -0
- package/dist/{chunk-ME4XILG5.js → chunk-ATYGDTMK.js} +5 -5
- package/dist/chunk-ATYGDTMK.js.map +1 -0
- package/dist/{chunk-S6XJMYME.js → chunk-C4SUXMI3.js} +3 -3
- package/dist/chunk-C4SUXMI3.js.map +1 -0
- package/dist/{chunk-EXFR2BFJ.js → chunk-DUPPPGXY.js} +7 -7
- package/dist/chunk-DUPPPGXY.js.map +1 -0
- package/dist/{chunk-I5O2WCGF.js → chunk-EU2LPUYP.js} +3 -3
- package/dist/chunk-EU2LPUYP.js.map +1 -0
- package/dist/{chunk-D7FB3TCA.js → chunk-GXI2H5XA.js} +3 -3
- package/dist/chunk-GXI2H5XA.js.map +1 -0
- package/dist/{chunk-ADO3SVUA.js → chunk-IABPUCAQ.js} +2 -2
- package/dist/chunk-IABPUCAQ.js.map +1 -0
- package/dist/{chunk-PC5SZFZC.js → chunk-JOKEKOP3.js} +8 -8
- package/dist/chunk-JOKEKOP3.js.map +1 -0
- package/dist/{chunk-GXWB6BSY.js → chunk-KUQWUTZA.js} +4 -4
- package/dist/chunk-KUQWUTZA.js.map +1 -0
- package/dist/{chunk-ZEDXARXW.js → chunk-U3J4UPV3.js} +2 -2
- package/dist/chunk-U3J4UPV3.js.map +1 -0
- package/dist/{chunk-DSEL7ELZ.js → chunk-VE2PNSDQ.js} +4 -4
- package/dist/chunk-VE2PNSDQ.js.map +1 -0
- package/dist/{chunk-YJ64RI7J.js → chunk-WAFHC4DQ.js} +3 -3
- package/dist/chunk-WAFHC4DQ.js.map +1 -0
- package/dist/{chunk-W5WNWTQC.js → chunk-YJ67WYVW.js} +5 -5
- package/dist/chunk-YJ67WYVW.js.map +1 -0
- package/dist/{chunk-4MS6ZHFQ.js → chunk-Z427NPMU.js} +7 -7
- package/dist/chunk-Z427NPMU.js.map +1 -0
- package/dist/core/index.cjs +4 -4
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/data-display/index.cjs.map +1 -1
- package/dist/data-display/index.js +5 -5
- package/dist/feedback/index.cjs.map +1 -1
- package/dist/feedback/index.js +2 -2
- package/dist/forms/index.cjs.map +1 -1
- package/dist/forms/index.js +8 -8
- package/dist/glass/index.cjs.map +1 -1
- package/dist/glass/index.js +2 -2
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +19 -19
- package/dist/layout/index.cjs.map +1 -1
- package/dist/layout/index.js +2 -2
- package/dist/navigation/index.cjs.map +1 -1
- package/dist/navigation/index.js +6 -6
- package/dist/overlays/index.cjs.map +1 -1
- package/dist/overlays/index.js +7 -7
- package/dist/reset.css +45 -45
- package/dist/tokens.css +11 -11
- package/dist/typography/index.cjs.map +1 -1
- package/dist/typography/index.js +4 -4
- package/llms.txt +3 -3
- package/package.json +165 -165
- package/scripts/postinstall.js +125 -125
- package/src/bento/BentoCard.tsx +140 -140
- package/src/bento/BentoGrid.tsx +50 -50
- package/src/bento/index.ts +2 -2
- package/src/bento/tokens.css +46 -46
- package/src/buttons/Button.tsx +104 -104
- package/src/buttons/ButtonGroup.tsx +43 -43
- package/src/buttons/IconButton.tsx +79 -79
- package/src/buttons/SplitButton.tsx +186 -186
- package/src/buttons/ToggleButton.tsx +157 -157
- package/src/buttons/index.ts +5 -5
- package/src/buttons/tokens.css +6 -6
- package/src/core/ThemeProvider.tsx +133 -133
- package/src/core/index.ts +2 -2
- package/src/core/tokens.css +134 -134
- package/src/data-display/Accordion.tsx +147 -147
- package/src/data-display/Avatar.tsx +134 -134
- package/src/data-display/Card.tsx +112 -112
- package/src/data-display/Chip.tsx +74 -74
- package/src/data-display/DataTablePro.tsx +534 -534
- package/src/data-display/StatCard.tsx +60 -60
- package/src/data-display/Table.tsx +74 -74
- package/src/data-display/Timeline.tsx +62 -62
- package/src/data-display/TreeView.tsx +279 -279
- package/src/data-display/tokens.css +4 -4
- package/src/feedback/Alert.tsx +80 -80
- package/src/feedback/EmptyState.tsx +45 -45
- package/src/feedback/Skeleton.tsx +66 -66
- package/src/feedback/Spinner.tsx +65 -65
- package/src/feedback/index.ts +5 -5
- package/src/feedback/tokens.css +4 -4
- package/src/forms/Checkbox.tsx +149 -149
- package/src/forms/CheckboxGroup.tsx +76 -76
- package/src/forms/Combobox.tsx +512 -512
- package/src/forms/DatePicker.tsx +784 -784
- package/src/forms/FileUpload.tsx +266 -266
- package/src/forms/FormField.tsx +115 -115
- package/src/forms/Input.tsx +151 -151
- package/src/forms/NumberInput.tsx +262 -262
- package/src/forms/PasswordInput.tsx +35 -35
- package/src/forms/PinInput.tsx +139 -139
- package/src/forms/Radio.tsx +142 -142
- package/src/forms/RangeSlider.tsx +237 -237
- package/src/forms/SearchInput.tsx +34 -34
- package/src/forms/Select.tsx +103 -103
- package/src/forms/Slider.tsx +85 -85
- package/src/forms/Textarea.tsx +114 -114
- package/src/forms/tokens.css +6 -6
- package/src/glass/GlassButton.tsx +59 -59
- package/src/glass/GlassCard.tsx +71 -71
- package/src/glass/index.ts +3 -3
- package/src/glass/tokens.css +81 -81
- package/src/index.ts +26 -26
- package/src/layout/AspectRatio.tsx +39 -39
- package/src/layout/Container.tsx +53 -53
- package/src/layout/Divider.tsx +61 -61
- package/src/layout/Flex.tsx +79 -79
- package/src/layout/Grid.tsx +124 -124
- package/src/layout/PageLayout.tsx +59 -59
- package/src/layout/Section.tsx +47 -47
- package/src/layout/Spacer.tsx +30 -30
- package/src/layout/Stack.tsx +88 -88
- package/src/layout/tokens.css +8 -8
- package/src/navigation/Breadcrumb.tsx +73 -73
- package/src/navigation/CommandPalette.tsx +142 -142
- package/src/navigation/DropdownMenu.tsx +244 -244
- package/src/navigation/Pagination.tsx +114 -114
- package/src/navigation/Stepper.tsx +93 -93
- package/src/navigation/Tabs.tsx +265 -265
- package/src/navigation/index.ts +6 -6
- package/src/navigation/tokens.css +4 -4
- package/src/overlays/AlertDialog.tsx +63 -63
- package/src/overlays/BottomSheet.tsx +143 -143
- package/src/overlays/ContextMenu.tsx +161 -161
- package/src/overlays/Drawer.tsx +160 -160
- package/src/overlays/FocusTrap.tsx +122 -122
- package/src/overlays/Modal.tsx +296 -296
- package/src/overlays/Popover.tsx +156 -156
- package/src/overlays/Portal.tsx +23 -23
- package/src/overlays/Toast.tsx +175 -175
- package/src/overlays/Tooltip.tsx +125 -125
- package/src/overlays/tokens.css +8 -8
- package/src/registry/components.ts +1368 -1368
- package/src/styles/components.css +39 -39
- package/src/styles/index.css +5 -5
- package/src/styles/reset.css +45 -45
- package/src/styles/tokens.css +11 -11
- package/src/typography/Blockquote.tsx +32 -32
- package/src/typography/Code.tsx +89 -89
- package/src/typography/Heading.tsx +56 -56
- package/src/typography/Kbd.tsx +25 -25
- package/src/typography/Label.tsx +47 -47
- package/src/typography/Link.tsx +56 -56
- package/src/typography/Text.tsx +62 -62
- package/src/typography/index.ts +7 -7
- package/src/typography/tokens.css +5 -5
- package/src/utils/cn.ts +6 -6
- package/src/utils/scroll-lock.ts +19 -19
- package/src/utils/useControllableState.ts +54 -54
- package/ui-contract.json +1 -1
- package/dist/chunk-2YJBW4E2.js.map +0 -1
- package/dist/chunk-4CRFNALF.js.map +0 -1
- package/dist/chunk-4MS6ZHFQ.js.map +0 -1
- package/dist/chunk-77KTYOMQ.js.map +0 -1
- package/dist/chunk-ADO3SVUA.js.map +0 -1
- package/dist/chunk-D7FB3TCA.js.map +0 -1
- package/dist/chunk-DSEL7ELZ.js.map +0 -1
- package/dist/chunk-EXFR2BFJ.js.map +0 -1
- package/dist/chunk-GXWB6BSY.js.map +0 -1
- package/dist/chunk-I2FQBMVW.js.map +0 -1
- package/dist/chunk-I5O2WCGF.js.map +0 -1
- package/dist/chunk-ME4XILG5.js.map +0 -1
- package/dist/chunk-PC5SZFZC.js.map +0 -1
- package/dist/chunk-S6XJMYME.js.map +0 -1
- package/dist/chunk-V52JOSDF.js.map +0 -1
- package/dist/chunk-W5WNWTQC.js.map +0 -1
- package/dist/chunk-YJ64RI7J.js.map +0 -1
- package/dist/chunk-YOCGBORV.js.map +0 -1
- package/dist/chunk-ZEDXARXW.js.map +0 -1
package/src/forms/FileUpload.tsx
CHANGED
|
@@ -1,266 +1,266 @@
|
|
|
1
|
-
import React, { forwardRef, useState, useRef, useEffect } from 'react';
|
|
2
|
-
import { UploadCloud, File, FileText, Image as ImageIcon, X, CheckCircle2, AlertCircle } from 'lucide-react';
|
|
3
|
-
import { cn } from '../utils/cn';
|
|
4
|
-
import { ProgressBar } from '../feedback/ProgressBar';
|
|
5
|
-
import { Button } from '../buttons/Button';
|
|
6
|
-
|
|
7
|
-
export interface UploadedFile {
|
|
8
|
-
id: string;
|
|
9
|
-
file: File;
|
|
10
|
-
name: string;
|
|
11
|
-
size: number;
|
|
12
|
-
type: string;
|
|
13
|
-
progress?: number;
|
|
14
|
-
status?: 'pending' | 'uploading' | 'completed' | 'error';
|
|
15
|
-
previewUrl?: string;
|
|
16
|
-
errorMessage?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface FileUploadProps {
|
|
20
|
-
accept?: string;
|
|
21
|
-
maxSizeMB?: number;
|
|
22
|
-
multiple?: boolean;
|
|
23
|
-
maxFiles?: number;
|
|
24
|
-
disabled?: boolean;
|
|
25
|
-
onFilesSelected?: (files: File[]) => void;
|
|
26
|
-
onFileRemove?: (fileId: string) => void;
|
|
27
|
-
className?: string;
|
|
28
|
-
label?: string;
|
|
29
|
-
description?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function formatFileSize(bytes: number): string {
|
|
33
|
-
if (bytes === 0) return '0 B';
|
|
34
|
-
const k = 1024;
|
|
35
|
-
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
36
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
37
|
-
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
|
|
41
|
-
(
|
|
42
|
-
{
|
|
43
|
-
accept,
|
|
44
|
-
maxSizeMB = 10,
|
|
45
|
-
multiple = true,
|
|
46
|
-
maxFiles = 5,
|
|
47
|
-
disabled = false,
|
|
48
|
-
onFilesSelected,
|
|
49
|
-
onFileRemove,
|
|
50
|
-
className,
|
|
51
|
-
label = 'Drag & drop files here, or browse',
|
|
52
|
-
description = 'Supports images, documents, and media up to 10MB',
|
|
53
|
-
},
|
|
54
|
-
ref
|
|
55
|
-
) => {
|
|
56
|
-
const [isDragOver, setIsDragOver] = useState(false);
|
|
57
|
-
const [fileList, setFileList] = useState<UploadedFile[]>([]);
|
|
58
|
-
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
59
|
-
const fileListRef = useRef<UploadedFile[]>(fileList);
|
|
60
|
-
fileListRef.current = fileList;
|
|
61
|
-
|
|
62
|
-
// Cleanup object URLs on unmount
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
return () => {
|
|
65
|
-
fileListRef.current.forEach((item) => {
|
|
66
|
-
if (item.previewUrl) {
|
|
67
|
-
URL.revokeObjectURL(item.previewUrl);
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
};
|
|
71
|
-
}, []);
|
|
72
|
-
|
|
73
|
-
const matchesAccept = (file: File, acceptStr?: string): boolean => {
|
|
74
|
-
if (!acceptStr) return true;
|
|
75
|
-
const patterns = acceptStr.split(',').map((p) => p.trim().toLowerCase());
|
|
76
|
-
const fileName = file.name.toLowerCase();
|
|
77
|
-
const fileType = file.type.toLowerCase();
|
|
78
|
-
|
|
79
|
-
return patterns.some((p) => {
|
|
80
|
-
if (p.startsWith('.')) {
|
|
81
|
-
return fileName.endsWith(p);
|
|
82
|
-
}
|
|
83
|
-
if (p.endsWith('/*')) {
|
|
84
|
-
const baseType = p.replace('/*', '');
|
|
85
|
-
return fileType.startsWith(baseType);
|
|
86
|
-
}
|
|
87
|
-
return fileType === p;
|
|
88
|
-
});
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const processFiles = (incoming: FileList | null) => {
|
|
92
|
-
if (!incoming) return;
|
|
93
|
-
|
|
94
|
-
const validFiles: File[] = [];
|
|
95
|
-
const newUploaded: UploadedFile[] = [];
|
|
96
|
-
|
|
97
|
-
Array.from(incoming).forEach((file) => {
|
|
98
|
-
if (fileList.length + newUploaded.length >= maxFiles) return;
|
|
99
|
-
|
|
100
|
-
const isAccepted = matchesAccept(file, accept);
|
|
101
|
-
const isOversized = file.size > maxSizeMB * 1024 * 1024;
|
|
102
|
-
const hasError = !isAccepted || isOversized;
|
|
103
|
-
|
|
104
|
-
let errorMsg: string | undefined;
|
|
105
|
-
if (!isAccepted) errorMsg = `File type not supported (${accept})`;
|
|
106
|
-
else if (isOversized) errorMsg = `File exceeds ${maxSizeMB}MB limit`;
|
|
107
|
-
|
|
108
|
-
const fileObj: UploadedFile = {
|
|
109
|
-
id: `${file.name}-${Date.now()}-${Math.random()}`,
|
|
110
|
-
file,
|
|
111
|
-
name: file.name,
|
|
112
|
-
size: file.size,
|
|
113
|
-
type: file.type,
|
|
114
|
-
progress: hasError ? 0 : 100,
|
|
115
|
-
status: hasError ? 'error' : 'completed',
|
|
116
|
-
errorMessage: errorMsg,
|
|
117
|
-
previewUrl: file.type.startsWith('image/') && !hasError ? URL.createObjectURL(file) : undefined,
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
newUploaded.push(fileObj);
|
|
121
|
-
if (!hasError) validFiles.push(file);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
setFileList((prev) => [...prev, ...newUploaded]);
|
|
125
|
-
if (validFiles.length > 0) {
|
|
126
|
-
onFilesSelected?.(validFiles);
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const handleDragOver = (e: React.DragEvent) => {
|
|
131
|
-
e.preventDefault();
|
|
132
|
-
if (!disabled) setIsDragOver(true);
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
const handleDragLeave = (e: React.DragEvent) => {
|
|
136
|
-
e.preventDefault();
|
|
137
|
-
setIsDragOver(false);
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
const handleDrop = (e: React.DragEvent) => {
|
|
141
|
-
e.preventDefault();
|
|
142
|
-
setIsDragOver(false);
|
|
143
|
-
if (disabled) return;
|
|
144
|
-
processFiles(e.dataTransfer.files);
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
148
|
-
processFiles(e.target.files);
|
|
149
|
-
if (inputRef.current) inputRef.current.value = '';
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const removeFile = (id: string) => {
|
|
153
|
-
setFileList((prev) => {
|
|
154
|
-
const item = prev.find((f) => f.id === id);
|
|
155
|
-
if (item?.previewUrl) {
|
|
156
|
-
URL.revokeObjectURL(item.previewUrl);
|
|
157
|
-
}
|
|
158
|
-
return prev.filter((f) => f.id !== id);
|
|
159
|
-
});
|
|
160
|
-
onFileRemove?.(id);
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
return (
|
|
164
|
-
<div ref={ref} className={cn('w-full space-y-4', className)}>
|
|
165
|
-
{/* Dropzone Box */}
|
|
166
|
-
<div
|
|
167
|
-
onDragOver={handleDragOver}
|
|
168
|
-
onDragLeave={handleDragLeave}
|
|
169
|
-
onDrop={handleDrop}
|
|
170
|
-
onClick={() => !disabled && inputRef.current?.click()}
|
|
171
|
-
className={cn(
|
|
172
|
-
'relative rounded-2xl p-6 sm:p-8 text-center border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center gap-3 select-none',
|
|
173
|
-
disabled
|
|
174
|
-
? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)] bg-[var(--ui-bg-secondary)]/30'
|
|
175
|
-
: isDragOver
|
|
176
|
-
? 'border-[var(--ui-primary)] bg-[var(--ui-primary)]/10 shadow-lg shadow-blue-500/20 scale-[0.99]'
|
|
177
|
-
: 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)]/60 hover:bg-[var(--ui-bg-secondary)]'
|
|
178
|
-
)}
|
|
179
|
-
>
|
|
180
|
-
<input
|
|
181
|
-
ref={inputRef}
|
|
182
|
-
type="file"
|
|
183
|
-
accept={accept}
|
|
184
|
-
multiple={multiple}
|
|
185
|
-
disabled={disabled}
|
|
186
|
-
onChange={handleFileChange}
|
|
187
|
-
className="hidden"
|
|
188
|
-
/>
|
|
189
|
-
|
|
190
|
-
<div className="w-12 h-12 rounded-2xl bg-[var(--ui-primary)]/15 text-[var(--ui-primary)] flex items-center justify-center shadow-inner">
|
|
191
|
-
<UploadCloud className="w-6 h-6" />
|
|
192
|
-
</div>
|
|
193
|
-
|
|
194
|
-
<div className="space-y-1">
|
|
195
|
-
<h4 className="text-sm font-bold text-[var(--ui-text-primary)]">{label}</h4>
|
|
196
|
-
<p className="text-xs text-[var(--ui-text-muted)]">{description}</p>
|
|
197
|
-
</div>
|
|
198
|
-
|
|
199
|
-
<Button size="xs" variant="outline" className="rounded-full px-4 mt-1 pointer-events-none">
|
|
200
|
-
Browse Files
|
|
201
|
-
</Button>
|
|
202
|
-
</div>
|
|
203
|
-
|
|
204
|
-
{/* Uploaded Files List */}
|
|
205
|
-
{fileList.length > 0 && (
|
|
206
|
-
<div className="space-y-2">
|
|
207
|
-
<span className="text-xs font-mono font-bold text-[var(--ui-text-muted)] uppercase tracking-wider">
|
|
208
|
-
Files ({fileList.length}/{maxFiles})
|
|
209
|
-
</span>
|
|
210
|
-
|
|
211
|
-
<div className="space-y-2">
|
|
212
|
-
{fileList.map((item) => (
|
|
213
|
-
<div
|
|
214
|
-
key={item.id}
|
|
215
|
-
className="p-3 rounded-2xl bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] flex items-center justify-between gap-3 shadow-xs animate-in fade-in duration-200"
|
|
216
|
-
>
|
|
217
|
-
<div className="flex items-center gap-3 truncate">
|
|
218
|
-
{item.previewUrl ? (
|
|
219
|
-
<img
|
|
220
|
-
src={item.previewUrl}
|
|
221
|
-
alt={item.name}
|
|
222
|
-
className="w-10 h-10 rounded-xl object-cover border border-[var(--ui-border-subtle)] shrink-0"
|
|
223
|
-
/>
|
|
224
|
-
) : (
|
|
225
|
-
<div className="w-10 h-10 rounded-xl bg-[var(--ui-bg-tertiary)] flex items-center justify-center text-[var(--ui-text-muted)] shrink-0">
|
|
226
|
-
{item.type.includes('pdf') ? <FileText className="w-5 h-5" /> : <File className="w-5 h-5" />}
|
|
227
|
-
</div>
|
|
228
|
-
)}
|
|
229
|
-
|
|
230
|
-
<div className="truncate space-y-0.5">
|
|
231
|
-
<span className="text-xs font-semibold text-[var(--ui-text-primary)] block truncate">
|
|
232
|
-
{item.name}
|
|
233
|
-
</span>
|
|
234
|
-
<div className="flex items-center gap-2 text-[11px] text-[var(--ui-text-muted)] font-mono">
|
|
235
|
-
<span>{formatFileSize(item.size)}</span>
|
|
236
|
-
{item.status === 'completed' && (
|
|
237
|
-
<span className="text-emerald-500 font-semibold flex items-center gap-0.5">
|
|
238
|
-
<CheckCircle2 className="w-3 h-3" /> Ready
|
|
239
|
-
</span>
|
|
240
|
-
)}
|
|
241
|
-
{item.status === 'error' && (
|
|
242
|
-
<span className="text-rose-500 font-semibold flex items-center gap-0.5">
|
|
243
|
-
<AlertCircle className="w-3 h-3" /> {item.errorMessage}
|
|
244
|
-
</span>
|
|
245
|
-
)}
|
|
246
|
-
</div>
|
|
247
|
-
</div>
|
|
248
|
-
</div>
|
|
249
|
-
|
|
250
|
-
<button
|
|
251
|
-
type="button"
|
|
252
|
-
onClick={() => removeFile(item.id)}
|
|
253
|
-
className="p-1.5 rounded-full hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-rose-500 transition-colors"
|
|
254
|
-
>
|
|
255
|
-
<X className="w-4 h-4" />
|
|
256
|
-
</button>
|
|
257
|
-
</div>
|
|
258
|
-
))}
|
|
259
|
-
</div>
|
|
260
|
-
</div>
|
|
261
|
-
)}
|
|
262
|
-
</div>
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
);
|
|
266
|
-
FileUpload.displayName = 'FileUpload';
|
|
1
|
+
import React, { forwardRef, useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { UploadCloud, File, FileText, Image as ImageIcon, X, CheckCircle2, AlertCircle } from 'lucide-react';
|
|
3
|
+
import { cn } from '../utils/cn';
|
|
4
|
+
import { ProgressBar } from '../feedback/ProgressBar';
|
|
5
|
+
import { Button } from '../buttons/Button';
|
|
6
|
+
|
|
7
|
+
export interface UploadedFile {
|
|
8
|
+
id: string;
|
|
9
|
+
file: File;
|
|
10
|
+
name: string;
|
|
11
|
+
size: number;
|
|
12
|
+
type: string;
|
|
13
|
+
progress?: number;
|
|
14
|
+
status?: 'pending' | 'uploading' | 'completed' | 'error';
|
|
15
|
+
previewUrl?: string;
|
|
16
|
+
errorMessage?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FileUploadProps {
|
|
20
|
+
accept?: string;
|
|
21
|
+
maxSizeMB?: number;
|
|
22
|
+
multiple?: boolean;
|
|
23
|
+
maxFiles?: number;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
onFilesSelected?: (files: File[]) => void;
|
|
26
|
+
onFileRemove?: (fileId: string) => void;
|
|
27
|
+
className?: string;
|
|
28
|
+
label?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatFileSize(bytes: number): string {
|
|
33
|
+
if (bytes === 0) return '0 B';
|
|
34
|
+
const k = 1024;
|
|
35
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
36
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
37
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
|
|
41
|
+
(
|
|
42
|
+
{
|
|
43
|
+
accept,
|
|
44
|
+
maxSizeMB = 10,
|
|
45
|
+
multiple = true,
|
|
46
|
+
maxFiles = 5,
|
|
47
|
+
disabled = false,
|
|
48
|
+
onFilesSelected,
|
|
49
|
+
onFileRemove,
|
|
50
|
+
className,
|
|
51
|
+
label = 'Drag & drop files here, or browse',
|
|
52
|
+
description = 'Supports images, documents, and media up to 10MB',
|
|
53
|
+
},
|
|
54
|
+
ref
|
|
55
|
+
) => {
|
|
56
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
57
|
+
const [fileList, setFileList] = useState<UploadedFile[]>([]);
|
|
58
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
59
|
+
const fileListRef = useRef<UploadedFile[]>(fileList);
|
|
60
|
+
fileListRef.current = fileList;
|
|
61
|
+
|
|
62
|
+
// Cleanup object URLs on unmount
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
return () => {
|
|
65
|
+
fileListRef.current.forEach((item) => {
|
|
66
|
+
if (item.previewUrl) {
|
|
67
|
+
URL.revokeObjectURL(item.previewUrl);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const matchesAccept = (file: File, acceptStr?: string): boolean => {
|
|
74
|
+
if (!acceptStr) return true;
|
|
75
|
+
const patterns = acceptStr.split(',').map((p) => p.trim().toLowerCase());
|
|
76
|
+
const fileName = file.name.toLowerCase();
|
|
77
|
+
const fileType = file.type.toLowerCase();
|
|
78
|
+
|
|
79
|
+
return patterns.some((p) => {
|
|
80
|
+
if (p.startsWith('.')) {
|
|
81
|
+
return fileName.endsWith(p);
|
|
82
|
+
}
|
|
83
|
+
if (p.endsWith('/*')) {
|
|
84
|
+
const baseType = p.replace('/*', '');
|
|
85
|
+
return fileType.startsWith(baseType);
|
|
86
|
+
}
|
|
87
|
+
return fileType === p;
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const processFiles = (incoming: FileList | null) => {
|
|
92
|
+
if (!incoming) return;
|
|
93
|
+
|
|
94
|
+
const validFiles: File[] = [];
|
|
95
|
+
const newUploaded: UploadedFile[] = [];
|
|
96
|
+
|
|
97
|
+
Array.from(incoming).forEach((file) => {
|
|
98
|
+
if (fileList.length + newUploaded.length >= maxFiles) return;
|
|
99
|
+
|
|
100
|
+
const isAccepted = matchesAccept(file, accept);
|
|
101
|
+
const isOversized = file.size > maxSizeMB * 1024 * 1024;
|
|
102
|
+
const hasError = !isAccepted || isOversized;
|
|
103
|
+
|
|
104
|
+
let errorMsg: string | undefined;
|
|
105
|
+
if (!isAccepted) errorMsg = `File type not supported (${accept})`;
|
|
106
|
+
else if (isOversized) errorMsg = `File exceeds ${maxSizeMB}MB limit`;
|
|
107
|
+
|
|
108
|
+
const fileObj: UploadedFile = {
|
|
109
|
+
id: `${file.name}-${Date.now()}-${Math.random()}`,
|
|
110
|
+
file,
|
|
111
|
+
name: file.name,
|
|
112
|
+
size: file.size,
|
|
113
|
+
type: file.type,
|
|
114
|
+
progress: hasError ? 0 : 100,
|
|
115
|
+
status: hasError ? 'error' : 'completed',
|
|
116
|
+
errorMessage: errorMsg,
|
|
117
|
+
previewUrl: file.type.startsWith('image/') && !hasError ? URL.createObjectURL(file) : undefined,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
newUploaded.push(fileObj);
|
|
121
|
+
if (!hasError) validFiles.push(file);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
setFileList((prev) => [...prev, ...newUploaded]);
|
|
125
|
+
if (validFiles.length > 0) {
|
|
126
|
+
onFilesSelected?.(validFiles);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
if (!disabled) setIsDragOver(true);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const handleDragLeave = (e: React.DragEvent) => {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
setIsDragOver(false);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
setIsDragOver(false);
|
|
143
|
+
if (disabled) return;
|
|
144
|
+
processFiles(e.dataTransfer.files);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
148
|
+
processFiles(e.target.files);
|
|
149
|
+
if (inputRef.current) inputRef.current.value = '';
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const removeFile = (id: string) => {
|
|
153
|
+
setFileList((prev) => {
|
|
154
|
+
const item = prev.find((f) => f.id === id);
|
|
155
|
+
if (item?.previewUrl) {
|
|
156
|
+
URL.revokeObjectURL(item.previewUrl);
|
|
157
|
+
}
|
|
158
|
+
return prev.filter((f) => f.id !== id);
|
|
159
|
+
});
|
|
160
|
+
onFileRemove?.(id);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div ref={ref} className={cn('w-full space-y-4', className)}>
|
|
165
|
+
{/* Dropzone Box */}
|
|
166
|
+
<div
|
|
167
|
+
onDragOver={handleDragOver}
|
|
168
|
+
onDragLeave={handleDragLeave}
|
|
169
|
+
onDrop={handleDrop}
|
|
170
|
+
onClick={() => !disabled && inputRef.current?.click()}
|
|
171
|
+
className={cn(
|
|
172
|
+
'relative rounded-2xl p-6 sm:p-8 text-center border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center gap-3 select-none',
|
|
173
|
+
disabled
|
|
174
|
+
? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)] bg-[var(--ui-bg-secondary)]/30'
|
|
175
|
+
: isDragOver
|
|
176
|
+
? 'border-[var(--ui-primary)] bg-[var(--ui-primary)]/10 shadow-lg shadow-blue-500/20 scale-[0.99]'
|
|
177
|
+
: 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)]/60 hover:bg-[var(--ui-bg-secondary)]'
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
180
|
+
<input
|
|
181
|
+
ref={inputRef}
|
|
182
|
+
type="file"
|
|
183
|
+
accept={accept}
|
|
184
|
+
multiple={multiple}
|
|
185
|
+
disabled={disabled}
|
|
186
|
+
onChange={handleFileChange}
|
|
187
|
+
className="hidden"
|
|
188
|
+
/>
|
|
189
|
+
|
|
190
|
+
<div className="w-12 h-12 rounded-2xl bg-[var(--ui-primary)]/15 text-[var(--ui-primary)] flex items-center justify-center shadow-inner">
|
|
191
|
+
<UploadCloud className="w-6 h-6" />
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div className="space-y-1">
|
|
195
|
+
<h4 className="text-sm font-bold text-[var(--ui-text-primary)]">{label}</h4>
|
|
196
|
+
<p className="text-xs text-[var(--ui-text-muted)]">{description}</p>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<Button size="xs" variant="outline" className="rounded-full px-4 mt-1 pointer-events-none">
|
|
200
|
+
Browse Files
|
|
201
|
+
</Button>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{/* Uploaded Files List */}
|
|
205
|
+
{fileList.length > 0 && (
|
|
206
|
+
<div className="space-y-2">
|
|
207
|
+
<span className="text-xs font-mono font-bold text-[var(--ui-text-muted)] uppercase tracking-wider">
|
|
208
|
+
Files ({fileList.length}/{maxFiles})
|
|
209
|
+
</span>
|
|
210
|
+
|
|
211
|
+
<div className="space-y-2">
|
|
212
|
+
{fileList.map((item) => (
|
|
213
|
+
<div
|
|
214
|
+
key={item.id}
|
|
215
|
+
className="p-3 rounded-2xl bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] flex items-center justify-between gap-3 shadow-xs animate-in fade-in duration-200"
|
|
216
|
+
>
|
|
217
|
+
<div className="flex items-center gap-3 truncate">
|
|
218
|
+
{item.previewUrl ? (
|
|
219
|
+
<img
|
|
220
|
+
src={item.previewUrl}
|
|
221
|
+
alt={item.name}
|
|
222
|
+
className="w-10 h-10 rounded-xl object-cover border border-[var(--ui-border-subtle)] shrink-0"
|
|
223
|
+
/>
|
|
224
|
+
) : (
|
|
225
|
+
<div className="w-10 h-10 rounded-xl bg-[var(--ui-bg-tertiary)] flex items-center justify-center text-[var(--ui-text-muted)] shrink-0">
|
|
226
|
+
{item.type.includes('pdf') ? <FileText className="w-5 h-5" /> : <File className="w-5 h-5" />}
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
<div className="truncate space-y-0.5">
|
|
231
|
+
<span className="text-xs font-semibold text-[var(--ui-text-primary)] block truncate">
|
|
232
|
+
{item.name}
|
|
233
|
+
</span>
|
|
234
|
+
<div className="flex items-center gap-2 text-[11px] text-[var(--ui-text-muted)] font-mono">
|
|
235
|
+
<span>{formatFileSize(item.size)}</span>
|
|
236
|
+
{item.status === 'completed' && (
|
|
237
|
+
<span className="text-emerald-500 font-semibold flex items-center gap-0.5">
|
|
238
|
+
<CheckCircle2 className="w-3 h-3" /> Ready
|
|
239
|
+
</span>
|
|
240
|
+
)}
|
|
241
|
+
{item.status === 'error' && (
|
|
242
|
+
<span className="text-rose-500 font-semibold flex items-center gap-0.5">
|
|
243
|
+
<AlertCircle className="w-3 h-3" /> {item.errorMessage}
|
|
244
|
+
</span>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
onClick={() => removeFile(item.id)}
|
|
253
|
+
className="p-1.5 rounded-full hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-rose-500 transition-colors"
|
|
254
|
+
>
|
|
255
|
+
<X className="w-4 h-4" />
|
|
256
|
+
</button>
|
|
257
|
+
</div>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
FileUpload.displayName = 'FileUpload';
|