diff --git a/src/app/hq-command/dashboard/applications/page.tsx b/src/app/hq-command/dashboard/applications/page.tsx index 958b2eb..c533670 100644 --- a/src/app/hq-command/dashboard/applications/page.tsx +++ b/src/app/hq-command/dashboard/applications/page.tsx @@ -12,25 +12,10 @@ import { import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions"; -// ───────────────────────────────────────────────────────────────────────────── -// 🔥 ASSET MANAGER — File Browser, Upload & Folder Creation -// ───────────────────────────────────────────────────────────────────────────── -// Connects to /api/assets to browse, upload, and organize media files -// within /public/applications/{slug}/ -// ───────────────────────────────────────────────────────────────────────────── - -interface AssetItem { - name: string; - type: "file" | "folder"; - mediaType?: string; - extension?: string; - path: string; - publicUrl?: string; - size?: string; - sizeBytes?: number; - modifiedAt?: string; - childCount?: number; -} +// AssetBucketBrowser is the unified picker. The applications page uses an +// onInsert(markdownSyntax) callback, so we wrap with an adapter that maps +// the picker's onSelect(item) into the markdown syntax the editor expects. +import AssetBucketBrowser, { type SelectedAsset } from "@/components/hq/AssetBucketBrowser"; interface AssetManagerProps { slug: string; @@ -40,326 +25,29 @@ interface AssetManagerProps { } function AssetManager({ slug, isOpen, onClose, onInsert }: AssetManagerProps) { - const [currentPath, setCurrentPath] = useState(""); - const [items, setItems] = useState([]); - const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [isUploading, setIsUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(""); - const fileInputRef = useRef(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(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) => { - const files = e.target.files; - if (files) Array.from(files).forEach(uploadFile); - e.target.value = ""; - }; - - const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; - const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }; - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); setIsDragging(false); - const files = e.dataTransfer.files; - if (files.length > 0) Array.from(files).forEach(uploadFile); - }; - - const createFolder = async () => { - if (!newFolderName.trim()) return; - try { - const res = await fetch("/api/assets", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ slug, folderName: newFolderName, parentPath: currentPath }), - }); - const data = await res.json(); - if (data.success) { - setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); - } else { alert(data.error || "Failed to create folder"); } - } catch { alert("Connection error creating folder"); } - }; - - const deleteFile = async (filePath: string, fileName: string) => { - if (!confirm(`Delete "${fileName}"? This cannot be undone.`)) return; - try { - const res = await fetch("/api/assets", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ slug, filePath }), - }); - const data = await res.json(); - if (data.success) await fetchAssets(currentPath); - else alert(data.error); - } catch { alert("Failed to delete file"); } - }; - - const insertAsset = (item: AssetItem) => { - if (item.type === "folder") return; - const url = item.publicUrl || `/applications/${slug}/${item.path}`; + const handleSelect = (item: SelectedAsset) => { let syntax = ""; switch (item.mediaType) { - case "image": syntax = `![${item.name}](${url})`; break; - case "video": syntax = `[VIDEO:${url}]`; break; - case "model": syntax = `[3D:${url}]`; break; - default: syntax = `[${item.name}](${url})`; + case "image": syntax = `![${item.name}](${item.publicUrl})`; break; + case "video": syntax = `[VIDEO:${item.publicUrl}]`; break; + case "model": syntax = `[3D:${item.publicUrl}]`; break; + default: syntax = `[${item.name}](${item.publicUrl})`; } onInsert(syntax); onClose(); }; - - const copyPath = (item: AssetItem) => { - const url = item.publicUrl || `/applications/${slug}/${item.path}`; - navigator.clipboard.writeText(url); - setCopiedPath(item.path); - setTimeout(() => setCopiedPath(null), 1500); - }; - - const filteredItems = searchQuery - ? items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase())) - : items; - - const renderThumbnail = (item: AssetItem) => { - if (item.type === "folder") return
; - if (item.mediaType === "image" && item.publicUrl) return {item.name}; - if (item.mediaType === "video") return
; - if (item.mediaType === "model") return
; - return
; - }; - - const typeBadge = (mediaType?: string) => { - const styles: Record = { - 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 ( -
-
- - {isDragging && ( -
-
- -

Drop files to upload

-

to /applications/{slug}/{currentPath || "root"}

-
-
- )} - - {/* Header */} -
-
-
-
-
-

Asset Manager

-

/public/applications/{slug}/

-
-
- -
- -
-
- {breadcrumbs.map((crumb, idx) => ( - - {idx > 0 && } - - - ))} -
-
-
- - 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" /> -
-
- - -
- - - -
-
- - {showNewFolder && ( -
- - 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" /> - - -
- )} - - {uploadProgress && ( -
-
- {isUploading && } - {uploadProgress} -
-
- )} -
- - {/* Content */} -
- {isLoading ? ( -
- ) : error ? ( -
- -

{error}

-

Make sure /api/assets/route.ts exists.

-
- ) : filteredItems.length === 0 ? ( -
- - {searchQuery ? ( -

No files matching "{searchQuery}"

- ) : ( - <> -

This directory is empty

-

Upload files or create subfolders to organize your assets

-
- {["images", "videos", "models"].map(folder => ( - - ))} -
- - )} -
- ) : viewMode === "grid" ? ( -
- {filteredItems.map((item) => ( -
item.type === "folder" ? navigateTo(item.path) : insertAsset(item)}> -
{renderThumbnail(item)}
-
-

{item.name}

-
- {item.type === "folder" ? ( - {item.childCount} items - ) : ( - {item.mediaType} - )} - {item.size && {item.size}} -
-
- {item.type === "file" && ( -
- - -
- )} -
- ))} -
- ) : ( -
- {filteredItems.map((item) => ( -
item.type === "folder" ? navigateTo(item.path) : insertAsset(item)}> -
- {item.type === "folder" ?
- : item.mediaType === "image" && item.publicUrl ? - :
- {item.mediaType === "video" ?
} -
- {item.name} - {item.type === "file" && {item.extension}} - {item.type === "folder" && {item.childCount} items} - {item.size && {item.size}} - {item.type === "file" && ( -
- - -
- )} - {item.type === "folder" && } -
- ))} -
- )} -
- -
- {filteredItems.length} items{searchQuery ? " (filtered)" : ""} • Click a file to insert into editor - Drag & drop supported -
-
-
+ ); } - // ───────────────────────────────────────────────────────────────────────────── // 🔥 MARKDOWN EDITOR — Rich Toolbar + Asset Manager // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/app/hq-command/dashboard/network/page.tsx b/src/app/hq-command/dashboard/network/page.tsx index 04e199b..009e269 100644 --- a/src/app/hq-command/dashboard/network/page.tsx +++ b/src/app/hq-command/dashboard/network/page.tsx @@ -14,216 +14,10 @@ import { import { getNodes, createNode, deleteNode, toggleNodeStatus, updateNodeCaseStudy, searchSatelliteLocation } from "./actions"; import { getApplications } from "../applications/actions"; -// ───────────────────────────────────────────────────────────────────────────── -// ASSET MANAGER — Reusable file browser for /public/cases/{slug}/ -// ───────────────────────────────────────────────────────────────────────────── - -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([]); - const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [isUploading, setIsUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(""); - const fileInputRef = useRef(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(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) => { 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 = { 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
; - if (item.mediaType === "image" && item.publicUrl) return {item.name}; - if (item.mediaType === "video") return
; - if (item.mediaType === "model") return
; - return
; - }; - - if (!isOpen) return null; - - return ( -
{ e.preventDefault(); e.stopPropagation(); }}> -
e.stopPropagation()}> - {isDragging && ( -
-
- -

Drop files to upload

-

to /{scope}/{slug}/{currentPath || "root"}

-
-
- )} -
-
-
-
-

