@hyperframes/studio 0.1.15 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/assets/index-Bkp9HQbo.css +1 -0
- package/dist/assets/index-DfhSlTti.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/App.tsx +231 -4
- package/src/components/editor/FileTree.tsx +759 -49
- package/src/components/sidebar/AssetsTab.tsx +180 -38
- package/src/components/sidebar/LeftSidebar.tsx +28 -3
- package/dist/assets/index-CLmYRLY-.css +0 -1
- package/dist/assets/index-CRvFpc0E.js +0 -84
package/dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>HyperFrames Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DfhSlTti.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Bkp9HQbo.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"codemirror": "^6.0.1",
|
|
34
34
|
"motion": "^12.38.0",
|
|
35
|
-
"@hyperframes/core": "0.
|
|
35
|
+
"@hyperframes/core": "0.2.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/react": "^19.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -24,8 +24,7 @@ export function StudioApp() {
|
|
|
24
24
|
const [projectId, setProjectId] = useState<string | null>(null);
|
|
25
25
|
const [resolving, setResolving] = useState(true);
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
useEffect(() => {
|
|
27
|
+
useMountEffect(() => {
|
|
29
28
|
const hashMatch = window.location.hash.match(/^#project\/([^/]+)/);
|
|
30
29
|
if (hashMatch) {
|
|
31
30
|
setProjectId(hashMatch[1]);
|
|
@@ -44,7 +43,7 @@ export function StudioApp() {
|
|
|
44
43
|
})
|
|
45
44
|
.catch(() => {})
|
|
46
45
|
.finally(() => setResolving(false));
|
|
47
|
-
}
|
|
46
|
+
});
|
|
48
47
|
|
|
49
48
|
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
50
49
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
@@ -57,7 +56,10 @@ export function StudioApp() {
|
|
|
57
56
|
const [rightWidth, setRightWidth] = useState(400);
|
|
58
57
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
59
58
|
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
59
|
+
const [globalDragOver, setGlobalDragOver] = useState(false);
|
|
60
|
+
const [uploadToast, setUploadToast] = useState<string | null>(null);
|
|
60
61
|
const [timelineVisible, setTimelineVisible] = useState(false);
|
|
62
|
+
const dragCounterRef = useRef(0);
|
|
61
63
|
const panelDragRef = useRef<{
|
|
62
64
|
side: "left" | "right";
|
|
63
65
|
startX: number;
|
|
@@ -246,6 +248,169 @@ export function StudioApp() {
|
|
|
246
248
|
}, 600);
|
|
247
249
|
}, []);
|
|
248
250
|
|
|
251
|
+
// ── File Management Handlers ──
|
|
252
|
+
|
|
253
|
+
const refreshFileTree = useCallback(async () => {
|
|
254
|
+
const pid = projectIdRef.current;
|
|
255
|
+
if (!pid) return;
|
|
256
|
+
const res = await fetch(`/api/projects/${pid}`);
|
|
257
|
+
const data = await res.json();
|
|
258
|
+
if (data.files) setFileTree(data.files);
|
|
259
|
+
}, []);
|
|
260
|
+
|
|
261
|
+
const handleCreateFile = useCallback(
|
|
262
|
+
async (path: string) => {
|
|
263
|
+
const pid = projectIdRef.current;
|
|
264
|
+
if (!pid) return;
|
|
265
|
+
let content = "";
|
|
266
|
+
if (path.endsWith(".html")) {
|
|
267
|
+
content =
|
|
268
|
+
'<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
|
|
269
|
+
}
|
|
270
|
+
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { "Content-Type": "text/plain" },
|
|
273
|
+
body: content,
|
|
274
|
+
});
|
|
275
|
+
if (res.ok) {
|
|
276
|
+
await refreshFileTree();
|
|
277
|
+
handleFileSelect(path);
|
|
278
|
+
} else {
|
|
279
|
+
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
280
|
+
console.error(`Create file failed: ${err.error}`);
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
[refreshFileTree, handleFileSelect],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const handleCreateFolder = useCallback(
|
|
287
|
+
async (path: string) => {
|
|
288
|
+
const pid = projectIdRef.current;
|
|
289
|
+
if (!pid) return;
|
|
290
|
+
// Create a .gitkeep inside the folder so it appears in the tree
|
|
291
|
+
const res = await fetch(
|
|
292
|
+
`/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`,
|
|
293
|
+
{
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers: { "Content-Type": "text/plain" },
|
|
296
|
+
body: "",
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
if (res.ok) {
|
|
300
|
+
await refreshFileTree();
|
|
301
|
+
} else {
|
|
302
|
+
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
303
|
+
console.error(`Create folder failed: ${err.error}`);
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
[refreshFileTree],
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const handleDeleteFile = useCallback(
|
|
310
|
+
async (path: string) => {
|
|
311
|
+
const pid = projectIdRef.current;
|
|
312
|
+
if (!pid) return;
|
|
313
|
+
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
314
|
+
method: "DELETE",
|
|
315
|
+
});
|
|
316
|
+
if (res.ok) {
|
|
317
|
+
if (editingPathRef.current === path) setEditingFile(null);
|
|
318
|
+
await refreshFileTree();
|
|
319
|
+
} else {
|
|
320
|
+
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
321
|
+
console.error(`Delete failed: ${err.error}`);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
[refreshFileTree],
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const handleRenameFile = useCallback(
|
|
328
|
+
async (oldPath: string, newPath: string) => {
|
|
329
|
+
const pid = projectIdRef.current;
|
|
330
|
+
if (!pid) return;
|
|
331
|
+
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(oldPath)}`, {
|
|
332
|
+
method: "PATCH",
|
|
333
|
+
headers: { "Content-Type": "application/json" },
|
|
334
|
+
body: JSON.stringify({ newPath }),
|
|
335
|
+
});
|
|
336
|
+
if (res.ok) {
|
|
337
|
+
if (editingPathRef.current === oldPath) {
|
|
338
|
+
handleFileSelect(newPath);
|
|
339
|
+
}
|
|
340
|
+
await refreshFileTree();
|
|
341
|
+
// Refresh preview — references in compositions may have been updated
|
|
342
|
+
setRefreshKey((k) => k + 1);
|
|
343
|
+
} else {
|
|
344
|
+
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
345
|
+
console.error(`Rename failed: ${err.error}`);
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
[refreshFileTree, handleFileSelect],
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const handleDuplicateFile = useCallback(
|
|
352
|
+
async (path: string) => {
|
|
353
|
+
const pid = projectIdRef.current;
|
|
354
|
+
if (!pid) return;
|
|
355
|
+
const res = await fetch(`/api/projects/${pid}/duplicate-file`, {
|
|
356
|
+
method: "POST",
|
|
357
|
+
headers: { "Content-Type": "application/json" },
|
|
358
|
+
body: JSON.stringify({ path }),
|
|
359
|
+
});
|
|
360
|
+
if (res.ok) {
|
|
361
|
+
const data = await res.json();
|
|
362
|
+
await refreshFileTree();
|
|
363
|
+
if (data.path) handleFileSelect(data.path);
|
|
364
|
+
} else {
|
|
365
|
+
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
366
|
+
console.error(`Duplicate failed: ${err.error}`);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
[refreshFileTree, handleFileSelect],
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const handleMoveFile = handleRenameFile;
|
|
373
|
+
|
|
374
|
+
const showUploadToast = useCallback((msg: string) => {
|
|
375
|
+
setUploadToast(msg);
|
|
376
|
+
setTimeout(() => setUploadToast(null), 4000);
|
|
377
|
+
}, []);
|
|
378
|
+
|
|
379
|
+
const handleImportFiles = useCallback(
|
|
380
|
+
async (files: FileList, dir?: string) => {
|
|
381
|
+
const pid = projectIdRef.current;
|
|
382
|
+
if (!pid || files.length === 0) return;
|
|
383
|
+
|
|
384
|
+
const formData = new FormData();
|
|
385
|
+
for (const file of Array.from(files)) {
|
|
386
|
+
formData.append("file", file);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
|
390
|
+
try {
|
|
391
|
+
const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
|
|
392
|
+
method: "POST",
|
|
393
|
+
body: formData,
|
|
394
|
+
});
|
|
395
|
+
if (res.ok) {
|
|
396
|
+
const data = await res.json();
|
|
397
|
+
if (data.skipped?.length) {
|
|
398
|
+
showUploadToast(`Skipped (too large): ${data.skipped.join(", ")}`);
|
|
399
|
+
}
|
|
400
|
+
await refreshFileTree();
|
|
401
|
+
setRefreshKey((k) => k + 1);
|
|
402
|
+
} else if (res.status === 413) {
|
|
403
|
+
showUploadToast("Upload rejected: payload too large");
|
|
404
|
+
} else {
|
|
405
|
+
showUploadToast(`Upload failed (${res.status})`);
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
showUploadToast("Upload failed: network error");
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
[refreshFileTree, showUploadToast],
|
|
412
|
+
);
|
|
413
|
+
|
|
249
414
|
const handleLint = useCallback(async () => {
|
|
250
415
|
const pid = projectIdRef.current;
|
|
251
416
|
if (!pid) return;
|
|
@@ -325,7 +490,31 @@ export function StudioApp() {
|
|
|
325
490
|
// At this point projectId is guaranteed non-null (narrowed by the guard above)
|
|
326
491
|
|
|
327
492
|
return (
|
|
328
|
-
<div
|
|
493
|
+
<div
|
|
494
|
+
className="flex flex-col h-screen w-screen bg-neutral-950 relative"
|
|
495
|
+
onDragOver={(e) => {
|
|
496
|
+
if (!e.dataTransfer.types.includes("Files")) return;
|
|
497
|
+
e.preventDefault();
|
|
498
|
+
}}
|
|
499
|
+
onDragEnter={(e) => {
|
|
500
|
+
if (!e.dataTransfer.types.includes("Files")) return;
|
|
501
|
+
e.preventDefault();
|
|
502
|
+
dragCounterRef.current++;
|
|
503
|
+
setGlobalDragOver(true);
|
|
504
|
+
}}
|
|
505
|
+
onDragLeave={() => {
|
|
506
|
+
dragCounterRef.current--;
|
|
507
|
+
if (dragCounterRef.current === 0) setGlobalDragOver(false);
|
|
508
|
+
}}
|
|
509
|
+
onDrop={(e) => {
|
|
510
|
+
dragCounterRef.current = 0;
|
|
511
|
+
setGlobalDragOver(false);
|
|
512
|
+
// Skip if a child (e.g. AssetsTab) already handled the drop
|
|
513
|
+
if (e.defaultPrevented) return;
|
|
514
|
+
e.preventDefault();
|
|
515
|
+
if (e.dataTransfer.files.length) handleImportFiles(e.dataTransfer.files);
|
|
516
|
+
}}
|
|
517
|
+
>
|
|
329
518
|
{/* Header bar */}
|
|
330
519
|
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
|
|
331
520
|
{/* Left: project name */}
|
|
@@ -433,6 +622,13 @@ export function StudioApp() {
|
|
|
433
622
|
fileTree={fileTree}
|
|
434
623
|
editingFile={editingFile}
|
|
435
624
|
onSelectFile={handleFileSelect}
|
|
625
|
+
onCreateFile={handleCreateFile}
|
|
626
|
+
onCreateFolder={handleCreateFolder}
|
|
627
|
+
onDeleteFile={handleDeleteFile}
|
|
628
|
+
onRenameFile={handleRenameFile}
|
|
629
|
+
onDuplicateFile={handleDuplicateFile}
|
|
630
|
+
onMoveFile={handleMoveFile}
|
|
631
|
+
onImportFiles={handleImportFiles}
|
|
436
632
|
codeChildren={
|
|
437
633
|
editingFile ? (
|
|
438
634
|
isMediaFile(editingFile.path) ? (
|
|
@@ -514,6 +710,37 @@ export function StudioApp() {
|
|
|
514
710
|
{lintModal !== null && projectId && (
|
|
515
711
|
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
|
|
516
712
|
)}
|
|
713
|
+
|
|
714
|
+
{/* Global drag-drop overlay */}
|
|
715
|
+
{globalDragOver && (
|
|
716
|
+
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|
|
717
|
+
<div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
|
|
718
|
+
<svg
|
|
719
|
+
width="32"
|
|
720
|
+
height="32"
|
|
721
|
+
viewBox="0 0 24 24"
|
|
722
|
+
fill="none"
|
|
723
|
+
stroke="currentColor"
|
|
724
|
+
strokeWidth="1.5"
|
|
725
|
+
strokeLinecap="round"
|
|
726
|
+
strokeLinejoin="round"
|
|
727
|
+
className="text-studio-accent"
|
|
728
|
+
>
|
|
729
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
730
|
+
<polyline points="7 10 12 15 17 10" />
|
|
731
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
732
|
+
</svg>
|
|
733
|
+
<span className="text-sm font-medium text-studio-accent">
|
|
734
|
+
Drop files to import into project
|
|
735
|
+
</span>
|
|
736
|
+
</div>
|
|
737
|
+
</div>
|
|
738
|
+
)}
|
|
739
|
+
{uploadToast && (
|
|
740
|
+
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg bg-red-900/90 border border-red-700/50 text-sm text-red-200 shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
|
741
|
+
{uploadToast}
|
|
742
|
+
</div>
|
|
743
|
+
)}
|
|
517
744
|
</div>
|
|
518
745
|
);
|
|
519
746
|
}
|