@hyperframes/studio 0.1.9 → 0.1.11

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 (38) hide show
  1. package/dist/assets/index-Bj0pPj_X.js +92 -0
  2. package/dist/assets/index-BnvciBdD.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +10 -4
  5. package/src/App.tsx +744 -271
  6. package/src/components/editor/FileTree.tsx +186 -32
  7. package/src/components/editor/SourceEditor.tsx +3 -1
  8. package/src/components/nle/NLELayout.tsx +125 -23
  9. package/src/components/renders/RenderQueue.tsx +123 -0
  10. package/src/components/renders/RenderQueueItem.tsx +133 -0
  11. package/src/components/renders/useRenderQueue.ts +161 -0
  12. package/src/components/sidebar/AssetsTab.tsx +360 -0
  13. package/src/components/sidebar/CompositionsTab.tsx +227 -0
  14. package/src/components/sidebar/LeftSidebar.tsx +102 -0
  15. package/src/components/ui/ExpandOnHover.tsx +194 -0
  16. package/src/hooks/useCodeEditor.ts +1 -1
  17. package/src/hooks/useElementPicker.ts +5 -1
  18. package/src/index.ts +10 -2
  19. package/src/player/components/AudioWaveform.tsx +168 -0
  20. package/src/player/components/CompositionThumbnail.tsx +140 -0
  21. package/src/player/components/EditModal.tsx +165 -0
  22. package/src/player/components/Player.tsx +6 -5
  23. package/src/player/components/PlayerControls.tsx +78 -39
  24. package/src/player/components/Timeline.test.ts +110 -0
  25. package/src/player/components/Timeline.tsx +537 -260
  26. package/src/player/components/TimelineClip.tsx +80 -0
  27. package/src/player/components/VideoThumbnail.tsx +196 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +404 -112
  29. package/src/player/index.ts +3 -3
  30. package/src/player/lib/time.test.ts +57 -0
  31. package/src/player/lib/time.ts +1 -0
  32. package/src/player/store/playerStore.test.ts +265 -0
  33. package/src/player/store/playerStore.ts +44 -16
  34. package/src/utils/htmlEditor.ts +164 -0
  35. package/dist/assets/index-Df6fO-S6.js +0 -78
  36. package/dist/assets/index-KoBceNoU.css +0 -1
  37. package/src/player/components/AgentActivityTrack.tsx +0 -93
  38. package/src/player/lib/useMountEffect.ts +0 -10
package/src/App.tsx CHANGED
@@ -1,18 +1,19 @@
1
- import { useState, useCallback, useRef, useEffect } from "react";
1
+ import { useState, useCallback, useRef, useEffect, type ReactNode } from "react";
2
+ import { useMountEffect } from "./hooks/useMountEffect";
2
3
  import { NLELayout } from "./components/nle/NLELayout";
3
4
  import { SourceEditor } from "./components/editor/SourceEditor";
4
5
  import { FileTree } from "./components/editor/FileTree";
5
- import {
6
- XIcon,
7
- CodeIcon,
8
- WarningIcon,
9
- CheckCircleIcon,
10
- CaretRightIcon,
11
- } from "@phosphor-icons/react";
6
+ import { LeftSidebar } from "./components/sidebar/LeftSidebar";
7
+ import { RenderQueue } from "./components/renders/RenderQueue";
8
+ import { useRenderQueue } from "./components/renders/useRenderQueue";
9
+ import { CompositionThumbnail, VideoThumbnail } from "./player";
10
+ import { AudioWaveform } from "./player/components/AudioWaveform";
11
+ import type { TimelineElement } from "./player";
12
+ import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
12
13
 
13
14
  interface EditingFile {
14
15
  path: string;
15
- content: string;
16
+ content: string | null;
16
17
  }
17
18
 
