alexui 1.0.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 (124) hide show
  1. package/README.md +57 -0
  2. package/components/ActionTable.tsx +307 -0
  3. package/components/AlertBanner.tsx +124 -0
  4. package/components/AnimatedAccordion.tsx +95 -0
  5. package/components/Autocomplete.tsx +144 -0
  6. package/components/Avatar.tsx +123 -0
  7. package/components/Badge.tsx +80 -0
  8. package/components/Breadcrumb.tsx +74 -0
  9. package/components/Calendar.tsx +340 -0
  10. package/components/Card3D.tsx +117 -0
  11. package/components/Carousel3D.tsx +193 -0
  12. package/components/CascadeSelect.tsx +232 -0
  13. package/components/ChartShowcase.tsx +700 -0
  14. package/components/Checkbox.tsx +212 -0
  15. package/components/ChipsInput.tsx +152 -0
  16. package/components/CircularKnob.tsx +240 -0
  17. package/components/CodeVisualizer.tsx +67 -0
  18. package/components/Collapsible.tsx +72 -0
  19. package/components/ColorThemeManager.tsx +458 -0
  20. package/components/CommandMenu.tsx +191 -0
  21. package/components/ConfirmDialog.tsx +152 -0
  22. package/components/ContextMenu.tsx +192 -0
  23. package/components/DashboardLayout.tsx +115 -0
  24. package/components/DatePicker.tsx +108 -0
  25. package/components/Divider.tsx +67 -0
  26. package/components/Dock.tsx +93 -0
  27. package/components/DragDropLists.tsx +160 -0
  28. package/components/Drawer.tsx +161 -0
  29. package/components/DropdownPlus.tsx +304 -0
  30. package/components/EmptyState.tsx +49 -0
  31. package/components/ErrorPage.tsx +62 -0
  32. package/components/FileDropzone.tsx +206 -0
  33. package/components/ForgotPassword.tsx +137 -0
  34. package/components/FormField.tsx +81 -0
  35. package/components/GlassButton.tsx +56 -0
  36. package/components/GlassCard.tsx +82 -0
  37. package/components/GlassInput.tsx +96 -0
  38. package/components/GlassmorphicModal.tsx +108 -0
  39. package/components/GlowInput.tsx +111 -0
  40. package/components/GlowSelect.tsx +203 -0
  41. package/components/GlowTextArea.tsx +105 -0
  42. package/components/HorizontalTimeline.tsx +121 -0
  43. package/components/HoverCard.tsx +105 -0
  44. package/components/ImageLightbox.tsx +259 -0
  45. package/components/InputGroup.tsx +118 -0
  46. package/components/InputOTP.tsx +147 -0
  47. package/components/InteractiveNavbar.tsx +266 -0
  48. package/components/InteractiveSidebar.tsx +211 -0
  49. package/components/Kbd.tsx +51 -0
  50. package/components/LiteYouTube.tsx +118 -0
  51. package/components/LoaderCollection.tsx +368 -0
  52. package/components/LoginForm.tsx +192 -0
  53. package/components/MagneticButton.tsx +101 -0
  54. package/components/MaskedInput.tsx +79 -0
  55. package/components/MentionInput.tsx +413 -0
  56. package/components/MorphingSwitch.tsx +86 -0
  57. package/components/MultiSelect.tsx +158 -0
  58. package/components/NumberInput.tsx +203 -0
  59. package/components/Panel.tsx +104 -0
  60. package/components/PasswordInput.tsx +203 -0
  61. package/components/Popover.tsx +91 -0
  62. package/components/PricingTable.tsx +113 -0
  63. package/components/ProgressBar.tsx +152 -0
  64. package/components/RadioButton.tsx +211 -0
  65. package/components/Rating.tsx +82 -0
  66. package/components/ResizablePanel.tsx +114 -0
  67. package/components/ScrollPanel.tsx +103 -0
  68. package/components/SettingsPage.tsx +154 -0
  69. package/components/SignupForm.tsx +182 -0
  70. package/components/Skeleton.tsx +41 -0
  71. package/components/Slider.tsx +95 -0
  72. package/components/SlidingTabs.tsx +54 -0
  73. package/components/SortableList.tsx +91 -0
  74. package/components/SpeedDial.tsx +134 -0
  75. package/components/Spinner.tsx +40 -0
  76. package/components/Stepper.tsx +124 -0
  77. package/components/TabMenu.tsx +72 -0
  78. package/components/TableControls.tsx +77 -0
  79. package/components/TablePagination.tsx +88 -0
  80. package/components/TextEditor.tsx +329 -0
  81. package/components/TextReveal.tsx +99 -0
  82. package/components/ThemeSwitcher.tsx +133 -0
  83. package/components/TimelineGSAP.tsx +164 -0
  84. package/components/ToastSystem.tsx +110 -0
  85. package/components/ToggleButton.tsx +79 -0
  86. package/components/Tooltip.tsx +121 -0
  87. package/components/Tree.tsx +138 -0
  88. package/dist/commands/add.d.ts +7 -0
  89. package/dist/commands/add.js +110 -0
  90. package/dist/commands/init.d.ts +5 -0
  91. package/dist/commands/init.js +76 -0
  92. package/dist/commands/list.d.ts +1 -0
  93. package/dist/commands/list.js +32 -0
  94. package/dist/index.d.ts +2 -0
  95. package/dist/index.js +60 -0
  96. package/dist/registry.d.ts +6 -0
  97. package/dist/registry.js +38 -0
  98. package/dist/tui/browse.d.ts +3 -0
  99. package/dist/tui/browse.js +139 -0
  100. package/dist/tui/format.d.ts +11 -0
  101. package/dist/tui/format.js +52 -0
  102. package/dist/tui/main.d.ts +1 -0
  103. package/dist/tui/main.js +86 -0
  104. package/dist/tui/panels.d.ts +9 -0
  105. package/dist/tui/panels.js +50 -0
  106. package/dist/tui/theme.d.ts +28 -0
  107. package/dist/tui/theme.js +76 -0
  108. package/dist/types.d.ts +28 -0
  109. package/dist/types.js +1 -0
  110. package/dist/utils/config.d.ts +6 -0
  111. package/dist/utils/config.js +24 -0
  112. package/dist/utils/copy.d.ts +9 -0
  113. package/dist/utils/copy.js +43 -0
  114. package/dist/utils/cwd.d.ts +6 -0
  115. package/dist/utils/cwd.js +30 -0
  116. package/dist/utils/deps.d.ts +1 -0
  117. package/dist/utils/deps.js +19 -0
  118. package/dist/utils/project.d.ts +5 -0
  119. package/dist/utils/project.js +30 -0
  120. package/dist/utils/theme.d.ts +1 -0
  121. package/dist/utils/theme.js +24 -0
  122. package/package.json +52 -0
  123. package/registry.json +1133 -0
  124. package/templates/theme.css +81 -0
