// fallow-ignore-file code-duplication import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react"; 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 { AudioRow } from "./AudioRow"; import { GlobalAssetsView } from "./GlobalAssetsView"; import { AssetCard, FontRow } from "./AssetCard"; interface AssetsTabProps { projectId: string; assets: string[]; onImport?: (files: FileList) => void; onDelete?: (path: string) => void; onRename?: (oldPath: string, newPath: string) => void; onAddAssetToTimeline?: (path: string) => void; } export type UsageFilter = "all" | "used" | "unused"; /** Filter assets by whether the composition references them. Pure — unit-tested. */ export function filterByUsage( assets: string[], usedPaths: Set, usageFilter: UsageFilter, ): string[] { if (usageFilter === "used") return assets.filter((a) => usedPaths.has(a)); if (usageFilter === "unused") return assets.filter((a) => !usedPaths.has(a)); return assets; } /** Count used vs unused over a media set. Pure — unit-tested. */ export function countUsage( assets: string[], usedPaths: Set, ): { used: number; unused: number } { let used = 0; for (const a of assets) if (usedPaths.has(a)) used++; return { used, unused: assets.length - used }; } /** * Project-relative asset paths referenced by composition elements — the set the * "in use" badge, used-first sort, and usage filter all key on. Element src is * populated from the core runtime's `resolveNodeAssetUrl` which calls * `new URL(raw, document.baseURI).toString()`, turning authored relative paths * into fully-absolute URLs with percent-encoded characters, e.g. * "assets/my file (1).mp4" * → "http://localhost:3012/api/projects/demo/preview/assets/my%20file%20(1).mp4" * * This function normalizes every src shape to the bare project-relative path so * it matches the asset-list entries: * - Absolute URL → strip origin + /api/projects//preview/ prefix, decode %XX * - Server-relative /api/…preview/… → same strip + decode * - Relative "./"-prefixed or bare → strip leading ./ or / * - ?query / #hash → dropped * * Pure — unit-tested. */ export function deriveUsedPaths(elements: Array<{ src?: string }>): Set { const paths = new Set(); for (const el of elements) { if (!el.src) continue; let s = el.src; // Strip absolute origin if present (http://host/path → /path) try { const u = new URL(s); s = u.pathname + (u.search ? u.search : "") + (u.hash ? u.hash : ""); } catch { // Not a valid absolute URL — leave as-is (relative path) } s = s .replace(/^\/api\/projects\/[^/]+\/preview\//, "") // strip the dev serve prefix .replace(/^\.?\//, "") // strip leading ./ or / .split(/[?#]/)[0]; // drop query / hash // Decode percent-encoded characters (spaces, parens, etc.) so the path // matches the plain-text asset-list entries the server returns. try { s = decodeURIComponent(s); } catch { // Malformed encoding — use as-is } if (s) paths.add(s); } return paths; } export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport, onDelete, onRename, onAddAssetToTimeline, }: AssetsTabProps) { const fileInputRef = useRef(null); const [dragOver, setDragOver] = useState(false); const [copiedPath, setCopiedPath] = useState(null); const [activeFilter, setActiveFilter] = useState("all"); const [usageFilter, setUsageFilter] = useState<"all" | "used" | "unused">("all"); const [searchQuery, setSearchQuery] = useState(""); const [viewMode, setViewMode] = useState<"local" | "global">("local"); const [manifest, setManifest] = useState< Map >(new Map()); const manifest404Ref = useRef>(new Set()); const assetsKey = assets.join("|"); useEffect(() => { if (manifest404Ref.current.has(projectId)) return; let cancelled = false; fetch(`/api/projects/${projectId}/preview/.media/manifest.jsonl`) .then((r) => { if (!r.ok) { manifest404Ref.current.add(projectId); return ""; } return r.text(); }) .then((text) => { if (cancelled || !text) return; const m = new Map< string, { description?: string; duration?: number; width?: number; height?: number } >(); for (const line of text.split("\n")) { if (!line.trim()) continue; try { const rec = JSON.parse(line); if (rec.path) m.set(rec.path, rec); } catch { /* skip */ } } setManifest(m); }) .catch(() => {}); return () => { cancelled = true; }; }, [projectId, assetsKey]); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); setDragOver(false); if (e.dataTransfer.files.length) onImport?.(e.dataTransfer.files); }, [onImport], ); const handleCopyPath = useCallback(async (path: string) => { const copied = await copyTextToClipboard(path); if (copied) { setCopiedPath(path); setTimeout(() => setCopiedPath(null), 1500); } }, []); const elements = usePlayerStore((s) => s.elements); const usedPaths = useMemo(() => deriveUsedPaths(elements), [elements]); const mediaAssets = useMemo(() => { const media = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)); const all = filterByUsage(media, usedPaths, usageFilter); if (!searchQuery) return all; const q = searchQuery.toLowerCase(); return all.filter((a) => { if ( a .split("/") .pop() ?.replace(/\.[^.]*$/, "") .toLowerCase() .includes(q) ) return true; const rec = manifest.get(a); return rec?.description?.toLowerCase().includes(q); }); }, [assets, searchQuery, manifest, usageFilter, usedPaths]); const categorized = useMemo(() => { const groups: Record = { audio: [], images: [], video: [], fonts: [] }; for (const a of mediaAssets) { const cat = getCategory(a); if (cat) groups[cat].push(a); } // Sort: used assets first within each category for (const cat of FILTER_ORDER) { groups[cat].sort((a, b) => { const aUsed = usedPaths.has(a) ? 0 : 1; const bUsed = usedPaths.has(b) ? 0 : 1; return aUsed - bUsed; }); } return groups; }, [mediaAssets, usedPaths]); const counts = useMemo(() => { const c: Record = { all: mediaAssets.length }; for (const cat of FILTER_ORDER) c[cat] = categorized[cat].length; return c; }, [mediaAssets, categorized]); const usageCounts = useMemo( () => countUsage( assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)), usedPaths, ), [assets, usedPaths], ); const visibleCategories = activeFilter === "all" ? FILTER_ORDER.filter((c) => categorized[c].length > 0) : [activeFilter as MediaCategory].filter((c) => categorized[c].length > 0); return (
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={handleDrop} > {/* Header — matches design panel Section pattern */}
{/* Scope toggle */}
{(["local", "global"] as const).map((m) => ( ))}
{/* Import */} {onImport && ( <> { if (e.target.files?.length) { onImport(e.target.files); e.target.value = ""; } }} /> )} {/* 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" />
)} {/* Filter chips */} {viewMode === "local" && mediaAssets.length > 0 && (
{FILTER_ORDER.map((cat) => counts[cat] > 0 ? ( ) : null, )} {usageCounts.used > 0 && usageCounts.unused > 0 && ( <>
)}
{viewMode === "global" ? ( ) : mediaAssets.length === 0 ? (

Drop media files here

) : ( visibleCategories.map((cat) => (
{activeFilter === "all" && (

{CATEGORY_LABELS[cat]}

{categorized[cat].length}
)} {cat === "audio" && categorized[cat].map((a) => ( ))} {(cat === "images" || cat === "video") && (
{categorized[cat].map((a) => ( ))}
)} {cat === "fonts" && categorized[cat].map((a) => ( ))}
)) )}
); });