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,212 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
|
|
4
|
+
// 1. INDIVIDUAL CHECKBOX COMPONENT
|
|
5
|
+
export interface CheckboxProps {
|
|
6
|
+
checked: boolean;
|
|
7
|
+
onChange: (checked: boolean) => void;
|
|
8
|
+
label?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
isInvalid?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
id?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const Checkbox: React.FC<CheckboxProps> = ({
|
|
16
|
+
checked,
|
|
17
|
+
onChange,
|
|
18
|
+
label,
|
|
19
|
+
disabled = false,
|
|
20
|
+
isInvalid = false,
|
|
21
|
+
className = '',
|
|
22
|
+
id
|
|
23
|
+
}) => {
|
|
24
|
+
const checkboxId = id || `check-${Math.random().toString(36).substring(2, 9)}`;
|
|
25
|
+
|
|
26
|
+
const handleToggle = () => {
|
|
27
|
+
if (disabled) return;
|
|
28
|
+
onChange(!checked);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const getBorderColor = () => {
|
|
32
|
+
if (isInvalid) return 'border-error';
|
|
33
|
+
if (checked && !disabled) return 'border-accent';
|
|
34
|
+
return 'border-border-app hover:border-text-muted/50';
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={`flex items-center gap-3 select-none ${className}`}>
|
|
39
|
+
<button
|
|
40
|
+
id={checkboxId}
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={handleToggle}
|
|
43
|
+
disabled={disabled}
|
|
44
|
+
className={`w-5.5 h-5.5 rounded-md border flex items-center justify-center transition-all duration-200 focus:outline-hidden ${getBorderColor()} ${
|
|
45
|
+
disabled ? 'opacity-40 cursor-not-allowed bg-bg-app/10' : 'cursor-pointer'
|
|
46
|
+
}`}
|
|
47
|
+
style={{
|
|
48
|
+
boxShadow: checked && !disabled && !isInvalid
|
|
49
|
+
? '0 0 8px var(--color-accent-glow)'
|
|
50
|
+
: isInvalid
|
|
51
|
+
? '0 0 8px var(--color-error-glow)'
|
|
52
|
+
: 'none',
|
|
53
|
+
backgroundColor: checked && !disabled && !isInvalid
|
|
54
|
+
? 'var(--color-accent)'
|
|
55
|
+
: 'transparent'
|
|
56
|
+
}}
|
|
57
|
+
role="checkbox"
|
|
58
|
+
aria-checked={checked}
|
|
59
|
+
>
|
|
60
|
+
<AnimatePresence mode="wait">
|
|
61
|
+
{checked && (
|
|
62
|
+
<motion.svg
|
|
63
|
+
initial={{ scale: 0.6, opacity: 0 }}
|
|
64
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
65
|
+
exit={{ scale: 0.6, opacity: 0 }}
|
|
66
|
+
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
|
|
67
|
+
className="w-3.5 h-3.5 text-white stroke-2"
|
|
68
|
+
fill="none"
|
|
69
|
+
viewBox="0 0 24 24"
|
|
70
|
+
stroke="currentColor"
|
|
71
|
+
>
|
|
72
|
+
<motion.path
|
|
73
|
+
initial={{ pathLength: 0 }}
|
|
74
|
+
animate={{ pathLength: 1 }}
|
|
75
|
+
transition={{ duration: 0.2 }}
|
|
76
|
+
strokeLinecap="round"
|
|
77
|
+
strokeLinejoin="round"
|
|
78
|
+
d="M5 13l4 4L19 7"
|
|
79
|
+
/>
|
|
80
|
+
</motion.svg>
|
|
81
|
+
)}
|
|
82
|
+
</AnimatePresence>
|
|
83
|
+
</button>
|
|
84
|
+
|
|
85
|
+
{label && (
|
|
86
|
+
<label
|
|
87
|
+
htmlFor={checkboxId}
|
|
88
|
+
onClick={handleToggle}
|
|
89
|
+
className={`text-sm font-medium ${
|
|
90
|
+
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer text-text-main'
|
|
91
|
+
} ${isInvalid ? 'text-error' : ''}`}
|
|
92
|
+
>
|
|
93
|
+
{label}
|
|
94
|
+
</label>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// 2. CHECKBOX GROUP COMPONENT
|
|
101
|
+
export interface CheckboxOption {
|
|
102
|
+
value: string;
|
|
103
|
+
label: string;
|
|
104
|
+
disabled?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface CheckboxGroupProps {
|
|
108
|
+
options: CheckboxOption[];
|
|
109
|
+
value: string[];
|
|
110
|
+
onChange: (value: string[]) => void;
|
|
111
|
+
orientation?: 'horizontal' | 'vertical';
|
|
112
|
+
className?: string;
|
|
113
|
+
disabled?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const CheckboxGroup: React.FC<CheckboxGroupProps> = ({
|
|
117
|
+
options,
|
|
118
|
+
value,
|
|
119
|
+
onChange,
|
|
120
|
+
orientation = 'horizontal',
|
|
121
|
+
className = '',
|
|
122
|
+
disabled = false
|
|
123
|
+
}) => {
|
|
124
|
+
const handleOptionChange = (optionValue: string, checked: boolean) => {
|
|
125
|
+
if (disabled) return;
|
|
126
|
+
if (checked) {
|
|
127
|
+
onChange([...value, optionValue]);
|
|
128
|
+
} else {
|
|
129
|
+
onChange(value.filter((val) => val !== optionValue));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
className={`flex ${
|
|
136
|
+
orientation === 'horizontal' ? 'flex-row flex-wrap gap-5' : 'flex-col gap-3.5'
|
|
137
|
+
} ${className}`}
|
|
138
|
+
>
|
|
139
|
+
{options.map((opt) => (
|
|
140
|
+
<Checkbox
|
|
141
|
+
key={opt.value}
|
|
142
|
+
checked={value.includes(opt.value)}
|
|
143
|
+
onChange={(checked) => handleOptionChange(opt.value, checked)}
|
|
144
|
+
label={opt.label}
|
|
145
|
+
disabled={disabled || opt.disabled}
|
|
146
|
+
/>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// 3. INTERACTIVE CHECKLIST COMPONENT (Todo item lists)
|
|
153
|
+
export interface ChecklistItem {
|
|
154
|
+
id: string | number;
|
|
155
|
+
label: string;
|
|
156
|
+
checked: boolean;
|
|
157
|
+
disabled?: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface ChecklistProps {
|
|
161
|
+
items: ChecklistItem[];
|
|
162
|
+
onChange: (items: ChecklistItem[]) => void;
|
|
163
|
+
className?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const Checklist: React.FC<ChecklistProps> = ({
|
|
167
|
+
items,
|
|
168
|
+
onChange,
|
|
169
|
+
className = ''
|
|
170
|
+
}) => {
|
|
171
|
+
const handleItemToggle = (itemId: string | number) => {
|
|
172
|
+
const nextItems = items.map((item) => {
|
|
173
|
+
if (item.id === itemId) {
|
|
174
|
+
return { ...item, checked: !item.checked };
|
|
175
|
+
}
|
|
176
|
+
return item;
|
|
177
|
+
});
|
|
178
|
+
onChange(nextItems);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className={`flex flex-col gap-2 bg-bg-card/40 border border-border-app/40 rounded-2xl p-4 w-full ${className}`}>
|
|
183
|
+
{items.map((item) => (
|
|
184
|
+
<div
|
|
185
|
+
key={item.id}
|
|
186
|
+
className={`flex items-center justify-between p-2.5 rounded-xl border border-transparent transition-all duration-300 ${
|
|
187
|
+
item.checked
|
|
188
|
+
? 'bg-accent/5 border-accent/10'
|
|
189
|
+
: 'hover:bg-bg-app/40'
|
|
190
|
+
} ${item.disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
|
191
|
+
>
|
|
192
|
+
<Checkbox
|
|
193
|
+
checked={item.checked}
|
|
194
|
+
onChange={() => handleItemToggle(item.id)}
|
|
195
|
+
label={item.label}
|
|
196
|
+
disabled={item.disabled}
|
|
197
|
+
className="flex-1"
|
|
198
|
+
/>
|
|
199
|
+
{item.checked && (
|
|
200
|
+
<motion.span
|
|
201
|
+
initial={{ scale: 0, opacity: 0 }}
|
|
202
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
203
|
+
className="text-[10px] bg-accent/20 border border-accent/20 px-2 py-0.5 rounded-full text-accent font-bold font-mono"
|
|
204
|
+
>
|
|
205
|
+
Completado
|
|
206
|
+
</motion.span>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import React, { useState, useRef } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export interface ChipsInputProps {
|
|
6
|
+
value: string[];
|
|
7
|
+
onChange: (value: string[]) => void;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
isInvalid?: boolean;
|
|
12
|
+
separator?: ','; // support comma as separator
|
|
13
|
+
blacklist?: string[]; // block certain values
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const ChipsInput: React.FC<ChipsInputProps> = ({
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
placeholder = 'Escribe y presiona Enter...',
|
|
21
|
+
label,
|
|
22
|
+
disabled = false,
|
|
23
|
+
isInvalid = false,
|
|
24
|
+
separator = ',',
|
|
25
|
+
blacklist = [],
|
|
26
|
+
className = ''
|
|
27
|
+
}) => {
|
|
28
|
+
const [inputValue, setInputValue] = useState('');
|
|
29
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
30
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
31
|
+
|
|
32
|
+
const addChip = (text: string) => {
|
|
33
|
+
const trimmed = text.trim();
|
|
34
|
+
if (!trimmed) return;
|
|
35
|
+
|
|
36
|
+
// Check duplicate
|
|
37
|
+
if (value.includes(trimmed)) return;
|
|
38
|
+
|
|
39
|
+
// Check blacklist / key blocking filter
|
|
40
|
+
if (blacklist.includes(trimmed.toLowerCase())) return;
|
|
41
|
+
|
|
42
|
+
onChange([...value, trimmed]);
|
|
43
|
+
setInputValue('');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const removeChip = (indexToRemove: number) => {
|
|
47
|
+
onChange(value.filter((_, idx) => idx !== indexToRemove));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
51
|
+
if (e.key === 'Enter') {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
addChip(inputValue);
|
|
54
|
+
} else if (e.key === 'Backspace' && inputValue === '' && value.length > 0) {
|
|
55
|
+
removeChip(value.length - 1);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
60
|
+
const val = e.target.value;
|
|
61
|
+
|
|
62
|
+
if (separator === ',' && val.endsWith(',')) {
|
|
63
|
+
addChip(val.slice(0, -1));
|
|
64
|
+
} else {
|
|
65
|
+
setInputValue(val);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className={`w-full flex flex-col gap-1.5 ${className}`}>
|
|
71
|
+
{label && (
|
|
72
|
+
<span className="text-xs font-bold text-text-muted uppercase tracking-wider px-1">
|
|
73
|
+
{label}
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
{/* Input container wrapper */}
|
|
78
|
+
<div className="relative rounded-xl overflow-hidden transition-all duration-300">
|
|
79
|
+
|
|
80
|
+
{/* Glow border ring */}
|
|
81
|
+
<motion.div
|
|
82
|
+
animate={{
|
|
83
|
+
opacity: isFocused && !disabled ? 1 : 0,
|
|
84
|
+
scale: isFocused && !disabled ? 1 : 0.98,
|
|
85
|
+
}}
|
|
86
|
+
transition={{ duration: 0.25 }}
|
|
87
|
+
className="absolute -inset-[1px] bg-gradient-to-r from-accent to-pink-500 rounded-xl pointer-events-none z-0 blur-[1px]"
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
{/* Chips Area Container */}
|
|
91
|
+
<div
|
|
92
|
+
onClick={() => !disabled && inputRef.current?.focus()}
|
|
93
|
+
className={`relative bg-bg-card/60 border rounded-xl z-10 p-2 flex flex-wrap gap-2 items-center transition-all duration-300 ${
|
|
94
|
+
isFocused && !disabled ? 'border-transparent' : 'border-border-app'
|
|
95
|
+
} ${disabled ? 'opacity-40 cursor-not-allowed select-none bg-bg-app/10' : 'cursor-text'}`}
|
|
96
|
+
style={{
|
|
97
|
+
boxShadow: isFocused && !disabled && !isInvalid
|
|
98
|
+
? '0 0 10px var(--color-accent-glow)'
|
|
99
|
+
: isInvalid
|
|
100
|
+
? '0 0 10px var(--color-error-glow)'
|
|
101
|
+
: 'none'
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{/* Render Chips List */}
|
|
105
|
+
<AnimatePresence>
|
|
106
|
+
{value.map((chip, idx) => (
|
|
107
|
+
<motion.div
|
|
108
|
+
key={chip}
|
|
109
|
+
initial={{ scale: 0.8, opacity: 0 }}
|
|
110
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
111
|
+
exit={{ scale: 0.8, opacity: 0 }}
|
|
112
|
+
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
|
|
113
|
+
className="flex items-center gap-1 bg-accent/10 border border-accent/20 px-2.5 py-1 rounded-lg text-xs font-semibold text-accent"
|
|
114
|
+
>
|
|
115
|
+
<span>{chip}</span>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={(e) => {
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
if (!disabled) removeChip(idx);
|
|
121
|
+
}}
|
|
122
|
+
disabled={disabled}
|
|
123
|
+
className={`p-0.5 rounded-md hover:bg-accent/20 transition-colors focus:outline-hidden ${
|
|
124
|
+
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
|
125
|
+
}`}
|
|
126
|
+
>
|
|
127
|
+
<X className="w-3 h-3" />
|
|
128
|
+
</button>
|
|
129
|
+
</motion.div>
|
|
130
|
+
))}
|
|
131
|
+
</AnimatePresence>
|
|
132
|
+
|
|
133
|
+
{/* Typing input field */}
|
|
134
|
+
<input
|
|
135
|
+
ref={inputRef}
|
|
136
|
+
type="text"
|
|
137
|
+
value={inputValue}
|
|
138
|
+
onChange={handleInputChange}
|
|
139
|
+
onKeyDown={handleKeyDown}
|
|
140
|
+
onFocus={() => setIsFocused(true)}
|
|
141
|
+
onBlur={() => setIsFocused(false)}
|
|
142
|
+
disabled={disabled}
|
|
143
|
+
placeholder={value.length === 0 ? placeholder : ''}
|
|
144
|
+
className={`flex-1 min-w-[120px] bg-transparent py-1 px-2 text-sm text-text-main placeholder-text-muted/50 focus:outline-hidden ${
|
|
145
|
+
disabled ? 'cursor-not-allowed' : ''
|
|
146
|
+
}`}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import React, { useRef, useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface CircularKnobProps {
|
|
4
|
+
value: number;
|
|
5
|
+
onChange: (value: number) => void;
|
|
6
|
+
min?: number;
|
|
7
|
+
max?: number;
|
|
8
|
+
size?: number;
|
|
9
|
+
unit?: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
variant?: '360' | 'semicircle'; // 360 full range, semicircle 180 (or 270 standard knob)
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const CircularKnob: React.FC<CircularKnobProps> = ({
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
min = 0,
|
|
20
|
+
max = 100,
|
|
21
|
+
size = 120,
|
|
22
|
+
unit = '%',
|
|
23
|
+
label = 'Volumen',
|
|
24
|
+
variant = 'semicircle',
|
|
25
|
+
disabled = false,
|
|
26
|
+
className = ''
|
|
27
|
+
}) => {
|
|
28
|
+
const knobRef = useRef<SVGSVGElement>(null);
|
|
29
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
30
|
+
|
|
31
|
+
// Normalize value to percentage
|
|
32
|
+
const percentage = Math.min(Math.max((value - min) / (max - min), 0), 1);
|
|
33
|
+
|
|
34
|
+
// Knob configuration depending on variant
|
|
35
|
+
// 'semicircle' (standard knob) goes from -135deg to +135deg (total 270deg span, gap at the bottom)
|
|
36
|
+
// '360' goes from 0deg to 360deg (starting at top)
|
|
37
|
+
const startAngle = variant === 'semicircle' ? -135 : 0;
|
|
38
|
+
const endAngle = variant === 'semicircle' ? 135 : 360;
|
|
39
|
+
const totalAngleSpan = endAngle - startAngle;
|
|
40
|
+
|
|
41
|
+
const currentAngle = startAngle + percentage * totalAngleSpan;
|
|
42
|
+
|
|
43
|
+
const updateValueFromAngle = (clientX: number, clientY: number) => {
|
|
44
|
+
if (!knobRef.current) return;
|
|
45
|
+
const rect = knobRef.current.getBoundingClientRect();
|
|
46
|
+
const cx = rect.left + rect.width / 2;
|
|
47
|
+
const cy = rect.top + rect.height / 2;
|
|
48
|
+
|
|
49
|
+
const dx = clientX - cx;
|
|
50
|
+
const dy = clientY - cy;
|
|
51
|
+
|
|
52
|
+
// Angle in degrees from -180 to 180 where 0 is right, 90 is bottom, -90 is top
|
|
53
|
+
let angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
|
54
|
+
|
|
55
|
+
// Normalize angle relative to the top (0 deg is top, clockwise positive)
|
|
56
|
+
let angleFromTop = angle + 90;
|
|
57
|
+
if (angleFromTop < -180) angleFromTop += 360;
|
|
58
|
+
if (angleFromTop > 180) angleFromTop -= 360;
|
|
59
|
+
|
|
60
|
+
let targetPercent = 0;
|
|
61
|
+
if (variant === 'semicircle') {
|
|
62
|
+
// Standard active zone: -135 to 135.
|
|
63
|
+
// Gap is at the bottom (between 135 and 225 relative to top, i.e., 135 to 180 and -180 to -135)
|
|
64
|
+
let targetAngle = angleFromTop;
|
|
65
|
+
if (targetAngle > 135) targetAngle = 135;
|
|
66
|
+
if (targetAngle < -135) targetAngle = -135;
|
|
67
|
+
|
|
68
|
+
targetPercent = (targetAngle + 135) / 270;
|
|
69
|
+
} else {
|
|
70
|
+
// 360 mode: 0 to 360.
|
|
71
|
+
let angleFromTop360 = angle + 90;
|
|
72
|
+
if (angleFromTop360 < 0) angleFromTop360 += 360;
|
|
73
|
+
targetPercent = angleFromTop360 / 360;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const nextValue = min + targetPercent * (max - min);
|
|
77
|
+
onChange(Math.round(nextValue));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleMouseDown = (e: React.MouseEvent) => {
|
|
81
|
+
if (disabled) return;
|
|
82
|
+
setIsDragging(true);
|
|
83
|
+
updateValueFromAngle(e.clientX, e.clientY);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleTouchStart = (e: React.TouchEvent) => {
|
|
87
|
+
if (disabled) return;
|
|
88
|
+
setIsDragging(true);
|
|
89
|
+
updateValueFromAngle(e.touches[0].clientX, e.touches[0].clientY);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
94
|
+
if (!isDragging) return;
|
|
95
|
+
updateValueFromAngle(e.clientX, e.clientY);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const handleTouchMove = (e: TouchEvent) => {
|
|
99
|
+
if (!isDragging) return;
|
|
100
|
+
// Prevent scrolling while dragging knob
|
|
101
|
+
if (e.cancelable) e.preventDefault();
|
|
102
|
+
updateValueFromAngle(e.touches[0].clientX, e.touches[0].clientY);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleMouseUp = () => {
|
|
106
|
+
setIsDragging(false);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (isDragging) {
|
|
110
|
+
window.addEventListener('mousemove', handleMouseMove);
|
|
111
|
+
window.addEventListener('mouseup', handleMouseUp);
|
|
112
|
+
window.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
113
|
+
window.addEventListener('touchend', handleMouseUp);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
window.removeEventListener('mousemove', handleMouseMove);
|
|
118
|
+
window.removeEventListener('mouseup', handleMouseUp);
|
|
119
|
+
window.removeEventListener('touchmove', handleTouchMove);
|
|
120
|
+
window.removeEventListener('touchend', handleMouseUp);
|
|
121
|
+
};
|
|
122
|
+
}, [isDragging]);
|
|
123
|
+
|
|
124
|
+
// SVG parameters for progress arc
|
|
125
|
+
const radius = size * 0.4;
|
|
126
|
+
const strokeWidth = size * 0.07;
|
|
127
|
+
const center = size / 2;
|
|
128
|
+
const circumference = 2 * Math.PI * radius;
|
|
129
|
+
|
|
130
|
+
// Semicircle stroke length adjustments
|
|
131
|
+
// If 270deg, length is 3/4 of circle
|
|
132
|
+
const arcLength = variant === 'semicircle' ? circumference * 0.75 : circumference;
|
|
133
|
+
const strokeDashoffset = arcLength - percentage * arcLength;
|
|
134
|
+
const strokeDasharray = `${arcLength} ${circumference}`;
|
|
135
|
+
|
|
136
|
+
// Rotate rotation mapping of progress bar
|
|
137
|
+
const arcRotation = variant === 'semicircle' ? 135 : -90;
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className={`flex flex-col items-center gap-2 select-none ${className}`}>
|
|
141
|
+
|
|
142
|
+
{/* Knob SVG container */}
|
|
143
|
+
<div
|
|
144
|
+
className={`relative ${
|
|
145
|
+
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-grab'
|
|
146
|
+
} ${isDragging ? 'cursor-grabbing' : ''}`}
|
|
147
|
+
style={{ width: size, height: size }}
|
|
148
|
+
>
|
|
149
|
+
<svg
|
|
150
|
+
ref={knobRef}
|
|
151
|
+
width={size}
|
|
152
|
+
height={size}
|
|
153
|
+
onMouseDown={handleMouseDown}
|
|
154
|
+
onTouchStart={handleTouchStart}
|
|
155
|
+
className="w-full h-full overflow-visible"
|
|
156
|
+
>
|
|
157
|
+
{/* Subtle Outer Glow Ring on drag */}
|
|
158
|
+
{isDragging && !disabled && (
|
|
159
|
+
<circle
|
|
160
|
+
cx={center}
|
|
161
|
+
cy={center}
|
|
162
|
+
r={radius + strokeWidth / 2 + 4}
|
|
163
|
+
className="fill-none stroke-accent/5 stroke-2 blur-[2px]"
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{/* Background Arc Track */}
|
|
168
|
+
<circle
|
|
169
|
+
cx={center}
|
|
170
|
+
cy={center}
|
|
171
|
+
r={radius}
|
|
172
|
+
className="fill-none stroke-border-app/30"
|
|
173
|
+
strokeWidth={strokeWidth}
|
|
174
|
+
strokeDasharray={arcLength === circumference ? undefined : strokeDasharray}
|
|
175
|
+
style={{
|
|
176
|
+
transform: `rotate(${arcRotation}deg)`,
|
|
177
|
+
transformOrigin: '50% 50%',
|
|
178
|
+
strokeLinecap: 'round'
|
|
179
|
+
}}
|
|
180
|
+
/>
|
|
181
|
+
|
|
182
|
+
{/* Glowing Active Progress Arc */}
|
|
183
|
+
<circle
|
|
184
|
+
cx={center}
|
|
185
|
+
cy={center}
|
|
186
|
+
r={radius}
|
|
187
|
+
className="fill-none stroke-accent transition-all duration-75"
|
|
188
|
+
strokeWidth={strokeWidth}
|
|
189
|
+
strokeDasharray={strokeDasharray}
|
|
190
|
+
strokeDashoffset={strokeDashoffset}
|
|
191
|
+
style={{
|
|
192
|
+
transform: `rotate(${arcRotation}deg)`,
|
|
193
|
+
transformOrigin: '50% 50%',
|
|
194
|
+
strokeLinecap: 'round',
|
|
195
|
+
filter: isDragging ? 'drop-shadow(0px 0px 4px var(--color-accent))' : 'none'
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
198
|
+
|
|
199
|
+
{/* Inner Rotating Knob Dial Disc */}
|
|
200
|
+
<g
|
|
201
|
+
style={{
|
|
202
|
+
transform: `rotate(${currentAngle}deg)`,
|
|
203
|
+
transformOrigin: '50% 50%'
|
|
204
|
+
}}
|
|
205
|
+
className="transition-transform duration-75"
|
|
206
|
+
>
|
|
207
|
+
<circle
|
|
208
|
+
cx={center}
|
|
209
|
+
cy={center}
|
|
210
|
+
r={radius - strokeWidth / 2 - 2}
|
|
211
|
+
className="fill-bg-card stroke-border-app/50"
|
|
212
|
+
strokeWidth="1"
|
|
213
|
+
/>
|
|
214
|
+
{/* Indicator Dot on the dial edge */}
|
|
215
|
+
<circle
|
|
216
|
+
cx={center}
|
|
217
|
+
cy={center - radius + strokeWidth + 4}
|
|
218
|
+
r="3.5"
|
|
219
|
+
className="fill-accent"
|
|
220
|
+
/>
|
|
221
|
+
</g>
|
|
222
|
+
</svg>
|
|
223
|
+
|
|
224
|
+
{/* Value Display in the center of the Knob */}
|
|
225
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
|
226
|
+
<span className="text-sm font-extrabold text-text-main font-mono leading-none flex items-baseline">
|
|
227
|
+
{value}
|
|
228
|
+
<span className="text-[10px] text-text-muted font-bold ml-0.5">{unit}</span>
|
|
229
|
+
</span>
|
|
230
|
+
{label && (
|
|
231
|
+
<span className="text-[9px] font-bold text-text-muted/70 uppercase tracking-widest mt-1">
|
|
232
|
+
{label}
|
|
233
|
+
</span>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface CodeVisualizerProps {
|
|
4
|
+
code: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Custom Lexer / Parser using regex to tokenize TypeScript/JSX code on the fly
|
|
9
|
+
const highlightCode = (rawCode: string) => {
|
|
10
|
+
if (!rawCode) return '';
|
|
11
|
+
|
|
12
|
+
// Escape HTML entities to prevent unescaped injections
|
|
13
|
+
const escaped = rawCode
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>');
|
|
17
|
+
|
|
18
|
+
// Unified regular expression parser matching all core tokens sequentially:
|
|
19
|
+
// Group 1: Comments (line & block)
|
|
20
|
+
// Group 2: Strings (single, double, or backticks)
|
|
21
|
+
// Group 3: JavaScript/TypeScript Keywords
|
|
22
|
+
// Group 4: React / PascalCase Components (e.g., <Card3D / </Card3D)
|
|
23
|
+
// Group 5: HTML standard tags (e.g., <div / </div)
|
|
24
|
+
// Group 6: JSX Properties (e.g., isLoading= / onClick=)
|
|
25
|
+
// Group 7: Numeric values
|
|
26
|
+
const tokenRegex = /(\/\/.*|\/\*[\s\S]*?\*\/)|('(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`)|(\b(?:export|import|const|let|var|function|return|if|else|switch|case|break|default|from|true|false|null|undefined|interface|type|extends|as|typeof|new|await|async|try|catch|finally|throw|class|implements|public|private|protected|readonly)\b)|(<\/?[A-Z][a-zA-Z0-9_]*\b)|(<\/?[a-z][a-zA-Z0-9_]*\b)|(\b[a-zA-Z0-9_]+(?=\s*=))|(\b\d+\b)/g;
|
|
27
|
+
|
|
28
|
+
return escaped.replace(tokenRegex, (match, comment, str, keyword, reactComp, htmlTag, jsxProp, num) => {
|
|
29
|
+
if (comment) {
|
|
30
|
+
return `<span class="text-text-muted/50 italic select-none">${match}</span>`;
|
|
31
|
+
}
|
|
32
|
+
if (str) {
|
|
33
|
+
return `<span class="text-amber-600 dark:text-amber-300 font-medium">${match}</span>`;
|
|
34
|
+
}
|
|
35
|
+
if (keyword) {
|
|
36
|
+
return `<span class="text-accent font-bold">${match}</span>`;
|
|
37
|
+
}
|
|
38
|
+
if (reactComp) {
|
|
39
|
+
return `<span class="text-pink-600 dark:text-pink-400 font-extrabold">${match}</span>`;
|
|
40
|
+
}
|
|
41
|
+
if (htmlTag) {
|
|
42
|
+
return `<span class="text-sky-600 dark:text-sky-400 font-bold">${match}</span>`;
|
|
43
|
+
}
|
|
44
|
+
if (jsxProp) {
|
|
45
|
+
return `<span class="text-purple-600 dark:text-purple-400 italic">${match}</span>`;
|
|
46
|
+
}
|
|
47
|
+
if (num) {
|
|
48
|
+
return `<span class="text-indigo-600 dark:text-indigo-400">${match}</span>`;
|
|
49
|
+
}
|
|
50
|
+
return match;
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const CodeVisualizer: React.FC<CodeVisualizerProps> = ({ code, className = '' }) => {
|
|
55
|
+
const highlightedHtml = useMemo(() => {
|
|
56
|
+
return highlightCode(code);
|
|
57
|
+
}, [code]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<pre className={`w-full h-full max-h-[500px] overflow-auto p-5 text-left font-mono text-xs text-text-main bg-bg-card/90 rounded-2xl leading-relaxed select-text border border-border-app/40 ${className}`}>
|
|
61
|
+
<code
|
|
62
|
+
className="block whitespace-pre select-all"
|
|
63
|
+
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
|
64
|
+
/>
|
|
65
|
+
</pre>
|
|
66
|
+
);
|
|
67
|
+
};
|