Asset Manager

/public/{scope}/{slug}/

-
- -
-
-
- {breadcrumbs.map((crumb, idx) => ( - - {idx > 0 && } - - - ))} -
-
-
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" />
-
- - -
- - - -
-
- {showNewFolder && ( -
- - 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" /> - - -
- )} - {uploadProgress &&
{isUploading && }{uploadProgress}
} -
-
- {isLoading ?
- : error ?

{error}

- : filtered.length === 0 ? ( -
- - {searchQuery ?

No matches

: ( - <>

Empty directory

-
{["images", "videos", "models", "renders"].map(f => ( - - ))}
- )} -
- ) : viewMode === "grid" ? ( -
- {filtered.map(item => ( -
handleSelect(item, e)}> -
{renderThumb(item)}
-

{item.name}

-
- {item.type === "folder" ? {item.childCount} items : {item.mediaType}} - {item.size && {item.size}} -
-
- {item.type === "file" &&
} -
- ))} -
- ) : ( -
{filtered.map(item => ( -
handleSelect(item, e)}> -
{renderThumb(item)}
- {item.name} - {item.type === "file" && {item.extension}} - {item.type === "folder" && <>{item.childCount} items} - {item.size && {item.size}} -
- ))}
- )} -
-
{filtered.length} items • Click to selectDrag & drop supported
-
-
- ); -} - +// AssetBucketBrowser is the unified picker — single source of truth across HQ. +// Aliased to AssetManager so existing JSX call sites remain untouched. +import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser"; +const AssetManager = AssetBucketBrowser; // ───────────────────────────────────────────────────────────────────────────── // MARKDOWN EDITOR — Cyan-themed for Network/Cases diff --git a/src/app/hq-command/dashboard/news/page.tsx b/src/app/hq-command/dashboard/news/page.tsx index e904718..d042753 100644 --- a/src/app/hq-command/dashboard/news/page.tsx +++ b/src/app/hq-command/dashboard/news/page.tsx @@ -14,181 +14,10 @@ import { } from "lucide-react"; import { getNewsArticles, createNewsArticle, updateNewsArticle, deleteNewsArticle } from "./actions"; -// ───────────────────────────────────────────────────────────────────────────── -// ASSET MANAGER — Reusable file browser (same as Network but for news scope) -// ───────────────────────────────────────────────────────────────────────────── - -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([]); - const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [isUploading, setIsUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(""); - const fileInputRef = useRef(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(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) => { 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 = { 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
; - if (item.mediaType === "image" && item.publicUrl) return {item.name}; - if (item.mediaType === "video") return
; - return
; - }; - - if (!isOpen) return null; - - return ( -
{ e.preventDefault(); e.stopPropagation(); }}> -
e.stopPropagation()}> - {isDragging && ( -
-

Drop files to upload

-
- )} -
-
-

