@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/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-CRvFpc0E.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-CLmYRLY-.css">
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.1.15",
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.1.15"
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
- // eslint-disable-next-line no-restricted-syntax
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 className="flex flex-col h-screen w-screen bg-neutral-950">
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
  }