claudeship 0.2.12 → 0.2.15

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.
Files changed (128) hide show
  1. package/README.md +18 -0
  2. package/apps/server/dist/app.module.js +10 -0
  3. package/apps/server/dist/app.module.js.map +1 -1
  4. package/apps/server/dist/chat/prompts/fullstack-express-prompt.d.ts +1 -1
  5. package/apps/server/dist/chat/prompts/fullstack-express-prompt.js +109 -1
  6. package/apps/server/dist/chat/prompts/fullstack-express-prompt.js.map +1 -1
  7. package/apps/server/dist/chat/prompts/fullstack-fastapi-prompt.d.ts +1 -1
  8. package/apps/server/dist/chat/prompts/fullstack-fastapi-prompt.js +109 -1
  9. package/apps/server/dist/chat/prompts/fullstack-fastapi-prompt.js.map +1 -1
  10. package/apps/server/dist/chat/prompts/web-system-prompt.d.ts +1 -1
  11. package/apps/server/dist/chat/prompts/web-system-prompt.js +156 -0
  12. package/apps/server/dist/chat/prompts/web-system-prompt.js.map +1 -1
  13. package/apps/server/dist/checkpoint/checkpoint.controller.d.ts +19 -0
  14. package/apps/server/dist/checkpoint/checkpoint.controller.js +93 -0
  15. package/apps/server/dist/checkpoint/checkpoint.controller.js.map +1 -0
  16. package/apps/server/dist/checkpoint/checkpoint.module.d.ts +2 -0
  17. package/apps/server/dist/checkpoint/checkpoint.module.js +25 -0
  18. package/apps/server/dist/checkpoint/checkpoint.module.js.map +1 -0
  19. package/apps/server/dist/checkpoint/checkpoint.service.d.ts +41 -0
  20. package/apps/server/dist/checkpoint/checkpoint.service.js +261 -0
  21. package/apps/server/dist/checkpoint/checkpoint.service.js.map +1 -0
  22. package/apps/server/dist/database/database.controller.d.ts +23 -0
  23. package/apps/server/dist/database/database.controller.js +109 -0
  24. package/apps/server/dist/database/database.controller.js.map +1 -0
  25. package/apps/server/dist/database/database.module.d.ts +2 -0
  26. package/apps/server/dist/database/database.module.js +25 -0
  27. package/apps/server/dist/database/database.module.js.map +1 -0
  28. package/apps/server/dist/database/database.service.d.ts +32 -0
  29. package/apps/server/dist/database/database.service.js +238 -0
  30. package/apps/server/dist/database/database.service.js.map +1 -0
  31. package/apps/server/dist/env/env.controller.d.ts +14 -0
  32. package/apps/server/dist/env/env.controller.js +84 -0
  33. package/apps/server/dist/env/env.controller.js.map +1 -0
  34. package/apps/server/dist/env/env.module.d.ts +2 -0
  35. package/apps/server/dist/env/env.module.js +25 -0
  36. package/apps/server/dist/env/env.module.js.map +1 -0
  37. package/apps/server/dist/env/env.service.d.ts +21 -0
  38. package/apps/server/dist/env/env.service.js +194 -0
  39. package/apps/server/dist/env/env.service.js.map +1 -0
  40. package/apps/server/dist/preview/preview.controller.d.ts +5 -0
  41. package/apps/server/dist/preview/preview.controller.js +41 -0
  42. package/apps/server/dist/preview/preview.controller.js.map +1 -1
  43. package/apps/server/dist/preview/preview.service.d.ts +20 -0
  44. package/apps/server/dist/preview/preview.service.js +51 -2
  45. package/apps/server/dist/preview/preview.service.js.map +1 -1
  46. package/apps/server/dist/project/project.controller.d.ts +10 -1
  47. package/apps/server/dist/project/project.controller.js +57 -0
  48. package/apps/server/dist/project/project.controller.js.map +1 -1
  49. package/apps/server/dist/project/project.service.d.ts +15 -0
  50. package/apps/server/dist/project/project.service.js +111 -0
  51. package/apps/server/dist/project/project.service.js.map +1 -1
  52. package/apps/server/dist/project-context/project-context.controller.d.ts +42 -0
  53. package/apps/server/dist/project-context/project-context.controller.js +127 -0
  54. package/apps/server/dist/project-context/project-context.controller.js.map +1 -0
  55. package/apps/server/dist/project-context/project-context.module.d.ts +2 -0
  56. package/apps/server/dist/project-context/project-context.module.js +25 -0
  57. package/apps/server/dist/project-context/project-context.module.js.map +1 -0
  58. package/apps/server/dist/project-context/project-context.service.d.ts +36 -0
  59. package/apps/server/dist/project-context/project-context.service.js +260 -0
  60. package/apps/server/dist/project-context/project-context.service.js.map +1 -0
  61. package/apps/server/dist/testing/testing.controller.d.ts +24 -0
  62. package/apps/server/dist/testing/testing.controller.js +126 -0
  63. package/apps/server/dist/testing/testing.controller.js.map +1 -0
  64. package/apps/server/dist/testing/testing.module.d.ts +2 -0
  65. package/apps/server/dist/testing/testing.module.js +26 -0
  66. package/apps/server/dist/testing/testing.module.js.map +1 -0
  67. package/apps/server/dist/testing/testing.service.d.ts +62 -0
  68. package/apps/server/dist/testing/testing.service.js +269 -0
  69. package/apps/server/dist/testing/testing.service.js.map +1 -0
  70. package/apps/server/dist/tsconfig.tsbuildinfo +1 -1
  71. package/apps/server/package.json +1 -1
  72. package/apps/web/.next/BUILD_ID +1 -1
  73. package/apps/web/.next/app-build-manifest.json +5 -5
  74. package/apps/web/.next/build-manifest.json +2 -2
  75. package/apps/web/.next/cache/.previewinfo +1 -1
  76. package/apps/web/.next/cache/.rscinfo +1 -1
  77. package/apps/web/.next/cache/.tsbuildinfo +1 -1
  78. package/apps/web/.next/cache/config.json +3 -3
  79. package/apps/web/.next/cache/eslint/.cache_j3uhuz +1 -1
  80. package/apps/web/.next/cache/webpack/client-production/0.pack +0 -0
  81. package/apps/web/.next/cache/webpack/client-production/index.pack +0 -0
  82. package/apps/web/.next/cache/webpack/edge-server-production/index.pack +0 -0
  83. package/apps/web/.next/cache/webpack/server-production/0.pack +0 -0
  84. package/apps/web/.next/cache/webpack/server-production/index.pack +0 -0
  85. package/apps/web/.next/prerender-manifest.json +10 -10
  86. package/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  87. package/apps/web/.next/server/app/_not-found.html +1 -1
  88. package/apps/web/.next/server/app/_not-found.rsc +2 -2
  89. package/apps/web/.next/server/app/index.html +1 -1
  90. package/apps/web/.next/server/app/index.rsc +3 -3
  91. package/apps/web/.next/server/app/page.js +2 -2
  92. package/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  93. package/apps/web/.next/server/app/project/[id]/page.js +2 -2
  94. package/apps/web/.next/server/app/project/[id]/page_client-reference-manifest.js +1 -1
  95. package/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  96. package/apps/web/.next/server/app/settings.html +1 -1
  97. package/apps/web/.next/server/app/settings.rsc +3 -3
  98. package/apps/web/.next/server/chunks/392.js +1 -1
  99. package/apps/web/.next/server/pages/404.html +1 -1
  100. package/apps/web/.next/server/pages/500.html +1 -1
  101. package/apps/web/.next/server/server-reference-manifest.json +1 -1
  102. package/apps/web/.next/static/chunks/574-1fe2bcd6cfb41646.js +1 -0
  103. package/apps/web/.next/static/chunks/app/page-f19cfa58541ca83d.js +1 -0
  104. package/apps/web/.next/static/chunks/app/project/[id]/page-dffaa1d02f012216.js +1 -0
  105. package/apps/web/.next/static/chunks/app/settings/page-d1318c2fd58729a5.js +1 -0
  106. package/apps/web/.next/static/css/0a24552d9794f8c8.css +3 -0
  107. package/apps/web/.next/trace +18 -17
  108. package/apps/web/node_modules/.bin/eslint +2 -2
  109. package/apps/web/package.json +2 -1
  110. package/apps/web/src/components/checkpoint/CheckpointPanel.tsx +384 -0
  111. package/apps/web/src/components/database/DatabasePanel.tsx +405 -0
  112. package/apps/web/src/components/env/EnvPanel.tsx +356 -0
  113. package/apps/web/src/components/preview/ConsoleViewer.tsx +270 -0
  114. package/apps/web/src/components/preview/ErrorOverlay.tsx +189 -0
  115. package/apps/web/src/components/preview/PreviewPanel.tsx +148 -6
  116. package/apps/web/src/components/testing/TestRunner.tsx +481 -0
  117. package/apps/web/src/components/ui/tabs.tsx +55 -0
  118. package/apps/web/src/components/visual-editor/VisualEditor.tsx +382 -0
  119. package/apps/web/src/components/workspace/WorkspaceLayout.tsx +66 -4
  120. package/apps/web/src/lib/api.ts +5 -2
  121. package/package.json +1 -1
  122. package/apps/web/.next/static/chunks/298-6f3d6b321c288cd3.js +0 -1
  123. package/apps/web/.next/static/chunks/app/page-3d093f7f480a8599.js +0 -1
  124. package/apps/web/.next/static/chunks/app/project/[id]/page-e5cda6f9050b0a52.js +0 -1
  125. package/apps/web/.next/static/chunks/app/settings/page-92d28565c3d8c755.js +0 -1
  126. package/apps/web/.next/static/css/8f946046a2047594.css +0 -3
  127. /package/apps/web/.next/static/{aXT20mSdxaem1-z8VH2F1 → mkY_TTl_ho_ehDKiX10AN}/_buildManifest.js +0 -0
  128. /package/apps/web/.next/static/{aXT20mSdxaem1-z8VH2F1 → mkY_TTl_ho_ehDKiX10AN}/_ssgManifest.js +0 -0
