diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index cb790ece7..bc9504808 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -2,7 +2,6 @@ import { useState, useCallback, useRef, useEffect, type ReactNode } from "react" import { useMountEffect } from "./hooks/useMountEffect"; import { NLELayout } from "./components/nle/NLELayout"; import { SourceEditor } from "./components/editor/SourceEditor"; -import { FileTree } from "./components/editor/FileTree"; import { LeftSidebar } from "./components/sidebar/LeftSidebar"; import { RenderQueue } from "./components/renders/RenderQueue"; import { useRenderQueue } from "./components/renders/useRenderQueue"; @@ -277,7 +276,6 @@ export function StudioApp() { }, []); const [editingFile, setEditingFile] = useState(null); - const [rightTab, setRightTab] = useState<"code" | "renders">("code"); const [activeCompPath, setActiveCompPath] = useState(null); const [fileTree, setFileTree] = useState([]); const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); @@ -287,10 +285,12 @@ export function StudioApp() { const [leftWidth, setLeftWidth] = useState(240); const [rightWidth, setRightWidth] = useState(400); const [leftCollapsed, setLeftCollapsed] = useState(false); - const [rightCollapsed, setRightCollapsed] = useState(false); - const panelDragRef = useRef<{ side: "left" | "right"; startX: number; startW: number } | null>( - null, - ); + const [rightCollapsed, setRightCollapsed] = useState(true); + const panelDragRef = useRef<{ + side: "left" | "right"; + startX: number; + startW: number; + } | null>(null); // Derive active preview URL from composition path (for drilled-down thumbnails) const activePreviewUrl = activeCompPath @@ -431,6 +431,8 @@ export function StudioApp() { const handleFileSelect = useCallback((path: string) => { const pid = projectIdRef.current; if (!pid) return; + // Expand left panel to 50vw when opening a file in Code tab + setLeftWidth((prev) => Math.max(prev, Math.floor(window.innerWidth * 0.5))); // Skip fetching binary content for media files — just set the path for preview if (isMediaFile(path)) { setEditingFile({ path, content: null }); @@ -509,9 +511,13 @@ export function StudioApp() { const drag = panelDragRef.current; if (!drag) return; const delta = e.clientX - drag.startX; + const maxLeft = Math.floor(window.innerWidth * 0.5); const newW = Math.max( 160, - Math.min(600, drag.startW + (drag.side === "left" ? delta : -delta)), + Math.min( + drag.side === "left" ? maxLeft : 600, + drag.startW + (drag.side === "left" ? delta : -delta), + ), ); if (drag.side === "left") setLeftWidth(newW); else setRightWidth(newW); @@ -540,38 +546,16 @@ export function StudioApp() {
{/* Header bar */}
- {/* Left: back button + project name */} + {/* Left: project name */}
- - / - {projectId} + {projectId}
{/* Right: toolbar buttons */}
-
@@ -649,6 +625,24 @@ export function StudioApp() { .then((data) => setEditingFile({ path: comp, content: data.content })) .catch(() => {}); }} + fileTree={fileTree} + editingFile={editingFile} + onSelectFile={handleFileSelect} + codeChildren={ + editingFile ? ( + isMediaFile(editingFile.path) ? ( + + ) : ( + + ) + ) : undefined + } + onLint={handleLint} + linting={linting} /> )} @@ -682,80 +676,20 @@ export function StudioApp() { />
- {/* Right resize handle */} + {/* Right panel: Renders-only (resizable, collapsible via header Renders button) */} {!rightCollapsed && ( -
handlePanelResizeStart("right", e)} - onPointerMove={handlePanelResizeMove} - onPointerUp={handlePanelResizeEnd} - /> - )} - - {/* Right panel: Code + Renders tabs (resizable, collapsible) */} - {!rightCollapsed && ( -
- {/* Tab bar */} -
- - -
- - {/* Tab content */} - {rightTab === "code" ? ( -
- {/* File tree sidebar */} - {fileTree.length > 0 && ( -
- -
- )} - {/* Code editor or media preview */} -
- {editingFile ? ( - isMediaFile(editingFile.path) ? ( - - ) : ( - - ) - ) : ( -
- Select a file to edit -
- )} -
-
- ) : ( + <> +
handlePanelResizeStart("right", e)} + onPointerMove={handlePanelResizeMove} + onPointerUp={handlePanelResizeEnd} + /> +
renderQueue.startRender(30, "standard", format)} isRendering={renderQueue.isRendering} /> - )} -
+
+ )}
diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index b416c68c5..31a0977ee 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -1,13 +1,5 @@ import { memo, useState, useCallback } from "react"; -import { - FileCode, - Image, - Film, - Music, - File, - ChevronDown, - ChevronRight, -} from "../../icons/SystemIcons"; +import { Film, Music, Image, ChevronDown, ChevronRight } from "../../icons/SystemIcons"; interface FileTreeProps { files: string[]; @@ -15,34 +7,65 @@ interface FileTreeProps { onSelectFile: (path: string) => void; } -const FILE_ICONS: Record = { - html: { icon: FileCode, color: "#3B82F6" }, - css: { icon: FileCode, color: "#A855F7" }, - js: { icon: FileCode, color: "#F59E0B" }, - ts: { icon: FileCode, color: "#3B82F6" }, - json: { icon: File, color: "#22C55E" }, - md: { icon: File, color: "#737373" }, - png: { icon: Image, color: "#22C55E" }, - jpg: { icon: Image, color: "#22C55E" }, - jpeg: { icon: Image, color: "#22C55E" }, - webp: { icon: Image, color: "#22C55E" }, - gif: { icon: Image, color: "#22C55E" }, - svg: { icon: Image, color: "#F97316" }, - mp4: { icon: Film, color: "#A855F7" }, - webm: { icon: Film, color: "#A855F7" }, - mov: { icon: Film, color: "#A855F7" }, - mp3: { icon: Music, color: "#F59E0B" }, - wav: { icon: Music, color: "#F59E0B" }, - ogg: { icon: Music, color: "#F59E0B" }, - m4a: { icon: Music, color: "#F59E0B" }, - woff: { icon: File, color: "#525252" }, - woff2: { icon: File, color: "#525252" }, - ttf: { icon: File, color: "#525252" }, -}; +/** VS Code–style language badge: colored rounded rect with a 2–3 letter label. */ +function Badge({ label, bg, text = "#fff" }: { label: string; bg: string; text?: string }) { + return ( + + {label} + + ); +} -function getFileIcon(path: string) { +/** Render a file-type icon for a given file path. */ +function FileIcon({ path }: { path: string }) { const ext = path.split(".").pop()?.toLowerCase() ?? ""; - return FILE_ICONS[ext] ?? { icon: File, color: "#737373" }; + // Language badges + if (ext === "html") return ; + if (ext === "js" || ext === "mjs" || ext === "cjs") + return ; + if (ext === "ts" || ext === "mts") return ; + if (ext === "css") return ; + if (ext === "json") return ; + if (ext === "md" || ext === "mdx") return ; + if (ext === "svg") return ; + if (ext === "wav" || ext === "mp3" || ext === "ogg" || ext === "m4a") + return ; + if (ext === "mp4" || ext === "webm" || ext === "mov") + return ; + if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "webp" || ext === "gif") + return ; + if (ext === "woff" || ext === "woff2" || ext === "ttf" || ext === "otf") + return ; + if (ext === "txt") return ; + // Generic document + return ( + + + + + ); } interface TreeNode { @@ -160,7 +183,6 @@ function TreeFile({ activeFile: string | null; onSelectFile: (path: string) => void; }) { - const { icon: Icon, color } = getFileIcon(node.name); const isActive = node.fullPath === activeFile; return ( @@ -173,7 +195,7 @@ function TreeFile({ }`} style={{ paddingLeft: `${8 + depth * 12 + 14}px` }} > - + {node.name} ); @@ -194,9 +216,6 @@ export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile return (
-
- Files -
{children.map((child) => child.isFile && child.children.size === 0 ? ( diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 522fbf662..9a6e23a53 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -63,11 +63,8 @@ export const RenderQueue = memo(function RenderQueue({ return (
- {/* Header */} -
- - Renders ({jobs.length}) - + {/* Header — no title, already shown in header button */} +
{completedCount > 0 && ( - } - /> - ); - } - - return ( -
-
- {isImage && ( - {name} - )} - {isAudio && ( -
- - - - - -
- )} -
-
-
-
{name}
-
{asset}
-
- -
-
+ ); } @@ -175,28 +92,42 @@ function AssetCard({ onCopy: (path: string) => void; isCopied: boolean; }) { + const [hovered, setHovered] = useState(false); const name = asset.split("/").pop() ?? asset; const serveUrl = `/api/projects/${projectId}/preview/${asset}`; - const isImage = IMAGE_EXT.test(asset); const isVideo = VIDEO_EXT.test(asset); - const isAudio = AUDIO_EXT.test(asset); - const hasExpandablePreview = isImage || isVideo || isAudio; - const card = ( + return (
onCopy(asset)} + onPointerEnter={() => setHovered(true)} + onPointerLeave={() => setHovered(false)} className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${ isCopied ? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]" : "border-l-2 border-transparent hover:bg-neutral-800/50" }`} > - +
+ + {/* Inline video autoplay on hover — same pattern as renders */} + {isVideo && hovered && ( +
{name} {isCopied ? ( @@ -207,43 +138,6 @@ function AssetCard({
); - - if (!hasExpandablePreview) { - return ( - - ); - } - - return ( - ( - { - closeExpand(); - onCopy(asset); - }} - /> - )} - onClick={() => onCopy(asset)} - expandScale={0.45} - delay={500} - > - {card} - - ); } export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }: AssetsTabProps) { diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx index fefc97195..6a2d66bbb 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -1,5 +1,4 @@ -import { memo, useRef, useState, useCallback, useEffect } from "react"; -import { ExpandOnHover } from "../ui/ExpandOnHover"; +import { memo, useState } from "react"; interface CompositionsTabProps { projectId: string; @@ -8,132 +7,6 @@ interface CompositionsTabProps { onSelect: (comp: string) => void; } -function ExpandedCompPreview({ - previewUrl, - name, - comp, - onSelect, -}: { - previewUrl: string; - name: string; - comp: string; - onSelect: () => void; -}) { - const containerRef = useRef(null); - const iframeRef = useRef(null); - const [dims, setDims] = useState({ w: 1920, h: 1080 }); - const [scale, setScale] = useState(1); - - const updateScale = useCallback(() => { - const el = containerRef.current; - if (!el) return; - const s = Math.min(el.clientWidth / dims.w, el.clientHeight / dims.h); - setScale(s); - }, [dims]); - - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - updateScale(); - const el = containerRef.current; - if (!el) return; - const ro = new ResizeObserver(updateScale); - ro.observe(el); - return () => ro.disconnect(); - }, [updateScale]); - - const handleLoad = useCallback(() => { - const iframe = iframeRef.current; - if (!iframe) return; - // Detect dimensions from composition - try { - const doc = iframe.contentDocument; - if (doc) { - const root = doc.querySelector("[data-composition-id]"); - if (root) { - const w = parseInt(root.getAttribute("data-width") ?? "0", 10); - const h = parseInt(root.getAttribute("data-height") ?? "0", 10); - if (w > 0 && h > 0) setDims({ w, h }); - } - } - } catch { - /* cross-origin */ - } - - let attempts = 0; - const interval = setInterval(() => { - try { - const win = iframe.contentWindow as Window & { - __player?: { play: () => void; seek: (t: number) => void }; - __timelines?: Record void; seek: (t: number) => void }>; - }; - if (win?.__player) { - win.__player.seek(0.5); - win.__player.play(); - clearInterval(interval); - return; - } - if (win?.__timelines) { - const keys = Object.keys(win.__timelines); - const tl = keys.length > 0 ? win.__timelines[keys[keys.length - 1]] : null; - if (tl) { - tl.seek(0.5); - tl.play(); - clearInterval(interval); - } - } - } catch { - /* cross-origin */ - } - if (++attempts > 15) clearInterval(interval); - }, 200); - }, []); - - const offsetX = containerRef.current - ? (containerRef.current.clientWidth - dims.w * scale) / 2 - : 0; - const offsetY = containerRef.current - ? (containerRef.current.clientHeight - dims.h * scale) / 2 - : 0; - - return ( -
-
-