diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 1a0b79156..0abfec0d0 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -1,4 +1,5 @@ import type { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; import { existsSync, readFileSync, @@ -238,4 +239,71 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { return c.json({ ok: true, path: copyPath }, 201); }); + + // ── Upload (binary assets via multipart form) ── + + const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB per file + + api.post( + "/projects/:id/upload", + bodyLimit({ + maxSize: MAX_UPLOAD_BYTES, + onError: (c) => c.json({ error: "payload too large" }, 413), + }), + async (c) => { + const project = await adapter.resolveProject(c.req.param("id")); + if (!project) return c.json({ error: "not found" }, 404); + + // Optional subdirectory within the project (e.g. "assets/audio") + const subDir = c.req.query("dir") ?? ""; + const targetDir = subDir ? resolve(project.dir, subDir) : project.dir; + if (!isSafePath(project.dir, targetDir)) return c.json({ error: "forbidden" }, 403); + if (subDir && !existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); + + const formData = await c.req.formData(); + const uploaded: string[] = []; + const skipped: string[] = []; + + for (const [, value] of formData.entries()) { + if (!(value instanceof File)) continue; + + // Strip path separators — browsers may include directory components + const name = value.name.split("/").pop()?.split("\\").pop() ?? ""; + if (!name || name.includes("\0") || name.includes("..")) continue; + + // Reject individual files that exceed the size limit + if (value.size > MAX_UPLOAD_BYTES) { + skipped.push(name); + continue; + } + + const destPath = resolve(targetDir, name); + if (!isSafePath(project.dir, destPath)) continue; + + // Don't overwrite — append (2), (3), etc. + let finalPath = destPath; + let finalName = name; + if (existsSync(finalPath)) { + // Handle dotfiles correctly: .gitignore → ext="", base=".gitignore" + const dotIdx = name.indexOf(".", name.startsWith(".") ? 1 : 0); + const ext = dotIdx > 0 ? name.slice(dotIdx) : ""; + const base = dotIdx > 0 ? name.slice(0, dotIdx) : name; + let n = 2; + while (n < 10000 && existsSync(resolve(targetDir, `${base} (${n})${ext}`))) n++; + if (n >= 10000) { + skipped.push(name); + continue; + } + finalName = `${base} (${n})${ext}`; + finalPath = resolve(targetDir, finalName); + } + + const buffer = Buffer.from(await value.arrayBuffer()); + writeFileSync(finalPath, buffer); + uploaded.push(subDir ? join(subDir, finalName) : finalName); + } + + return c.json({ ok: true, files: uploaded, skipped }, 201); + }, + ); } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index dc79aada0..6823b6c13 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -56,7 +56,10 @@ export function StudioApp() { const [rightWidth, setRightWidth] = useState(400); const [leftCollapsed, setLeftCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(true); + const [globalDragOver, setGlobalDragOver] = useState(false); + const [uploadToast, setUploadToast] = useState(null); const [timelineVisible, setTimelineVisible] = useState(false); + const dragCounterRef = useRef(0); const panelDragRef = useRef<{ side: "left" | "right"; startX: number; @@ -368,6 +371,46 @@ export function StudioApp() { const handleMoveFile = handleRenameFile; + const showUploadToast = useCallback((msg: string) => { + setUploadToast(msg); + setTimeout(() => setUploadToast(null), 4000); + }, []); + + const handleImportFiles = useCallback( + async (files: FileList, dir?: string) => { + const pid = projectIdRef.current; + if (!pid || files.length === 0) return; + + const formData = new FormData(); + for (const file of Array.from(files)) { + formData.append("file", file); + } + + const qs = dir ? `?dir=${encodeURIComponent(dir)}` : ""; + try { + const res = await fetch(`/api/projects/${pid}/upload${qs}`, { + method: "POST", + body: formData, + }); + if (res.ok) { + const data = await res.json(); + if (data.skipped?.length) { + showUploadToast(`Skipped (too large): ${data.skipped.join(", ")}`); + } + await refreshFileTree(); + setRefreshKey((k) => k + 1); + } else if (res.status === 413) { + showUploadToast("Upload rejected: payload too large"); + } else { + showUploadToast(`Upload failed (${res.status})`); + } + } catch { + showUploadToast("Upload failed: network error"); + } + }, + [refreshFileTree, showUploadToast], + ); + const handleLint = useCallback(async () => { const pid = projectIdRef.current; if (!pid) return; @@ -447,7 +490,31 @@ export function StudioApp() { // At this point projectId is guaranteed non-null (narrowed by the guard above) return ( -
+
{ + if (!e.dataTransfer.types.includes("Files")) return; + e.preventDefault(); + }} + onDragEnter={(e) => { + if (!e.dataTransfer.types.includes("Files")) return; + e.preventDefault(); + dragCounterRef.current++; + setGlobalDragOver(true); + }} + onDragLeave={() => { + dragCounterRef.current--; + if (dragCounterRef.current === 0) setGlobalDragOver(false); + }} + onDrop={(e) => { + dragCounterRef.current = 0; + setGlobalDragOver(false); + // Skip if a child (e.g. AssetsTab) already handled the drop + if (e.defaultPrevented) return; + e.preventDefault(); + if (e.dataTransfer.files.length) handleImportFiles(e.dataTransfer.files); + }} + > {/* Header bar */}
{/* Left: project name */} @@ -561,6 +628,7 @@ export function StudioApp() { onRenameFile={handleRenameFile} onDuplicateFile={handleDuplicateFile} onMoveFile={handleMoveFile} + onImportFiles={handleImportFiles} codeChildren={ editingFile ? ( isMediaFile(editingFile.path) ? ( @@ -642,6 +710,37 @@ export function StudioApp() { {lintModal !== null && projectId && ( setLintModal(null)} /> )} + + {/* Global drag-drop overlay */} + {globalDragOver && ( +
+
+ + + + + + + Drop files to import into project + +
+
+ )} + {uploadToast && ( +
+ {uploadToast} +
+ )}
); } diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index 4d511463d..05d598290 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -39,6 +39,7 @@ export interface FileTreeProps { onRenameFile?: (oldPath: string, newPath: string) => void; onDuplicateFile?: (path: string) => void; onMoveFile?: (oldPath: string, newPath: string) => void; + onImportFiles?: (files: FileList, dir?: string) => void; } interface TreeNode { @@ -475,6 +476,8 @@ function TreeFolder({ return ( <>
)} -
+
{ + e.preventDefault(); + // Show root highlight when dragging over the background (not a child folder) + if (e.target === e.currentTarget) setDragOverFolder(""); + }} + onDragLeave={(e) => { + if (e.target === e.currentTarget) setDragOverFolder(null); + }} + onDrop={(e) => { + e.preventDefault(); + handleDrop(e, ""); + }} + > {/* Root-level inline input for new file/folder */} {inlineInput && (inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") && diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index 72f08d170..5c211d0a8 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -22,7 +22,7 @@ interface LeftSidebarProps { assets: string[]; activeComposition: string | null; onSelectComposition: (comp: string) => void; - onImportFiles?: (files: FileList) => void; + onImportFiles?: (files: FileList, dir?: string) => void; fileTree?: string[]; editingFile?: { path: string; content: string | null } | null; onSelectFile?: (path: string) => void; @@ -156,6 +156,7 @@ export const LeftSidebar = memo(function LeftSidebar({ onRenameFile={onRenameFile} onDuplicateFile={onDuplicateFile} onMoveFile={onMoveFile} + onImportFiles={onImportFiles} />
)}