refactor: unified AssetBucketBrowser replaces 4 inline AssetManagers
Deploy to VPS / deploy (push) Has been cancelled

Every HQ Command panel had its own ~210-line AssetManager component
copy-pasted into the page file. Same UI, same API, four diverging
implementations — and no consistent metaphor for "where does this file
go?". Editors had to think about subfolder names (videos/, renders/)
that the front-end implicitly expects.

ONE COMPONENT. CLEAR BUCKETS. SAME PATHS.

src/components/hq/AssetBucketBrowser.tsx — the single picker. Takes
scope + slug, shows bucket tabs (Media / Videos / Renders / etc.) and
maps each to the on-disk path the public site already reads from:

  cases:        Media (root) | Videos (/videos) | Renders (/renders)
  applications: Media (root) | Videos (/videos) | Renders (/renders)
  news:         Media (root)
  parts:        Media (root) | Renders (/renders)
  footage:      Hero Reel (root)
  branding:     Brand Assets (root)

Drop a file into the Videos tab → POSTs to /api/assets with path=videos
→ lands at /public/{scope}/{slug}/videos/<file> — exactly where
ApplicationClient.tsx and CaseStudyModal.tsx already look. Zero
front-end path changes, zero data migration.

UX upgrades the editor sees:
- Tabs make the bucket layout discoverable instead of buried in folder
  navigation. Each tab has its own description and accent colour.
