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,329 @@
|
|
|
1
|
+
import React, { useRef, useState, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight,
|
|
4
|
+
List, ListOrdered, Link, Image, Trash2
|
|
5
|
+
} from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
export interface TextEditorProps {
|
|
8
|
+
value?: string;
|
|
9
|
+
onChange?: (html: string) => void;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const TextEditor: React.FC<TextEditorProps> = ({
|
|
16
|
+
value = '',
|
|
17
|
+
onChange,
|
|
18
|
+
placeholder = 'Comienza a escribir aquí...',
|
|
19
|
+
disabled = false,
|
|
20
|
+
className = ''
|
|
21
|
+
}) => {
|
|
22
|
+
const editorRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
24
|
+
const [wordCount, setWordCount] = useState(0);
|
|
25
|
+
const [charCount, setCharCount] = useState(0);
|
|
26
|
+
const [activeFormats, setActiveFormats] = useState({
|
|
27
|
+
bold: false,
|
|
28
|
+
italic: false,
|
|
29
|
+
underline: false,
|
|
30
|
+
alignLeft: false,
|
|
31
|
+
alignCenter: false,
|
|
32
|
+
alignRight: false
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Sync value from parent if it changes and is different from editor's innerHTML
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (editorRef.current && editorRef.current.innerHTML !== value) {
|
|
38
|
+
editorRef.current.innerHTML = value;
|
|
39
|
+
updateCounts();
|
|
40
|
+
}
|
|
41
|
+
}, [value]);
|
|
42
|
+
|
|
43
|
+
const updateCounts = () => {
|
|
44
|
+
if (!editorRef.current) return;
|
|
45
|
+
const text = editorRef.current.innerText || '';
|
|
46
|
+
setCharCount(text.replace(/\n/g, '').length);
|
|
47
|
+
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
48
|
+
setWordCount(words.length);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const executeCommand = (command: string, arg: string = '') => {
|
|
52
|
+
if (disabled) return;
|
|
53
|
+
document.execCommand(command, false, arg);
|
|
54
|
+
if (editorRef.current) {
|
|
55
|
+
editorRef.current.focus();
|
|
56
|
+
}
|
|
57
|
+
checkActiveFormats();
|
|
58
|
+
handleInput();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleInput = () => {
|
|
62
|
+
if (!editorRef.current) return;
|
|
63
|
+
const html = editorRef.current.innerHTML;
|
|
64
|
+
updateCounts();
|
|
65
|
+
if (onChange) {
|
|
66
|
+
onChange(html);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const checkActiveFormats = () => {
|
|
71
|
+
setActiveFormats({
|
|
72
|
+
bold: document.queryCommandState('bold'),
|
|
73
|
+
italic: document.queryCommandState('italic'),
|
|
74
|
+
underline: document.queryCommandState('underline'),
|
|
75
|
+
alignLeft: document.queryCommandState('justifyLeft'),
|
|
76
|
+
alignCenter: document.queryCommandState('justifyCenter'),
|
|
77
|
+
alignRight: document.queryCommandState('justifyRight')
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const handleLinkInsert = () => {
|
|
82
|
+
const url = prompt('Ingresa la URL del enlace:');
|
|
83
|
+
if (url) {
|
|
84
|
+
executeCommand('createLink', url);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleImageInsert = () => {
|
|
89
|
+
const url = prompt('Ingresa la URL de la imagen:');
|
|
90
|
+
if (url) {
|
|
91
|
+
executeCommand('insertImage', url);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const handleFormatClear = () => {
|
|
96
|
+
executeCommand('removeFormat');
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className={`relative w-full flex flex-col bg-bg-card/40 border border-border-app/40 rounded-2xl overflow-hidden transition-all duration-300 ${
|
|
101
|
+
isFocused ? 'border-accent shadow-[0_0_12px_var(--color-accent)]' : ''
|
|
102
|
+
} ${disabled ? 'opacity-40 cursor-not-allowed select-none pointer-events-none' : ''} ${className}`}>
|
|
103
|
+
|
|
104
|
+
{/* Editor Toolbar */}
|
|
105
|
+
<div className="flex flex-wrap items-center gap-1.5 p-2 bg-bg-app/40 border-b border-border-app/30 select-none">
|
|
106
|
+
|
|
107
|
+
{/* Formatting actions */}
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={() => executeCommand('bold')}
|
|
111
|
+
className={`p-2 rounded-lg cursor-pointer transition-colors ${
|
|
112
|
+
activeFormats.bold ? 'bg-accent/20 text-accent font-black' : 'text-text-muted hover:text-text-main'
|
|
113
|
+
}`}
|
|
114
|
+
title="Negrita"
|
|
115
|
+
>
|
|
116
|
+
<Bold size={14} />
|
|
117
|
+
</button>
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => executeCommand('italic')}
|
|
121
|
+
className={`p-2 rounded-lg cursor-pointer transition-colors ${
|
|
122
|
+
activeFormats.italic ? 'bg-accent/20 text-accent font-black' : 'text-text-muted hover:text-text-main'
|
|
123
|
+
}`}
|
|
124
|
+
title="Cursiva"
|
|
125
|
+
>
|
|
126
|
+
<Italic size={14} />
|
|
127
|
+
</button>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={() => executeCommand('underline')}
|
|
131
|
+
className={`p-2 rounded-lg cursor-pointer transition-colors ${
|
|
132
|
+
activeFormats.underline ? 'bg-accent/20 text-accent font-black' : 'text-text-muted hover:text-text-main'
|
|
133
|
+
}`}
|
|
134
|
+
title="Subrayado"
|
|
135
|
+
>
|
|
136
|
+
<Underline size={14} />
|
|
137
|
+
</button>
|
|
138
|
+
|
|
139
|
+
<span className="w-[1px] h-4 bg-border-app/30 mx-1" />
|
|
140
|
+
|
|
141
|
+
{/* Alignment actions */}
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
onClick={() => executeCommand('justifyLeft')}
|
|
145
|
+
className={`p-2 rounded-lg cursor-pointer transition-colors ${
|
|
146
|
+
activeFormats.alignLeft ? 'bg-accent/20 text-accent' : 'text-text-muted hover:text-text-main'
|
|
147
|
+
}`}
|
|
148
|
+
title="Alinear Izquierda"
|
|
149
|
+
>
|
|
150
|
+
<AlignLeft size={14} />
|
|
151
|
+
</button>
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
onClick={() => executeCommand('justifyCenter')}
|
|
155
|
+
className={`p-2 rounded-lg cursor-pointer transition-colors ${
|
|
156
|
+
activeFormats.alignCenter ? 'bg-accent/20 text-accent' : 'text-text-muted hover:text-text-main'
|
|
157
|
+
}`}
|
|
158
|
+
title="Centrar"
|
|
159
|
+
>
|
|
160
|
+
<AlignCenter size={14} />
|
|
161
|
+
</button>
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
onClick={() => executeCommand('justifyRight')}
|
|
165
|
+
className={`p-2 rounded-lg cursor-pointer transition-colors ${
|
|
166
|
+
activeFormats.alignRight ? 'bg-accent/20 text-accent' : 'text-text-muted hover:text-text-main'
|
|
167
|
+
}`}
|
|
168
|
+
title="Alinear Derecha"
|
|
169
|
+
>
|
|
170
|
+
<AlignRight size={14} />
|
|
171
|
+
</button>
|
|
172
|
+
|
|
173
|
+
<span className="w-[1px] h-4 bg-border-app/30 mx-1" />
|
|
174
|
+
|
|
175
|
+
{/* Lists */}
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
onClick={() => executeCommand('insertUnorderedList')}
|
|
179
|
+
className="p-2 rounded-lg text-text-muted hover:text-text-main transition-colors cursor-pointer"
|
|
180
|
+
title="Lista Viñetas"
|
|
181
|
+
>
|
|
182
|
+
<List size={14} />
|
|
183
|
+
</button>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
onClick={() => executeCommand('insertOrderedList')}
|
|
187
|
+
className="p-2 rounded-lg text-text-muted hover:text-text-main transition-colors cursor-pointer"
|
|
188
|
+
title="Lista Numerada"
|
|
189
|
+
>
|
|
190
|
+
<ListOrdered size={14} />
|
|
191
|
+
</button>
|
|
192
|
+
|
|
193
|
+
<span className="w-[1px] h-4 bg-border-app/30 mx-1" />
|
|
194
|
+
|
|
195
|
+
{/* Fonts & Headers */}
|
|
196
|
+
<select
|
|
197
|
+
onChange={(e) => executeCommand('fontName', e.target.value)}
|
|
198
|
+
className="bg-transparent text-text-muted text-xs p-1.5 rounded-lg border border-border-app/30 cursor-pointer focus:outline-hidden"
|
|
199
|
+
title="Tipografía"
|
|
200
|
+
>
|
|
201
|
+
<option value="system-ui">Inter / System</option>
|
|
202
|
+
<option value="Georgia">Serif (Georgia)</option>
|
|
203
|
+
<option value="Courier New">Mono (Courier)</option>
|
|
204
|
+
</select>
|
|
205
|
+
|
|
206
|
+
<select
|
|
207
|
+
onChange={(e) => executeCommand('formatBlock', e.target.value)}
|
|
208
|
+
className="bg-transparent text-text-muted text-xs p-1.5 rounded-lg border border-border-app/30 cursor-pointer focus:outline-hidden"
|
|
209
|
+
title="Estilo de Bloque"
|
|
210
|
+
>
|
|
211
|
+
<option value="div">Normal</option>
|
|
212
|
+
<option value="h1">H1 / Título</option>
|
|
213
|
+
<option value="h2">H2 / Subtítulo</option>
|
|
214
|
+
<option value="blockquote">Cita</option>
|
|
215
|
+
</select>
|
|
216
|
+
|
|
217
|
+
<span className="w-[1px] h-4 bg-border-app/30 mx-1" />
|
|
218
|
+
|
|
219
|
+
{/* Media insertion & Reset */}
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
onClick={handleLinkInsert}
|
|
223
|
+
className="p-2 rounded-lg text-text-muted hover:text-accent transition-colors cursor-pointer"
|
|
224
|
+
title="Insertar Enlace"
|
|
225
|
+
>
|
|
226
|
+
<Link size={14} />
|
|
227
|
+
</button>
|
|
228
|
+
<button
|
|
229
|
+
type="button"
|
|
230
|
+
onClick={handleImageInsert}
|
|
231
|
+
className="p-2 rounded-lg text-text-muted hover:text-accent transition-colors cursor-pointer"
|
|
232
|
+
title="Insertar Imagen URL"
|
|
233
|
+
>
|
|
234
|
+
<Image size={14} />
|
|
235
|
+
</button>
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
onClick={handleFormatClear}
|
|
239
|
+
className="p-2 rounded-lg text-text-muted hover:text-red-500 transition-colors cursor-pointer"
|
|
240
|
+
title="Limpiar Formato"
|
|
241
|
+
>
|
|
242
|
+
<Trash2 size={14} />
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{/* Editor editable body */}
|
|
247
|
+
<div className="relative min-h-[140px] p-4 text-sm text-text-main flex-grow focus:outline-hidden">
|
|
248
|
+
|
|
249
|
+
{/* Custom HTML5 contentEditable box */}
|
|
250
|
+
<div
|
|
251
|
+
ref={editorRef}
|
|
252
|
+
contentEditable={!disabled}
|
|
253
|
+
onInput={handleInput}
|
|
254
|
+
onFocus={() => setIsFocused(true)}
|
|
255
|
+
onBlur={() => {
|
|
256
|
+
setIsFocused(false);
|
|
257
|
+
checkActiveFormats();
|
|
258
|
+
}}
|
|
259
|
+
onKeyUp={checkActiveFormats}
|
|
260
|
+
onMouseUp={checkActiveFormats}
|
|
261
|
+
className="w-full min-h-[120px] focus:outline-hidden leading-relaxed text-text-main editor-content-area"
|
|
262
|
+
style={{
|
|
263
|
+
minHeight: '120px'
|
|
264
|
+
}}
|
|
265
|
+
/>
|
|
266
|
+
|
|
267
|
+
{/* Placeholder label display */}
|
|
268
|
+
{charCount === 0 && (
|
|
269
|
+
<span className="absolute left-4 top-4 text-text-muted/65 pointer-events-none text-sm font-medium">
|
|
270
|
+
{placeholder}
|
|
271
|
+
</span>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{/* Status Footer bar showing statistics */}
|
|
276
|
+
<div className="flex items-center justify-between px-4 py-1.5 bg-bg-app/20 border-t border-border-app/20 select-none text-[10px] font-mono text-text-muted">
|
|
277
|
+
<span>Total: {charCount} caracteres</span>
|
|
278
|
+
<span>{wordCount} palabras</span>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
{/* Basic global styles for contentEditable styling anchors */}
|
|
282
|
+
<style>{`
|
|
283
|
+
.editor-content-area h1 {
|
|
284
|
+
font-size: 1.8rem;
|
|
285
|
+
font-weight: 800;
|
|
286
|
+
line-height: 1.25;
|
|
287
|
+
margin-bottom: 0.75rem;
|
|
288
|
+
color: var(--color-text-main);
|
|
289
|
+
}
|
|
290
|
+
.editor-content-area h2 {
|
|
291
|
+
font-size: 1.4rem;
|
|
292
|
+
font-weight: 700;
|
|
293
|
+
line-height: 1.3;
|
|
294
|
+
margin-bottom: 0.5rem;
|
|
295
|
+
color: var(--color-text-main);
|
|
296
|
+
}
|
|
297
|
+
.editor-content-area blockquote {
|
|
298
|
+
border-left: 3px solid var(--color-accent);
|
|
299
|
+
padding-left: 1rem;
|
|
300
|
+
color: var(--color-text-muted);
|
|
301
|
+
font-style: italic;
|
|
302
|
+
margin: 0.75rem 0;
|
|
303
|
+
}
|
|
304
|
+
.editor-content-area ul {
|
|
305
|
+
list-style-type: disc;
|
|
306
|
+
padding-left: 1.5rem;
|
|
307
|
+
margin: 0.5rem 0;
|
|
308
|
+
}
|
|
309
|
+
.editor-content-area ol {
|
|
310
|
+
list-style-type: decimal;
|
|
311
|
+
padding-left: 1.5rem;
|
|
312
|
+
margin: 0.5rem 0;
|
|
313
|
+
}
|
|
314
|
+
.editor-content-area a {
|
|
315
|
+
color: var(--color-accent);
|
|
316
|
+
text-decoration: underline;
|
|
317
|
+
font-weight: 600;
|
|
318
|
+
}
|
|
319
|
+
.editor-content-area img {
|
|
320
|
+
max-width: 100%;
|
|
321
|
+
border-radius: 0.5rem;
|
|
322
|
+
margin: 0.75rem 0;
|
|
323
|
+
display: block;
|
|
324
|
+
}
|
|
325
|
+
`}</style>
|
|
326
|
+
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import gsap from 'gsap';
|
|
3
|
+
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
|
4
|
+
|
|
5
|
+
// Register scroll trigger plugin
|
|
6
|
+
gsap.registerPlugin(ScrollTrigger);
|
|
7
|
+
|
|
8
|
+
export interface TextRevealProps {
|
|
9
|
+
text: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
mode?: 'scrub' | 'play';
|
|
12
|
+
duration?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const TextReveal: React.FC<TextRevealProps> = ({
|
|
16
|
+
text,
|
|
17
|
+
className = '',
|
|
18
|
+
mode = 'play',
|
|
19
|
+
duration = 0.8
|
|
20
|
+
}) => {
|
|
21
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
const wordsRef = useRef<HTMLSpanElement[]>([]);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!containerRef.current) return;
|
|
26
|
+
|
|
27
|
+
// Select all split spans
|
|
28
|
+
const targets = wordsRef.current.filter(Boolean);
|
|
29
|
+
|
|
30
|
+
if (targets.length === 0) return;
|
|
31
|
+
|
|
32
|
+
const ctx = gsap.context(() => {
|
|
33
|
+
if (mode === 'scrub') {
|
|
34
|
+
gsap.fromTo(
|
|
35
|
+
targets,
|
|
36
|
+
{
|
|
37
|
+
opacity: 0.1,
|
|
38
|
+
y: 10,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
opacity: 1,
|
|
42
|
+
y: 0,
|
|
43
|
+
stagger: 0.1,
|
|
44
|
+
scrollTrigger: {
|
|
45
|
+
trigger: containerRef.current,
|
|
46
|
+
start: 'top 85%',
|
|
47
|
+
end: 'top 50%',
|
|
48
|
+
scrub: true,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
} else {
|
|
53
|
+
gsap.fromTo(
|
|
54
|
+
targets,
|
|
55
|
+
{
|
|
56
|
+
opacity: 0,
|
|
57
|
+
y: 30,
|
|
58
|
+
filter: 'blur(4px)'
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
opacity: 1,
|
|
62
|
+
y: 0,
|
|
63
|
+
filter: 'blur(0px)',
|
|
64
|
+
duration: duration,
|
|
65
|
+
stagger: 0.05,
|
|
66
|
+
ease: 'power3.out',
|
|
67
|
+
scrollTrigger: {
|
|
68
|
+
trigger: containerRef.current,
|
|
69
|
+
start: 'top 85%',
|
|
70
|
+
toggleActions: 'play none none reverse',
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}, containerRef);
|
|
76
|
+
|
|
77
|
+
return () => ctx.revert(); // Cleanup GSAP animations
|
|
78
|
+
}, [text, mode, duration]);
|
|
79
|
+
|
|
80
|
+
const words = text.split(' ');
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div ref={containerRef} className={`inline-block ${className}`}>
|
|
84
|
+
<span className="flex flex-wrap gap-x-2 gap-y-1">
|
|
85
|
+
{words.map((word, idx) => (
|
|
86
|
+
<span
|
|
87
|
+
key={idx}
|
|
88
|
+
ref={(el) => {
|
|
89
|
+
if (el) wordsRef.current[idx] = el;
|
|
90
|
+
}}
|
|
91
|
+
className="inline-block origin-bottom transition-all"
|
|
92
|
+
>
|
|
93
|
+
{word}
|
|
94
|
+
</span>
|
|
95
|
+
))}
|
|
96
|
+
</span>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { Sun, Moon, Monitor, Check } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
6
|
+
|
|
7
|
+
export interface ThemeSwitcherProps {
|
|
8
|
+
className?: string;
|
|
9
|
+
onThemeChange?: (theme: ThemeMode) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className = '', onThemeChange }) => {
|
|
13
|
+
// Mismo valor en servidor y cliente; localStorage se lee tras el mount
|
|
14
|
+
const [theme, setTheme] = useState<ThemeMode>('system');
|
|
15
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
16
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const saved = localStorage.getItem('theme-mode') as ThemeMode | null;
|
|
20
|
+
if (saved === 'light' || saved === 'dark' || saved === 'system') {
|
|
21
|
+
setTheme(saved);
|
|
22
|
+
}
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
// Sync theme to document element
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const root = window.document.documentElement;
|
|
28
|
+
|
|
29
|
+
const applyTheme = (mode: ThemeMode) => {
|
|
30
|
+
root.classList.remove('dark', 'light');
|
|
31
|
+
|
|
32
|
+
if (mode === 'system') {
|
|
33
|
+
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
34
|
+
root.classList.add(systemPrefersDark ? 'dark' : 'light');
|
|
35
|
+
} else {
|
|
36
|
+
root.classList.add(mode);
|
|
37
|
+
}
|
|
38
|
+
localStorage.setItem('theme-mode', mode);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
applyTheme(theme);
|
|
42
|
+
|
|
43
|
+
// Watch system changes if set to system
|
|
44
|
+
if (theme === 'system') {
|
|
45
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
46
|
+
const handleSystemChange = () => applyTheme('system');
|
|
47
|
+
|
|
48
|
+
mediaQuery.addEventListener('change', handleSystemChange);
|
|
49
|
+
return () => mediaQuery.removeEventListener('change', handleSystemChange);
|
|
50
|
+
}
|
|
51
|
+
}, [theme, onThemeChange]);
|
|
52
|
+
|
|
53
|
+
// Click outside listener to close dropdown
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
56
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
57
|
+
setIsOpen(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
61
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const options = [
|
|
65
|
+
{ value: 'light', label: 'Claro', icon: <Sun className="w-4 h-4 text-amber-500" /> },
|
|
66
|
+
{ value: 'dark', label: 'Oscuro', icon: <Moon className="w-4 h-4 text-indigo-500" /> },
|
|
67
|
+
{ value: 'system', label: 'Sistema', icon: <Monitor className="w-4 h-4 text-sky-500" /> }
|
|
68
|
+
] as const;
|
|
69
|
+
|
|
70
|
+
const currentOption = options.find((opt) => opt.value === theme)!;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div ref={containerRef} className={`relative inline-block ${className}`}>
|
|
74
|
+
|
|
75
|
+
{/* Dropdown trigger button */}
|
|
76
|
+
<button
|
|
77
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
78
|
+
className="p-2.5 rounded-xl border border-border-app bg-bg-card text-text-muted hover:text-text-main hover:border-accent hover:shadow-[0_0_10px_rgba(99,102,241,0.2)] transition-all duration-300 cursor-pointer flex items-center gap-2"
|
|
79
|
+
aria-label="Seleccionar tema"
|
|
80
|
+
>
|
|
81
|
+
{currentOption.icon}
|
|
82
|
+
</button>
|
|
83
|
+
|
|
84
|
+
{/* Animated Dropdown Menu options */}
|
|
85
|
+
<AnimatePresence>
|
|
86
|
+
{isOpen && (
|
|
87
|
+
<motion.ul
|
|
88
|
+
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
89
|
+
animate={{
|
|
90
|
+
opacity: 1,
|
|
91
|
+
y: 6,
|
|
92
|
+
scale: 1,
|
|
93
|
+
transition: { type: 'spring', stiffness: 350, damping: 25 }
|
|
94
|
+
}}
|
|
95
|
+
exit={{
|
|
96
|
+
opacity: 0,
|
|
97
|
+
y: 10,
|
|
98
|
+
scale: 0.95,
|
|
99
|
+
transition: { duration: 0.15 }
|
|
100
|
+
}}
|
|
101
|
+
className="absolute right-0 w-36 glass rounded-xl shadow-2xl py-1 z-50 p-1 flex flex-col gap-0.5"
|
|
102
|
+
>
|
|
103
|
+
{options.map((opt) => {
|
|
104
|
+
const isSelected = opt.value === theme;
|
|
105
|
+
return (
|
|
106
|
+
<li
|
|
107
|
+
key={opt.value}
|
|
108
|
+
onClick={() => {
|
|
109
|
+
setTheme(opt.value);
|
|
110
|
+
setIsOpen(false);
|
|
111
|
+
if (onThemeChange) onThemeChange(opt.value);
|
|
112
|
+
}}
|
|
113
|
+
className={`px-3 py-2 text-xs font-bold rounded-lg cursor-pointer flex items-center justify-between transition-colors duration-150 ${
|
|
114
|
+
isSelected
|
|
115
|
+
? 'bg-accent/10 text-accent'
|
|
116
|
+
: 'text-text-main hover:bg-bg-app'
|
|
117
|
+
}`}
|
|
118
|
+
>
|
|
119
|
+
<div className="flex items-center gap-2">
|
|
120
|
+
{opt.icon}
|
|
121
|
+
<span>{opt.label}</span>
|
|
122
|
+
</div>
|
|
123
|
+
{isSelected && <Check className="w-3.5 h-3.5" />}
|
|
124
|
+
</li>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</motion.ul>
|
|
128
|
+
)}
|
|
129
|
+
</AnimatePresence>
|
|
130
|
+
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
};
|