18
19
  interface ProjectEntry {
@@ -28,13 +29,251 @@ interface LintFinding {
28
29
  fixHint?: string;
29
30
  }
30
31
 
32
+ import { ExpandOnHover } from "./components/ui/ExpandOnHover";
33
+
34
+ // ── Media file detection and preview ──
35
+
36
+ const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
37
+ const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
38
+ const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
39
+ const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i;
40
+
41
+ function isMediaFile(path: string): boolean {
42
+ return (
43
+ IMAGE_EXT.test(path) || VIDEO_EXT.test(path) || AUDIO_EXT.test(path) || FONT_EXT.test(path)
44
+ );
45
+ }
46
+
47
+ function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
48
+ const serveUrl = `/api/projects/${projectId}/preview/${filePath}`;
49
+ const name = filePath.split("/").pop() ?? filePath;
50
+
51
+ if (IMAGE_EXT.test(filePath)) {
52
+ return (
53
+ <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950">
54
+ <img
55
+ src={serveUrl}
56
+ alt={name}
57
+ className="max-w-full max-h-[70%] object-contain rounded border border-neutral-800"
58
+ />
59
+ <span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ if (VIDEO_EXT.test(filePath)) {
65
+ return (
66
+ <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950">
67
+ <video
68
+ src={serveUrl}
69
+ controls
70
+ className="max-w-full max-h-[70%] rounded border border-neutral-800"
71
+ />
72
+ <span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
73
+ </div>
74
+ );
75
+ }
76
+
77
+ if (AUDIO_EXT.test(filePath)) {
78
+ return (
79
+ <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-3">
80
+ <svg
81
+ width="48"
82
+ height="48"
83
+ viewBox="0 0 24 24"
84
+ fill="none"
85
+ stroke="currentColor"
86
+ strokeWidth="1.5"
87
+ className="text-neutral-600"
88
+ >
89
+ <path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
90
+ <circle cx="6" cy="18" r="3" />
91
+ <circle cx="18" cy="16" r="3" />
92
+ </svg>
93
+ <audio src={serveUrl} controls className="w-full max-w-[280px]" />
94
+ <span className="text-[11px] text-neutral-500 font-mono">{filePath}</span>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ // Fonts and other binary — show info instead of binary dump
100
+ return (
101
+ <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-2">
102
+ <svg
103
+ width="40"
104
+ height="40"
105
+ viewBox="0 0 24 24"
106
+ fill="none"
107
+ stroke="currentColor"
108
+ strokeWidth="1.5"
109
+ className="text-neutral-600"
110
+ >
111
+ <path
112
+ d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
113
+ strokeLinecap="round"
114
+ strokeLinejoin="round"
115
+ />
116
+ <polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round" />
117
+ </svg>
118
+ <span className="text-sm text-neutral-400 font-medium">{name}</span>
119
+ <span className="text-[11px] text-neutral-600 font-mono">{filePath}</span>
120
+ <span className="text-[10px] text-neutral-600">Binary file — preview not available</span>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ // ── Project Card with hover-to-preview ──
126
+
127
+ function ExpandedPreviewIframe({ src }: { src: string }) {
128
+ const containerRef = useRef<HTMLDivElement>(null);
129
+ const iframeRef = useRef<HTMLIFrameElement>(null);
130
+ const [dims, setDims] = useState({ w: 1920, h: 1080 });
131
+ const [scale, setScale] = useState(1);
132
+
133
+ // Recalculate scale when container resizes or dims change.
134
+ // Note: useEffect with [dims] dep — syncs with ResizeObserver (external system).
135
+ // eslint-disable-next-line no-restricted-syntax
136
+ useEffect(() => {
137
+ const el = containerRef.current;
138
+ if (!el) return;
139
+ const update = () => {
140
+ const cw = el.clientWidth;
141
+ const ch = el.clientHeight;
142
+ // Fit the composition inside the container (contain, not cover)
143
+ const s = Math.min(cw / dims.w, ch / dims.h);
144
+ setScale(s);
145
+ };
146
+ update();
147
+ const ro = new ResizeObserver(update);
148
+ ro.observe(el);
149
+ return () => ro.disconnect();
150
+ }, [dims]);
151
+
152
+ // After iframe loads: detect composition dimensions, seek, and play
153
+ const handleLoad = useCallback(() => {
154
+ const iframe = iframeRef.current;
155
+ if (!iframe) return;
156
+ let attempts = 0;
157
+ const interval = setInterval(() => {
158
+ try {
159
+ const doc = iframe.contentDocument;
160
+ if (doc) {
161
+ const comp = doc.querySelector("[data-composition-id]") as HTMLElement | null;
162
+ if (comp) {
163
+ const w = parseInt(comp.getAttribute("data-width") ?? "0", 10);
164
+ const h = parseInt(comp.getAttribute("data-height") ?? "0", 10);
165
+ if (w > 0 && h > 0) setDims({ w, h });
166
+ }
167
+ }
168
+ const win = iframe.contentWindow as Window & {
169
+ __player?: { seek: (t: number) => void; play: () => void };
170
+ };
171
+ if (win?.__player) {
172
+ win.__player.seek(2);
173
+ win.__player.play();
174
+ clearInterval(interval);
175
+ }
176
+ } catch {
177
+ /* cross-origin */
178
+ }
179
+ if (++attempts > 25) clearInterval(interval);
180
+ }, 200);
181
+ }, []);
182
+
183
+ // Center the scaled iframe
184
+ const offsetX = containerRef.current
185
+ ? (containerRef.current.clientWidth - dims.w * scale) / 2
186
+ : 0;
187
+ const offsetY = containerRef.current
188
+ ? (containerRef.current.clientHeight - dims.h * scale) / 2
189
+ : 0;
190
+
191
+ return (
192
+ <div ref={containerRef} className="w-full h-full relative overflow-hidden bg-black">
193
+ <iframe
194
+ ref={iframeRef}
195
+ src={src}
196
+ sandbox="allow-scripts allow-same-origin"
197
+ onLoad={handleLoad}
198
+ className="absolute border-none"
199
+ style={{
200
+ left: Math.max(0, offsetX),
201
+ top: Math.max(0, offsetY),
202
+ width: dims.w,
203
+ height: dims.h,
204
+ transformOrigin: "0 0",
205
+ transform: `scale(${scale})`,
206
+ }}
207
+ />
208
+ </div>
209
+ );
210
+ }
211
+
212
+ function ProjectCard({ project: p, onSelect }: { project: ProjectEntry; onSelect: () => void }) {
213
+ const thumbnailUrl = `/api/projects/${p.id}/thumbnail/index.html?t=0.5`;
214
+ const previewUrl = `/api/projects/${p.id}/preview`;
215
+
216
+ const card = (
217
+ <div className="rounded-xl overflow-hidden bg-neutral-900 border border-neutral-800/60 hover:border-[#3CE6AC]/30 hover:shadow-lg hover:shadow-[#3CE6AC]/5 transition-all duration-200 cursor-pointer">
218
+ <div className="aspect-video bg-neutral-950 relative overflow-hidden flex items-center justify-center">
219
+ <img
220
+ src={thumbnailUrl}
221
+ alt={p.title ?? p.id}
222
+ loading="lazy"
223
+ className="max-w-full max-h-full object-contain"
224
+ onError={(e) => {
225
+ (e.target as HTMLImageElement).style.display = "none";
226
+ }}
227
+ />
228
+ </div>
229
+ <div className="px-3.5 py-3">
230
+ <div className="text-sm font-medium text-neutral-200 truncate">{p.title ?? p.id}</div>
231
+ <div className="text-[10px] text-neutral-600 font-mono truncate mt-0.5">{p.id}</div>
232
+ </div>
233
+ </div>
234
+ );
235
+
236
+ return (
237
+ <ExpandOnHover
238
+ expandedContent={(closeExpand) => (
239
+ <div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
240
+ <div className="flex-1 min-h-0">
241
+ <ExpandedPreviewIframe src={previewUrl} />
242
+ </div>
243
+ <div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
244
+ <div>
245
+ <div className="text-sm font-medium text-neutral-200">{p.title ?? p.id}</div>
246
+ <div className="text-[10px] text-neutral-600 font-mono mt-0.5">{p.id}</div>
247
+ </div>
248
+ <button
249
+ onClick={(e) => {
250
+ e.stopPropagation();
251
+ closeExpand();
252
+ onSelect();
253
+ }}
254
+ className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
255
+ >
256
+ Open
257
+ </button>
258
+ </div>
259
+ </div>
260
+ )}
261
+ onClick={onSelect}
262
+ expandScale={0.6}
263
+ delay={400}
264
+ >
265
+ {card}
266
+ </ExpandOnHover>
267
+ );
268
+ }
269
+
31
270
  // ── Project Picker ──
32
271
 
33
272
  function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
34
273
  const [projects, setProjects] = useState<ProjectEntry[]>([]);
35
274
  const [loading, setLoading] = useState(true);
36
275
 
37
- useEffect(() => {
276
+ useMountEffect(() => {
38
277
  fetch("/api/projects")
39
278
  .then((r) => r.json())
40
279
  .then((data: { projects?: ProjectEntry[] }) => {
@@ -42,30 +281,79 @@ function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
42
281
  setLoading(false);
43
282
  })
44
283
  .catch(() => setLoading(false));
45
- }, []);
284
+ });
46
285
 
47
286
  return (
48
287
  <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>
288
+ {/* Header */}
289
+ <div className="max-w-4xl mx-auto px-6 pt-16 pb-8">
290
+ <div className="flex items-center gap-3 mb-2">
291
+ <svg width="32" height="32" viewBox="0 0 512 512" className="flex-shrink-0">
292
+ <rect width="512" height="512" rx="115" fill="#1A1913" />
293
+ <g strokeLinecap="round" strokeLinejoin="round">
294
+ <polyline
295
+ points="156,176 76,256 156,336"
296
+ fill="none"
297
+ stroke="#7B7568"
298
+ strokeWidth="32"
299
+ />
300
+ <line x1="206" y1="346" x2="286" y2="166" stroke="#D8D3C5" strokeWidth="32" />
301
+ <polygon
302
+ points="336,176 436,256 336,336"
303
+ fill="#3CE6AC"
304
+ stroke="#3CE6AC"
305
+ strokeWidth="32"
306
+ />
307
+ </g>
308
+ </svg>
309
+ <h1 className="text-2xl font-bold text-neutral-100 tracking-tight">HyperFrames Studio</h1>
310
+ </div>
311
+ <p className="text-sm text-neutral-500 ml-11">Your projects</p>
312
+ </div>
313
+
314
+ {/* Project grid */}
315
+ <div className="max-w-4xl mx-auto px-6 pb-16">
52
316
  {loading ? (
53
- <div className="text-sm text-neutral-600">Loading projects...</div>
317
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
318
+ {[1, 2, 3].map((i) => (
319
+ <div key={i} className="aspect-video rounded-xl bg-neutral-900 animate-pulse" />
320
+ ))}
321
+ </div>
54
322
  ) : projects.length === 0 ? (
55
- <div className="text-sm text-neutral-600">No projects found.</div>
323
+ <div className="flex flex-col items-center justify-center py-24 gap-4">
324
+ <svg width="48" height="48" viewBox="0 0 512 512" className="opacity-20">
325
+ <rect width="512" height="512" rx="115" fill="#1A1913" />
326
+ <g strokeLinecap="round" strokeLinejoin="round">
327
+ <polyline
328
+ points="156,176 76,256 156,336"
329
+ fill="none"
330
+ stroke="#7B7568"
331
+ strokeWidth="32"
332
+ />
333
+ <line x1="206" y1="346" x2="286" y2="166" stroke="#D8D3C5" strokeWidth="32" />
334
+ <polygon
335
+ points="336,176 436,256 336,336"
336
+ fill="#3CE6AC"
337
+ stroke="#3CE6AC"
338
+ strokeWidth="32"
339
+ />
340
+ </g>
341
+ </svg>
342
+ <div className="text-center">
343
+ <p className="text-sm text-neutral-400 font-medium">No projects yet</p>
344
+ <p className="text-xs text-neutral-600 mt-1">
345
+ Run{" "}
346
+ <code className="px-1.5 py-0.5 rounded bg-neutral-800 text-[#3CE6AC] text-[11px]">
347
+ hyperframes init
348
+ </code>{" "}
349
+ to create one
350
+ </p>
351
+ </div>
352
+ </div>
56
353
  ) : (
57
- <div className="flex flex-col gap-1.5">
354
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
58
355
  {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>
356
+ <ProjectCard key={p.id} project={p} onSelect={() => onSelect(p.id)} />
69
357
  ))}
70
358
  </div>
71
359
  )}
@@ -76,10 +364,36 @@ function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
76
364
 
77
365
  // ── Lint Modal ──
78
366
 
79
- function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: () => void }) {
367
+ function LintModal({
368
+ findings,
369
+ projectId,
370
+ onClose,
371
+ }: {
372
+ findings: LintFinding[];
373
+ projectId: string;
374
+ onClose: () => void;
375
+ }) {
80
376
  const errors = findings.filter((f) => f.severity === "error");
81
377
  const warnings = findings.filter((f) => f.severity === "warning");
82
378
  const hasIssues = findings.length > 0;
379
+ const [copied, setCopied] = useState(false);
380
+
381
+ const handleCopyToAgent = async () => {
382
+ const lines = findings.map((f) => {
383
+ let line = `[${f.severity}] ${f.message}`;
384
+ if (f.file) line += `\n File: ${f.file}`;
385
+ if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
386
+ return line;
387
+ });
388
+ const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`;
389
+ try {
390
+ await navigator.clipboard.writeText(text);
391
+ setCopied(true);
392
+ setTimeout(() => setCopied(false), 2000);
393
+ } catch {
394
+ // ignore
395
+ }
396
+ };
83
397
 
84
398
  return (
85
399
  <div
@@ -98,8 +412,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
98
412
  <WarningIcon size={18} className="text-red-400" weight="fill" />
99
413
  </div>
100
414
  ) : (
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" />
415
+ <div className="w-8 h-8 rounded-full bg-[#3CE6AC]/10 flex items-center justify-center">
416
+ <CheckCircleIcon size={18} className="text-[#3CE6AC]" weight="fill" />
103
417
  </div>
104
418
  )}
105
419
  <div>
@@ -119,7 +433,19 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
119
433
  </button>
120
434
  </div>
121
435
 
122
- {/* Findings */}
436
+ {/* Copy to agent + findings */}
437
+ {hasIssues && (
438
+ <div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
439
+ <button
440
+ onClick={handleCopyToAgent}
441
+ className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
442
+ copied ? "bg-green-600 text-white" : "bg-[#3CE6AC] hover:bg-[#3CE6AC]/80 text-white"
443
+ }`}
444
+ >
445
+ {copied ? "Copied!" : "Copy to Agent"}
446
+ </button>
447
+ </div>
448
+ )}
123
449
  <div className="flex-1 overflow-y-auto px-5 py-3">
124
450
  {!hasIssues && (
125
451
  <div className="py-8 text-center text-neutral-500 text-sm">
@@ -139,8 +465,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
139
465
  {f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
140
466
  {f.fixHint && (
141
467
  <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>
468
+ <CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
469
+ <p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
144
470
  </div>
145
471
  )}
146
472
  </div>
@@ -156,8 +482,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
156
482
  {f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
157
483
  {f.fixHint && (
158
484
  <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>
485
+ <CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
486
+ <p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
161
487
  </div>
162
488
  )}
163
489
  </div>
@@ -176,7 +502,7 @@ export function StudioApp() {
176
502
  const [projectId, setProjectId] = useState<string | null>(null);
177
503
  const [resolving, setResolving] = useState(true);
178
504
 
179
- useEffect(() => {
505
+ useMountEffect(() => {
180
506
  const hash = window.location.hash;
181
507
  const projectMatch = hash.match(/project\/([^/]+)/);
182
508
  const sessionMatch = hash.match(/session\/([^/]+)/);
@@ -197,25 +523,126 @@ export function StudioApp() {
197
523
  } else {
198
524
  setResolving(false);
199
525
  }
200
- }, []);
526
+ });
201
527
 
202
528
  const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
203
- const [sidebarOpen, setSidebarOpen] = useState(false);
529
+ const [rightTab, setRightTab] = useState<"code" | "renders">("code");
530
+ const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
204
531
  const [fileTree, setFileTree] = useState<string[]>([]);
532
+ const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
533
+ const renderQueue = useRenderQueue(projectId);
534
+
535
+ // Resizable and collapsible panel widths
536
+ const [leftWidth, setLeftWidth] = useState(240);
537
+ const [rightWidth, setRightWidth] = useState(400);
538
+ const [leftCollapsed, setLeftCollapsed] = useState(false);
539
+ const [rightCollapsed, setRightCollapsed] = useState(false);
540
+ const panelDragRef = useRef<{ side: "left" | "right"; startX: number; startW: number } | null>(
541
+ null,
542
+ );
543
+
544
+ // Derive active preview URL from composition path (for drilled-down thumbnails)
545
+ const activePreviewUrl = activeCompPath
546
+ ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
547
+ : null;
548
+
549
+ const renderClipContent = useCallback(
550
+ (el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
551
+ const pid = projectIdRef.current;
552
+ if (!pid) return null;
553
+
554
+ // Resolve composition source path using the compIdToSrc map
555
+ let compSrc = el.compositionSrc;
556
+ if (compSrc && compIdToSrc.size > 0) {
557
+ const resolved =
558
+ compIdToSrc.get(el.id) ||
559
+ compIdToSrc.get(compSrc.replace(/^compositions\//, "").replace(/\.html$/, ""));
560
+ if (resolved) compSrc = resolved;
561
+ }
562
+
563
+ // Composition clips — always use the comp's own preview URL for thumbnails.
564
+ // This renders the composition in isolation so we get clean frames
565
+ // instead of capturing the master at a time when the comp is fading in.
566
+ if (compSrc) {
567
+ return (
568
+ <CompositionThumbnail
569
+ previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
570
+ label={el.id || el.tag}
571
+ labelColor={style.label}
572
+ seekTime={0}
573
+ duration={el.duration}
574
+ />
575
+ );
576
+ }
577
+
578
+ // When drilled into a composition, render all inner elements via
579
+ // CompositionThumbnail at their start time — most accurate visual.
580
+ if (activePreviewUrl && el.duration > 0) {
581
+ return (
582
+ <CompositionThumbnail
583
+ previewUrl={activePreviewUrl}
584
+ label={el.id || el.tag}
585
+ labelColor={style.label}
586
+ seekTime={el.start}
587
+ duration={el.duration}
588
+ />
589
+ );
590
+ }
591
+
592
+ // Audio clips — waveform visualization
593
+ if (el.tag === "audio") {
594
+ const audioUrl = el.src
595
+ ? el.src.startsWith("http")
596
+ ? el.src
597
+ : `/api/projects/${pid}/preview/${el.src}`
598
+ : "";
599
+ return (
600
+ <AudioWaveform audioUrl={audioUrl} label={el.id || el.tag} labelColor={style.label} />
601
+ );
602
+ }
603
+
604
+ if ((el.tag === "video" || el.tag === "img") && el.src) {
605
+ const mediaSrc = el.src.startsWith("http")
606
+ ? el.src
607
+ : `/api/projects/${pid}/preview/${el.src}`;
608
+ return (
609
+ <VideoThumbnail
610
+ videoSrc={mediaSrc}
611
+ label={el.id || el.tag}
612
+ labelColor={style.label}
613
+ duration={el.duration}
614
+ />
615
+ );
616
+ }
617
+
618
+ // HTML scene elements — render from the master preview at the scene's time
619
+ if (el.tag === "div" && el.duration > 0) {
620
+ const previewUrl = `/api/projects/${pid}/preview`;
621
+ return (
622
+ <CompositionThumbnail
623
+ previewUrl={previewUrl}
624
+ label={el.id || el.tag}
625
+ labelColor={style.label}
626
+ seekTime={el.start}
627
+ duration={el.duration}
628
+ />
629
+ );
630
+ }
631
+
632
+ return null;
633
+ },
634
+ [compIdToSrc, activePreviewUrl],
635
+ );
205
636
  const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
206
637
  const [linting, setLinting] = useState(false);
207
638
  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
639
  const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
214
640
  const projectIdRef = useRef(projectId);
641
+ const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
215
642
 
216
643
  // Listen for external file changes (user editing HTML outside the editor).
217
644
  // In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
218
- useEffect(() => {
645
+ useMountEffect(() => {
219
646
  const handler = () => {
220
647
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
221
648
  refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
@@ -228,35 +655,50 @@ export function StudioApp() {
228
655
  const es = new EventSource("/api/events");
229
656
  es.addEventListener("file-change", handler);
230
657
  return () => es.close();
231
- }, []);
658
+ });
232
659
  projectIdRef.current = projectId;
233
660
 
234
- // Load file tree when projectId changes
235
- const prevProjectIdRef = useRef<string | null>(null);
236
- if (projectId && projectId !== prevProjectIdRef.current) {
237
- prevProjectIdRef.current = projectId;
661
+ // Load file tree when projectId changes.
662
+ // Note: This is one of the few places where useEffect with deps is acceptable —
663
+ // it's data fetching tied to a prop change. Ideally this would use a data-fetching
664
+ // library (useQuery/useSWR) or the parent component would own the fetch.
665
+ // eslint-disable-next-line no-restricted-syntax
666
+ useEffect(() => {
667
+ if (!projectId) return;
668
+ let cancelled = false;
238
669
  fetch(`/api/projects/${projectId}`)
239
670
  .then((r) => r.json())
240
671
  .then((data: { files?: string[] }) => {
241
- if (data.files) setFileTree(data.files);
672
+ if (!cancelled && data.files) setFileTree(data.files);
242
673
  })
243
674
  .catch(() => {});
244
- }
675
+ return () => {
676
+ cancelled = true;
677
+ };
678
+ }, [projectId]);
245
679
 
246
680
  const handleSelectProject = useCallback((id: string) => {
247
681
  window.location.hash = `#project/${id}`;
248
682
  setProjectId(id);
683
+ setActiveCompPath(null);
684
+ setEditingFile(null);
685
+ setCompIdToSrc(new Map());
686
+ setFileTree([]);
249
687
  }, []);
250
688
 
251
689
  const handleFileSelect = useCallback((path: string) => {
252
690
  const pid = projectIdRef.current;
253
691
  if (!pid) return;
692
+ // Skip fetching binary content for media files — just set the path for preview
693
+ if (isMediaFile(path)) {
694
+ setEditingFile({ path, content: null });
695
+ return;
696
+ }
254
697
  fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
255
698
  .then((r) => r.json())
256
699
  .then((data: { content?: string }) => {
257
700
  if (data.content != null) {
258
701
  setEditingFile({ path, content: data.content });
259
- setSidebarOpen(true);
260
702
  }
261
703
  })
262
704
  .catch(() => {});
@@ -288,105 +730,16 @@ export function StudioApp() {
288
730
  if (!pid) return;
289
731
  setLinting(true);
290
732
  try {
291
- // Fetch all HTML files and lint them client-side using the core linter
292
- const res = await fetch(`/api/projects/${pid}`);
733
+ const res = await fetch(`/api/projects/${pid}/lint`);
293
734
  const data = await res.json();
294
- const files: string[] = data.files?.filter((f: string) => f.endsWith(".html")) ?? [];
295
-
296
- const findings: LintFinding[] = [];
297
- for (const file of files) {
298
- const fileRes = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(file)}`);
299
- const fileData = await fileRes.json();
300
- if (!fileData.content) continue;
301
-
302
- // Basic lint checks (subset of the full linter)
303
- const html = fileData.content as string;
304
-
305
- if (file === "index.html") {
306
- // Check for root composition
307
- if (!html.includes("data-composition-id")) {
308
- findings.push({
309
- severity: "error",
310
- message: "No element with `data-composition-id` found.",
311
- file,
312
- fixHint: "Add `data-composition-id` to the root composition wrapper.",
313
- });
314
- }
315
- // Check for timeline registration
316
- if (!html.includes("__timelines")) {
317
- findings.push({
318
- severity: "error",
319
- message: "Missing `window.__timelines` registration.",
320
- file,
321
- fixHint: 'Add: window.__timelines["compositionId"] = tl;',
322
- });
323
- }
324
- // Check for TARGET_DURATION
325
- if (
326
- html.includes("gsap.timeline") &&
327
- !html.includes("TARGET_DURATION") &&
328
- !html.includes("tl.set({}, {},")
329
- ) {
330
- findings.push({
331
- severity: "warning",
332
- message: "No TARGET_DURATION spacer found. Video may be shorter than intended.",
333
- file,
334
- fixHint:
335
- "Add: const TARGET_DURATION = 30; if (tl.duration() < TARGET_DURATION) { tl.set({}, {}, TARGET_DURATION); }",
336
- });
337
- }
338
- }
339
-
340
- // Check for composition hosts missing dimensions
341
- const hostRe = /data-composition-src=["']([^"']+)["']/g;
342
- let hostMatch;
343
- while ((hostMatch = hostRe.exec(html)) !== null) {
344
- const surrounding = html.slice(
345
- Math.max(0, hostMatch.index - 300),
346
- hostMatch.index + hostMatch[0].length + 50,
347
- );
348
- const hasDataDims =
349
- /data-width\s*=/i.test(surrounding) && /data-height\s*=/i.test(surrounding);
350
- const hasStyleDims = /style\s*=.*width:\s*\d+px.*height:\s*\d+px/i.test(surrounding);
351
- if (!hasDataDims && !hasStyleDims) {
352
- findings.push({
353
- severity: "warning",
354
- message: `Composition host for "${hostMatch[1]}" missing data-width/data-height. May render with zero dimensions.`,
355
- file,
356
- fixHint:
357
- 'Add data-width="1920" data-height="1080" style="position:relative;width:1920px;height:1080px"',
358
- });
359
- }
360
- }
361
-
362
- // Check for repeat: -1
363
- if (/repeat\s*:\s*-\s*1/.test(html)) {
364
- findings.push({
365
- severity: "error",
366
- message: "GSAP `repeat: -1` found — infinite loop breaks timeline duration.",
367
- file,
368
- fixHint: "Use a finite repeat count or CSS animation.",
369
- });
370
- }
371
-
372
- // Check script syntax
373
- const scriptRe = /<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi;
374
- let scriptMatch;
375
- while ((scriptMatch = scriptRe.exec(html)) !== null) {
376
- const js = scriptMatch[1]?.trim();
377
- if (!js) continue;
378
- try {
379
- new Function(js);
380
- } catch (e) {
381
- findings.push({
382
- severity: "error",
383
- message: `Script syntax error: ${e instanceof Error ? e.message : String(e)}`,
384
- file,
385
- });
386
- }
387
- }
388
- }
389
-
735
+ const findings: LintFinding[] = (data.findings ?? []).map(
736
+ (f: { severity?: string; message?: string; file?: string; fixHint?: string }) => ({
737
+ severity: f.severity === "error" ? ("error" as const) : ("warning" as const),
738
+ message: f.message ?? "",
739
+ file: f.file,
740
+ fixHint: f.fixHint,
741
+ }),
742
+ );
390
743
  setLintModal(findings);
391
744
  } catch (err) {
392
745
  const msg = err instanceof Error ? err.message : String(err);
@@ -396,52 +749,35 @@ export function StudioApp() {
396
749
  }
397
750
  }, []);
398
751
 
399
- const handleRender = useCallback(async () => {
400
- const pid = projectIdRef.current;
401
- if (!pid || renderState === "rendering") return;
402
- setRenderState("rendering");
403
- setRenderProgress(0);
404
- setRenderError(null);
405
- try {
406
- // Start render via studio backend
407
- const res = await fetch(`/api/projects/${pid}/render`, {
408
- method: "POST",
409
- headers: { "Content-Type": "application/json" },
410
- body: JSON.stringify({}),
411
- });
412
- if (!res.ok) throw new Error(`Render failed: ${res.status}`);
413
- const { jobId } = await res.json();
414
-
415
- // Subscribe to progress via SSE
416
- const eventSource = new EventSource(`/api/render/${jobId}/progress`);
417
- eventSource.addEventListener("progress", (event) => {
418
- try {
419
- const data = JSON.parse(event.data);
420
- setRenderProgress(data.progress ?? 0);
421
- if (data.status === "complete") {
422
- setRenderState("complete");
423
- eventSource.close();
424
- // Auto-download
425
- window.open(`/api/render/${jobId}/download`, "_blank");
426
- } else if (data.status === "failed") {
427
- setRenderState("error");
428
- setRenderError(data.error || "Render failed");
429
- eventSource.close();
430
- }
431
- } catch {
432
- /* ignore */
433
- }
434
- });
435
- eventSource.onerror = () => {
436
- setRenderState("error");
437
- setRenderError("Lost connection to render server");
438
- eventSource.close();
752
+ // Panel resize via pointer events (works for both left sidebar and right panel)
753
+ const handlePanelResizeStart = useCallback(
754
+ (side: "left" | "right", e: React.PointerEvent) => {
755
+ e.preventDefault();
756
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
757
+ panelDragRef.current = {
758
+ side,
759
+ startX: e.clientX,
760
+ startW: side === "left" ? leftWidth : rightWidth,
439
761
  };
440
- } catch (err) {
441
- setRenderState("error");
442
- setRenderError(err instanceof Error ? err.message : "Render failed");
443
- }
444
- }, [renderState]);
762
+ },
763
+ [leftWidth, rightWidth],
764
+ );
765
+
766
+ const handlePanelResizeMove = useCallback((e: React.PointerEvent) => {
767
+ const drag = panelDragRef.current;
768
+ if (!drag) return;
769
+ const delta = e.clientX - drag.startX;
770
+ const newW = Math.max(
771
+ 160,
772
+ Math.min(600, drag.startW + (drag.side === "left" ? delta : -delta)),
773
+ );
774
+ if (drag.side === "left") setLeftWidth(newW);
775
+ else setRightWidth(newW);
776
+ }, []);
777
+
778
+ const handlePanelResizeEnd = useCallback(() => {
779
+ panelDragRef.current = null;
780
+ }, []);
445
781
 
446
782
  if (resolving) {
447
783
  return (
@@ -455,110 +791,247 @@ export function StudioApp() {
455
791
  return <ProjectPicker onSelect={handleSelectProject} />;
456
792
  }
457
793
 
458
- return (
459
- <div className="flex h-screen w-screen bg-neutral-950">
460
- {/* NLE: Preview + Timeline */}
461
- <div className="flex-1 relative min-w-0">
462
- <NLELayout
463
- projectId={projectId}
464
- refreshKey={refreshKey}
465
- activeCompositionPath={
466
- editingFile?.path?.startsWith("compositions/") ? editingFile.path : null
467
- }
468
- />
469
- </div>
794
+ const compositions = fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/"));
795
+ const assets = fileTree.filter(
796
+ (f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json"),
797
+ );
470
798
 
471
- {/* Action buttons — positioned based on sidebar state */}
472
- {!sidebarOpen && (
473
- <div className="absolute top-3 right-3 z-50 flex items-center gap-1.5">
799
+ return (
800
+ <div className="flex flex-col h-screen w-screen bg-neutral-950">
801
+ {/* Header bar */}
802
+ <div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
803
+ {/* Left: back button + project name */}
804
+ <div className="flex items-center gap-2">
474
805
  <button
475
- onClick={() => setSidebarOpen(true)}
476
- 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"
477
- title="Source editor"
806
+ onClick={() => {
807
+ window.location.hash = "";
808
+ setProjectId(null);
809
+ }}
810
+ className="flex items-center gap-1.5 px-2 py-1 rounded-md text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
478
811
  >
479
- <CodeIcon size={16} />
812
+ <svg
813
+ width="14"
814
+ height="14"
815
+ viewBox="0 0 24 24"
816
+ fill="none"
817
+ stroke="currentColor"
818
+ strokeWidth="2"
819
+ strokeLinecap="round"
820
+ strokeLinejoin="round"
821
+ >
822
+ <polyline points="15 18 9 12 15 6" />
823
+ </svg>
824
+ <span className="text-xs">Projects</span>
825
+ </button>
826
+ <span className="text-[11px] text-neutral-600">/</span>
827
+ <span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
828
+ </div>
829
+ {/* Right: toolbar buttons */}
830
+ <div className="flex items-center gap-1.5">
831
+ <button
832
+ onClick={() => setLeftCollapsed((v) => !v)}
833
+ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
834
+ leftCollapsed
835
+ ? "bg-neutral-800 border-neutral-700 text-neutral-300"
836
+ : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
837
+ }`}
838
+ title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
839
+ >
840
+ <svg
841
+ width="14"
842
+ height="14"
843
+ viewBox="0 0 24 24"
844
+ fill="none"
845
+ stroke="currentColor"
846
+ strokeWidth="1.5"
847
+ strokeLinecap="round"
848
+ strokeLinejoin="round"
849
+ >
850
+ <rect x="3" y="3" width="18" height="18" rx="2" />
851
+ <path d="M9 3v18" />
852
+ </svg>
480
853
  </button>
481
854
  <button
482
855
  onClick={handleLint}
483
856
  disabled={linting}
484
- 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"
857
+ className="h-7 px-2.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
485
858
  >
486
859
  {linting ? "Linting..." : "Lint"}
487
860
  </button>
488
861
  <button
489
- onClick={handleRender}
490
- disabled={renderState === "rendering"}
491
- 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"
862
+ onClick={() => setRightCollapsed((v) => !v)}
863
+ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
864
+ rightCollapsed
865
+ ? "bg-neutral-800 border-neutral-700 text-neutral-300"
866
+ : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
867
+ }`}
868
+ title={rightCollapsed ? "Show code panel" : "Hide code panel"}
492
869
  >
493
- {renderState === "rendering"
494
- ? `${Math.round(renderProgress)}%`
495
- : renderState === "complete"
496
- ? "Done!"
497
- : "Export MP4"}
870
+ <svg
871
+ width="14"
872
+ height="14"
873
+ viewBox="0 0 24 24"
874
+ fill="none"
875
+ stroke="currentColor"
876
+ strokeWidth="1.5"
877
+ strokeLinecap="round"
878
+ strokeLinejoin="round"
879
+ >
880
+ <rect x="3" y="3" width="18" height="18" rx="2" />
881
+ <path d="M15 3v18" />
882
+ </svg>
498
883
  </button>
499
884
  </div>
500
- )}
885
+ </div>
501
886
 
502
- {/* Source editor sidebar */}
503
- {sidebarOpen && (
504
- <div className="w-[420px] flex flex-col border-l border-neutral-800 bg-neutral-900">
505
- <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800 gap-2">
506
- <span className="text-xs font-medium text-neutral-500 truncate min-w-0 flex-1">
507
- {editingFile?.path ?? "Source"}
508
- </span>
509
- <div className="flex items-center gap-1.5 flex-shrink-0">
510
- <button
511
- onClick={handleLint}
512
- disabled={linting}
513
- className="px-2 py-1 rounded text-[11px] font-medium text-neutral-500 hover:text-amber-300 transition-colors disabled:opacity-40"
514
- >
515
- {linting ? "..." : "Lint"}
516
- </button>
887
+ {/* Main content: sidebar + preview + right panel */}
888
+ <div className="flex flex-1 min-h-0">
889
+ {/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
890
+ {!leftCollapsed && (
891
+ <LeftSidebar
892
+ width={leftWidth}
893
+ projectId={projectId}
894
+ compositions={compositions}
895
+ assets={assets}
896
+ activeComposition={editingFile?.path ?? null}
897
+ onSelectComposition={(comp) => {
898
+ // Set active composition for preview drill-down
899
+ // Don't increment refreshKey — that reloads the master iframe and
900
+ // overrides the composition navigation. Let activeCompositionPath
901
+ // handle the preview change via the composition stack.
902
+ setActiveCompPath(
903
+ comp === "index.html" || comp.startsWith("compositions/") ? comp : null,
904
+ );
905
+ // Load file content for code editor
906
+ setEditingFile({ path: comp, content: null });
907
+ fetch(`/api/projects/${projectId}/files/${comp}`)
908
+ .then((r) => r.json())
909
+ .then((data) => setEditingFile({ path: comp, content: data.content }))
910
+ .catch(() => {});
911
+ }}
912
+ />
913
+ )}
914
+
915
+ {/* Left resize handle */}
916
+ {!leftCollapsed && (
917
+ <div
918
+ className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-col-resize transition-colors active:bg-blue-400"
919
+ style={{ touchAction: "none" }}
920
+ onPointerDown={(e) => handlePanelResizeStart("left", e)}
921
+ onPointerMove={handlePanelResizeMove}
922
+ onPointerUp={handlePanelResizeEnd}
923
+ />
924
+ )}
925
+
926
+ {/* Center: Preview */}
927
+ <div className="flex-1 relative min-w-0">
928
+ <NLELayout
929
+ projectId={projectId}
930
+ refreshKey={refreshKey}
931
+ activeCompositionPath={activeCompPath}
932
+ renderClipContent={renderClipContent}
933
+ onCompIdToSrcChange={setCompIdToSrc}
934
+ onCompositionChange={(compPath) => {
935
+ // Sync activeCompPath when user drills down via timeline double-click
936
+ // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
937
+ setActiveCompPath(compPath);
938
+ }}
939
+ onIframeRef={(iframe) => {
940
+ previewIframeRef.current = iframe;
941
+ }}
942
+ />
943
+ </div>
944
+
945
+ {/* Right resize handle */}
946
+ {!rightCollapsed && (
947
+ <div
948
+ className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-col-resize transition-colors active:bg-blue-400"
949
+ style={{ touchAction: "none" }}
950
+ onPointerDown={(e) => handlePanelResizeStart("right", e)}
951
+ onPointerMove={handlePanelResizeMove}
952
+ onPointerUp={handlePanelResizeEnd}
953
+ />
954
+ )}
955
+
956
+ {/* Right panel: Code + Renders tabs (resizable, collapsible) */}
957
+ {!rightCollapsed && (
958
+ <div
959
+ className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
960
+ style={{ width: rightWidth }}
961
+ >
962
+ {/* Tab bar */}
963
+ <div className="flex items-center border-b border-neutral-800 flex-shrink-0">
517
964
  <button
518
- onClick={handleRender}
519
- disabled={renderState === "rendering"}
520
- className="px-2 py-1 rounded text-[11px] font-semibold text-blue-400 hover:text-blue-300 transition-colors disabled:opacity-60 tabular-nums"
965
+ onClick={() => setRightTab("code")}
966
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
967
+ rightTab === "code"
968
+ ? "text-neutral-200 border-b-2 border-[#3CE6AC]"
969
+ : "text-neutral-500 hover:text-neutral-400"
970
+ }`}
521
971
  >
522
- {renderState === "rendering" ? `${Math.round(renderProgress)}%` : "Export MP4"}
972
+ Code
523
973
  </button>
524
974
  <button
525
- onClick={() => setSidebarOpen(false)}
526
- className="p-1 rounded text-neutral-600 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
527
- title="Close source panel"
975
+ onClick={() => setRightTab("renders")}
976
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
977
+ rightTab === "renders"
978
+ ? "text-neutral-200 border-b-2 border-[#3CE6AC]"
979
+ : "text-neutral-500 hover:text-neutral-400"
980
+ }`}
528
981
  >
529
- <XIcon size={14} />
982
+ Renders{renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
530
983
  </button>
531
984
  </div>
532
- </div>
533
985
 
534
- {fileTree.length > 0 && (
535
- <div className="border-b border-neutral-800 max-h-40 overflow-y-auto">
536
- <FileTree
537
- files={fileTree}
538
- activeFile={editingFile?.path ?? null}
539
- onSelectFile={handleFileSelect}
540
- />
541
- </div>
542
- )}
543
-
544
- <div className="flex-1 overflow-hidden">
545
- {editingFile ? (
546
- <SourceEditor
547
- content={editingFile.content}
548
- filePath={editingFile.path}
549
- onChange={handleContentChange}
550
- />
551
- ) : (
552
- <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
553
- Select a file to edit
986
+ {/* Tab content */}
987
+ {rightTab === "code" ? (
988
+ <div className="flex flex-1 min-h-0">
989
+ {/* File tree sidebar */}
990
+ {fileTree.length > 0 && (
991
+ <div className="w-[140px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
992
+ <FileTree
993
+ files={fileTree}
994
+ activeFile={editingFile?.path ?? null}
995
+ onSelectFile={handleFileSelect}
996
+ />
997
+ </div>
998
+ )}
999
+ {/* Code editor or media preview */}
1000
+ <div className="flex-1 overflow-hidden min-w-0">
1001
+ {editingFile ? (
1002
+ isMediaFile(editingFile.path) ? (
1003
+ <MediaPreview projectId={projectId} filePath={editingFile.path} />
1004
+ ) : (
1005
+ <SourceEditor
1006
+ content={editingFile.content ?? ""}
1007
+ filePath={editingFile.path}
1008
+ onChange={handleContentChange}
1009
+ />
1010
+ )
1011
+ ) : (
1012
+ <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
1013
+ Select a file to edit
1014
+ </div>
1015
+ )}
1016
+ </div>
554
1017
  </div>
1018
+ ) : (
1019
+ <RenderQueue
1020
+ jobs={renderQueue.jobs}
1021
+ onDelete={renderQueue.deleteRender}
1022
+ onClearCompleted={renderQueue.clearCompleted}
1023
+ onStartRender={(format) => renderQueue.startRender(30, "standard", format)}
1024
+ isRendering={renderQueue.isRendering}
1025
+ />
555
1026
  )}
556
1027
  </div>
557
- </div>
558
- )}
1028
+ )}
1029
+ </div>
559
1030
 
560
1031
  {/* Lint modal */}
561
- {lintModal !== null && <LintModal findings={lintModal} onClose={() => setLintModal(null)} />}
1032
+ {lintModal !== null && (
1033
+ <LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
1034
+ )}
562
1035
  </div>
563
1036
  );
564
1037
  }