From e474a3b9eec0c5d5457489c26d08401e453323ea Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:19 -0700 Subject: [PATCH] feat(studio): sidebar asset card (unwired) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: AssetCard — the sidebar asset tile (thumbnail, hover preview, click policy, context menu incl. add-at-playhead), unwired. Why: standalone sidebar concept; AssetsTab adopts it in the app-shell swap. How: new file against the coexistence layer (assetHelpers, AssetContextMenu, assetClickBehavior, assetPreviewStore); one TEMP(studio-dnd) entry until AssetsTab wires it. Test plan: tsc --noEmit; fallow audit clean. --- .fallowrc.jsonc | 6 + .../src/components/sidebar/AssetCard.tsx | 316 ++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 packages/studio/src/components/sidebar/AssetCard.tsx diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index ba95b95ebe..543f806d80 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -55,6 +55,9 @@ "packages/studio/src/hooks/gsapRuntimePreview.ts", // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. + "packages/studio/src/components/sidebar/AssetCard.tsx", + // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; + // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. "packages/studio/src/components/EditorShell.tsx", "packages/studio/src/components/nle/TimelinePane.tsx", "packages/studio/src/components/nle/useTimelineEditCallbacks.ts", @@ -659,6 +662,9 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ + // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); + // removed by studio-dnd/pr22 when the final config lands. + "packages/studio/src/components/sidebar/AssetCard.tsx", // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); // removed by studio-dnd/pr22 when the final config lands. "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", diff --git a/packages/studio/src/components/sidebar/AssetCard.tsx b/packages/studio/src/components/sidebar/AssetCard.tsx new file mode 100644 index 0000000000..e2ef1bf4f6 --- /dev/null +++ b/packages/studio/src/components/sidebar/AssetCard.tsx @@ -0,0 +1,316 @@ +/** + * AssetCard and FontRow — visual asset tile / row components for the Assets panel. + * Extracted from AssetsTab.tsx to keep that file under the 600-line CI gate. + */ +import { useState, useEffect, useRef, useCallback } from "react"; +import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail"; +import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes"; +import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; +import { ContextMenu } from "./AssetContextMenu"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { useAssetPreviewStore } from "../../utils/assetPreviewStore"; +import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior"; +import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers"; + +/** Drag payload writer shared by the asset tile and the font row: copy effect + * plus the timeline-asset MIME and a plain-text path fallback. */ +function writeAssetDragData(e: React.DragEvent, asset: string): void { + e.dataTransfer.effectAllowed = "copy"; + e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset })); + e.dataTransfer.setData("text/plain", asset); +} + +/** Open the row/tile context menu at the pointer, shared by asset tile + font row. */ +function openAssetContextMenu( + e: React.MouseEvent, + setContextMenu: (menu: { x: number; y: number }) => void, +): void { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); +} + +/** + * Lazily probe a video/audio URL for its duration via a hidden HTMLVideoElement + * (`preload="metadata"`). The manifest only covers ~/.media assets, so project + * assets in assets/ have no manifest entry — this fills the gap. + * Returns `undefined` until the probe completes; `null` if it failed. + */ +function useProbedDuration(src: string, skip: boolean): number | null | undefined { + const [duration, setDuration] = useState(undefined); + useEffect(() => { + if (skip) return; + let cancelled = false; + + function probe(attempt: number) { + if (cancelled) return; + const vid = document.createElement("video"); + vid.preload = "metadata"; + vid.muted = true; + vid.onloadedmetadata = () => { + const d = Number.isFinite(vid.duration) && vid.duration > 0 ? vid.duration : null; + if (!cancelled) setDuration(d); + vid.onloadedmetadata = null; + vid.onerror = null; + vid.src = ""; + }; + vid.onerror = () => { + vid.onloadedmetadata = null; + vid.onerror = null; + vid.src = ""; + if (!cancelled) { + if (attempt < 1) setTimeout(() => probe(attempt + 1), 50); + else setDuration(null); + } + }; + vid.src = src; + } + + probe(0); + return () => { + cancelled = true; + }; + }, [src, skip]); + return duration; +} + +export interface AssetCardProps { + projectId: string; + asset: string; + used: boolean; + duration?: number; + onCopy: (path: string) => void; + isCopied: boolean; + onDelete?: (path: string) => void; + onRename?: (oldPath: string, newPath: string) => void; + onAddAssetToTimeline?: (path: string) => void; +} + +/** + * Thumbnail card for images and video assets. Renders in a 2-col grid. + * + * Click behaviour (CapCut-style): + * - Already added → selects the clip on the timeline (setSelectedElementId). + * - Not yet added → opens the asset preview overlay over the canvas. + * Drag behaviour is preserved: a pointer movement exceeding DRAG_THRESHOLD_PX + * before pointerup is treated as drag-start, not a click. + */ +// fallow-ignore-next-line complexity +export function AssetCard({ + projectId, + asset, + used, + duration, + onCopy, + isCopied, + onDelete, + onRename, + onAddAssetToTimeline, +}: AssetCardProps) { + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [hovered, setHovered] = useState(false); + const fullName = asset.split("/").pop() ?? asset; + const name = basename(asset); + const extension = ext(asset); + const serveUrl = `/api/projects/${projectId}/preview/${asset}`; + const isVideo = VIDEO_EXT.test(asset); + const isImage = IMAGE_EXT.test(asset); + const probedDuration = useProbedDuration(serveUrl, !isVideo || duration != null); + const resolvedDuration = duration ?? probedDuration ?? undefined; + const durationLabel = formatDuration(resolvedDuration ?? 0); + + // Drag-threshold click gate: track pointer-down position so we can ignore + // pointer-up events that followed a real drag gesture. + const pointerDownRef = useRef<{ x: number; y: number } | null>(null); + + const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); + const elements = usePlayerStore((s) => s.elements); + const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset); + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + pointerDownRef.current = { x: e.clientX, y: e.clientY }; + }, []); + + 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) { + setSelectedElementId(clip.key ?? clip.id); + return; + } + } + // Not added (or no matching clip found) → preview overlay + setPreviewAsset(asset, projectId); + }, + [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset], + ); + + return ( + <> +
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 ${ + isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/40" + }`} + > + {/* Thumbnail */} +
+ {isImage && ( + {name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {isVideo && ( + <> + + {hovered && ( +
+ + {/* Filename caption */} + + {truncateMiddle(fullName, 22)} + +
+ + {contextMenu && ( + setContextMenu(null)} + onCopy={onCopy} + onDelete={onDelete} + onRename={onRename} + onAddAtPlayhead={onAddAssetToTimeline} + /> + )} + + ); +} + +export interface FontRowProps { + asset: string; + used: boolean; + onCopy: (path: string) => void; + isCopied: boolean; + onDelete?: (path: string) => void; + onRename?: (oldPath: string, newPath: string) => void; + onAddAssetToTimeline?: (path: string) => void; +} + +/** + * Compact row for font assets (no meaningful thumbnail; show ext badge + name). + */ +export function FontRow({ + asset, + used, + onCopy, + isCopied, + onDelete, + onRename, + onAddAssetToTimeline, +}: FontRowProps) { + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const name = basename(asset); + const extension = ext(asset); + + return ( + <> +
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 ${ + isCopied + ? "bg-studio-accent/10 border-l-2 border-studio-accent" + : "border-l-2 border-transparent hover:bg-neutral-800/50" + }`} + > +
+ {extension} +
+
+ + {name} + +
+ {extension} + {used && ( + + in use + + )} +
+
+
+ + {contextMenu && ( + setContextMenu(null)} + onCopy={onCopy} + onDelete={onDelete} + onRename={onRename} + onAddAtPlayhead={onAddAssetToTimeline} + /> + )} + + ); +}