@evolve.labs/devflow 0.8.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/.claude/commands/agents/architect.md +1162 -0
- package/.claude/commands/agents/architect.meta.yaml +124 -0
- package/.claude/commands/agents/builder.md +1432 -0
- package/.claude/commands/agents/builder.meta.yaml +117 -0
- package/.claude/commands/agents/chronicler.md +633 -0
- package/.claude/commands/agents/chronicler.meta.yaml +217 -0
- package/.claude/commands/agents/guardian.md +456 -0
- package/.claude/commands/agents/guardian.meta.yaml +127 -0
- package/.claude/commands/agents/strategist.md +483 -0
- package/.claude/commands/agents/strategist.meta.yaml +158 -0
- package/.claude/commands/agents/system-designer.md +1137 -0
- package/.claude/commands/agents/system-designer.meta.yaml +156 -0
- package/.claude/commands/devflow-help.md +93 -0
- package/.claude/commands/devflow-status.md +60 -0
- package/.claude/commands/quick/create-adr.md +82 -0
- package/.claude/commands/quick/new-feature.md +57 -0
- package/.claude/commands/quick/security-check.md +54 -0
- package/.claude/commands/quick/system-design.md +58 -0
- package/.claude_project +52 -0
- package/.devflow/agents/architect.meta.yaml +122 -0
- package/.devflow/agents/builder.meta.yaml +116 -0
- package/.devflow/agents/chronicler.meta.yaml +222 -0
- package/.devflow/agents/guardian.meta.yaml +127 -0
- package/.devflow/agents/strategist.meta.yaml +158 -0
- package/.devflow/agents/system-designer.meta.yaml +265 -0
- package/.devflow/project.yaml +242 -0
- package/.gitignore-template +84 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/devflow.js +54 -0
- package/lib/autopilot.js +235 -0
- package/lib/autopilotConstants.js +213 -0
- package/lib/constants.js +95 -0
- package/lib/init.js +200 -0
- package/lib/update.js +181 -0
- package/lib/utils.js +157 -0
- package/lib/web.js +119 -0
- package/package.json +57 -0
- package/web/CHANGELOG.md +192 -0
- package/web/README.md +156 -0
- package/web/app/api/autopilot/execute/route.ts +102 -0
- package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
- package/web/app/api/files/route.ts +280 -0
- package/web/app/api/files/tree/route.ts +160 -0
- package/web/app/api/git/route.ts +201 -0
- package/web/app/api/health/route.ts +94 -0
- package/web/app/api/project/open/route.ts +134 -0
- package/web/app/api/search/route.ts +247 -0
- package/web/app/api/specs/route.ts +405 -0
- package/web/app/api/terminal/route.ts +222 -0
- package/web/app/globals.css +160 -0
- package/web/app/ide/layout.tsx +43 -0
- package/web/app/ide/page.tsx +216 -0
- package/web/app/layout.tsx +34 -0
- package/web/app/page.tsx +303 -0
- package/web/components/agents/AgentIcons.tsx +281 -0
- package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
- package/web/components/autopilot/AutopilotPanel.tsx +299 -0
- package/web/components/dashboard/DashboardPanel.tsx +393 -0
- package/web/components/editor/Breadcrumbs.tsx +134 -0
- package/web/components/editor/EditorPanel.tsx +120 -0
- package/web/components/editor/EditorTabs.tsx +229 -0
- package/web/components/editor/MarkdownPreview.tsx +154 -0
- package/web/components/editor/MermaidDiagram.tsx +113 -0
- package/web/components/editor/MonacoEditor.tsx +177 -0
- package/web/components/editor/TabContextMenu.tsx +207 -0
- package/web/components/git/GitPanel.tsx +534 -0
- package/web/components/layout/Shell.tsx +15 -0
- package/web/components/layout/StatusBar.tsx +100 -0
- package/web/components/modals/CommandPalette.tsx +393 -0
- package/web/components/modals/GlobalSearch.tsx +348 -0
- package/web/components/modals/QuickOpen.tsx +241 -0
- package/web/components/modals/RecentFiles.tsx +208 -0
- package/web/components/projects/ProjectSelector.tsx +147 -0
- package/web/components/settings/SettingItem.tsx +150 -0
- package/web/components/settings/SettingsPanel.tsx +323 -0
- package/web/components/specs/SpecsPanel.tsx +1091 -0
- package/web/components/terminal/TerminalPanel.tsx +683 -0
- package/web/components/ui/ContextMenu.tsx +182 -0
- package/web/components/ui/LoadingSpinner.tsx +66 -0
- package/web/components/ui/ResizeHandle.tsx +110 -0
- package/web/components/ui/Skeleton.tsx +108 -0
- package/web/components/ui/SkipLinks.tsx +37 -0
- package/web/components/ui/Toaster.tsx +57 -0
- package/web/hooks/useFocusTrap.ts +141 -0
- package/web/hooks/useKeyboardShortcuts.ts +169 -0
- package/web/hooks/useListNavigation.ts +237 -0
- package/web/lib/autopilotConstants.ts +213 -0
- package/web/lib/constants/agents.ts +67 -0
- package/web/lib/git.ts +339 -0
- package/web/lib/ptyManager.ts +191 -0
- package/web/lib/specsParser.ts +299 -0
- package/web/lib/stores/autopilotStore.ts +288 -0
- package/web/lib/stores/fileStore.ts +550 -0
- package/web/lib/stores/gitStore.ts +386 -0
- package/web/lib/stores/projectStore.ts +196 -0
- package/web/lib/stores/settingsStore.ts +126 -0
- package/web/lib/stores/specsStore.ts +297 -0
- package/web/lib/stores/uiStore.ts +175 -0
- package/web/lib/types/index.ts +177 -0
- package/web/lib/utils.ts +98 -0
- package/web/next.config.js +50 -0
- package/web/package.json +54 -0
- package/web/postcss.config.js +6 -0
- package/web/tailwind.config.ts +68 -0
- package/web/tsconfig.json +41 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
const ContextMenu = ContextMenuPrimitive.Root;
|
|
8
|
+
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
|
9
|
+
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
|
10
|
+
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
|
11
|
+
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
|
12
|
+
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
|
13
|
+
|
|
14
|
+
const ContextMenuSubTrigger = React.forwardRef<
|
|
15
|
+
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
|
16
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
|
17
|
+
inset?: boolean;
|
|
18
|
+
}
|
|
19
|
+
>(({ className, inset, children, ...props }, ref) => (
|
|
20
|
+
<ContextMenuPrimitive.SubTrigger
|
|
21
|
+
ref={ref}
|
|
22
|
+
className={cn(
|
|
23
|
+
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
|
|
24
|
+
'focus:bg-white/10 focus:text-white data-[state=open]:bg-white/10',
|
|
25
|
+
inset && 'pl-8',
|
|
26
|
+
className
|
|
27
|
+
)}
|
|
28
|
+
{...props}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</ContextMenuPrimitive.SubTrigger>
|
|
32
|
+
));
|
|
33
|
+
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
|
34
|
+
|
|
35
|
+
const ContextMenuSubContent = React.forwardRef<
|
|
36
|
+
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
|
37
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
|
38
|
+
>(({ className, ...props }, ref) => (
|
|
39
|
+
<ContextMenuPrimitive.SubContent
|
|
40
|
+
ref={ref}
|
|
41
|
+
className={cn(
|
|
42
|
+
'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-white/10 bg-[#1a1a24] p-1 shadow-xl',
|
|
43
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
44
|
+
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
45
|
+
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
46
|
+
className
|
|
47
|
+
)}
|
|
48
|
+
{...props}
|
|
49
|
+
/>
|
|
50
|
+
));
|
|
51
|
+
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
|
52
|
+
|
|
53
|
+
const ContextMenuContent = React.forwardRef<
|
|
54
|
+
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
|
55
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
|
56
|
+
>(({ className, ...props }, ref) => (
|
|
57
|
+
<ContextMenuPrimitive.Portal>
|
|
58
|
+
<ContextMenuPrimitive.Content
|
|
59
|
+
ref={ref}
|
|
60
|
+
className={cn(
|
|
61
|
+
'z-50 min-w-[160px] overflow-hidden rounded-lg border border-white/10 bg-[#1a1a24] p-1 shadow-xl',
|
|
62
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
63
|
+
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
64
|
+
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
{...props}
|
|
68
|
+
/>
|
|
69
|
+
</ContextMenuPrimitive.Portal>
|
|
70
|
+
));
|
|
71
|
+
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
|
72
|
+
|
|
73
|
+
const ContextMenuItem = React.forwardRef<
|
|
74
|
+
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
|
75
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
|
76
|
+
inset?: boolean;
|
|
77
|
+
}
|
|
78
|
+
>(({ className, inset, ...props }, ref) => (
|
|
79
|
+
<ContextMenuPrimitive.Item
|
|
80
|
+
ref={ref}
|
|
81
|
+
className={cn(
|
|
82
|
+
'relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-1.5 text-sm text-gray-300 outline-none transition-colors',
|
|
83
|
+
'focus:bg-white/10 focus:text-white',
|
|
84
|
+
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
85
|
+
inset && 'pl-8',
|
|
86
|
+
className
|
|
87
|
+
)}
|
|
88
|
+
{...props}
|
|
89
|
+
/>
|
|
90
|
+
));
|
|
91
|
+
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
|
92
|
+
|
|
93
|
+
const ContextMenuCheckboxItem = React.forwardRef<
|
|
94
|
+
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
|
95
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
|
96
|
+
>(({ className, children, checked, ...props }, ref) => (
|
|
97
|
+
<ContextMenuPrimitive.CheckboxItem
|
|
98
|
+
ref={ref}
|
|
99
|
+
className={cn(
|
|
100
|
+
'relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors',
|
|
101
|
+
'focus:bg-white/10 focus:text-white',
|
|
102
|
+
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
103
|
+
className
|
|
104
|
+
)}
|
|
105
|
+
checked={checked}
|
|
106
|
+
{...props}
|
|
107
|
+
>
|
|
108
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
109
|
+
<ContextMenuPrimitive.ItemIndicator>
|
|
110
|
+
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
|
111
|
+
<path
|
|
112
|
+
fillRule="evenodd"
|
|
113
|
+
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
114
|
+
clipRule="evenodd"
|
|
115
|
+
/>
|
|
116
|
+
</svg>
|
|
117
|
+
</ContextMenuPrimitive.ItemIndicator>
|
|
118
|
+
</span>
|
|
119
|
+
{children}
|
|
120
|
+
</ContextMenuPrimitive.CheckboxItem>
|
|
121
|
+
));
|
|
122
|
+
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
|
|
123
|
+
|
|
124
|
+
const ContextMenuLabel = React.forwardRef<
|
|
125
|
+
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
|
126
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
|
127
|
+
inset?: boolean;
|
|
128
|
+
}
|
|
129
|
+
>(({ className, inset, ...props }, ref) => (
|
|
130
|
+
<ContextMenuPrimitive.Label
|
|
131
|
+
ref={ref}
|
|
132
|
+
className={cn(
|
|
133
|
+
'px-2 py-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider',
|
|
134
|
+
inset && 'pl-8',
|
|
135
|
+
className
|
|
136
|
+
)}
|
|
137
|
+
{...props}
|
|
138
|
+
/>
|
|
139
|
+
));
|
|
140
|
+
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
|
141
|
+
|
|
142
|
+
const ContextMenuSeparator = React.forwardRef<
|
|
143
|
+
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
|
144
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
|
145
|
+
>(({ className, ...props }, ref) => (
|
|
146
|
+
<ContextMenuPrimitive.Separator
|
|
147
|
+
ref={ref}
|
|
148
|
+
className={cn('-mx-1 my-1 h-px bg-white/10', className)}
|
|
149
|
+
{...props}
|
|
150
|
+
/>
|
|
151
|
+
));
|
|
152
|
+
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
|
153
|
+
|
|
154
|
+
const ContextMenuShortcut = ({
|
|
155
|
+
className,
|
|
156
|
+
...props
|
|
157
|
+
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
158
|
+
return (
|
|
159
|
+
<span
|
|
160
|
+
className={cn('ml-auto text-xs tracking-widest text-gray-500', className)}
|
|
161
|
+
{...props}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
|
|
166
|
+
|
|
167
|
+
export {
|
|
168
|
+
ContextMenu,
|
|
169
|
+
ContextMenuTrigger,
|
|
170
|
+
ContextMenuContent,
|
|
171
|
+
ContextMenuItem,
|
|
172
|
+
ContextMenuCheckboxItem,
|
|
173
|
+
ContextMenuLabel,
|
|
174
|
+
ContextMenuSeparator,
|
|
175
|
+
ContextMenuShortcut,
|
|
176
|
+
ContextMenuGroup,
|
|
177
|
+
ContextMenuPortal,
|
|
178
|
+
ContextMenuSub,
|
|
179
|
+
ContextMenuSubContent,
|
|
180
|
+
ContextMenuSubTrigger,
|
|
181
|
+
ContextMenuRadioGroup,
|
|
182
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
import { Loader2 } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
interface LoadingSpinnerProps {
|
|
7
|
+
size?: 'sm' | 'md' | 'lg';
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const sizeClasses = {
|
|
12
|
+
sm: 'w-4 h-4',
|
|
13
|
+
md: 'w-6 h-6',
|
|
14
|
+
lg: 'w-8 h-8',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
|
18
|
+
return (
|
|
19
|
+
<Loader2
|
|
20
|
+
className={cn(
|
|
21
|
+
'animate-spin text-purple-400',
|
|
22
|
+
sizeClasses[size],
|
|
23
|
+
className
|
|
24
|
+
)}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface LoadingOverlayProps {
|
|
30
|
+
message?: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function LoadingOverlay({ message, className }: LoadingOverlayProps) {
|
|
35
|
+
return (
|
|
36
|
+
<div className={cn(
|
|
37
|
+
'absolute inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50',
|
|
38
|
+
className
|
|
39
|
+
)}>
|
|
40
|
+
<div className="flex flex-col items-center gap-3">
|
|
41
|
+
<LoadingSpinner size="lg" />
|
|
42
|
+
{message && (
|
|
43
|
+
<span className="text-sm text-gray-300">{message}</span>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface LoadingDotsProps {
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function LoadingDots({ className }: LoadingDotsProps) {
|
|
55
|
+
return (
|
|
56
|
+
<span className={cn('inline-flex gap-1', className)}>
|
|
57
|
+
{[0, 1, 2].map((i) => (
|
|
58
|
+
<span
|
|
59
|
+
key={i}
|
|
60
|
+
className="w-1.5 h-1.5 rounded-full bg-purple-400 animate-bounce"
|
|
61
|
+
style={{ animationDelay: `${i * 150}ms` }}
|
|
62
|
+
/>
|
|
63
|
+
))}
|
|
64
|
+
</span>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
type ResizeDirection = 'horizontal' | 'vertical';
|
|
7
|
+
type ResizeSide = 'left' | 'right' | 'top' | 'bottom';
|
|
8
|
+
|
|
9
|
+
interface ResizeHandleProps {
|
|
10
|
+
direction: ResizeDirection;
|
|
11
|
+
side: ResizeSide;
|
|
12
|
+
onResize: (delta: number) => void;
|
|
13
|
+
onResizeEnd?: () => void;
|
|
14
|
+
minSize?: number;
|
|
15
|
+
maxSize?: number;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ResizeHandle({
|
|
20
|
+
direction,
|
|
21
|
+
side,
|
|
22
|
+
onResize,
|
|
23
|
+
onResizeEnd,
|
|
24
|
+
className,
|
|
25
|
+
}: ResizeHandleProps) {
|
|
26
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
27
|
+
const startPosRef = useRef(0);
|
|
28
|
+
const handleRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
|
|
30
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
setIsDragging(true);
|
|
33
|
+
startPosRef.current = direction === 'horizontal' ? e.clientX : e.clientY;
|
|
34
|
+
}, [direction]);
|
|
35
|
+
|
|
36
|
+
const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
37
|
+
if (!isDragging) return;
|
|
38
|
+
|
|
39
|
+
const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
|
|
40
|
+
let delta = currentPos - startPosRef.current;
|
|
41
|
+
|
|
42
|
+
// Invert delta for left/top sides
|
|
43
|
+
if (side === 'left' || side === 'top') {
|
|
44
|
+
delta = -delta;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
onResize(delta);
|
|
48
|
+
startPosRef.current = currentPos;
|
|
49
|
+
}, [isDragging, direction, side, onResize]);
|
|
50
|
+
|
|
51
|
+
const handleMouseUp = useCallback(() => {
|
|
52
|
+
if (isDragging) {
|
|
53
|
+
setIsDragging(false);
|
|
54
|
+
onResizeEnd?.();
|
|
55
|
+
}
|
|
56
|
+
}, [isDragging, onResizeEnd]);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (isDragging) {
|
|
60
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
61
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
62
|
+
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
|
|
63
|
+
document.body.style.userSelect = 'none';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
68
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
69
|
+
document.body.style.cursor = '';
|
|
70
|
+
document.body.style.userSelect = '';
|
|
71
|
+
};
|
|
72
|
+
}, [isDragging, handleMouseMove, handleMouseUp, direction]);
|
|
73
|
+
|
|
74
|
+
const isHorizontal = direction === 'horizontal';
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
ref={handleRef}
|
|
79
|
+
onMouseDown={handleMouseDown}
|
|
80
|
+
className={cn(
|
|
81
|
+
'group flex-shrink-0 transition-colors',
|
|
82
|
+
isHorizontal
|
|
83
|
+
? 'w-1 cursor-col-resize hover:w-1'
|
|
84
|
+
: 'h-1 cursor-row-resize hover:h-1',
|
|
85
|
+
'relative',
|
|
86
|
+
className
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
{/* Visual indicator */}
|
|
90
|
+
<div
|
|
91
|
+
className={cn(
|
|
92
|
+
'absolute transition-all duration-150',
|
|
93
|
+
isHorizontal
|
|
94
|
+
? 'inset-y-0 left-0 w-1 group-hover:w-1 group-hover:bg-purple-500/50'
|
|
95
|
+
: 'inset-x-0 top-0 h-1 group-hover:h-1 group-hover:bg-purple-500/50',
|
|
96
|
+
isDragging && 'bg-purple-500'
|
|
97
|
+
)}
|
|
98
|
+
/>
|
|
99
|
+
{/* Extended hit area */}
|
|
100
|
+
<div
|
|
101
|
+
className={cn(
|
|
102
|
+
'absolute',
|
|
103
|
+
isHorizontal
|
|
104
|
+
? 'inset-y-0 -left-1 -right-1 w-3'
|
|
105
|
+
: 'inset-x-0 -top-1 -bottom-1 h-3'
|
|
106
|
+
)}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { CSSProperties } from 'react';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
interface SkeletonProps {
|
|
7
|
+
className?: string;
|
|
8
|
+
style?: CSSProperties;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Skeleton({ className, style }: SkeletonProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className={cn(
|
|
15
|
+
'animate-pulse rounded-md bg-white/5',
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
style={style}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SkeletonTextProps {
|
|
24
|
+
lines?: number;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function SkeletonText({ lines = 3, className }: SkeletonTextProps) {
|
|
29
|
+
return (
|
|
30
|
+
<div className={cn('space-y-2', className)}>
|
|
31
|
+
{Array.from({ length: lines }).map((_, i) => (
|
|
32
|
+
<Skeleton
|
|
33
|
+
key={i}
|
|
34
|
+
className={cn(
|
|
35
|
+
'h-4',
|
|
36
|
+
i === lines - 1 ? 'w-3/4' : 'w-full'
|
|
37
|
+
)}
|
|
38
|
+
/>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SkeletonCardProps {
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function SkeletonCard({ className }: SkeletonCardProps) {
|
|
49
|
+
return (
|
|
50
|
+
<div className={cn('p-4 rounded-lg border border-white/10 bg-white/5', className)}>
|
|
51
|
+
<div className="flex items-center gap-3 mb-3">
|
|
52
|
+
<Skeleton className="w-8 h-8 rounded-full" />
|
|
53
|
+
<div className="flex-1">
|
|
54
|
+
<Skeleton className="h-4 w-1/3 mb-1" />
|
|
55
|
+
<Skeleton className="h-3 w-1/2" />
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<SkeletonText lines={2} />
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface SkeletonListProps {
|
|
64
|
+
items?: number;
|
|
65
|
+
className?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function SkeletonList({ items = 5, className }: SkeletonListProps) {
|
|
69
|
+
return (
|
|
70
|
+
<div className={cn('space-y-2', className)}>
|
|
71
|
+
{Array.from({ length: items }).map((_, i) => (
|
|
72
|
+
<div key={i} className="flex items-center gap-2 p-2">
|
|
73
|
+
<Skeleton className="w-4 h-4" />
|
|
74
|
+
<Skeleton className="h-4 flex-1" />
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface SkeletonTreeProps {
|
|
82
|
+
depth?: number;
|
|
83
|
+
items?: number;
|
|
84
|
+
className?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function SkeletonTree({ depth = 2, items = 5, className }: SkeletonTreeProps) {
|
|
88
|
+
const renderItems = (level: number, count: number) => {
|
|
89
|
+
return Array.from({ length: count }).map((_, i) => (
|
|
90
|
+
<div key={`${level}-${i}`}>
|
|
91
|
+
<div
|
|
92
|
+
className="flex items-center gap-2 py-1"
|
|
93
|
+
style={{ paddingLeft: level * 16 }}
|
|
94
|
+
>
|
|
95
|
+
<Skeleton className="w-4 h-4" />
|
|
96
|
+
<Skeleton className={cn('h-4', i % 3 === 0 ? 'w-24' : i % 2 === 0 ? 'w-32' : 'w-20')} />
|
|
97
|
+
</div>
|
|
98
|
+
{level < depth && i % 2 === 0 && renderItems(level + 1, 2)}
|
|
99
|
+
</div>
|
|
100
|
+
));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className={cn('space-y-1', className)}>
|
|
105
|
+
{renderItems(0, items)}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Skip Links component for keyboard accessibility.
|
|
5
|
+
* Provides quick navigation to main content areas for screen reader users.
|
|
6
|
+
* Links are visually hidden but appear on focus.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface SkipLink {
|
|
10
|
+
href: string;
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const skipLinks: SkipLink[] = [
|
|
15
|
+
{ href: '#main-editor', label: 'Skip to editor' },
|
|
16
|
+
{ href: '#main-sidebar', label: 'Skip to sidebar' },
|
|
17
|
+
{ href: '#main-chat', label: 'Skip to chat' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function SkipLinks() {
|
|
21
|
+
return (
|
|
22
|
+
<nav aria-label="Skip navigation" className="sr-only focus-within:not-sr-only">
|
|
23
|
+
<ul className="fixed top-0 left-0 z-[100] flex gap-2 p-2 bg-[#12121a] border-b border-white/10">
|
|
24
|
+
{skipLinks.map((link) => (
|
|
25
|
+
<li key={link.href}>
|
|
26
|
+
<a
|
|
27
|
+
href={link.href}
|
|
28
|
+
className="sr-only focus:not-sr-only focus:inline-block px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-[#0a0a0f]"
|
|
29
|
+
>
|
|
30
|
+
{link.label}
|
|
31
|
+
</a>
|
|
32
|
+
</li>
|
|
33
|
+
))}
|
|
34
|
+
</ul>
|
|
35
|
+
</nav>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Toaster as SonnerToaster } from 'sonner';
|
|
4
|
+
|
|
5
|
+
export function Toaster() {
|
|
6
|
+
return (
|
|
7
|
+
<SonnerToaster
|
|
8
|
+
position="bottom-right"
|
|
9
|
+
toastOptions={{
|
|
10
|
+
style: {
|
|
11
|
+
background: '#12121a',
|
|
12
|
+
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
13
|
+
color: '#fff',
|
|
14
|
+
},
|
|
15
|
+
classNames: {
|
|
16
|
+
toast: 'group',
|
|
17
|
+
title: 'text-sm font-medium',
|
|
18
|
+
description: 'text-xs text-gray-400',
|
|
19
|
+
success: 'border-green-500/30 bg-green-500/10',
|
|
20
|
+
error: 'border-red-500/30 bg-red-500/10',
|
|
21
|
+
warning: 'border-yellow-500/30 bg-yellow-500/10',
|
|
22
|
+
info: 'border-purple-500/30 bg-purple-500/10',
|
|
23
|
+
},
|
|
24
|
+
}}
|
|
25
|
+
icons={{
|
|
26
|
+
success: (
|
|
27
|
+
<div className="w-5 h-5 rounded-full bg-green-500/20 flex items-center justify-center">
|
|
28
|
+
<svg className="w-3 h-3 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
29
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
30
|
+
</svg>
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
error: (
|
|
34
|
+
<div className="w-5 h-5 rounded-full bg-red-500/20 flex items-center justify-center">
|
|
35
|
+
<svg className="w-3 h-3 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
36
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
37
|
+
</svg>
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
warning: (
|
|
41
|
+
<div className="w-5 h-5 rounded-full bg-yellow-500/20 flex items-center justify-center">
|
|
42
|
+
<svg className="w-3 h-3 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
43
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
44
|
+
</svg>
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
info: (
|
|
48
|
+
<div className="w-5 h-5 rounded-full bg-purple-500/20 flex items-center justify-center">
|
|
49
|
+
<svg className="w-3 h-3 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
50
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
51
|
+
</svg>
|
|
52
|
+
</div>
|
|
53
|
+
),
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, RefObject } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Custom hook for trapping focus within a container element.
|
|
7
|
+
* Implements accessible modal focus management per WAI-ARIA guidelines.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Traps Tab/Shift+Tab navigation within container
|
|
11
|
+
* - Auto-focuses first focusable element on activation
|
|
12
|
+
* - Restores focus to previous element on deactivation
|
|
13
|
+
* - Handles ESC key for closing (optional)
|
|
14
|
+
*
|
|
15
|
+
* @param containerRef - Reference to the container element
|
|
16
|
+
* @param isActive - Whether the focus trap is currently active
|
|
17
|
+
* @param options - Configuration options
|
|
18
|
+
*/
|
|
19
|
+
interface UseFocusTrapOptions {
|
|
20
|
+
/** Called when Escape is pressed */
|
|
21
|
+
onEscape?: () => void;
|
|
22
|
+
/** Auto-focus first element when activated (default: true) */
|
|
23
|
+
autoFocus?: boolean;
|
|
24
|
+
/** Restore focus when deactivated (default: true) */
|
|
25
|
+
restoreFocus?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const FOCUSABLE_SELECTORS = [
|
|
29
|
+
'button:not([disabled])',
|
|
30
|
+
'a[href]',
|
|
31
|
+
'input:not([disabled])',
|
|
32
|
+
'select:not([disabled])',
|
|
33
|
+
'textarea:not([disabled])',
|
|
34
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
35
|
+
'[contenteditable="true"]',
|
|
36
|
+
].join(', ');
|
|
37
|
+
|
|
38
|
+
export function useFocusTrap(
|
|
39
|
+
containerRef: RefObject<HTMLElement | null>,
|
|
40
|
+
isActive: boolean,
|
|
41
|
+
options: UseFocusTrapOptions = {}
|
|
42
|
+
) {
|
|
43
|
+
const { onEscape, autoFocus = true, restoreFocus = true } = options;
|
|
44
|
+
const previousActiveElement = useRef<HTMLElement | null>(null);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!isActive || !containerRef.current) return;
|
|
48
|
+
|
|
49
|
+
const container = containerRef.current;
|
|
50
|
+
|
|
51
|
+
// Store currently focused element
|
|
52
|
+
if (restoreFocus) {
|
|
53
|
+
previousActiveElement.current = document.activeElement as HTMLElement;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Get all focusable elements
|
|
57
|
+
const getFocusableElements = (): HTMLElement[] => {
|
|
58
|
+
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS))
|
|
59
|
+
.filter((el) => {
|
|
60
|
+
// Filter out hidden elements
|
|
61
|
+
const style = window.getComputedStyle(el);
|
|
62
|
+
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Auto-focus first element
|
|
67
|
+
if (autoFocus) {
|
|
68
|
+
const focusableElements = getFocusableElements();
|
|
69
|
+
if (focusableElements.length > 0) {
|
|
70
|
+
// Small delay to ensure DOM is ready
|
|
71
|
+
requestAnimationFrame(() => {
|
|
72
|
+
focusableElements[0]?.focus();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Handle keydown for tab trapping and escape
|
|
78
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
79
|
+
if (event.key === 'Escape' && onEscape) {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
onEscape();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (event.key !== 'Tab') return;
|
|
86
|
+
|
|
87
|
+
const focusableElements = getFocusableElements();
|
|
88
|
+
if (focusableElements.length === 0) return;
|
|
89
|
+
|
|
90
|
+
const firstElement = focusableElements[0];
|
|
91
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
92
|
+
const activeElement = document.activeElement;
|
|
93
|
+
|
|
94
|
+
// Shift+Tab on first element → focus last
|
|
95
|
+
if (event.shiftKey && activeElement === firstElement) {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
lastElement?.focus();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Tab on last element → focus first
|
|
102
|
+
if (!event.shiftKey && activeElement === lastElement) {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
firstElement?.focus();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// If focus is outside container, bring it back
|
|
109
|
+
if (!container.contains(activeElement)) {
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
firstElement?.focus();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Prevent focus from leaving container via mouse click
|
|
116
|
+
const handleFocusIn = (event: FocusEvent) => {
|
|
117
|
+
if (!container.contains(event.target as Node)) {
|
|
118
|
+
const focusableElements = getFocusableElements();
|
|
119
|
+
if (focusableElements.length > 0) {
|
|
120
|
+
focusableElements[0]?.focus();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
126
|
+
document.addEventListener('focusin', handleFocusIn);
|
|
127
|
+
|
|
128
|
+
return () => {
|
|
129
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
130
|
+
document.removeEventListener('focusin', handleFocusIn);
|
|
131
|
+
|
|
132
|
+
// Restore focus to previous element
|
|
133
|
+
if (restoreFocus && previousActiveElement.current) {
|
|
134
|
+
// Small delay to prevent focus issues
|
|
135
|
+
requestAnimationFrame(() => {
|
|
136
|
+
previousActiveElement.current?.focus();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}, [isActive, containerRef, onEscape, autoFocus, restoreFocus]);
|
|
141
|
+
}
|