Asset Manager

/public/{scope}/{slug}/

- -
-
-
- {breadcrumbs.map((crumb, idx) => ({idx > 0 && }))} -
-
-
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" />
-
- - -
- - - -
-
- {showNewFolder && (
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" />
)} - {uploadProgress &&
{isUploading && }{uploadProgress}
} -
-
- {isLoading ?
- : error ?

{error}

- : filtered.length === 0 ? ( -
- {searchQuery ?

No matches

: <>

Empty directory

{["images", "gallery"].map(f => ())}
} -
- ) : viewMode === "grid" ? ( -
- {filtered.map(item => ( -
handleSelect(item, e)}> -
{renderThumb(item)}
-

{item.name}

-
{item.type === "folder" ? {item.childCount} items : {item.mediaType}}{item.size && {item.size}}
-
- {item.type === "file" &&
} -
- ))} -
- ) : ( -
{filtered.map(item => ( -
handleSelect(item, e)}> -
{renderThumb(item)}
- {item.name} - {item.type === "file" && {item.extension}} - {item.type === "folder" && <>{item.childCount} items} - {item.size && {item.size}} -
- ))}
- )} -
-
{filtered.length} items • Click to selectDrag & drop
-
-
- ); -} - +// AssetBucketBrowser is the unified picker — single source of truth across HQ. +// Aliased to AssetManager so existing JSX call sites remain untouched. +import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser"; +const AssetManager = AssetBucketBrowser; // ───────────────────────────────────────────────────────────────────────────── // MARKDOWN EDITOR — Cyan-themed for News articles diff --git a/src/app/hq-command/dashboard/parts/page.tsx b/src/app/hq-command/dashboard/parts/page.tsx index 06a9e4c..c930960 100644 --- a/src/app/hq-command/dashboard/parts/page.tsx +++ b/src/app/hq-command/dashboard/parts/page.tsx @@ -12,117 +12,10 @@ import { } from "lucide-react"; import { getParts, createPart, updatePart, deletePart, togglePartStatus, getPartsCatalogHero, updatePartsCatalogHero } from "./actions"; -// ───────────────────────────────────────────────────────────────────────────── -// ASSET MANAGER — Reusable (scope=parts, /public/parts/{sku}/) -// ───────────────────────────────────────────────────────────────────────────── - -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([]); - const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [isUploading, setIsUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(""); - const fileInputRef = useRef(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(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) => { 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
; - if (item.mediaType === "image" && item.publicUrl) return {item.name}; - return
; - }; - if (!isOpen) return null; - - return ( -
{ e.preventDefault(); e.stopPropagation(); }}> -
e.stopPropagation()}> - {isDragging &&

Drop files

} -
-
-

