@aion0/forge 0.2.0 → 0.2.2
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/app/api/code/route.ts +83 -22
- package/app/api/docs/route.ts +48 -3
- package/app/api/git/route.ts +131 -0
- package/app/api/online/route.ts +40 -0
- package/app/api/preview/[...path]/route.ts +64 -0
- package/app/api/preview/route.ts +135 -0
- package/app/api/tasks/[id]/route.ts +8 -2
- package/components/CodeViewer.tsx +274 -37
- package/components/Dashboard.tsx +68 -3
- package/components/DocsViewer.tsx +54 -5
- package/components/NewTaskModal.tsx +7 -7
- package/components/PreviewPanel.tsx +154 -0
- package/components/ProjectManager.tsx +410 -0
- package/components/SettingsModal.tsx +4 -1
- package/components/TaskDetail.tsx +1 -1
- package/components/WebTerminal.tsx +131 -21
- package/lib/task-manager.ts +70 -12
- package/lib/telegram-bot.ts +99 -1
- package/package.json +1 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface Project {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
root: string;
|
|
9
|
+
hasGit: boolean;
|
|
10
|
+
language: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface GitInfo {
|
|
14
|
+
branch: string;
|
|
15
|
+
changes: { status: string; path: string }[];
|
|
16
|
+
remote: string;
|
|
17
|
+
ahead: number;
|
|
18
|
+
behind: number;
|
|
19
|
+
lastCommit: string;
|
|
20
|
+
log: { hash: string; message: string; author: string; date: string }[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function ProjectManager() {
|
|
24
|
+
const [projects, setProjects] = useState<Project[]>([]);
|
|
25
|
+
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
|
26
|
+
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
|
|
27
|
+
const [loading, setLoading] = useState(false);
|
|
28
|
+
const [commitMsg, setCommitMsg] = useState('');
|
|
29
|
+
const [gitLoading, setGitLoading] = useState(false);
|
|
30
|
+
const [gitResult, setGitResult] = useState<{ ok?: boolean; error?: string } | null>(null);
|
|
31
|
+
const [showClone, setShowClone] = useState(false);
|
|
32
|
+
const [cloneUrl, setCloneUrl] = useState('');
|
|
33
|
+
const [cloneLoading, setCloneLoading] = useState(false);
|
|
34
|
+
const [fileTree, setFileTree] = useState<any[]>([]);
|
|
35
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
36
|
+
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
37
|
+
const [fileLanguage, setFileLanguage] = useState('');
|
|
38
|
+
const [fileLoading, setFileLoading] = useState(false);
|
|
39
|
+
const [showLog, setShowLog] = useState(false);
|
|
40
|
+
|
|
41
|
+
// Fetch projects
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
fetch('/api/projects').then(r => r.json())
|
|
44
|
+
.then((p: Project[]) => { if (Array.isArray(p)) setProjects(p); })
|
|
45
|
+
.catch(() => {});
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
// Fetch git info when project selected
|
|
49
|
+
const fetchGitInfo = useCallback(async (project: Project) => {
|
|
50
|
+
if (!project.hasGit) { setGitInfo(null); return; }
|
|
51
|
+
setLoading(true);
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`/api/git?dir=${encodeURIComponent(project.path)}`);
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
if (!data.error) setGitInfo(data);
|
|
56
|
+
else setGitInfo(null);
|
|
57
|
+
} catch { setGitInfo(null); }
|
|
58
|
+
setLoading(false);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
// Fetch file tree
|
|
62
|
+
const fetchTree = useCallback(async (project: Project) => {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(project.path)}`);
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
setFileTree(data.tree || []);
|
|
67
|
+
} catch { setFileTree([]); }
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const selectProject = useCallback((p: Project) => {
|
|
71
|
+
setSelectedProject(p);
|
|
72
|
+
setSelectedFile(null);
|
|
73
|
+
setFileContent(null);
|
|
74
|
+
setGitResult(null);
|
|
75
|
+
setCommitMsg('');
|
|
76
|
+
fetchGitInfo(p);
|
|
77
|
+
fetchTree(p);
|
|
78
|
+
}, [fetchGitInfo, fetchTree]);
|
|
79
|
+
|
|
80
|
+
const openFile = useCallback(async (path: string) => {
|
|
81
|
+
if (!selectedProject) return;
|
|
82
|
+
setSelectedFile(path);
|
|
83
|
+
setFileLoading(true);
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(selectedProject.path)}&file=${encodeURIComponent(path)}`);
|
|
86
|
+
const data = await res.json();
|
|
87
|
+
setFileContent(data.content || null);
|
|
88
|
+
setFileLanguage(data.language || '');
|
|
89
|
+
} catch { setFileContent(null); }
|
|
90
|
+
setFileLoading(false);
|
|
91
|
+
}, [selectedProject]);
|
|
92
|
+
|
|
93
|
+
// Git operations
|
|
94
|
+
const gitAction = async (action: string, extra?: any) => {
|
|
95
|
+
if (!selectedProject) return;
|
|
96
|
+
setGitLoading(true);
|
|
97
|
+
setGitResult(null);
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch('/api/git', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ action, dir: selectedProject.path, ...extra }),
|
|
103
|
+
});
|
|
104
|
+
const data = await res.json();
|
|
105
|
+
setGitResult(data);
|
|
106
|
+
if (data.ok) fetchGitInfo(selectedProject);
|
|
107
|
+
} catch (e: any) {
|
|
108
|
+
setGitResult({ error: e.message });
|
|
109
|
+
}
|
|
110
|
+
setGitLoading(false);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleClone = async () => {
|
|
114
|
+
if (!cloneUrl.trim()) return;
|
|
115
|
+
setCloneLoading(true);
|
|
116
|
+
setGitResult(null);
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch('/api/git', {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ action: 'clone', repoUrl: cloneUrl.trim() }),
|
|
122
|
+
});
|
|
123
|
+
const data = await res.json();
|
|
124
|
+
if (data.ok) {
|
|
125
|
+
setCloneUrl('');
|
|
126
|
+
setShowClone(false);
|
|
127
|
+
// Refresh project list
|
|
128
|
+
const pRes = await fetch('/api/projects');
|
|
129
|
+
const pData = await pRes.json();
|
|
130
|
+
if (Array.isArray(pData)) setProjects(pData);
|
|
131
|
+
setGitResult({ ok: true });
|
|
132
|
+
} else {
|
|
133
|
+
setGitResult(data);
|
|
134
|
+
}
|
|
135
|
+
} catch (e: any) {
|
|
136
|
+
setGitResult({ error: e.message });
|
|
137
|
+
}
|
|
138
|
+
setCloneLoading(false);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Group projects by root
|
|
142
|
+
const roots = [...new Set(projects.map(p => p.root))];
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="flex-1 flex min-h-0">
|
|
146
|
+
{/* Left sidebar — project list */}
|
|
147
|
+
<aside className="w-64 border-r border-[var(--border)] flex flex-col shrink-0">
|
|
148
|
+
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
|
|
149
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)]">Projects</span>
|
|
150
|
+
<button
|
|
151
|
+
onClick={() => setShowClone(v => !v)}
|
|
152
|
+
className={`text-[10px] px-2 py-0.5 rounded ${showClone ? 'text-white bg-[var(--accent)]' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
|
|
153
|
+
>
|
|
154
|
+
+ Clone
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Clone form */}
|
|
159
|
+
{showClone && (
|
|
160
|
+
<div className="p-2 border-b border-[var(--border)] space-y-2">
|
|
161
|
+
<input
|
|
162
|
+
value={cloneUrl}
|
|
163
|
+
onChange={e => setCloneUrl(e.target.value)}
|
|
164
|
+
onKeyDown={e => e.key === 'Enter' && handleClone()}
|
|
165
|
+
placeholder="https://github.com/user/repo.git"
|
|
166
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
167
|
+
/>
|
|
168
|
+
<button
|
|
169
|
+
onClick={handleClone}
|
|
170
|
+
disabled={cloneLoading || !cloneUrl.trim()}
|
|
171
|
+
className="w-full text-[10px] px-2 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
172
|
+
>
|
|
173
|
+
{cloneLoading ? 'Cloning...' : 'Clone Repository'}
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
{/* Project list */}
|
|
179
|
+
<div className="flex-1 overflow-y-auto">
|
|
180
|
+
{roots.map(root => {
|
|
181
|
+
const rootName = root.split('/').pop() || root;
|
|
182
|
+
const rootProjects = projects.filter(p => p.root === root);
|
|
183
|
+
return (
|
|
184
|
+
<div key={root}>
|
|
185
|
+
<div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase bg-[var(--bg-tertiary)]">
|
|
186
|
+
{rootName}
|
|
187
|
+
</div>
|
|
188
|
+
{rootProjects.map(p => (
|
|
189
|
+
<button
|
|
190
|
+
key={p.path}
|
|
191
|
+
onClick={() => selectProject(p)}
|
|
192
|
+
className={`w-full text-left px-3 py-1.5 text-xs border-b border-[var(--border)]/30 flex items-center gap-2 ${
|
|
193
|
+
selectedProject?.path === p.path ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
|
|
194
|
+
}`}
|
|
195
|
+
>
|
|
196
|
+
<span className="truncate">{p.name}</span>
|
|
197
|
+
{p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
|
|
198
|
+
{p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
|
|
199
|
+
</button>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
205
|
+
</aside>
|
|
206
|
+
|
|
207
|
+
{/* Main area */}
|
|
208
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
209
|
+
{selectedProject ? (
|
|
210
|
+
<>
|
|
211
|
+
{/* Project header */}
|
|
212
|
+
<div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
213
|
+
<div className="flex items-center gap-2">
|
|
214
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">{selectedProject.name}</span>
|
|
215
|
+
{gitInfo?.branch && (
|
|
216
|
+
<span className="text-[9px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded">{gitInfo.branch}</span>
|
|
217
|
+
)}
|
|
218
|
+
{gitInfo?.ahead ? <span className="text-[9px] text-green-400">↑{gitInfo.ahead}</span> : null}
|
|
219
|
+
{gitInfo?.behind ? <span className="text-[9px] text-yellow-400">↓{gitInfo.behind}</span> : null}
|
|
220
|
+
<button
|
|
221
|
+
onClick={() => { fetchGitInfo(selectedProject); fetchTree(selectedProject); if (selectedFile) openFile(selectedFile); }}
|
|
222
|
+
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto"
|
|
223
|
+
title="Refresh"
|
|
224
|
+
>
|
|
225
|
+
↻
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
<div className="text-[9px] text-[var(--text-secondary)] mt-0.5">
|
|
229
|
+
{selectedProject.path}
|
|
230
|
+
{gitInfo?.remote && (
|
|
231
|
+
<span className="ml-2">{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
{gitInfo?.lastCommit && (
|
|
235
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
236
|
+
<span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{gitInfo.lastCommit}</span>
|
|
237
|
+
<button
|
|
238
|
+
onClick={() => setShowLog(v => !v)}
|
|
239
|
+
className={`text-[9px] px-1.5 py-0.5 rounded shrink-0 ${showLog ? 'text-white bg-[var(--accent)]/30' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
|
|
240
|
+
>
|
|
241
|
+
History
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{/* Git log */}
|
|
248
|
+
{showLog && gitInfo?.log && gitInfo.log.length > 0 && (
|
|
249
|
+
<div className="max-h-48 overflow-y-auto border-b border-[var(--border)] bg-[var(--bg-tertiary)]">
|
|
250
|
+
{gitInfo.log.map(c => (
|
|
251
|
+
<div key={c.hash} className="px-4 py-1.5 border-b border-[var(--border)]/30 text-xs flex items-start gap-2">
|
|
252
|
+
<span className="font-mono text-[var(--accent)] shrink-0 text-[10px]">{c.hash}</span>
|
|
253
|
+
<span className="text-[var(--text-primary)] truncate flex-1">{c.message}</span>
|
|
254
|
+
<span className="text-[var(--text-secondary)] text-[9px] shrink-0">{c.author}</span>
|
|
255
|
+
<span className="text-[var(--text-secondary)] text-[9px] shrink-0 w-16 text-right">{c.date}</span>
|
|
256
|
+
</div>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{/* Content area */}
|
|
262
|
+
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
263
|
+
{/* File tree */}
|
|
264
|
+
<div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
|
|
265
|
+
{fileTree.map((node: any) => (
|
|
266
|
+
<FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
|
|
267
|
+
))}
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{/* File content — independent scroll */}
|
|
271
|
+
<div className="flex-1 min-w-0 overflow-auto bg-[var(--bg-primary)]">
|
|
272
|
+
{fileLoading ? (
|
|
273
|
+
<div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
274
|
+
) : selectedFile && fileContent !== null ? (
|
|
275
|
+
<>
|
|
276
|
+
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
|
|
277
|
+
<pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
|
|
278
|
+
{fileContent.split('\n').map((line, i) => (
|
|
279
|
+
<div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
|
|
280
|
+
<span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
|
|
281
|
+
<span className="flex-1">{line || ' '}</span>
|
|
282
|
+
</div>
|
|
283
|
+
))}
|
|
284
|
+
</pre>
|
|
285
|
+
</>
|
|
286
|
+
) : (
|
|
287
|
+
<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
|
288
|
+
Select a file to view
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Git panel — bottom */}
|
|
295
|
+
{gitInfo && (
|
|
296
|
+
<div className="border-t border-[var(--border)] shrink-0">
|
|
297
|
+
{/* Changes list */}
|
|
298
|
+
{gitInfo.changes.length > 0 && (
|
|
299
|
+
<div className="max-h-32 overflow-y-auto border-b border-[var(--border)]">
|
|
300
|
+
<div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0">
|
|
301
|
+
{gitInfo.changes.length} changes
|
|
302
|
+
</div>
|
|
303
|
+
{gitInfo.changes.map(g => (
|
|
304
|
+
<div key={g.path} className="px-3 py-0.5 text-xs flex items-center gap-2">
|
|
305
|
+
<span className={`text-[10px] font-mono w-4 ${
|
|
306
|
+
g.status.includes('M') ? 'text-yellow-500' :
|
|
307
|
+
g.status.includes('?') ? 'text-green-500' :
|
|
308
|
+
g.status.includes('D') ? 'text-red-500' : 'text-[var(--text-secondary)]'
|
|
309
|
+
}`}>
|
|
310
|
+
{g.status.includes('?') ? '+' : g.status[0]}
|
|
311
|
+
</span>
|
|
312
|
+
<span className="text-[var(--text-secondary)] truncate">{g.path}</span>
|
|
313
|
+
</div>
|
|
314
|
+
))}
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
{/* Git actions */}
|
|
319
|
+
<div className="px-3 py-2 flex items-center gap-2">
|
|
320
|
+
<input
|
|
321
|
+
value={commitMsg}
|
|
322
|
+
onChange={e => setCommitMsg(e.target.value)}
|
|
323
|
+
onKeyDown={e => e.key === 'Enter' && commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
|
|
324
|
+
placeholder="Commit message..."
|
|
325
|
+
className="flex-1 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
326
|
+
/>
|
|
327
|
+
<button
|
|
328
|
+
onClick={() => commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
|
|
329
|
+
disabled={gitLoading || !commitMsg.trim() || gitInfo.changes.length === 0}
|
|
330
|
+
className="text-[10px] px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0"
|
|
331
|
+
>
|
|
332
|
+
Commit
|
|
333
|
+
</button>
|
|
334
|
+
<button
|
|
335
|
+
onClick={() => gitAction('push')}
|
|
336
|
+
disabled={gitLoading || gitInfo.ahead === 0}
|
|
337
|
+
className="text-[10px] px-3 py-1.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50 shrink-0"
|
|
338
|
+
>
|
|
339
|
+
Push{gitInfo.ahead > 0 ? ` (${gitInfo.ahead})` : ''}
|
|
340
|
+
</button>
|
|
341
|
+
<button
|
|
342
|
+
onClick={() => gitAction('pull')}
|
|
343
|
+
disabled={gitLoading}
|
|
344
|
+
className="text-[10px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
|
|
345
|
+
>
|
|
346
|
+
Pull{gitInfo.behind > 0 ? ` (${gitInfo.behind})` : ''}
|
|
347
|
+
</button>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
{/* Result */}
|
|
351
|
+
{gitResult && (
|
|
352
|
+
<div className={`px-3 py-1 text-[10px] ${gitResult.ok ? 'text-green-400' : 'text-red-400'}`}>
|
|
353
|
+
{gitResult.ok ? '✅ Done' : `❌ ${gitResult.error}`}
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
</>
|
|
359
|
+
) : (
|
|
360
|
+
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
361
|
+
<div className="text-center space-y-2">
|
|
362
|
+
<p className="text-sm">Select a project</p>
|
|
363
|
+
<p className="text-xs">{projects.length} projects across {roots.length} directories</p>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Simple file tree node
|
|
373
|
+
function FileTreeNode({ node, depth, selected, onSelect }: {
|
|
374
|
+
node: { name: string; path: string; type: string; children?: any[] };
|
|
375
|
+
depth: number;
|
|
376
|
+
selected: string | null;
|
|
377
|
+
onSelect: (path: string) => void;
|
|
378
|
+
}) {
|
|
379
|
+
const [expanded, setExpanded] = useState(depth < 1);
|
|
380
|
+
|
|
381
|
+
if (node.type === 'dir') {
|
|
382
|
+
return (
|
|
383
|
+
<div>
|
|
384
|
+
<button
|
|
385
|
+
onClick={() => setExpanded(v => !v)}
|
|
386
|
+
className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
|
|
387
|
+
style={{ paddingLeft: depth * 12 + 4 }}
|
|
388
|
+
>
|
|
389
|
+
<span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
|
|
390
|
+
<span className="text-[var(--text-primary)]">{node.name}</span>
|
|
391
|
+
</button>
|
|
392
|
+
{expanded && node.children?.map((child: any) => (
|
|
393
|
+
<FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} />
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<button
|
|
401
|
+
onClick={() => onSelect(node.path)}
|
|
402
|
+
className={`w-full text-left px-1 py-0.5 rounded text-xs truncate ${
|
|
403
|
+
selected === node.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
|
404
|
+
}`}
|
|
405
|
+
style={{ paddingLeft: depth * 12 + 16 }}
|
|
406
|
+
>
|
|
407
|
+
{node.name}
|
|
408
|
+
</button>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
@@ -216,9 +216,12 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
216
216
|
<input
|
|
217
217
|
value={settings.telegramChatId}
|
|
218
218
|
onChange={e => setSettings({ ...settings, telegramChatId: e.target.value })}
|
|
219
|
-
placeholder="Chat ID (
|
|
219
|
+
placeholder="Chat ID (comma-separated for multiple)"
|
|
220
220
|
className="w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
221
221
|
/>
|
|
222
|
+
<p className="text-[9px] text-[var(--text-secondary)]">
|
|
223
|
+
Allowed user IDs (whitelist). Multiple IDs separated by commas. Only these users can interact with the bot.
|
|
224
|
+
</p>
|
|
222
225
|
<div className="flex items-center gap-4">
|
|
223
226
|
<label className="flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]">
|
|
224
227
|
<input
|
|
@@ -231,7 +231,7 @@ export default function TaskDetail({
|
|
|
231
231
|
|
|
232
232
|
{editing && (
|
|
233
233
|
<NewTaskModal
|
|
234
|
-
editTask={{ id: task.id, projectName: task.projectName, prompt: task.prompt, priority: task.priority, mode: task.mode }}
|
|
234
|
+
editTask={{ id: task.id, projectName: task.projectName, prompt: task.prompt, priority: task.priority, mode: task.mode, scheduledAt: task.scheduledAt }}
|
|
235
235
|
onClose={() => setEditing(false)}
|
|
236
236
|
onCreate={async (data) => {
|
|
237
237
|
await fetch(`/api/tasks/${task.id}`, {
|