@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
|
@@ -9,6 +9,7 @@ interface FileNode {
|
|
|
9
9
|
name: string;
|
|
10
10
|
path: string;
|
|
11
11
|
type: 'file' | 'dir';
|
|
12
|
+
fileType?: 'md' | 'image' | 'other';
|
|
12
13
|
children?: FileNode[];
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -41,16 +42,20 @@ function TreeNode({ node, depth, selected, onSelect }: {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
const isSelected = selected === node.path;
|
|
45
|
+
const canOpen = node.fileType === 'md' || node.fileType === 'image';
|
|
46
|
+
|
|
44
47
|
return (
|
|
45
48
|
<button
|
|
46
|
-
onClick={() => onSelect(node.path)}
|
|
49
|
+
onClick={() => canOpen && onSelect(node.path)}
|
|
47
50
|
className={`w-full text-left flex items-center gap-1 px-1 py-0.5 rounded text-xs truncate ${
|
|
48
|
-
|
|
51
|
+
!canOpen ? 'text-[var(--text-secondary)]/40 cursor-default'
|
|
52
|
+
: isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
|
|
53
|
+
: 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
|
49
54
|
}`}
|
|
50
55
|
style={{ paddingLeft: depth * 12 + 16 }}
|
|
51
56
|
title={node.path}
|
|
52
57
|
>
|
|
53
|
-
{node.name.replace(/\.md$/, '')}
|
|
58
|
+
{node.fileType === 'image' ? '🖼 ' : ''}{node.name.replace(/\.md$/, '')}
|
|
54
59
|
</button>
|
|
55
60
|
);
|
|
56
61
|
}
|
|
@@ -92,13 +97,30 @@ export default function DocsViewer() {
|
|
|
92
97
|
|
|
93
98
|
useEffect(() => { fetchTree(activeRoot); }, [activeRoot, fetchTree]);
|
|
94
99
|
|
|
100
|
+
const [fileWarning, setFileWarning] = useState<string | null>(null);
|
|
101
|
+
|
|
95
102
|
// Fetch file content
|
|
103
|
+
const isImageFile = (path: string) => /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|avif)$/i.test(path);
|
|
104
|
+
|
|
96
105
|
const openFile = useCallback(async (path: string) => {
|
|
97
106
|
setSelectedFile(path);
|
|
107
|
+
setFileWarning(null);
|
|
108
|
+
|
|
109
|
+
if (isImageFile(path)) {
|
|
110
|
+
setContent(null);
|
|
111
|
+
setLoading(false);
|
|
112
|
+
return; // images rendered directly via img tag
|
|
113
|
+
}
|
|
114
|
+
|
|
98
115
|
setLoading(true);
|
|
99
116
|
const res = await fetch(`/api/docs?root=${activeRoot}&file=${encodeURIComponent(path)}`);
|
|
100
117
|
const data = await res.json();
|
|
101
|
-
|
|
118
|
+
if (data.tooLarge) {
|
|
119
|
+
setContent(null);
|
|
120
|
+
setFileWarning(`File too large (${data.sizeLabel})`);
|
|
121
|
+
} else {
|
|
122
|
+
setContent(data.content || null);
|
|
123
|
+
}
|
|
102
124
|
setLoading(false);
|
|
103
125
|
}, [activeRoot]);
|
|
104
126
|
|
|
@@ -157,6 +179,18 @@ export default function DocsViewer() {
|
|
|
157
179
|
</div>
|
|
158
180
|
)}
|
|
159
181
|
|
|
182
|
+
{/* Header with refresh */}
|
|
183
|
+
<div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center">
|
|
184
|
+
<span className="text-[10px] text-[var(--text-secondary)] truncate">{roots[activeRoot] || 'Docs'}</span>
|
|
185
|
+
<button
|
|
186
|
+
onClick={() => { fetchTree(activeRoot); if (selectedFile) openFile(selectedFile); }}
|
|
187
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto shrink-0"
|
|
188
|
+
title="Refresh files"
|
|
189
|
+
>
|
|
190
|
+
↻
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
160
194
|
{/* Search */}
|
|
161
195
|
<div className="p-2 border-b border-[var(--border)]">
|
|
162
196
|
<input
|
|
@@ -219,7 +253,22 @@ export default function DocsViewer() {
|
|
|
219
253
|
</div>
|
|
220
254
|
|
|
221
255
|
{/* Content */}
|
|
222
|
-
{
|
|
256
|
+
{fileWarning ? (
|
|
257
|
+
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
258
|
+
<div className="text-center space-y-2">
|
|
259
|
+
<div className="text-3xl">⚠️</div>
|
|
260
|
+
<p className="text-sm">{fileWarning}</p>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
) : selectedFile && isImageFile(selectedFile) ? (
|
|
264
|
+
<div className="flex-1 overflow-auto flex items-center justify-center p-6 bg-[var(--bg-tertiary)]">
|
|
265
|
+
<img
|
|
266
|
+
src={`/api/docs?root=${activeRoot}&image=${encodeURIComponent(selectedFile)}`}
|
|
267
|
+
alt={selectedFile}
|
|
268
|
+
className="max-w-full max-h-full object-contain rounded shadow-lg"
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
) : selectedFile && content ? (
|
|
223
272
|
<div className="flex-1 overflow-y-auto px-8 py-6">
|
|
224
273
|
{loading ? (
|
|
225
274
|
<div className="text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
@@ -35,7 +35,7 @@ export default function NewTaskModal({
|
|
|
35
35
|
}: {
|
|
36
36
|
onClose: () => void;
|
|
37
37
|
onCreate: (data: TaskData) => void;
|
|
38
|
-
editTask?: { id: string; projectName: string; prompt: string; priority: number; mode: TaskMode };
|
|
38
|
+
editTask?: { id: string; projectName: string; prompt: string; priority: number; mode: TaskMode; scheduledAt?: string };
|
|
39
39
|
}) {
|
|
40
40
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
41
41
|
const [selectedProject, setSelectedProject] = useState(editTask?.projectName || '');
|
|
@@ -60,9 +60,9 @@ export default function NewTaskModal({
|
|
|
60
60
|
const [autoSessionId, setAutoSessionId] = useState<string | null>(null);
|
|
61
61
|
|
|
62
62
|
// Scheduling
|
|
63
|
-
const [scheduleMode, setScheduleMode] = useState<'now' | 'delay' | 'time'>('now');
|
|
63
|
+
const [scheduleMode, setScheduleMode] = useState<'now' | 'delay' | 'time'>(editTask?.scheduledAt ? 'time' : 'now');
|
|
64
64
|
const [delayMinutes, setDelayMinutes] = useState(30);
|
|
65
|
-
const [scheduledTime, setScheduledTime] = useState('');
|
|
65
|
+
const [scheduledTime, setScheduledTime] = useState(editTask?.scheduledAt ? new Date(editTask.scheduledAt).toISOString().slice(0, 16) : '');
|
|
66
66
|
|
|
67
67
|
useEffect(() => {
|
|
68
68
|
fetch('/api/projects').then(r => r.json()).then((p: Project[]) => {
|
|
@@ -88,15 +88,15 @@ export default function NewTaskModal({
|
|
|
88
88
|
.catch(() => setSessions([]));
|
|
89
89
|
}, [selectedProject]);
|
|
90
90
|
|
|
91
|
-
const getScheduledAt = (): string | undefined => {
|
|
92
|
-
if (scheduleMode === 'now') return undefined;
|
|
91
|
+
const getScheduledAt = (): string | null | undefined => {
|
|
92
|
+
if (scheduleMode === 'now') return editTask ? null : undefined; // null clears existing schedule
|
|
93
93
|
if (scheduleMode === 'delay') {
|
|
94
94
|
return new Date(Date.now() + delayMinutes * 60_000).toISOString();
|
|
95
95
|
}
|
|
96
96
|
if (scheduleMode === 'time' && scheduledTime) {
|
|
97
97
|
return new Date(scheduledTime).toISOString();
|
|
98
98
|
}
|
|
99
|
-
return undefined;
|
|
99
|
+
return editTask ? null : undefined;
|
|
100
100
|
};
|
|
101
101
|
|
|
102
102
|
const handleSubmit = (e: React.FormEvent) => {
|
|
@@ -110,7 +110,7 @@ export default function NewTaskModal({
|
|
|
110
110
|
projectName: selectedProject,
|
|
111
111
|
prompt: taskMode === 'monitor' ? `Monitor session ${selectedSessionId}` : prompt.trim(),
|
|
112
112
|
priority,
|
|
113
|
-
scheduledAt: getScheduledAt(),
|
|
113
|
+
scheduledAt: getScheduledAt() ?? undefined,
|
|
114
114
|
mode: taskMode,
|
|
115
115
|
};
|
|
116
116
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
export default function PreviewPanel() {
|
|
6
|
+
const [port, setPort] = useState(0);
|
|
7
|
+
const [inputPort, setInputPort] = useState('');
|
|
8
|
+
const [tunnelUrl, setTunnelUrl] = useState<string | null>(null);
|
|
9
|
+
const [status, setStatus] = useState<string>('stopped');
|
|
10
|
+
const [error, setError] = useState('');
|
|
11
|
+
const [isRemote, setIsRemote] = useState(false);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
setIsRemote(!['localhost', '127.0.0.1'].includes(window.location.hostname));
|
|
15
|
+
fetch('/api/preview')
|
|
16
|
+
.then(r => r.json())
|
|
17
|
+
.then(d => {
|
|
18
|
+
if (d.port) {
|
|
19
|
+
setPort(d.port);
|
|
20
|
+
setInputPort(String(d.port));
|
|
21
|
+
setTunnelUrl(d.url || null);
|
|
22
|
+
setStatus(d.status || 'stopped');
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
.catch(() => {});
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
const handleStart = async () => {
|
|
29
|
+
const p = parseInt(inputPort);
|
|
30
|
+
if (!p || p < 1 || p > 65535) {
|
|
31
|
+
setError('Invalid port');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
setError('');
|
|
35
|
+
setStatus('starting');
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch('/api/preview', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({ port: p }),
|
|
41
|
+
});
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
if (data.error) {
|
|
44
|
+
setError(data.error);
|
|
45
|
+
setStatus('error');
|
|
46
|
+
} else {
|
|
47
|
+
setPort(data.port);
|
|
48
|
+
setTunnelUrl(data.url || null);
|
|
49
|
+
setStatus(data.status || 'running');
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
setError('Failed to start tunnel');
|
|
53
|
+
setStatus('error');
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleStop = async () => {
|
|
58
|
+
await fetch('/api/preview', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ action: 'stop' }),
|
|
62
|
+
});
|
|
63
|
+
setPort(0);
|
|
64
|
+
setTunnelUrl(null);
|
|
65
|
+
setStatus('stopped');
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// What to show in iframe: tunnel URL for remote, localhost for local
|
|
69
|
+
const previewSrc = isRemote
|
|
70
|
+
? tunnelUrl
|
|
71
|
+
: port ? `http://localhost:${port}` : null;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
75
|
+
{/* Control bar */}
|
|
76
|
+
<div className="px-4 py-2 border-b border-[var(--border)] flex items-center gap-3 shrink-0 flex-wrap">
|
|
77
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)]">Preview</span>
|
|
78
|
+
|
|
79
|
+
<input
|
|
80
|
+
type="number"
|
|
81
|
+
value={inputPort}
|
|
82
|
+
onChange={e => setInputPort(e.target.value)}
|
|
83
|
+
onKeyDown={e => e.key === 'Enter' && handleStart()}
|
|
84
|
+
placeholder="Port"
|
|
85
|
+
className="w-24 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
{status === 'stopped' || status === 'error' ? (
|
|
89
|
+
<button
|
|
90
|
+
onClick={handleStart}
|
|
91
|
+
disabled={!inputPort}
|
|
92
|
+
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
93
|
+
>
|
|
94
|
+
Start Tunnel
|
|
95
|
+
</button>
|
|
96
|
+
) : status === 'starting' ? (
|
|
97
|
+
<span className="text-[10px] text-yellow-400">Starting tunnel...</span>
|
|
98
|
+
) : (
|
|
99
|
+
<>
|
|
100
|
+
<span className="text-[10px] text-green-400">● localhost:{port}</span>
|
|
101
|
+
{tunnelUrl && (
|
|
102
|
+
<button
|
|
103
|
+
onClick={() => { navigator.clipboard.writeText(tunnelUrl); }}
|
|
104
|
+
className="text-[10px] text-green-400 hover:text-green-300 truncate max-w-[250px]"
|
|
105
|
+
title={`Click to copy: ${tunnelUrl}`}
|
|
106
|
+
>
|
|
107
|
+
{tunnelUrl.replace('https://', '')}
|
|
108
|
+
</button>
|
|
109
|
+
)}
|
|
110
|
+
<a
|
|
111
|
+
href={previewSrc || '#'}
|
|
112
|
+
target="_blank"
|
|
113
|
+
rel="noopener"
|
|
114
|
+
className="text-[10px] text-[var(--accent)] hover:underline"
|
|
115
|
+
>
|
|
116
|
+
Open ↗
|
|
117
|
+
</a>
|
|
118
|
+
<button
|
|
119
|
+
onClick={handleStop}
|
|
120
|
+
className="text-[10px] text-red-400 hover:text-red-300"
|
|
121
|
+
>
|
|
122
|
+
Stop
|
|
123
|
+
</button>
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{error && <span className="text-[10px] text-red-400">{error}</span>}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Preview iframe */}
|
|
131
|
+
{previewSrc && status === 'running' ? (
|
|
132
|
+
<iframe
|
|
133
|
+
src={previewSrc}
|
|
134
|
+
className="flex-1 w-full border-0 bg-white"
|
|
135
|
+
title="Preview"
|
|
136
|
+
/>
|
|
137
|
+
) : (
|
|
138
|
+
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
139
|
+
<div className="text-center space-y-3 max-w-md">
|
|
140
|
+
<p className="text-sm">Preview a local dev server</p>
|
|
141
|
+
<p className="text-xs">Enter the port of your running dev server and click Start Tunnel.</p>
|
|
142
|
+
<p className="text-xs">A dedicated Cloudflare Tunnel will be created for that port, giving it its own public URL — no path prefix issues.</p>
|
|
143
|
+
<div className="text-[10px] text-left bg-[var(--bg-tertiary)] rounded p-3 space-y-1">
|
|
144
|
+
<p>1. Start your dev server: <code className="text-[var(--accent)]">npm run dev</code></p>
|
|
145
|
+
<p>2. Enter its port (e.g. <code className="text-[var(--accent)]">4321</code>)</p>
|
|
146
|
+
<p>3. Click <strong>Start Tunnel</strong></p>
|
|
147
|
+
<p>4. Share the generated URL — it maps directly to your dev server</p>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|