From 3ab9c2b09c16f4bbcc340b50d3bed9dbe57834c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 27 Mar 2026 14:51:05 -0400 Subject: [PATCH] feat(studio): add left sidebar with Compositions and Assets tabs --- packages/studio/src/App.tsx | 37 ++- .../src/components/sidebar/AssetsTab.tsx | 228 ++++++++++++++++++ .../components/sidebar/CompositionsTab.tsx | 77 ++++++ .../src/components/sidebar/LeftSidebar.tsx | 99 ++++++++ 4 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 packages/studio/src/components/sidebar/AssetsTab.tsx create mode 100644 packages/studio/src/components/sidebar/CompositionsTab.tsx create mode 100644 packages/studio/src/components/sidebar/LeftSidebar.tsx diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b17163823..a4f0799a6 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect, type ReactNode } from "react" 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 { CompositionThumbnail } from "./player/components/CompositionThumbnail"; import { VideoThumbnail } from "./player/components/VideoThumbnail"; import type { TimelineElement } from "./player/store/playerStore"; @@ -15,7 +16,7 @@ import { interface EditingFile { path: string; - content: string; + content: string | null; } interface ProjectEntry { @@ -550,8 +551,37 @@ export function StudioApp() { return ; } + const compositions = fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")); + const assets = fileTree.filter( + (f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json"), + ); + return (
+ {/* Left sidebar: Compositions + Assets */} + { + // Open code editor for this composition + const controller = new AbortController(); + setEditingFile({ path: comp, content: null }); + fetch(`/api/projects/${projectId}/files/${comp}`, { signal: controller.signal }) + .then((r) => r.json()) + .then((data) => { + // Only update if the path still matches (race condition guard) + setEditingFile((prev) => + prev?.path === comp ? { path: comp, content: data.content } : prev, + ); + }) + .catch((err) => { + if (err.name !== "AbortError") console.warn("Failed to load composition:", err); + }); + }} + /> + {/* NLE: Preview + Timeline */}
{ previewIframeRef.current = iframe; }} @@ -641,7 +674,7 @@ export function StudioApp() {
{editingFile ? ( diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx new file mode 100644 index 000000000..8bd4a3edb --- /dev/null +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -0,0 +1,228 @@ +import { memo, useState, useCallback, useRef } from "react"; + +interface AssetsTabProps { + projectId: string; + assets: string[]; + onImport?: (files: FileList) => void; +} + +const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|jpg|jpeg|png|gif|webp|svg)$/i; +const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg)$/i; +const VIDEO_EXT = /\.(mp4|webm|mov)$/i; +const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i; + +function AssetIcon({ ext }: { ext: string }) { + if (VIDEO_EXT.test(ext)) { + return ( + + + + ); + } + if (AUDIO_EXT.test(ext)) { + return ( + + + + + + ); + } + if (IMAGE_EXT.test(ext)) { + return ( + + + + + + ); + } + return ( + + + + + ); +} + +export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }: AssetsTabProps) { + const fileInputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + const [copiedPath, setCopiedPath] = useState(null); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + if (e.dataTransfer.files.length) onImport?.(e.dataTransfer.files); + }, + [onImport], + ); + + const handleCopyPath = useCallback(async (path: string) => { + try { + await navigator.clipboard.writeText(path); + setCopiedPath(path); + setTimeout(() => setCopiedPath(null), 1500); + } catch { + // ignore + } + }, []); + + const mediaAssets = assets.filter((a) => MEDIA_EXT.test(a)); + + return ( +
{ + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + > + {/* Import button */} + {onImport && ( +
+ + { + if (e.target.files?.length) { + onImport(e.target.files); + e.target.value = ""; + } + }} + /> +
+ )} + + {/* Asset list */} +
+ {mediaAssets.length === 0 ? ( +
+ + + + + +

Drop media files here

+
+ ) : ( + mediaAssets.map((asset) => { + const name = asset.split("/").pop() ?? asset; + const ext = "." + (name.split(".").pop() ?? ""); + const isImage = IMAGE_EXT.test(asset); + const isCopied = copiedPath === asset; + const serveUrl = `/api/projects/${projectId}/serve/${asset}`; + + return ( + + ); + }) + )} +
+
+ ); +}); diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx new file mode 100644 index 000000000..79e792b78 --- /dev/null +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -0,0 +1,77 @@ +import { memo, useState } from "react"; + +interface CompositionsTabProps { + projectId: string; + compositions: string[]; + activeComposition: string | null; + onSelect: (comp: string) => void; +} + +export const CompositionsTab = memo(function CompositionsTab({ + projectId, + compositions, + activeComposition, + onSelect, +}: CompositionsTabProps) { + const [hoveredComp, setHoveredComp] = useState(null); + + if (compositions.length === 0) { + return ( +
+

No compositions found

+
+ ); + } + + return ( +
+ {compositions.map((comp) => { + const name = comp.replace(/^compositions\//, "").replace(/\.html$/, ""); + const isActive = activeComposition === comp; + const isHovered = hoveredComp === comp; + const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=0.5`; + + return ( + + ); + })} +
+ ); +}); diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx new file mode 100644 index 000000000..03f715fa4 --- /dev/null +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -0,0 +1,99 @@ +import { memo, useState, useCallback, useEffect } from "react"; +import { CompositionsTab } from "./CompositionsTab"; +import { AssetsTab } from "./AssetsTab"; + +type SidebarTab = "compositions" | "assets"; + +const STORAGE_KEY = "hf-studio-sidebar-tab"; + +function getPersistedTab(): SidebarTab { + const stored = localStorage.getItem(STORAGE_KEY); + return stored === "assets" ? "assets" : "compositions"; +} + +interface LeftSidebarProps { + projectId: string; + compositions: string[]; + assets: string[]; + activeComposition: string | null; + onSelectComposition: (comp: string) => void; + onImportFiles?: (files: FileList) => void; +} + +export const LeftSidebar = memo(function LeftSidebar({ + projectId, + compositions, + assets, + activeComposition, + onSelectComposition, + onImportFiles, +}: LeftSidebarProps) { + const [tab, setTab] = useState(getPersistedTab); + + const selectTab = useCallback((t: SidebarTab) => { + setTab(t); + localStorage.setItem(STORAGE_KEY, t); + }, []); + + // Keyboard shortcuts: Cmd+1 for Compositions, Cmd+2 for Assets + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!e.metaKey && !e.ctrlKey) return; + if (e.key === "1") { + e.preventDefault(); + selectTab("compositions"); + } + if (e.key === "2") { + e.preventDefault(); + selectTab("assets"); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [selectTab]); + + return ( +
+ {/* Tabs */} +
+ + +
+ + {/* Tab content */} + {tab === "compositions" ? ( + + ) : ( + + )} +
+ ); +});