This commit is contained in:
@@ -0,0 +1,720 @@
|
||||
//src/app/hq-command/dashboard/network/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft, Globe, Plus, Trash2, Loader2, X, MapPin, Eye, EyeOff, Search, BookOpen, Calendar,
|
||||
Image as ImageIcon, Video, Box, Cpu, FileText, Sparkles,
|
||||
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus,
|
||||
RotateCcw, RotateCw, Maximize2, Minimize2, ChevronDown,
|
||||
FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine,
|
||||
Grid3X3, LayoutList, Copy, Check
|
||||
} from "lucide-react";
|
||||
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<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 });
|
||||
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>;
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function MarkdownEditorCyan({ name, defaultValue = "", required, rows = 10, placeholder, slug }: {
|
||||
name: string; defaultValue?: string; required?: boolean; rows?: number; placeholder?: string; slug?: string;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showInsertMenu, setShowInsertMenu] = useState(false);
|
||||
const [history, setHistory] = useState<string[]>([defaultValue]);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
const insertMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [isAssetManagerOpen, setIsAssetManagerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => { if (insertMenuRef.current && !insertMenuRef.current.contains(e.target as Node)) setShowInsertMenu(false); };
|
||||
document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h);
|
||||
}, []);
|
||||
|
||||
const historyTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const pushHistory = useCallback((v: string) => {
|
||||
if (historyTimeout.current) clearTimeout(historyTimeout.current);
|
||||
historyTimeout.current = setTimeout(() => { setHistory(p => [...p.slice(0, historyIndex + 1), v].slice(-50)); setHistoryIndex(p => Math.min(p + 1, 49)); }, 500);
|
||||
}, [historyIndex]);
|
||||
|
||||
const handleChange = (v: string) => { setValue(v); pushHistory(v); };
|
||||
const undo = () => { if (historyIndex > 0) { const i = historyIndex - 1; setHistoryIndex(i); setValue(history[i]); } };
|
||||
const redo = () => { if (historyIndex < history.length - 1) { const i = historyIndex + 1; setHistoryIndex(i); setValue(history[i]); } };
|
||||
|
||||
const getSelection = () => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return { start: 0, end: 0, selected: "", before: "", after: "" };
|
||||
return { start: ta.selectionStart, end: ta.selectionEnd, selected: value.substring(ta.selectionStart, ta.selectionEnd), before: value.substring(0, ta.selectionStart), after: value.substring(ta.selectionEnd) };
|
||||
};
|
||||
const replaceSelection = (t: string, o?: number) => {
|
||||
const { before, after } = getSelection();
|
||||
handleChange(before + t + after);
|
||||
const p = o !== undefined ? before.length + o : before.length + t.length;
|
||||
setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(p, p); } }, 0);
|
||||
};
|
||||
const wrapSelection = (pre: string, suf: string) => { const { selected } = getSelection(); if (selected) replaceSelection(pre + selected + suf); else replaceSelection(pre + "text" + suf, pre.length); };
|
||||
const insertAtCursor = (t: string, o?: number) => replaceSelection(t, o);
|
||||
const prependLine = (pre: string) => {
|
||||
const { start, selected } = getSelection();
|
||||
const ls = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const bef = value.substring(0, ls);
|
||||
const line = selected || value.substring(ls).split('\n')[0];
|
||||
const aft = value.substring(ls + line.length);
|
||||
handleChange(bef + pre + line + aft);
|
||||
setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(ls + pre.length, ls + pre.length + line.length); } }, 0);
|
||||
};
|
||||
|
||||
const handleAssetInsert = (item: { publicUrl: string; mediaType: string; name: string }) => {
|
||||
let syntax = "";
|
||||
switch (item.mediaType) {
|
||||
case "image": syntax = ""; break;
|
||||
case "video": syntax = "[VIDEO:" + item.publicUrl + "]"; break;
|
||||
case "model": syntax = "[3D:" + item.publicUrl + "]"; break;
|
||||
default: syntax = "[" + item.name + "](" + item.publicUrl + ")";
|
||||
}
|
||||
insertAtCursor("\n" + syntax + "\n");
|
||||
};
|
||||
|
||||
const basePath = "/cases/" + (slug || "slug");
|
||||
const actions = {
|
||||
bold: () => wrapSelection("**", "**"), italic: () => wrapSelection("*", "*"),
|
||||
h1: () => prependLine("# "), h2: () => prependLine("## "), h3: () => prependLine("### "),
|
||||
quote: () => prependLine("> "),
|
||||
ul: () => insertAtCursor("\n- Item 1\n- Item 2\n- Item 3\n", 3),
|
||||
ol: () => insertAtCursor("\n1. First\n2. Second\n3. Third\n", 4),
|
||||
hr: () => insertAtCursor("\n---\n"),
|
||||
table: () => insertAtCursor("\n| Column A | Column B | Highlight |\n|---|---|---|\n| Data 1 | Data 2 | Value |\n", 2),
|
||||
image: () => insertAtCursor("\n\n", 3),
|
||||
video: () => insertAtCursor("\n[VIDEO:" + basePath + "/videos/clip.mp4]\n", 8),
|
||||
model3d: () => insertAtCursor("\n[3D:" + basePath + "/models/machine.glb]\n", 5),
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const m = e.metaKey || e.ctrlKey;
|
||||
if (m && e.key === 'b') { e.preventDefault(); actions.bold(); }
|
||||
if (m && e.key === 'i') { e.preventDefault(); actions.italic(); }
|
||||
if (m && e.key === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
|
||||
if (e.key === 'Tab') { e.preventDefault(); insertAtCursor(" "); }
|
||||
};
|
||||
|
||||
const ToolBtn = ({ icon: Icon, label, onClick, className = "" }: { icon: any; label: string; onClick: () => void; className?: string }) => (
|
||||
<button type="button" onClick={onClick} title={label} className={"p-1.5 rounded-lg text-[#86868B] hover:text-white hover:bg-white/10 transition-all active:scale-90 " + className}><Icon size={15} strokeWidth={2} /></button>
|
||||
);
|
||||
const Divider = () => <div className="w-px h-5 bg-white/10 mx-1" />;
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col " + (isExpanded ? "fixed inset-0 z-[60] bg-[#0A0A0A] p-6" : "")}>
|
||||
<input type="hidden" name={name} value={value} />
|
||||
<div className={"flex flex-wrap items-center gap-0.5 px-2 py-1.5 bg-black/60 border border-white/10 rounded-t-xl " + (isExpanded ? "border-b-0 rounded-t-2xl" : "")}>
|
||||
<ToolBtn icon={Bold} label="Bold" onClick={actions.bold} />
|
||||
<ToolBtn icon={Italic} label="Italic" onClick={actions.italic} />
|
||||
<Divider />
|
||||
<ToolBtn icon={Heading1} label="H1" onClick={actions.h1} />
|
||||
<ToolBtn icon={Heading2} label="H2" onClick={actions.h2} />
|
||||
<ToolBtn icon={Heading3} label="H3" onClick={actions.h3} />
|
||||
<Divider />
|
||||
<ToolBtn icon={List} label="Bullet" onClick={actions.ul} />
|
||||
<ToolBtn icon={ListOrdered} label="Numbered" onClick={actions.ol} />
|
||||
<ToolBtn icon={Quote} label="Quote" onClick={actions.quote} />
|
||||
<ToolBtn icon={Table} label="Table" onClick={actions.table} />
|
||||
<ToolBtn icon={Minus} label="HR" onClick={actions.hr} />
|
||||
<Divider />
|
||||
<div className="relative" ref={insertMenuRef}>
|
||||
<button type="button" onClick={() => setShowInsertMenu(!showInsertMenu)} className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-[#00F0FF] hover:bg-[#00F0FF]/10 transition-all text-[11px] font-semibold uppercase tracking-wider">
|
||||
<Plus size={13} /> Insert <ChevronDown size={11} className={"transition-transform " + (showInsertMenu ? "rotate-180" : "")} />
|
||||
</button>
|
||||
{showInsertMenu && (
|
||||
<div className="absolute top-full left-0 mt-1 w-56 bg-[#1A1A1A] border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden">
|
||||
<div className="p-1">
|
||||
<button type="button" onClick={() => { actions.image(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-emerald-500/15 rounded-lg text-emerald-400"><ImageIcon size={14} /></div><div><p className="text-xs font-medium">Image</p><p className="text-[10px] text-[#86868B]">.jpg .png .webp</p></div></button>
|
||||
<button type="button" onClick={() => { actions.video(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-blue-500/15 rounded-lg text-blue-400"><Video size={14} /></div><div><p className="text-xs font-medium">Video</p><p className="text-[10px] text-[#86868B]">.mp4 local</p></div></button>
|
||||
<button type="button" onClick={() => { actions.model3d(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-purple-500/15 rounded-lg text-purple-400"><Box size={14} /></div><div><p className="text-xs font-medium">3D Model</p><p className="text-[10px] text-[#86868B]">.glb / .usdz</p></div></button>
|
||||
<div className="border-t border-white/5 mt-1 pt-1">
|
||||
<button type="button" onClick={() => { actions.table(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-amber-500/15 rounded-lg text-amber-400"><Table size={14} /></div><div><p className="text-xs font-medium">Data Table</p><p className="text-[10px] text-[#86868B]">Auto-highlight</p></div></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{slug && (<><Divider /><button type="button" onClick={() => setIsAssetManagerOpen(true)} className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10 transition-all text-[11px] font-semibold uppercase tracking-wider"><FolderOpen size={14} /> Assets</button></>)}
|
||||
<div className="flex-1" />
|
||||
<ToolBtn icon={RotateCcw} label="Undo" onClick={undo} className={historyIndex <= 0 ? "opacity-30 pointer-events-none" : ""} />
|
||||
<ToolBtn icon={RotateCw} label="Redo" onClick={redo} className={historyIndex >= history.length - 1 ? "opacity-30 pointer-events-none" : ""} />
|
||||
<Divider />
|
||||
<ToolBtn icon={isExpanded ? Minimize2 : Maximize2} label="Fullscreen" onClick={() => setIsExpanded(!isExpanded)} />
|
||||
</div>
|
||||
<textarea ref={textareaRef} value={value} onChange={e => handleChange(e.target.value)} onKeyDown={handleKeyDown} required={required} rows={isExpanded ? 40 : rows} placeholder={placeholder} className={"w-full bg-black/40 border border-white/10 border-t-0 p-4 text-white font-mono text-sm focus:border-[#00F0FF] outline-none resize-none leading-relaxed " + (isExpanded ? "flex-1 rounded-b-2xl text-base leading-loose" : "rounded-b-xl")} style={{ tabSize: 2 }} />
|
||||
<div className="flex items-center justify-between mt-1.5 px-1">
|
||||
<div className="flex items-center gap-3 text-[10px] text-[#86868B] font-mono"><span>{value.split('\n').length} lines</span><span className="text-white/10">|</span><span>{value.length} chars</span></div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-[#86868B]"><span className="opacity-60">⌘B</span><span className="opacity-60">⌘I</span><span className="opacity-60">Tab</span></div>
|
||||
</div>
|
||||
{!isExpanded && (
|
||||
<div className="bg-[#00F0FF]/5 border border-[#00F0FF]/20 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed mt-3">
|
||||
<p className="text-[#00F0FF] font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
|
||||
<p><strong># Title 1</strong> | <strong>## Title 2</strong> | <strong>**Bold**</strong></p>
|
||||
<p className="mt-1"><strong>- List Item</strong> | <strong>> Blockquote</strong></p>
|
||||
<p className="mt-1 text-white"><strong></strong></p>
|
||||
</div>
|
||||
)}
|
||||
{slug && <AssetManager scope="cases" slug={slug} isOpen={isAssetManagerOpen} onClose={() => setIsAssetManagerOpen(false)} onSelect={handleAssetInsert} accentColor="#00F0FF" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MAIN PAGE — Network Manager (Global Network)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkManager() {
|
||||
const [nodes, setNodes] = useState<any[]>([]);
|
||||
const [appsList, setAppsList] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [createNodeType, setCreateNodeType] = useState("installation");
|
||||
const [editingNode, setEditingNode] = useState<any | null>(null);
|
||||
const [isSavingCaseStudy, setIsSavingCaseStudy] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"story" | "tech" | "media" | "3d">("story");
|
||||
const [gallery, setGallery] = useState<string[]>([]);
|
||||
const [videos, setVideos] = useState<string[]>([]);
|
||||
const [renders, setRenders] = useState<string[]>([]);
|
||||
const [datasheet, setDatasheet] = useState<{model?: string, specs?: any[]}>({});
|
||||
const [eventDate, setEventDate] = useState("");
|
||||
const [model3DDims, setModel3DDims] = useState<{w?:string,h?:string,d?:string,unit?:string,weight?:string}>({});
|
||||
const [locationQuery, setLocationQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [isSearchingMap, setIsSearchingMap] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [selectedLat, setSelectedLat] = useState("");
|
||||
const [selectedLon, setSelectedLon] = useState("");
|
||||
const [mediaAssetsOpen, setMediaAssetsOpen] = useState(false);
|
||||
const [threeDAssetsOpen, setThreeDAssetsOpen] = useState(false);
|
||||
const [mediaAssetTarget, setMediaAssetTarget] = useState<"video" | "image">("video");
|
||||
const [threeDAssetTarget, setThreeDAssetTarget] = useState<"model" | "render">("model");
|
||||
const [coverAssetsOpen, setCoverAssetsOpen] = useState(false);
|
||||
|
||||
const nodeSlug = editingNode?.title?.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '') || "untitled";
|
||||
|
||||
const fetchNodesAndApps = async () => {
|
||||
setIsLoading(true);
|
||||
const resNodes = await getNodes(); if (resNodes.success && resNodes.nodes) setNodes(resNodes.nodes);
|
||||
const resApps = await getApplications(); if (resApps.success && resApps.apps) setAppsList(resApps.apps);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { fetchNodesAndApps(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(async () => {
|
||||
if (locationQuery.length > 2 && showResults) {
|
||||
setIsSearchingMap(true);
|
||||
const res = await searchSatelliteLocation(locationQuery);
|
||||
if (res.success && res.data) setSearchResults(res.data); else setSearchResults([]);
|
||||
setIsSearchingMap(false);
|
||||
} else setSearchResults([]);
|
||||
}, 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [locationQuery, showResults]);
|
||||
|
||||
const selectLocation = (place: any) => { setLocationQuery(place.display_name); setSelectedLat(place.lat); setSelectedLon(place.lon); setShowResults(false); };
|
||||
|
||||
const openGeoBlogModal = (node: any) => {
|
||||
setEditingNode(node); setActiveTab("story");
|
||||
try { setGallery(JSON.parse(node.galleryJson || "[]")); } catch { setGallery([]); }
|
||||
try { setVideos(JSON.parse(node.videosJson || "[]")); } catch { setVideos([]); }
|
||||
try { setRenders(JSON.parse(node.rendersJson || "[]")); } catch { setRenders([]); }
|
||||
try { const ds = JSON.parse(node.specificDatasheetJson || "{}"); setDatasheet(Array.isArray(ds) ? {} : ds); } catch { setDatasheet({}); }
|
||||
try { setModel3DDims(JSON.parse(node.model3DDimsJson || "{}")); } catch { setModel3DDims({}); }
|
||||
if (node.eventDate) setEventDate(new Date(node.eventDate).toISOString().split('T')[0]); else setEventDate("");
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault(); setIsSubmitting(true); setError("");
|
||||
const formData = new FormData(e.currentTarget);
|
||||
formData.set("lat", selectedLat); formData.set("lon", selectedLon); formData.set("location", locationQuery);
|
||||
const res = await createNode(formData);
|
||||
if (res.error) setError(res.error);
|
||||
else { setIsModalOpen(false); setLocationQuery(""); setSelectedLat(""); setSelectedLon(""); fetchNodesAndApps(); }
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleSaveCaseStudy = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault(); setIsSavingCaseStudy(true); setError("");
|
||||
const formData = new FormData(e.currentTarget);
|
||||
formData.append("galleryJson", JSON.stringify(gallery.filter(i => i.trim())));
|
||||
formData.append("videosJson", JSON.stringify(videos.filter(v => v.trim())));
|
||||
formData.append("rendersJson", JSON.stringify(renders.filter(r => r.trim())));
|
||||
formData.append("specificDatasheetJson", JSON.stringify(datasheet));
|
||||
formData.append("model3DDimsJson", JSON.stringify(model3DDims));
|
||||
const res = await updateNodeCaseStudy(formData);
|
||||
if (res.error) setError(res.error); else { setEditingNode(null); fetchNodesAndApps(); }
|
||||
setIsSavingCaseStudy(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => { if (confirm("Delete this deployment?")) { await deleteNode(id); fetchNodesAndApps(); } };
|
||||
const handleToggle = async (id: string, status: boolean) => { await toggleNodeStatus(id, status); fetchNodesAndApps(); };
|
||||
|
||||
const availableTabs = [
|
||||
{ id: "story", label: "The Story", icon: FileText, hideForEvent: false },
|
||||
{ id: "tech", label: "Datasheet", icon: Cpu, hideForEvent: true },
|
||||
{ id: "media", label: "Media & Video", icon: Video, hideForEvent: false },
|
||||
{ id: "3d", label: "3D & Renders", icon: Box, hideForEvent: true }
|
||||
].filter(t => !(editingNode?.nodeType === "event" && t.hideForEvent));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
|
||||
<div className="mb-10">
|
||||
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-[#00F0FF] transition-colors mb-6 group"><ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center</Link>
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||
<div><h1 className="text-3xl font-light text-white flex items-center gap-3"><Globe className="text-[#00F0FF]" /> Global Network</h1><p className="text-[#86868B] mt-2">Manage 3D Map coordinates, Installations, and Deep Case Studies.</p></div>
|
||||
<button onClick={() => setIsModalOpen(true)} className="flex items-center gap-2 bg-[#00F0FF]/10 text-[#00F0FF] border border-[#00F0FF]/20 px-5 py-2.5 rounded-xl font-medium hover:bg-[#00F0FF] hover:text-black transition-all"><Plus size={18} /> Add Deployment</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TABLE */}
|
||||
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl"><div className="overflow-x-auto"><table className="w-full text-left border-collapse"><thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold">Deployment Title & Location</th><th className="p-6 font-semibold">Type & Application</th><th className="p-6 font-semibold">Geo-Chronicle</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{isLoading ? <tr><td colSpan={5} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Syncing...</td></tr>
|
||||
: nodes.length === 0 ? <tr><td colSpan={5} className="p-8 text-center text-[#86868B]">No map nodes. Add the first deployment.</td></tr>
|
||||
: nodes.map(node => (
|
||||
<tr key={node.id} className={`border-b border-white/5 transition-colors group ${!node.isActive ? 'opacity-50' : 'hover:bg-white/[0.02]'}`}>
|
||||
<td className="p-6"><p className="font-medium text-white">{node.title}</p><p className="text-xs text-[#86868B] flex items-center gap-1 mt-1"><MapPin size={10} /> {node.location}</p></td>
|
||||
<td className="p-6"><div className="flex flex-col gap-1 items-start"><span className="bg-[#00F0FF]/10 text-[#00F0FF] px-2 py-0.5 rounded text-[10px] uppercase tracking-wider border border-[#00F0FF]/20">{node.nodeType}</span><span className="bg-white/10 text-white/80 px-2 py-0.5 rounded text-[10px] uppercase tracking-wider">{node.application.replace("-", " ")}</span></div></td>
|
||||
<td className="p-6">{node.projectOverview ? <span className="inline-flex items-center gap-1 text-[10px] text-[#00F0FF] border border-[#00F0FF]/30 bg-[#00F0FF]/10 px-2 py-1 rounded uppercase tracking-widest"><BookOpen size={10} /> Case Study Active</span> : <span className="text-[10px] text-[#86868B] uppercase tracking-widest">No Deep Data</span>}</td>
|
||||
<td className="p-6"><button onClick={() => handleToggle(node.id, node.isActive)} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${node.isActive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>{node.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td>
|
||||
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openGeoBlogModal(node)} className="text-[#86868B] hover:text-[#00F0FF] hover:bg-[#00F0FF]/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><BookOpen size={18} /></button><button onClick={() => handleDelete(node.id)} className="text-[#86868B] hover:text-red-400 hover:bg-red-400/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Trash2 size={18} /></button></div></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody></table></div></div>
|
||||
|
||||
|
||||
{/* SOLUTION EDITOR MODAL */}
|
||||
{editingNode && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
|
||||
<div className="bg-[#111] border border-white/10 w-full max-w-4xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]">
|
||||
<div className="p-6 md:p-8 border-b border-white/10 relative pb-0">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#00F0FF] to-transparent"></div>
|
||||
<button onClick={() => setEditingNode(null)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
|
||||
<div className="flex items-center gap-3 mb-1"><BookOpen size={24} className="text-[#00F0FF]" /><h3 className="text-2xl font-light text-white">Solution Editor</h3></div>
|
||||
<p className="text-[#86868B] text-xs font-mono uppercase tracking-widest mb-6">Target: {editingNode.title}</p>
|
||||
<div className="flex gap-6 border-b border-white/10 overflow-x-auto">
|
||||
{availableTabs.map(t => (
|
||||
<button key={t.id} type="button" onClick={() => setActiveTab(t.id as any)} className={`pb-4 text-xs uppercase tracking-widest font-semibold flex items-center gap-2 transition-all border-b-2 whitespace-nowrap ${activeTab === t.id ? "text-[#00F0FF] border-[#00F0FF]" : "text-[#86868B] border-transparent hover:text-white"}`}><t.icon size={14} /> {t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="network-form" onSubmit={handleSaveCaseStudy} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
|
||||
<input type="hidden" name="id" value={editingNode.id} />
|
||||
|
||||
{/* TAB: THE STORY */}
|
||||
<div className={activeTab === "story" ? "block animate-in fade-in" : "hidden"}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-1"><ImageIcon size={12}/> Cover Image</label>
|
||||
<div className="flex gap-2">
|
||||
<input name="mediaFileName" type="text" defaultValue={editingNode.mediaFileName || ""} placeholder="e.g., medellin-machine.jpg" className="flex-1 bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm focus:border-[#00F0FF] outline-none" />
|
||||
<button type="button" onClick={() => setCoverAssetsOpen(true)} className="flex items-center gap-1.5 px-3 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-xl hover:bg-emerald-500/20 shrink-0"><FolderOpen size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-1"><Calendar size={12}/> Date</label><input name="eventDate" type="date" value={eventDate} onChange={e => setEventDate(e.target.value)} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none [color-scheme:dark]" /></div>
|
||||
</div>
|
||||
<div className="mb-6"><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Energy Savings / Highlight</label><input name="energySavings" type="text" defaultValue={editingNode.energySavings || ""} placeholder="e.g., -45% Energy vs Steam" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-emerald-400 font-semibold text-sm focus:border-[#00F0FF] outline-none" /></div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 flex justify-between"><span>Project Chronicle (Markdown)</span><span className="text-[#00F0FF]/60 text-[9px] font-normal normal-case">Rich Editor + Assets</span></label>
|
||||
<MarkdownEditorCyan name="projectOverview" defaultValue={editingNode.projectOverview || ""} rows={10} placeholder="Write the full technical article..." slug={nodeSlug} />
|
||||
</div>
|
||||
<div className="bg-gradient-to-r from-[#00F0FF]/10 to-transparent border border-[#00F0FF]/20 p-4 rounded-xl flex items-center justify-between mt-6">
|
||||
<div className="flex items-center gap-3"><div className="p-2 bg-[#00F0FF]/20 rounded-lg text-[#00F0FF]"><Sparkles size={18} /></div><div><p className="text-sm text-white font-medium">Flux AI Auto-Translate</p><p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">IT, VEC, ES, DE</p></div></div>
|
||||
<label className="relative inline-flex items-center cursor-pointer"><input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" /><div className="w-11 h-6 bg-black/50 border border-white/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#00F0FF]"></div></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TAB: DATASHEET */}
|
||||
{editingNode?.nodeType !== "event" && (
|
||||
<div className={activeTab === "tech" ? "block animate-in fade-in" : "hidden"}>
|
||||
<div className="bg-[#00F0FF]/5 border border-[#00F0FF]/20 p-4 rounded-xl mb-6 flex items-center gap-3"><Cpu className="text-[#00F0FF]" size={20} /><p className="text-xs text-[#00F0FF]/80">Dynamic Terminal Datasheet. Check to make a stat glow large.</p></div>
|
||||
<div className="space-y-4">
|
||||
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Machine Model</label><input value={datasheet.model || ""} onChange={e => setDatasheet({...datasheet, model: e.target.value})} placeholder="e.g. Tiffany 20" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-lg font-medium focus:border-[#00F0FF] outline-none" /></div>
|
||||
<div className="space-y-3 pt-4 border-t border-white/5">
|
||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B]">Specifications</label>
|
||||
{(datasheet.specs || []).map((spec: any, idx: number) => (
|
||||
<div key={idx} className={`flex gap-3 items-center p-3 rounded-xl border ${spec.highlight ? 'bg-[#00F0FF]/5 border-[#00F0FF]/30' : 'bg-black/40 border-white/10'}`}>
|
||||
<input value={spec.label} onChange={e => { const n = [...(datasheet.specs||[])]; n[idx].label = e.target.value; setDatasheet({...datasheet, specs: n}); }} placeholder="Spec Name" className="w-1/3 bg-transparent text-[#86868B] text-[10px] uppercase tracking-wider font-semibold outline-none" />
|
||||
<input value={spec.value} onChange={e => { const n = [...(datasheet.specs||[])]; n[idx].value = e.target.value; setDatasheet({...datasheet, specs: n}); }} placeholder="Value" className="flex-1 bg-transparent text-white font-medium text-sm outline-none" />
|
||||
<label className="flex items-center gap-1.5 text-[10px] uppercase text-[#00F0FF] cursor-pointer shrink-0 border-l border-white/10 pl-3"><input type="checkbox" checked={spec.highlight} onChange={e => { const n = [...(datasheet.specs||[])]; n[idx].highlight = e.target.checked; setDatasheet({...datasheet, specs: n}); }} className="accent-[#00F0FF] w-4 h-4" /> Big</label>
|
||||
<button type="button" onClick={() => { const n = [...(datasheet.specs||[])]; n.splice(idx, 1); setDatasheet({...datasheet, specs: n}); }} className="text-[#86868B] hover:text-red-400 pl-2"><Trash2 size={16}/></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setDatasheet({...datasheet, specs: [...(datasheet.specs||[]), {label:"",value:"",highlight:false}]})} className="w-full border border-dashed border-white/20 text-[#86868B] hover:text-white hover:border-[#00F0FF] py-3.5 rounded-xl flex justify-center items-center gap-2 text-xs uppercase tracking-widest font-semibold"><Plus size={14} /> Add Spec</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB: MEDIA & VIDEO */}
|
||||
<div className={activeTab === "media" ? "block animate-in fade-in" : "hidden"}>
|
||||
<p className="text-xs text-[#86868B] mb-6">Videos and photos for this case study.</p>
|
||||
<div className="mb-8">
|
||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3 flex items-center gap-2"><Video size={12}/> Videos — public/cases/videos</label>
|
||||
{videos.map((vid, idx) => (<div key={idx} className="flex gap-2 mb-3"><input value={vid} onChange={e => { const n = [...videos]; n[idx] = e.target.value; setVideos(n); }} placeholder="e.g., videoDemo.mp4" className="flex-1 bg-black/60 border border-white/10 rounded-lg px-4 py-2.5 text-[#00F0FF] font-mono text-sm focus:border-[#00F0FF] outline-none" /><button type="button" onClick={() => setVideos(videos.filter((_, i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-3 bg-black/40 rounded-lg border border-white/10"><Trash2 size={14}/></button></div>))}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setVideos([...videos, ""])} className="flex-1 border border-dashed border-white/20 text-[#86868B] hover:text-white py-3 rounded-lg flex justify-center items-center gap-2 text-xs"><Plus size={14} /> Add Video</button>
|
||||
<button type="button" onClick={() => { setMediaAssetTarget("video"); setMediaAssetsOpen(true); }} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-lg hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse Assets</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3 flex items-center gap-2"><ImageIcon size={12}/> Photo Gallery — public/cases</label>
|
||||
{gallery.map((img, idx) => (<div key={idx} className="flex gap-2 mb-3"><input value={img} onChange={e => { const n = [...gallery]; n[idx] = e.target.value; setGallery(n); }} placeholder="e.g., install-1.jpg" className="flex-1 bg-black/60 border border-white/10 rounded-lg px-4 py-2.5 text-white text-sm focus:border-[#00F0FF] outline-none" /><button type="button" onClick={() => setGallery(gallery.filter((_, i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-3 bg-black/40 rounded-lg border border-white/10"><Trash2 size={14}/></button></div>))}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setGallery([...gallery, ""])} className="flex-1 border border-dashed border-white/20 text-[#86868B] hover:text-white py-3 rounded-lg flex justify-center items-center gap-2 text-xs"><Plus size={14} /> Add Photo</button>
|
||||
<button type="button" onClick={() => { setMediaAssetTarget("image"); setMediaAssetsOpen(true); }} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-lg hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse Assets</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TAB: 3D & RENDERS */}
|
||||
{editingNode?.nodeType !== "event" && (
|
||||
<div className={activeTab === "3d" ? "block animate-in fade-in" : "hidden"}>
|
||||
<p className="text-xs text-[#86868B] mb-6">3D models, dimensions, and renders for the AR viewer.</p>
|
||||
<div className="mb-8">
|
||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-2"><Box size={12}/> 3D Model (AR) — public/cases/models</label>
|
||||
<div className="flex gap-2">
|
||||
<input name="model3DPath" defaultValue={editingNode.model3DPath || ""} placeholder="e.g., flxd60a.glb" className="flex-1 bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-purple-400 font-mono text-sm focus:border-purple-400 outline-none" />
|
||||
<button type="button" onClick={() => { setThreeDAssetTarget("model"); setThreeDAssetsOpen(true); }} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-xl hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-[#86868B] mt-1.5">GLB for Android/desktop. USDZ (iOS) auto-derived.</p>
|
||||
</div>
|
||||
|
||||
{/* DIMENSIONS PANEL */}
|
||||
<div className="mb-8 bg-white/[0.03] border border-white/8 rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<label className="text-[10px] uppercase tracking-widest text-[#00F0FF] font-semibold flex items-center gap-2">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[#00F0FF]"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
Physical Dimensions — AR Scale
|
||||
</label>
|
||||
{model3DDims.w && model3DDims.d && (<div className="flex items-center gap-1.5 bg-[#00F0FF]/10 border border-[#00F0FF]/20 rounded-lg px-3 py-1"><span className="text-[9px] text-[#00F0FF] font-bold uppercase">Footprint</span><span className="text-[#00F0FF] font-mono text-sm font-bold">{((Number(model3DDims.w)/1000) * (Number(model3DDims.d)/1000)).toFixed(2)}</span><span className="text-[9px] text-[#86868B]">m²</span></div>)}
|
||||
</div>
|
||||
<p className="text-[10px] text-[#86868B] mb-4">These values feed the AR viewer HUD, space notes, and human scale reference.</p>
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
{[{key:'w',label:'Width (W)',ph:'9200',color:'#f472b6',hint:'Largo'},{key:'h',label:'Height (H)',ph:'3600',color:'#34d399',hint:'Alto'},{key:'d',label:'Depth (D)',ph:'2100',color:'#60a5fa',hint:'Fondo'}].map(dim => (
|
||||
<div key={dim.key}>
|
||||
<label className="block text-[9px] uppercase tracking-widest mb-1.5" style={{color:dim.color}}>{dim.label}</label>
|
||||
<div className="flex items-center bg-black/60 border border-white/10 rounded-xl overflow-hidden">
|
||||
<input type="number" min="0" value={(model3DDims as any)[dim.key] || ''} onChange={e => setModel3DDims({...model3DDims, [dim.key]: e.target.value})} placeholder={dim.ph} className="flex-1 bg-transparent px-3 py-2.5 font-mono text-sm outline-none" style={{color:dim.color}} />
|
||||
<span className="pr-3 text-[10px] text-[#86868B] font-mono">{model3DDims.unit || 'mm'}</span>
|
||||
</div>
|
||||
<span className="text-[9px] text-[#86868B] mt-0.5 block">{dim.hint}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="block text-[9px] uppercase tracking-widest text-[#86868B] mb-1.5">Unit</label><select value={model3DDims.unit || 'mm'} onChange={e => setModel3DDims({...model3DDims, unit: e.target.value})} className="w-full bg-black/60 border border-white/10 rounded-xl px-3 py-2.5 text-white text-sm outline-none"><option value="mm">mm</option><option value="cm">cm</option><option value="m">m</option></select></div>
|
||||
<div><label className="block text-[9px] uppercase tracking-widest text-[#86868B] mb-1.5">Weight</label><input type="text" value={model3DDims.weight || ''} onChange={e => setModel3DDims({...model3DDims, weight: e.target.value})} placeholder="e.g. 4200 kg" className="w-full bg-black/60 border border-white/10 rounded-xl px-3 py-2.5 text-white font-mono text-sm outline-none" /></div>
|
||||
</div>
|
||||
{(model3DDims.w || model3DDims.h || model3DDims.d) && (
|
||||
<div className="mt-4 pt-4 border-t border-white/5 flex flex-wrap gap-3 items-center">
|
||||
<span className="text-[9px] text-[#86868B] uppercase tracking-widest font-semibold">Preview:</span>
|
||||
{[{l:'W',v:model3DDims.w,c:'#f472b6'},{l:'H',v:model3DDims.h,c:'#34d399'},{l:'D',v:model3DDims.d,c:'#60a5fa'}].filter(d=>d.v).map(d=>(<div key={d.l} className="flex items-baseline gap-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5"><span className="text-[9px] font-bold" style={{color:d.c}}>{d.l}</span><span className="text-white font-mono text-sm">{d.v}</span><span className="text-[9px] text-[#86868B]">{model3DDims.unit||'mm'}</span></div>))}
|
||||
{model3DDims.w && model3DDims.d && <div className="flex items-baseline gap-1 bg-[#00F0FF]/8 border border-[#00F0FF]/15 rounded-lg px-3 py-1.5"><span className="text-[9px] font-bold text-[#00F0FF]">Area</span><span className="text-[#00F0FF] font-mono text-sm">{((Number(model3DDims.w)/1000)*(Number(model3DDims.d)/1000)).toFixed(2)}</span><span className="text-[9px] text-[#86868B]">m²</span></div>}
|
||||
{model3DDims.h && <div className="text-[9px] text-[#86868B]">{Number(model3DDims.h) > 1750 ? (Number(model3DDims.h)/1750).toFixed(1) + '× taller' : (1750/Number(model3DDims.h)).toFixed(1) + '× shorter'} than 1.75m</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3 flex items-center gap-2"><ImageIcon size={12}/> Studio Renders</label>
|
||||
{renders.map((ren, idx) => (<div key={idx} className="flex gap-2 mb-3"><input value={ren} onChange={e => { const n = [...renders]; n[idx] = e.target.value; setRenders(n); }} placeholder="render-front.jpg" className="flex-1 bg-black/60 border border-white/10 rounded-lg px-4 py-2.5 text-white text-sm focus:border-[#00F0FF] outline-none" /><button type="button" onClick={() => setRenders(renders.filter((_, i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-3 bg-black/40 rounded-lg border border-white/10"><Trash2 size={14}/></button></div>))}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setRenders([...renders, ""])} className="flex-1 border border-dashed border-white/20 text-[#86868B] hover:text-white py-3 rounded-lg flex justify-center items-center gap-2 text-xs"><Plus size={14} /> Add Render</button>
|
||||
<button type="button" onClick={() => { setThreeDAssetTarget("render"); setThreeDAssetsOpen(true); }} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-lg hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse Assets</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Asset Managers OUTSIDE the form to prevent submit propagation */}
|
||||
<AssetManager scope="cases" slug={nodeSlug} isOpen={coverAssetsOpen} onClose={() => setCoverAssetsOpen(false)} onSelect={(item) => {
|
||||
const inp = document.querySelector('input[name="mediaFileName"]') as HTMLInputElement;
|
||||
if (inp) { inp.value = item.name; inp.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
}} accentColor="#00F0FF" />
|
||||
<AssetManager scope="cases" slug={nodeSlug} isOpen={mediaAssetsOpen} onClose={() => setMediaAssetsOpen(false)} onSelect={(item) => {
|
||||
if (mediaAssetTarget === "video") setVideos(p => [...p, item.name]);
|
||||
else setGallery(p => [...p, item.name]);
|
||||
}} accentColor="#00F0FF" />
|
||||
<AssetManager scope="cases" slug={nodeSlug} isOpen={threeDAssetsOpen} onClose={() => setThreeDAssetsOpen(false)} onSelect={(item) => {
|
||||
if (threeDAssetTarget === "model") {
|
||||
const inp = document.querySelector('input[name="model3DPath"]') as HTMLInputElement;
|
||||
if (inp) { inp.value = item.name; inp.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
} else {
|
||||
setRenders(p => [...p, item.name]);
|
||||
}
|
||||
}} accentColor="#00F0FF" initialPath={threeDAssetTarget === "model" ? "models" : "renders"} />
|
||||
|
||||
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setEditingNode(null)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white">Cancel</button>
|
||||
<button onClick={() => (document.getElementById("network-form") as HTMLFormElement)?.requestSubmit()} disabled={isSavingCaseStudy} className="flex items-center gap-2 bg-[#00F0FF] text-black px-8 py-3 rounded-xl text-sm font-semibold hover:bg-white disabled:opacity-50 shadow-[0_0_15px_rgba(0,240,255,0.3)]">{isSavingCaseStudy ? <><Loader2 size={18} className="animate-spin" /> Processing AI...</> : "Save Complete Chronicle"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* ADD DEPLOYMENT MODAL */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
|
||||
<div className="bg-[#111] border border-white/10 w-full max-w-lg rounded-[2rem] p-8 relative shadow-2xl overflow-visible">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#00F0FF] to-transparent"></div>
|
||||
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
|
||||
<h3 className="text-2xl font-light mb-2 text-[#00F0FF]">Add Deployment</h3>
|
||||
<p className="text-[#86868B] text-sm mb-6">Search for a global city to auto-calculate coordinates.</p>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Company / Facility Name</label><input name="title" type="text" required placeholder="e.g., Advanced Fabrics Inc." className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none" /></div>
|
||||
<div className="relative">
|
||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-1"><Search size={10} className="text-[#00F0FF]" /> City Search (Satellite)</label>
|
||||
<input type="text" value={locationQuery} onChange={e => { setLocationQuery(e.target.value); setShowResults(true); }} required placeholder="Type a city name..." className="w-full bg-black/60 border border-[#00F0FF]/30 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none" autoComplete="off"/>
|
||||
{showResults && locationQuery.length > 2 && (
|
||||
<div className="absolute z-50 top-full mt-2 w-full bg-[#1A1A1A] border border-white/10 rounded-xl shadow-2xl overflow-hidden max-h-48 overflow-y-auto">
|
||||
{isSearchingMap ? <div className="p-4 text-center text-[#86868B] text-xs flex justify-center items-center gap-2"><Loader2 className="animate-spin" size={14} /> Scanning...</div>
|
||||
: searchResults.length > 0 ? searchResults.map((place, idx) => (
|
||||
<button key={idx} type="button" onClick={() => selectLocation(place)} className="w-full text-left px-4 py-3 border-b border-white/5 hover:bg-[#00F0FF]/10 hover:text-[#00F0FF] transition-colors text-xs text-white">{place.display_name}</button>
|
||||
)) : <div className="p-4 text-center text-[#86868B] text-xs">No coordinates found.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Latitude</label><input readOnly value={selectedLat} placeholder="Auto" className="w-full bg-black/20 border border-white/5 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm opacity-60 cursor-not-allowed" /></div>
|
||||
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Longitude</label><input readOnly value={selectedLon} placeholder="Auto" className="w-full bg-black/20 border border-white/5 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm opacity-60 cursor-not-allowed" /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Deployment Type</label><select name="nodeType" value={createNodeType} onChange={e => setCreateNodeType(e.target.value)} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-[#00F0FF] text-sm focus:border-[#00F0FF] outline-none appearance-none"><option value="installation">📍 Field Installation</option><option value="event">🗓️ Event</option><option value="hq">🏢 FLUX Legacy HQ</option></select></div>
|
||||
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Application Category</label><select name="application" required className={"w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none appearance-none " + (createNodeType === "hq" ? "opacity-50" : "")}><option value="all">🌐 All Applications</option>{appsList.filter(a => a.isActive).map(a => <option key={a.slug} value={a.slug}>{a.title}</option>)}</select></div>
|
||||
<div className="md:col-span-2"><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">{createNodeType === "event" ? "Event Location / Stand" : "Key Stat / Metric"}</label><input name="stats" type="text" required placeholder={createNodeType === "event" ? "e.g., Hall 4, Stand B12" : "e.g., 50% Energy Savings"} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none" /></div>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-xs text-center bg-red-400/10 py-2 rounded-lg">{error}</p>}
|
||||
<button type="submit" disabled={isSubmitting || !selectedLat} className="w-full flex items-center justify-center gap-2 bg-[#00F0FF] text-black py-3.5 mt-4 rounded-xl text-sm font-semibold hover:bg-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed">{isSubmitting ? <Loader2 size={18} className="animate-spin" /> : "Deploy to Map"}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user