@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.
- package/dist/assets/index-Bj0pPj_X.js +92 -0
- package/dist/assets/index-BnvciBdD.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +10 -4
- package/src/App.tsx +744 -271
- package/src/components/editor/FileTree.tsx +186 -32
- package/src/components/editor/SourceEditor.tsx +3 -1
- package/src/components/nle/NLELayout.tsx +125 -23
- package/src/components/renders/RenderQueue.tsx +123 -0
- package/src/components/renders/RenderQueueItem.tsx +133 -0
- package/src/components/renders/useRenderQueue.ts +161 -0
- package/src/components/sidebar/AssetsTab.tsx +360 -0
- package/src/components/sidebar/CompositionsTab.tsx +227 -0
- package/src/components/sidebar/LeftSidebar.tsx +102 -0
- package/src/components/ui/ExpandOnHover.tsx +194 -0
- package/src/hooks/useCodeEditor.ts +1 -1
- package/src/hooks/useElementPicker.ts +5 -1
- package/src/index.ts +10 -2
- package/src/player/components/AudioWaveform.tsx +168 -0
- package/src/player/components/CompositionThumbnail.tsx +140 -0
- package/src/player/components/EditModal.tsx +165 -0
- package/src/player/components/Player.tsx +6 -5
- package/src/player/components/PlayerControls.tsx +78 -39
- package/src/player/components/Timeline.test.ts +110 -0
- package/src/player/components/Timeline.tsx +537 -260
- package/src/player/components/TimelineClip.tsx +80 -0
- package/src/player/components/VideoThumbnail.tsx +196 -0
- package/src/player/hooks/useTimelinePlayer.ts +404 -112
- package/src/player/index.ts +3 -3
- package/src/player/lib/time.test.ts +57 -0
- package/src/player/lib/time.ts +1 -0
- package/src/player/store/playerStore.test.ts +265 -0
- package/src/player/store/playerStore.ts +44 -16
- package/src/utils/htmlEditor.ts +164 -0
- package/dist/assets/index-Df6fO-S6.js +0 -78
- package/dist/assets/index-KoBceNoU.css +0 -1
- package/src/player/components/AgentActivityTrack.tsx +0 -93
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
<
|
|
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="
|
|
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="
|
|
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="
|
|
354
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
58
355
|
{projects.map((p) => (
|
|
59
|
-
<
|
|
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({
|
|
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-
|
|
102
|
-
<CheckCircleIcon size={18} className="text-
|
|
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
|
-
{/*
|
|
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-
|
|
143
|
-
<p className="text-xs text-
|
|
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-
|
|
160
|
-
<p className="text-xs text-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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={() =>
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
<
|
|
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-
|
|
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={
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
{/*
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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={
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
972
|
+
Code
|
|
523
973
|
</button>
|
|
524
974
|
<button
|
|
525
|
-
onClick={() =>
|
|
526
|
-
className=
|
|
527
|
-
|
|
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
|
-
|
|
982
|
+
Renders{renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
|
|
530
983
|
</button>
|
|
531
984
|
</div>
|
|
532
|
-
</div>
|
|
533
985
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
<
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
558
|
-
|
|
1028
|
+
)}
|
|
1029
|
+
</div>
|
|
559
1030
|
|
|
560
1031
|
{/* Lint modal */}
|
|
561
|
-
{lintModal !== null &&
|
|
1032
|
+
{lintModal !== null && (
|
|
1033
|
+
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
|
|
1034
|
+
)}
|
|
562
1035
|
</div>
|
|
563
1036
|
);
|
|
564
1037
|
}
|