mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): full Blocks panel — browse, search, add, drag-and-drop registry items
Adds a Blocks tab to the Studio left sidebar with the full 78-item registry
catalog (58 blocks + 20 components). Users can browse by category, search by
title/description, preview CDN-hosted poster thumbnails with video-on-hover,
and install items on-demand with one click or drag-to-timeline.
Core changes:
- BlockCategory type + resolveBlockCategory() for 7 categories (Captions, VFX,
Transitions, Effects, Social, Data, Scenes)
- Registry API routes: GET /api/registry/blocks (catalog) + POST install
- StudioApiAdapter extended with listRegistryCatalog + installRegistryBlock
- Vite adapter reads from disk; CLI adapter fetches from GitHub (24h cache)
- BlockParam interface + params on 6 blocks for future parameter controls
Studio UI:
- 4th sidebar tab "Blocks" with responsive grid, category pills, search bar
- BlockCard: CDN poster thumbnail, video autoplay on hover, duration + WebGL badges
- On-demand install: blocks append as sub-compositions on timeline; components
overlay at start=0 spanning full duration with transparent background patching
- TIMELINE_BLOCK_MIME drag-and-drop to timeline
- BlockParamsPanel (Phase 3 scaffold) auto-opens for parameterized blocks
Registry manifests:
- All 58 blocks backfilled with preview: { video, poster } CDN URLs
- All 20 components normalized to object format + poster URLs added
- 6 blocks annotated with params (Liquid Glass/Background, Portal, Chart,
Logo Outro, Magnetic)
- flowchart-vertical preview generated and uploaded to CDN
This commit is contained in:
@@ -11,13 +11,16 @@ import { getPersistedRenderSettings } from "./renders/renderSettings";
|
||||
export interface StudioLeftSidebarProps {
|
||||
leftSidebarRef: RefObject<LeftSidebarHandle | null>;
|
||||
onSelectComposition: (comp: string) => void;
|
||||
onAddBlock: (blockName: string) => void;
|
||||
onLint: () => void;
|
||||
linting: boolean;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioLeftSidebar({
|
||||
leftSidebarRef,
|
||||
onSelectComposition,
|
||||
onAddBlock,
|
||||
onLint,
|
||||
linting,
|
||||
}: StudioLeftSidebarProps) {
|
||||
@@ -124,6 +127,7 @@ export function StudioLeftSidebar({
|
||||
onLint={onLint}
|
||||
linting={linting}
|
||||
onToggleCollapse={toggleLeftSidebar}
|
||||
onAddBlock={onAddBlock}
|
||||
/>
|
||||
<div
|
||||
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface StudioPreviewAreaProps {
|
||||
assetPath: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
) => Promise<void> | void;
|
||||
handleTimelineBlockDrop?: (
|
||||
blockName: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
) => Promise<void> | void;
|
||||
handleTimelineFileDrop: (
|
||||
files: File[],
|
||||
placement?: Pick<TimelineElement, "start" | "track">,
|
||||
@@ -48,6 +52,7 @@ export function StudioPreviewArea({
|
||||
renderClipContent,
|
||||
handleTimelineElementDelete,
|
||||
handleTimelineAssetDrop,
|
||||
handleTimelineBlockDrop,
|
||||
handleTimelineFileDrop,
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
@@ -98,6 +103,7 @@ export function StudioPreviewArea({
|
||||
renderClipContent={renderClipContent}
|
||||
onDeleteElement={handleTimelineElementDelete}
|
||||
onAssetDrop={handleTimelineAssetDrop}
|
||||
onBlockDrop={handleTimelineBlockDrop}
|
||||
onFileDrop={handleTimelineFileDrop}
|
||||
onMoveElement={handleTimelineElementMove}
|
||||
onResizeElement={handleTimelineElementResize}
|
||||
|
||||
@@ -2,9 +2,11 @@ import { PropertyPanel } from "./editor/PropertyPanel";
|
||||
import { MotionPanel } from "./editor/MotionPanel";
|
||||
import { LayersPanel } from "./editor/LayersPanel";
|
||||
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
|
||||
import { BlockParamsPanel } from "./editor/BlockParamsPanel";
|
||||
import { RenderQueue } from "./renders/RenderQueue";
|
||||
import type { RenderJob } from "./renders/useRenderQueue";
|
||||
import type { StudioGsapMotion } from "./editor/studioMotion";
|
||||
import type { BlockParam } from "@hyperframes/core/registry";
|
||||
import {
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED,
|
||||
STUDIO_MOTION_PANEL_ENABLED,
|
||||
@@ -22,12 +24,21 @@ export interface StudioRightPanelProps {
|
||||
selectedStudioMotion: StudioMotionData | null;
|
||||
designPanelActive: boolean;
|
||||
motionPanelActive: boolean;
|
||||
activeBlockParams?: {
|
||||
blockName: string;
|
||||
blockTitle: string;
|
||||
params: BlockParam[];
|
||||
compositionPath: string;
|
||||
} | null;
|
||||
onCloseBlockParams?: () => void;
|
||||
}
|
||||
|
||||
export function StudioRightPanel({
|
||||
selectedStudioMotion,
|
||||
designPanelActive,
|
||||
motionPanelActive,
|
||||
activeBlockParams,
|
||||
onCloseBlockParams,
|
||||
}: StudioRightPanelProps) {
|
||||
const {
|
||||
rightWidth,
|
||||
@@ -145,7 +156,15 @@ export function StudioRightPanel({
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{rightPanelTab === "layers" ? (
|
||||
{rightPanelTab === "block-params" && activeBlockParams ? (
|
||||
<BlockParamsPanel
|
||||
blockName={activeBlockParams.blockName}
|
||||
blockTitle={activeBlockParams.blockTitle}
|
||||
params={activeBlockParams.params}
|
||||
compositionPath={activeBlockParams.compositionPath}
|
||||
onClose={onCloseBlockParams ?? (() => {})}
|
||||
/>
|
||||
) : rightPanelTab === "layers" ? (
|
||||
<LayersPanel />
|
||||
) : designPanelActive ? (
|
||||
<PropertyPanel
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { memo, useState, useCallback } from "react";
|
||||
import type { BlockParam } from "@hyperframes/core/registry";
|
||||
|
||||
interface BlockParamsPanelProps {
|
||||
blockName: string;
|
||||
blockTitle: string;
|
||||
params: BlockParam[];
|
||||
compositionPath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BlockParamsPanel = memo(function BlockParamsPanel({
|
||||
blockTitle,
|
||||
params,
|
||||
compositionPath,
|
||||
onClose,
|
||||
}: BlockParamsPanelProps) {
|
||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
for (const p of params) {
|
||||
initial[p.key] = p.default;
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: string) => {
|
||||
setValues((prev) => ({ ...prev, [key]: value }));
|
||||
console.log(`[BlockParams] ${compositionPath} ${key}: ${value}`);
|
||||
},
|
||||
[compositionPath],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800">
|
||||
<div className="text-[11px] font-semibold text-neutral-200 truncate">{blockTitle}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-neutral-500 hover:text-neutral-300 transition-colors"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
<div className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
||||
Parameters
|
||||
</div>
|
||||
{params.map((param) => (
|
||||
<ParamControl
|
||||
key={param.key}
|
||||
param={param}
|
||||
value={values[param.key] ?? param.default}
|
||||
onChange={(v) => handleChange(param.key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function ParamControl({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
param: BlockParam;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-neutral-400">{param.label}</label>
|
||||
|
||||
{param.type === "color" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{param.type === "number" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={param.min ?? 0}
|
||||
max={param.max ?? 100}
|
||||
step={param.step ?? 1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-neutral-400 w-8 text-right tabular-nums">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{param.type === "text" && (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
|
||||
/>
|
||||
)}
|
||||
|
||||
{param.type === "select" && param.options && (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
|
||||
>
|
||||
{param.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,12 @@ export const STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED =
|
||||
true,
|
||||
);
|
||||
|
||||
export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
env,
|
||||
["VITE_STUDIO_ENABLE_BLOCKS_PANEL", "VITE_STUDIO_BLOCKS_PANEL_ENABLED"],
|
||||
false,
|
||||
);
|
||||
|
||||
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
||||
|
||||
export const STUDIO_MANUAL_EDITING_ENABLED = STUDIO_PREVIEW_MANUAL_EDITING_ENABLED;
|
||||
|
||||
@@ -42,6 +42,10 @@ interface NLELayoutProps {
|
||||
assetPath: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
) => Promise<void> | void;
|
||||
onBlockDrop?: (
|
||||
blockName: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
) => Promise<void> | void;
|
||||
/** Persist timeline move actions back into source HTML */
|
||||
onMoveElement?: (
|
||||
element: TimelineElement,
|
||||
@@ -85,6 +89,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
onFileDrop,
|
||||
onDeleteElement,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt,
|
||||
@@ -371,6 +376,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
onFileDrop={onFileDrop}
|
||||
onDeleteElement={onDeleteElement}
|
||||
onAssetDrop={onAssetDrop}
|
||||
onBlockDrop={onBlockDrop}
|
||||
onMoveElement={onMoveElement}
|
||||
onResizeElement={onResizeElement}
|
||||
onBlockedEditAttempt={onBlockedEditAttempt}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import { memo, useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useBlockCatalog } from "../../hooks/useBlockCatalog";
|
||||
import {
|
||||
BLOCK_CATEGORIES,
|
||||
getCategoryColors,
|
||||
type BlockCategory,
|
||||
} from "../../utils/blockCategories";
|
||||
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
||||
|
||||
interface BlocksTabProps {
|
||||
onAddBlock: (blockName: string) => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps) {
|
||||
const { loading, error, search, setSearch, category, setCategory, filteredBlocks } =
|
||||
useBlockCatalog();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-neutral-600 text-xs">
|
||||
Loading blocks…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-red-400 text-xs px-4 text-center">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Search */}
|
||||
<div className="px-3 pt-2 pb-1 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-500"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search blocks…"
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-md pl-7 pr-2 py-1.5 text-[11px] text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-neutral-700 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
<div className="px-3 pt-1 pb-2 flex-shrink-0 overflow-x-auto">
|
||||
<div className="flex gap-1">
|
||||
<CategoryPill label="All" active={category === null} onClick={() => setCategory(null)} />
|
||||
{BLOCK_CATEGORIES.map((cat) => (
|
||||
<CategoryPill
|
||||
key={cat.id}
|
||||
label={cat.label}
|
||||
category={cat.id}
|
||||
active={category === cat.id}
|
||||
onClick={() => setCategory(category === cat.id ? null : cat.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block grid */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 px-2 pb-2">
|
||||
{category === "vfx" && (
|
||||
<div className="mb-2 px-2 py-1.5 rounded-md bg-purple-500/10 border border-purple-500/20 text-[9px] text-purple-300 leading-relaxed">
|
||||
VFX blocks use WebGL via HTML-in-Canvas. Enable{" "}
|
||||
<span className="font-mono text-purple-200">chrome://flags/#html-in-canvas</span> for
|
||||
preview.
|
||||
</div>
|
||||
)}
|
||||
{filteredBlocks.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-neutral-600 text-xs">
|
||||
No blocks match your search
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))" }}
|
||||
>
|
||||
{filteredBlocks.map((block) => {
|
||||
const dur = "duration" in block ? (block.duration as number) : undefined;
|
||||
const dims =
|
||||
"dimensions" in block
|
||||
? (block.dimensions as { width: number; height: number })
|
||||
: undefined;
|
||||
return (
|
||||
<BlockCard
|
||||
key={block.name}
|
||||
name={block.name}
|
||||
title={block.title}
|
||||
duration={dur}
|
||||
category={block.category}
|
||||
tags={block.tags}
|
||||
posterUrl={block.preview?.poster}
|
||||
videoUrl={block.preview?.video}
|
||||
dimensions={dims}
|
||||
onAdd={() => onAddBlock(block.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function CategoryPill({
|
||||
label,
|
||||
category,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
category?: BlockCategory;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const colors = category ? getCategoryColors(category) : null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex-shrink-0 px-2 py-1 rounded-full text-[10px] font-medium transition-colors ${
|
||||
active
|
||||
? colors
|
||||
? `${colors.bg} ${colors.text}`
|
||||
: "bg-neutral-700 text-neutral-200"
|
||||
: "bg-neutral-900 text-neutral-500 hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockCard({
|
||||
name,
|
||||
title,
|
||||
duration,
|
||||
category,
|
||||
tags,
|
||||
posterUrl,
|
||||
videoUrl,
|
||||
dimensions,
|
||||
onAdd,
|
||||
}: {
|
||||
name: string;
|
||||
title: string;
|
||||
duration?: number;
|
||||
category: BlockCategory;
|
||||
tags?: string[];
|
||||
posterUrl?: string;
|
||||
videoUrl?: string;
|
||||
dimensions?: { width: number; height: number };
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const colors = getCategoryColors(category);
|
||||
const needsWebGL = tags?.includes("html-in-canvas") || tags?.includes("webgl");
|
||||
|
||||
const cancelLeave = useCallback(() => {
|
||||
if (leaveTimer.current) {
|
||||
clearTimeout(leaveTimer.current);
|
||||
leaveTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEnter = useCallback(() => {
|
||||
cancelLeave();
|
||||
hoverTimer.current = setTimeout(() => setHovered(true), 500);
|
||||
}, [cancelLeave]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (hoverTimer.current) {
|
||||
clearTimeout(hoverTimer.current);
|
||||
hoverTimer.current = null;
|
||||
}
|
||||
cancelLeave();
|
||||
setHovered(false);
|
||||
}, [cancelLeave]);
|
||||
|
||||
const handleLeave = useCallback(() => {
|
||||
if (hoverTimer.current) {
|
||||
clearTimeout(hoverTimer.current);
|
||||
hoverTimer.current = null;
|
||||
}
|
||||
leaveTimer.current = setTimeout(() => setHovered(false), 150);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hovered) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") dismiss();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [hovered, dismiss]);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (adding) return;
|
||||
setAdding(true);
|
||||
onAdd();
|
||||
setTimeout(() => setAdding(false), 1000);
|
||||
},
|
||||
[onAdd, adding],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData(TIMELINE_BLOCK_MIME, JSON.stringify({ name, duration, dimensions }));
|
||||
},
|
||||
[name, duration, dimensions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/card rounded-md overflow-hidden cursor-pointer transition-colors bg-neutral-900 hover:bg-neutral-800"
|
||||
onPointerEnter={handleEnter}
|
||||
onPointerLeave={handleLeave}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-video w-full overflow-hidden relative">
|
||||
{hovered && videoUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : posterUrl ? (
|
||||
<img src={posterUrl} alt={title} loading="lazy" className="w-full h-full object-cover" />
|
||||
) : videoUrl ? (
|
||||
<video
|
||||
src={videoUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className={`w-full h-full flex items-center justify-center ${colors.bg}`}>
|
||||
<span className={`text-[9px] font-medium ${colors.text}`}>
|
||||
{category.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add button overlay */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 group-hover/card:opacity-100 transition-opacity"
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-white">{adding ? "Added" : "Add"}</span>
|
||||
</button>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="absolute top-1 right-1 flex items-center gap-0.5 pointer-events-none">
|
||||
{needsWebGL && (
|
||||
<span className="px-1 py-px rounded text-[7px] font-semibold text-purple-300 bg-purple-900/70">
|
||||
WebGL
|
||||
</span>
|
||||
)}
|
||||
{duration != null && (
|
||||
<span className="px-1 py-px rounded text-[8px] font-medium text-white/80 bg-black/50">
|
||||
{duration}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-1.5 py-1.5">
|
||||
<div className="text-[10px] font-medium text-neutral-200 truncate leading-tight">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${colors.dot}`} />
|
||||
<span className={`text-[8px] ${colors.text}`}>
|
||||
{BLOCK_CATEGORIES.find((c) => c.id === category)?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen hover preview */}
|
||||
{hovered &&
|
||||
(videoUrl || posterUrl) &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center cursor-pointer"
|
||||
onClick={dismiss}
|
||||
onPointerEnter={cancelLeave}
|
||||
onPointerLeave={handleLeave}
|
||||
>
|
||||
<div className="bg-black/80 absolute inset-0" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800/80 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="relative rounded-xl overflow-hidden shadow-2xl border border-neutral-600/30 cursor-default"
|
||||
style={{ width: "80vw", maxWidth: 1200, maxHeight: "80vh" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="aspect-video bg-neutral-950">
|
||||
{videoUrl ? (
|
||||
<video
|
||||
src={videoUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img src={posterUrl} alt={title} className="w-full h-full object-contain" />
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-neutral-900/95 px-4 py-3">
|
||||
<div className="text-[14px] font-semibold text-neutral-100">{title}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`w-2 h-2 rounded-full ${colors.dot}`} />
|
||||
<span className={`text-[11px] ${colors.text}`}>
|
||||
{BLOCK_CATEGORIES.find((c) => c.id === category)?.label}
|
||||
</span>
|
||||
{duration != null && (
|
||||
<span className="text-[11px] text-neutral-500">{duration}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
} from "react";
|
||||
import { CompositionsTab } from "./CompositionsTab";
|
||||
import { AssetsTab } from "./AssetsTab";
|
||||
import { BlocksTab } from "./BlocksTab";
|
||||
import { FileTree } from "../editor/FileTree";
|
||||
import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
|
||||
|
||||
export type SidebarTab = "compositions" | "assets" | "code";
|
||||
export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
|
||||
|
||||
export interface LeftSidebarHandle {
|
||||
selectTab: (tab: SidebarTab) => void;
|
||||
@@ -22,6 +24,7 @@ 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";
|
||||
}
|
||||
|
||||
@@ -48,6 +51,7 @@ interface LeftSidebarProps {
|
||||
onLint?: () => void;
|
||||
linting?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onAddBlock?: (blockName: string) => void;
|
||||
takeoverContent?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -76,6 +80,7 @@ export const LeftSidebar = memo(
|
||||
onLint,
|
||||
linting,
|
||||
onToggleCollapse,
|
||||
onAddBlock,
|
||||
takeoverContent,
|
||||
},
|
||||
ref,
|
||||
@@ -103,7 +108,11 @@ export const LeftSidebar = memo(
|
||||
<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: "1fr 1fr 1fr" }}
|
||||
style={{
|
||||
gridTemplateColumns: STUDIO_BLOCKS_PANEL_ENABLED
|
||||
? "1fr 1fr 1fr 1fr"
|
||||
: "1fr 1fr 1fr",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -138,6 +147,19 @@ export const LeftSidebar = memo(
|
||||
>
|
||||
Assets
|
||||
</button>
|
||||
{STUDIO_BLOCKS_PANEL_ENABLED && (
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
Blocks
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onToggleCollapse && (
|
||||
<button
|
||||
@@ -214,6 +236,10 @@ export const LeftSidebar = memo(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && onAddBlock && (
|
||||
<BlocksTab onAddBlock={onAddBlock} />
|
||||
)}
|
||||
|
||||
{/* Lint button pinned at the bottom */}
|
||||
{onLint && (
|
||||
<div className="border-t border-neutral-800 p-2 flex-shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user