@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,208 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useMemo } from 'react';
|
|
4
|
+
import { useUIStore } from '@/lib/stores/uiStore';
|
|
5
|
+
import { useFileStore } from '@/lib/stores/fileStore';
|
|
6
|
+
import { useProjectStore } from '@/lib/stores/projectStore';
|
|
7
|
+
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
|
8
|
+
import { useListNavigation } from '@/hooks/useListNavigation';
|
|
9
|
+
import { File, FileText, FileCode, FileJson, Clock, Pin } from 'lucide-react';
|
|
10
|
+
import { cn, getFileName } from '@/lib/utils';
|
|
11
|
+
|
|
12
|
+
interface RecentFileItem {
|
|
13
|
+
path: string;
|
|
14
|
+
name: string;
|
|
15
|
+
relativePath: string;
|
|
16
|
+
extension: string;
|
|
17
|
+
isOpen: boolean;
|
|
18
|
+
isPinned: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const getFileIcon = (extension: string) => {
|
|
22
|
+
switch (extension) {
|
|
23
|
+
case 'md':
|
|
24
|
+
return <FileText className="w-4 h-4 text-blue-400" aria-hidden="true" />;
|
|
25
|
+
case 'json':
|
|
26
|
+
case 'yaml':
|
|
27
|
+
case 'yml':
|
|
28
|
+
return <FileJson className="w-4 h-4 text-yellow-400" aria-hidden="true" />;
|
|
29
|
+
case 'ts':
|
|
30
|
+
case 'tsx':
|
|
31
|
+
case 'js':
|
|
32
|
+
case 'jsx':
|
|
33
|
+
return <FileCode className="w-4 h-4 text-green-400" aria-hidden="true" />;
|
|
34
|
+
default:
|
|
35
|
+
return <File className="w-4 h-4 text-gray-400" aria-hidden="true" />;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Recent Files modal (Ctrl+Tab).
|
|
41
|
+
* Shows recently opened files for quick navigation.
|
|
42
|
+
*/
|
|
43
|
+
export function RecentFiles() {
|
|
44
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
|
|
47
|
+
const { activeModal, closeModal } = useUIStore();
|
|
48
|
+
const { recentFiles, openFiles, pinnedFiles, openFile, activeFile } = useFileStore();
|
|
49
|
+
const { currentProject } = useProjectStore();
|
|
50
|
+
|
|
51
|
+
const isOpen = activeModal === 'recentFiles';
|
|
52
|
+
|
|
53
|
+
// Build list of recent files with metadata
|
|
54
|
+
const fileItems = useMemo((): RecentFileItem[] => {
|
|
55
|
+
const projectPath = currentProject?.path || '';
|
|
56
|
+
|
|
57
|
+
return recentFiles.map((path) => {
|
|
58
|
+
let relativePath = path;
|
|
59
|
+
if (projectPath && path.startsWith(projectPath)) {
|
|
60
|
+
relativePath = path.slice(projectPath.length);
|
|
61
|
+
if (relativePath.startsWith('/')) {
|
|
62
|
+
relativePath = relativePath.slice(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const name = getFileName(path);
|
|
67
|
+
const extension = name.includes('.') ? name.split('.').pop() || '' : '';
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
path,
|
|
71
|
+
name,
|
|
72
|
+
relativePath,
|
|
73
|
+
extension,
|
|
74
|
+
isOpen: openFiles.some((f) => f.path === path),
|
|
75
|
+
isPinned: pinnedFiles.includes(path),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}, [recentFiles, openFiles, pinnedFiles, currentProject]);
|
|
79
|
+
|
|
80
|
+
// Keyboard navigation
|
|
81
|
+
const { selectedIndex, setSelectedIndex, handleKeyDown, isSelected } = useListNavigation({
|
|
82
|
+
items: fileItems,
|
|
83
|
+
onSelect: (item) => {
|
|
84
|
+
openFile(item.path);
|
|
85
|
+
closeModal();
|
|
86
|
+
},
|
|
87
|
+
onEscape: closeModal,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Focus trap
|
|
91
|
+
useFocusTrap(modalRef, isOpen, {
|
|
92
|
+
onEscape: closeModal,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Reset selection when opened
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (isOpen) {
|
|
98
|
+
// Find current active file in the list and select it, or default to 0
|
|
99
|
+
const activeIndex = fileItems.findIndex((f) => f.path === activeFile);
|
|
100
|
+
setSelectedIndex(activeIndex >= 0 ? activeIndex : 0);
|
|
101
|
+
}
|
|
102
|
+
}, [isOpen, fileItems, activeFile, setSelectedIndex]);
|
|
103
|
+
|
|
104
|
+
// Scroll selected into view
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!listRef.current || !isOpen) return;
|
|
107
|
+
const selectedEl = listRef.current.children[selectedIndex] as HTMLElement;
|
|
108
|
+
selectedEl?.scrollIntoView({ block: 'nearest' });
|
|
109
|
+
}, [selectedIndex, isOpen]);
|
|
110
|
+
|
|
111
|
+
if (!isOpen) return null;
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
|
|
115
|
+
{/* Backdrop */}
|
|
116
|
+
<div
|
|
117
|
+
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
118
|
+
onClick={closeModal}
|
|
119
|
+
aria-hidden="true"
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
{/* Modal */}
|
|
123
|
+
<div
|
|
124
|
+
ref={modalRef}
|
|
125
|
+
className={cn(
|
|
126
|
+
'relative w-full max-w-lg bg-[#12121a] border border-white/10 rounded-xl shadow-2xl overflow-hidden',
|
|
127
|
+
'animate-in fade-in-0 zoom-in-95 duration-150'
|
|
128
|
+
)}
|
|
129
|
+
role="dialog"
|
|
130
|
+
aria-modal="true"
|
|
131
|
+
aria-label="Recent files"
|
|
132
|
+
onKeyDown={handleKeyDown}
|
|
133
|
+
>
|
|
134
|
+
{/* Header */}
|
|
135
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-white/10">
|
|
136
|
+
<Clock className="w-5 h-5 text-purple-400" aria-hidden="true" />
|
|
137
|
+
<h2 className="text-sm font-medium text-white">Recent Files</h2>
|
|
138
|
+
<div className="flex-1" />
|
|
139
|
+
<div className="text-xs text-gray-600 border border-white/10 px-1.5 py-0.5 rounded">
|
|
140
|
+
esc
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* File List */}
|
|
145
|
+
<div
|
|
146
|
+
ref={listRef}
|
|
147
|
+
className="max-h-[50vh] overflow-y-auto"
|
|
148
|
+
role="listbox"
|
|
149
|
+
aria-label="Recent files list"
|
|
150
|
+
>
|
|
151
|
+
{fileItems.length === 0 ? (
|
|
152
|
+
<div className="px-4 py-8 text-center text-gray-500 text-sm">
|
|
153
|
+
No recent files
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
fileItems.map((file, index) => (
|
|
157
|
+
<button
|
|
158
|
+
key={file.path}
|
|
159
|
+
onClick={() => {
|
|
160
|
+
openFile(file.path);
|
|
161
|
+
closeModal();
|
|
162
|
+
}}
|
|
163
|
+
onMouseEnter={() => setSelectedIndex(index)}
|
|
164
|
+
className={cn(
|
|
165
|
+
'w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors',
|
|
166
|
+
isSelected(index)
|
|
167
|
+
? 'bg-purple-500/20 border-l-2 border-purple-500'
|
|
168
|
+
: 'hover:bg-white/5 border-l-2 border-transparent'
|
|
169
|
+
)}
|
|
170
|
+
role="option"
|
|
171
|
+
aria-selected={isSelected(index)}
|
|
172
|
+
tabIndex={isSelected(index) ? 0 : -1}
|
|
173
|
+
>
|
|
174
|
+
{getFileIcon(file.extension)}
|
|
175
|
+
|
|
176
|
+
<div className="flex-1 min-w-0">
|
|
177
|
+
<div className="flex items-center gap-2">
|
|
178
|
+
<span className="text-sm text-white truncate">{file.name}</span>
|
|
179
|
+
{file.isPinned && (
|
|
180
|
+
<Pin className="w-3 h-3 text-purple-400 flex-shrink-0" aria-label="Pinned" />
|
|
181
|
+
)}
|
|
182
|
+
{file.isOpen && (
|
|
183
|
+
<span className="text-xs text-gray-500 flex-shrink-0">• open</span>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
<div className="text-xs text-gray-500 truncate">{file.relativePath}</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{file.path === activeFile && (
|
|
190
|
+
<span className="text-xs text-purple-400 flex-shrink-0">current</span>
|
|
191
|
+
)}
|
|
192
|
+
</button>
|
|
193
|
+
))
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{/* Footer */}
|
|
198
|
+
<div className="flex items-center justify-between px-4 py-2 border-t border-white/10 text-xs text-gray-500">
|
|
199
|
+
<div className="flex items-center gap-4">
|
|
200
|
+
<span>↑↓ navigate</span>
|
|
201
|
+
<span>↵ open</span>
|
|
202
|
+
</div>
|
|
203
|
+
<div>{fileItems.length} files</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from 'react';
|
|
4
|
+
import { FolderPlus, X, Check, ChevronDown } from 'lucide-react';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
import { useProjectStore } from '@/lib/stores/projectStore';
|
|
7
|
+
|
|
8
|
+
export function ProjectSelector() {
|
|
9
|
+
const { projects, activeProjectPath, addProject, removeProject, setActiveProject } = useProjectStore();
|
|
10
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
11
|
+
const [showAddInput, setShowAddInput] = useState(false);
|
|
12
|
+
const [newPath, setNewPath] = useState('');
|
|
13
|
+
const [isAdding, setIsAdding] = useState(false);
|
|
14
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
15
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
16
|
+
|
|
17
|
+
const activeProject = projects.find(p => p.path === activeProjectPath) || projects[0];
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (showAddInput && inputRef.current) {
|
|
21
|
+
inputRef.current.focus();
|
|
22
|
+
}
|
|
23
|
+
}, [showAddInput]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
27
|
+
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
28
|
+
setIsOpen(false);
|
|
29
|
+
setShowAddInput(false);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
33
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const handleAddProject = async () => {
|
|
37
|
+
if (!newPath.trim()) return;
|
|
38
|
+
setIsAdding(true);
|
|
39
|
+
await addProject(newPath.trim());
|
|
40
|
+
setIsAdding(false);
|
|
41
|
+
setNewPath('');
|
|
42
|
+
setShowAddInput(false);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const getProjectName = (path: string) => path.split('/').pop() || path;
|
|
46
|
+
|
|
47
|
+
if (projects.length === 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="relative" ref={dropdownRef}>
|
|
53
|
+
<button
|
|
54
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
55
|
+
className="flex items-center gap-2 px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-lg transition-colors text-sm w-full"
|
|
56
|
+
>
|
|
57
|
+
<div className="w-2 h-2 rounded-full bg-green-400 flex-shrink-0" />
|
|
58
|
+
<span className="truncate text-white font-medium">
|
|
59
|
+
{activeProject ? getProjectName(activeProject.path) : 'No project'}
|
|
60
|
+
</span>
|
|
61
|
+
{projects.length > 1 && (
|
|
62
|
+
<span className="text-[10px] text-gray-500 flex-shrink-0">+{projects.length - 1}</span>
|
|
63
|
+
)}
|
|
64
|
+
<ChevronDown className={cn('w-3.5 h-3.5 text-gray-400 ml-auto flex-shrink-0 transition-transform', isOpen && 'rotate-180')} />
|
|
65
|
+
</button>
|
|
66
|
+
|
|
67
|
+
{isOpen && (
|
|
68
|
+
<div className="absolute top-full left-0 right-0 mt-1 bg-[#1a1a24] border border-white/10 rounded-lg shadow-xl z-50 overflow-hidden">
|
|
69
|
+
{/* Project list */}
|
|
70
|
+
<div className="max-h-48 overflow-y-auto">
|
|
71
|
+
{projects.map((project) => (
|
|
72
|
+
<div
|
|
73
|
+
key={project.path}
|
|
74
|
+
className={cn(
|
|
75
|
+
'flex items-center gap-2 px-3 py-2 hover:bg-white/5 cursor-pointer group',
|
|
76
|
+
project.path === activeProjectPath && 'bg-purple-500/10'
|
|
77
|
+
)}
|
|
78
|
+
onClick={() => {
|
|
79
|
+
setActiveProject(project.path);
|
|
80
|
+
setIsOpen(false);
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{project.path === activeProjectPath ? (
|
|
84
|
+
<Check className="w-3.5 h-3.5 text-purple-400 flex-shrink-0" />
|
|
85
|
+
) : (
|
|
86
|
+
<div className="w-3.5 h-3.5 flex-shrink-0" />
|
|
87
|
+
)}
|
|
88
|
+
<div className="flex-1 min-w-0">
|
|
89
|
+
<div className="text-sm text-white truncate">{getProjectName(project.path)}</div>
|
|
90
|
+
<div className="text-[10px] text-gray-500 truncate">{project.path}</div>
|
|
91
|
+
</div>
|
|
92
|
+
{projects.length > 1 && (
|
|
93
|
+
<button
|
|
94
|
+
onClick={(e) => {
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
removeProject(project.path);
|
|
97
|
+
}}
|
|
98
|
+
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-white/10 rounded transition-all text-gray-400 hover:text-red-400"
|
|
99
|
+
title="Remove project"
|
|
100
|
+
>
|
|
101
|
+
<X className="w-3 h-3" />
|
|
102
|
+
</button>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{/* Add project */}
|
|
109
|
+
<div className="border-t border-white/10">
|
|
110
|
+
{showAddInput ? (
|
|
111
|
+
<div className="p-2 flex gap-1">
|
|
112
|
+
<input
|
|
113
|
+
ref={inputRef}
|
|
114
|
+
type="text"
|
|
115
|
+
value={newPath}
|
|
116
|
+
onChange={(e) => setNewPath(e.target.value)}
|
|
117
|
+
onKeyDown={(e) => {
|
|
118
|
+
if (e.key === 'Enter') handleAddProject();
|
|
119
|
+
if (e.key === 'Escape') setShowAddInput(false);
|
|
120
|
+
}}
|
|
121
|
+
placeholder="/path/to/project"
|
|
122
|
+
className="flex-1 px-2 py-1.5 bg-white/5 border border-white/10 rounded text-sm text-white placeholder-gray-500 focus:outline-none focus:border-purple-500"
|
|
123
|
+
disabled={isAdding}
|
|
124
|
+
/>
|
|
125
|
+
<button
|
|
126
|
+
onClick={handleAddProject}
|
|
127
|
+
disabled={isAdding || !newPath.trim()}
|
|
128
|
+
className="px-2 py-1.5 bg-purple-600 hover:bg-purple-500 text-white text-xs rounded transition-colors disabled:opacity-50"
|
|
129
|
+
>
|
|
130
|
+
{isAdding ? '...' : 'Add'}
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
) : (
|
|
134
|
+
<button
|
|
135
|
+
onClick={() => setShowAddInput(true)}
|
|
136
|
+
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-white hover:bg-white/5 transition-colors"
|
|
137
|
+
>
|
|
138
|
+
<FolderPlus className="w-3.5 h-3.5" />
|
|
139
|
+
Add Project
|
|
140
|
+
</button>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
|
|
5
|
+
interface BaseSettingProps {
|
|
6
|
+
label: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ToggleSettingProps extends BaseSettingProps {
|
|
11
|
+
type: 'toggle';
|
|
12
|
+
value: boolean;
|
|
13
|
+
onChange: (value: boolean) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface SliderSettingProps extends BaseSettingProps {
|
|
17
|
+
type: 'slider';
|
|
18
|
+
value: number;
|
|
19
|
+
onChange: (value: number) => void;
|
|
20
|
+
min: number;
|
|
21
|
+
max: number;
|
|
22
|
+
step?: number;
|
|
23
|
+
unit?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SelectSettingProps<T extends string | number> extends BaseSettingProps {
|
|
27
|
+
type: 'select';
|
|
28
|
+
value: T;
|
|
29
|
+
onChange: (value: T) => void;
|
|
30
|
+
options: { label: string; value: T; disabled?: boolean }[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SettingItemProps =
|
|
34
|
+
| ToggleSettingProps
|
|
35
|
+
| SliderSettingProps
|
|
36
|
+
| SelectSettingProps<string | number>;
|
|
37
|
+
|
|
38
|
+
export function SettingItem(props: SettingItemProps) {
|
|
39
|
+
const { label, description, type } = props;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex items-center justify-between py-3 border-b border-white/5 last:border-0">
|
|
43
|
+
<div className="flex-1 pr-4">
|
|
44
|
+
<h4 className="text-sm font-medium text-white">{label}</h4>
|
|
45
|
+
{description && (
|
|
46
|
+
<p className="text-xs text-gray-400 mt-0.5">{description}</p>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div className="flex-shrink-0">
|
|
51
|
+
{type === 'toggle' && <ToggleControl {...props} />}
|
|
52
|
+
{type === 'slider' && <SliderControl {...props} />}
|
|
53
|
+
{type === 'select' && <SelectControl {...props} />}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ToggleControl({ value, onChange }: ToggleSettingProps) {
|
|
60
|
+
return (
|
|
61
|
+
<button
|
|
62
|
+
onClick={() => onChange(!value)}
|
|
63
|
+
className={cn(
|
|
64
|
+
'relative w-10 h-5 rounded-full transition-colors duration-200',
|
|
65
|
+
value ? 'bg-purple-500' : 'bg-gray-600'
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
<span
|
|
69
|
+
className={cn(
|
|
70
|
+
'absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform duration-200',
|
|
71
|
+
value && 'translate-x-5'
|
|
72
|
+
)}
|
|
73
|
+
/>
|
|
74
|
+
</button>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function SliderControl({ value, onChange, min, max, step = 1, unit }: SliderSettingProps) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="flex items-center gap-3">
|
|
81
|
+
<input
|
|
82
|
+
type="range"
|
|
83
|
+
value={value}
|
|
84
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
85
|
+
min={min}
|
|
86
|
+
max={max}
|
|
87
|
+
step={step}
|
|
88
|
+
className="w-24 h-1.5 bg-gray-600 rounded-full appearance-none cursor-pointer
|
|
89
|
+
[&::-webkit-slider-thumb]:appearance-none
|
|
90
|
+
[&::-webkit-slider-thumb]:w-3.5
|
|
91
|
+
[&::-webkit-slider-thumb]:h-3.5
|
|
92
|
+
[&::-webkit-slider-thumb]:rounded-full
|
|
93
|
+
[&::-webkit-slider-thumb]:bg-purple-500
|
|
94
|
+
[&::-webkit-slider-thumb]:cursor-pointer
|
|
95
|
+
[&::-webkit-slider-thumb]:transition-transform
|
|
96
|
+
[&::-webkit-slider-thumb]:hover:scale-110"
|
|
97
|
+
/>
|
|
98
|
+
<span className="text-xs text-gray-400 w-12 text-right tabular-nums">
|
|
99
|
+
{value}{unit}
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function SelectControl<T extends string | number>({
|
|
106
|
+
value,
|
|
107
|
+
onChange,
|
|
108
|
+
options,
|
|
109
|
+
}: SelectSettingProps<T>) {
|
|
110
|
+
return (
|
|
111
|
+
<select
|
|
112
|
+
value={value}
|
|
113
|
+
onChange={(e) => {
|
|
114
|
+
const newValue = typeof value === 'number'
|
|
115
|
+
? Number(e.target.value)
|
|
116
|
+
: e.target.value;
|
|
117
|
+
onChange(newValue as T);
|
|
118
|
+
}}
|
|
119
|
+
className="bg-[#1a1a24] border border-white/10 rounded px-2 py-1 text-sm text-white
|
|
120
|
+
focus:outline-none focus:ring-1 focus:ring-purple-500 cursor-pointer
|
|
121
|
+
[&>option]:bg-[#1a1a24] [&>option]:text-white"
|
|
122
|
+
>
|
|
123
|
+
{options.map((opt) => (
|
|
124
|
+
<option key={String(opt.value)} value={opt.value} disabled={opt.disabled}>
|
|
125
|
+
{opt.label}
|
|
126
|
+
</option>
|
|
127
|
+
))}
|
|
128
|
+
</select>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Section header component
|
|
133
|
+
export function SettingSection({
|
|
134
|
+
title,
|
|
135
|
+
children,
|
|
136
|
+
}: {
|
|
137
|
+
title: string;
|
|
138
|
+
children: React.ReactNode;
|
|
139
|
+
}) {
|
|
140
|
+
return (
|
|
141
|
+
<div className="mb-6">
|
|
142
|
+
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
|
143
|
+
{title}
|
|
144
|
+
</h3>
|
|
145
|
+
<div className="bg-[#12121a] rounded-lg p-4 border border-white/5">
|
|
146
|
+
{children}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|