diff --git a/packages/studio/src/components/panels/SlideshowPanel.tsx b/packages/studio/src/components/panels/SlideshowPanel.tsx index 76a52e96fd..c9ad12b452 100644 --- a/packages/studio/src/components/panels/SlideshowPanel.tsx +++ b/packages/studio/src/components/panels/SlideshowPanel.tsx @@ -183,6 +183,13 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP const [expandedSections, setExpandedSections] = useState>( () => new Set(["slides", "inspector"]), ); + // Persist failure surfacing: edits keep working in memory, but the user must + // know the file write failed (and be able to retry) — never silent data loss. + const [persistError, setPersistError] = useState(false); + const [retrying, setRetrying] = useState(false); + // In-panel undo history of manifest snapshots (discrete edits only). + const undoStackRef = useRef([]); + const [undoDepth, setUndoDepth] = useState(0); const currentTime = usePlayerStore((s) => s.currentTime); const { domEditSelection } = useDomEditSelectionContext(); @@ -209,26 +216,56 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP setManifest(parsed); manifestRef.current = parsed; setSelectedSequenceId(null); + // History belongs to one composition — a switch starts fresh. + undoStackRef.current = []; + setUndoDepth(0); + setPersistError(false); }, [compHtml]); /** Discrete actions (toggle, reorder, add/delete, hotspot): persist immediately. */ const applyManifest = useCallback( - async (next: SlideshowManifest) => { + async (next: SlideshowManifest, opts?: { skipUndo?: boolean }) => { // Fold any in-flight typed notes into the discrete manifest so they are // not silently dropped when the debounce timer would have fired later. const merged = notesCtrlRef.current.mergeIntoDiscrete(next); + if (!opts?.skipUndo) { + undoStackRef.current.push(manifestRef.current); + if (undoStackRef.current.length > 50) undoStackRef.current.shift(); + setUndoDepth(undoStackRef.current.length); + } setManifest(merged); manifestRef.current = merged; // Surface persist failures instead of swallowing them at each call site. try { await onPersist(merged); + setPersistError(false); } catch (err) { console.error("[slideshow] failed to persist manifest edit:", err); + setPersistError(true); } }, [onPersist], ); + const handleUndo = useCallback(() => { + const prev = undoStackRef.current.pop(); + if (!prev) return; + setUndoDepth(undoStackRef.current.length); + applyManifest(prev, { skipUndo: true }).catch(() => {}); + }, [applyManifest]); + + const handleRetryPersist = useCallback(async () => { + setRetrying(true); + try { + await onPersist(manifestRef.current); + setPersistError(false); + } catch (err) { + console.error("[slideshow] retry persist failed:", err); + } finally { + setRetrying(false); + } + }, [onPersist]); + /** * Notes path: update in-memory state immediately for a responsive UI, but * debounce the disk persist to ~450 ms after the last keystroke. The pending @@ -338,18 +375,10 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP [applyManifest], ); + // Confirmation is inline in BranchItem (consistent with the FileTree/assets + // delete pattern); by the time this fires the user has already confirmed. const handleDeleteSequence = useCallback( (id: string) => { - // Deleting a branch removes its slides and orphans any hotspot targeting it — - // confirm first to prevent accidental data loss. - const seq = (manifestRef.current.slideSequences ?? []).find((s) => s.id === id); - const count = seq?.slides.length ?? 0; - const label = seq?.label ?? id; - const ok = window.confirm( - `Delete branch "${label}"${count ? ` and its ${count} slide${count === 1 ? "" : "s"}` : ""}? ` + - `Hotspots pointing to it will no longer resolve.`, - ); - if (!ok) return; applyManifest(deleteSequence(manifestRef.current, id)).catch(() => {}); }, [applyManifest], @@ -383,7 +412,47 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP ); return ( -
+
{ + // In-panel undo — scoped so it never fights the app-level file undo. + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z") { + const target = e.target instanceof HTMLElement ? e.target.tagName : ""; + if (target === "TEXTAREA" || target === "INPUT") return; + e.preventDefault(); + e.stopPropagation(); + handleUndo(); + } + }} + > + {persistError && ( +
+ Changes not saved + +
+ )} + {undoDepth > 0 && ( +
+ +
+ )} toggleSection("slides")} diff --git a/packages/studio/src/components/panels/SlideshowSubPanels.tsx b/packages/studio/src/components/panels/SlideshowSubPanels.tsx index 580e75e9c2..a30a366e1c 100644 --- a/packages/studio/src/components/panels/SlideshowSubPanels.tsx +++ b/packages/studio/src/components/panels/SlideshowSubPanels.tsx @@ -23,7 +23,7 @@ export function SectionHeader({ return (
+ {confirmingDelete && ( +
+ + Delete branch “{seq.label}” + {seq.slides.length > 0 + ? ` and its ${seq.slides.length} slide${seq.slides.length === 1 ? "" : "s"}` + : ""} + ? Hotspots pointing to it will no longer resolve. + +
+ + +
+
+ )}
{scenes.map((scene) => { const assigned = seq.slides.some((s) => s.sceneId === scene.id); @@ -447,6 +484,16 @@ export function HotspotTool({ Selected element:{" "} {elementKey ?? "none"}

+ {!elementKey && ( +

+ Click an element on the canvas to choose the hotspot target. +

+ )} + {sequences.length === 0 && ( +

+ Create a branch in the Branches section first — hotspots jump to a branch. +

+ )} Make hotspot diff --git a/packages/studio/src/components/sidebar/AssetCard.test.tsx b/packages/studio/src/components/sidebar/AssetCard.test.tsx index ce825db8aa..5ee895ab17 100644 --- a/packages/studio/src/components/sidebar/AssetCard.test.tsx +++ b/packages/studio/src/components/sidebar/AssetCard.test.tsx @@ -52,7 +52,7 @@ describe("AssetCard click behavior", () => { const cardProps = { projectId: "p1", onCopy: vi.fn(), - isCopied: false, + copyFeedback: null, }; it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => { @@ -81,7 +81,7 @@ describe("AudioRow click behavior", () => { const rowProps = { projectId: "p1", onCopy: vi.fn(), - isCopied: false, + copyFeedback: null, }; it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => { diff --git a/packages/studio/src/components/sidebar/AssetCard.tsx b/packages/studio/src/components/sidebar/AssetCard.tsx index 6ebf6fcb91..993665241a 100644 --- a/packages/studio/src/components/sidebar/AssetCard.tsx +++ b/packages/studio/src/components/sidebar/AssetCard.tsx @@ -11,7 +11,7 @@ import { usePlayerStore } from "../../player/store/playerStore"; import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity"; import { useAssetPreviewStore } from "../../utils/assetPreviewStore"; import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior"; -import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers"; +import { basename, ext, truncateMiddle, formatDuration, type CopyFeedback } from "./assetHelpers"; import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils"; /** Drag payload writer shared by the asset tile and the font row: copy effect @@ -22,6 +22,23 @@ function writeAssetDragData(e: React.DragEvent, asset: string): void { e.dataTransfer.setData("text/plain", asset); } +/** Copy-path outcome chip. Copying is a context-menu action, so this is pure + * feedback — it renders only once a copy has succeeded or failed, and never + * as an idle affordance for something the tile itself does not do. */ +function CopyChip({ feedback, asset }: { feedback: CopyFeedback; asset: string }) { + if (feedback?.path !== asset) return null; + return ( + + {feedback.ok ? "Copied" : "Copy failed"} + + ); +} + /** Open the row/tile context menu at the pointer, shared by asset tile + font row. */ function openAssetContextMenu( e: React.MouseEvent, @@ -90,7 +107,7 @@ export interface AssetCardProps { used: boolean; duration?: number; onCopy: (path: string) => void; - isCopied: boolean; + copyFeedback: CopyFeedback; onDelete?: (path: string) => void; onRename?: (oldPath: string, newPath: string) => void; onAddAssetToTimeline?: (path: string) => void; @@ -112,13 +129,15 @@ export function AssetCard({ used, duration, onCopy, - isCopied, + copyFeedback, onDelete, onRename, onAddAssetToTimeline, }: AssetCardProps) { const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [hovered, setHovered] = useState(false); + const [imgError, setImgError] = useState(false); + const isCopied = copyFeedback?.path === asset && copyFeedback.ok; const fullName = asset.split("/").pop() ?? asset; const name = basename(asset); const extension = ext(asset); @@ -143,66 +162,80 @@ export function AssetCard({ pointerDownRef.current = { x: e.clientX, y: e.clientY }; }, []); + // Reveal the clip when the asset is already on the timeline, otherwise open + // the preview overlay. Shared by pointer-up and keyboard activation so the + // tile does the same thing however it is operated. + const activateCard = useCallback(() => { + if (used) { + const clip = findClipForAsset(elements, asset); + if (clip) { + // Dismiss any open preview overlay (from another asset) — the reveal + // must not leave a stale preview card floating over the canvas. + clearPreviewAsset(); + const clipKey = clip.key ?? clip.id; + setSelectedElementId(clipKey); + // Scroll the timeline so the selected clip is actually visible. + requestTimelineFocus(timelineClipFocusId(clipKey)); + return; + } + } + // Not added (or no matching clip found) → preview overlay + setPreviewAsset(asset, projectId); + }, [ + used, + elements, + asset, + projectId, + setSelectedElementId, + requestTimelineFocus, + setPreviewAsset, + clearPreviewAsset, + ]); + const handlePointerUp = useCallback( (e: React.PointerEvent) => { const origin = pointerDownRef.current; pointerDownRef.current = null; if (!origin) return; if (!isPointerClick(e.clientX - origin.x, e.clientY - origin.y)) return; - // Treat as click - if (used) { - const clip = findClipForAsset(elements, asset); - if (clip) { - // Dismiss any open preview overlay (from another asset) — the reveal - // must not leave a stale preview card floating over the canvas. - clearPreviewAsset(); - const clipKey = clip.key ?? clip.id; - setSelectedElementId(clipKey); - // Scroll the timeline so the selected clip is actually visible. - requestTimelineFocus(timelineClipFocusId(clipKey)); - return; - } - } - // Not added (or no matching clip found) → preview overlay - setPreviewAsset(asset, projectId); + activateCard(); }, - [ - used, - elements, - asset, - projectId, - setSelectedElementId, - requestTimelineFocus, - setPreviewAsset, - clearPreviewAsset, - ], + [activateCard], ); return ( <>
{ + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + activateCard(); + } + }} onDragStart={(e) => writeAssetDragData(e, asset)} onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)} onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)} - className={`flex flex-col gap-1 cursor-pointer rounded-md p-1 transition-colors ${ + className={`flex flex-col gap-1 cursor-pointer rounded-md p-1 transition-colors outline-none focus-visible:bg-neutral-800/60 ${ isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/40" }`} > {/* Thumbnail */}
- {isImage && ( + {isImage && !imgError && ( {name} { - (e.target as HTMLImageElement).style.display = "none"; - }} + onError={() => setImgError(true)} /> )} {isVideo && ( @@ -220,7 +253,7 @@ export function AssetCard({ )} )} - {!isImage && !isVideo && ( + {((!isImage && !isVideo) || (isImage && imgError)) && (
{extension}
@@ -242,14 +275,17 @@ export function AssetCard({
{/* Filename caption */} - - {truncateMiddle(fullName, 22)} - +
+ + {truncateMiddle(fullName, 22)} + + +
{contextMenu && ( @@ -272,7 +308,7 @@ export interface FontRowProps { asset: string; used: boolean; onCopy: (path: string) => void; - isCopied: boolean; + copyFeedback: CopyFeedback; onDelete?: (path: string) => void; onRename?: (oldPath: string, newPath: string) => void; onAddAssetToTimeline?: (path: string) => void; @@ -285,7 +321,7 @@ export function FontRow({ asset, used, onCopy, - isCopied, + copyFeedback, onDelete, onRename, onAddAssetToTimeline, @@ -293,15 +329,26 @@ export function FontRow({ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const name = basename(asset); const extension = ext(asset); + const isCopied = copyFeedback?.path === asset && copyFeedback.ok; return ( <>
onCopy(asset)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onCopy(asset); + } + }} onDragStart={(e) => writeAssetDragData(e, asset)} onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)} - className={`px-2.5 py-1.5 flex items-center gap-2.5 cursor-pointer transition-colors ${ + className={`px-2.5 py-1.5 flex items-center gap-2.5 cursor-pointer transition-colors outline-none focus-visible:bg-neutral-800/60 ${ isCopied ? "bg-studio-accent/10 border-l-2 border-studio-accent" : "border-l-2 border-transparent hover:bg-neutral-800/50" @@ -323,6 +370,7 @@ export function FontRow({ in use )} +
diff --git a/packages/studio/src/components/sidebar/AssetContextMenu.tsx b/packages/studio/src/components/sidebar/AssetContextMenu.tsx index d4e2e187c4..fd807a2f55 100644 --- a/packages/studio/src/components/sidebar/AssetContextMenu.tsx +++ b/packages/studio/src/components/sidebar/AssetContextMenu.tsx @@ -1,3 +1,12 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { filename } from "./assetHelpers"; + +/** Reject names that would escape the asset directory or break paths. */ +function isValidAssetName(name: string): boolean { + return name.length > 0 && !/[/\\]/.test(name) && !name.includes(".."); +} + +// fallow-ignore-next-line complexity export function ContextMenu({ x, y, @@ -17,6 +26,77 @@ export function ContextMenu({ onRename?: (oldPath: string, newPath: string) => void; onAddAtPlayhead?: (path: string) => void; }) { + const menuRef = useRef(null); + const [pos, setPos] = useState({ x, y }); + const [mode, setMode] = useState<"menu" | "confirm-delete" | "rename">("menu"); + const [renameDraft, setRenameDraft] = useState(() => filename(asset)); + const [renameError, setRenameError] = useState(null); + + // Clamp the menu inside the viewport once it has a size. + useLayoutEffect(() => { + const el = menuRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const margin = 8; + setPos({ + x: Math.min(x, window.innerWidth - rect.width - margin), + y: Math.min(y, window.innerHeight - rect.height - margin), + }); + }, [x, y, mode]); + + // Keyboard contract: Escape backs out one level (rename/delete-confirm → + // menu → closed), arrows move between menu items. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + if (mode !== "menu") { + setMode("menu"); + } else { + onClose(); + } + return; + } + if (mode !== "menu" || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return; + const items = Array.from( + menuRef.current?.querySelectorAll('[role="menuitem"]') ?? [], + ); + if (items.length === 0) return; + e.preventDefault(); + const idx = items.findIndex((el) => el === document.activeElement); + const delta = e.key === "ArrowDown" ? 1 : -1; + const next = items[(idx + delta + items.length) % items.length]; + next.focus(); + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, [mode, onClose]); + + // Move focus into the menu on open so arrow keys work immediately. + useEffect(() => { + if (mode === "menu") { + menuRef.current?.querySelector('[role="menuitem"]')?.focus(); + } + }, [mode]); + + const commitRename = useCallback(() => { + const trimmed = renameDraft.trim(); + if (trimmed === filename(asset)) { + onClose(); + return; + } + if (!isValidAssetName(trimmed)) { + setRenameError("Name can't contain / or .."); + return; + } + const dir = asset.includes("/") ? asset.slice(0, asset.lastIndexOf("/") + 1) : ""; + onRename?.(asset, `${dir}${trimmed}`); + onClose(); + }, [renameDraft, asset, onRename, onClose]); + + const itemCls = + "w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none active:bg-neutral-700/70 transition-colors"; + return (
e.stopPropagation()} > - {onAddAtPlayhead && ( - + )} + + {onRename && ( + + )} + {onDelete && ( + + )} + + )} + {mode === "confirm-delete" && ( + { + onDelete?.(asset); onClose(); }} - className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors" - > - Add at playhead - + onCancel={() => setMode("menu")} + /> + )} + {mode === "rename" && ( +
+ { + setRenameDraft(e.target.value); + setRenameError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(); + if (e.key === "Escape") { + e.stopPropagation(); + setMode("menu"); + } + }} + aria-label={`Rename ${filename(asset)}`} + className="w-full bg-neutral-800 border border-neutral-600 rounded px-1.5 py-1 text-[11px] text-white focus:border-studio-accent/60 focus:outline-none" + /> + {renameError && {renameError}} +
+ + +
+
)} +
+
+ ); +} + +function DeleteConfirm({ + name, + onConfirm, + onCancel, +}: { + name: string; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( +
+ Delete {name}? +
+ - {onRename && ( - - )} - {onDelete && ( - - )}
); diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index e1344c88e6..aa8270dbf0 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -1,9 +1,17 @@ // fallow-ignore-file code-duplication import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react"; +import { SearchInput } from "../ui/SearchInput"; import { MEDIA_EXT, FONT_EXT } from "../../utils/mediaTypes"; import { copyTextToClipboard } from "../../utils/clipboard"; import { usePlayerStore } from "../../player/store/playerStore"; -import { type MediaCategory, getCategory, CATEGORY_LABELS, FILTER_ORDER } from "./assetHelpers"; +import { + type MediaCategory, + type CopyFeedback, + getCategory, + basename, + CATEGORY_LABELS, + FILTER_ORDER, +} from "./assetHelpers"; import { AudioRow } from "./AudioRow"; import { GlobalAssetsView } from "./GlobalAssetsView"; import { AssetCard, FontRow } from "./AssetCard"; @@ -11,7 +19,7 @@ import { AssetCard, FontRow } from "./AssetCard"; interface AssetsTabProps { projectId: string; assets: string[]; - onImport?: (files: FileList) => void; + onImport?: (files: FileList) => void | Promise; onDelete?: (path: string) => void; onRename?: (oldPath: string, newPath: string) => void; onAddAssetToTimeline?: (path: string) => void; @@ -90,6 +98,99 @@ export function deriveUsedPaths(elements: Array<{ src?: string }>): Set return paths; } +/** Import trigger. An import is an await, so the button owns the pending state + * instead of leaving the author clicking a control that looks idle. */ +function ImportButton({ importing, onClick }: { importing: boolean; onClick: () => void }) { + return ( + + ); +} + +/** Empty list body. A query that matched nothing says so and offers a way out; + * a genuinely empty project gets the drop hint. */ +function EmptyState({ + searchQuery, + onClearSearch, +}: { + searchQuery: string; + onClearSearch: () => void; +}) { + if (searchQuery) { + return ( +
+

+ No assets match “{searchQuery}” +

+ +
+ ); + } + return ( +
+ + + + + +

Drop media files here

+
+ ); +} + export const AssetsTab = memo(function AssetsTab({ projectId, assets, @@ -100,7 +201,8 @@ export const AssetsTab = memo(function AssetsTab({ }: AssetsTabProps) { const fileInputRef = useRef(null); const [dragOver, setDragOver] = useState(false); - const [copiedPath, setCopiedPath] = useState(null); + const [copyFeedback, setCopyFeedback] = useState(null); + const [importing, setImporting] = useState(false); const [activeFilter, setActiveFilter] = useState("all"); const [usageFilter, setUsageFilter] = useState<"all" | "used" | "unused">("all"); const [searchQuery, setSearchQuery] = useState(""); @@ -144,42 +246,55 @@ export const AssetsTab = memo(function AssetsTab({ cancelled = true; }; }, [projectId, assetsKey]); + + const handleImport = useCallback( + async (files: FileList) => { + if (!onImport) return; + setImporting(true); + try { + await onImport(files); + } finally { + setImporting(false); + } + }, + [onImport], + ); + const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); setDragOver(false); - if (e.dataTransfer.files.length) onImport?.(e.dataTransfer.files); + if (e.dataTransfer.files.length) void handleImport(e.dataTransfer.files); }, - [onImport], + [handleImport], ); + const handleCopyPath = useCallback(async (path: string) => { const copied = await copyTextToClipboard(path); - if (copied) { - setCopiedPath(path); - setTimeout(() => setCopiedPath(null), 1500); - } + setCopyFeedback({ path, ok: copied }); + setTimeout(() => setCopyFeedback(null), copied ? 1500 : 3000); }, []); const elements = usePlayerStore((s) => s.elements); const usedPaths = useMemo(() => deriveUsedPaths(elements), [elements]); + + // Unfiltered pool — header controls (search, chips) are gated on THIS, not + // the search-filtered list, so a no-match query can't unmount its own input. + const allMediaAssets = useMemo( + () => assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)), + [assets], + ); + const mediaAssets = useMemo(() => { - const media = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)); - const all = filterByUsage(media, usedPaths, usageFilter); + const all = filterByUsage(allMediaAssets, usedPaths, usageFilter); if (!searchQuery) return all; const q = searchQuery.toLowerCase(); return all.filter((a) => { - if ( - a - .split("/") - .pop() - ?.replace(/\.[^.]*$/, "") - .toLowerCase() - .includes(q) - ) - return true; + if (basename(a).toLowerCase().includes(q)) return true; const rec = manifest.get(a); return rec?.description?.toLowerCase().includes(q); }); - }, [assets, searchQuery, manifest, usageFilter, usedPaths]); + }, [allMediaAssets, searchQuery, manifest, usageFilter, usedPaths]); + const categorized = useMemo(() => { const groups: Record = { audio: [], images: [], video: [], fonts: [] }; for (const a of mediaAssets) { @@ -244,23 +359,7 @@ export const AssetsTab = memo(function AssetsTab({ {/* Import */} {onImport && ( <> - + fileInputRef.current?.click()} /> { if (e.target.files?.length) { - onImport(e.target.files); + void handleImport(e.target.files); e.target.value = ""; } }} @@ -277,45 +376,24 @@ export const AssetsTab = memo(function AssetsTab({ )} - {/* Search */} - {mediaAssets.length > 0 && ( -
- - - - - setSearchQuery(e.target.value)} - placeholder="Search assets..." - className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5" - /> -
+ {/* Search — gated on the UNFILTERED pool so it never unmounts itself */} + {allMediaAssets.length > 0 && ( + setSearchQuery(e.target.value)} + placeholder="Search assets..." + aria-label="Search assets" + className="mb-2" + /> )} {/* Filter chips */} - {viewMode === "local" && mediaAssets.length > 0 && ( + {viewMode === "local" && allMediaAssets.length > 0 && (
{bars.length > 0 && (
diff --git a/packages/studio/src/components/sidebar/BlocksTab.tsx b/packages/studio/src/components/sidebar/BlocksTab.tsx index 6b4c27eec8..9fb4abcc76 100644 --- a/packages/studio/src/components/sidebar/BlocksTab.tsx +++ b/packages/studio/src/components/sidebar/BlocksTab.tsx @@ -1,6 +1,8 @@ // fallow-ignore-file code-duplication import { memo, useState, useCallback, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; +import { SearchInput } from "../ui/SearchInput"; +import { PromptPreviewModal } from "./PromptPreviewModal"; import { useBlockCatalog } from "../../hooks/useBlockCatalog"; import { BLOCK_CATEGORIES, @@ -18,7 +20,7 @@ export interface BlockPreviewInfo { } interface BlocksTabProps { - onAddBlock?: (blockName: string) => void; + onAddBlock?: (blockName: string) => void | Promise; onPreviewBlock?: (preview: BlockPreviewInfo | null) => void; } @@ -48,29 +50,12 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
{/* Search */}
-
- - - - - setSearch(e.target.value)} - placeholder="Search by name, category, or tag…" - 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" - /> -
+ setSearch(e.target.value)} + placeholder="Search by name, category, or tag…" + aria-label="Search blocks" + />
{/* Category pills */} @@ -165,7 +150,8 @@ function CategoryPill({
)} - {/* Action overlay */} -
+ {/* Action overlay — also revealed when a button inside receives focus */} +
{onAdd && ( )} -
-
-

- Edit the prompt below, then copy and paste into your AI agent -

-