refactor: unified AssetBucketBrowser replaces 4 inline AssetManagers
Deploy to VPS / deploy (push) Has been cancelled
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:
@@ -12,25 +12,10 @@ import {
|
|||||||
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions";
|
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions";
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// AssetBucketBrowser is the unified picker. The applications page uses an
|
||||||
// 🔥 ASSET MANAGER — File Browser, Upload & Folder Creation
|
// onInsert(markdownSyntax) callback, so we wrap with an adapter that maps
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// the picker's onSelect(item) into the markdown syntax the editor expects.
|
||||||
// Connects to /api/assets to browse, upload, and organize media files
|
import AssetBucketBrowser, { type SelectedAsset } from "@/components/hq/AssetBucketBrowser";
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssetManagerProps {
|
interface AssetManagerProps {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -40,326 +25,29 @@ interface AssetManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AssetManager({ slug, isOpen, onClose, onInsert }: AssetManagerProps) {
|
function AssetManager({ slug, isOpen, onClose, onInsert }: AssetManagerProps) {
|
||||||
const [currentPath, setCurrentPath] = useState("");
|
const handleSelect = (item: SelectedAsset) => {
|
||||||
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}`;
|
|
||||||
let syntax = "";
|
let syntax = "";
|
||||||
switch (item.mediaType) {
|
switch (item.mediaType) {
|
||||||
case "image": syntax = ``; break;
|
case "image": syntax = ``; break;
|
||||||
case "video": syntax = `[VIDEO:${url}]`; break;
|
case "video": syntax = `[VIDEO:${item.publicUrl}]`; break;
|
||||||
case "model": syntax = `[3D:${url}]`; break;
|
case "model": syntax = `[3D:${item.publicUrl}]`; break;
|
||||||
default: syntax = `[${item.name}](${url})`;
|
default: syntax = `[${item.name}](${item.publicUrl})`;
|
||||||
}
|
}
|
||||||
onInsert(syntax);
|
onInsert(syntax);
|
||||||
onClose();
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md">
|
<AssetBucketBrowser
|
||||||
<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"
|
slug={slug}
|
||||||
onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}>
|
scope="applications"
|
||||||
|
isOpen={isOpen}
|
||||||
{isDragging && (
|
onClose={onClose}
|
||||||
<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">
|
onSelect={handleSelect}
|
||||||
<div className="text-center">
|
accentColor="#9333EA"
|
||||||
<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 "{searchQuery}"</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// 🔥 MARKDOWN EDITOR — Rich Toolbar + Asset Manager
|
// 🔥 MARKDOWN EDITOR — Rich Toolbar + Asset Manager
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -14,216 +14,10 @@ import {
|
|||||||
import { getNodes, createNode, deleteNode, toggleNodeStatus, updateNodeCaseStudy, searchSatelliteLocation } from "./actions";
|
import { getNodes, createNode, deleteNode, toggleNodeStatus, updateNodeCaseStudy, searchSatelliteLocation } from "./actions";
|
||||||
import { getApplications } from "../applications/actions";
|
import { getApplications } from "../applications/actions";
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// AssetBucketBrowser is the unified picker — single source of truth across HQ.
|
||||||
// ASSET MANAGER — Reusable file browser for /public/cases/{slug}/
|
// Aliased to AssetManager so existing JSX call sites remain untouched.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser";
|
||||||
|
const AssetManager = AssetBucketBrowser;
|
||||||
interface AssetItem {
|
|
||||||
name: string; type: "file" | "folder"; mediaType?: string; extension?: string;
|
|
||||||
path: string; publicUrl?: string; size?: string; childCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssetManagerProps {
|
|
||||||
slug: string;
|
|
||||||
scope?: string;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSelect: (item: { name: string; publicUrl: string; mediaType: string; path: string }) => void;
|
|
||||||
accentColor?: string;
|
|
||||||
initialPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AssetManager({ slug, scope = "cases", isOpen, onClose, onSelect, accentColor = "#00F0FF", initialPath = "" }: AssetManagerProps) {
|
|
||||||
const [currentPath, setCurrentPath] = useState(initialPath);
|
|
||||||
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({ scope, 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");
|
|
||||||
} catch { setError("Connection error — check /api/assets/route.ts"); }
|
|
||||||
setIsLoading(false);
|
|
||||||
}, [scope, slug]);
|
|
||||||
|
|
||||||
useEffect(() => { if (isOpen) { fetchAssets(initialPath || currentPath); setSearchQuery(""); } }, [isOpen]); // eslint-disable-line
|
|
||||||
|
|
||||||
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", currentPath); fd.append("file", file);
|
|
||||||
const res = await fetch("/api/assets", { method: "POST", body: fd });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) { setUploadProgress("✓ " + data.file.name); await fetchAssets(currentPath); setTimeout(() => setUploadProgress(""), 2000); }
|
|
||||||
else { setUploadProgress("✗ " + data.error); setTimeout(() => setUploadProgress(""), 4000); }
|
|
||||||
} catch { setUploadProgress("✗ Failed"); setTimeout(() => setUploadProgress(""), 3000); }
|
|
||||||
setIsUploading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) Array.from(e.target.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); if (e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.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({ scope, slug, folderName: newFolderName, parentPath: currentPath }) });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) { setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); }
|
|
||||||
else alert(data.error);
|
|
||||||
} catch { alert("Connection error"); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteFile = async (filePath: string, fileName: string) => {
|
|
||||||
if (!confirm('Delete "' + fileName + '"?')) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePath }) });
|
|
||||||
const data = await res.json(); if (data.success) await fetchAssets(currentPath); else alert(data.error);
|
|
||||||
} catch { alert("Failed to delete"); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (item: AssetItem, e?: React.MouseEvent) => {
|
|
||||||
if (e) { e.preventDefault(); e.stopPropagation(); }
|
|
||||||
if (item.type === "folder") { fetchAssets(item.path); return; }
|
|
||||||
onSelect({ name: item.name, publicUrl: item.publicUrl || "/" + scope + "/" + slug + "/" + item.path, mediaType: item.mediaType || "unknown", path: item.path });
|
|
||||||
// Don't close automatically — let user select multiple files or close manually
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyPath = (item: AssetItem) => {
|
|
||||||
navigator.clipboard.writeText(item.publicUrl || "/" + scope + "/" + slug + "/" + item.path);
|
|
||||||
setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = searchQuery ? items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items;
|
|
||||||
|
|
||||||
const typeBadge = (mt?: string) => {
|
|
||||||
const s: 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" };
|
|
||||||
return s[mt || ""] || "bg-white/5 text-[#86868B] border-white/10";
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderThumb = (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} style={{ color: accentColor, opacity: 0.7 }} /></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>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
|
||||||
<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} onClick={(e) => e.stopPropagation()}>
|
|
||||||
{isDragging && (
|
|
||||||
<div className="absolute inset-0 z-50 border-2 border-dashed rounded-[2rem] flex items-center justify-center backdrop-blur-sm" style={{ borderColor: accentColor + "80", background: accentColor + "15" }}>
|
|
||||||
<div className="text-center">
|
|
||||||
<ArrowUpFromLine size={48} style={{ color: accentColor }} className="mx-auto mb-3 animate-bounce" />
|
|
||||||
<p style={{ color: accentColor }} className="font-medium text-lg">Drop files to upload</p>
|
|
||||||
<p className="text-[#86868B] text-sm mt-1">to /{scope}/{slug}/{currentPath || "root"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<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 rounded-xl" style={{ background: accentColor + "20", color: accentColor }}><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/{scope}/{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={() => fetchAssets(crumb.path)} className={`px-2 py-1 rounded-lg transition-colors text-xs ${idx === breadcrumbs.length - 1 ? "bg-white/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`} style={idx === breadcrumbs.length - 1 ? { color: accentColor } : {}}>{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" /></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" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><Grid3X3 size={14} /></button>
|
|
||||||
<button onClick={() => setViewMode("list")} className={`p-1.5 transition-colors ${viewMode === "list" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><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"><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-black rounded-lg font-medium disabled:opacity-50" style={{ background: accentColor }}><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} style={{ color: accentColor }} />
|
|
||||||
<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" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white outline-none font-mono" />
|
|
||||||
<button onClick={createFolder} className="px-3 py-1.5 text-xs text-black rounded-lg font-medium" style={{ background: accentColor }}>Create</button>
|
|
||||||
<button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B]">Cancel</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{uploadProgress && <div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2 text-xs">{isUploading && <Loader2 size={12} className="animate-spin" style={{ color: accentColor }} />}<span className={uploadProgress.startsWith("✓") ? "text-emerald-400" : uploadProgress.startsWith("✗") ? "text-red-400" : ""} style={isUploading ? { color: accentColor } : {}}>{uploadProgress}</span></div>}
|
|
||||||
</div>
|
|
||||||
<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" style={{ color: accentColor }} /></div>
|
|
||||||
: error ? <div className="flex flex-col items-center justify-center py-20 text-center"><X size={32} className="text-red-400/50 mb-3" /><p className="text-red-400/80 text-sm">{error}</p></div>
|
|
||||||
: filtered.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 matches</p> : (
|
|
||||||
<><p className="text-[#86868B] text-sm mb-2">Empty directory</p>
|
|
||||||
<div className="flex gap-2 mt-4">{["images", "videos", "models", "renders"].map(f => (
|
|
||||||
<button key={f} onClick={async () => { await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: f, parentPath: currentPath }) }); fetchAssets(currentPath); }} className="px-3 py-2 text-xs border rounded-lg" style={{ color: accentColor, borderColor: accentColor + "30" }}>+ {f}/</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">
|
|
||||||
{filtered.map(item => (
|
|
||||||
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-all cursor-pointer" onClick={(e) => handleSelect(item, e)}>
|
|
||||||
<div className="aspect-square overflow-hidden bg-black/20">{renderThumb(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">{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"><Trash2 size={11} /></button></div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">{filtered.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] cursor-pointer" onClick={(e) => handleSelect(item, e)}>
|
|
||||||
<div className="w-8 h-8 rounded-lg overflow-hidden shrink-0 border border-white/5">{renderThumb(item)}</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]">{item.childCount} items</span><ChevronRight size={14} className="text-[#86868B]/50" /></>}
|
|
||||||
{item.size && <span className="text-[10px] text-[#86868B] w-16 text-right shrink-0">{item.size}</span>}
|
|
||||||
</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>{filtered.length} items • Click to select</span><span className="font-mono">Drag & drop supported</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// MARKDOWN EDITOR — Cyan-themed for Network/Cases
|
// MARKDOWN EDITOR — Cyan-themed for Network/Cases
|
||||||
|
|||||||
@@ -14,181 +14,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getNewsArticles, createNewsArticle, updateNewsArticle, deleteNewsArticle } from "./actions";
|
import { getNewsArticles, createNewsArticle, updateNewsArticle, deleteNewsArticle } from "./actions";
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// AssetBucketBrowser is the unified picker — single source of truth across HQ.
|
||||||
// ASSET MANAGER — Reusable file browser (same as Network but for news scope)
|
// Aliased to AssetManager so existing JSX call sites remain untouched.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser";
|
||||||
|
const AssetManager = AssetBucketBrowser;
|
||||||
interface AssetItem {
|
|
||||||
name: string; type: "file" | "folder"; mediaType?: string; extension?: string;
|
|
||||||
path: string; publicUrl?: string; size?: string; childCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssetManagerProps {
|
|
||||||
slug: string; scope?: string; isOpen: boolean; onClose: () => void;
|
|
||||||
onSelect: (item: { name: string; publicUrl: string; mediaType: string; path: string }) => void;
|
|
||||||
accentColor?: string; initialPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AssetManager({ slug, scope = "news", isOpen, onClose, onSelect, accentColor = "#00F0FF", initialPath = "" }: AssetManagerProps) {
|
|
||||||
const [currentPath, setCurrentPath] = useState(initialPath);
|
|
||||||
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({ scope, 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");
|
|
||||||
} catch { setError("Connection error — check /api/assets/route.ts"); }
|
|
||||||
setIsLoading(false);
|
|
||||||
}, [scope, slug]);
|
|
||||||
|
|
||||||
useEffect(() => { if (isOpen) { fetchAssets(initialPath || currentPath); setSearchQuery(""); } }, [isOpen]); // eslint-disable-line
|
|
||||||
|
|
||||||
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", currentPath); fd.append("file", file);
|
|
||||||
const res = await fetch("/api/assets", { method: "POST", body: fd });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) { setUploadProgress("✓ " + data.file.name); await fetchAssets(currentPath); setTimeout(() => setUploadProgress(""), 2000); }
|
|
||||||
else { setUploadProgress("✗ " + data.error); setTimeout(() => setUploadProgress(""), 4000); }
|
|
||||||
} catch { setUploadProgress("✗ Failed"); setTimeout(() => setUploadProgress(""), 3000); }
|
|
||||||
setIsUploading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) Array.from(e.target.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); if (e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.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({ scope, slug, folderName: newFolderName, parentPath: currentPath }) });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) { setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); }
|
|
||||||
else alert(data.error);
|
|
||||||
} catch { alert("Connection error"); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteFile = async (filePath: string, fileName: string) => {
|
|
||||||
if (!confirm('Delete "' + fileName + '"?')) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePath }) });
|
|
||||||
const data = await res.json(); if (data.success) await fetchAssets(currentPath); else alert(data.error);
|
|
||||||
} catch { alert("Failed"); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (item: AssetItem, e?: React.MouseEvent) => {
|
|
||||||
if (e) { e.preventDefault(); e.stopPropagation(); }
|
|
||||||
if (item.type === "folder") { fetchAssets(item.path); return; }
|
|
||||||
onSelect({ name: item.name, publicUrl: item.publicUrl || "/" + scope + "/" + slug + "/" + item.path, mediaType: item.mediaType || "unknown", path: item.path });
|
|
||||||
//onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyPath = (item: AssetItem) => {
|
|
||||||
navigator.clipboard.writeText(item.publicUrl || "/" + scope + "/" + slug + "/" + item.path);
|
|
||||||
setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = searchQuery ? items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items;
|
|
||||||
const typeBadge = (mt?: string) => {
|
|
||||||
const s: 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" };
|
|
||||||
return s[mt || ""] || "bg-white/5 text-[#86868B] border-white/10";
|
|
||||||
};
|
|
||||||
const renderThumb = (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} style={{ color: accentColor, opacity: 0.7 }} /></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>;
|
|
||||||
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
|
||||||
<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} onClick={(e) => e.stopPropagation()}>
|
|
||||||
{isDragging && (
|
|
||||||
<div className="absolute inset-0 z-50 border-2 border-dashed rounded-[2rem] flex items-center justify-center backdrop-blur-sm" style={{ borderColor: accentColor + "80", background: accentColor + "15" }}>
|
|
||||||
<div className="text-center"><ArrowUpFromLine size={48} style={{ color: accentColor }} className="mx-auto mb-3 animate-bounce" /><p style={{ color: accentColor }} className="font-medium text-lg">Drop files to upload</p></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<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 rounded-xl" style={{ background: accentColor + "20", color: accentColor }}><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/{scope}/{slug}/</p></div></div>
|
|
||||||
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl"><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={() => fetchAssets(crumb.path)} className={`px-2 py-1 rounded-lg text-xs ${idx === breadcrumbs.length - 1 ? "bg-white/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`} style={idx === breadcrumbs.length - 1 ? { color: accentColor } : {}}>{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" /></div>
|
|
||||||
<div className="flex bg-white/5 rounded-lg border border-white/10 overflow-hidden">
|
|
||||||
<button onClick={() => setViewMode("grid")} className={`p-1.5 ${viewMode === "grid" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><Grid3X3 size={14} /></button>
|
|
||||||
<button onClick={() => setViewMode("list")} className={`p-1.5 ${viewMode === "list" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><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"><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-black rounded-lg font-medium disabled:opacity-50" style={{ background: accentColor }}><Upload size={13} /> Upload</button>
|
|
||||||
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.mp4,.webm,.mov,.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} style={{ color: accentColor }} /><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" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white outline-none font-mono" /><button onClick={createFolder} className="px-3 py-1.5 text-xs text-black rounded-lg font-medium" style={{ background: accentColor }}>Create</button><button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B]">Cancel</button></div>)}
|
|
||||||
{uploadProgress && <div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2 text-xs">{isUploading && <Loader2 size={12} className="animate-spin" style={{ color: accentColor }} />}<span className={uploadProgress.startsWith("✓") ? "text-emerald-400" : uploadProgress.startsWith("✗") ? "text-red-400" : ""} style={isUploading ? { color: accentColor } : {}}>{uploadProgress}</span></div>}
|
|
||||||
</div>
|
|
||||||
<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" style={{ color: accentColor }} /></div>
|
|
||||||
: error ? <div className="flex flex-col items-center justify-center py-20 text-center"><X size={32} className="text-red-400/50 mb-3" /><p className="text-red-400/80 text-sm">{error}</p></div>
|
|
||||||
: filtered.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 matches</p> : <><p className="text-[#86868B] text-sm mb-2">Empty directory</p><div className="flex gap-2 mt-4">{["images", "gallery"].map(f => (<button key={f} onClick={async () => { await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: f, parentPath: currentPath }) }); fetchAssets(currentPath); }} className="px-3 py-2 text-xs border rounded-lg" style={{ color: accentColor, borderColor: accentColor + "30" }}>+ {f}/</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">
|
|
||||||
{filtered.map(item => (
|
|
||||||
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-all cursor-pointer" onClick={(e) => handleSelect(item, e)}>
|
|
||||||
<div className="aspect-square overflow-hidden bg-black/20">{renderThumb(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">{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"><Trash2 size={11} /></button></div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">{filtered.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] cursor-pointer" onClick={(e) => handleSelect(item, e)}>
|
|
||||||
<div className="w-8 h-8 rounded-lg overflow-hidden shrink-0 border border-white/5">{renderThumb(item)}</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]">{item.childCount} items</span><ChevronRight size={14} className="text-[#86868B]/50" /></>}
|
|
||||||
{item.size && <span className="text-[10px] text-[#86868B] w-16 text-right shrink-0">{item.size}</span>}
|
|
||||||
</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>{filtered.length} items • Click to select</span><span className="font-mono">Drag & drop</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// MARKDOWN EDITOR — Cyan-themed for News articles
|
// MARKDOWN EDITOR — Cyan-themed for News articles
|
||||||
|
|||||||
@@ -12,117 +12,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getParts, createPart, updatePart, deletePart, togglePartStatus, getPartsCatalogHero, updatePartsCatalogHero } from "./actions";
|
import { getParts, createPart, updatePart, deletePart, togglePartStatus, getPartsCatalogHero, updatePartsCatalogHero } from "./actions";
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// AssetBucketBrowser is the unified picker — single source of truth across HQ.
|
||||||
// ASSET MANAGER — Reusable (scope=parts, /public/parts/{sku}/)
|
// Aliased to AssetManager so existing JSX call sites remain untouched.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser";
|
||||||
|
const AssetManager = AssetBucketBrowser;
|
||||||
interface AssetItem { name: string; type: "file"|"folder"; mediaType?: string; extension?: string; path: string; publicUrl?: string; size?: string; childCount?: number; }
|
|
||||||
interface AssetManagerProps { slug: string; scope?: string; isOpen: boolean; onClose: () => void; onSelect: (item: { name: string; publicUrl: string; mediaType: string; path: string }) => void; accentColor?: string; initialPath?: string; }
|
|
||||||
|
|
||||||
function AssetManager({ slug, scope = "parts", isOpen, onClose, onSelect, accentColor = "#f59e0b", initialPath = "" }: AssetManagerProps) {
|
|
||||||
const [currentPath, setCurrentPath] = useState(initialPath);
|
|
||||||
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({ scope, 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);
|
|
||||||
} catch { setError("Connection error"); }
|
|
||||||
setIsLoading(false);
|
|
||||||
}, [scope, slug]);
|
|
||||||
|
|
||||||
useEffect(() => { if (isOpen) { fetchAssets(initialPath || currentPath); setSearchQuery(""); } }, [isOpen]); // eslint-disable-line
|
|
||||||
|
|
||||||
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", currentPath); fd.append("file", file);
|
|
||||||
const res = await fetch("/api/assets", { method: "POST", body: fd });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) { setUploadProgress("✓ " + data.file.name); await fetchAssets(currentPath); setTimeout(() => setUploadProgress(""), 2000); }
|
|
||||||
else { setUploadProgress("✗ " + data.error); setTimeout(() => setUploadProgress(""), 4000); }
|
|
||||||
} catch { setUploadProgress("✗ Failed"); setTimeout(() => setUploadProgress(""), 3000); }
|
|
||||||
setIsUploading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) Array.from(e.target.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); if (e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.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({ scope, slug, folderName: newFolderName, parentPath: currentPath }) }); const data = await res.json(); if (data.success) { setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); } else alert(data.error); } catch { alert("Error"); } };
|
|
||||||
const deleteFile = async (fp: string, fn: string) => { if (!confirm('Delete "'+fn+'"?')) return; try { const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePath: fp }) }); const data = await res.json(); if (data.success) await fetchAssets(currentPath); else alert(data.error); } catch { alert("Failed"); } };
|
|
||||||
|
|
||||||
const handleSelect = (item: AssetItem, e?: React.MouseEvent) => {
|
|
||||||
if (e) { e.preventDefault(); e.stopPropagation(); }
|
|
||||||
if (item.type === "folder") { fetchAssets(item.path); return; }
|
|
||||||
onSelect({ name: item.name, publicUrl: item.publicUrl || "/"+scope+"/"+slug+"/"+item.path, mediaType: item.mediaType || "unknown", path: item.path });
|
|
||||||
//onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyPath = (item: AssetItem) => { navigator.clipboard.writeText(item.publicUrl || "/"+scope+"/"+slug+"/"+item.path); setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500); };
|
|
||||||
const filtered = searchQuery ? items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items;
|
|
||||||
const typeBadge = (mt?: 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" }[mt || ""] || "bg-white/5 text-[#86868B] border-white/10");
|
|
||||||
const renderThumb = (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} style={{ color: accentColor, opacity: 0.7 }} /></div>;
|
|
||||||
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
|
|
||||||
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
|
|
||||||
};
|
|
||||||
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" onClick={e => { e.preventDefault(); e.stopPropagation(); }}>
|
|
||||||
<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} onClick={e => e.stopPropagation()}>
|
|
||||||
{isDragging && <div className="absolute inset-0 z-50 border-2 border-dashed rounded-[2rem] flex items-center justify-center backdrop-blur-sm" style={{ borderColor: accentColor+"80", background: accentColor+"15" }}><div className="text-center"><ArrowUpFromLine size={48} style={{ color: accentColor }} className="mx-auto mb-3 animate-bounce" /><p style={{ color: accentColor }} className="font-medium text-lg">Drop files</p></div></div>}
|
|
||||||
<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 rounded-xl" style={{ background: accentColor+"20", color: accentColor }}><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/{scope}/{slug}/</p></div></div>
|
|
||||||
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl"><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((c, i) => (<span key={i} className="flex items-center shrink-0">{i > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}<button onClick={() => fetchAssets(c.path)} className={`px-2 py-1 rounded-lg text-xs ${i === breadcrumbs.length-1 ? "bg-white/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`} style={i === breadcrumbs.length-1 ? { color: accentColor } : {}}>{c.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" /></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"><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-black rounded-lg font-medium disabled:opacity-50" style={{ background: accentColor }}><Upload size={13} /> Upload</button>
|
|
||||||
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.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} style={{ color: accentColor }} /><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" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white outline-none font-mono" /><button onClick={createFolder} className="px-3 py-1.5 text-xs text-black rounded-lg font-medium" style={{ background: accentColor }}>Create</button><button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B]">Cancel</button></div>}
|
|
||||||
{uploadProgress && <div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2 text-xs">{isUploading && <Loader2 size={12} className="animate-spin" style={{ color: accentColor }} />}<span className={uploadProgress.startsWith("✓") ? "text-emerald-400" : uploadProgress.startsWith("✗") ? "text-red-400" : ""} style={isUploading ? { color: accentColor } : {}}>{uploadProgress}</span></div>}
|
|
||||||
</div>
|
|
||||||
<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" style={{ color: accentColor }} /></div>
|
|
||||||
: error ? <div className="text-center py-20"><X size={32} className="text-red-400/50 mx-auto mb-3" /><p className="text-red-400/80 text-sm">{error}</p></div>
|
|
||||||
: filtered.length === 0 ? <div className="text-center py-20"><FolderOpen size={48} className="text-[#86868B]/20 mx-auto mb-4" />{searchQuery ? <p className="text-[#86868B] text-sm">No matches</p> : <p className="text-[#86868B] text-sm">Empty — upload product photos here</p>}</div>
|
|
||||||
: <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">{filtered.map(item => (
|
|
||||||
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 cursor-pointer" onClick={e => handleSelect(item, e)}>
|
|
||||||
<div className="aspect-square overflow-hidden bg-black/20">{renderThumb(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"><button onClick={e => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 rounded-lg text-[#86868B] hover:text-white">{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 rounded-lg text-[#86868B] hover:text-red-400"><Trash2 size={11} /></button></div>}
|
|
||||||
</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>{filtered.length} items • Click to select</span><span className="font-mono">Drag & drop</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// MARKDOWN EDITOR — Amber-themed for Parts descriptions
|
// MARKDOWN EDITOR — Amber-themed for Parts descriptions
|
||||||
|
|||||||
@@ -0,0 +1,628 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// AssetBucketBrowser — single component used everywhere in HQ Command for
|
||||||
|
// uploading and selecting files. Replaces the four ad-hoc AssetManager
|
||||||
|
// implementations that lived inline in network/news/applications/parts pages.
|
||||||
|
//
|
||||||
|
// Key design:
|
||||||
|
// • The on-disk path layout is UNCHANGED. We don't rename or move existing
|
||||||
|
// files. Buckets are pure UI groupings on top of the existing
|
||||||
|
// /{scope}/{slug}/[subdir]/<file> convention that the public-facing
|
||||||
|
// pages already render from.
|
||||||
|
// • Drop a file into the "Videos" bucket → POST to /api/assets with
|
||||||
|
// path=videos. Drop into "Media" → path="" (root). That's it.
|
||||||
|
// • Same prop signature as the previous AssetManager, so callers can swap
|
||||||
|
// <AssetManager .../> for <AssetBucketBrowser .../> with no other changes.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
X, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine,
|
||||||
|
Grid3X3, LayoutList, Copy, Check, Image as ImageIcon, Video, Box,
|
||||||
|
Search, Loader2, FileText, Wrench, Trash2, Info,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
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 <input accept=...> hint, not enforced. */
|
||||||
|
accept: string;
|
||||||
|
accentColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUCKETS_BY_SCOPE: Record<Scope, BucketDef[]> = {
|
||||||
|
cases: [
|
||||||
|
{
|
||||||
|
id: "media",
|
||||||
|
path: "",
|
||||||
|
label: "Media",
|
||||||
|
description: "Cover, gallery, 3D models — files at the case root",
|
||||||
|
icon: ImageIcon,
|
||||||
|
accept: "image/*,.glb,.gltf,.usdz",
|
||||||
|
accentColor: "#00F0FF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "videos",
|
||||||
|
path: "videos",
|
||||||
|
label: "Videos",
|
||||||
|
description: "MP4 clips of the installation in operation",
|
||||||
|
icon: Video,
|
||||||
|
accept: "video/*",
|
||||||
|
accentColor: "#4DA6FF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maps a file extension to a soft-warning if it doesn't fit the bucket's
|
||||||
|
// suggested accept. Returns null when the file is fine.
|
||||||
|
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 === "media" && !isImage && !isVideo && !isModel) return "Most pages expect images, videos or 3D model files here.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetBucketBrowserProps {
|
||||||
|
slug: string;
|
||||||
|
scope?: Scope;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (item: SelectedAsset) => void;
|
||||||
|
accentColor?: string;
|
||||||
|
initialPath?: string;
|
||||||
|
/** Optional title override */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssetBucketBrowser({
|
||||||
|
slug,
|
||||||
|
scope = "cases",
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
accentColor = "#00F0FF",
|
||||||
|
initialPath = "",
|
||||||
|
title,
|
||||||
|
}: AssetBucketBrowserProps) {
|
||||||
|
const buckets = BUCKETS_BY_SCOPE[scope] || BUCKETS_BY_SCOPE.cases;
|
||||||
|
|
||||||
|
// Pick the bucket whose path matches initialPath; default to the first.
|
||||||
|
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<AssetItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState("");
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
|
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Reset to the initial bucket whenever the modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setActiveBucketId(defaultBucketId);
|
||||||
|
setSearchQuery("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [isOpen, defaultBucketId]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
const res = await fetch("/api/assets", { method: "POST", body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setUploadProgress(`✓ ${data.file.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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Delete ─────────────────────────────────────────────────────
|
||||||
|
const deleteFile = async (file: AssetItem) => {
|
||||||
|
if (!confirm(`Delete "${file.name}"? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/assets", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ scope, slug, filePath: file.path }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) await fetchItems();
|
||||||
|
else alert(data.error || "Delete failed");
|
||||||
|
} catch {
|
||||||
|
alert("Delete failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────
|
||||||
|
const handleSelect = (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;
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-6xl h-[80vh] bg-[#111] border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ ["--accent" as any]: accentColor }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FolderOpen size={18} style={{ color: accentColor }} />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-white">{title || "Assets"}</div>
|
||||||
|
<div className="text-[10px] text-[#86868B] font-mono">/{scope}/{slug}{activeBucket.path ? `/${activeBucket.path}` : ""}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/5 text-[#86868B] hover:text-white transition-colors">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bucket tabs */}
|
||||||
|
<div className="flex gap-1 px-6 pt-4 border-b border-white/5">
|
||||||
|
{buckets.map((b) => {
|
||||||
|
const Icon = b.icon;
|
||||||
|
const isActive = activeBucketId === b.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
onClick={() => setActiveBucketId(b.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-all -mb-px ${
|
||||||
|
isActive
|
||||||
|
? "border-[var(--accent)] text-white"
|
||||||
|
: "border-transparent text-[#86868B] hover:text-white hover:bg-white/[0.02]"
|
||||||
|
}`}
|
||||||
|
title={b.description}
|
||||||
|
>
|
||||||
|
<Icon size={14} style={{ color: isActive ? b.accentColor : undefined }} />
|
||||||
|
{b.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bucket helper */}
|
||||||
|
<div className="flex items-center gap-3 px-6 py-3 bg-white/[0.02] border-b border-white/5 text-xs text-[#86868B]">
|
||||||
|
<Info size={12} className="flex-shrink-0" style={{ color: activeBucket.accentColor }} />
|
||||||
|
<span className="leading-relaxed">{activeBucket.description}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-2 px-6 py-3 border-b border-white/5 flex-shrink-0">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#86868B]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("grid")}
|
||||||
|
className={`p-2 rounded-lg ${viewMode === "grid" ? "bg-white/10 text-white" : "text-[#86868B] hover:bg-white/5"}`}
|
||||||
|
title="Grid view"
|
||||||
|
>
|
||||||
|
<Grid3X3 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
className={`p-2 rounded-lg ${viewMode === "list" ? "bg-white/10 text-white" : "text-[#86868B] hover:bg-white/5"}`}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<LayoutList size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={activeBucket.accept}
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
handleFiles(e.target.files);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="inline-flex items-center gap-2 bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25 px-3 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drop zone + items */}
|
||||||
|
<div
|
||||||
|
onDragEnter={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDragLeave={() => setIsDragging(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
className={`flex-1 overflow-y-auto px-6 py-4 transition-colors ${
|
||||||
|
isDragging ? "bg-[var(--accent)]/5 border-2 border-dashed border-[var(--accent)]/30" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{uploadProgress && (
|
||||||
|
<div className="mb-3 px-4 py-2.5 rounded-lg bg-white/[0.04] text-xs text-white border border-white/10 inline-block">
|
||||||
|
{uploadProgress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-[#86868B] text-sm">
|
||||||
|
<Loader2 size={14} className="animate-spin mr-2" /> Loading…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-12 text-rose-400 text-sm">{error}</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-[#86868B] text-sm">
|
||||||
|
{searchQuery ? (
|
||||||
|
<>No files match "{searchQuery}".</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<activeBucket.icon size={32} className="mx-auto opacity-40" />
|
||||||
|
<p>No files in {activeBucket.label} yet.</p>
|
||||||
|
<p className="text-xs">Drop a file here or click Upload.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : viewMode === "grid" ? (
|
||||||
|
<GridView
|
||||||
|
items={filtered}
|
||||||
|
accent={accentColor}
|
||||||
|
copiedPath={copiedPath}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onCopy={copyPath}
|
||||||
|
onDelete={deleteFile}
|
||||||
|
hint={(name: string) => bucketHint(activeBucket, name)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ListView
|
||||||
|
items={filtered}
|
||||||
|
accent={accentColor}
|
||||||
|
copiedPath={copiedPath}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onCopy={copyPath}
|
||||||
|
onDelete={deleteFile}
|
||||||
|
hint={(name: string) => bucketHint(activeBucket, name)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Grid view ─────────────────────────────────────────────────────────
|
||||||
|
function GridView({
|
||||||
|
items, accent, copiedPath, onSelect, onCopy, onDelete, hint,
|
||||||
|
}: {
|
||||||
|
items: AssetItem[];
|
||||||
|
accent: string;
|
||||||
|
copiedPath: string | null;
|
||||||
|
onSelect: (i: AssetItem) => void;
|
||||||
|
onCopy: (i: AssetItem) => void;
|
||||||
|
onDelete: (i: AssetItem) => void;
|
||||||
|
hint: (name: string) => string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||||
|
{items.map((item) => {
|
||||||
|
const warning = hint(item.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.path}
|
||||||
|
className="group bg-white/[0.02] border border-white/5 hover:border-white/15 rounded-xl overflow-hidden transition-all"
|
||||||
|
>
|
||||||
|
<button onClick={() => onSelect(item)} className="block w-full aspect-video bg-black overflow-hidden">
|
||||||
|
{item.mediaType === "image" && item.publicUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform" loading="lazy" />
|
||||||
|
) : item.mediaType === "video" ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-blue-500/5">
|
||||||
|
<Video size={28} className="text-blue-400/60" />
|
||||||
|
</div>
|
||||||
|
) : item.mediaType === "model" ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-purple-500/5">
|
||||||
|
<Box size={28} className="text-purple-400/60" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<File size={28} className="text-[#86868B]/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="p-2.5">
|
||||||
|
<div className="text-[11px] text-white truncate font-medium">{item.name}</div>
|
||||||
|
<div className="flex items-center justify-between mt-1.5">
|
||||||
|
<span className="text-[9px] text-[#86868B]">{item.size}</span>
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onCopy(item); }}
|
||||||
|
className="p-1 rounded text-[#86868B] hover:text-white hover:bg-white/5"
|
||||||
|
title="Copy URL"
|
||||||
|
>
|
||||||
|
{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(item); }}
|
||||||
|
className="p-1 rounded text-rose-400 hover:bg-rose-500/10"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{warning && (
|
||||||
|
<div className="text-[9px] text-amber-400/80 mt-1 leading-tight">{warning}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List view ─────────────────────────────────────────────────────────
|
||||||
|
function ListView({
|
||||||
|
items, accent, copiedPath, onSelect, onCopy, onDelete, hint,
|
||||||
|
}: {
|
||||||
|
items: AssetItem[];
|
||||||
|
accent: string;
|
||||||
|
copiedPath: string | null;
|
||||||
|
onSelect: (i: AssetItem) => void;
|
||||||
|
onCopy: (i: AssetItem) => void;
|
||||||
|
onDelete: (i: AssetItem) => void;
|
||||||
|
hint: (name: string) => string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.path}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/[0.02] group"
|
||||||
|
>
|
||||||
|
<button onClick={() => onSelect(item)} className="flex-shrink-0 w-12 h-12 rounded bg-black overflow-hidden">
|
||||||
|
{item.mediaType === "image" && item.publicUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />
|
||||||
|
) : item.mediaType === "video" ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={16} className="text-blue-400/60" /></div>
|
||||||
|
) : item.mediaType === "model" ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-purple-500/5"><Box size={16} className="text-purple-400/60" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center"><File size={16} className="text-[#86868B]/50" /></div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<button onClick={() => onSelect(item)} className="block text-left">
|
||||||
|
<div className="text-xs text-white font-medium truncate">{item.name}</div>
|
||||||
|
<div className="text-[10px] text-[#86868B]">{item.size}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => onCopy(item)}
|
||||||
|
className="p-1.5 rounded text-[#86868B] hover:text-white hover:bg-white/5"
|
||||||
|
title="Copy URL"
|
||||||
|
>
|
||||||
|
{copiedPath === item.path ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(item)}
|
||||||
|
className="p-1.5 rounded text-rose-400 hover:bg-rose-500/10"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user