mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 04:18:01 +00:00
feat(studio): full IDE-like file management (#147)
## Summary - **API**: POST (create), DELETE (delete), PATCH (rename/move), POST duplicate endpoints with null-byte sanitization - **FileTree**: right-click context menu with New File, New Folder, Rename, Duplicate, Delete - **Drag-and-drop**: move files between folders with visual feedback and subtree guard - **Inline editing**: rename/create inputs with filename validation - **Header actions**: quick New File / New Folder buttons in the FILES header Stacks on top of `feat/studio-code-quality`. ## Test plan - [x] Right-click file → Rename, Delete, Duplicate all work - [x] Right-click folder → New File, New Folder, Delete work - [x] Drag file from one folder to another - [x] Create file with invalid name (`../foo`, `a/b`) → rejected client-side - [x] Delete currently-edited file → editor clears - [x] Studio build succeeds
This commit is contained in:
@@ -6,6 +6,8 @@ interface AssetsTabProps {
|
||||
projectId: string;
|
||||
assets: string[];
|
||||
onImport?: (files: FileList) => void;
|
||||
onDelete?: (path: string) => void;
|
||||
onRename?: (oldPath: string, newPath: string) => void;
|
||||
}
|
||||
|
||||
/** Inline thumbnail content — rendered inside the container div in AssetCard. */
|
||||
@@ -82,61 +84,199 @@ function AssetCard({
|
||||
asset,
|
||||
onCopy,
|
||||
isCopied,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: {
|
||||
projectId: string;
|
||||
asset: string;
|
||||
onCopy: (path: string) => void;
|
||||
isCopied: boolean;
|
||||
onDelete?: (path: string) => void;
|
||||
onRename?: (oldPath: string, newPath: string) => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const name = asset.split("/").pop() ?? asset;
|
||||
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
||||
const isVideo = VIDEO_EXT.test(asset);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => 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-studio-accent/10 border-l-2 border-studio-accent"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
<AssetThumbnail
|
||||
serveUrl={serveUrl}
|
||||
name={name}
|
||||
isImage={IMAGE_EXT.test(asset)}
|
||||
isVideo={isVideo}
|
||||
isAudio={AUDIO_EXT.test(asset)}
|
||||
/>
|
||||
{/* Inline video autoplay on hover — same pattern as renders */}
|
||||
{isVideo && hovered && (
|
||||
<video
|
||||
src={serveUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
<>
|
||||
<div
|
||||
onClick={() => onCopy(asset)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
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-studio-accent/10 border-l-2 border-studio-accent"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
<AssetThumbnail
|
||||
serveUrl={serveUrl}
|
||||
name={name}
|
||||
isImage={IMAGE_EXT.test(asset)}
|
||||
isVideo={isVideo}
|
||||
isAudio={AUDIO_EXT.test(asset)}
|
||||
/>
|
||||
)}
|
||||
{isVideo && hovered && (
|
||||
<video
|
||||
src={serveUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const trimmed = renameName.trim();
|
||||
if (trimmed && trimmed !== name) {
|
||||
const dir = asset.includes("/")
|
||||
? asset.slice(0, asset.lastIndexOf("/") + 1)
|
||||
: "";
|
||||
onRename?.(asset, dir + trimmed);
|
||||
}
|
||||
setRenaming(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const trimmed = renameName.trim();
|
||||
if (trimmed && trimmed !== name) {
|
||||
const dir = asset.includes("/") ? asset.slice(0, asset.lastIndexOf("/") + 1) : "";
|
||||
onRename?.(asset, dir + trimmed);
|
||||
}
|
||||
setRenaming(false);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full bg-neutral-800 text-neutral-200 text-[11px] px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-studio-accent"
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate block">
|
||||
{name}
|
||||
</span>
|
||||
{isCopied ? (
|
||||
<span className="text-[9px] text-studio-accent">Copied!</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
||||
{isCopied ? (
|
||||
<span className="text-[9px] text-studio-accent">Copied!</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed inset-0 z-[200]"
|
||||
onClick={() => setContextMenu(null)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[140px] text-xs"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy(asset);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Copy path
|
||||
</button>
|
||||
{onRename && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameName(name);
|
||||
setRenaming(true);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(true);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{confirmDelete && (
|
||||
<div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-red-400 truncate">Delete {name}?</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(asset);
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white hover:bg-red-500 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }: AssetsTabProps) {
|
||||
export const AssetsTab = memo(function AssetsTab({
|
||||
projectId,
|
||||
assets,
|
||||
onImport,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: AssetsTabProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
||||
@@ -239,6 +379,8 @@ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }
|
||||
asset={asset}
|
||||
onCopy={handleCopyPath}
|
||||
isCopied={copiedPath === asset}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,12 @@ interface LeftSidebarProps {
|
||||
fileTree?: string[];
|
||||
editingFile?: { path: string; content: string | null } | null;
|
||||
onSelectFile?: (path: string) => void;
|
||||
onCreateFile?: (path: string) => void;
|
||||
onCreateFolder?: (path: string) => void;
|
||||
onDeleteFile?: (path: string) => void;
|
||||
onRenameFile?: (oldPath: string, newPath: string) => void;
|
||||
onDuplicateFile?: (path: string) => void;
|
||||
onMoveFile?: (oldPath: string, newPath: string) => void;
|
||||
codeChildren?: ReactNode;
|
||||
onLint?: () => void;
|
||||
linting?: boolean;
|
||||
@@ -42,6 +48,12 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
fileTree: fileProp,
|
||||
editingFile,
|
||||
onSelectFile,
|
||||
onCreateFile,
|
||||
onCreateFolder,
|
||||
onDeleteFile,
|
||||
onRenameFile,
|
||||
onDuplicateFile,
|
||||
onMoveFile,
|
||||
codeChildren,
|
||||
onLint,
|
||||
linting,
|
||||
@@ -122,16 +134,28 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
/>
|
||||
)}
|
||||
{tab === "assets" && (
|
||||
<AssetsTab projectId={projectId} assets={assets} onImport={onImportFiles} />
|
||||
<AssetsTab
|
||||
projectId={projectId}
|
||||
assets={assets}
|
||||
onImport={onImportFiles}
|
||||
onDelete={onDeleteFile}
|
||||
onRename={onRenameFile}
|
||||
/>
|
||||
)}
|
||||
{tab === "code" && (
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{(fileProp?.length ?? 0) > 0 && (
|
||||
<div className="w-[140px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
|
||||
<div className="w-[160px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
|
||||
<FileTree
|
||||
files={fileProp ?? []}
|
||||
activeFile={editingFile?.path ?? null}
|
||||
onSelectFile={onSelectFile ?? (() => {})}
|
||||
onCreateFile={onCreateFile}
|
||||
onCreateFolder={onCreateFolder}
|
||||
onDeleteFile={onDeleteFile}
|
||||
onRenameFile={onRenameFile}
|
||||
onDuplicateFile={onDuplicateFile}
|
||||
onMoveFile={onMoveFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user