alexui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +57 -0
  2. package/components/ActionTable.tsx +307 -0
  3. package/components/AlertBanner.tsx +124 -0
  4. package/components/AnimatedAccordion.tsx +95 -0
  5. package/components/Autocomplete.tsx +144 -0
  6. package/components/Avatar.tsx +123 -0
  7. package/components/Badge.tsx +80 -0
  8. package/components/Breadcrumb.tsx +74 -0
  9. package/components/Calendar.tsx +340 -0
  10. package/components/Card3D.tsx +117 -0
  11. package/components/Carousel3D.tsx +193 -0
  12. package/components/CascadeSelect.tsx +232 -0
  13. package/components/ChartShowcase.tsx +700 -0
  14. package/components/Checkbox.tsx +212 -0
  15. package/components/ChipsInput.tsx +152 -0
  16. package/components/CircularKnob.tsx +240 -0
  17. package/components/CodeVisualizer.tsx +67 -0
  18. package/components/Collapsible.tsx +72 -0
  19. package/components/ColorThemeManager.tsx +458 -0
  20. package/components/CommandMenu.tsx +191 -0
  21. package/components/ConfirmDialog.tsx +152 -0
  22. package/components/ContextMenu.tsx +192 -0
  23. package/components/DashboardLayout.tsx +115 -0
  24. package/components/DatePicker.tsx +108 -0
  25. package/components/Divider.tsx +67 -0
  26. package/components/Dock.tsx +93 -0
  27. package/components/DragDropLists.tsx +160 -0
  28. package/components/Drawer.tsx +161 -0
  29. package/components/DropdownPlus.tsx +304 -0
  30. package/components/EmptyState.tsx +49 -0
  31. package/components/ErrorPage.tsx +62 -0
  32. package/components/FileDropzone.tsx +206 -0
  33. package/components/ForgotPassword.tsx +137 -0
  34. package/components/FormField.tsx +81 -0
  35. package/components/GlassButton.tsx +56 -0
  36. package/components/GlassCard.tsx +82 -0
  37. package/components/GlassInput.tsx +96 -0
  38. package/components/GlassmorphicModal.tsx +108 -0
  39. package/components/GlowInput.tsx +111 -0
  40. package/components/GlowSelect.tsx +203 -0
  41. package/components/GlowTextArea.tsx +105 -0
  42. package/components/HorizontalTimeline.tsx +121 -0
  43. package/components/HoverCard.tsx +105 -0
  44. package/components/ImageLightbox.tsx +259 -0
  45. package/components/InputGroup.tsx +118 -0
  46. package/components/InputOTP.tsx +147 -0
  47. package/components/InteractiveNavbar.tsx +266 -0
  48. package/components/InteractiveSidebar.tsx +211 -0
  49. package/components/Kbd.tsx +51 -0
  50. package/components/LiteYouTube.tsx +118 -0
  51. package/components/LoaderCollection.tsx +368 -0
  52. package/components/LoginForm.tsx +192 -0
  53. package/components/MagneticButton.tsx +101 -0
  54. package/components/MaskedInput.tsx +79 -0
  55. package/components/MentionInput.tsx +413 -0
  56. package/components/MorphingSwitch.tsx +86 -0
  57. package/components/MultiSelect.tsx +158 -0
  58. package/components/NumberInput.tsx +203 -0
  59. package/components/Panel.tsx +104 -0
  60. package/components/PasswordInput.tsx +203 -0
  61. package/components/Popover.tsx +91 -0
  62. package/components/PricingTable.tsx +113 -0
  63. package/components/ProgressBar.tsx +152 -0
  64. package/components/RadioButton.tsx +211 -0
  65. package/components/Rating.tsx +82 -0
  66. package/components/ResizablePanel.tsx +114 -0
  67. package/components/ScrollPanel.tsx +103 -0
  68. package/components/SettingsPage.tsx +154 -0
  69. package/components/SignupForm.tsx +182 -0
  70. package/components/Skeleton.tsx +41 -0
  71. package/components/Slider.tsx +95 -0
  72. package/components/SlidingTabs.tsx +54 -0
  73. package/components/SortableList.tsx +91 -0
  74. package/components/SpeedDial.tsx +134 -0
  75. package/components/Spinner.tsx +40 -0
  76. package/components/Stepper.tsx +124 -0
  77. package/components/TabMenu.tsx +72 -0
  78. package/components/TableControls.tsx +77 -0
  79. package/components/TablePagination.tsx +88 -0
  80. package/components/TextEditor.tsx +329 -0
  81. package/components/TextReveal.tsx +99 -0
  82. package/components/ThemeSwitcher.tsx +133 -0
  83. package/components/TimelineGSAP.tsx +164 -0
  84. package/components/ToastSystem.tsx +110 -0
  85. package/components/ToggleButton.tsx +79 -0
  86. package/components/Tooltip.tsx +121 -0
  87. package/components/Tree.tsx +138 -0
  88. package/dist/commands/add.d.ts +7 -0
  89. package/dist/commands/add.js +110 -0
  90. package/dist/commands/init.d.ts +5 -0
  91. package/dist/commands/init.js +76 -0
  92. package/dist/commands/list.d.ts +1 -0
  93. package/dist/commands/list.js +32 -0
  94. package/dist/index.d.ts +2 -0
  95. package/dist/index.js +60 -0
  96. package/dist/registry.d.ts +6 -0
  97. package/dist/registry.js +38 -0
  98. package/dist/tui/browse.d.ts +3 -0
  99. package/dist/tui/browse.js +139 -0
  100. package/dist/tui/format.d.ts +11 -0
  101. package/dist/tui/format.js +52 -0
  102. package/dist/tui/main.d.ts +1 -0
  103. package/dist/tui/main.js +86 -0
  104. package/dist/tui/panels.d.ts +9 -0
  105. package/dist/tui/panels.js +50 -0
  106. package/dist/tui/theme.d.ts +28 -0
  107. package/dist/tui/theme.js +76 -0
  108. package/dist/types.d.ts +28 -0
  109. package/dist/types.js +1 -0
  110. package/dist/utils/config.d.ts +6 -0
  111. package/dist/utils/config.js +24 -0
  112. package/dist/utils/copy.d.ts +9 -0
  113. package/dist/utils/copy.js +43 -0
  114. package/dist/utils/cwd.d.ts +6 -0
  115. package/dist/utils/cwd.js +30 -0
  116. package/dist/utils/deps.d.ts +1 -0
  117. package/dist/utils/deps.js +19 -0
  118. package/dist/utils/project.d.ts +5 -0
  119. package/dist/utils/project.js +30 -0
  120. package/dist/utils/theme.d.ts +1 -0
  121. package/dist/utils/theme.js +24 -0
  122. package/package.json +52 -0
  123. package/registry.json +1133 -0
  124. package/templates/theme.css +81 -0
