@hyperframes/studio 0.1.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.
Files changed (37) hide show
  1. package/dist/assets/index-B1830ANq.js +78 -0
  2. package/dist/assets/index-KoBceNoU.css +1 -0
  3. package/dist/icons/timeline/audio.svg +7 -0
  4. package/dist/icons/timeline/captions.svg +5 -0
  5. package/dist/icons/timeline/composition.svg +12 -0
  6. package/dist/icons/timeline/image.svg +18 -0
  7. package/dist/icons/timeline/music.svg +10 -0
  8. package/dist/icons/timeline/text.svg +3 -0
  9. package/dist/index.html +13 -0
  10. package/package.json +50 -0
  11. package/src/App.tsx +557 -0
  12. package/src/components/editor/FileTree.tsx +70 -0
  13. package/src/components/editor/PropertyPanel.tsx +209 -0
  14. package/src/components/editor/SourceEditor.tsx +116 -0
  15. package/src/components/nle/CompositionBreadcrumb.tsx +57 -0
  16. package/src/components/nle/NLELayout.tsx +252 -0
  17. package/src/components/nle/NLEPreview.tsx +37 -0
  18. package/src/components/ui/Button.tsx +123 -0
  19. package/src/components/ui/index.ts +2 -0
  20. package/src/hooks/useCodeEditor.ts +82 -0
  21. package/src/hooks/useElementPicker.ts +338 -0
  22. package/src/hooks/useMountEffect.ts +18 -0
  23. package/src/icons/SystemIcons.tsx +130 -0
  24. package/src/index.ts +31 -0
  25. package/src/main.tsx +10 -0
  26. package/src/player/components/AgentActivityTrack.tsx +98 -0
  27. package/src/player/components/Player.tsx +120 -0
  28. package/src/player/components/PlayerControls.tsx +181 -0
  29. package/src/player/components/PreviewPanel.tsx +149 -0
  30. package/src/player/components/Timeline.tsx +431 -0
  31. package/src/player/hooks/useTimelinePlayer.ts +465 -0
  32. package/src/player/index.ts +17 -0
  33. package/src/player/lib/time.ts +5 -0
  34. package/src/player/lib/useMountEffect.ts +10 -0
  35. package/src/player/store/playerStore.ts +93 -0
  36. package/src/styles/studio.css +31 -0
  37. package/src/utils/sourcePatcher.ts +149 -0