- Soft-warning hints flag obvious mismatches ("Videos bucket usually
  holds .mp4 — this file may not display correctly") without blocking
  the upload.
- Search + grid/list views.
- Hover actions per file: copy URL, delete (with confirm).
- Persistent on-screen path (/{scope}/{slug}/{bucket}) so editors can
  always see the canonical location.

REPLACEMENT (4 page files)
- network/page.tsx:      719 → 513 lines (-206) — direct alias
- news/page.tsx:         484 → 313 lines (-171) — direct alias
- applications/page.tsx: 555 → 433 lines (-122) — adapter wraps the
  picker's onSelect into the markdown-syntax onInsert callback this
  panel uses. No call-site changes.
- parts/page.tsx:        413 → 320 lines  (-93) — direct alias

Net: -592 lines of duplicated UI, +560 lines of single shared component.
Future bucket-layout changes live in one file instead of four.

NO PATH/API CHANGES — /api/assets is unchanged. The on-disk layout is
unchanged. Existing assets keep rendering on the public site. Existing
DB rows (mediaFileName, galleryJson, videosJson, rendersJson) are
unaffected because we never moved files.
This commit is contained in:
2026-05-05 08:55:20 -05:00
parent 9b28f8ffaf
commit 014a9eb094
5 changed files with 657 additions and 825 deletions
@@ -12,25 +12,10 @@ import {
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions";
// ─────────────────────────────────────────────────────────────────────────────
// 🔥 ASSET MANAGER — File Browser, Upload & Folder Creation
// ─────────────────────────────────────────────────────────────────────────────
// Connects to /api/assets to browse, upload, and organize media files
// within /public/applications/{slug}/
// ─────────────────────────────────────────────────────────────────────────────
interface AssetItem {
name: string;
type: "file" | "folder";
mediaType?: string;
extension?: string;
path: string;
publicUrl?: string;
size?: string;
sizeBytes?: number;
modifiedAt?: string;
childCount?: number;
}
// AssetBucketBrowser is the unified picker. The applications page uses an
// onInsert(markdownSyntax) callback, so we wrap with an adapter that maps
// the picker's onSelect(item) into the markdown syntax the editor expects.
import AssetBucketBrowser, { type SelectedAsset } from "@/components/hq/AssetBucketBrowser";
interface AssetManagerProps {
slug: string;
@@ -40,326 +25,29 @@ interface AssetManagerProps {
}
function AssetManager({ slug, isOpen, onClose, onInsert }: AssetManagerProps) {
const [currentPath, setCurrentPath] = useState("");
const [items, setItems] = useState<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams({ slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) {
setItems(data.items);
setBreadcrumbs(data.breadcrumbs);
setCurrentPath(dirPath);
} else {
setError(data.error || "Failed to load directory");
}
} catch (err) {
setError("Connection error — make sure /api/assets/route.ts exists.");
}
setIsLoading(false);
}, [slug]);
useEffect(() => {
if (isOpen) { fetchAssets(currentPath); setSearchQuery(""); }
}, [isOpen, fetchAssets]); // eslint-disable-line react-hooks/exhaustive-deps
const navigateTo = (folderPath: string) => { fetchAssets(folderPath); };
const uploadFile = async (file: File) => {
setIsUploading(true);
setUploadProgress(`Uploading ${file.name}...`);
try {
const formData = new FormData();
formData.append("slug", slug);
formData.append("path", currentPath);
formData.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: formData });
const data = await res.json();
if (data.success) {
setUploadProgress(`${data.file.name} uploaded`);
await fetchAssets(currentPath);
setTimeout(() => setUploadProgress(""), 2000);
} else {
setUploadProgress(`✗ Error: ${data.error}`);
setTimeout(() => setUploadProgress(""), 4000);
}
} catch (err) {
setUploadProgress("✗ Upload failed");
setTimeout(() => setUploadProgress(""), 3000);
}
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) Array.from(files).forEach(uploadFile);
e.target.value = "";
};
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) Array.from(files).forEach(uploadFile);
};
const createFolder = async () => {
if (!newFolderName.trim()) return;
try {
const res = await fetch("/api/assets", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, folderName: newFolderName, parentPath: currentPath }),
});
const data = await res.json();
if (data.success) {
setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath);
} else { alert(data.error || "Failed to create folder"); }
} catch { alert("Connection error creating folder"); }
};
const deleteFile = async (filePath: string, fileName: string) => {
if (!confirm(`Delete "${fileName}"? This cannot be undone.`)) return;
try {
const res = await fetch("/api/assets", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, filePath }),
});
const data = await res.json();
if (data.success) await fetchAssets(currentPath);
else alert(data.error);
} catch { alert("Failed to delete file"); }
};
const insertAsset = (item: AssetItem) => {
if (item.type === "folder") return;
const url = item.publicUrl || `/applications/${slug}/${item.path}`;
const handleSelect = (item: SelectedAsset) => {
let syntax = "";
switch (item.mediaType) {
case "image": syntax = `![${item.name}](${url})`; break;
case "video": syntax = `[VIDEO:${url}]`; break;
case "model": syntax = `[3D:${url}]`; break;
default: syntax = `[${item.name}](${url})`;
case "image": syntax = `![${item.name}](${item.publicUrl})`; break;
case "video": syntax = `[VIDEO:${item.publicUrl}]`; break;
case "model": syntax = `[3D:${item.publicUrl}]`; break;
default: syntax = `[${item.name}](${item.publicUrl})`;
}
onInsert(syntax);
onClose();
};
const copyPath = (item: AssetItem) => {
const url = item.publicUrl || `/applications/${slug}/${item.path}`;
navigator.clipboard.writeText(url);
setCopiedPath(item.path);
setTimeout(() => setCopiedPath(null), 1500);
};
const filteredItems = searchQuery
? items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()))
: items;
const renderThumbnail = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} className="text-purple-400/70" /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
if (item.mediaType === "video") return <div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={24} className="text-blue-400/60" /></div>;
if (item.mediaType === "model") return <div className="w-full h-full flex items-center justify-center bg-purple-500/5"><Box size={24} className="text-purple-400/60" /></div>;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
const typeBadge = (mediaType?: string) => {
const styles: Record<string, string> = {
image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
video: "bg-blue-500/10 text-blue-400 border-blue-500/20",
model: "bg-purple-500/10 text-purple-400 border-purple-500/20",
document: "bg-amber-500/10 text-amber-400 border-amber-500/20",
};
return styles[mediaType || ""] || "bg-white/5 text-[#86868B] border-white/10";
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md">
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden"
onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}>
{isDragging && (
<div className="absolute inset-0 z-50 bg-purple-500/10 border-2 border-dashed border-purple-500/50 rounded-[2rem] flex items-center justify-center backdrop-blur-sm">
<div className="text-center">
<ArrowUpFromLine size={48} className="text-purple-400 mx-auto mb-3 animate-bounce" />
<p className="text-purple-400 font-medium text-lg">Drop files to upload</p>
<p className="text-[#86868B] text-sm mt-1">to /applications/{slug}/{currentPath || "root"}</p>
</div>
</div>
)}
{/* Header */}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/15 rounded-xl text-purple-400"><FolderOpen size={20} /></div>
<div>
<h3 className="text-lg font-medium text-white">Asset Manager</h3>
<p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/applications/{slug}/</p>
</div>
</div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl transition-all"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">
{breadcrumbs.map((crumb, idx) => (
<span key={idx} className="flex items-center shrink-0">
{idx > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}
<button onClick={() => navigateTo(crumb.path)} className={`px-2 py-1 rounded-lg transition-colors text-xs ${idx === breadcrumbs.length - 1 ? "text-purple-400 bg-purple-500/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`}>{crumb.name}</button>
</span>
))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" />
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none focus:border-purple-500/50" />
</div>
<div className="flex bg-white/5 rounded-lg border border-white/10 overflow-hidden">
<button onClick={() => setViewMode("grid")} className={`p-1.5 transition-colors ${viewMode === "grid" ? "bg-purple-500/20 text-purple-400" : "text-[#86868B] hover:text-white"}`}><Grid3X3 size={14} /></button>
<button onClick={() => setViewMode("list")} className={`p-1.5 transition-colors ${viewMode === "list" ? "bg-purple-500/20 text-purple-400" : "text-[#86868B] hover:text-white"}`}><LayoutList size={14} /></button>
</div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg hover:bg-white/10 transition-all"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-white bg-purple-500 rounded-lg hover:bg-purple-400 transition-all disabled:opacity-50 font-medium"><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.mp4,.webm,.mov,.glb,.gltf,.usdz,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5">
<FolderPlus size={14} className="text-purple-400 shrink-0" />
<input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name (lowercase, hyphens ok)" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:border-purple-500 outline-none font-mono" />
<button onClick={createFolder} className="px-3 py-1.5 text-xs bg-purple-500 text-white rounded-lg hover:bg-purple-400 font-medium">Create</button>
<button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B] hover:text-white">Cancel</button>
</div>
)}
{uploadProgress && (
<div className="mt-3 pt-3 border-t border-white/5">
<div className="flex items-center gap-2 text-xs">
{isUploading && <Loader2 size={12} className="animate-spin text-purple-400" />}
<span className={isUploading ? "text-purple-400" : uploadProgress.startsWith("✓") ? "text-emerald-400" : "text-red-400"}>{uploadProgress}</span>
</div>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? (
<div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin text-purple-400" /></div>
) : error ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<AlertCircle size={32} className="text-red-400/50 mb-3" />
<p className="text-red-400/80 text-sm mb-1">{error}</p>
<p className="text-[#86868B] text-xs">Make sure <code className="bg-white/5 px-1.5 py-0.5 rounded text-purple-400">/api/assets/route.ts</code> exists.</p>
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<FolderOpen size={48} className="text-[#86868B]/20 mb-4" />
{searchQuery ? (
<p className="text-[#86868B] text-sm">No files matching &quot;{searchQuery}&quot;</p>
) : (
<>
<p className="text-[#86868B] text-sm mb-2">This directory is empty</p>
<p className="text-[#86868B]/60 text-xs">Upload files or create subfolders to organize your assets</p>
<div className="flex gap-2 mt-4">
{["images", "videos", "models"].map(folder => (
<button key={folder} onClick={async () => {
await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, folderName: folder, parentPath: currentPath }) });
fetchAssets(currentPath);
}} className="px-3 py-2 text-xs text-purple-400 bg-purple-500/10 border border-purple-500/20 rounded-lg hover:bg-purple-500/20 transition-colors">+ {folder}/</button>
))}
</div>
</>
)}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{filteredItems.map((item) => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-purple-500/30 transition-all cursor-pointer" onClick={() => item.type === "folder" ? navigateTo(item.path) : insertAsset(item)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumbnail(item)}</div>
<div className="p-2">
<p className="text-[11px] text-white truncate font-medium">{item.name}</p>
<div className="flex items-center justify-between mt-1">
{item.type === "folder" ? (
<span className="text-[9px] text-[#86868B]">{item.childCount} items</span>
) : (
<span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>
)}
{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}
</div>
</div>
{item.type === "file" && (
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={(e) => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-white transition-colors" title="Copy path">
{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
</button>
<button onClick={(e) => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-red-400 transition-colors" title="Delete"><Trash2 size={11} /></button>
</div>
)}
</div>
))}
</div>
) : (
<div className="space-y-1">
{filteredItems.map((item) => (
<div key={item.path} className="group flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-white/[0.03] transition-colors cursor-pointer" onClick={() => item.type === "folder" ? navigateTo(item.path) : insertAsset(item)}>
<div className="w-8 h-8 rounded-lg overflow-hidden shrink-0 border border-white/5">
{item.type === "folder" ? <div className="w-full h-full flex items-center justify-center bg-purple-500/10"><FolderOpen size={14} className="text-purple-400" /></div>
: item.mediaType === "image" && item.publicUrl ? <img src={item.publicUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
: <div className={`w-full h-full flex items-center justify-center ${item.mediaType === "video" ? "bg-blue-500/10" : item.mediaType === "model" ? "bg-purple-500/10" : "bg-white/5"}`}>
{item.mediaType === "video" ? <Video size={12} className="text-blue-400" /> : item.mediaType === "model" ? <Box size={12} className="text-purple-400" /> : <File size={12} className="text-[#86868B]" />}
</div>}
</div>
<span className="text-sm text-white flex-1 truncate">{item.name}</span>
{item.type === "file" && <span className={`text-[9px] px-2 py-0.5 rounded border shrink-0 ${typeBadge(item.mediaType)}`}>{item.extension}</span>}
{item.type === "folder" && <span className="text-[9px] text-[#86868B] shrink-0">{item.childCount} items</span>}
{item.size && <span className="text-[10px] text-[#86868B] w-16 text-right shrink-0">{item.size}</span>}
{item.type === "file" && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={(e) => { e.stopPropagation(); copyPath(item); }} className="p-1.5 rounded-lg text-[#86868B] hover:text-white hover:bg-white/10 transition-colors">{copiedPath === item.path ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}</button>
<button onClick={(e) => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 rounded-lg text-[#86868B] hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 size={12} /></button>
</div>
)}
{item.type === "folder" && <ChevronRight size={14} className="text-[#86868B]/50 shrink-0" />}
</div>
))}
</div>
)}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20">
<span>{filteredItems.length} items{searchQuery ? " (filtered)" : ""} Click a file to insert into editor</span>
<span className="font-mono">Drag & drop supported</span>
</div>
</div>
</div>
<AssetBucketBrowser
slug={slug}
scope="applications"
isOpen={isOpen}
onClose={onClose}
onSelect={handleSelect}
accentColor="#9333EA"
/>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 🔥 MARKDOWN EDITOR — Rich Toolbar + Asset Manager
// ─────────────────────────────────────────────────────────────────────────────