mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
Create a Tooltip component with styled popover (dark bg, border, shadow) that appears on hover with a 400ms delay. Applied to: - Left sidebar tabs: Code, Comps, Assets, Catalog - Right panel tabs: Design, Layers, Motion, Renders Replaces native title attributes with proper styled tooltips.
291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
import {
|
|
memo,
|
|
useState,
|
|
useCallback,
|
|
useImperativeHandle,
|
|
useRef,
|
|
forwardRef,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { CompositionsTab } from "./CompositionsTab";
|
|
import { AssetsTab } from "./AssetsTab";
|
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
|
|
import { FileTree } from "../editor/FileTree";
|
|
import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
|
|
import { Tooltip } from "../ui";
|
|
|
|
export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
|
|
|
|
export interface LeftSidebarHandle {
|
|
selectTab: (tab: SidebarTab) => void;
|
|
getTab: () => SidebarTab;
|
|
}
|
|
|
|
const STORAGE_KEY = "hf-studio-sidebar-tab";
|
|
|
|
function getPersistedTab(): SidebarTab {
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
if (stored === "assets") return "assets";
|
|
if (stored === "code") return "code";
|
|
if (stored === "blocks") return "blocks";
|
|
return "compositions";
|
|
}
|
|
|
|
interface LeftSidebarProps {
|
|
width?: number;
|
|
projectId: string;
|
|
compositions: string[];
|
|
assets: string[];
|
|
activeComposition: string | null;
|
|
onSelectComposition: (comp: string) => void;
|
|
onImportFiles?: (files: FileList, dir?: string) => void;
|
|
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;
|
|
onRenderComposition?: (comp: string) => void;
|
|
isRendering?: boolean;
|
|
onLint?: () => void;
|
|
linting?: boolean;
|
|
onToggleCollapse?: () => void;
|
|
onAddBlock?: (blockName: string) => void;
|
|
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
|
takeoverContent?: ReactNode;
|
|
}
|
|
|
|
export const LeftSidebar = memo(
|
|
forwardRef<LeftSidebarHandle, LeftSidebarProps>(function LeftSidebar(
|
|
{
|
|
width = 240,
|
|
projectId,
|
|
compositions,
|
|
assets,
|
|
activeComposition,
|
|
onSelectComposition,
|
|
onImportFiles,
|
|
fileTree: fileProp,
|
|
editingFile,
|
|
onSelectFile,
|
|
onCreateFile,
|
|
onCreateFolder,
|
|
onDeleteFile,
|
|
onRenameFile,
|
|
onDuplicateFile,
|
|
onMoveFile,
|
|
codeChildren,
|
|
onRenderComposition,
|
|
isRendering,
|
|
onLint,
|
|
linting,
|
|
onToggleCollapse,
|
|
onAddBlock,
|
|
onPreviewBlock,
|
|
takeoverContent,
|
|
},
|
|
ref,
|
|
) {
|
|
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
|
const tabRef = useRef(tab);
|
|
tabRef.current = tab;
|
|
|
|
const selectTab = useCallback((t: SidebarTab) => {
|
|
setTab(t);
|
|
localStorage.setItem(STORAGE_KEY, t);
|
|
trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
|
|
}, []);
|
|
|
|
const getTab = useCallback(() => tabRef.current, []);
|
|
|
|
useImperativeHandle(ref, () => ({ selectTab, getTab }), [selectTab, getTab]);
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
|
|
style={{ width }}
|
|
>
|
|
{takeoverContent ? (
|
|
<div className="flex min-h-0 flex-1">{takeoverContent}</div>
|
|
) : (
|
|
<>
|
|
{/* Tabs — Code first */}
|
|
<div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="grid min-w-0 flex-1 gap-0.5 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
|
|
style={{
|
|
gridTemplateColumns: STUDIO_BLOCKS_PANEL_ENABLED
|
|
? "1fr 1fr 1fr 1fr"
|
|
: "1fr 1fr 1fr",
|
|
}}
|
|
>
|
|
<Tooltip label="Source code editor" side="bottom">
|
|
<button
|
|
type="button"
|
|
onClick={() => selectTab("code")}
|
|
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
tab === "code"
|
|
? "bg-neutral-800 text-white"
|
|
: "text-neutral-500 hover:text-neutral-200"
|
|
}`}
|
|
>
|
|
Code
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label="Compositions and sub-compositions" side="bottom">
|
|
<button
|
|
type="button"
|
|
onClick={() => selectTab("compositions")}
|
|
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
tab === "compositions"
|
|
? "bg-neutral-800 text-white"
|
|
: "text-neutral-500 hover:text-neutral-200"
|
|
}`}
|
|
>
|
|
Comps
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label="Videos, images, audio, fonts" side="bottom">
|
|
<button
|
|
type="button"
|
|
onClick={() => selectTab("assets")}
|
|
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
tab === "assets"
|
|
? "bg-neutral-800 text-white"
|
|
: "text-neutral-500 hover:text-neutral-200"
|
|
}`}
|
|
>
|
|
Assets
|
|
</button>
|
|
</Tooltip>
|
|
{STUDIO_BLOCKS_PANEL_ENABLED && (
|
|
<Tooltip label="Browse blocks and components" side="bottom">
|
|
<button
|
|
type="button"
|
|
onClick={() => selectTab("blocks")}
|
|
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
tab === "blocks"
|
|
? "bg-neutral-800 text-white"
|
|
: "text-neutral-500 hover:text-neutral-200"
|
|
}`}
|
|
>
|
|
Catalog
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
{onToggleCollapse && (
|
|
<button
|
|
type="button"
|
|
onClick={onToggleCollapse}
|
|
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
|
|
title="Hide sidebar"
|
|
aria-label="Hide sidebar"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="m14 7-5 5 5 5" />
|
|
<path d="M19 4v16" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
{tab === "compositions" && (
|
|
<CompositionsTab
|
|
projectId={projectId}
|
|
compositions={compositions}
|
|
activeComposition={activeComposition}
|
|
onSelect={onSelectComposition}
|
|
onRenderComposition={onRenderComposition}
|
|
isRendering={isRendering}
|
|
/>
|
|
)}
|
|
{tab === "assets" && (
|
|
<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-[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}
|
|
onImportFiles={onImportFiles}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex-1 overflow-hidden min-w-0">
|
|
{codeChildren ?? (
|
|
<div className="flex items-center justify-center h-full text-neutral-600 text-sm">
|
|
Select a file to edit
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && (
|
|
<BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
|
|
)}
|
|
|
|
{/* Lint button pinned at the bottom */}
|
|
{onLint && (
|
|
<div className="border-t border-neutral-800 p-2 flex-shrink-0">
|
|
<button
|
|
onClick={onLint}
|
|
disabled={linting}
|
|
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
|
|
>
|
|
<svg
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M9 11l3 3L22 4" />
|
|
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
|
</svg>
|
|
{linting ? "Linting…" : "Lint"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}),
|
|
);
|