package/src/App.tsx ADDED
@@ -0,0 +1,557 @@
1
+ import { useState, useCallback, useRef, useEffect } from "react";
2
+ import { NLELayout } from "./components/nle/NLELayout";
3
+ import { SourceEditor } from "./components/editor/SourceEditor";
4
+ import { FileTree } from "./components/editor/FileTree";
5
+ import {
6
+ XIcon,
7
+ CodeIcon,
8
+ WarningIcon,
9
+ CheckCircleIcon,
10
+ CaretRightIcon,
11
+ } from "@phosphor-icons/react";
12
+
13
+ interface EditingFile {
14
+ path: string;
15
+ content: string;
16
+ }
17
+
18
+ interface ProjectEntry {
19
+ id: string;
20
+ title?: string;
21
+ sessionId?: string;
22
+ }
23
+
24
+ interface LintFinding {
25
+ severity: "error" | "warning";
26
+ message: string;
27
+ file?: string;
28
+ fixHint?: string;
29
+ }
30
+
31
+ // ── Project Picker ──
32
+
33
+ function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
34
+ const [projects, setProjects] = useState<ProjectEntry[]>([]);
35
+ const [loading, setLoading] = useState(true);
36
+
37
+ useEffect(() => {
38
+ fetch("/api/projects")
39
+ .then((r) => r.json())
40
+ .then((data: { projects?: ProjectEntry[] }) => {
41
+ setProjects(data.projects ?? []);
42
+ setLoading(false);
43
+ })
44
+ .catch(() => setLoading(false));
45
+ }, []);
46
+
47
+ return (
48
+ <div className="h-screen w-screen bg-neutral-950 overflow-y-auto">
49
+ <div className="max-w-lg w-full mx-auto px-4 py-12">
50
+ <h1 className="text-xl font-semibold text-neutral-200 mb-1">HyperFrames Studio</h1>
51
+ <p className="text-sm text-neutral-500 mb-6">Select a project to open</p>
52
+ {loading ? (
53
+ <div className="text-sm text-neutral-600">Loading projects...</div>
54
+ ) : projects.length === 0 ? (
55
+ <div className="text-sm text-neutral-600">No projects found.</div>
56
+ ) : (
57
+ <div className="flex flex-col gap-1.5">
58
+ {projects.map((p) => (
59
+ <button
60
+ key={p.id}
61
+ onClick={() => onSelect(p.id)}
62
+ className="text-left px-4 py-3 rounded-lg bg-neutral-900 border border-neutral-800 hover:border-neutral-600 hover:bg-neutral-800/80 transition-all group"
63
+ >
64
+ <div className="text-sm text-neutral-200 truncate">{p.title ?? p.id}</div>
65
+ <div className="text-[11px] text-neutral-600 font-mono truncate mt-0.5 group-hover:text-neutral-500">
66
+ {p.id}
67
+ </div>
68
+ </button>
69
+ ))}
70
+ </div>
71
+ )}
72
+ </div>
73
+ </div>
74
+ );
75
+ }
76
+
77
+ // ── Lint Modal ──
78
+
79
+ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: () => void }) {
80
+ const errors = findings.filter((f) => f.severity === "error");
81
+ const warnings = findings.filter((f) => f.severity === "warning");
82
+ const hasIssues = findings.length > 0;
83
+
84
+ return (
85
+ <div
86
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
87
+ onClick={onClose}
88
+ >
89
+ <div
90
+ className="bg-neutral-950 border border-neutral-800 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col overflow-hidden"
91
+ onClick={(e) => e.stopPropagation()}
92
+ >
93
+ {/* Header */}
94
+ <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
95
+ <div className="flex items-center gap-3">
96
+ {hasIssues ? (
97
+ <div className="w-8 h-8 rounded-full bg-red-500/10 flex items-center justify-center">
98
+ <WarningIcon size={18} className="text-red-400" weight="fill" />
99
+ </div>
100
+ ) : (
101
+ <div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center">
102
+ <CheckCircleIcon size={18} className="text-green-400" weight="fill" />
103
+ </div>
104
+ )}
105
+ <div>
106
+ <h2 className="text-sm font-semibold text-neutral-200">
107
+ {hasIssues
108
+ ? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`
109
+ : "All checks passed"}
110
+ </h2>
111
+ <p className="text-xs text-neutral-500">HyperFrame Lint Results</p>
112
+ </div>
113
+ </div>
114
+ <button
115
+ onClick={onClose}
116
+ className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
117
+ >
118
+ <XIcon size={16} />
119
+ </button>
120
+ </div>
121
+
122
+ {/* Findings */}
123
+ <div className="flex-1 overflow-y-auto px-5 py-3">
124
+ {!hasIssues && (
125
+ <div className="py-8 text-center text-neutral-500 text-sm">
126
+ No errors or warnings found. Your composition looks good!
127
+ </div>
128
+ )}
129
+ {errors.map((f, i) => (
130
+ <div key={`e-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
131
+ <div className="flex items-start gap-2">
132
+ <WarningIcon
133
+ size={14}
134
+ className="text-red-400 flex-shrink-0 mt-0.5"
135
+ weight="fill"
136
+ />
137
+ <div className="min-w-0">
138
+ <p className="text-sm text-neutral-200">{f.message}</p>
139
+ {f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
140
+ {f.fixHint && (
141
+ <div className="flex items-start gap-1 mt-1.5">
142
+ <CaretRightIcon size={10} className="text-blue-400 flex-shrink-0 mt-0.5" />
143
+ <p className="text-xs text-blue-400">{f.fixHint}</p>
144
+ </div>
145
+ )}
146
+ </div>
147
+ </div>
148
+ </div>
149
+ ))}
150
+ {warnings.map((f, i) => (
151
+ <div key={`w-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
152
+ <div className="flex items-start gap-2">
153
+ <WarningIcon size={14} className="text-amber-400 flex-shrink-0 mt-0.5" />
154
+ <div className="min-w-0">
155
+ <p className="text-sm text-neutral-300">{f.message}</p>
156
+ {f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
157
+ {f.fixHint && (
158
+ <div className="flex items-start gap-1 mt-1.5">
159
+ <CaretRightIcon size={10} className="text-blue-400 flex-shrink-0 mt-0.5" />
160
+ <p className="text-xs text-blue-400">{f.fixHint}</p>
161
+ </div>
162
+ )}
163
+ </div>
164
+ </div>
165
+ </div>
166
+ ))}
167
+ </div>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ // ── Main App ──
174
+
175
+ export function StudioApp() {
176
+ const [projectId, setProjectId] = useState<string | null>(null);
177
+ const [resolving, setResolving] = useState(true);
178
+
179
+ useEffect(() => {
180
+ const hash = window.location.hash;
181
+ const projectMatch = hash.match(/project\/([^/]+)/);
182
+ const sessionMatch = hash.match(/session\/([^/]+)/);
183
+ if (projectMatch) {
184
+ setProjectId(projectMatch[1]);
185
+ setResolving(false);
186
+ } else if (sessionMatch) {
187
+ fetch(`/api/resolve-session/${sessionMatch[1]}`)
188
+ .then((r) => r.json())
189
+ .then((data: { projectId?: string }) => {
190
+ if (data.projectId) {
191
+ window.location.hash = `#project/${data.projectId}`;
192
+ setProjectId(data.projectId);
193
+ }
194
+ setResolving(false);
195
+ })
196
+ .catch(() => setResolving(false));
197
+ } else {
198
+ setResolving(false);
199
+ }
200
+ }, []);
201
+
202
+ const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
203
+ const [sidebarOpen, setSidebarOpen] = useState(false);
204
+ const [fileTree, setFileTree] = useState<string[]>([]);
205
+ const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
206
+ const [linting, setLinting] = useState(false);
207
+ const [refreshKey, setRefreshKey] = useState(0);
208
+ const [renderState, setRenderState] = useState<"idle" | "rendering" | "complete" | "error">(
209
+ "idle",
210
+ );
211
+ const [renderProgress, setRenderProgress] = useState(0);
212
+ const [renderError, setRenderError] = useState<string | null>(null);
213
+ const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
214
+ const projectIdRef = useRef(projectId);
215
+
216
+ // Listen for external file changes (user editing HTML outside the editor)
217
+ useEffect(() => {
218
+ if (!import.meta.hot) return;
219
+ const handler = () => {
220
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
221
+ refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
222
+ };
223
+ import.meta.hot.on("hf:file-change", handler);
224
+ return () => import.meta.hot?.off?.("hf:file-change", handler);
225
+ }, []);
226
+ projectIdRef.current = projectId;
227
+
228
+ // Load file tree when projectId changes
229
+ const prevProjectIdRef = useRef<string | null>(null);
230
+ if (projectId && projectId !== prevProjectIdRef.current) {
231
+ prevProjectIdRef.current = projectId;
232
+ fetch(`/api/projects/${projectId}`)
233
+ .then((r) => r.json())
234
+ .then((data: { files?: string[] }) => {
235
+ if (data.files) setFileTree(data.files);
236
+ })
237
+ .catch(() => {});
238
+ }
239
+
240
+ const handleSelectProject = useCallback((id: string) => {
241
+ window.location.hash = `#project/${id}`;
242
+ setProjectId(id);
243
+ }, []);
244
+
245
+ const handleFileSelect = useCallback((path: string) => {
246
+ const pid = projectIdRef.current;
247
+ if (!pid) return;
248
+ fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
249
+ .then((r) => r.json())
250
+ .then((data: { content?: string }) => {
251
+ if (data.content != null) {
252
+ setEditingFile({ path, content: data.content });
253
+ setSidebarOpen(true);
254
+ }
255
+ })
256
+ .catch(() => {});
257
+ }, []);
258
+
259
+ const editingPathRef = useRef(editingFile?.path);
260
+ editingPathRef.current = editingFile?.path;
261
+
262
+ const handleContentChange = useCallback((content: string) => {
263
+ const pid = projectIdRef.current;
264
+ const path = editingPathRef.current;
265
+ if (!pid || !path) return;
266
+ // Don't update editingFile state — the editor manages its own content.
267
+ // Only save to disk and refresh the preview.
268
+ fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
269
+ method: "PUT",
270
+ headers: { "Content-Type": "text/plain" },
271
+ body: content,
272
+ })
273
+ .then(() => {
274
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
275
+ refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
276
+ })
277
+ .catch(() => {});
278
+ }, []);
279
+
280
+ const handleLint = useCallback(async () => {
281
+ const pid = projectIdRef.current;
282
+ if (!pid) return;
283
+ setLinting(true);
284
+ try {
285
+ // Fetch all HTML files and lint them client-side using the core linter
286
+ const res = await fetch(`/api/projects/${pid}`);
287
+ const data = await res.json();
288
+ const files: string[] = data.files?.filter((f: string) => f.endsWith(".html")) ?? [];
289
+
290
+ const findings: LintFinding[] = [];
291
+ for (const file of files) {
292
+ const fileRes = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(file)}`);
293
+ const fileData = await fileRes.json();
294
+ if (!fileData.content) continue;
295
+
296
+ // Basic lint checks (subset of the full linter)
297
+ const html = fileData.content as string;
298
+
299
+ if (file === "index.html") {
300
+ // Check for root composition
301
+ if (!html.includes("data-composition-id")) {
302
+ findings.push({
303
+ severity: "error",
304
+ message: "No element with `data-composition-id` found.",
305
+ file,
306
+ fixHint: "Add `data-composition-id` to the root composition wrapper.",
307
+ });
308
+ }
309
+ // Check for timeline registration
310
+ if (!html.includes("__timelines")) {
311
+ findings.push({
312
+ severity: "error",
313
+ message: "Missing `window.__timelines` registration.",
314
+ file,
315
+ fixHint: 'Add: window.__timelines["compositionId"] = tl;',
316
+ });
317
+ }
318
+ // Check for TARGET_DURATION
319
+ if (
320
+ html.includes("gsap.timeline") &&
321
+ !html.includes("TARGET_DURATION") &&
322
+ !html.includes("tl.set({}, {},")
323
+ ) {
324
+ findings.push({
325
+ severity: "warning",
326
+ message: "No TARGET_DURATION spacer found. Video may be shorter than intended.",
327
+ file,
328
+ fixHint:
329
+ "Add: const TARGET_DURATION = 30; if (tl.duration() < TARGET_DURATION) { tl.set({}, {}, TARGET_DURATION); }",
330
+ });
331
+ }
332
+ }
333
+
334
+ // Check for composition hosts missing dimensions
335
+ const hostRe = /data-composition-src=["']([^"']+)["']/g;
336
+ let hostMatch;
337
+ while ((hostMatch = hostRe.exec(html)) !== null) {
338
+ const surrounding = html.slice(
339
+ Math.max(0, hostMatch.index - 300),
340
+ hostMatch.index + hostMatch[0].length + 50,
341
+ );
342
+ const hasDataDims =
343
+ /data-width\s*=/i.test(surrounding) && /data-height\s*=/i.test(surrounding);
344
+ const hasStyleDims = /style\s*=.*width:\s*\d+px.*height:\s*\d+px/i.test(surrounding);
345
+ if (!hasDataDims && !hasStyleDims) {
346
+ findings.push({
347
+ severity: "warning",
348
+ message: `Composition host for "${hostMatch[1]}" missing data-width/data-height. May render with zero dimensions.`,
349
+ file,
350
+ fixHint:
351
+ 'Add data-width="1920" data-height="1080" style="position:relative;width:1920px;height:1080px"',
352
+ });
353
+ }
354
+ }
355
+
356
+ // Check for repeat: -1
357
+ if (/repeat\s*:\s*-\s*1/.test(html)) {
358
+ findings.push({
359
+ severity: "error",
360
+ message: "GSAP `repeat: -1` found — infinite loop breaks timeline duration.",
361
+ file,
362
+ fixHint: "Use a finite repeat count or CSS animation.",
363
+ });
364
+ }
365
+
366
+ // Check script syntax
367
+ const scriptRe = /<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi;
368
+ let scriptMatch;
369
+ while ((scriptMatch = scriptRe.exec(html)) !== null) {
370
+ const js = scriptMatch[1]?.trim();
371
+ if (!js) continue;
372
+ try {
373
+ new Function(js);
374
+ } catch (e) {
375
+ findings.push({
376
+ severity: "error",
377
+ message: `Script syntax error: ${e instanceof Error ? e.message : String(e)}`,
378
+ file,
379
+ });
380
+ }
381
+ }
382
+ }
383
+
384
+ setLintModal(findings);
385
+ } catch {
386
+ setLintModal([{ severity: "error", message: "Failed to run lint." }]);
387
+ } finally {
388
+ setLinting(false);
389
+ }
390
+ }, []);
391
+
392
+ const handleRender = useCallback(async () => {
393
+ const pid = projectIdRef.current;
394
+ if (!pid || renderState === "rendering") return;
395
+ setRenderState("rendering");
396
+ setRenderProgress(0);
397
+ setRenderError(null);
398
+ try {
399
+ // Start render via studio backend
400
+ const res = await fetch(`/api/projects/${pid}/render`, {
401
+ method: "POST",
402
+ headers: { "Content-Type": "application/json" },
403
+ body: JSON.stringify({}),
404
+ });
405
+ if (!res.ok) throw new Error(`Render failed: ${res.status}`);
406
+ const { jobId } = await res.json();
407
+
408
+ // Subscribe to progress via SSE
409
+ const eventSource = new EventSource(`/api/render/${jobId}/progress`);
410
+ eventSource.addEventListener("progress", (event) => {
411
+ try {
412
+ const data = JSON.parse(event.data);
413
+ setRenderProgress(data.progress ?? 0);
414
+ if (data.status === "complete") {
415
+ setRenderState("complete");
416
+ eventSource.close();
417
+ // Auto-download
418
+ window.open(`/api/render/${jobId}/download`, "_blank");
419
+ } else if (data.status === "failed") {
420
+ setRenderState("error");
421
+ setRenderError(data.error || "Render failed");
422
+ eventSource.close();
423
+ }
424
+ } catch {
425
+ /* ignore */
426
+ }
427
+ });
428
+ eventSource.onerror = () => {
429
+ setRenderState("error");
430
+ setRenderError("Lost connection to render server");
431
+ eventSource.close();
432
+ };
433
+ } catch (err) {
434
+ setRenderState("error");
435
+ setRenderError(err instanceof Error ? err.message : "Render failed");
436
+ }
437
+ }, [renderState]);
438
+
439
+ if (resolving) {
440
+ return (
441
+ <div className="h-screen w-screen bg-neutral-950 flex items-center justify-center">
442
+ <div className="text-sm text-neutral-500">Loading...</div>
443
+ </div>
444
+ );
445
+ }
446
+
447
+ if (!projectId) {
448
+ return <ProjectPicker onSelect={handleSelectProject} />;
449
+ }
450
+
451
+ return (
452
+ <div className="flex h-screen w-screen bg-neutral-950">
453
+ {/* NLE: Preview + Timeline */}
454
+ <div className="flex-1 relative min-w-0">
455
+ <NLELayout
456
+ projectId={projectId}
457
+ refreshKey={refreshKey}
458
+ activeCompositionPath={
459
+ editingFile?.path?.startsWith("compositions/") ? editingFile.path : null
460
+ }
461
+ />
462
+ </div>
463
+
464
+ {/* Action buttons — positioned based on sidebar state */}
465
+ {!sidebarOpen && (
466
+ <div className="absolute top-3 right-3 z-50 flex items-center gap-1.5">
467
+ <button
468
+ onClick={() => setSidebarOpen(true)}
469
+ className="h-8 px-3 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-500 hover:text-neutral-200 transition-colors flex items-center justify-center"
470
+ title="Source editor"
471
+ >
472
+ <CodeIcon size={16} />
473
+ </button>
474
+ <button
475
+ onClick={handleLint}
476
+ disabled={linting}
477
+ className="h-8 px-3 rounded-lg bg-neutral-900 border border-neutral-800 text-xs font-medium text-neutral-400 hover:text-amber-300 hover:border-amber-800/50 transition-colors disabled:opacity-40"
478
+ >
479
+ {linting ? "Linting..." : "Lint"}
480
+ </button>
481
+ <button
482
+ onClick={handleRender}
483
+ disabled={renderState === "rendering"}
484
+ className="h-8 px-3 rounded-lg bg-blue-600 border border-blue-500 text-xs font-semibold text-white hover:bg-blue-500 transition-colors disabled:opacity-60 tabular-nums"
485
+ >
486
+ {renderState === "rendering"
487
+ ? `${Math.round(renderProgress)}%`
488
+ : renderState === "complete"
489
+ ? "Done!"
490
+ : "Export MP4"}
491
+ </button>
492
+ </div>
493
+ )}
494
+
495
+ {/* Source editor sidebar */}
496
+ {sidebarOpen && (
497
+ <div className="w-[420px] flex flex-col border-l border-neutral-800 bg-neutral-900">
498
+ <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800 gap-2">
499
+ <span className="text-xs font-medium text-neutral-500 truncate min-w-0 flex-1">
500
+ {editingFile?.path ?? "Source"}
501
+ </span>
502
+ <div className="flex items-center gap-1.5 flex-shrink-0">
503
+ <button
504
+ onClick={handleLint}
505
+ disabled={linting}
506
+ className="px-2 py-1 rounded text-[11px] font-medium text-neutral-500 hover:text-amber-300 transition-colors disabled:opacity-40"
507
+ >
508
+ {linting ? "..." : "Lint"}
509
+ </button>
510
+ <button
511
+ onClick={handleRender}
512
+ disabled={renderState === "rendering"}
513
+ className="px-2 py-1 rounded text-[11px] font-semibold text-blue-400 hover:text-blue-300 transition-colors disabled:opacity-60 tabular-nums"
514
+ >
515
+ {renderState === "rendering" ? `${Math.round(renderProgress)}%` : "Export MP4"}
516
+ </button>
517
+ <button
518
+ onClick={() => setSidebarOpen(false)}
519
+ className="p-1 rounded text-neutral-600 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
520
+ title="Close source panel"
521
+ >
522
+ <XIcon size={14} />
523
+ </button>
524
+ </div>
525
+ </div>
526
+
527
+ {fileTree.length > 0 && (
528
+ <div className="border-b border-neutral-800 max-h-40 overflow-y-auto">
529
+ <FileTree
530
+ files={fileTree}
531
+ activeFile={editingFile?.path ?? null}
532
+ onSelectFile={handleFileSelect}
533
+ />
534
+ </div>
535
+ )}
536
+
537
+ <div className="flex-1 overflow-hidden">
538
+ {editingFile ? (
539
+ <SourceEditor
540
+ content={editingFile.content}
541
+ filePath={editingFile.path}
542
+ onChange={handleContentChange}
543
+ />
544
+ ) : (
545
+ <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
546
+ Select a file to edit
547
+ </div>
548
+ )}
549
+ </div>
550
+ </div>
551
+ )}
552
+
553
+ {/* Lint modal */}
554
+ {lintModal !== null && <LintModal findings={lintModal} onClose={() => setLintModal(null)} />}
555
+ </div>
556
+ );
557
+ }
@@ -0,0 +1,70 @@
1
+ import { memo } from "react";
2
+ import { FileCode, Image, Film, Music, File } from "../../icons/SystemIcons";
3
+
4
+ interface FileTreeProps {
5
+ files: string[];
6
+ activeFile: string | null;
7
+ onSelectFile: (path: string) => void;
8
+ }
9
+
10
+ const FILE_ICONS: Record<string, { icon: typeof File; color: string }> = {
11
+ html: { icon: FileCode, color: "#3B82F6" },
12
+ css: { icon: FileCode, color: "#A855F7" },
13
+ js: { icon: FileCode, color: "#F59E0B" },
14
+ ts: { icon: FileCode, color: "#3B82F6" },
15
+ json: { icon: File, color: "#22C55E" },
16
+ png: { icon: Image, color: "#22C55E" },
17
+ jpg: { icon: Image, color: "#22C55E" },
18
+ svg: { icon: Image, color: "#F97316" },
19
+ mp4: { icon: Film, color: "#A855F7" },
20
+ mp3: { icon: Music, color: "#F59E0B" },
21
+ wav: { icon: Music, color: "#F59E0B" },
22
+ };
23
+
24
+ function getFileIcon(path: string) {
25
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
26
+ return FILE_ICONS[ext] ?? { icon: File, color: "#737373" };
27
+ }
28
+
29
+ export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
30
+ const sorted = [...files].sort((a, b) => {
31
+ // index.html first, then alphabetical
32
+ if (a === "index.html") return -1;
33
+ if (b === "index.html") return 1;
34
+ return a.localeCompare(b);
35
+ });
36
+
37
+ return (
38
+ <div className="flex flex-col h-full min-h-0">
39
+ <div className="px-2.5 py-1.5 border-b border-neutral-800 flex-shrink-0">
40
+ <span className="text-2xs font-medium text-neutral-500 uppercase tracking-caps">Files</span>
41
+ </div>
42
+ <div className="flex-1 overflow-y-auto py-1">
43
+ {sorted.map((path) => {
44
+ const { icon: Icon, color } = getFileIcon(path);
45
+ const isActive = path === activeFile;
46
+ const name = path.split("/").pop() ?? path;
47
+ const dir = path.includes("/") ? path.split("/").slice(0, -1).join("/") + "/" : "";
48
+
49
+ return (
50
+ <button
51
+ key={path}
52
+ onClick={() => onSelectFile(path)}
53
+ className={`w-full flex items-center gap-2 px-2.5 py-1 min-h-7 text-left transition-all duration-press text-xs ${
54
+ isActive
55
+ ? "bg-neutral-800/60 text-neutral-200"
56
+ : "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300 active:scale-[0.98]"
57
+ }`}
58
+ >
59
+ <Icon size={12} style={{ color }} className="flex-shrink-0" />
60
+ <span className="truncate">
61
+ {dir && <span className="text-neutral-600">{dir}</span>}
62
+ {name}
63
+ </span>
64
+ </button>
65
+ );
66
+ })}
67
+ </div>
68
+ </div>
69
+ );
70
+ });