@@ -0,0 +1,304 @@
1
+ import React, { useState, useRef, useEffect, useMemo } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { ChevronDown, Search, X, Check } from 'lucide-react';
4
+
5
+ export interface DropdownPlusOption {
6
+ value: string;
7
+ label: string;
8
+ icon?: React.ReactNode;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ export interface DropdownPlusGroup {
13
+ label: string;
14
+ options: DropdownPlusOption[];
15
+ }
16
+
17
+ export interface DropdownPlusProps {
18
+ options: (DropdownPlusOption | DropdownPlusGroup)[];
19
+ value: string | string[]; // string for single, string[] for multi
20
+ onChange: (value: any) => void;
21
+ label?: string;
22
+ isMulti?: boolean;
23
+ showSearch?: boolean;
24
+ disabled?: boolean;
25
+ placeholder?: string;
26
+ className?: string;
27
+ }
28
+
29
+ export const DropdownPlus: React.FC<DropdownPlusProps> = ({
30
+ options,
31
+ value,
32
+ onChange,
33
+ label,
34
+ isMulti = false,
35
+ showSearch = true,
36
+ disabled = false,
37
+ placeholder = 'Selecciona una opción...',
38
+ className = ''
39
+ }) => {
40
+ const [isOpen, setIsOpen] = useState(false);
41
+ const [searchTerm, setSearchTerm] = useState('');
42
+ const containerRef = useRef<HTMLDivElement>(null);
43
+
44
+ // Normalize selected values to array
45
+ const selectedValues = useMemo((): string[] => {
46
+ if (isMulti) {
47
+ return Array.isArray(value) ? value : [];
48
+ }
49
+ return typeof value === 'string' && value ? [value] : [];
50
+ }, [value, isMulti]);
51
+
52
+ // Close dropdown on click outside
53
+ useEffect(() => {
54
+ const handleClickOutside = (event: MouseEvent) => {
55
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
56
+ setIsOpen(false);
57
+ }
58
+ };
59
+ document.addEventListener('mousedown', handleClickOutside);
60
+ return () => document.removeEventListener('mousedown', handleClickOutside);
61
+ }, []);
62
+
63
+ // Reset search when opening/closing
64
+ useEffect(() => {
65
+ if (!isOpen) {
66
+ setSearchTerm('');
67
+ }
68
+ }, [isOpen]);
69
+
70
+ // Extract flat list of all options to map values to labels
71
+ const allFlatOptions = useMemo((): DropdownPlusOption[] => {
72
+ const flat: DropdownPlusOption[] = [];
73
+ options.forEach((opt) => {
74
+ if ('options' in opt) {
75
+ flat.push(...opt.options);
76
+ } else {
77
+ flat.push(opt);
78
+ }
79
+ });
80
+ return flat;
81
+ }, [options]);
82
+
83
+ const selectedOptions = useMemo(() => {
84
+ return allFlatOptions.filter((opt) => selectedValues.includes(opt.value));
85
+ }, [allFlatOptions, selectedValues]);
86
+
87
+ // Filter options based on search query
88
+ const filteredOptions = useMemo(() => {
89
+ if (!searchTerm) return options;
90
+
91
+ const query = searchTerm.toLowerCase();
92
+ return options.map((opt) => {
93
+ if ('options' in opt) {
94
+ const matchingSubOptions = opt.options.filter((sub) =>
95
+ sub.label.toLowerCase().includes(query)
96
+ );
97
+ if (matchingSubOptions.length > 0) {
98
+ return { ...opt, options: matchingSubOptions };
99
+ }
100
+ return null;
101
+ } else {
102
+ return opt.label.toLowerCase().includes(query) ? opt : null;
103
+ }
104
+ }).filter(Boolean) as (DropdownPlusOption | DropdownPlusGroup)[];
105
+ }, [options, searchTerm]);
106
+
107
+ const handleOptionClick = (optionVal: string) => {
108
+ if (isMulti) {
109
+ if (selectedValues.includes(optionVal)) {
110
+ onChange(selectedValues.filter((v) => v !== optionVal));
111
+ } else {
112
+ onChange([...selectedValues, optionVal]);
113
+ }
114
+ } else {
115
+ onChange(optionVal);
116
+ setIsOpen(false);
117
+ }
118
+ };
119
+
120
+ const removeValue = (e: React.MouseEvent, optionVal: string) => {
121
+ e.stopPropagation();
122
+ if (disabled) return;
123
+ onChange(selectedValues.filter((v) => v !== optionVal));
124
+ };
125
+
126
+ return (
127
+ <div ref={containerRef} className={`relative w-full flex flex-col gap-1.5 ${className}`}>
128
+ {label && (
129
+ <span className="text-xs font-bold text-text-muted uppercase tracking-wider px-1">
130
+ {label}
131
+ </span>
132
+ )}
133
+
134
+ {/* Dropdown Box wrapper */}
135
+ <div className="relative rounded-xl overflow-visible transition-all duration-300">
136
+
137
+ {/* Glow border ring */}
138
+ <motion.div
139
+ animate={{
140
+ opacity: isOpen ? 1 : 0,
141
+ scale: isOpen ? 1 : 0.98,
142
+ }}
143
+ transition={{ duration: 0.25 }}
144
+ className="absolute -inset-[1px] bg-gradient-to-r from-accent to-pink-500 rounded-xl pointer-events-none z-0 blur-[1px]"
145
+ />
146
+
147
+ {/* Selected Trigger Button */}
148
+ <div
149
+ onClick={() => !disabled && setIsOpen(!isOpen)}
150
+ className={`w-full relative bg-bg-card/60 border rounded-xl z-10 p-3 min-h-[48px] flex items-center justify-between gap-3 transition-colors duration-300 select-none ${
151
+ isOpen ? 'border-transparent' : 'border-border-app'
152
+ } ${disabled ? 'opacity-40 cursor-not-allowed bg-bg-app/10' : 'cursor-pointer'}`}
153
+ style={{
154
+ boxShadow: isOpen && !disabled
155
+ ? '0 0 10px var(--color-accent-glow)'
156
+ : 'none'
157
+ }}
158
+ >
159
+ {/* Selected values display */}
160
+ <div className="flex-1 flex flex-wrap gap-1.5 items-center">
161
+ {selectedOptions.length === 0 ? (
162
+ <span className="text-sm text-text-muted/60 pl-1">{placeholder}</span>
163
+ ) : isMulti ? (
164
+ selectedOptions.map((opt) => (
165
+ <div
166
+ key={opt.value}
167
+ className="flex items-center gap-1.5 bg-accent/10 border border-accent/20 px-2 py-0.5 rounded-lg text-xs font-bold text-accent"
168
+ >
169
+ {opt.icon && <span className="flex-shrink-0">{opt.icon}</span>}
170
+ <span>{opt.label}</span>
171
+ <button
172
+ type="button"
173
+ onClick={(e) => removeValue(e, opt.value)}
174
+ disabled={disabled}
175
+ className="p-0.5 rounded-md hover:bg-accent/20 text-accent transition-colors cursor-pointer"
176
+ >
177
+ <X className="w-2.5 h-2.5" />
178
+ </button>
179
+ </div>
180
+ ))
181
+ ) : (
182
+ <div className="flex items-center gap-2 text-sm text-text-main pl-1 font-medium">
183
+ {selectedOptions[0].icon && <span className="flex-shrink-0">{selectedOptions[0].icon}</span>}
184
+ <span>{selectedOptions[0].label}</span>
185
+ </div>
186
+ )}
187
+ </div>
188
+
189
+ {/* Chevron down arrow */}
190
+ <motion.div
191
+ animate={{ rotate: isOpen ? 180 : 0 }}
192
+ className="text-text-muted pr-1"
193
+ >
194
+ <ChevronDown className="w-4 h-4" />
195
+ </motion.div>
196
+ </div>
197
+
198
+ {/* Dropdown Options Box list */}
199
+ <AnimatePresence>
200
+ {isOpen && (
201
+ <motion.div
202
+ initial={{ opacity: 0, y: 10, scale: 0.95 }}
203
+ animate={{ opacity: 1, y: 4, scale: 1 }}
204
+ exit={{ opacity: 0, y: 10, scale: 0.95 }}
205
+ transition={{ type: 'spring', stiffness: 350, damping: 25 }}
206
+ className="absolute left-0 w-full glass bg-bg-card rounded-xl shadow-2xl p-2 z-50 border border-border-app/50 flex flex-col gap-2 max-h-72 overflow-visible"
207
+ >
208
+ {/* Optional Search field */}
209
+ {showSearch && (
210
+ <div className="relative flex items-center bg-bg-app/40 border border-border-app/40 rounded-lg overflow-hidden px-2.5 py-1.5">
211
+ <Search className="w-3.5 h-3.5 text-text-muted/60 mr-2" />
212
+ <input
213
+ type="text"
214
+ value={searchTerm}
215
+ onChange={(e) => setSearchTerm(e.target.value)}
216
+ placeholder="Buscar..."
217
+ className="w-full bg-transparent text-xs text-text-main focus:outline-hidden"
218
+ />
219
+ {searchTerm && (
220
+ <button
221
+ onClick={() => setSearchTerm('')}
222
+ className="text-text-muted hover:text-text-main cursor-pointer"
223
+ >
224
+ <X className="w-3.5 h-3.5" />
225
+ </button>
226
+ )}
227
+ </div>
228
+ )}
229
+
230
+ {/* Scrollable list options container */}
231
+ <div className="overflow-y-auto flex-1 max-h-56 flex flex-col gap-0.5">
232
+ {filteredOptions.length === 0 ? (
233
+ <div className="px-3 py-4 text-center text-xs text-text-muted italic">
234
+ Sin resultados
235
+ </div>
236
+ ) : (
237
+ filteredOptions.map((opt, groupIdx) => {
238
+ if ('options' in opt) {
239
+ // Rendering Category/Group Headers
240
+ return (
241
+ <div key={groupIdx} className="flex flex-col gap-0.5 mt-1.5 first:mt-0">
242
+ {/* Group Label (Blocked, Not Selectable) */}
243
+ <span className="px-3 py-1.5 text-[10px] font-extrabold text-text-muted uppercase tracking-widest bg-bg-app/20 rounded-md mb-0.5 select-none">
244
+ {opt.label}
245
+ </span>
246
+ {/* Render Group Sub-items */}
247
+ {opt.options.map((subOpt) => {
248
+ const isSelected = selectedValues.includes(subOpt.value);
249
+ return (
250
+ <button
251
+ key={subOpt.value}
252
+ type="button"
253
+ onClick={() => !subOpt.disabled && handleOptionClick(subOpt.value)}
254
+ disabled={subOpt.disabled}
255
+ className={`w-full px-5 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition-colors duration-200 ${
256
+ isSelected
257
+ ? 'bg-accent/10 text-accent'
258
+ : 'text-text-main hover:bg-bg-app'
259
+ } ${subOpt.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
260
+ >
261
+ <div className="flex items-center gap-2">
262
+ {subOpt.icon && <span className="flex-shrink-0">{subOpt.icon}</span>}
263
+ <span>{subOpt.label}</span>
264
+ </div>
265
+ {isSelected && <Check className="w-3.5 h-3.5 text-accent stroke-[3]" />}
266
+ </button>
267
+ );
268
+ })}
269
+ </div>
270
+ );
271
+ } else {
272
+ // Rendering Flat list options
273
+ const isSelected = selectedValues.includes(opt.value);
274
+ return (
275
+ <button
276
+ key={opt.value}
277
+ type="button"
278
+ onClick={() => !opt.disabled && handleOptionClick(opt.value)}
279
+ disabled={opt.disabled}
280
+ className={`w-full px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition-colors duration-200 ${
281
+ isSelected
282
+ ? 'bg-accent/10 text-accent'
283
+ : 'text-text-main hover:bg-bg-app'
284
+ } ${opt.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
285
+ >
286
+ <div className="flex items-center gap-2">
287
+ {opt.icon && <span className="flex-shrink-0">{opt.icon}</span>}
288
+ <span>{opt.label}</span>
289
+ </div>
290
+ {isSelected && <Check className="w-3.5 h-3.5 text-accent stroke-[3]" />}
291
+ </button>
292
+ );
293
+ }
294
+ })
295
+ )}
296
+ </div>
297
+ </motion.div>
298
+ )}
299
+ </AnimatePresence>
300
+
301
+ </div>
302
+ </div>
303
+ );
304
+ };
@@ -0,0 +1,49 @@
1
+ import React from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { Inbox } from 'lucide-react';
4
+
5
+ export interface EmptyStateProps {
6
+ icon?: React.ReactNode;
7
+ title: string;
8
+ description?: string;
9
+ action?: React.ReactNode;
10
+ compact?: boolean;
11
+ className?: string;
12
+ }
13
+
14
+ export const EmptyState: React.FC<EmptyStateProps> = ({
15
+ icon,
16
+ title,
17
+ description,
18
+ action,
19
+ compact = false,
20
+ className = ''
21
+ }) => (
22
+ <motion.div
23
+ initial={{ opacity: 0, y: 8 }}
24
+ animate={{ opacity: 1, y: 0 }}
25
+ transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
26
+ className={`flex flex-col items-center justify-center text-center glass rounded-2xl border border-border-app border-dashed ${
27
+ compact ? 'p-6 gap-3' : 'p-10 gap-4'
28
+ } ${className}`}
29
+ >
30
+ <div className={`rounded-2xl bg-accent/10 border border-accent/20 text-accent flex items-center justify-center ${
31
+ compact ? 'w-12 h-12' : 'w-16 h-16'
32
+ }`}>
33
+ {icon ?? <Inbox className={compact ? 'w-6 h-6' : 'w-8 h-8'} />}
34
+ </div>
35
+
36
+ <div className="flex flex-col gap-1.5 max-w-sm">
37
+ <h3 className={`font-extrabold text-text-main font-display ${compact ? 'text-sm' : 'text-base'}`}>
38
+ {title}
39
+ </h3>
40
+ {description && (
41
+ <p className={`text-text-muted leading-relaxed ${compact ? 'text-xs' : 'text-sm'}`}>
42
+ {description}
43
+ </p>
44
+ )}
45
+ </div>
46
+
47
+ {action && <div className="pt-1">{action}</div>}
48
+ </motion.div>
49
+ );
@@ -0,0 +1,62 @@
1
+ import React from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { Home, ArrowLeft, AlertTriangle } from 'lucide-react';
4
+ import { GlassButton } from './GlassButton';
5
+
6
+ export interface ErrorPageProps {
7
+ code?: string | number;
8
+ title?: string;
9
+ description?: string;
10
+ onHome?: () => void;
11
+ onBack?: () => void;
12
+ showBack?: boolean;
13
+ className?: string;
14
+ }
15
+
16
+ export const ErrorPage: React.FC<ErrorPageProps> = ({
17
+ code = '404',
18
+ title = 'Página no encontrada',
19
+ description = 'La ruta que buscás no existe o fue movida. Volvé al inicio o explorá el catálogo.',
20
+ onHome,
21
+ onBack,
22
+ showBack = true,
23
+ className = ''
24
+ }) => {
25
+ const isServerError = String(code).startsWith('5');
26
+
27
+ return (
28
+ <motion.div
29
+ initial={{ opacity: 0, scale: 0.98 }}
30
+ animate={{ opacity: 1, scale: 1 }}
31
+ transition={{ type: 'spring', stiffness: 280, damping: 26 }}
32
+ className={`w-full max-w-lg mx-auto flex flex-col items-center text-center gap-6 p-8 glass rounded-3xl border border-border-app border-dashed ${className}`}
33
+ >
34
+ <div className={`w-14 h-14 rounded-2xl flex items-center justify-center border ${
35
+ isServerError
36
+ ? 'bg-red-500/10 border-red-500/25 text-red-400'
37
+ : 'bg-accent/10 border-accent/25 text-accent'
38
+ }`}>
39
+ <AlertTriangle className="w-7 h-7" />
40
+ </div>
41
+
42
+ <div className="flex flex-col gap-2">
43
+ <span className="text-6xl font-extrabold font-display bg-gradient-to-br from-accent via-violet-500 to-pink-500 bg-clip-text text-transparent leading-none">
44
+ {code}
45
+ </span>
46
+ <h1 className="text-xl font-extrabold text-text-main font-display mt-2">{title}</h1>
47
+ <p className="text-sm text-text-muted leading-relaxed max-w-sm">{description}</p>
48
+ </div>
49
+
50
+ <div className="flex flex-wrap items-center justify-center gap-3 pt-2">
51
+ {showBack && (
52
+ <GlassButton variant="secondary" leftIcon={<ArrowLeft className="w-4 h-4" />} onClick={onBack}>
53
+ Volver
54
+ </GlassButton>
55
+ )}
56
+ <GlassButton variant="primary" leftIcon={<Home className="w-4 h-4" />} onClick={onHome}>
57
+ Ir al inicio
58
+ </GlassButton>
59
+ </div>
60
+ </motion.div>
61
+ );
62
+ };
@@ -0,0 +1,206 @@
1
+ import React, { useCallback, useRef, useState } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { UploadCloud, X, Image as ImageIcon, FileText, Film, Music } from 'lucide-react';
4
+
5
+ export interface FileDropzoneProps {
6
+ onFilesSelected?: (files: File[]) => void;
7
+ maxFiles?: number;
8
+ maxSizeMB?: number;
9
+ accept?: string;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ export const FileDropzone: React.FC<FileDropzoneProps> = ({
14
+ onFilesSelected,
15
+ maxFiles = 5,
16
+ maxSizeMB = 10,
17
+ accept = "*",
18
+ disabled = false
19
+ }) => {
20
+ const [isDragging, setIsDragging] = useState(false);
21
+ const [files, setFiles] = useState<File[]>([]);
22
+ const [error, setError] = useState<string | null>(null);
23
+ const inputRef = useRef<HTMLInputElement>(null);
24
+
25
+ const handleDragOver = useCallback((e: React.DragEvent) => {
26
+ e.preventDefault();
27
+ e.stopPropagation();
28
+ if (!disabled) setIsDragging(true);
29
+ }, [disabled]);
30
+
31
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
32
+ e.preventDefault();
33
+ e.stopPropagation();
34
+ setIsDragging(false);
35
+ }, []);
36
+
37
+ const processFiles = (newFiles: FileList | File[]) => {
38
+ setError(null);
39
+ const validFiles: File[] = [];
40
+ const maxSizeBytes = maxSizeMB * 1024 * 1024;
41
+
42
+ Array.from(newFiles).forEach(file => {
43
+ if (file.size > maxSizeBytes) {
44
+ setError(`El archivo ${file.name} supera los ${maxSizeMB}MB permitidos.`);
45
+ return;
46
+ }
47
+ validFiles.push(file);
48
+ });
49
+
50
+ const updatedFiles = [...files, ...validFiles].slice(0, maxFiles);
51
+ setFiles(updatedFiles);
52
+ if (onFilesSelected) onFilesSelected(updatedFiles);
53
+ };
54
+
55
+ const handleDrop = useCallback((e: React.DragEvent) => {
56
+ e.preventDefault();
57
+ e.stopPropagation();
58
+ setIsDragging(false);
59
+ if (disabled) return;
60
+
61
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
62
+ processFiles(e.dataTransfer.files);
63
+ }
64
+ }, [disabled, files, maxFiles, maxSizeMB]);
65
+
66
+ const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
67
+ if (e.target.files && e.target.files.length > 0) {
68
+ processFiles(e.target.files);
69
+ }
70
+ // Reset input value so the same file can be selected again
71
+ if (inputRef.current) inputRef.current.value = '';
72
+ };
73
+
74
+ const removeFile = (indexToRemove: number) => {
75
+ const updatedFiles = files.filter((_, idx) => idx !== indexToRemove);
76
+ setFiles(updatedFiles);
77
+ if (onFilesSelected) onFilesSelected(updatedFiles);
78
+ };
79
+
80
+ const getFileIcon = (type: string) => {
81
+ if (type.startsWith('image/')) return <ImageIcon className="w-5 h-5 text-blue-400" />;
82
+ if (type.startsWith('video/')) return <Film className="w-5 h-5 text-purple-400" />;
83
+ if (type.startsWith('audio/')) return <Music className="w-5 h-5 text-pink-400" />;
84
+ return <FileText className="w-5 h-5 text-gray-400" />;
85
+ };
86
+
87
+ const formatSize = (bytes: number) => {
88
+ if (bytes === 0) return '0 B';
89
+ const k = 1024;
90
+ const sizes = ['B', 'KB', 'MB', 'GB'];
91
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
92
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
93
+ };
94
+
95
+ return (
96
+ <div className="w-full flex flex-col gap-4">
97
+ {/* Dropzone Area */}
98
+ <motion.div
99
+ animate={{ scale: isDragging ? 1.02 : 1 }}
100
+ transition={{ type: 'spring', stiffness: 400, damping: 28 }}
101
+ className={`relative w-full p-8 rounded-2xl border-2 border-dashed glass flex flex-col items-center justify-center gap-3 transition-colors duration-200 ${
102
+ isDragging ? 'border-accent bg-accent/5' : 'border-border-app hover:border-accent/50 hover:bg-bg-app/50'
103
+ }`}
104
+ onDragOver={handleDragOver}
105
+ onDragLeave={handleDragLeave}
106
+ onDrop={handleDrop}
107
+ onClick={() => !disabled && inputRef.current?.click()}
108
+ >
109
+ <input
110
+ ref={inputRef}
111
+ type="file"
112
+ multiple
113
+ accept={accept}
114
+ onChange={handleFileInput}
115
+ className="hidden"
116
+ disabled={disabled}
117
+ />
118
+
119
+ <div className={`p-4 rounded-full ${isDragging ? 'bg-accent/20' : 'bg-bg-app border border-border-app'} transition-colors duration-300`}>
120
+ <UploadCloud className={`w-8 h-8 ${isDragging ? 'text-accent' : 'text-text-muted'}`} />
121
+ </div>
122
+
123
+ <div className="text-center">
124
+ <p className="text-sm font-bold text-text-main">
125
+ {isDragging ? 'Soltá los archivos acá' : 'Arrastrá tus archivos o hacé click'}
126
+ </p>
127
+ <p className="text-xs text-text-muted mt-1">
128
+ Soporta hasta {maxFiles} archivos de {maxSizeMB}MB max.
129
+ </p>
130
+ </div>
131
+
132
+ {/* Glow effect when dragging */}
133
+ <AnimatePresence>
134
+ {isDragging && (
135
+ <motion.div
136
+ initial={{ opacity: 0 }}
137
+ animate={{ opacity: 1 }}
138
+ exit={{ opacity: 0 }}
139
+ className="absolute inset-0 rounded-2xl shadow-[0_0_30px_rgba(var(--color-accent-rgb),0.3)] pointer-events-none"
140
+ />
141
+ )}
142
+ </AnimatePresence>
143
+ </motion.div>
144
+
145
+ {/* Error Message */}
146
+ <AnimatePresence>
147
+ {error && (
148
+ <motion.div
149
+ initial={{ opacity: 0, height: 0 }}
150
+ animate={{ opacity: 1, height: 'auto' }}
151
+ exit={{ opacity: 0, height: 0 }}
152
+ className="text-xs text-red-400 font-medium"
153
+ >
154
+ {error}
155
+ </motion.div>
156
+ )}
157
+ </AnimatePresence>
158
+
159
+ {/* File List */}
160
+ <AnimatePresence>
161
+ {files.length > 0 && (
162
+ <motion.div
163
+ initial={{ opacity: 0, y: 10 }}
164
+ animate={{ opacity: 1, y: 0 }}
165
+ className="flex flex-col gap-2"
166
+ >
167
+ {files.map((file, idx) => (
168
+ <motion.div
169
+ key={`${file.name}-${idx}`}
170
+ initial={{ opacity: 0, x: -10 }}
171
+ animate={{ opacity: 1, x: 0 }}
172
+ exit={{ opacity: 0, scale: 0.95 }}
173
+ transition={{ delay: idx * 0.05 }}
174
+ className="flex items-center gap-3 p-3 rounded-xl bg-bg-card border border-border-app group"
175
+ >
176
+ <div className="p-2 rounded-lg bg-bg-app">
177
+ {getFileIcon(file.type)}
178
+ </div>
179
+
180
+ <div className="flex-1 min-w-0">
181
+ <p className="text-sm font-medium text-text-main truncate">
182
+ {file.name}
183
+ </p>
184
+ <p className="text-xs text-text-muted">
185
+ {formatSize(file.size)}
186
+ </p>
187
+ </div>
188
+
189
+ <button
190
+ onClick={(e) => {
191
+ e.stopPropagation();
192
+ removeFile(idx);
193
+ }}
194
+ disabled={disabled}
195
+ className="p-2 rounded-lg text-text-muted hover:text-red-400 hover:bg-red-400/10 transition-colors opacity-0 group-hover:opacity-100 disabled:opacity-0"
196
+ >
197
+ <X className="w-4 h-4" />
198
+ </button>
199
+ </motion.div>
200
+ ))}
201
+ </motion.div>
202
+ )}
203
+ </AnimatePresence>
204
+ </div>
205
+ );
206
+ };