radtools 0.1.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 +108 -0
- package/bin/radtools.js +5 -0
- package/dist/cli/index.js +427 -0
- package/package.json +55 -0
- package/templates/api-routes/assets/optimize/route.ts +94 -0
- package/templates/api-routes/assets/route.ts +159 -0
- package/templates/api-routes/components/create-folder/route.ts +55 -0
- package/templates/api-routes/components/route.ts +156 -0
- package/templates/api-routes/fonts/route.ts +96 -0
- package/templates/api-routes/fonts/upload/route.ts +79 -0
- package/templates/api-routes/read-css/route.ts +29 -0
- package/templates/api-routes/write-css/route.ts +423 -0
- package/templates/components/Rad_os/AppWindow.tsx +423 -0
- package/templates/components/Rad_os/MobileAppModal.tsx +76 -0
- package/templates/components/Rad_os/WindowTitleBar.tsx +290 -0
- package/templates/components/icons/Icon.tsx +224 -0
- package/templates/components/icons/README.md +85 -0
- package/templates/components/icons/index.ts +20 -0
- package/templates/components/icons.tsx +164 -0
- package/templates/components/ui/Accordion.tsx +268 -0
- package/templates/components/ui/Alert.tsx +111 -0
- package/templates/components/ui/Badge.tsx +87 -0
- package/templates/components/ui/Breadcrumbs.tsx +88 -0
- package/templates/components/ui/Button.tsx +249 -0
- package/templates/components/ui/Card.tsx +137 -0
- package/templates/components/ui/Checkbox.tsx +137 -0
- package/templates/components/ui/ContextMenu.tsx +220 -0
- package/templates/components/ui/Dialog.tsx +264 -0
- package/templates/components/ui/Divider.tsx +70 -0
- package/templates/components/ui/DropdownMenu.tsx +301 -0
- package/templates/components/ui/HelpPanel.tsx +119 -0
- package/templates/components/ui/Input.tsx +176 -0
- package/templates/components/ui/Popover.tsx +211 -0
- package/templates/components/ui/Progress.tsx +158 -0
- package/templates/components/ui/Select.tsx +134 -0
- package/templates/components/ui/Sheet.tsx +316 -0
- package/templates/components/ui/Slider.tsx +223 -0
- package/templates/components/ui/Switch.tsx +155 -0
- package/templates/components/ui/Tabs.tsx +253 -0
- package/templates/components/ui/Toast.tsx +192 -0
- package/templates/components/ui/Tooltip.tsx +129 -0
- package/templates/components/ui/hooks/useModalBehavior.ts +66 -0
- package/templates/components/ui/index.ts +84 -0
- package/templates/devtools/DevToolsPanel.tsx +261 -0
- package/templates/devtools/DevToolsProvider.tsx +43 -0
- package/templates/devtools/components/BreakpointIndicator.tsx +49 -0
- package/templates/devtools/components/ColorPicker.tsx +33 -0
- package/templates/devtools/components/ComponentsSecondaryNav.tsx +44 -0
- package/templates/devtools/components/ContextualFooter.tsx +56 -0
- package/templates/devtools/components/DraggablePanel.tsx +43 -0
- package/templates/devtools/components/PrimaryNavigationFooter.tsx +254 -0
- package/templates/devtools/components/SearchableColorDropdown.tsx +253 -0
- package/templates/devtools/components/SecondaryNavigation.tsx +36 -0
- package/templates/devtools/components/TokenDropdown.tsx +47 -0
- package/templates/devtools/components/TypographyFooter.tsx +145 -0
- package/templates/devtools/hooks/useMockState.ts +16 -0
- package/templates/devtools/index.ts +17 -0
- package/templates/devtools/lib/componentScanner.ts +78 -0
- package/templates/devtools/lib/cssParser.ts +465 -0
- package/templates/devtools/lib/searchIndexes.ts +45 -0
- package/templates/devtools/lib/selectorGenerator.ts +86 -0
- package/templates/devtools/store/index.ts +66 -0
- package/templates/devtools/store/slices/assetsSlice.ts +106 -0
- package/templates/devtools/store/slices/componentsSlice.ts +59 -0
- package/templates/devtools/store/slices/mockStatesSlice.ts +77 -0
- package/templates/devtools/store/slices/panelSlice.ts +17 -0
- package/templates/devtools/store/slices/typographySlice.ts +538 -0
- package/templates/devtools/store/slices/variablesSlice.ts +167 -0
- package/templates/devtools/tabs/AssetsTab/AssetGrid.tsx +76 -0
- package/templates/devtools/tabs/AssetsTab/FolderTree.tsx +53 -0
- package/templates/devtools/tabs/AssetsTab/UploadDropzone.tsx +76 -0
- package/templates/devtools/tabs/AssetsTab/index.tsx +182 -0
- package/templates/devtools/tabs/ComponentsTab/AddTabButton.tsx +63 -0
- package/templates/devtools/tabs/ComponentsTab/ComponentList.tsx +153 -0
- package/templates/devtools/tabs/ComponentsTab/DesignSystemTab.tsx +1515 -0
- package/templates/devtools/tabs/ComponentsTab/DynamicFolderTab.tsx +113 -0
- package/templates/devtools/tabs/ComponentsTab/PropDisplay.tsx +55 -0
- package/templates/devtools/tabs/ComponentsTab/index.tsx +167 -0
- package/templates/devtools/tabs/ComponentsTab/previews/.gitkeep +4 -0
- package/templates/devtools/tabs/ComponentsTab/previews/Rad_os.tsx +262 -0
- package/templates/devtools/tabs/ComponentsTab/tabConfig.ts +53 -0
- package/templates/devtools/tabs/MockStatesTab/index.tsx +29 -0
- package/templates/devtools/tabs/TypographyTab/FontManager.tsx +421 -0
- package/templates/devtools/tabs/TypographyTab/TypographyStylesDisplay.tsx +290 -0
- package/templates/devtools/tabs/TypographyTab/index.tsx +98 -0
- package/templates/devtools/tabs/VariablesTab/BaseColorEditor.tsx +267 -0
- package/templates/devtools/tabs/VariablesTab/BorderRadiusEditor.tsx +37 -0
- package/templates/devtools/tabs/VariablesTab/ColorModeSelector.tsx +235 -0
- package/templates/devtools/tabs/VariablesTab/index.tsx +100 -0
- package/templates/devtools/types/index.ts +99 -0
- package/templates/globals.css +574 -0
- package/templates/hooks/index.ts +1 -0
- package/templates/hooks/useWindowManager.ts +212 -0
- package/templates/public/assets/icons/avatar.svg +18 -0
- package/templates/public/assets/icons/checkmark-filled.svg +14 -0
- package/templates/public/assets/icons/checkmark.svg +14 -0
- package/templates/public/assets/icons/chevron-down.svg +14 -0
- package/templates/public/assets/icons/close.svg +14 -0
- package/templates/public/assets/icons/copy.svg +14 -0
- package/templates/public/assets/icons/download.svg +14 -0
- package/templates/public/assets/icons/expand.svg +31 -0
- package/templates/public/assets/icons/file-blank.svg +17 -0
- package/templates/public/assets/icons/file-image.svg +19 -0
- package/templates/public/assets/icons/file-written.svg +17 -0
- package/templates/public/assets/icons/folder-closed.svg +17 -0
- package/templates/public/assets/icons/folder-open.svg +17 -0
- package/templates/public/assets/icons/hamburger.svg +18 -0
- package/templates/public/assets/icons/home-outline.svg +28 -0
- package/templates/public/assets/icons/home.svg +30 -0
- package/templates/public/assets/icons/hourglass.svg +25 -0
- package/templates/public/assets/icons/information-circle.svg +14 -0
- package/templates/public/assets/icons/information.svg +17 -0
- package/templates/public/assets/icons/lightning.svg +14 -0
- package/templates/public/assets/icons/locked.svg +17 -0
- package/templates/public/assets/icons/not-allowed.svg +14 -0
- package/templates/public/assets/icons/plus.svg +5 -0
- package/templates/public/assets/icons/power-thin.svg +17 -0
- package/templates/public/assets/icons/power.svg +17 -0
- package/templates/public/assets/icons/question-block.svg +14 -0
- package/templates/public/assets/icons/question.svg +17 -0
- package/templates/public/assets/icons/refresh-block.svg +14 -0
- package/templates/public/assets/icons/refresh.svg +17 -0
- package/templates/public/assets/icons/save.svg +14 -0
- package/templates/public/assets/icons/search.svg +25 -0
- package/templates/public/assets/icons/settings.svg +14 -0
- package/templates/public/assets/icons/trash-full.svg +21 -0
- package/templates/public/assets/icons/trash-open.svg +23 -0
- package/templates/public/assets/icons/trash.svg +18 -0
- package/templates/public/assets/icons/unlocked.svg +17 -0
- package/templates/public/assets/icons/waring-triangle-filled.svg +17 -0
- package/templates/public/assets/icons/warning-triangle-filled-2.svg +30 -0
- package/templates/public/assets/icons/warning-triangle-lines.svg +29 -0
- package/templates/public/assets/icons/wrench.svg +17 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useState, useRef, useEffect, useCallback } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { useEscapeKey, useClickOutside } from './hooks/useModalBehavior';
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Types
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
type PopoverPosition = 'top' | 'bottom' | 'left' | 'right';
|
|
12
|
+
|
|
13
|
+
interface PopoverContextValue {
|
|
14
|
+
open: boolean;
|
|
15
|
+
setOpen: (open: boolean) => void;
|
|
16
|
+
triggerRef: React.RefObject<HTMLElement | null>;
|
|
17
|
+
position: PopoverPosition;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Context
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
const PopoverContext = createContext<PopoverContextValue | null>(null);
|
|
25
|
+
|
|
26
|
+
function usePopoverContext() {
|
|
27
|
+
const context = useContext(PopoverContext);
|
|
28
|
+
if (!context) {
|
|
29
|
+
throw new Error('Popover components must be used within a Popover');
|
|
30
|
+
}
|
|
31
|
+
return context;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Popover Root
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
interface PopoverProps {
|
|
39
|
+
/** Controlled open state */
|
|
40
|
+
open?: boolean;
|
|
41
|
+
/** Default open state */
|
|
42
|
+
defaultOpen?: boolean;
|
|
43
|
+
/** Callback when open state changes */
|
|
44
|
+
onOpenChange?: (open: boolean) => void;
|
|
45
|
+
/** Position relative to trigger */
|
|
46
|
+
position?: PopoverPosition;
|
|
47
|
+
/** Children */
|
|
48
|
+
children: React.ReactNode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function Popover({
|
|
52
|
+
open: controlledOpen,
|
|
53
|
+
defaultOpen = false,
|
|
54
|
+
onOpenChange,
|
|
55
|
+
position = 'bottom',
|
|
56
|
+
children,
|
|
57
|
+
}: PopoverProps) {
|
|
58
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
59
|
+
const isControlled = controlledOpen !== undefined;
|
|
60
|
+
const open = isControlled ? controlledOpen : internalOpen;
|
|
61
|
+
const triggerRef = useRef<HTMLElement>(null);
|
|
62
|
+
|
|
63
|
+
const setOpen = useCallback((newOpen: boolean) => {
|
|
64
|
+
if (!isControlled) {
|
|
65
|
+
setInternalOpen(newOpen);
|
|
66
|
+
}
|
|
67
|
+
onOpenChange?.(newOpen);
|
|
68
|
+
}, [isControlled, onOpenChange]);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<PopoverContext.Provider value={{ open, setOpen, triggerRef, position }}>
|
|
72
|
+
{children}
|
|
73
|
+
</PopoverContext.Provider>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// Popover Trigger
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
interface PopoverTriggerProps {
|
|
82
|
+
/** Trigger element */
|
|
83
|
+
children: React.ReactElement;
|
|
84
|
+
/** Pass through as child instead of wrapping */
|
|
85
|
+
asChild?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function PopoverTrigger({ children, asChild }: PopoverTriggerProps) {
|
|
89
|
+
const { open, setOpen, triggerRef } = usePopoverContext();
|
|
90
|
+
|
|
91
|
+
const handleClick = () => {
|
|
92
|
+
setOpen(!open);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (asChild && React.isValidElement(children)) {
|
|
96
|
+
return React.cloneElement(children as React.ReactElement<{ onClick?: () => void; ref?: React.Ref<HTMLElement | null> }>, {
|
|
97
|
+
onClick: handleClick,
|
|
98
|
+
ref: triggerRef as React.Ref<HTMLElement | null>,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
ref={triggerRef as React.RefObject<HTMLButtonElement>}
|
|
106
|
+
onClick={handleClick}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</button>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ============================================================================
|
|
114
|
+
// Popover Content
|
|
115
|
+
// ============================================================================
|
|
116
|
+
|
|
117
|
+
interface PopoverContentProps {
|
|
118
|
+
/** Additional className */
|
|
119
|
+
className?: string;
|
|
120
|
+
/** Children */
|
|
121
|
+
children: React.ReactNode;
|
|
122
|
+
/** Alignment relative to trigger */
|
|
123
|
+
align?: 'start' | 'center' | 'end';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function PopoverContent({ className = '', children, align = 'center' }: PopoverContentProps) {
|
|
127
|
+
const { open, setOpen, triggerRef, position } = usePopoverContext();
|
|
128
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
129
|
+
const [mounted, setMounted] = useState(false);
|
|
130
|
+
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
setMounted(true);
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
// Calculate position
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!open || !triggerRef.current || !contentRef.current) return;
|
|
139
|
+
|
|
140
|
+
const trigger = triggerRef.current.getBoundingClientRect();
|
|
141
|
+
const content = contentRef.current.getBoundingClientRect();
|
|
142
|
+
const gap = 8;
|
|
143
|
+
|
|
144
|
+
let top = 0;
|
|
145
|
+
let left = 0;
|
|
146
|
+
|
|
147
|
+
switch (position) {
|
|
148
|
+
case 'top':
|
|
149
|
+
top = trigger.top - content.height - gap;
|
|
150
|
+
left = trigger.left + (trigger.width - content.width) / 2;
|
|
151
|
+
break;
|
|
152
|
+
case 'bottom':
|
|
153
|
+
top = trigger.bottom + gap;
|
|
154
|
+
left = trigger.left + (trigger.width - content.width) / 2;
|
|
155
|
+
break;
|
|
156
|
+
case 'left':
|
|
157
|
+
top = trigger.top + (trigger.height - content.height) / 2;
|
|
158
|
+
left = trigger.left - content.width - gap;
|
|
159
|
+
break;
|
|
160
|
+
case 'right':
|
|
161
|
+
top = trigger.top + (trigger.height - content.height) / 2;
|
|
162
|
+
left = trigger.right + gap;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Adjust for alignment
|
|
167
|
+
if (position === 'top' || position === 'bottom') {
|
|
168
|
+
if (align === 'start') {
|
|
169
|
+
left = trigger.left;
|
|
170
|
+
} else if (align === 'end') {
|
|
171
|
+
left = trigger.right - content.width;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Keep within viewport
|
|
176
|
+
top = Math.max(8, Math.min(top, window.innerHeight - content.height - 8));
|
|
177
|
+
left = Math.max(8, Math.min(left, window.innerWidth - content.width - 8));
|
|
178
|
+
|
|
179
|
+
setCoords({ top, left });
|
|
180
|
+
}, [open, position, align, triggerRef]);
|
|
181
|
+
|
|
182
|
+
// Handle click outside
|
|
183
|
+
useClickOutside(open, [contentRef, triggerRef], () => setOpen(false));
|
|
184
|
+
|
|
185
|
+
// Handle escape
|
|
186
|
+
useEscapeKey(open, () => setOpen(false));
|
|
187
|
+
|
|
188
|
+
if (!mounted || !open) return null;
|
|
189
|
+
|
|
190
|
+
return createPortal(
|
|
191
|
+
<div
|
|
192
|
+
ref={contentRef}
|
|
193
|
+
className={`
|
|
194
|
+
fixed z-50
|
|
195
|
+
bg-warm-cloud
|
|
196
|
+
border-2 border-black
|
|
197
|
+
rounded-sm
|
|
198
|
+
shadow-[2px_2px_0_0_var(--color-black)]
|
|
199
|
+
p-4
|
|
200
|
+
animate-fadeIn
|
|
201
|
+
${className}
|
|
202
|
+
`.trim()}
|
|
203
|
+
style={{ top: coords.top, left: coords.left }}
|
|
204
|
+
>
|
|
205
|
+
{children}
|
|
206
|
+
</div>,
|
|
207
|
+
document.body
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export default Popover;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
type ProgressVariant = 'default' | 'success' | 'warning' | 'error';
|
|
10
|
+
type ProgressSize = 'sm' | 'md' | 'lg';
|
|
11
|
+
|
|
12
|
+
interface ProgressProps {
|
|
13
|
+
/** Progress value (0-100) */
|
|
14
|
+
value: number;
|
|
15
|
+
/** Maximum value */
|
|
16
|
+
max?: number;
|
|
17
|
+
/** Visual variant */
|
|
18
|
+
variant?: ProgressVariant;
|
|
19
|
+
/** Size preset */
|
|
20
|
+
size?: ProgressSize;
|
|
21
|
+
/** Show percentage label */
|
|
22
|
+
showLabel?: boolean;
|
|
23
|
+
/** Additional classes */
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Styles
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
const sizeStyles: Record<ProgressSize, string> = {
|
|
32
|
+
sm: 'h-2',
|
|
33
|
+
md: 'h-4',
|
|
34
|
+
lg: 'h-6',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const variantStyles: Record<ProgressVariant, string> = {
|
|
38
|
+
default: 'bg-sun-yellow',
|
|
39
|
+
success: 'bg-success-green',
|
|
40
|
+
warning: 'bg-sunset-fuzz',
|
|
41
|
+
error: 'bg-error-red',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Component
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Progress bar with retro styling
|
|
50
|
+
*/
|
|
51
|
+
export function Progress({
|
|
52
|
+
value,
|
|
53
|
+
max = 100,
|
|
54
|
+
variant = 'default',
|
|
55
|
+
size = 'md',
|
|
56
|
+
showLabel = false,
|
|
57
|
+
className = '',
|
|
58
|
+
}: ProgressProps) {
|
|
59
|
+
const percentage = Math.min(100, Math.max(0, (value / max) * 100));
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className={`w-full ${className}`}>
|
|
63
|
+
{/* Track */}
|
|
64
|
+
<div
|
|
65
|
+
className={`
|
|
66
|
+
w-full
|
|
67
|
+
bg-warm-cloud
|
|
68
|
+
border border-black
|
|
69
|
+
rounded-sm
|
|
70
|
+
overflow-hidden
|
|
71
|
+
${sizeStyles[size]}
|
|
72
|
+
`}
|
|
73
|
+
role="progressbar"
|
|
74
|
+
aria-valuenow={value}
|
|
75
|
+
aria-valuemin={0}
|
|
76
|
+
aria-valuemax={max}
|
|
77
|
+
>
|
|
78
|
+
{/* Fill */}
|
|
79
|
+
<div
|
|
80
|
+
className={`
|
|
81
|
+
h-full
|
|
82
|
+
${variantStyles[variant]}
|
|
83
|
+
`}
|
|
84
|
+
style={{ width: `${percentage}%` }}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{/* Label */}
|
|
89
|
+
{showLabel && (
|
|
90
|
+
<div className="mt-1 font-joystix text-2xs text-black text-right">
|
|
91
|
+
{Math.round(percentage)}%
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Loading Spinner
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
interface SpinnerProps {
|
|
103
|
+
/** Size in pixels */
|
|
104
|
+
size?: number;
|
|
105
|
+
/** Additional classes */
|
|
106
|
+
className?: string;
|
|
107
|
+
/** Whether loading is completed - shows checkmark */
|
|
108
|
+
completed?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// PixelCode loader frames - Private Use Area characters from PixelCode font
|
|
112
|
+
// These are the 6-frame loader animation characters (U+EE06-U+EE0B)
|
|
113
|
+
// Frame1: Frame2: Frame3: Frame4: Frame5: Frame6:
|
|
114
|
+
const LOADER_FRAMES = ['\uEE06', '\uEE07', '\uEE08', '\uEE09', '\uEE0A', '\uEE0B'];
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* PixelCode loader with animated frames that loop through 6 frames
|
|
118
|
+
* When completed, displays a checkmark (✓)
|
|
119
|
+
*/
|
|
120
|
+
export function Spinner({ size = 24, className = '', completed = false }: SpinnerProps) {
|
|
121
|
+
const [frameIndex, setFrameIndex] = React.useState(0);
|
|
122
|
+
|
|
123
|
+
React.useEffect(() => {
|
|
124
|
+
if (completed) {
|
|
125
|
+
setFrameIndex(0); // Reset to first frame when completed
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const interval = setInterval(() => {
|
|
130
|
+
setFrameIndex((prev) => (prev + 1) % LOADER_FRAMES.length);
|
|
131
|
+
}, 150); // Change frame every 150ms for smooth animation
|
|
132
|
+
|
|
133
|
+
return () => clearInterval(interval);
|
|
134
|
+
}, [completed]);
|
|
135
|
+
|
|
136
|
+
const fontSize = size;
|
|
137
|
+
const displayChar = completed ? '✓' : LOADER_FRAMES[frameIndex];
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
className={`inline-block flex items-center justify-center ${className}`}
|
|
142
|
+
style={{
|
|
143
|
+
width: size,
|
|
144
|
+
height: size,
|
|
145
|
+
fontSize: fontSize,
|
|
146
|
+
fontFamily: 'PixelCode, monospace',
|
|
147
|
+
lineHeight: 1,
|
|
148
|
+
}}
|
|
149
|
+
aria-label={completed ? 'Completed' : 'Loading'}
|
|
150
|
+
role="status"
|
|
151
|
+
>
|
|
152
|
+
{displayChar}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export default Progress;
|
|
158
|
+
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
interface SelectOption {
|
|
10
|
+
value: string;
|
|
11
|
+
label: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SelectProps {
|
|
16
|
+
/** Available options */
|
|
17
|
+
options: SelectOption[];
|
|
18
|
+
/** Currently selected value */
|
|
19
|
+
value?: string;
|
|
20
|
+
/** Placeholder text when no value selected */
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
/** Change handler */
|
|
23
|
+
onChange?: (value: string) => void;
|
|
24
|
+
/** Disabled state */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Error state */
|
|
27
|
+
error?: boolean;
|
|
28
|
+
/** Full width */
|
|
29
|
+
fullWidth?: boolean;
|
|
30
|
+
/** Additional classes */
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Component
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Custom select/dropdown with retro styling
|
|
40
|
+
*/
|
|
41
|
+
export function Select({
|
|
42
|
+
options,
|
|
43
|
+
value,
|
|
44
|
+
placeholder = 'Select...',
|
|
45
|
+
onChange,
|
|
46
|
+
disabled = false,
|
|
47
|
+
error = false,
|
|
48
|
+
fullWidth = false,
|
|
49
|
+
className = '',
|
|
50
|
+
}: SelectProps) {
|
|
51
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
52
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
|
|
54
|
+
// Close dropdown when clicking outside
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
function handleClickOutside(event: MouseEvent) {
|
|
57
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
58
|
+
setIsOpen(false);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
62
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const selectedOption = options.find(opt => opt.value === value);
|
|
66
|
+
|
|
67
|
+
const handleSelect = (optionValue: string) => {
|
|
68
|
+
onChange?.(optionValue);
|
|
69
|
+
setIsOpen(false);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
ref={containerRef}
|
|
75
|
+
className={`relative ${fullWidth ? 'w-full' : 'w-fit'} ${className}`}
|
|
76
|
+
>
|
|
77
|
+
{/* Trigger Button */}
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
81
|
+
disabled={disabled}
|
|
82
|
+
className={`
|
|
83
|
+
flex items-center justify-between gap-2
|
|
84
|
+
w-full h-10 px-3
|
|
85
|
+
font-mondwest text-base
|
|
86
|
+
bg-warm-cloud text-black
|
|
87
|
+
border rounded-sm
|
|
88
|
+
${error ? 'border-error' : 'border-black'}
|
|
89
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
|
90
|
+
${isOpen ? 'shadow-[0_3px_0_0_var(--color-black)] -translate-y-0.5' : 'shadow-[0_1px_0_0_var(--color-black)]'}
|
|
91
|
+
`}
|
|
92
|
+
>
|
|
93
|
+
<span className={selectedOption ? 'text-black' : 'text-black/40'}>
|
|
94
|
+
{selectedOption?.label || placeholder}
|
|
95
|
+
</span>
|
|
96
|
+
<span className={`text-black ${isOpen ? 'rotate-180' : ''}`}>▼</span>
|
|
97
|
+
</button>
|
|
98
|
+
|
|
99
|
+
{/* Dropdown Menu */}
|
|
100
|
+
{isOpen && (
|
|
101
|
+
<div
|
|
102
|
+
className={`
|
|
103
|
+
absolute z-50 top-full left-0 right-0 mt-1
|
|
104
|
+
bg-warm-cloud
|
|
105
|
+
border border-black
|
|
106
|
+
rounded-sm
|
|
107
|
+
shadow-[2px_2px_0_0_var(--color-black)]
|
|
108
|
+
overflow-hidden
|
|
109
|
+
`}
|
|
110
|
+
>
|
|
111
|
+
{options.map((option) => (
|
|
112
|
+
<button
|
|
113
|
+
key={option.value}
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={() => !option.disabled && handleSelect(option.value)}
|
|
116
|
+
disabled={option.disabled}
|
|
117
|
+
className={`
|
|
118
|
+
w-full px-3 py-2
|
|
119
|
+
font-mondwest text-base text-left
|
|
120
|
+
${option.value === value ? 'bg-sun-yellow text-black' : 'text-black'}
|
|
121
|
+
${option.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-sun-yellow cursor-pointer'}
|
|
122
|
+
`}
|
|
123
|
+
>
|
|
124
|
+
{option.label}
|
|
125
|
+
</button>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export default Select;
|
|
134
|
+
|