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.
- package/README.md +57 -0
- package/components/ActionTable.tsx +307 -0
- package/components/AlertBanner.tsx +124 -0
- package/components/AnimatedAccordion.tsx +95 -0
- package/components/Autocomplete.tsx +144 -0
- package/components/Avatar.tsx +123 -0
- package/components/Badge.tsx +80 -0
- package/components/Breadcrumb.tsx +74 -0
- package/components/Calendar.tsx +340 -0
- package/components/Card3D.tsx +117 -0
- package/components/Carousel3D.tsx +193 -0
- package/components/CascadeSelect.tsx +232 -0
- package/components/ChartShowcase.tsx +700 -0
- package/components/Checkbox.tsx +212 -0
- package/components/ChipsInput.tsx +152 -0
- package/components/CircularKnob.tsx +240 -0
- package/components/CodeVisualizer.tsx +67 -0
- package/components/Collapsible.tsx +72 -0
- package/components/ColorThemeManager.tsx +458 -0
- package/components/CommandMenu.tsx +191 -0
- package/components/ConfirmDialog.tsx +152 -0
- package/components/ContextMenu.tsx +192 -0
- package/components/DashboardLayout.tsx +115 -0
- package/components/DatePicker.tsx +108 -0
- package/components/Divider.tsx +67 -0
- package/components/Dock.tsx +93 -0
- package/components/DragDropLists.tsx +160 -0
- package/components/Drawer.tsx +161 -0
- package/components/DropdownPlus.tsx +304 -0
- package/components/EmptyState.tsx +49 -0
- package/components/ErrorPage.tsx +62 -0
- package/components/FileDropzone.tsx +206 -0
- package/components/ForgotPassword.tsx +137 -0
- package/components/FormField.tsx +81 -0
- package/components/GlassButton.tsx +56 -0
- package/components/GlassCard.tsx +82 -0
- package/components/GlassInput.tsx +96 -0
- package/components/GlassmorphicModal.tsx +108 -0
- package/components/GlowInput.tsx +111 -0
- package/components/GlowSelect.tsx +203 -0
- package/components/GlowTextArea.tsx +105 -0
- package/components/HorizontalTimeline.tsx +121 -0
- package/components/HoverCard.tsx +105 -0
- package/components/ImageLightbox.tsx +259 -0
- package/components/InputGroup.tsx +118 -0
- package/components/InputOTP.tsx +147 -0
- package/components/InteractiveNavbar.tsx +266 -0
- package/components/InteractiveSidebar.tsx +211 -0
- package/components/Kbd.tsx +51 -0
- package/components/LiteYouTube.tsx +118 -0
- package/components/LoaderCollection.tsx +368 -0
- package/components/LoginForm.tsx +192 -0
- package/components/MagneticButton.tsx +101 -0
- package/components/MaskedInput.tsx +79 -0
- package/components/MentionInput.tsx +413 -0
- package/components/MorphingSwitch.tsx +86 -0
- package/components/MultiSelect.tsx +158 -0
- package/components/NumberInput.tsx +203 -0
- package/components/Panel.tsx +104 -0
- package/components/PasswordInput.tsx +203 -0
- package/components/Popover.tsx +91 -0
- package/components/PricingTable.tsx +113 -0
- package/components/ProgressBar.tsx +152 -0
- package/components/RadioButton.tsx +211 -0
- package/components/Rating.tsx +82 -0
- package/components/ResizablePanel.tsx +114 -0
- package/components/ScrollPanel.tsx +103 -0
- package/components/SettingsPage.tsx +154 -0
- package/components/SignupForm.tsx +182 -0
- package/components/Skeleton.tsx +41 -0
- package/components/Slider.tsx +95 -0
- package/components/SlidingTabs.tsx +54 -0
- package/components/SortableList.tsx +91 -0
- package/components/SpeedDial.tsx +134 -0
- package/components/Spinner.tsx +40 -0
- package/components/Stepper.tsx +124 -0
- package/components/TabMenu.tsx +72 -0
- package/components/TableControls.tsx +77 -0
- package/components/TablePagination.tsx +88 -0
- package/components/TextEditor.tsx +329 -0
- package/components/TextReveal.tsx +99 -0
- package/components/ThemeSwitcher.tsx +133 -0
- package/components/TimelineGSAP.tsx +164 -0
- package/components/ToastSystem.tsx +110 -0
- package/components/ToggleButton.tsx +79 -0
- package/components/Tooltip.tsx +121 -0
- package/components/Tree.tsx +138 -0
- package/dist/commands/add.d.ts +7 -0
- package/dist/commands/add.js +110 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +76 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +32 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +60 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +38 -0
- package/dist/tui/browse.d.ts +3 -0
- package/dist/tui/browse.js +139 -0
- package/dist/tui/format.d.ts +11 -0
- package/dist/tui/format.js +52 -0
- package/dist/tui/main.d.ts +1 -0
- package/dist/tui/main.js +86 -0
- package/dist/tui/panels.d.ts +9 -0
- package/dist/tui/panels.js +50 -0
- package/dist/tui/theme.d.ts +28 -0
- package/dist/tui/theme.js +76 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +1 -0
- package/dist/utils/config.d.ts +6 -0
- package/dist/utils/config.js +24 -0
- package/dist/utils/copy.d.ts +9 -0
- package/dist/utils/copy.js +43 -0
- package/dist/utils/cwd.d.ts +6 -0
- package/dist/utils/cwd.js +30 -0
- package/dist/utils/deps.d.ts +1 -0
- package/dist/utils/deps.js +19 -0
- package/dist/utils/project.d.ts +5 -0
- package/dist/utils/project.js +30 -0
- package/dist/utils/theme.d.ts +1 -0
- package/dist/utils/theme.js +24 -0
- package/package.json +52 -0
- package/registry.json +1133 -0
- 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
|
+
};
|