@@ -0,0 +1,356 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+ import {
5
+ Eye,
6
+ EyeOff,
7
+ Plus,
8
+ Trash2,
9
+ Save,
10
+ FileText,
11
+ RefreshCw,
12
+ AlertCircle,
13
+ } from "lucide-react";
14
+ import { Button } from "@/components/ui/button";
15
+ import { Input } from "@/components/ui/input";
16
+ import { api } from "@/lib/api";
17
+
18
+ interface EnvVariable {
19
+ key: string;
20
+ value: string;
21
+ }
22
+
23
+ interface EnvFile {
24
+ path: string;
25
+ variables: EnvVariable[];
26
+ }
27
+
28
+ interface EnvPanelProps {
29
+ projectId: string;
30
+ }
31
+
32
+ // Keys that should be masked by default
33
+ const SENSITIVE_KEYS = [
34
+ "SECRET",
35
+ "PASSWORD",
36
+ "KEY",
37
+ "TOKEN",
38
+ "API_KEY",
39
+ "PRIVATE",
40
+ "CREDENTIAL",
41
+ ];
42
+
43
+ function isSensitiveKey(key: string): boolean {
44
+ const upperKey = key.toUpperCase();
45
+ return SENSITIVE_KEYS.some((sensitive) => upperKey.includes(sensitive));
46
+ }
47
+
48
+ export function EnvPanel({ projectId }: EnvPanelProps) {
49
+ const [envFiles, setEnvFiles] = useState<EnvFile[]>([]);
50
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
51
+ const [variables, setVariables] = useState<EnvVariable[]>([]);
52
+ const [visibleValues, setVisibleValues] = useState<Set<string>>(new Set());
53
+ const [isLoading, setIsLoading] = useState(false);
54
+ const [isSaving, setIsSaving] = useState(false);
55
+ const [error, setError] = useState<string | null>(null);
56
+ const [hasChanges, setHasChanges] = useState(false);
57
+
58
+ // Fetch env files list
59
+ const fetchEnvFiles = useCallback(async () => {
60
+ setIsLoading(true);
61
+ setError(null);
62
+ try {
63
+ const files = await api.get<EnvFile[]>(`/projects/${projectId}/env`);
64
+ setEnvFiles(files);
65
+ if (files.length > 0 && !selectedFile) {
66
+ setSelectedFile(files[0].path);
67
+ setVariables(files[0].variables);
68
+ }
69
+ } catch (e) {
70
+ setError("Failed to load environment files");
71
+ } finally {
72
+ setIsLoading(false);
73
+ }
74
+ }, [projectId, selectedFile]);
75
+
76
+ // Fetch specific env file
77
+ const fetchEnvFile = useCallback(async (filePath: string) => {
78
+ setIsLoading(true);
79
+ setError(null);
80
+ try {
81
+ const file = await api.get<EnvFile>(
82
+ `/projects/${projectId}/env/file?path=${encodeURIComponent(filePath)}`
83
+ );
84
+ setVariables(file.variables);
85
+ setHasChanges(false);
86
+ } catch (e) {
87
+ setError(`Failed to load ${filePath}`);
88
+ } finally {
89
+ setIsLoading(false);
90
+ }
91
+ }, [projectId]);
92
+
93
+ useEffect(() => {
94
+ fetchEnvFiles();
95
+ }, [fetchEnvFiles]);
96
+
97
+ useEffect(() => {
98
+ if (selectedFile) {
99
+ fetchEnvFile(selectedFile);
100
+ }
101
+ }, [selectedFile, fetchEnvFile]);
102
+
103
+ const handleSelectFile = (filePath: string) => {
104
+ if (hasChanges) {
105
+ if (!confirm("You have unsaved changes. Discard them?")) {
106
+ return;
107
+ }
108
+ }
109
+ setSelectedFile(filePath);
110
+ setVisibleValues(new Set());
111
+ };
112
+
113
+ const handleAddVariable = () => {
114
+ setVariables([...variables, { key: "", value: "" }]);
115
+ setHasChanges(true);
116
+ };
117
+
118
+ const handleUpdateVariable = (index: number, field: "key" | "value", value: string) => {
119
+ const newVariables = [...variables];
120
+ newVariables[index] = { ...newVariables[index], [field]: value };
121
+ setVariables(newVariables);
122
+ setHasChanges(true);
123
+ };
124
+
125
+ const handleDeleteVariable = (index: number) => {
126
+ const newVariables = variables.filter((_, i) => i !== index);
127
+ setVariables(newVariables);
128
+ setHasChanges(true);
129
+ };
130
+
131
+ const handleToggleVisibility = (key: string) => {
132
+ const newVisible = new Set(visibleValues);
133
+ if (newVisible.has(key)) {
134
+ newVisible.delete(key);
135
+ } else {
136
+ newVisible.add(key);
137
+ }
138
+ setVisibleValues(newVisible);
139
+ };
140
+
141
+ const handleSave = async () => {
142
+ if (!selectedFile) return;
143
+
144
+ // Validate: no empty keys
145
+ const emptyKeys = variables.filter((v) => !v.key.trim());
146
+ if (emptyKeys.length > 0) {
147
+ setError("Variable names cannot be empty");
148
+ return;
149
+ }
150
+
151
+ // Validate: no duplicate keys
152
+ const keys = variables.map((v) => v.key.trim());
153
+ const duplicates = keys.filter((key, i) => keys.indexOf(key) !== i);
154
+ if (duplicates.length > 0) {
155
+ setError(`Duplicate variable name: ${duplicates[0]}`);
156
+ return;
157
+ }
158
+
159
+ setIsSaving(true);
160
+ setError(null);
161
+ try {
162
+ await api.put(
163
+ `/projects/${projectId}/env/file?path=${encodeURIComponent(selectedFile)}`,
164
+ { variables }
165
+ );
166
+ setHasChanges(false);
167
+ } catch (e) {
168
+ setError("Failed to save environment variables");
169
+ } finally {
170
+ setIsSaving(false);
171
+ }
172
+ };
173
+
174
+ const handleCreateEnvFile = async () => {
175
+ const fileName = prompt("Enter .env file name (e.g., .env.local):");
176
+ if (!fileName) return;
177
+
178
+ if (!fileName.startsWith(".env")) {
179
+ setError("File name must start with .env");
180
+ return;
181
+ }
182
+
183
+ try {
184
+ await api.post(
185
+ `/projects/${projectId}/env/file?path=${encodeURIComponent(fileName)}`,
186
+ { variables: [] }
187
+ );
188
+ await fetchEnvFiles();
189
+ setSelectedFile(fileName);
190
+ } catch (e) {
191
+ setError("Failed to create environment file");
192
+ }
193
+ };
194
+
195
+ if (isLoading && envFiles.length === 0) {
196
+ return (
197
+ <div className="flex h-full items-center justify-center text-muted-foreground">
198
+ <RefreshCw className="h-5 w-5 animate-spin mr-2" />
199
+ Loading...
200
+ </div>
201
+ );
202
+ }
203
+
204
+ return (
205
+ <div className="flex h-full flex-col">
206
+ {/* Header */}
207
+ <div className="flex items-center justify-between border-b px-4 py-3">
208
+ <h3 className="font-medium">Environment Variables</h3>
209
+ <div className="flex items-center gap-2">
210
+ <Button
211
+ variant="outline"
212
+ size="sm"
213
+ onClick={handleCreateEnvFile}
214
+ className="h-8"
215
+ >
216
+ <Plus className="h-4 w-4 mr-1" />
217
+ New File
218
+ </Button>
219
+ <Button
220
+ variant="default"
221
+ size="sm"
222
+ onClick={handleSave}
223
+ disabled={!hasChanges || isSaving}
224
+ className="h-8"
225
+ >
226
+ {isSaving ? (
227
+ <RefreshCw className="h-4 w-4 animate-spin mr-1" />
228
+ ) : (
229
+ <Save className="h-4 w-4 mr-1" />
230
+ )}
231
+ Save
232
+ </Button>
233
+ </div>
234
+ </div>
235
+
236
+ {/* Error */}
237
+ {error && (
238
+ <div className="flex items-center gap-2 px-4 py-2 bg-destructive/10 text-destructive text-sm">
239
+ <AlertCircle className="h-4 w-4" />
240
+ {error}
241
+ </div>
242
+ )}
243
+
244
+ <div className="flex flex-1 overflow-hidden">
245
+ {/* File List Sidebar */}
246
+ <div className="w-48 border-r overflow-auto">
247
+ <div className="p-2 space-y-1">
248
+ {envFiles.map((file) => (
249
+ <button
250
+ key={file.path}
251
+ onClick={() => handleSelectFile(file.path)}
252
+ className={`w-full flex items-center gap-2 px-3 py-2 text-sm rounded-md transition-colors ${
253
+ selectedFile === file.path
254
+ ? "bg-primary/10 text-primary"
255
+ : "hover:bg-muted"
256
+ }`}
257
+ >
258
+ <FileText className="h-4 w-4 shrink-0" />
259
+ <span className="truncate">{file.path}</span>
260
+ </button>
261
+ ))}
262
+ {envFiles.length === 0 && (
263
+ <p className="text-sm text-muted-foreground px-3 py-2">
264
+ No .env files found
265
+ </p>
266
+ )}
267
+ </div>
268
+ </div>
269
+
270
+ {/* Variables Editor */}
271
+ <div className="flex-1 overflow-auto">
272
+ {selectedFile ? (
273
+ <div className="p-4 space-y-3">
274
+ {/* Table Header */}
275
+ <div className="grid grid-cols-[1fr_1fr_auto] gap-2 px-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
276
+ <div>Key</div>
277
+ <div>Value</div>
278
+ <div className="w-16"></div>
279
+ </div>
280
+
281
+ {/* Variables */}
282
+ {variables.map((variable, index) => (
283
+ <div
284
+ key={index}
285
+ className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center"
286
+ >
287
+ <Input
288
+ value={variable.key}
289
+ onChange={(e) => handleUpdateVariable(index, "key", e.target.value)}
290
+ placeholder="VARIABLE_NAME"
291
+ className="font-mono text-sm"
292
+ />
293
+ <div className="relative">
294
+ <Input
295
+ type={
296
+ isSensitiveKey(variable.key) && !visibleValues.has(variable.key)
297
+ ? "password"
298
+ : "text"
299
+ }
300
+ value={variable.value}
301
+ onChange={(e) => handleUpdateVariable(index, "value", e.target.value)}
302
+ placeholder="value"
303
+ className="font-mono text-sm pr-10"
304
+ />
305
+ {isSensitiveKey(variable.key) && (
306
+ <button
307
+ onClick={() => handleToggleVisibility(variable.key)}
308
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
309
+ >
310
+ {visibleValues.has(variable.key) ? (
311
+ <EyeOff className="h-4 w-4" />
312
+ ) : (
313
+ <Eye className="h-4 w-4" />
314
+ )}
315
+ </button>
316
+ )}
317
+ </div>
318
+ <Button
319
+ variant="ghost"
320
+ size="sm"
321
+ onClick={() => handleDeleteVariable(index)}
322
+ className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
323
+ >
324
+ <Trash2 className="h-4 w-4" />
325
+ </Button>
326
+ </div>
327
+ ))}
328
+
329
+ {/* Add Variable Button */}
330
+ <Button
331
+ variant="outline"
332
+ size="sm"
333
+ onClick={handleAddVariable}
334
+ className="w-full mt-4"
335
+ >
336
+ <Plus className="h-4 w-4 mr-1" />
337
+ Add Variable
338
+ </Button>
339
+
340
+ {/* Tip */}
341
+ {variables.some((v) => v.key.startsWith("NEXT_PUBLIC_")) && (
342
+ <p className="text-xs text-muted-foreground mt-4">
343
+ Variables starting with NEXT_PUBLIC_ will be exposed to the browser.
344
+ </p>
345
+ )}
346
+ </div>
347
+ ) : (
348
+ <div className="flex h-full items-center justify-center text-muted-foreground">
349
+ <p>Select a file or create a new one</p>
350
+ </div>
351
+ )}
352
+ </div>
353
+ </div>
354
+ </div>
355
+ );
356
+ }
@@ -0,0 +1,270 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useRef, useCallback } from "react";
4
+ import { Trash2, Download, Filter, AlertCircle, Terminal } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:14000";
8
+
9
+ interface LogEntry {
10
+ id: string;
11
+ timestamp: number;
12
+ level: "stdout" | "stderr";
13
+ source: "frontend" | "backend";
14
+ message: string;
15
+ }
16
+
17
+ interface ConsoleViewerProps {
18
+ projectId: string;
19
+ isRunning: boolean;
20
+ }
21
+
22
+ type FilterType = "all" | "frontend" | "backend" | "error";
23
+
24
+ export function ConsoleViewer({ projectId, isRunning }: ConsoleViewerProps) {
25
+ const [logs, setLogs] = useState<LogEntry[]>([]);
26
+ const [filter, setFilter] = useState<FilterType>("all");
27
+ const [autoScroll, setAutoScroll] = useState(true);
28
+ const containerRef = useRef<HTMLDivElement>(null);
29
+ const eventSourceRef = useRef<EventSource | null>(null);
30
+
31
+ // Fetch initial logs
32
+ useEffect(() => {
33
+ if (!isRunning) {
34
+ setLogs([]);
35
+ return;
36
+ }
37
+
38
+ const fetchLogs = async () => {
39
+ try {
40
+ const response = await fetch(`${API_BASE}/projects/${projectId}/preview/logs`);
41
+ if (response.ok) {
42
+ const initialLogs = await response.json();
43
+ setLogs(initialLogs);
44
+ }
45
+ } catch (error) {
46
+ console.error("Failed to fetch logs:", error);
47
+ }
48
+ };
49
+
50
+ fetchLogs();
51
+ }, [projectId, isRunning]);
52
+
53
+ // Subscribe to log stream
54
+ useEffect(() => {
55
+ if (!isRunning) {
56
+ if (eventSourceRef.current) {
57
+ eventSourceRef.current.close();
58
+ eventSourceRef.current = null;
59
+ }
60
+ return;
61
+ }
62
+
63
+ const eventSource = new EventSource(
64
+ `${API_BASE}/projects/${projectId}/preview/logs/stream`
65
+ );
66
+
67
+ eventSource.onmessage = (event) => {
68
+ try {
69
+ const data = JSON.parse(event.data);
70
+ if (data.type === "log" && data.entry) {
71
+ setLogs((prev) => {
72
+ const newLogs = [...prev, data.entry];
73
+ // Keep max 1000 logs
74
+ if (newLogs.length > 1000) {
75
+ return newLogs.slice(-1000);
76
+ }
77
+ return newLogs;
78
+ });
79
+ }
80
+ } catch {
81
+ // Ignore parse errors
82
+ }
83
+ };
84
+
85
+ eventSource.onerror = () => {
86
+ eventSource.close();
87
+ eventSourceRef.current = null;
88
+ };
89
+
90
+ eventSourceRef.current = eventSource;
91
+
92
+ return () => {
93
+ eventSource.close();
94
+ eventSourceRef.current = null;
95
+ };
96
+ }, [projectId, isRunning]);
97
+
98
+ // Auto-scroll to bottom
99
+ useEffect(() => {
100
+ if (autoScroll && containerRef.current) {
101
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
102
+ }
103
+ }, [logs, autoScroll]);
104
+
105
+ const handleScroll = useCallback(() => {
106
+ if (!containerRef.current) return;
107
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
108
+ // Enable auto-scroll if scrolled near bottom
109
+ setAutoScroll(scrollHeight - scrollTop - clientHeight < 50);
110
+ }, []);
111
+
112
+ const handleClear = async () => {
113
+ try {
114
+ await fetch(`${API_BASE}/projects/${projectId}/preview/logs`, {
115
+ method: "DELETE",
116
+ });
117
+ setLogs([]);
118
+ } catch (error) {
119
+ console.error("Failed to clear logs:", error);
120
+ }
121
+ };
122
+
123
+ const handleDownload = () => {
124
+ const content = filteredLogs
125
+ .map((log) => {
126
+ const time = new Date(log.timestamp).toISOString();
127
+ return `[${time}] [${log.source}] [${log.level}] ${log.message}`;
128
+ })
129
+ .join("\n");
130
+
131
+ const blob = new Blob([content], { type: "text/plain" });
132
+ const url = URL.createObjectURL(blob);
133
+ const a = document.createElement("a");
134
+ a.href = url;
135
+ a.download = `console-${projectId}-${Date.now()}.log`;
136
+ a.click();
137
+ URL.revokeObjectURL(url);
138
+ };
139
+
140
+ const filteredLogs = logs.filter((log) => {
141
+ switch (filter) {
142
+ case "frontend":
143
+ return log.source === "frontend";
144
+ case "backend":
145
+ return log.source === "backend";
146
+ case "error":
147
+ return log.level === "stderr";
148
+ default:
149
+ return true;
150
+ }
151
+ });
152
+
153
+ const formatTime = (timestamp: number) => {
154
+ return new Date(timestamp).toLocaleTimeString();
155
+ };
156
+
157
+ if (!isRunning) {
158
+ return (
159
+ <div className="flex h-full items-center justify-center text-muted-foreground">
160
+ <div className="text-center">
161
+ <Terminal className="mx-auto h-8 w-8 mb-2 opacity-50" />
162
+ <p className="text-sm">Start the preview to see console output</p>
163
+ </div>
164
+ </div>
165
+ );
166
+ }
167
+
168
+ return (
169
+ <div className="flex h-full flex-col">
170
+ {/* Toolbar */}
171
+ <div className="flex h-10 items-center justify-between border-b px-2">
172
+ <div className="flex items-center gap-1">
173
+ <Button
174
+ variant={filter === "all" ? "secondary" : "ghost"}
175
+ size="sm"
176
+ onClick={() => setFilter("all")}
177
+ className="h-7 px-2 text-xs"
178
+ >
179
+ All
180
+ </Button>
181
+ <Button
182
+ variant={filter === "frontend" ? "secondary" : "ghost"}
183
+ size="sm"
184
+ onClick={() => setFilter("frontend")}
185
+ className="h-7 px-2 text-xs"
186
+ >
187
+ Frontend
188
+ </Button>
189
+ <Button
190
+ variant={filter === "backend" ? "secondary" : "ghost"}
191
+ size="sm"
192
+ onClick={() => setFilter("backend")}
193
+ className="h-7 px-2 text-xs"
194
+ >
195
+ Backend
196
+ </Button>
197
+ <Button
198
+ variant={filter === "error" ? "secondary" : "ghost"}
199
+ size="sm"
200
+ onClick={() => setFilter("error")}
201
+ className="h-7 px-2 text-xs text-red-500"
202
+ >
203
+ <AlertCircle className="h-3 w-3 mr-1" />
204
+ Errors
205
+ </Button>
206
+ </div>
207
+ <div className="flex items-center gap-1">
208
+ <Button
209
+ variant="ghost"
210
+ size="sm"
211
+ onClick={handleDownload}
212
+ className="h-7 w-7 p-0"
213
+ title="Download logs"
214
+ >
215
+ <Download className="h-4 w-4" />
216
+ </Button>
217
+ <Button
218
+ variant="ghost"
219
+ size="sm"
220
+ onClick={handleClear}
221
+ className="h-7 w-7 p-0"
222
+ title="Clear logs"
223
+ >
224
+ <Trash2 className="h-4 w-4" />
225
+ </Button>
226
+ </div>
227
+ </div>
228
+
229
+ {/* Log Output */}
230
+ <div
231
+ ref={containerRef}
232
+ onScroll={handleScroll}
233
+ className="flex-1 overflow-auto bg-zinc-950 p-2 font-mono text-xs"
234
+ >
235
+ {filteredLogs.length === 0 ? (
236
+ <div className="flex h-full items-center justify-center text-zinc-500">
237
+ <span>No logs yet...</span>
238
+ </div>
239
+ ) : (
240
+ <div className="space-y-0.5">
241
+ {filteredLogs.map((log) => (
242
+ <div
243
+ key={log.id}
244
+ className={`flex gap-2 ${
245
+ log.level === "stderr" ? "text-red-400" : "text-zinc-300"
246
+ }`}
247
+ >
248
+ <span className="text-zinc-600 shrink-0">
249
+ {formatTime(log.timestamp)}
250
+ </span>
251
+ <span
252
+ className={`shrink-0 px-1 rounded text-[10px] ${
253
+ log.source === "frontend"
254
+ ? "bg-blue-500/20 text-blue-400"
255
+ : "bg-green-500/20 text-green-400"
256
+ }`}
257
+ >
258
+ {log.source === "frontend" ? "FE" : "BE"}
259
+ </span>
260
+ <span className="whitespace-pre-wrap break-all">
261
+ {log.message}
262
+ </span>
263
+ </div>
264
+ ))}
265
+ </div>
266
+ )}
267
+ </div>
268
+ </div>
269
+ );
270
+ }