@hyperframes/studio 0.6.32 → 0.6.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-CSG9kRJg.js +138 -0
- package/dist/assets/index-SKRp8mGz.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/components/StudioRightPanel.tsx +46 -37
- package/src/components/TimelineToolbar.tsx +62 -55
- package/src/components/nle/NLEPreview.tsx +15 -0
- package/src/components/sidebar/BlocksTab.tsx +304 -29
- package/src/components/sidebar/LeftSidebar.tsx +47 -38
- package/src/components/ui/Tooltip.tsx +63 -0
- package/src/components/ui/index.ts +1 -0
- package/src/hooks/useBlockCatalog.ts +5 -1
- package/src/player/components/PlayerControls.tsx +253 -234
- package/src/player/lib/playbackAdapter.test.ts +3 -3
- package/src/player/lib/playbackAdapter.ts +3 -1
- package/src/utils/timelineAssetDrop.test.ts +2 -1
- package/dist/assets/index-C-pv1DOD.js +0 -120
- package/dist/assets/index-Cd3DF1je.css +0 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { memo, useState, useCallback, useRef, useEffect } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
2
3
|
import { useBlockCatalog } from "../../hooks/useBlockCatalog";
|
|
3
4
|
import {
|
|
4
5
|
BLOCK_CATEGORIES,
|
|
5
6
|
getCategoryColors,
|
|
6
7
|
type BlockCategory,
|
|
7
8
|
} from "../../utils/blockCategories";
|
|
8
|
-
import {
|
|
9
|
-
|
|
9
|
+
import { usePlayerStore } from "../../player";
|
|
10
|
+
import { formatTime } from "../../player/lib/time";
|
|
11
|
+
import { useStudioContext } from "../../contexts/StudioContext";
|
|
10
12
|
export interface BlockPreviewInfo {
|
|
11
13
|
videoUrl?: string;
|
|
12
14
|
posterUrl?: string;
|
|
@@ -14,7 +16,7 @@ export interface BlockPreviewInfo {
|
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
interface BlocksTabProps {
|
|
17
|
-
onAddBlock
|
|
19
|
+
onAddBlock?: (blockName: string) => void;
|
|
18
20
|
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -22,6 +24,7 @@ interface BlocksTabProps {
|
|
|
22
24
|
export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }: BlocksTabProps) {
|
|
23
25
|
const { loading, error, search, setSearch, category, setCategory, filteredBlocks } =
|
|
24
26
|
useBlockCatalog();
|
|
27
|
+
const [promptModal, setPromptModal] = useState<{ title: string; prompt: string } | null>(null);
|
|
25
28
|
|
|
26
29
|
if (loading) {
|
|
27
30
|
return (
|
|
@@ -62,7 +65,7 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
|
|
|
62
65
|
type="text"
|
|
63
66
|
value={search}
|
|
64
67
|
onChange={(e) => setSearch(e.target.value)}
|
|
65
|
-
placeholder="Search
|
|
68
|
+
placeholder="Search by name, category, or tag…"
|
|
66
69
|
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"
|
|
67
70
|
/>
|
|
68
71
|
</div>
|
|
@@ -104,29 +107,42 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
|
|
|
104
107
|
>
|
|
105
108
|
{filteredBlocks.map((block) => {
|
|
106
109
|
const dur = "duration" in block ? (block.duration as number) : undefined;
|
|
107
|
-
const dims =
|
|
108
|
-
"dimensions" in block
|
|
109
|
-
? (block.dimensions as { width: number; height: number })
|
|
110
|
-
: undefined;
|
|
111
110
|
return (
|
|
112
111
|
<BlockCard
|
|
113
112
|
key={block.name}
|
|
114
113
|
name={block.name}
|
|
115
114
|
title={block.title}
|
|
115
|
+
description={block.description}
|
|
116
|
+
blockType={block.type}
|
|
116
117
|
duration={dur}
|
|
117
118
|
category={block.category}
|
|
118
119
|
tags={block.tags}
|
|
119
120
|
posterUrl={block.preview?.poster}
|
|
120
121
|
videoUrl={block.preview?.video}
|
|
121
|
-
dimensions={dims}
|
|
122
|
-
onAdd={() => onAddBlock(block.name)}
|
|
123
122
|
onPreview={onPreviewBlock}
|
|
123
|
+
onShowPrompt={setPromptModal}
|
|
124
|
+
onAdd={
|
|
125
|
+
block.category === "vfx" ||
|
|
126
|
+
block.category === "social" ||
|
|
127
|
+
block.category === "scenes"
|
|
128
|
+
? () => onAddBlock?.(block.name)
|
|
129
|
+
: undefined
|
|
130
|
+
}
|
|
124
131
|
/>
|
|
125
132
|
);
|
|
126
133
|
})}
|
|
127
134
|
</div>
|
|
128
135
|
)}
|
|
129
136
|
</div>
|
|
137
|
+
{promptModal &&
|
|
138
|
+
createPortal(
|
|
139
|
+
<PromptPreviewModal
|
|
140
|
+
title={promptModal.title}
|
|
141
|
+
prompt={promptModal.prompt}
|
|
142
|
+
onClose={() => setPromptModal(null)}
|
|
143
|
+
/>,
|
|
144
|
+
document.body,
|
|
145
|
+
)}
|
|
130
146
|
</div>
|
|
131
147
|
);
|
|
132
148
|
});
|
|
@@ -160,27 +176,134 @@ function CategoryPill({
|
|
|
160
176
|
);
|
|
161
177
|
}
|
|
162
178
|
|
|
179
|
+
interface CompositionContext {
|
|
180
|
+
currentTime: number;
|
|
181
|
+
activeCompPath: string | null;
|
|
182
|
+
elements: Array<{
|
|
183
|
+
id: string;
|
|
184
|
+
start: number;
|
|
185
|
+
duration: number;
|
|
186
|
+
track: number;
|
|
187
|
+
label?: string;
|
|
188
|
+
compositionSrc?: string;
|
|
189
|
+
}>;
|
|
190
|
+
compositionDimensions?: { width: number; height: number };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatCompositionContext(ctx: CompositionContext): string {
|
|
194
|
+
const lines: string[] = [
|
|
195
|
+
`Playback time: ${formatTime(ctx.currentTime)}`,
|
|
196
|
+
`Active composition: ${ctx.activeCompPath || "index.html"}`,
|
|
197
|
+
];
|
|
198
|
+
if (ctx.compositionDimensions) {
|
|
199
|
+
lines.push(
|
|
200
|
+
`Dimensions: ${ctx.compositionDimensions.width}x${ctx.compositionDimensions.height}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const visibleNow = ctx.elements.filter(
|
|
204
|
+
(el) => ctx.currentTime >= el.start && ctx.currentTime < el.start + el.duration,
|
|
205
|
+
);
|
|
206
|
+
if (visibleNow.length > 0) {
|
|
207
|
+
lines.push(
|
|
208
|
+
"",
|
|
209
|
+
`Elements visible at ${formatTime(ctx.currentTime)}:`,
|
|
210
|
+
...visibleNow.map(
|
|
211
|
+
(el) =>
|
|
212
|
+
`- ${el.label || el.id} (track ${el.track}, ${formatTime(el.start)}–${formatTime(el.start + el.duration)}${el.compositionSrc ? `, src: ${el.compositionSrc}` : ""})`,
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const maxZ = ctx.elements.length > 0 ? Math.max(...ctx.elements.map((_, i) => i + 1)) : 0;
|
|
217
|
+
lines.push("", `Highest track index: ${maxZ}`);
|
|
218
|
+
return lines.join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildAgentPrompt(
|
|
222
|
+
title: string,
|
|
223
|
+
name: string,
|
|
224
|
+
description: string,
|
|
225
|
+
category: BlockCategory,
|
|
226
|
+
blockType: string,
|
|
227
|
+
context: CompositionContext,
|
|
228
|
+
): string {
|
|
229
|
+
const isComponent = blockType === "hyperframes:component";
|
|
230
|
+
const kind = isComponent ? "component" : "block";
|
|
231
|
+
const compositionInfo = formatCompositionContext(context);
|
|
232
|
+
|
|
233
|
+
const categoryPrompts: Record<string, string> = {
|
|
234
|
+
captions: [
|
|
235
|
+
`Using /hyperframes, add the "${title}" caption style (registry: ${name}) to my composition.`,
|
|
236
|
+
`${description}`,
|
|
237
|
+
`Transcribe the audio with /hyperframes-media, then wire the transcript into this caption component. Match the font colors and animation timing to my composition's design tokens. Place it as an overlay above the main content with the highest z-index.`,
|
|
238
|
+
].join("\n\n"),
|
|
239
|
+
vfx: [
|
|
240
|
+
`Using /hyperframes, add the "${title}" VFX (registry: ${name}) as a full-screen overlay on my composition.`,
|
|
241
|
+
`${description}`,
|
|
242
|
+
`This is a WebGL effect that requires chrome://flags/#html-in-canvas. Layer it on top of all content, adjust the shader uniforms and color palette to complement my scene, and set the duration to match the composition length.`,
|
|
243
|
+
].join("\n\n"),
|
|
244
|
+
transitions: [
|
|
245
|
+
`Using /hyperframes, add the "${title}" transition (registry: ${name}) between my scenes.`,
|
|
246
|
+
`${description}`,
|
|
247
|
+
`Place this transition at the cut point between the current scene and the next. Set the duration to 0.5–1s, position it at the scene boundary on the timeline, and make sure the z-index is above both scenes. Adjust colors to match my palette.`,
|
|
248
|
+
].join("\n\n"),
|
|
249
|
+
effects: [
|
|
250
|
+
`Using /hyperframes, add the "${title}" effect (registry: ${name}) as an overlay on my composition.`,
|
|
251
|
+
`${description}`,
|
|
252
|
+
`Layer this on top of the current content. Adjust the opacity, colors, and animation timing to enhance the scene without overwhelming the main content.`,
|
|
253
|
+
].join("\n\n"),
|
|
254
|
+
social: [
|
|
255
|
+
`Using /hyperframes, add the "${title}" template (registry: ${name}) to my composition.`,
|
|
256
|
+
`${description}`,
|
|
257
|
+
`Replace the placeholder text, handle, and avatar with my actual content. Match the typography and colors to my brand. Adjust timing so the elements animate in sync with the voiceover.`,
|
|
258
|
+
].join("\n\n"),
|
|
259
|
+
data: [
|
|
260
|
+
`Using /hyperframes, add the "${title}" visualization (registry: ${name}) to my composition.`,
|
|
261
|
+
`${description}`,
|
|
262
|
+
`Replace the placeholder data with my actual values and labels. Adjust the color scale, animation stagger timing, and typography to match my composition's design system. Size it to fit the current viewport.`,
|
|
263
|
+
].join("\n\n"),
|
|
264
|
+
scenes: [
|
|
265
|
+
`Using /hyperframes, add the "${title}" scene (registry: ${name}) to my composition.`,
|
|
266
|
+
`${description}`,
|
|
267
|
+
`Replace all placeholder text, images, and content with my actual material. Match fonts, colors, and layout to my existing design tokens. Set the timeline position and duration to fit the narrative flow.`,
|
|
268
|
+
].join("\n\n"),
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const instruction =
|
|
272
|
+
categoryPrompts[category] ??
|
|
273
|
+
[
|
|
274
|
+
`Using /hyperframes, add the "${title}" ${kind} (registry: ${name}) to my composition.`,
|
|
275
|
+
`${description}`,
|
|
276
|
+
`Customize it to match my composition's design and timeline.`,
|
|
277
|
+
].join("\n\n");
|
|
278
|
+
|
|
279
|
+
return [instruction, "", "## Current composition state", "", compositionInfo].join("\n");
|
|
280
|
+
}
|
|
281
|
+
|
|
163
282
|
function BlockCard({
|
|
164
283
|
name,
|
|
165
284
|
title,
|
|
285
|
+
description,
|
|
286
|
+
blockType,
|
|
166
287
|
duration,
|
|
167
288
|
category,
|
|
168
289
|
tags,
|
|
169
290
|
posterUrl,
|
|
170
291
|
videoUrl,
|
|
171
|
-
dimensions,
|
|
172
292
|
onAdd,
|
|
293
|
+
onShowPrompt,
|
|
173
294
|
onPreview,
|
|
174
295
|
}: {
|
|
175
296
|
name: string;
|
|
176
297
|
title: string;
|
|
298
|
+
description: string;
|
|
299
|
+
blockType: string;
|
|
177
300
|
duration?: number;
|
|
178
301
|
category: BlockCategory;
|
|
179
302
|
tags?: string[];
|
|
180
303
|
posterUrl?: string;
|
|
181
304
|
videoUrl?: string;
|
|
182
|
-
|
|
183
|
-
|
|
305
|
+
onAdd?: () => void;
|
|
306
|
+
onShowPrompt?: (info: { title: string; prompt: string }) => void;
|
|
184
307
|
onPreview?: (preview: BlockPreviewInfo | null) => void;
|
|
185
308
|
}) {
|
|
186
309
|
const [hovered, setHovered] = useState(false);
|
|
@@ -214,7 +337,7 @@ function BlockCard({
|
|
|
214
337
|
const handleAdd = useCallback(
|
|
215
338
|
(e: React.MouseEvent) => {
|
|
216
339
|
e.stopPropagation();
|
|
217
|
-
if (adding) return;
|
|
340
|
+
if (adding || !onAdd) return;
|
|
218
341
|
setAdding(true);
|
|
219
342
|
onAdd();
|
|
220
343
|
setTimeout(() => setAdding(false), 1000);
|
|
@@ -222,12 +345,38 @@ function BlockCard({
|
|
|
222
345
|
[onAdd, adding],
|
|
223
346
|
);
|
|
224
347
|
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
348
|
+
const { activeCompPath, compositionDimensions } = useStudioContext();
|
|
349
|
+
|
|
350
|
+
const handleShowPrompt = useCallback(
|
|
351
|
+
(e: React.MouseEvent) => {
|
|
352
|
+
e.stopPropagation();
|
|
353
|
+
const state = usePlayerStore.getState();
|
|
354
|
+
const context: CompositionContext = {
|
|
355
|
+
currentTime: state.currentTime,
|
|
356
|
+
activeCompPath,
|
|
357
|
+
elements: state.elements.map((el) => ({
|
|
358
|
+
id: el.id,
|
|
359
|
+
start: el.start,
|
|
360
|
+
duration: el.duration,
|
|
361
|
+
track: el.track,
|
|
362
|
+
label: el.label,
|
|
363
|
+
compositionSrc: el.compositionSrc,
|
|
364
|
+
})),
|
|
365
|
+
compositionDimensions: compositionDimensions ?? undefined,
|
|
366
|
+
};
|
|
367
|
+
const prompt = buildAgentPrompt(title, name, description, category, blockType, context);
|
|
368
|
+
onShowPrompt?.({ title, prompt });
|
|
229
369
|
},
|
|
230
|
-
[
|
|
370
|
+
[
|
|
371
|
+
title,
|
|
372
|
+
name,
|
|
373
|
+
description,
|
|
374
|
+
category,
|
|
375
|
+
blockType,
|
|
376
|
+
activeCompPath,
|
|
377
|
+
compositionDimensions,
|
|
378
|
+
onShowPrompt,
|
|
379
|
+
],
|
|
231
380
|
);
|
|
232
381
|
|
|
233
382
|
return (
|
|
@@ -235,8 +384,6 @@ function BlockCard({
|
|
|
235
384
|
className="group/card rounded-md overflow-hidden cursor-pointer transition-colors bg-neutral-900 hover:bg-neutral-800"
|
|
236
385
|
onPointerEnter={handleEnter}
|
|
237
386
|
onPointerLeave={handleLeave}
|
|
238
|
-
draggable
|
|
239
|
-
onDragStart={handleDragStart}
|
|
240
387
|
>
|
|
241
388
|
{/* Thumbnail */}
|
|
242
389
|
<div className="aspect-video w-full overflow-hidden relative">
|
|
@@ -267,14 +414,52 @@ function BlockCard({
|
|
|
267
414
|
</div>
|
|
268
415
|
)}
|
|
269
416
|
|
|
270
|
-
{/*
|
|
271
|
-
<
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
417
|
+
{/* Action overlay */}
|
|
418
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60 opacity-0 group-hover/card:opacity-100 transition-opacity">
|
|
419
|
+
{onAdd && (
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
onClick={handleAdd}
|
|
423
|
+
title="Add to composition at current time"
|
|
424
|
+
className="flex items-center gap-1 px-3 py-1.5 rounded-md bg-white text-black text-[10px] font-semibold hover:bg-neutral-200 transition-colors"
|
|
425
|
+
>
|
|
426
|
+
<svg
|
|
427
|
+
width="10"
|
|
428
|
+
height="10"
|
|
429
|
+
viewBox="0 0 24 24"
|
|
430
|
+
fill="none"
|
|
431
|
+
stroke="currentColor"
|
|
432
|
+
strokeWidth="2.5"
|
|
433
|
+
>
|
|
434
|
+
<path d="M12 5v14M5 12h14" />
|
|
435
|
+
</svg>
|
|
436
|
+
{adding ? "Added!" : "Add"}
|
|
437
|
+
</button>
|
|
438
|
+
)}
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
onClick={handleShowPrompt}
|
|
442
|
+
title="Generate a prompt to paste into your AI agent"
|
|
443
|
+
className={`flex items-center gap-1.5 px-3 ${onAdd ? "py-1" : "py-1.5"} rounded-md transition-colors ${
|
|
444
|
+
onAdd
|
|
445
|
+
? "bg-white/15 text-white/90 hover:bg-white/25 text-[9px]"
|
|
446
|
+
: "bg-white text-black hover:bg-neutral-200 text-[10px] font-semibold"
|
|
447
|
+
}`}
|
|
448
|
+
>
|
|
449
|
+
<svg
|
|
450
|
+
width={onAdd ? 9 : 11}
|
|
451
|
+
height={onAdd ? 9 : 11}
|
|
452
|
+
viewBox="0 0 24 24"
|
|
453
|
+
fill="none"
|
|
454
|
+
stroke="currentColor"
|
|
455
|
+
strokeWidth="2"
|
|
456
|
+
>
|
|
457
|
+
<rect x="9" y="9" width="13" height="13" rx="2" />
|
|
458
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
459
|
+
</svg>
|
|
460
|
+
Ask agent
|
|
461
|
+
</button>
|
|
462
|
+
</div>
|
|
278
463
|
|
|
279
464
|
{/* Badges */}
|
|
280
465
|
<div className="absolute top-1 right-1 flex items-center gap-0.5 pointer-events-none">
|
|
@@ -306,3 +491,93 @@ function BlockCard({
|
|
|
306
491
|
</div>
|
|
307
492
|
);
|
|
308
493
|
}
|
|
494
|
+
|
|
495
|
+
function PromptPreviewModal({
|
|
496
|
+
title,
|
|
497
|
+
prompt,
|
|
498
|
+
onClose,
|
|
499
|
+
}: {
|
|
500
|
+
title: string;
|
|
501
|
+
prompt: string;
|
|
502
|
+
onClose: () => void;
|
|
503
|
+
}) {
|
|
504
|
+
const [value, setValue] = useState(prompt);
|
|
505
|
+
const [copied, setCopied] = useState(false);
|
|
506
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
507
|
+
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
requestAnimationFrame(() => textareaRef.current?.focus());
|
|
510
|
+
}, []);
|
|
511
|
+
|
|
512
|
+
const handleCopy = useCallback(() => {
|
|
513
|
+
navigator.clipboard.writeText(value);
|
|
514
|
+
setCopied(true);
|
|
515
|
+
setTimeout(() => setCopied(false), 1500);
|
|
516
|
+
}, [value]);
|
|
517
|
+
|
|
518
|
+
return (
|
|
519
|
+
<div
|
|
520
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
521
|
+
onClick={onClose}
|
|
522
|
+
>
|
|
523
|
+
<div
|
|
524
|
+
className="w-[560px] max-h-[80vh] flex flex-col rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
|
|
525
|
+
onClick={(e) => e.stopPropagation()}
|
|
526
|
+
>
|
|
527
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
|
|
528
|
+
<div>
|
|
529
|
+
<h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
|
|
530
|
+
<p className="text-xs text-neutral-500 mt-0.5">{title}</p>
|
|
531
|
+
</div>
|
|
532
|
+
<button
|
|
533
|
+
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
|
|
534
|
+
onClick={onClose}
|
|
535
|
+
>
|
|
536
|
+
<svg
|
|
537
|
+
width="14"
|
|
538
|
+
height="14"
|
|
539
|
+
viewBox="0 0 24 24"
|
|
540
|
+
fill="none"
|
|
541
|
+
stroke="currentColor"
|
|
542
|
+
strokeWidth="2"
|
|
543
|
+
strokeLinecap="round"
|
|
544
|
+
>
|
|
545
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
546
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
547
|
+
</svg>
|
|
548
|
+
</button>
|
|
549
|
+
</div>
|
|
550
|
+
<div className="flex-1 overflow-y-auto px-5 py-4">
|
|
551
|
+
<p className="text-[11px] text-neutral-500 mb-2">
|
|
552
|
+
Edit the prompt below, then copy and paste into your AI agent
|
|
553
|
+
</p>
|
|
554
|
+
<textarea
|
|
555
|
+
ref={textareaRef}
|
|
556
|
+
value={value}
|
|
557
|
+
onChange={(e) => setValue(e.target.value)}
|
|
558
|
+
onKeyDown={(e) => {
|
|
559
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleCopy();
|
|
560
|
+
if (e.key === "Escape") onClose();
|
|
561
|
+
}}
|
|
562
|
+
className="w-full min-h-[240px] text-[11px] text-neutral-200 leading-relaxed font-mono bg-neutral-900/60 rounded-lg p-3 border border-neutral-800 resize-y focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
|
|
563
|
+
/>
|
|
564
|
+
</div>
|
|
565
|
+
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
|
|
566
|
+
<span className="text-[11px] text-neutral-600">
|
|
567
|
+
{navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
|
|
568
|
+
</span>
|
|
569
|
+
<button
|
|
570
|
+
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
|
571
|
+
copied
|
|
572
|
+
? "bg-emerald-500 text-white"
|
|
573
|
+
: "bg-studio-accent/90 text-neutral-950 hover:bg-studio-accent"
|
|
574
|
+
}`}
|
|
575
|
+
onClick={handleCopy}
|
|
576
|
+
>
|
|
577
|
+
{copied ? "Copied!" : "Copy prompt"}
|
|
578
|
+
</button>
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
@@ -13,6 +13,7 @@ import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
|
13
13
|
import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
|
|
14
14
|
import { FileTree } from "../editor/FileTree";
|
|
15
15
|
import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
|
|
16
|
+
import { Tooltip } from "../ui";
|
|
16
17
|
|
|
17
18
|
export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
|
|
18
19
|
|
|
@@ -124,51 +125,59 @@ export const LeftSidebar = memo(
|
|
|
124
125
|
: "1fr 1fr 1fr",
|
|
125
126
|
}}
|
|
126
127
|
>
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
142
|
-
tab === "compositions"
|
|
143
|
-
? "bg-neutral-800 text-white"
|
|
144
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
145
|
-
}`}
|
|
146
|
-
>
|
|
147
|
-
Comps
|
|
148
|
-
</button>
|
|
149
|
-
<button
|
|
150
|
-
type="button"
|
|
151
|
-
onClick={() => selectTab("assets")}
|
|
152
|
-
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
153
|
-
tab === "assets"
|
|
154
|
-
? "bg-neutral-800 text-white"
|
|
155
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
156
|
-
}`}
|
|
157
|
-
>
|
|
158
|
-
Assets
|
|
159
|
-
</button>
|
|
160
|
-
{STUDIO_BLOCKS_PANEL_ENABLED && (
|
|
128
|
+
<Tooltip label="Source code editor" side="bottom">
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => selectTab("code")}
|
|
132
|
+
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
133
|
+
tab === "code"
|
|
134
|
+
? "bg-neutral-800 text-white"
|
|
135
|
+
: "text-neutral-500 hover:text-neutral-200"
|
|
136
|
+
}`}
|
|
137
|
+
>
|
|
138
|
+
Code
|
|
139
|
+
</button>
|
|
140
|
+
</Tooltip>
|
|
141
|
+
<Tooltip label="Compositions and sub-compositions" side="bottom">
|
|
161
142
|
<button
|
|
162
143
|
type="button"
|
|
163
|
-
onClick={() => selectTab("
|
|
144
|
+
onClick={() => selectTab("compositions")}
|
|
164
145
|
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
165
|
-
tab === "
|
|
146
|
+
tab === "compositions"
|
|
166
147
|
? "bg-neutral-800 text-white"
|
|
167
148
|
: "text-neutral-500 hover:text-neutral-200"
|
|
168
149
|
}`}
|
|
169
150
|
>
|
|
170
|
-
|
|
151
|
+
Comps
|
|
171
152
|
</button>
|
|
153
|
+
</Tooltip>
|
|
154
|
+
<Tooltip label="Videos, images, audio, fonts" side="bottom">
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={() => selectTab("assets")}
|
|
158
|
+
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
159
|
+
tab === "assets"
|
|
160
|
+
? "bg-neutral-800 text-white"
|
|
161
|
+
: "text-neutral-500 hover:text-neutral-200"
|
|
162
|
+
}`}
|
|
163
|
+
>
|
|
164
|
+
Assets
|
|
165
|
+
</button>
|
|
166
|
+
</Tooltip>
|
|
167
|
+
{STUDIO_BLOCKS_PANEL_ENABLED && (
|
|
168
|
+
<Tooltip label="Browse blocks and components" side="bottom">
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
onClick={() => selectTab("blocks")}
|
|
172
|
+
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
173
|
+
tab === "blocks"
|
|
174
|
+
? "bg-neutral-800 text-white"
|
|
175
|
+
: "text-neutral-500 hover:text-neutral-200"
|
|
176
|
+
}`}
|
|
177
|
+
>
|
|
178
|
+
Catalog
|
|
179
|
+
</button>
|
|
180
|
+
</Tooltip>
|
|
172
181
|
)}
|
|
173
182
|
</div>
|
|
174
183
|
{onToggleCollapse && (
|
|
@@ -246,7 +255,7 @@ export const LeftSidebar = memo(
|
|
|
246
255
|
</div>
|
|
247
256
|
)}
|
|
248
257
|
|
|
249
|
-
{STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" &&
|
|
258
|
+
{STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && (
|
|
250
259
|
<BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
|
|
251
260
|
)}
|
|
252
261
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, type ReactNode } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
|
|
4
|
+
interface TooltipProps {
|
|
5
|
+
label: string;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
delay?: number;
|
|
8
|
+
side?: "top" | "bottom";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) {
|
|
12
|
+
const [visible, setVisible] = useState(false);
|
|
13
|
+
const [pos, setPos] = useState({ x: 0, y: 0 });
|
|
14
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
15
|
+
const triggerRef = useRef<HTMLSpanElement>(null);
|
|
16
|
+
|
|
17
|
+
const show = useCallback(() => {
|
|
18
|
+
timerRef.current = setTimeout(() => {
|
|
19
|
+
const el = triggerRef.current;
|
|
20
|
+
if (!el) return;
|
|
21
|
+
const child = el.firstElementChild as HTMLElement | null;
|
|
22
|
+
const rect = (child ?? el).getBoundingClientRect();
|
|
23
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
24
|
+
setPos({
|
|
25
|
+
x: rect.left + rect.width / 2,
|
|
26
|
+
y: side === "top" ? rect.top - 6 : rect.bottom + 6,
|
|
27
|
+
});
|
|
28
|
+
setVisible(true);
|
|
29
|
+
}, delay);
|
|
30
|
+
}, [delay, side]);
|
|
31
|
+
|
|
32
|
+
const hide = useCallback(() => {
|
|
33
|
+
if (timerRef.current) {
|
|
34
|
+
clearTimeout(timerRef.current);
|
|
35
|
+
timerRef.current = null;
|
|
36
|
+
}
|
|
37
|
+
setVisible(false);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
<span ref={triggerRef} onPointerEnter={show} onPointerLeave={hide} className="contents">
|
|
43
|
+
{children}
|
|
44
|
+
</span>
|
|
45
|
+
{visible &&
|
|
46
|
+
createPortal(
|
|
47
|
+
<div
|
|
48
|
+
className="fixed z-[200] pointer-events-none"
|
|
49
|
+
style={{
|
|
50
|
+
left: pos.x,
|
|
51
|
+
top: pos.y,
|
|
52
|
+
transform: side === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<div className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg">
|
|
56
|
+
{label}
|
|
57
|
+
</div>
|
|
58
|
+
</div>,
|
|
59
|
+
document.body,
|
|
60
|
+
)}
|
|
61
|
+
</>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -56,7 +56,11 @@ export function useBlockCatalog() {
|
|
|
56
56
|
if (search.trim()) {
|
|
57
57
|
const q = search.toLowerCase();
|
|
58
58
|
result = result.filter(
|
|
59
|
-
(b) =>
|
|
59
|
+
(b) =>
|
|
60
|
+
b.title.toLowerCase().includes(q) ||
|
|
61
|
+
b.description.toLowerCase().includes(q) ||
|
|
62
|
+
b.category.toLowerCase().includes(q) ||
|
|
63
|
+
b.tags?.some((t) => t.toLowerCase().includes(q)),
|
|
60
64
|
);
|
|
61
65
|
}
|
|
62
66
|
return result;
|