@@ -0,0 +1,340 @@
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { ChevronLeft, ChevronRight, X } from 'lucide-react';
4
+
5
+ export interface CalendarProps {
6
+ value?: Date | null;
7
+ onChange?: (date: Date | null) => void;
8
+ selectsRange?: boolean;
9
+ startDate?: Date | null;
10
+ endDate?: Date | null;
11
+ onChangeRange?: (start: Date | null, end: Date | null) => void;
12
+ minDate?: Date;
13
+ maxDate?: Date;
14
+ excludeDates?: Date[];
15
+ disabled?: boolean;
16
+ className?: string;
17
+ }
18
+
19
+ const WEEKDAYS = ['Dom', 'Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab'];
20
+ const MONTHS = [
21
+ 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
22
+ 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
23
+ ];
24
+
25
+ export const Calendar: React.FC<CalendarProps> = ({
26
+ value = null,
27
+ onChange,
28
+ selectsRange = false,
29
+ startDate = null,
30
+ endDate = null,
31
+ onChangeRange,
32
+ minDate,
33
+ maxDate,
34
+ excludeDates = [],
35
+ disabled = false,
36
+ className = ''
37
+ }) => {
38
+ const [currentDate, setCurrentDate] = useState(() => {
39
+ if (selectsRange && startDate) return new Date(startDate);
40
+ if (value) return new Date(value);
41
+ return new Date();
42
+ });
43
+
44
+ const [viewMode, setViewMode] = useState<'days' | 'months' | 'years'>('days');
45
+
46
+ const currentYear = currentDate.getFullYear();
47
+ const currentMonth = currentDate.getMonth();
48
+
49
+ const handlePrevMonth = () => {
50
+ if (disabled) return;
51
+ setCurrentDate(new Date(currentYear, currentMonth - 1, 1));
52
+ };
53
+
54
+ const handleNextMonth = () => {
55
+ if (disabled) return;
56
+ setCurrentDate(new Date(currentYear, currentMonth + 1, 1));
57
+ };
58
+
59
+ const getDaysInMonth = (year: number, month: number) => {
60
+ return new Date(year, month + 1, 0).getDate();
61
+ };
62
+
63
+ const getFirstDayOfMonth = (year: number, month: number) => {
64
+ return new Date(year, month, 1).getDay();
65
+ };
66
+
67
+ const isDateBlocked = (date: Date) => {
68
+ if (minDate && new Date(date.getFullYear(), date.getMonth(), date.getDate()) < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) return true;
69
+ if (maxDate && new Date(date.getFullYear(), date.getMonth(), date.getDate()) > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate())) return true;
70
+ return excludeDates.some(
71
+ (d) => d.getDate() === date.getDate() &&
72
+ d.getMonth() === date.getMonth() &&
73
+ d.getFullYear() === date.getFullYear()
74
+ );
75
+ };
76
+
77
+ const isSameDay = (d1: Date | null, d2: Date | null) => {
78
+ if (!d1 || !d2) return false;
79
+ return d1.getDate() === d2.getDate() &&
80
+ d1.getMonth() === d2.getMonth() &&
81
+ d1.getFullYear() === d2.getFullYear();
82
+ };
83
+
84
+ const isBetweenDays = (date: Date, start: Date | null, end: Date | null) => {
85
+ if (!start || !end) return false;
86
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
87
+ const s = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime();
88
+ const e = new Date(end.getFullYear(), end.getMonth(), end.getDate()).getTime();
89
+ return d > s && d < e;
90
+ };
91
+
92
+ const handleDayClick = (day: number, isCurrentMonth: 'prev' | 'current' | 'next') => {
93
+ if (disabled) return;
94
+
95
+ let clickedDate: Date;
96
+ if (isCurrentMonth === 'prev') {
97
+ clickedDate = new Date(currentYear, currentMonth - 1, day);
98
+ } else if (isCurrentMonth === 'next') {
99
+ clickedDate = new Date(currentYear, currentMonth + 1, day);
100
+ } else {
101
+ clickedDate = new Date(currentYear, currentMonth, day);
102
+ }
103
+
104
+ if (isDateBlocked(clickedDate)) return;
105
+
106
+ if (selectsRange) {
107
+ if (!startDate || (startDate && endDate)) {
108
+ if (onChangeRange) onChangeRange(clickedDate, null);
109
+ } else {
110
+ if (clickedDate < startDate) {
111
+ if (onChangeRange) onChangeRange(clickedDate, null);
112
+ } else {
113
+ if (onChangeRange) onChangeRange(startDate, clickedDate);
114
+ }
115
+ }
116
+ } else {
117
+ if (onChange) onChange(clickedDate);
118
+ }
119
+ };
120
+
121
+ // Generate 42 cells (6 rows * 7 days)
122
+ const renderCells = () => {
123
+ const firstDayIndex = getFirstDayOfMonth(currentYear, currentMonth);
124
+ const daysInCurrentMonth = getDaysInMonth(currentYear, currentMonth);
125
+ const daysInPrevMonth = getDaysInMonth(currentYear, currentMonth - 1);
126
+
127
+ const cells: React.ReactNode[] = [];
128
+
129
+ // Prev month days
130
+ for (let i = firstDayIndex - 1; i >= 0; i--) {
131
+ const day = daysInPrevMonth - i;
132
+ const date = new Date(currentYear, currentMonth - 1, day);
133
+ const blocked = isDateBlocked(date);
134
+ cells.push(
135
+ <button
136
+ key={`prev-${day}`}
137
+ onClick={() => handleDayClick(day, 'prev')}
138
+ disabled={blocked || disabled}
139
+ type="button"
140
+ className={`h-9 w-9 text-xs text-text-muted/40 font-medium rounded-lg hover:bg-white/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed`}
141
+ >
142
+ {day}
143
+ </button>
144
+ );
145
+ }
146
+
147
+ // Current month days
148
+ for (let day = 1; day <= daysInCurrentMonth; day++) {
149
+ const date = new Date(currentYear, currentMonth, day);
150
+ const isSelected = selectsRange
151
+ ? isSameDay(date, startDate) || isSameDay(date, endDate)
152
+ : isSameDay(date, value);
153
+ const isInRange = selectsRange && isBetweenDays(date, startDate, endDate);
154
+ const isStart = selectsRange && isSameDay(date, startDate);
155
+ const isEnd = selectsRange && isSameDay(date, endDate);
156
+ const blocked = isDateBlocked(date);
157
+ const isToday = isSameDay(date, new Date());
158
+
159
+ cells.push(
160
+ <button
161
+ key={`curr-${day}`}
162
+ onClick={() => handleDayClick(day, 'current')}
163
+ disabled={blocked || disabled}
164
+ type="button"
165
+ className={`h-9 w-9 text-xs font-bold rounded-lg relative transition-all duration-200 cursor-pointer select-none disabled:opacity-25 disabled:cursor-not-allowed ${
166
+ isSelected
167
+ ? 'bg-accent text-white shadow-[0_0_12px_var(--color-accent)]'
168
+ : isInRange
169
+ ? 'bg-accent/15 text-accent border border-accent/20 rounded-none first:rounded-l-lg last:rounded-r-lg'
170
+ : 'text-text-main hover:bg-white/5'
171
+ } ${isToday && !isSelected ? 'border border-accent/40 text-accent font-extrabold' : ''}`}
172
+ >
173
+ {day}
174
+ {isStart && endDate && (
175
+ <div className="absolute right-0 top-0 bottom-0 w-1/2 bg-accent/10 -z-10" />
176
+ )}
177
+ {isEnd && startDate && (
178
+ <div className="absolute left-0 top-0 bottom-0 w-1/2 bg-accent/10 -z-10" />
179
+ )}
180
+ </button>
181
+ );
182
+ }
183
+
184
+ // Next month days to complete 42 cells
185
+ const remainingCells = 42 - cells.length;
186
+ for (let day = 1; day <= remainingCells; day++) {
187
+ const date = new Date(currentYear, currentMonth + 1, day);
188
+ const blocked = isDateBlocked(date);
189
+ cells.push(
190
+ <button
191
+ key={`next-${day}`}
192
+ onClick={() => handleDayClick(day, 'next')}
193
+ disabled={blocked || disabled}
194
+ type="button"
195
+ className={`h-9 w-9 text-xs text-text-muted/40 font-medium rounded-lg hover:bg-white/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed`}
196
+ >
197
+ {day}
198
+ </button>
199
+ );
200
+ }
201
+
202
+ return cells;
203
+ };
204
+
205
+ const handleMonthSelect = (mIdx: number) => {
206
+ setCurrentDate(new Date(currentYear, mIdx, 1));
207
+ setViewMode('days');
208
+ };
209
+
210
+ const handleYearSelect = (year: number) => {
211
+ setCurrentDate(new Date(year, currentMonth, 1));
212
+ setViewMode('days');
213
+ };
214
+
215
+ const renderMonthGrid = () => {
216
+ return (
217
+ <div className="grid grid-cols-3 gap-2 py-4">
218
+ {MONTHS.map((m, idx) => (
219
+ <button
220
+ key={m}
221
+ type="button"
222
+ onClick={() => handleMonthSelect(idx)}
223
+ className={`py-3 text-xs font-bold rounded-xl hover:bg-white/5 transition-all text-text-main cursor-pointer ${
224
+ currentMonth === idx ? 'bg-accent/20 text-accent border border-accent/30' : ''
225
+ }`}
226
+ >
227
+ {m}
228
+ </button>
229
+ ))}
230
+ </div>
231
+ );
232
+ };
233
+
234
+ const renderYearGrid = () => {
235
+ const years: number[] = [];
236
+ const baseYear = Math.floor(currentYear / 12) * 12;
237
+ for (let i = -1; i < 11; i++) {
238
+ years.push(baseYear + i);
239
+ }
240
+
241
+ return (
242
+ <div className="grid grid-cols-3 gap-2 py-4">
243
+ {years.map((y) => (
244
+ <button
245
+ key={y}
246
+ type="button"
247
+ onClick={() => handleYearSelect(y)}
248
+ className={`py-3 text-xs font-bold rounded-xl hover:bg-white/5 transition-all text-text-main cursor-pointer ${
249
+ currentYear === y ? 'bg-accent/20 text-accent border border-accent/30' : ''
250
+ }`}
251
+ >
252
+ {y}
253
+ </button>
254
+ ))}
255
+ </div>
256
+ );
257
+ };
258
+
259
+ return (
260
+ <div className={`p-4 bg-bg-card/40 border border-border-app/40 rounded-2xl w-[290px] select-none ${
261
+ disabled ? 'opacity-40 pointer-events-none' : ''
262
+ } ${className}`}>
263
+
264
+ {/* Calendar Header */}
265
+ <div className="flex items-center justify-between pb-3 border-b border-border-app/30">
266
+ <div className="flex items-center gap-1.5">
267
+ <button
268
+ type="button"
269
+ onClick={() => setViewMode(viewMode === 'months' ? 'days' : 'months')}
270
+ className="text-xs font-black text-text-main hover:text-accent cursor-pointer transition-colors"
271
+ >
272
+ {MONTHS[currentMonth]}
273
+ </button>
274
+ <button
275
+ type="button"
276
+ onClick={() => setViewMode(viewMode === 'years' ? 'days' : 'years')}
277
+ className="text-xs font-mono font-bold text-text-muted hover:text-accent cursor-pointer transition-colors"
278
+ >
279
+ {currentYear}
280
+ </button>
281
+ </div>
282
+
283
+ {viewMode === 'days' && (
284
+ <div className="flex items-center gap-1">
285
+ <button
286
+ type="button"
287
+ onClick={handlePrevMonth}
288
+ className="p-1 rounded-lg hover:bg-white/5 text-text-muted hover:text-text-main transition-colors cursor-pointer"
289
+ >
290
+ <ChevronLeft size={16} />
291
+ </button>
292
+ <button
293
+ type="button"
294
+ onClick={handleNextMonth}
295
+ className="p-1 rounded-lg hover:bg-white/5 text-text-muted hover:text-text-main transition-colors cursor-pointer"
296
+ >
297
+ <ChevronRight size={16} />
298
+ </button>
299
+ </div>
300
+ )}
301
+ </div>
302
+
303
+ {/* Calendar Body */}
304
+ {viewMode === 'days' && (
305
+ <>
306
+ <div className="grid grid-cols-7 gap-1 text-center py-2">
307
+ {WEEKDAYS.map((day) => (
308
+ <span key={day} className="text-[10px] font-black uppercase text-text-muted/60 tracking-wider">
309
+ {day}
310
+ </span>
311
+ ))}
312
+ </div>
313
+ <div className="grid grid-cols-7 gap-1 text-center">
314
+ {renderCells()}
315
+ </div>
316
+ </>
317
+ )}
318
+
319
+ {viewMode === 'months' && renderMonthGrid()}
320
+ {viewMode === 'years' && renderYearGrid()}
321
+
322
+ {/* Clear/Reset Range support */}
323
+ {selectsRange && (startDate || endDate) && (
324
+ <div className="mt-3 pt-2 border-t border-border-app/30 flex justify-between items-center">
325
+ <span className="text-[9px] font-mono text-text-muted">
326
+ {startDate ? `${startDate.getDate()}/${startDate.getMonth() + 1}` : ''}
327
+ {endDate ? ` - ${endDate.getDate()}/${endDate.getMonth() + 1}` : ''}
328
+ </span>
329
+ <button
330
+ type="button"
331
+ onClick={() => onChangeRange && onChangeRange(null, null)}
332
+ className="text-[10px] font-bold text-red-500 hover:text-red-400 cursor-pointer transition-colors flex items-center gap-1"
333
+ >
334
+ <X size={10} /> Limpiar
335
+ </button>
336
+ </div>
337
+ )}
338
+ </div>
339
+ );
340
+ };
@@ -0,0 +1,117 @@
1
+ import React, { useRef } from 'react';
2
+ import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
3
+ import { Skeleton } from './Skeleton';
4
+
5
+ export interface Card3DProps {
6
+ children: React.ReactNode;
7
+ className?: string;
8
+ glowColor?: string; // e.g. "rgba(99,102,241,0.15)"
9
+ isLoading?: boolean;
10
+ }
11
+
12
+ export const Card3D: React.FC<Card3DProps> = ({ children, className = '', glowColor = 'rgba(99, 102, 241, 0.2)', isLoading = false }) => {
13
+ const cardRef = useRef<HTMLDivElement>(null);
14
+
15
+ // Track relative cursor position inside the card (-0.5 to 0.5 range)
16
+ const rotateX = useMotionValue(0);
17
+ const rotateY = useMotionValue(0);
18
+
19
+ // Spotlight position
20
+ const glowX = useMotionValue(0);
21
+ const glowY = useMotionValue(0);
22
+ const glowOpacity = useMotionValue(0);
23
+
24
+ // Smooth springs for tilt
25
+ const springX = useSpring(rotateX, { stiffness: 150, damping: 20 });
26
+ const springY = useSpring(rotateY, { stiffness: 150, damping: 20 });
27
+
28
+ const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
29
+ if (!cardRef.current) return;
30
+
31
+ const rect = cardRef.current.getBoundingClientRect();
32
+ const width = rect.width;
33
+ const height = rect.height;
34
+
35
+ // Position of cursor relative to element
36
+ const mouseX = e.clientX - rect.left;
37
+ const mouseY = e.clientY - rect.top;
38
+
39
+ // Convert to percentage offsets (-0.5 to 0.5)
40
+ const xPct = (mouseX / width) - 0.5;
41
+ const yPct = (mouseY / height) - 0.5;
42
+
43
+ // Tilt limits: maximum rotate angle
44
+ rotateX.set(-yPct * 20); // Tilt up/down
45
+ rotateY.set(xPct * 20); // Tilt left/right
46
+
47
+ // Glow coordinates
48
+ glowX.set(mouseX);
49
+ glowY.set(mouseY);
50
+ glowOpacity.set(1);
51
+ };
52
+
53
+ const handleMouseLeave = () => {
54
+ rotateX.set(0);
55
+ rotateY.set(0);
56
+ glowOpacity.set(0);
57
+ };
58
+
59
+ return (
60
+ <div
61
+ style={{ perspective: 1000 }}
62
+ className="inline-block"
63
+ >
64
+ <motion.div
65
+ ref={cardRef}
66
+ onMouseMove={handleMouseMove}
67
+ onMouseLeave={handleMouseLeave}
68
+ style={{
69
+ rotateX: springX,
70
+ rotateY: springY,
71
+ transformStyle: 'preserve-3d',
72
+ }}
73
+ className={`relative overflow-hidden rounded-2xl border border-border-app bg-bg-card p-6 shadow-lg transition-colors duration-300 select-none ${className}`}
74
+ >
75
+ {/* Dynamic Glow Layer */}
76
+ <motion.div
77
+ style={{
78
+ position: 'absolute',
79
+ top: 0,
80
+ left: 0,
81
+ width: '100%',
82
+ height: '100%',
83
+ pointerEvents: 'none',
84
+ background: useTransform(
85
+ [glowX, glowY, glowOpacity],
86
+ ([x, y]) => `radial-gradient(400px circle at ${x}px ${y}px, ${glowColor}, transparent 80%)`
87
+ ),
88
+ opacity: glowOpacity,
89
+ }}
90
+ className="z-10 transition-opacity duration-300"
91
+ />
92
+
93
+ {/* Card Contents (can utilize transformZ to lift objects) */}
94
+ <div style={{ transform: 'translateZ(30px)', transformStyle: 'preserve-3d' }} className="relative z-20 w-full h-full">
95
+ {isLoading ? (
96
+ <div className="flex flex-col h-full justify-between gap-4">
97
+ <div>
98
+ <div className="flex items-center justify-between mb-4">
99
+ <Skeleton variant="text" className="w-16 h-3" />
100
+ <Skeleton variant="circle" className="w-4 h-4" />
101
+ </div>
102
+ <Skeleton variant="text" className="w-3/4 h-5 mb-2" />
103
+ <Skeleton variant="text" className="w-1/2 h-3" />
104
+ </div>
105
+ <div className="flex items-center justify-between mt-4 border-t border-border-app/40 pt-3">
106
+ <Skeleton variant="text" className="w-12 h-3" />
107
+ <Skeleton variant="circle" className="w-6 h-6" />
108
+ </div>
109
+ </div>
110
+ ) : (
111
+ children
112
+ )}
113
+ </div>
114
+ </motion.div>
115
+ </div>
116
+ );
117
+ };
@@ -0,0 +1,193 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
4
+
5
+ export interface Carousel3DProps {
6
+ items: React.ReactNode[];
7
+ autoPlay?: boolean;
8
+ interval?: number;
9
+ className?: string;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ function getRelativeOffset(index: number, current: number, length: number): number {
14
+ let offset = index - current;
15
+ const half = Math.floor(length / 2);
16
+ if (offset > half) offset -= length;
17
+ if (offset < -half) offset += length;
18
+ return offset;
19
+ }
20
+
21
+ function getItemStyle(offset: number, length: number) {
22
+ const isCenter = offset === 0;
23
+ const absOffset = Math.abs(offset);
24
+
25
+ return {
26
+ zIndex: length - absOffset,
27
+ scale: isCenter ? 1 : Math.max(0.62, 1 - absOffset * 0.14),
28
+ opacity: isCenter ? 1 : Math.max(0.2, 0.85 - absOffset * 0.3),
29
+ x: `${offset * 42}%`,
30
+ rotateY: Math.max(-35, Math.min(35, offset * -28)),
31
+ translateZ: isCenter ? 0 : -70 - absOffset * 55,
32
+ filter: isCenter ? 'blur(0px)' : `blur(${Math.min(3, absOffset * 1.2)}px)`,
33
+ };
34
+ }
35
+
36
+ export const Carousel3D: React.FC<Carousel3DProps> = ({
37
+ items,
38
+ autoPlay = false,
39
+ interval = 4000,
40
+ className = '',
41
+ disabled = false,
42
+ }) => {
43
+ const [currentIndex, setCurrentIndex] = useState(0);
44
+ const containerRef = useRef<HTMLDivElement>(null);
45
+
46
+ const goNext = useCallback(() => {
47
+ if (disabled || items.length === 0) return;
48
+ setCurrentIndex((prev) => (prev + 1) % items.length);
49
+ }, [disabled, items.length]);
50
+
51
+ const goPrev = useCallback(() => {
52
+ if (disabled || items.length === 0) return;
53
+ setCurrentIndex((prev) => (prev - 1 + items.length) % items.length);
54
+ }, [disabled, items.length]);
55
+
56
+ useEffect(() => {
57
+ if (!autoPlay || disabled || items.length < 2) return;
58
+ const timer = window.setInterval(goNext, interval);
59
+ return () => window.clearInterval(timer);
60
+ }, [autoPlay, disabled, goNext, interval, items.length]);
61
+
62
+ const handleKeyDown = (event: React.KeyboardEvent) => {
63
+ if (disabled) return;
64
+ if (event.key === 'ArrowRight') {
65
+ event.preventDefault();
66
+ goNext();
67
+ }
68
+ if (event.key === 'ArrowLeft') {
69
+ event.preventDefault();
70
+ goPrev();
71
+ }
72
+ };
73
+
74
+ const itemStyles = useMemo(
75
+ () => items.map((_, index) => getItemStyle(getRelativeOffset(index, currentIndex, items.length), items.length)),
76
+ [items, currentIndex],
77
+ );
78
+
79
+ if (items.length === 0) {
80
+ return (
81
+ <div className={`flex items-center justify-center h-[400px] text-text-muted text-sm ${className}`}>
82
+ Sin elementos en el carrusel
83
+ </div>
84
+ );
85
+ }
86
+
87
+ return (
88
+ <div
89
+ ref={containerRef}
90
+ tabIndex={disabled ? -1 : 0}
91
+ onKeyDown={handleKeyDown}
92
+ role="region"
93
+ aria-roledescription="carousel"
94
+ aria-label="Carrusel 3D"
95
+ className={`relative w-full max-w-4xl mx-auto h-[420px] flex flex-col items-center justify-center outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded-2xl ${className}`}
96
+ >
97
+ <div className="relative w-full max-w-md h-[300px] flex items-center justify-center perspective-[1200px]">
98
+ <div className="relative w-full h-full transform-style-3d">
99
+ <AnimatePresence initial={false}>
100
+ {items.map((item, index) => {
101
+ const offset = getRelativeOffset(index, currentIndex, items.length);
102
+ const styles = itemStyles[index];
103
+ const isCenter = offset === 0;
104
+
105
+ return (
106
+ <motion.div
107
+ key={index}
108
+ className={`absolute inset-0 select-none ${disabled ? 'cursor-not-allowed' : isCenter ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'}`}
109
+ onClick={() => {
110
+ if (disabled || isCenter) return;
111
+ setCurrentIndex(index);
112
+ }}
113
+ drag={isCenter && !disabled ? 'x' : false}
114
+ dragConstraints={{ left: 0, right: 0 }}
115
+ dragElastic={0.15}
116
+ onDragEnd={(_event, info) => {
117
+ if (disabled) return;
118
+ if (info.offset.x < -50) goNext();
119
+ else if (info.offset.x > 50) goPrev();
120
+ }}
121
+ whileHover={!disabled && isCenter ? { scale: 1.02 } : undefined}
122
+ animate={{
123
+ x: styles.x,
124
+ scale: styles.scale,
125
+ opacity: styles.opacity,
126
+ rotateY: styles.rotateY,
127
+ z: styles.translateZ,
128
+ zIndex: styles.zIndex,
129
+ filter: styles.filter,
130
+ }}
131
+ transition={{ type: 'spring', stiffness: 260, damping: 28 }}
132
+ style={{ transformStyle: 'preserve-3d' }}
133
+ >
134
+ <div
135
+ className="absolute -bottom-8 left-1/2 w-[80%] h-5 rounded-full bg-black/40 pointer-events-none"
136
+ style={{
137
+ opacity: isCenter ? 0.55 : Math.max(0.08, 0.5 - Math.abs(offset) * 0.15),
138
+ transform: `translateX(-50%) scale(${isCenter ? 1 : 0.75 - Math.abs(offset) * 0.08})`,
139
+ filter: `blur(${isCenter ? 8 : 5}px)`,
140
+ }}
141
+ />
142
+ <div className="w-full h-full">{item}</div>
143
+ </motion.div>
144
+ );
145
+ })}
146
+ </AnimatePresence>
147
+ </div>
148
+ </div>
149
+
150
+ <div className="absolute top-1/2 left-0 right-0 flex justify-between px-2 -translate-y-1/2 pointer-events-none z-50">
151
+ <button
152
+ type="button"
153
+ onClick={goPrev}
154
+ disabled={disabled}
155
+ aria-label="Anterior"
156
+ className="w-11 h-11 rounded-full glass bg-bg-card/60 border border-border-app flex items-center justify-center pointer-events-auto hover:bg-accent/20 hover:text-accent transition-colors disabled:opacity-40 shadow-lg"
157
+ >
158
+ <ChevronLeft className="w-5 h-5" />
159
+ </button>
160
+ <button
161
+ type="button"
162
+ onClick={goNext}
163
+ disabled={disabled}
164
+ aria-label="Siguiente"
165
+ className="w-11 h-11 rounded-full glass bg-bg-card/60 border border-border-app flex items-center justify-center pointer-events-auto hover:bg-accent/20 hover:text-accent transition-colors disabled:opacity-40 shadow-lg"
166
+ >
167
+ <ChevronRight className="w-5 h-5" />
168
+ </button>
169
+ </div>
170
+
171
+ {items.length > 1 && (
172
+ <div className="flex gap-2 mt-4 z-50" role="tablist" aria-label="Slides">
173
+ {items.map((_, index) => (
174
+ <button
175
+ key={index}
176
+ type="button"
177
+ role="tab"
178
+ aria-selected={index === currentIndex}
179
+ aria-label={`Ir a slide ${index + 1}`}
180
+ disabled={disabled}
181
+ onClick={() => !disabled && setCurrentIndex(index)}
182
+ className={`h-2 rounded-full transition-all duration-300 ${
183
+ index === currentIndex
184
+ ? 'w-6 bg-accent'
185
+ : 'w-2 bg-border-app hover:bg-accent/50'
186
+ } disabled:opacity-40`}
187
+ />
188
+ ))}
189
+ </div>
190
+ )}
191
+ </div>
192
+ );
193
+ };