@hyperframes/studio 0.1.12 → 0.1.14
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-CLmYRLY-.css +1 -0
- package/dist/assets/index-CRvFpc0E.js +84 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/App.tsx +139 -657
- package/src/components/LintModal.tsx +149 -0
- package/src/components/MediaPreview.tsx +79 -0
- package/src/components/editor/FileTree.tsx +50 -40
- package/src/components/editor/PropertyPanel.tsx +3 -3
- package/src/components/nle/NLELayout.tsx +59 -43
- package/src/components/renders/RenderQueue.tsx +19 -16
- package/src/components/renders/RenderQueueItem.tsx +77 -19
- package/src/components/renders/useRenderQueue.ts +1 -0
- package/src/components/sidebar/AssetsTab.tsx +37 -149
- package/src/components/sidebar/CompositionsTab.tsx +48 -162
- package/src/components/sidebar/LeftSidebar.tsx +79 -8
- package/src/components/ui/VideoFrameThumbnail.tsx +50 -0
- package/src/index.ts +0 -3
- package/src/player/components/CompositionThumbnail.tsx +21 -95
- package/src/player/components/EditModal.tsx +5 -5
- package/src/player/components/Player.tsx +0 -1
- package/src/player/components/PlayerControls.tsx +56 -3
- package/src/player/components/Timeline.tsx +14 -18
- package/src/player/components/TimelineClip.tsx +0 -1
- package/src/player/index.ts +0 -1
- package/src/player/store/playerStore.ts +3 -28
- package/src/utils/mediaTypes.ts +9 -0
- package/dist/assets/index-BEwJNmPo.js +0 -92
- package/dist/assets/index-BnvciBdD.css +0 -1
- package/src/components/ui/ExpandOnHover.tsx +0 -194
- package/src/hooks/useCodeEditor.ts +0 -88
- package/src/player/components/PreviewPanel.tsx +0 -181
package/src/App.tsx
CHANGED
|
@@ -1,532 +1,52 @@
|
|
|
1
|
-
import { useState, useCallback, useRef, useEffect, type ReactNode } from "react";
|
|
1
|
+
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
|
|
2
2
|
import { useMountEffect } from "./hooks/useMountEffect";
|
|
3
3
|
import { NLELayout } from "./components/nle/NLELayout";
|
|
4
4
|
import { SourceEditor } from "./components/editor/SourceEditor";
|
|
5
|
-
import { FileTree } from "./components/editor/FileTree";
|
|
6
5
|
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
|
7
6
|
import { RenderQueue } from "./components/renders/RenderQueue";
|
|
8
7
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
9
8
|
import { CompositionThumbnail, VideoThumbnail } from "./player";
|
|
10
9
|
import { AudioWaveform } from "./player/components/AudioWaveform";
|
|
11
10
|
import type { TimelineElement } from "./player";
|
|
12
|
-
import {
|
|
11
|
+
import { LintModal } from "./components/LintModal";
|
|
12
|
+
import type { LintFinding } from "./components/LintModal";
|
|
13
|
+
import { MediaPreview } from "./components/MediaPreview";
|
|
14
|
+
import { isMediaFile } from "./utils/mediaTypes";
|
|
13
15
|
|
|
14
16
|
interface EditingFile {
|
|
15
17
|
path: string;
|
|
16
18
|
content: string | null;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
interface ProjectEntry {
|
|
20
|
-
id: string;
|
|
21
|
-
title?: string;
|
|
22
|
-
sessionId?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface LintFinding {
|
|
26
|
-
severity: "error" | "warning";
|
|
27
|
-
message: string;
|
|
28
|
-
file?: string;
|
|
29
|
-
fixHint?: string;
|
|
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
|
-
|
|
270
|
-
// ── Project Picker ──
|
|
271
|
-
|
|
272
|
-
function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
|
|
273
|
-
const [projects, setProjects] = useState<ProjectEntry[]>([]);
|
|
274
|
-
const [loading, setLoading] = useState(true);
|
|
275
|
-
|
|
276
|
-
useMountEffect(() => {
|
|
277
|
-
fetch("/api/projects")
|
|
278
|
-
.then((r) => r.json())
|
|
279
|
-
.then((data: { projects?: ProjectEntry[] }) => {
|
|
280
|
-
setProjects(data.projects ?? []);
|
|
281
|
-
setLoading(false);
|
|
282
|
-
})
|
|
283
|
-
.catch(() => setLoading(false));
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
return (
|
|
287
|
-
<div className="h-screen w-screen bg-neutral-950 overflow-y-auto">
|
|
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">
|
|
316
|
-
{loading ? (
|
|
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>
|
|
322
|
-
) : projects.length === 0 ? (
|
|
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>
|
|
353
|
-
) : (
|
|
354
|
-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
355
|
-
{projects.map((p) => (
|
|
356
|
-
<ProjectCard key={p.id} project={p} onSelect={() => onSelect(p.id)} />
|
|
357
|
-
))}
|
|
358
|
-
</div>
|
|
359
|
-
)}
|
|
360
|
-
</div>
|
|
361
|
-
</div>
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// ── Lint Modal ──
|
|
366
|
-
|
|
367
|
-
function LintModal({
|
|
368
|
-
findings,
|
|
369
|
-
projectId,
|
|
370
|
-
onClose,
|
|
371
|
-
}: {
|
|
372
|
-
findings: LintFinding[];
|
|
373
|
-
projectId: string;
|
|
374
|
-
onClose: () => void;
|
|
375
|
-
}) {
|
|
376
|
-
const errors = findings.filter((f) => f.severity === "error");
|
|
377
|
-
const warnings = findings.filter((f) => f.severity === "warning");
|
|
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
|
-
};
|
|
397
|
-
|
|
398
|
-
return (
|
|
399
|
-
<div
|
|
400
|
-
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
401
|
-
onClick={onClose}
|
|
402
|
-
>
|
|
403
|
-
<div
|
|
404
|
-
className="bg-neutral-950 border border-neutral-800 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col overflow-hidden"
|
|
405
|
-
onClick={(e) => e.stopPropagation()}
|
|
406
|
-
>
|
|
407
|
-
{/* Header */}
|
|
408
|
-
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
|
|
409
|
-
<div className="flex items-center gap-3">
|
|
410
|
-
{hasIssues ? (
|
|
411
|
-
<div className="w-8 h-8 rounded-full bg-red-500/10 flex items-center justify-center">
|
|
412
|
-
<WarningIcon size={18} className="text-red-400" weight="fill" />
|
|
413
|
-
</div>
|
|
414
|
-
) : (
|
|
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" />
|
|
417
|
-
</div>
|
|
418
|
-
)}
|
|
419
|
-
<div>
|
|
420
|
-
<h2 className="text-sm font-semibold text-neutral-200">
|
|
421
|
-
{hasIssues
|
|
422
|
-
? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`
|
|
423
|
-
: "All checks passed"}
|
|
424
|
-
</h2>
|
|
425
|
-
<p className="text-xs text-neutral-500">HyperFrame Lint Results</p>
|
|
426
|
-
</div>
|
|
427
|
-
</div>
|
|
428
|
-
<button
|
|
429
|
-
onClick={onClose}
|
|
430
|
-
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
|
|
431
|
-
>
|
|
432
|
-
<XIcon size={16} />
|
|
433
|
-
</button>
|
|
434
|
-
</div>
|
|
435
|
-
|
|
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
|
-
)}
|
|
449
|
-
<div className="flex-1 overflow-y-auto px-5 py-3">
|
|
450
|
-
{!hasIssues && (
|
|
451
|
-
<div className="py-8 text-center text-neutral-500 text-sm">
|
|
452
|
-
No errors or warnings found. Your composition looks good!
|
|
453
|
-
</div>
|
|
454
|
-
)}
|
|
455
|
-
{errors.map((f, i) => (
|
|
456
|
-
<div key={`e-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
|
|
457
|
-
<div className="flex items-start gap-2">
|
|
458
|
-
<WarningIcon
|
|
459
|
-
size={14}
|
|
460
|
-
className="text-red-400 flex-shrink-0 mt-0.5"
|
|
461
|
-
weight="fill"
|
|
462
|
-
/>
|
|
463
|
-
<div className="min-w-0">
|
|
464
|
-
<p className="text-sm text-neutral-200">{f.message}</p>
|
|
465
|
-
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
|
|
466
|
-
{f.fixHint && (
|
|
467
|
-
<div className="flex items-start gap-1 mt-1.5">
|
|
468
|
-
<CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
|
|
469
|
-
<p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
|
|
470
|
-
</div>
|
|
471
|
-
)}
|
|
472
|
-
</div>
|
|
473
|
-
</div>
|
|
474
|
-
</div>
|
|
475
|
-
))}
|
|
476
|
-
{warnings.map((f, i) => (
|
|
477
|
-
<div key={`w-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
|
|
478
|
-
<div className="flex items-start gap-2">
|
|
479
|
-
<WarningIcon size={14} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
|
480
|
-
<div className="min-w-0">
|
|
481
|
-
<p className="text-sm text-neutral-300">{f.message}</p>
|
|
482
|
-
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
|
|
483
|
-
{f.fixHint && (
|
|
484
|
-
<div className="flex items-start gap-1 mt-1.5">
|
|
485
|
-
<CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
|
|
486
|
-
<p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
|
|
487
|
-
</div>
|
|
488
|
-
)}
|
|
489
|
-
</div>
|
|
490
|
-
</div>
|
|
491
|
-
</div>
|
|
492
|
-
))}
|
|
493
|
-
</div>
|
|
494
|
-
</div>
|
|
495
|
-
</div>
|
|
496
|
-
);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
21
|
// ── Main App ──
|
|
500
22
|
|
|
501
23
|
export function StudioApp() {
|
|
502
24
|
const [projectId, setProjectId] = useState<string | null>(null);
|
|
503
25
|
const [resolving, setResolving] = useState(true);
|
|
504
26
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
setProjectId(projectMatch[1]);
|
|
511
|
-
setResolving(false);
|
|
512
|
-
} else if (sessionMatch) {
|
|
513
|
-
fetch(`/api/resolve-session/${sessionMatch[1]}`)
|
|
514
|
-
.then((r) => r.json())
|
|
515
|
-
.then((data: { projectId?: string }) => {
|
|
516
|
-
if (data.projectId) {
|
|
517
|
-
window.location.hash = `#project/${data.projectId}`;
|
|
518
|
-
setProjectId(data.projectId);
|
|
519
|
-
}
|
|
520
|
-
setResolving(false);
|
|
521
|
-
})
|
|
522
|
-
.catch(() => setResolving(false));
|
|
523
|
-
} else {
|
|
27
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const hashMatch = window.location.hash.match(/^#project\/([^/]+)/);
|
|
30
|
+
if (hashMatch) {
|
|
31
|
+
setProjectId(hashMatch[1]);
|
|
524
32
|
setResolving(false);
|
|
33
|
+
return;
|
|
525
34
|
}
|
|
526
|
-
|
|
35
|
+
// No hash — auto-select first available project
|
|
36
|
+
fetch("/api/projects")
|
|
37
|
+
.then((r) => r.json())
|
|
38
|
+
.then((data) => {
|
|
39
|
+
const first = (data.projects ?? [])[0];
|
|
40
|
+
if (first) {
|
|
41
|
+
setProjectId(first.id);
|
|
42
|
+
window.location.hash = `#project/${first.id}`;
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.catch(() => {})
|
|
46
|
+
.finally(() => setResolving(false));
|
|
47
|
+
}, []);
|
|
527
48
|
|
|
528
49
|
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
529
|
-
const [rightTab, setRightTab] = useState<"code" | "renders">("code");
|
|
530
50
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
531
51
|
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
532
52
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
@@ -536,10 +56,13 @@ export function StudioApp() {
|
|
|
536
56
|
const [leftWidth, setLeftWidth] = useState(240);
|
|
537
57
|
const [rightWidth, setRightWidth] = useState(400);
|
|
538
58
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
539
|
-
const [rightCollapsed, setRightCollapsed] = useState(
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
59
|
+
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
60
|
+
const [timelineVisible, setTimelineVisible] = useState(false);
|
|
61
|
+
const panelDragRef = useRef<{
|
|
62
|
+
side: "left" | "right";
|
|
63
|
+
startX: number;
|
|
64
|
+
startW: number;
|
|
65
|
+
} | null>(null);
|
|
543
66
|
|
|
544
67
|
// Derive active preview URL from composition path (for drilled-down thumbnails)
|
|
545
68
|
const activePreviewUrl = activeCompPath
|
|
@@ -637,6 +160,7 @@ export function StudioApp() {
|
|
|
637
160
|
const [linting, setLinting] = useState(false);
|
|
638
161
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
639
162
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
163
|
+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
640
164
|
const projectIdRef = useRef(projectId);
|
|
641
165
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
642
166
|
|
|
@@ -677,18 +201,11 @@ export function StudioApp() {
|
|
|
677
201
|
};
|
|
678
202
|
}, [projectId]);
|
|
679
203
|
|
|
680
|
-
const handleSelectProject = useCallback((id: string) => {
|
|
681
|
-
window.location.hash = `#project/${id}`;
|
|
682
|
-
setProjectId(id);
|
|
683
|
-
setActiveCompPath(null);
|
|
684
|
-
setEditingFile(null);
|
|
685
|
-
setCompIdToSrc(new Map());
|
|
686
|
-
setFileTree([]);
|
|
687
|
-
}, []);
|
|
688
|
-
|
|
689
204
|
const handleFileSelect = useCallback((path: string) => {
|
|
690
205
|
const pid = projectIdRef.current;
|
|
691
206
|
if (!pid) return;
|
|
207
|
+
// Expand left panel to 50vw when opening a file in Code tab
|
|
208
|
+
setLeftWidth((prev) => Math.max(prev, Math.floor(window.innerWidth * 0.5)));
|
|
692
209
|
// Skip fetching binary content for media files — just set the path for preview
|
|
693
210
|
if (isMediaFile(path)) {
|
|
694
211
|
setEditingFile({ path, content: null });
|
|
@@ -709,20 +226,24 @@ export function StudioApp() {
|
|
|
709
226
|
|
|
710
227
|
const handleContentChange = useCallback((content: string) => {
|
|
711
228
|
const pid = projectIdRef.current;
|
|
229
|
+
if (!pid) return;
|
|
712
230
|
const path = editingPathRef.current;
|
|
713
|
-
if (!
|
|
714
|
-
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
723
|
-
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
|
231
|
+
if (!path) return;
|
|
232
|
+
|
|
233
|
+
// Debounce the server write (600ms)
|
|
234
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
235
|
+
saveTimerRef.current = setTimeout(() => {
|
|
236
|
+
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
237
|
+
method: "PUT",
|
|
238
|
+
headers: { "Content-Type": "text/plain" },
|
|
239
|
+
body: content,
|
|
724
240
|
})
|
|
725
|
-
|
|
241
|
+
.then(() => {
|
|
242
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
243
|
+
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
|
244
|
+
})
|
|
245
|
+
.catch(() => {});
|
|
246
|
+
}, 600);
|
|
726
247
|
}, []);
|
|
727
248
|
|
|
728
249
|
const handleLint = useCallback(async () => {
|
|
@@ -767,9 +288,13 @@ export function StudioApp() {
|
|
|
767
288
|
const drag = panelDragRef.current;
|
|
768
289
|
if (!drag) return;
|
|
769
290
|
const delta = e.clientX - drag.startX;
|
|
291
|
+
const maxLeft = Math.floor(window.innerWidth * 0.5);
|
|
770
292
|
const newW = Math.max(
|
|
771
293
|
160,
|
|
772
|
-
Math.min(
|
|
294
|
+
Math.min(
|
|
295
|
+
drag.side === "left" ? maxLeft : 600,
|
|
296
|
+
drag.startW + (drag.side === "left" ? delta : -delta),
|
|
297
|
+
),
|
|
773
298
|
);
|
|
774
299
|
if (drag.side === "left") setLeftWidth(newW);
|
|
775
300
|
else setRightWidth(newW);
|
|
@@ -779,60 +304,41 @@ export function StudioApp() {
|
|
|
779
304
|
panelDragRef.current = null;
|
|
780
305
|
}, []);
|
|
781
306
|
|
|
782
|
-
|
|
307
|
+
const compositions = useMemo(
|
|
308
|
+
() => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")),
|
|
309
|
+
[fileTree],
|
|
310
|
+
);
|
|
311
|
+
const assets = useMemo(
|
|
312
|
+
() =>
|
|
313
|
+
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
|
|
314
|
+
[fileTree],
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
if (resolving || !projectId) {
|
|
783
318
|
return (
|
|
784
319
|
<div className="h-screen w-screen bg-neutral-950 flex items-center justify-center">
|
|
785
|
-
<div className="
|
|
320
|
+
<div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse" />
|
|
786
321
|
</div>
|
|
787
322
|
);
|
|
788
323
|
}
|
|
789
324
|
|
|
790
|
-
|
|
791
|
-
return <ProjectPicker onSelect={handleSelectProject} />;
|
|
792
|
-
}
|
|
793
|
-
|
|
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
|
-
);
|
|
325
|
+
// At this point projectId is guaranteed non-null (narrowed by the guard above)
|
|
798
326
|
|
|
799
327
|
return (
|
|
800
328
|
<div className="flex flex-col h-screen w-screen bg-neutral-950">
|
|
801
329
|
{/* Header bar */}
|
|
802
330
|
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
|
|
803
|
-
{/* Left:
|
|
331
|
+
{/* Left: project name */}
|
|
804
332
|
<div className="flex items-center gap-2">
|
|
805
|
-
<
|
|
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"
|
|
811
|
-
>
|
|
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>
|
|
333
|
+
<span className="text-[11px] font-medium text-neutral-400">{projectId}</span>
|
|
828
334
|
</div>
|
|
829
335
|
{/* Right: toolbar buttons */}
|
|
830
336
|
<div className="flex items-center gap-1.5">
|
|
831
337
|
<button
|
|
832
338
|
onClick={() => setLeftCollapsed((v) => !v)}
|
|
833
339
|
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
834
|
-
leftCollapsed
|
|
835
|
-
? "
|
|
340
|
+
!leftCollapsed
|
|
341
|
+
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
836
342
|
: "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
|
|
837
343
|
}`}
|
|
838
344
|
title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
|
|
@@ -852,20 +358,13 @@ export function StudioApp() {
|
|
|
852
358
|
</svg>
|
|
853
359
|
</button>
|
|
854
360
|
<button
|
|
855
|
-
onClick={
|
|
856
|
-
disabled={linting}
|
|
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"
|
|
858
|
-
>
|
|
859
|
-
{linting ? "Linting..." : "Lint"}
|
|
860
|
-
</button>
|
|
861
|
-
<button
|
|
862
|
-
onClick={() => setRightCollapsed((v) => !v)}
|
|
361
|
+
onClick={() => setTimelineVisible((v) => !v)}
|
|
863
362
|
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
864
|
-
|
|
865
|
-
? "
|
|
363
|
+
timelineVisible
|
|
364
|
+
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
866
365
|
: "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
|
|
867
366
|
}`}
|
|
868
|
-
title={
|
|
367
|
+
title={timelineVisible ? "Hide timeline" : "Show timeline"}
|
|
869
368
|
>
|
|
870
369
|
<svg
|
|
871
370
|
width="14"
|
|
@@ -875,11 +374,33 @@ export function StudioApp() {
|
|
|
875
374
|
stroke="currentColor"
|
|
876
375
|
strokeWidth="1.5"
|
|
877
376
|
strokeLinecap="round"
|
|
878
|
-
strokeLinejoin="round"
|
|
879
377
|
>
|
|
880
|
-
<rect x="3" y="
|
|
881
|
-
<
|
|
378
|
+
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
379
|
+
<line x1="3" y1="9" x2="21" y2="9" />
|
|
380
|
+
<line x1="3" y1="5" x2="21" y2="5" />
|
|
381
|
+
</svg>
|
|
382
|
+
</button>
|
|
383
|
+
<button
|
|
384
|
+
onClick={() => setRightCollapsed((v) => !v)}
|
|
385
|
+
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
|
386
|
+
!rightCollapsed
|
|
387
|
+
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
388
|
+
: "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
|
|
389
|
+
}`}
|
|
390
|
+
>
|
|
391
|
+
<svg
|
|
392
|
+
width="12"
|
|
393
|
+
height="12"
|
|
394
|
+
viewBox="0 0 24 24"
|
|
395
|
+
fill="none"
|
|
396
|
+
stroke="currentColor"
|
|
397
|
+
strokeWidth="2"
|
|
398
|
+
>
|
|
399
|
+
<circle cx="12" cy="12" r="10" />
|
|
400
|
+
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
|
|
882
401
|
</svg>
|
|
402
|
+
Renders
|
|
403
|
+
{renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
|
|
883
404
|
</button>
|
|
884
405
|
</div>
|
|
885
406
|
</div>
|
|
@@ -909,13 +430,31 @@ export function StudioApp() {
|
|
|
909
430
|
.then((data) => setEditingFile({ path: comp, content: data.content }))
|
|
910
431
|
.catch(() => {});
|
|
911
432
|
}}
|
|
433
|
+
fileTree={fileTree}
|
|
434
|
+
editingFile={editingFile}
|
|
435
|
+
onSelectFile={handleFileSelect}
|
|
436
|
+
codeChildren={
|
|
437
|
+
editingFile ? (
|
|
438
|
+
isMediaFile(editingFile.path) ? (
|
|
439
|
+
<MediaPreview projectId={projectId ?? ""} filePath={editingFile.path} />
|
|
440
|
+
) : (
|
|
441
|
+
<SourceEditor
|
|
442
|
+
content={editingFile.content ?? ""}
|
|
443
|
+
filePath={editingFile.path}
|
|
444
|
+
onChange={handleContentChange}
|
|
445
|
+
/>
|
|
446
|
+
)
|
|
447
|
+
) : undefined
|
|
448
|
+
}
|
|
449
|
+
onLint={handleLint}
|
|
450
|
+
linting={linting}
|
|
912
451
|
/>
|
|
913
452
|
)}
|
|
914
453
|
|
|
915
454
|
{/* Left resize handle */}
|
|
916
455
|
{!leftCollapsed && (
|
|
917
456
|
<div
|
|
918
|
-
className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-
|
|
457
|
+
className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-col-resize transition-colors active:bg-studio-accent/80"
|
|
919
458
|
style={{ touchAction: "none" }}
|
|
920
459
|
onPointerDown={(e) => handlePanelResizeStart("left", e)}
|
|
921
460
|
onPointerMove={handlePanelResizeMove}
|
|
@@ -939,97 +478,40 @@ export function StudioApp() {
|
|
|
939
478
|
onIframeRef={(iframe) => {
|
|
940
479
|
previewIframeRef.current = iframe;
|
|
941
480
|
}}
|
|
481
|
+
timelineVisible={timelineVisible}
|
|
482
|
+
onToggleTimeline={() => setTimelineVisible((v) => !v)}
|
|
942
483
|
/>
|
|
943
484
|
</div>
|
|
944
485
|
|
|
945
|
-
{/* Right
|
|
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) */}
|
|
486
|
+
{/* Right panel: Renders-only (resizable, collapsible via header Renders button) */}
|
|
957
487
|
{!rightCollapsed && (
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
}`}
|
|
971
|
-
>
|
|
972
|
-
Code
|
|
973
|
-
</button>
|
|
974
|
-
<button
|
|
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
|
-
}`}
|
|
981
|
-
>
|
|
982
|
-
Renders{renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
|
|
983
|
-
</button>
|
|
984
|
-
</div>
|
|
985
|
-
|
|
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>
|
|
1017
|
-
</div>
|
|
1018
|
-
) : (
|
|
488
|
+
<>
|
|
489
|
+
<div
|
|
490
|
+
className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-col-resize transition-colors active:bg-studio-accent/80"
|
|
491
|
+
style={{ touchAction: "none" }}
|
|
492
|
+
onPointerDown={(e) => handlePanelResizeStart("right", e)}
|
|
493
|
+
onPointerMove={handlePanelResizeMove}
|
|
494
|
+
onPointerUp={handlePanelResizeEnd}
|
|
495
|
+
/>
|
|
496
|
+
<div
|
|
497
|
+
className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
|
|
498
|
+
style={{ width: rightWidth }}
|
|
499
|
+
>
|
|
1019
500
|
<RenderQueue
|
|
1020
501
|
jobs={renderQueue.jobs}
|
|
502
|
+
projectId={projectId}
|
|
1021
503
|
onDelete={renderQueue.deleteRender}
|
|
1022
504
|
onClearCompleted={renderQueue.clearCompleted}
|
|
1023
505
|
onStartRender={(format) => renderQueue.startRender(30, "standard", format)}
|
|
1024
506
|
isRendering={renderQueue.isRendering}
|
|
1025
507
|
/>
|
|
1026
|
-
|
|
1027
|
-
|
|
508
|
+
</div>
|
|
509
|
+
</>
|
|
1028
510
|
)}
|
|
1029
511
|
</div>
|
|
1030
512
|
|
|
1031
513
|
{/* Lint modal */}
|
|
1032
|
-
{lintModal !== null && (
|
|
514
|
+
{lintModal !== null && projectId && (
|
|
1033
515
|
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
|
|
1034
516
|
)}
|
|
1035
517
|
</div>
|