Asset Manager

/public/{scope}/{slug}/

- -
-
-
{breadcrumbs.map((c, i) => ({i > 0 && }))}
-
-
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" />
- - - -
-
- {showNewFolder &&
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" />
} - {uploadProgress &&
{isUploading && }{uploadProgress}
} -
-
- {isLoading ?
- : error ?

{error}

- : filtered.length === 0 ?
{searchQuery ?

No matches

:

Empty — upload product photos here

}
- :
{filtered.map(item => ( -
handleSelect(item, e)}> -
{renderThumb(item)}
-

{item.name}

{item.type === "folder" ? {item.childCount} items : {item.mediaType}}{item.size && {item.size}}
- {item.type === "file" &&
} -
- ))}
} -
-
{filtered.length} items • Click to selectDrag & drop
-
-
- ); -} +// AssetBucketBrowser is the unified picker — single source of truth across HQ. +// Aliased to AssetManager so existing JSX call sites remain untouched. +import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser"; +const AssetManager = AssetBucketBrowser; // ───────────────────────────────────────────────────────────────────────────── // MARKDOWN EDITOR — Amber-themed for Parts descriptions diff --git a/src/components/hq/AssetBucketBrowser.tsx b/src/components/hq/AssetBucketBrowser.tsx new file mode 100644 index 0000000..f6ead3c --- /dev/null +++ b/src/components/hq/AssetBucketBrowser.tsx @@ -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]/ 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 +// for 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 hint, not enforced. */ + accept: string; + accentColor: string; +} + +const BUCKETS_BY_SCOPE: Record = { + 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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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(null); + + const fileInputRef = useRef(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 ( +
+
e.stopPropagation()} + style={{ ["--accent" as any]: accentColor }} + > + {/* Header */} +
+
+ +
+
{title || "Assets"}
+
/{scope}/{slug}{activeBucket.path ? `/${activeBucket.path}` : ""}
+
+
+ +
+ + {/* Bucket tabs */} +
+ {buckets.map((b) => { + const Icon = b.icon; + const isActive = activeBucketId === b.id; + return ( + + ); + })} +
+ + {/* Bucket helper */} +
+ + {activeBucket.description} +
+ + {/* Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search files…" + className="w-full bg-black/40 border border-white/10 text-white text-xs rounded-lg pl-9 pr-3 py-2 outline-none focus:border-white/30" + /> +
+ +
+ + +
+ + { + handleFiles(e.target.files); + e.target.value = ""; + }} + /> + +
+ + {/* Drop zone + items */} +
{ 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 && ( +
+ {uploadProgress} +
+ )} + + {isLoading ? ( +
+ Loading… +
+ ) : error ? ( +
{error}
+ ) : filtered.length === 0 ? ( +
+ {searchQuery ? ( + <>No files match "{searchQuery}". + ) : ( +
+ +

No files in {activeBucket.label} yet.

+

Drop a file here or click Upload.

+
+ )} +
+ ) : viewMode === "grid" ? ( + bucketHint(activeBucket, name)} + /> + ) : ( + bucketHint(activeBucket, name)} + /> + )} +
+
+
+ ); +} + +// ─── 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 ( +
+ {items.map((item) => { + const warning = hint(item.name); + return ( +
+ + +
+
{item.name}
+
+ {item.size} +
+ + +
+
+ {warning && ( +
{warning}
+ )} +
+
+ ); + })} +
+ ); +} + +// ─── 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 ( +
+ {items.map((item) => ( +
+ + +
+ +
+ +
+ + +
+
+ ))} +
+ ); +}