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,191 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { Search, Command } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export interface CommandItem {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
icon?: React.ReactNode;
|
|
9
|
+
group?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CommandMenuProps {
|
|
13
|
+
isOpen: boolean;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
commands: CommandItem[];
|
|
16
|
+
onSelect: (id: string) => void;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const CommandMenu: React.FC<CommandMenuProps> = ({
|
|
22
|
+
isOpen,
|
|
23
|
+
onClose,
|
|
24
|
+
commands,
|
|
25
|
+
onSelect,
|
|
26
|
+
placeholder = "Escribe un comando o busca...",
|
|
27
|
+
disabled = false
|
|
28
|
+
}) => {
|
|
29
|
+
const [query, setQuery] = useState('');
|
|
30
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
31
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
32
|
+
|
|
33
|
+
// Filter commands based on query
|
|
34
|
+
const filteredCommands = commands.filter(cmd =>
|
|
35
|
+
cmd.label.toLowerCase().includes(query.toLowerCase()) ||
|
|
36
|
+
(cmd.group && cmd.group.toLowerCase().includes(query.toLowerCase()))
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Groups for rendering
|
|
40
|
+
const groups = Array.from(new Set(filteredCommands.map(c => c.group || 'General')));
|
|
41
|
+
|
|
42
|
+
// Reset state when opened
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (isOpen) {
|
|
45
|
+
setQuery('');
|
|
46
|
+
setActiveIndex(0);
|
|
47
|
+
// Small delay to allow animation to start before focusing
|
|
48
|
+
setTimeout(() => inputRef.current?.focus(), 100);
|
|
49
|
+
}
|
|
50
|
+
}, [isOpen]);
|
|
51
|
+
|
|
52
|
+
// Handle Keyboard Navigation
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!isOpen || disabled) return;
|
|
55
|
+
|
|
56
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
57
|
+
if (e.key === 'ArrowDown') {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
setActiveIndex(prev => (prev < filteredCommands.length - 1 ? prev + 1 : prev));
|
|
60
|
+
} else if (e.key === 'ArrowUp') {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
setActiveIndex(prev => (prev > 0 ? prev - 1 : prev));
|
|
63
|
+
} else if (e.key === 'Enter') {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
if (filteredCommands[activeIndex]) {
|
|
66
|
+
onSelect(filteredCommands[activeIndex].id);
|
|
67
|
+
onClose();
|
|
68
|
+
}
|
|
69
|
+
} else if (e.key === 'Escape') {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
onClose();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
76
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
77
|
+
}, [isOpen, filteredCommands, activeIndex, onSelect, onClose, disabled]);
|
|
78
|
+
|
|
79
|
+
// Global Ctrl+K / Cmd+K listener to open (handled by parent usually, but we can prevent default here just in case)
|
|
80
|
+
// Actually, parent should handle it.
|
|
81
|
+
|
|
82
|
+
if (disabled) return null;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<AnimatePresence>
|
|
86
|
+
{isOpen && (
|
|
87
|
+
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]">
|
|
88
|
+
{/* Backdrop */}
|
|
89
|
+
<motion.div
|
|
90
|
+
initial={{ opacity: 0 }}
|
|
91
|
+
animate={{ opacity: 1 }}
|
|
92
|
+
exit={{ opacity: 0 }}
|
|
93
|
+
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
|
94
|
+
onClick={onClose}
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
{/* Modal */}
|
|
98
|
+
<motion.div
|
|
99
|
+
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
|
100
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
101
|
+
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
|
102
|
+
transition={{ duration: 0.2, ease: "easeOut" }}
|
|
103
|
+
className="relative w-full max-w-xl bg-bg-card/90 backdrop-blur-xl border border-border-app rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
|
104
|
+
>
|
|
105
|
+
{/* Header / Input */}
|
|
106
|
+
<div className="flex items-center px-4 py-3 border-b border-border-app gap-3">
|
|
107
|
+
<Search className="w-5 h-5 text-text-muted" />
|
|
108
|
+
<input
|
|
109
|
+
ref={inputRef}
|
|
110
|
+
type="text"
|
|
111
|
+
value={query}
|
|
112
|
+
onChange={(e) => {
|
|
113
|
+
setQuery(e.target.value);
|
|
114
|
+
setActiveIndex(0);
|
|
115
|
+
}}
|
|
116
|
+
placeholder={placeholder}
|
|
117
|
+
className="flex-1 bg-transparent border-none outline-none text-text-main placeholder:text-text-muted/50 font-medium text-lg"
|
|
118
|
+
/>
|
|
119
|
+
<div className="flex items-center gap-1 text-[10px] text-text-muted font-bold bg-bg-app px-2 py-1 rounded-md border border-border-app">
|
|
120
|
+
<Command className="w-3 h-3" />
|
|
121
|
+
<span>K</span>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{/* List */}
|
|
126
|
+
<div className="max-h-[60vh] overflow-y-auto p-2 scrollbar-thin">
|
|
127
|
+
{filteredCommands.length === 0 ? (
|
|
128
|
+
<div className="py-8 text-center text-text-muted text-sm">
|
|
129
|
+
No se encontraron resultados para "{query}"
|
|
130
|
+
</div>
|
|
131
|
+
) : (
|
|
132
|
+
groups.map(group => {
|
|
133
|
+
const groupCommands = filteredCommands.filter(c => (c.group || 'General') === group);
|
|
134
|
+
if (groupCommands.length === 0) return null;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div key={group} className="mb-2 last:mb-0">
|
|
138
|
+
<div className="px-3 py-2 text-xs font-bold text-text-muted uppercase tracking-wider">
|
|
139
|
+
{group}
|
|
140
|
+
</div>
|
|
141
|
+
<div className="flex flex-col gap-1">
|
|
142
|
+
{groupCommands.map((cmd) => {
|
|
143
|
+
const globalIndex = filteredCommands.findIndex(c => c.id === cmd.id);
|
|
144
|
+
const isActive = globalIndex === activeIndex;
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
key={cmd.id}
|
|
149
|
+
onMouseEnter={() => setActiveIndex(globalIndex)}
|
|
150
|
+
onClick={() => {
|
|
151
|
+
onSelect(cmd.id);
|
|
152
|
+
onClose();
|
|
153
|
+
}}
|
|
154
|
+
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-colors duration-150 ${
|
|
155
|
+
isActive
|
|
156
|
+
? 'bg-accent/10 text-accent'
|
|
157
|
+
: 'text-text-main hover:bg-bg-app'
|
|
158
|
+
}`}
|
|
159
|
+
>
|
|
160
|
+
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${isActive ? 'bg-accent/20' : 'bg-bg-app border border-border-app'}`}>
|
|
161
|
+
{cmd.icon ? cmd.icon : <Command className="w-4 h-4" />}
|
|
162
|
+
</div>
|
|
163
|
+
<span className="font-medium text-sm flex-1">{cmd.label}</span>
|
|
164
|
+
{isActive && (
|
|
165
|
+
<span className="text-[10px] uppercase font-bold text-accent">Enter ↵</span>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
})
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Footer */}
|
|
178
|
+
<div className="px-4 py-2 border-t border-border-app bg-bg-app/50 flex items-center justify-between text-[10px] text-text-muted">
|
|
179
|
+
<div className="flex gap-4">
|
|
180
|
+
<span className="flex items-center gap-1">↑↓ Navegar</span>
|
|
181
|
+
<span className="flex items-center gap-1">↵ Seleccionar</span>
|
|
182
|
+
<span className="flex items-center gap-1">Esc Cerrar</span>
|
|
183
|
+
</div>
|
|
184
|
+
<span className="font-mono opacity-50">AlexUI</span>
|
|
185
|
+
</div>
|
|
186
|
+
</motion.div>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</AnimatePresence>
|
|
190
|
+
);
|
|
191
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
4
|
+
import { AlertTriangle, Info, Trash2, X } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
export type ConfirmVariant = 'danger' | 'warning' | 'info';
|
|
7
|
+
|
|
8
|
+
export interface ConfirmDialogProps {
|
|
9
|
+
isOpen: boolean;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
onConfirm: () => void;
|
|
12
|
+
title: string;
|
|
13
|
+
message: React.ReactNode;
|
|
14
|
+
confirmLabel?: string;
|
|
15
|
+
cancelLabel?: string;
|
|
16
|
+
variant?: ConfirmVariant;
|
|
17
|
+
loading?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const variantConfig: Record<ConfirmVariant, { icon: React.ReactNode; accent: string; button: string }> = {
|
|
22
|
+
danger: {
|
|
23
|
+
icon: <Trash2 className="w-6 h-6" />,
|
|
24
|
+
accent: 'text-red-500 bg-red-500/10 border-red-500/25',
|
|
25
|
+
button: 'bg-red-500 hover:bg-red-600 text-white border-red-500/30'
|
|
26
|
+
},
|
|
27
|
+
warning: {
|
|
28
|
+
icon: <AlertTriangle className="w-6 h-6" />,
|
|
29
|
+
accent: 'text-yellow-500 bg-yellow-500/10 border-yellow-500/25',
|
|
30
|
+
button: 'bg-yellow-500 hover:bg-yellow-600 text-white border-yellow-500/30'
|
|
31
|
+
},
|
|
32
|
+
info: {
|
|
33
|
+
icon: <Info className="w-6 h-6" />,
|
|
34
|
+
accent: 'text-accent bg-accent/10 border-accent/25',
|
|
35
|
+
button: 'bg-accent hover:bg-accent-hover text-white border-accent/30'
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
40
|
+
isOpen,
|
|
41
|
+
onClose,
|
|
42
|
+
onConfirm,
|
|
43
|
+
title,
|
|
44
|
+
message,
|
|
45
|
+
confirmLabel = 'Confirmar',
|
|
46
|
+
cancelLabel = 'Cancelar',
|
|
47
|
+
variant = 'danger',
|
|
48
|
+
loading = false,
|
|
49
|
+
className = ''
|
|
50
|
+
}) => {
|
|
51
|
+
const config = variantConfig[variant];
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
55
|
+
if (e.key === 'Escape' && isOpen) onClose();
|
|
56
|
+
};
|
|
57
|
+
if (isOpen) {
|
|
58
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
59
|
+
document.body.style.overflow = 'hidden';
|
|
60
|
+
}
|
|
61
|
+
return () => {
|
|
62
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
63
|
+
document.body.style.overflow = '';
|
|
64
|
+
};
|
|
65
|
+
}, [isOpen, onClose]);
|
|
66
|
+
|
|
67
|
+
if (typeof document === 'undefined') return null;
|
|
68
|
+
|
|
69
|
+
return createPortal(
|
|
70
|
+
<AnimatePresence>
|
|
71
|
+
{isOpen && (
|
|
72
|
+
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
|
|
73
|
+
<motion.div
|
|
74
|
+
initial={{ opacity: 0 }}
|
|
75
|
+
animate={{ opacity: 1 }}
|
|
76
|
+
exit={{ opacity: 0 }}
|
|
77
|
+
className="fixed inset-0 bg-black/40 backdrop-blur-md"
|
|
78
|
+
onClick={onClose}
|
|
79
|
+
/>
|
|
80
|
+
|
|
81
|
+
<motion.div
|
|
82
|
+
initial={{ scale: 0.95, opacity: 0, y: 15 }}
|
|
83
|
+
animate={{
|
|
84
|
+
scale: 1,
|
|
85
|
+
opacity: 1,
|
|
86
|
+
y: 0,
|
|
87
|
+
transition: {
|
|
88
|
+
type: 'spring',
|
|
89
|
+
stiffness: 350,
|
|
90
|
+
damping: 25
|
|
91
|
+
}
|
|
92
|
+
}}
|
|
93
|
+
exit={{
|
|
94
|
+
scale: 0.95,
|
|
95
|
+
opacity: 0,
|
|
96
|
+
y: 15,
|
|
97
|
+
transition: {
|
|
98
|
+
duration: 0.2
|
|
99
|
+
}
|
|
100
|
+
}}
|
|
101
|
+
className={`relative z-10 w-full max-w-md rounded-2xl glass bg-bg-card/90 shadow-2xl border border-border-app p-6 flex flex-col gap-5 ${className}`}
|
|
102
|
+
role="alertdialog"
|
|
103
|
+
aria-modal="true"
|
|
104
|
+
>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={onClose}
|
|
108
|
+
className="absolute top-4 right-4 p-1.5 rounded-lg text-text-muted hover:text-text-main hover:bg-bg-app transition-colors cursor-pointer"
|
|
109
|
+
aria-label="Cerrar"
|
|
110
|
+
>
|
|
111
|
+
<X className="w-4 h-4" />
|
|
112
|
+
</button>
|
|
113
|
+
|
|
114
|
+
<div className="flex items-start gap-4">
|
|
115
|
+
<div className={`p-3 rounded-xl border flex-shrink-0 ${config.accent}`}>
|
|
116
|
+
{config.icon}
|
|
117
|
+
</div>
|
|
118
|
+
<div className="flex flex-col gap-2 pr-6">
|
|
119
|
+
<h3 className="font-extrabold text-lg text-text-main font-display leading-tight">
|
|
120
|
+
{title}
|
|
121
|
+
</h3>
|
|
122
|
+
<div className="text-sm text-text-muted leading-relaxed">
|
|
123
|
+
{message}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="flex items-center justify-end gap-3 pt-2 border-t border-border-app/50">
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={onClose}
|
|
132
|
+
disabled={loading}
|
|
133
|
+
className="px-4 py-2 rounded-xl text-sm font-bold text-text-muted hover:text-text-main hover:bg-bg-app border border-border-app transition-colors cursor-pointer disabled:opacity-50"
|
|
134
|
+
>
|
|
135
|
+
{cancelLabel}
|
|
136
|
+
</button>
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={onConfirm}
|
|
140
|
+
disabled={loading}
|
|
141
|
+
className={`px-5 py-2 rounded-xl text-sm font-bold border transition-colors cursor-pointer disabled:opacity-50 ${config.button}`}
|
|
142
|
+
>
|
|
143
|
+
{loading ? 'Procesando...' : confirmLabel}
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
</motion.div>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</AnimatePresence>,
|
|
150
|
+
document.body
|
|
151
|
+
);
|
|
152
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
4
|
+
import { ChevronRight } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
export interface ContextMenuItem {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
icon?: React.ReactNode;
|
|
10
|
+
shortcut?: string;
|
|
11
|
+
danger?: boolean;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
divider?: boolean;
|
|
14
|
+
subItems?: ContextMenuItem[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ContextMenuProps {
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
items: ContextMenuItem[];
|
|
20
|
+
onAction: (id: string) => void;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|
25
|
+
children,
|
|
26
|
+
items,
|
|
27
|
+
onAction,
|
|
28
|
+
disabled = false
|
|
29
|
+
}) => {
|
|
30
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
31
|
+
const [position, setPosition] = useState({ x: 0, y: 0 });
|
|
32
|
+
const [activeSubMenu, setActiveSubMenu] = useState<string | null>(null);
|
|
33
|
+
const [mounted, setMounted] = useState(false);
|
|
34
|
+
|
|
35
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setMounted(true);
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const handleContextMenu = (e: React.MouseEvent) => {
|
|
42
|
+
if (disabled) return;
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
e.stopPropagation();
|
|
45
|
+
|
|
46
|
+
// Calculate position to prevent overflowing screen
|
|
47
|
+
const x = e.clientX;
|
|
48
|
+
const y = e.clientY;
|
|
49
|
+
|
|
50
|
+
setPosition({ x, y });
|
|
51
|
+
setIsOpen(true);
|
|
52
|
+
setActiveSubMenu(null);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!isOpen) return;
|
|
57
|
+
|
|
58
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
59
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
60
|
+
setIsOpen(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
65
|
+
if (e.key === 'Escape') setIsOpen(false);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Close on scroll or resize
|
|
69
|
+
const handleScrollOrResize = () => setIsOpen(false);
|
|
70
|
+
|
|
71
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
72
|
+
document.addEventListener('keydown', handleEscape);
|
|
73
|
+
window.addEventListener('scroll', handleScrollOrResize, { capture: true });
|
|
74
|
+
window.addEventListener('resize', handleScrollOrResize);
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
78
|
+
document.removeEventListener('keydown', handleEscape);
|
|
79
|
+
window.removeEventListener('scroll', handleScrollOrResize, { capture: true });
|
|
80
|
+
window.removeEventListener('resize', handleScrollOrResize);
|
|
81
|
+
};
|
|
82
|
+
}, [isOpen]);
|
|
83
|
+
|
|
84
|
+
// Adjust position if it overflows the viewport
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (isOpen && menuRef.current) {
|
|
87
|
+
const rect = menuRef.current.getBoundingClientRect();
|
|
88
|
+
const viewportWidth = window.innerWidth;
|
|
89
|
+
const viewportHeight = window.innerHeight;
|
|
90
|
+
|
|
91
|
+
let newX = position.x;
|
|
92
|
+
let newY = position.y;
|
|
93
|
+
|
|
94
|
+
if (position.x + rect.width > viewportWidth) {
|
|
95
|
+
newX = viewportWidth - rect.width - 10;
|
|
96
|
+
}
|
|
97
|
+
if (position.y + rect.height > viewportHeight) {
|
|
98
|
+
newY = viewportHeight - rect.height - 10;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (newX !== position.x || newY !== position.y) {
|
|
102
|
+
setPosition({ x: newX, y: newY });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}, [isOpen, position]);
|
|
106
|
+
|
|
107
|
+
const renderItems = (itemsList: ContextMenuItem[]) => {
|
|
108
|
+
return itemsList.map((item, idx) => {
|
|
109
|
+
if (item.divider) {
|
|
110
|
+
return <div key={`div-${idx}`} className="my-1 h-px bg-border-app/50 w-full" />;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hasSubItems = item.subItems && item.subItems.length > 0;
|
|
114
|
+
const isSubOpen = activeSubMenu === item.id;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
key={item.id}
|
|
119
|
+
className="relative"
|
|
120
|
+
onMouseEnter={() => hasSubItems && setActiveSubMenu(item.id)}
|
|
121
|
+
onMouseLeave={() => hasSubItems && setActiveSubMenu(null)}
|
|
122
|
+
>
|
|
123
|
+
<button
|
|
124
|
+
disabled={item.disabled}
|
|
125
|
+
onClick={(e) => {
|
|
126
|
+
if (hasSubItems) return;
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
onAction(item.id);
|
|
129
|
+
setIsOpen(false);
|
|
130
|
+
}}
|
|
131
|
+
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors ${
|
|
132
|
+
item.disabled
|
|
133
|
+
? 'opacity-50 cursor-not-allowed text-text-muted'
|
|
134
|
+
: item.danger
|
|
135
|
+
? 'text-red-400 hover:bg-red-400/10'
|
|
136
|
+
: 'text-text-main hover:bg-accent/10 hover:text-accent'
|
|
137
|
+
} ${isSubOpen && !item.disabled ? 'bg-bg-app' : ''}`}
|
|
138
|
+
>
|
|
139
|
+
<div className="flex items-center gap-2">
|
|
140
|
+
{item.icon && <span className="w-4 h-4 flex items-center justify-center opacity-70">{item.icon}</span>}
|
|
141
|
+
<span>{item.label}</span>
|
|
142
|
+
</div>
|
|
143
|
+
{item.shortcut && (
|
|
144
|
+
<span className="text-[10px] text-text-muted font-mono tracking-widest opacity-60">
|
|
145
|
+
{item.shortcut}
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
{hasSubItems && <ChevronRight className="w-4 h-4 opacity-50" />}
|
|
149
|
+
</button>
|
|
150
|
+
|
|
151
|
+
{/* Submenu rendering */}
|
|
152
|
+
{hasSubItems && isSubOpen && !item.disabled && (
|
|
153
|
+
<div className="absolute top-0 left-[calc(100%+4px)] min-w-[180px] p-1.5 glass bg-bg-card/95 border border-border-app rounded-xl shadow-xl z-50 animate-in fade-in zoom-in-95 duration-100">
|
|
154
|
+
{renderItems(item.subItems!)}
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className="relative inline-block w-full h-full" onContextMenu={handleContextMenu}>
|
|
164
|
+
{children}
|
|
165
|
+
|
|
166
|
+
{/* We use standard relative/absolute portals, but since this is inline, we use fixed position overlay for the menu */}
|
|
167
|
+
{mounted && createPortal(
|
|
168
|
+
<AnimatePresence>
|
|
169
|
+
{isOpen && !disabled && (
|
|
170
|
+
<div className="fixed inset-0 z-[999] pointer-events-none">
|
|
171
|
+
<motion.div
|
|
172
|
+
ref={menuRef}
|
|
173
|
+
initial={{ opacity: 0, scale: 0.95, filter: 'blur(4px)' }}
|
|
174
|
+
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
|
175
|
+
exit={{ opacity: 0, scale: 0.95, filter: 'blur(4px)' }}
|
|
176
|
+
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
177
|
+
className="absolute min-w-[220px] p-1.5 glass bg-bg-card/90 backdrop-blur-2xl border border-border-app shadow-[0_10px_40px_-10px_rgba(0,0,0,0.5)] rounded-xl pointer-events-auto flex flex-col"
|
|
178
|
+
style={{
|
|
179
|
+
left: `${position.x}px`,
|
|
180
|
+
top: `${position.y}px`,
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
{renderItems(items)}
|
|
184
|
+
</motion.div>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</AnimatePresence>,
|
|
188
|
+
document.body
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { motion } from 'framer-motion';
|
|
3
|
+
import { Users, Download, Layers, TrendingUp } from 'lucide-react';
|
|
4
|
+
import { GlassCard } from './GlassCard';
|
|
5
|
+
import { ChartShowcase } from './ChartShowcase';
|
|
6
|
+
import { ActionTable, type TableRowData } from './ActionTable';
|
|
7
|
+
import { Badge } from './Badge';
|
|
8
|
+
import { Skeleton } from './Skeleton';
|
|
9
|
+
|
|
10
|
+
export interface DashboardStat {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
value: string;
|
|
14
|
+
change?: string;
|
|
15
|
+
icon?: React.ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DashboardLayoutProps {
|
|
19
|
+
title?: string;
|
|
20
|
+
subtitle?: string;
|
|
21
|
+
stats?: DashboardStat[];
|
|
22
|
+
tableData?: TableRowData[];
|
|
23
|
+
onTableAction?: (action: string, row: TableRowData) => void;
|
|
24
|
+
isLoading?: boolean;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_STATS: DashboardStat[] = [
|
|
30
|
+
{ id: 'users', label: 'Usuarios activos', value: '2.4k', change: '+12%', icon: <Users className="w-4 h-4" /> },
|
|
31
|
+
{ id: 'downloads', label: 'Descargas CLI', value: '18.2k', change: '+8%', icon: <Download className="w-4 h-4" /> },
|
|
32
|
+
{ id: 'components', label: 'Componentes', value: '86', change: 'V3.5', icon: <Layers className="w-4 h-4" /> },
|
|
33
|
+
{ id: 'growth', label: 'Crecimiento', value: '34%', change: '+4%', icon: <TrendingUp className="w-4 h-4" /> },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const DEFAULT_TABLE: TableRowData[] = [
|
|
37
|
+
{ id: 1, name: 'Alexis Jardin', email: 'alex@alexui.dev', role: 'Admin', status: 'Active' },
|
|
38
|
+
{ id: 2, name: 'Sofía López', email: 'sofia@alexui.dev', role: 'Editor', status: 'Active' },
|
|
39
|
+
{ id: 3, name: 'Mateo Díaz', email: 'mateo@alexui.dev', role: 'Viewer', status: 'Pending' },
|
|
40
|
+
{ id: 4, name: 'Lucía Vega', email: 'lucia@alexui.dev', role: 'Editor', status: 'Inactive' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({
|
|
44
|
+
title = 'Panel de control',
|
|
45
|
+
subtitle = 'Resumen de actividad y métricas del catálogo',
|
|
46
|
+
stats = DEFAULT_STATS,
|
|
47
|
+
tableData = DEFAULT_TABLE,
|
|
48
|
+
onTableAction,
|
|
49
|
+
isLoading = false,
|
|
50
|
+
disabled = false,
|
|
51
|
+
className = ''
|
|
52
|
+
}) => {
|
|
53
|
+
if (isLoading) {
|
|
54
|
+
return (
|
|
55
|
+
<div className={`w-full flex flex-col gap-4 ${className}`}>
|
|
56
|
+
<Skeleton variant="text" className="w-48 h-6" />
|
|
57
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
58
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
59
|
+
<Skeleton key={i} variant="rect" className="h-24 rounded-2xl" />
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
<Skeleton variant="rect" className="h-48 rounded-2xl" />
|
|
63
|
+
<Skeleton variant="rect" className="h-40 rounded-2xl" />
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<motion.div
|
|
70
|
+
initial={{ opacity: 0, y: 12 }}
|
|
71
|
+
animate={{ opacity: 1, y: 0 }}
|
|
72
|
+
transition={{ duration: 0.3 }}
|
|
73
|
+
className={`w-full flex flex-col gap-5 ${className}`}
|
|
74
|
+
>
|
|
75
|
+
<div className="flex flex-col gap-1">
|
|
76
|
+
<h2 className="text-lg font-extrabold text-text-main font-display">{title}</h2>
|
|
77
|
+
<p className="text-xs text-text-muted">{subtitle}</p>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
81
|
+
{stats.map((stat, index) => (
|
|
82
|
+
<motion.div
|
|
83
|
+
key={stat.id}
|
|
84
|
+
initial={{ opacity: 0, y: 8 }}
|
|
85
|
+
animate={{ opacity: 1, y: 0 }}
|
|
86
|
+
transition={{ delay: index * 0.05 }}
|
|
87
|
+
>
|
|
88
|
+
<GlassCard padding="sm" hoverable className="h-full">
|
|
89
|
+
<div className="flex items-start justify-between gap-2">
|
|
90
|
+
<div className="p-2 rounded-xl bg-accent/10 text-accent">
|
|
91
|
+
{stat.icon}
|
|
92
|
+
</div>
|
|
93
|
+
{stat.change && (
|
|
94
|
+
<Badge variant="success" size="sm">{stat.change}</Badge>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
<p className="text-xl font-extrabold text-text-main font-display mt-3">{stat.value}</p>
|
|
98
|
+
<p className="text-[10px] text-text-muted font-medium mt-0.5">{stat.label}</p>
|
|
99
|
+
</GlassCard>
|
|
100
|
+
</motion.div>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<ChartShowcase type="bar" className="!p-4" />
|
|
105
|
+
|
|
106
|
+
<ActionTable
|
|
107
|
+
data={tableData}
|
|
108
|
+
onAction={onTableAction}
|
|
109
|
+
showPagination={false}
|
|
110
|
+
itemsPerPage={4}
|
|
111
|
+
disabled={disabled}
|
|
112
|
+
/>
|
|
113
|
+
</motion.div>
|
|
114
|
+
);
|
|
115
|
+
};
|