"use client"; // ───────────────────────────────────────────────────────────────────────────── // AssetBucketBrowser — single picker used everywhere in HQ Command. // // What's in the box: // • Bucket tabs (Media / Videos / Renders / etc.) per scope. Each tab // maps to an on-disk subfolder. Public-facing layout unchanged. // • Upload via drag-drop, file picker or paste-URL. // • Bulk select with click + Shift-click + Cmd/Ctrl-click. Bulk delete // and bulk move-to-bucket from the toolbar. // • Drag a file onto another bucket tab to MOVE it (PATCH /api/assets). // • Double-click a filename to rename in place. // • Search, grid/list views, copy URL per file. // • Toast + confirm via the global HqUiProvider — no browser popups. // ───────────────────────────────────────────────────────────────────────────── import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { X, FolderOpen, Upload, File, Grid3X3, LayoutList, Copy, Check, Image as ImageIcon, Video, Box, Search, Loader2, Trash2, Info, ArrowRightLeft, Pencil, CheckSquare, Square, } from "lucide-react"; import { useHqUi } from "@/components/hq/Toast"; export interface AssetItem { name: string; type: "file" | "folder"; mediaType?: string; extension?: string; path: string; publicUrl?: string; size?: string; childCount?: number; } export interface SelectedAsset { name: string; publicUrl: string; mediaType: string; path: string; } type Scope = "cases" | "applications" | "news" | "parts" | "footage" | "branding"; export interface BucketDef { id: string; /** Folder under /{scope}/{slug}/. Empty string means the entity root. */ path: string; label: string; description: string; icon: typeof ImageIcon; /** Suggested file types. Used for the hint, not enforced. */ accept: string; accentColor: string; } const BUCKETS_BY_SCOPE: Record = { cases: [ { id: "media", path: "", label: "Media", description: "Cover and gallery images at the case root", icon: ImageIcon, accept: "image/*", accentColor: "#00F0FF" }, { id: "videos", path: "videos", label: "Videos", description: "MP4 clips of the installation in operation", icon: Video, accept: "video/*", accentColor: "#4DA6FF" }, { id: "models", path: "models", label: "3D Models", description: "GLB/USDZ for AR viewer and 3D display", icon: Box, accept: ".glb,.gltf,.usdz", accentColor: "#A855F7" }, { id: "renders", path: "renders", label: "Renders", description: "3D rendered images of the equipment", icon: Box, accept: "image/*", accentColor: "#FF6B9D" }, ], applications: [ { id: "media", path: "", label: "Media", description: "Hero, blueprints, machines — files at the application root", icon: ImageIcon, accept: "image/*,video/*", accentColor: "#9333EA" }, { id: "videos", path: "videos", label: "Videos", description: "Demo videos for this application", icon: Video, accept: "video/*", accentColor: "#4DA6FF" }, { id: "renders", path: "renders", label: "Renders", description: "3D rendered images", icon: Box, accept: "image/*", accentColor: "#FF6B9D" }, ], news: [ { id: "media", path: "", label: "Media", description: "Cover image and gallery photos", icon: ImageIcon, accept: "image/*", accentColor: "#0A66C2" }, ], parts: [ { id: "media", path: "", label: "Media", description: "Part photos and product shots", icon: ImageIcon, accept: "image/*", accentColor: "#F59E0B" }, { id: "renders", path: "renders", label: "Renders", description: "3D renders of the component", icon: Box, accept: "image/*", accentColor: "#FF6B9D" }, ], footage: [ { id: "media", path: "", label: "Hero Reel", description: "Hero carousel images and videos", icon: ImageIcon, accept: "image/*,video/*", accentColor: "#FF6B9D" }, ], branding: [ { id: "media", path: "", label: "Brand Assets", description: "Favicons, logos, OG images", icon: ImageIcon, accept: "image/*", accentColor: "#FF6B9D" }, ], }; function bucketHint(bucket: BucketDef, fileName: string): string | null { const ext = (fileName.split(".").pop() || "").toLowerCase(); const isImage = ["jpg", "jpeg", "png", "webp", "gif", "svg", "avif"].includes(ext); const isVideo = ["mp4", "webm", "mov"].includes(ext); const isModel = ["glb", "gltf", "usdz"].includes(ext); if (bucket.id === "videos" && !isVideo) return "Videos bucket usually holds .mp4 — this file may not display correctly."; if (bucket.id === "renders" && !isImage) return "Renders bucket expects images (PNG/JPG/WebP)."; if (bucket.id === "models" && !isModel) return "Models bucket expects 3D files (.glb, .gltf, .usdz)."; if (bucket.id === "media" && !isImage && !isVideo) return "Media bucket expects images or videos here."; return null; } export interface AssetBucketBrowserProps { slug: string; scope?: Scope; isOpen: boolean; onClose: () => void; onSelect: (item: SelectedAsset) => void; accentColor?: string; initialPath?: string; title?: string; } // Shape used internally to track drag state across the modal. interface DragPayload { paths: string[]; fromBucketId: string; } export default function AssetBucketBrowser({ slug, scope = "cases", isOpen, onClose, onSelect, accentColor = "#00F0FF", initialPath = "", title, }: AssetBucketBrowserProps) { const ui = useHqUi(); const buckets = BUCKETS_BY_SCOPE[scope] || BUCKETS_BY_SCOPE.cases; const defaultBucketId = useMemo(() => { const match = buckets.find((b) => b.path === initialPath); return match ? match.id : buckets[0].id; }, [buckets, initialPath]); const [activeBucketId, setActiveBucketId] = useState(defaultBucketId); const activeBucket = buckets.find((b) => b.id === activeBucketId) || buckets[0]; const [items, setItems] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(""); const [isDragOverDropZone, setIsDragOverDropZone] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [copiedPath, setCopiedPath] = useState(null); // Selection + drag + rename state const [selected, setSelected] = useState>(new Set()); const [lastSelected, setLastSelected] = useState(null); const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null); const [moveBusy, setMoveBusy] = useState(false); const [overBucketId, setOverBucketId] = useState(null); const dragPayloadRef = useRef(null); const fileInputRef = useRef(null); useEffect(() => { if (isOpen) { setActiveBucketId(defaultBucketId); setSearchQuery(""); setError(null); setSelected(new Set()); setLastSelected(null); setRenaming(null); } }, [isOpen, defaultBucketId]); // Reset selection when switching buckets useEffect(() => { setSelected(new Set()); setLastSelected(null); setRenaming(null); }, [activeBucketId]); const fetchItems = useCallback(async () => { setIsLoading(true); setError(null); try { const params = new URLSearchParams({ scope, slug, path: activeBucket.path }); const res = await fetch(`/api/assets?${params}`); const data = await res.json(); if (data.success) setItems(data.items.filter((i: AssetItem) => i.type === "file")); else setError(data.error || "Failed to load"); } catch { setError("Connection error"); } setIsLoading(false); }, [scope, slug, activeBucket.path]); useEffect(() => { if (isOpen && slug) fetchItems(); }, [isOpen, fetchItems, slug]); // ─── Upload ───────────────────────────────────────────────────── const uploadFile = async (file: File) => { setIsUploading(true); setUploadProgress(`Uploading ${file.name}…`); try { const fd = new FormData(); fd.append("scope", scope); fd.append("slug", slug); fd.append("path", activeBucket.path); fd.append("file", file); fd.append("optimize", "1"); const res = await fetch("/api/assets", { method: "POST", body: fd }); const data = await res.json(); if (data.success) { const f = data.file; if (f.optimized && f.savedBytes > 0) { const pct = Math.round((f.savedBytes / f.originalBytes) * 100); setUploadProgress(`✓ ${f.name} (−${pct}% optimized)`); } else { setUploadProgress(`✓ ${f.name}`); } await fetchItems(); setTimeout(() => setUploadProgress(""), 1500); } else { setUploadProgress(`✗ ${data.error}`); setTimeout(() => setUploadProgress(""), 4000); } } catch (err: any) { setUploadProgress(`✗ ${err.message || "Upload failed"}`); setTimeout(() => setUploadProgress(""), 4000); } setIsUploading(false); }; const handleFiles = (files: FileList | null) => { if (!files) return; Array.from(files).forEach(uploadFile); }; // ─── Bulk delete ──────────────────────────────────────────────── const deleteFiles = async (paths: string[]) => { if (paths.length === 0) return; const ok = await ui.confirm({ title: paths.length === 1 ? "Delete file" : `Delete ${paths.length} files`, message: paths.length === 1 ? `Permanently delete "${paths[0].split("/").pop()}"? This cannot be undone.` : `Permanently delete ${paths.length} files? This cannot be undone.`, confirmLabel: "Delete", destructive: true, }); if (!ok) return; try { const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePaths: paths }), }); const data = await res.json(); if (data.success) { ui.toast( data.deleted.length === 1 ? "File deleted." : `${data.deleted.length} files deleted.`, "success" ); if (data.failed?.length > 0) ui.toast(`${data.failed.length} files failed: ${data.failed.map((f: any) => f.reason).join(", ")}`, "error"); } else { ui.toast(data.error || "Delete failed", "error"); } setSelected(new Set()); await fetchItems(); } catch (err: any) { ui.toast(err.message || "Delete failed", "error"); } }; // ─── Move (single file or bulk) ───────────────────────────────── const moveFiles = async (paths: string[], toBucketId: string) => { if (paths.length === 0) return; const target = buckets.find((b) => b.id === toBucketId); if (!target) return; setMoveBusy(true); let okCount = 0; let failCount = 0; for (const fromPath of paths) { const filename = fromPath.split("/").pop()!; const toPath = target.path ? `${target.path}/${filename}` : filename; if (fromPath === toPath) continue; try { const res = await fetch("/api/assets", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, fromPath, toPath }), }); const data = await res.json(); if (data.success) okCount++; else failCount++; } catch { failCount++; } } setMoveBusy(false); setSelected(new Set()); if (okCount > 0) ui.toast(`Moved ${okCount} file${okCount > 1 ? "s" : ""} to ${target.label}.`, "success"); if (failCount > 0) ui.toast(`${failCount} file${failCount > 1 ? "s" : ""} could not be moved (name conflict?).`, "error"); await fetchItems(); }; // ─── Rename ───────────────────────────────────────────────────── const submitRename = async () => { if (!renaming) return; const newName = renaming.value.trim(); const oldName = renaming.path.split("/").pop()!; if (!newName || newName === oldName) { setRenaming(null); return; } const cleanName = newName.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, ""); if (!cleanName) { ui.toast("Invalid filename.", "error"); return; } const dir = renaming.path.includes("/") ? renaming.path.slice(0, renaming.path.lastIndexOf("/")) : ""; const toPath = dir ? `${dir}/${cleanName}` : cleanName; try { const res = await fetch("/api/assets", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, fromPath: renaming.path, toPath }), }); const data = await res.json(); if (data.success) { ui.toast("Renamed.", "success"); setRenaming(null); await fetchItems(); } else { ui.toast(data.error || "Rename failed", "error"); } } catch (err: any) { ui.toast(err.message || "Rename failed", "error"); } }; // ─── Selection helpers ────────────────────────────────────────── const toggleSelect = (path: string, e?: React.MouseEvent) => { const next = new Set(selected); if (e?.shiftKey && lastSelected) { // Range select const ids = filtered.map((i) => i.path); const a = ids.indexOf(lastSelected); const b = ids.indexOf(path); if (a >= 0 && b >= 0) { const [start, end] = a < b ? [a, b] : [b, a]; for (let i = start; i <= end; i++) next.add(ids[i]); } } else if (e?.metaKey || e?.ctrlKey) { if (next.has(path)) next.delete(path); else next.add(path); } else { // Plain click toggles, leaves others alone if (next.has(path)) next.delete(path); else next.add(path); } setSelected(next); setLastSelected(path); }; const selectAll = () => { if (selected.size === filtered.length) setSelected(new Set()); else setSelected(new Set(filtered.map((i) => i.path))); }; // ─── Drag-between-buckets ─────────────────────────────────────── const onItemDragStart = (e: React.DragEvent, item: AssetItem) => { // If the dragged item isn't in selection, drag just that one. If it is, // drag the whole selection. let paths: string[]; if (selected.has(item.path)) paths = Array.from(selected); else { paths = [item.path]; setSelected(new Set([item.path])); } dragPayloadRef.current = { paths, fromBucketId: activeBucketId }; e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", paths.join(",")); }; const onTabDragOver = (e: React.DragEvent, bucketId: string) => { if (!dragPayloadRef.current) return; if (dragPayloadRef.current.fromBucketId === bucketId) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; setOverBucketId(bucketId); }; const onTabDragLeave = () => setOverBucketId(null); const onTabDrop = async (e: React.DragEvent, bucketId: string) => { e.preventDefault(); setOverBucketId(null); const payload = dragPayloadRef.current; dragPayloadRef.current = null; if (!payload || payload.fromBucketId === bucketId) return; await moveFiles(payload.paths, bucketId); }; // ─── Misc ──────────────────────────────────────────────────────── const handlePick = (item: AssetItem) => { onSelect({ name: item.name, publicUrl: item.publicUrl || `/${scope}/${slug}/${item.path}`, mediaType: item.mediaType || "unknown", path: item.path, }); }; const copyPath = (item: AssetItem) => { const url = item.publicUrl || `/${scope}/${slug}/${item.path}`; navigator.clipboard.writeText(url); setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500); }; const filtered = searchQuery ? items.filter((i) => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items; const otherBuckets = buckets.filter((b) => b.id !== activeBucketId); const hasSelection = selected.size > 0; if (!isOpen) return null; return (
e.stopPropagation()} style={{ ["--accent" as any]: accentColor }} > {/* Header */}
{title || "Assets"}
/{scope}/{slug}{activeBucket.path ? `/${activeBucket.path}` : ""}
{/* Bucket tabs (drop targets while dragging) */}
{buckets.map((b) => { const Icon = b.icon; const isActive = activeBucketId === b.id; const isDropTarget = overBucketId === b.id; return ( ); })}
{/* Bucket helper */}
{activeBucket.description} {moveBusy && Moving…}
{/* Toolbar — switches to bulk-action mode when items are selected */}
{hasSelection ? ( <>
{otherBuckets.length > 0 && (
Move to: {otherBuckets.map((b) => ( ))}
)} ) : ( <>
setSearchQuery(e.target.value)} placeholder="Search files…" className="w-full bg-black/40 border border-white/10 text-white text-xs rounded-lg pl-9 pr-3 py-2 outline-none focus:border-white/30" />
{ handleFiles(e.target.files); e.target.value = ""; }} /> )}
{/* Items area */}
{ e.preventDefault(); if (!dragPayloadRef.current) setIsDragOverDropZone(true); }} onDragOver={(e) => e.preventDefault()} onDragLeave={() => setIsDragOverDropZone(false)} onDrop={(e) => { e.preventDefault(); setIsDragOverDropZone(false); if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files); }} className={`flex-1 overflow-y-auto px-6 py-4 transition-colors ${ isDragOverDropZone ? "bg-[var(--accent)]/5 border-2 border-dashed border-[var(--accent)]/30" : "" }`} > {uploadProgress && (
{uploadProgress}
)} {isLoading ? (
Loading…
) : error ? (
{error}
) : filtered.length === 0 ? (
{searchQuery ? <>No files match "{searchQuery}". : (

No files in {activeBucket.label} yet.

Drop a file here or click Upload. Drag a file from another tab to move it in.

)}
) : viewMode === "grid" ? ( { if (hasSelection || e.shiftKey || e.metaKey || e.ctrlKey) toggleSelect(item.path, e); else handlePick(item); }} onToggleSelect={(item, e) => toggleSelect(item.path, e)} onStartRename={(item) => setRenaming({ path: item.path, value: item.name })} onChangeRename={(value) => setRenaming((r) => r ? { ...r, value } : r)} onSubmitRename={submitRename} onCancelRename={() => setRenaming(null)} onCopy={copyPath} onDelete={(item) => deleteFiles([item.path])} onDragStart={onItemDragStart} hint={(name: string) => bucketHint(activeBucket, name)} /> ) : ( { if (hasSelection || e.shiftKey || e.metaKey || e.ctrlKey) toggleSelect(item.path, e); else handlePick(item); }} onToggleSelect={(item, e) => toggleSelect(item.path, e)} onStartRename={(item) => setRenaming({ path: item.path, value: item.name })} onChangeRename={(value) => setRenaming((r) => r ? { ...r, value } : r)} onSubmitRename={submitRename} onCancelRename={() => setRenaming(null)} onCopy={copyPath} onDelete={(item) => deleteFiles([item.path])} onDragStart={onItemDragStart} hint={(name: string) => bucketHint(activeBucket, name)} /> )}
{/* Footer hint */}
Click open ⇧Click select range ⌘Click add to selection 2× click rename Drag onto another tab to move
); } // ─── Grid view ──────────────────────────────────────────────────────────── interface ViewProps { items: AssetItem[]; selected: Set; renaming: { path: string; value: string } | null; copiedPath: string | null; onClickItem: (item: AssetItem, e: React.MouseEvent) => void; onToggleSelect: (item: AssetItem, e: React.MouseEvent) => void; onStartRename: (item: AssetItem) => void; onChangeRename: (value: string) => void; onSubmitRename: () => void; onCancelRename: () => void; onCopy: (item: AssetItem) => void; onDelete: (item: AssetItem) => void; onDragStart: (e: React.DragEvent, item: AssetItem) => void; hint: (name: string) => string | null; } function GridView(props: ViewProps) { return (
{props.items.map((item) => { const isSel = props.selected.has(item.path); const isRen = props.renaming?.path === item.path; const warning = props.hint(item.name); return (
props.onDragStart(e, item)} className={`group bg-white/[0.02] border rounded-xl overflow-hidden transition-all ${ isSel ? "border-[#00F0FF] ring-2 ring-[#00F0FF]/30" : "border-white/5 hover:border-white/15" }`} > {/* Selection checkbox overlay */}
{isRen ? ( props.onChangeRename(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") props.onSubmitRename(); else if (e.key === "Escape") props.onCancelRename(); }} onBlur={props.onSubmitRename} className="w-full bg-black/60 border border-[#00F0FF]/40 text-white text-[11px] font-mono rounded px-2 py-1 outline-none" /> ) : (
props.onStartRename(item)} className="text-[11px] text-white truncate font-medium cursor-text" title="Double-click to rename" > {item.name}
)}
{item.size}
{warning && (
{warning}
)}
); })}
); } // ─── List view ──────────────────────────────────────────────────────────── function ListView(props: ViewProps) { return (
{props.items.map((item) => { const isSel = props.selected.has(item.path); const isRen = props.renaming?.path === item.path; return (
props.onDragStart(e, item)} className={`flex items-center gap-3 px-3 py-2 rounded-lg group ${ isSel ? "bg-[#00F0FF]/10 border border-[#00F0FF]/30" : "hover:bg-white/[0.02] border border-transparent" }`} >
{isRen ? ( props.onChangeRename(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") props.onSubmitRename(); else if (e.key === "Escape") props.onCancelRename(); }} onBlur={props.onSubmitRename} className="w-full bg-black/60 border border-[#00F0FF]/40 text-white text-xs font-mono rounded px-2 py-1 outline-none" /> ) : ( )}
); })}
); }