production: docker + nginx config for rf-flux.com
Deploy to VPS / deploy (push) Has been cancelled

This commit is contained in:
2026-03-20 13:46:05 -05:00
parent b275b19f08
commit fc24313f15
187 changed files with 20977 additions and 767 deletions
@@ -0,0 +1,163 @@
//src/app/hq-command/dashboard/applications/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { unstable_noStore as noStore } from "next/cache"; // 🔥 ANTI-CACHÉ
// 🔥 IMPORTAMOS EL TRADUCTOR DE IA
import { translateContentForCMS } from "@/lib/aiTranslator";
const generateSlug = (title: string) => {
return title.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
};
// 1. OBTENER LA LISTA DE APLICACIONES
export async function getApplications() {
noStore();
try {
const apps = await prisma.application.findMany({
orderBy: { createdAt: "asc" }
});
return { success: true, apps };
} catch (error) {
return { error: "Failed to fetch applications." };
}
}
// 2. OBTENER UNA APLICACIÓN ESPECÍFICA
export async function getApplicationBySlug(slug: string) {
try {
const app = await prisma.application.findUnique({
where: { slug }
});
if (!app) return { error: "Application not found." };
return { success: true, app };
} catch (error) {
return { error: "Failed to fetch application details." };
}
}
// 3. CREAR NUEVA APLICACIÓN (¡Ahora con IA!)
export async function createApplication(formData: FormData) {
try {
const title = formData.get("title") as string;
const subtitle = formData.get("subtitle") as string;
const category = formData.get("category") as string;
// Capturamos el switch de IA
const autoTranslate = formData.get("autoTranslate") === "on";
const shortDescription = "New application ready to be configured.";
const slug = generateSlug(title);
let translationsJson = "{}";
// 🔥 LA MAGIA DE LA IA EN LA CREACIÓN 🔥
if (autoTranslate) {
const aiResult = await translateContentForCMS({
title, subtitle, category, shortDescription
});
if (aiResult) translationsJson = JSON.stringify(aiResult);
}
await prisma.application.create({
data: {
slug, title, subtitle, category,
shortDescription,
heroDescription: "", sectionsJson: "[]", advantagesJson: "[]", datasheetJson: "{}", dashboardMetricsJson: "[]",
isActive: true,
translationsJson
}
});
revalidatePath("/hq-command/dashboard/applications");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to create application. Title might already exist." };
}
}
// 4. ACTUALIZAR TODA LA INFORMACIÓN (¡Traduciendo JSONs completos!)
export async function updateApplicationData(formData: FormData) {
try {
const slug = formData.get("slug") as string;
const title = formData.get("title") as string;
const subtitle = formData.get("subtitle") as string;
const category = formData.get("category") as string;
const shortDescription = formData.get("shortDescription") as string;
const heroDescription = formData.get("heroDescription") as string;
const sectionsJson = formData.get("sectionsJson") as string;
const advantagesJson = formData.get("advantagesJson") as string;
const datasheetJson = formData.get("datasheetJson") as string || "{}";
const dashboardMetricsJson = formData.get("dashboardMetricsJson") as string || "[]";
const autoTranslate = formData.get("autoTranslate") === "on";
let updateData: any = {
title, subtitle, category, shortDescription, heroDescription,
sectionsJson, advantagesJson, datasheetJson, dashboardMetricsJson
};
// 🔥 LA MAGIA DE LA IA PARA EL CONTENIDO PROFUNDO 🔥
// Nota: Le mandamos los JSON stringificados. GPT-4o los traducirá y nos los devolverá con la misma estructura.
if (autoTranslate) {
const aiResult = await translateContentForCMS({
title, subtitle, category, shortDescription, heroDescription,
sectionsJson, advantagesJson, dashboardMetricsJson
});
if (aiResult) {
updateData.translationsJson = JSON.stringify(aiResult);
}
}
await prisma.application.update({
where: { slug },
data: updateData
});
revalidatePath(`/applications/${slug}`);
revalidatePath("/hq-command/dashboard/applications");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
console.error("Update Error:", error);
return { error: "Failed to update application data." };
}
}
// 5. OCULTAR / MOSTRAR APLICACIÓN
export async function toggleApplication(slug: string, currentStatus: boolean) {
try {
await prisma.application.update({
where: { slug },
data: { isActive: !currentStatus }
});
revalidatePath("/hq-command/dashboard/applications");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (e) {
return { error: "Failed to toggle status." };
}
}
// 6. ELIMINAR APLICACIÓN
export async function deleteApplication(slug: string) {
try {
await prisma.application.delete({ where: { slug } });
revalidatePath("/hq-command/dashboard/applications");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (e) {
return { error: "Failed to delete application." };
}
}
// 7. LA SEMILLA: AUTO-POBLAR LA BASE DE DATOS (Intacto)
export async function seedInitialApplications() {
// ... Tu código actual de la semilla se queda exactamente igual ...
// (Para mantener este mensaje limpio, asume que la función de seedInitialApplications() que ya tienes va aquí)
}
@@ -0,0 +1,746 @@
//src/app/hq-command/dashboard/applications/page.ts
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowLeft, Layers, DatabaseZap, Loader2, X, CheckCircle2, FileText, LayoutTemplate, AlignLeft, Plus, Trash2, AlertCircle, Eye, EyeOff, Sparkles,
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus, Image as ImageIcon, Video, Box, Type, Code, RotateCcw, RotateCw,
Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check
} from "lucide-react";
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;
}
interface AssetManagerProps {
slug: string;
isOpen: boolean;
onClose: () => void;
onInsert: (markdownSyntax: string) => void;
}
function AssetManager({ slug, isOpen, onClose, onInsert }: AssetManagerProps) {
const [currentPath, setCurrentPath] = useState("");
const [items, setItems] = useState<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams({ slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) {
setItems(data.items);
setBreadcrumbs(data.breadcrumbs);
setCurrentPath(dirPath);
} else {
setError(data.error || "Failed to load directory");
}
} catch (err) {
setError("Connection error — make sure /api/assets/route.ts exists.");
}
setIsLoading(false);
}, [slug]);
useEffect(() => {
if (isOpen) { fetchAssets(currentPath); setSearchQuery(""); }
}, [isOpen, fetchAssets]); // eslint-disable-line react-hooks/exhaustive-deps
const navigateTo = (folderPath: string) => { fetchAssets(folderPath); };
const uploadFile = async (file: File) => {
setIsUploading(true);
setUploadProgress(`Uploading ${file.name}...`);
try {
const formData = new FormData();
formData.append("slug", slug);
formData.append("path", currentPath);
formData.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: formData });
const data = await res.json();
if (data.success) {
setUploadProgress(`${data.file.name} uploaded`);
await fetchAssets(currentPath);
setTimeout(() => setUploadProgress(""), 2000);
} else {
setUploadProgress(`✗ Error: ${data.error}`);
setTimeout(() => setUploadProgress(""), 4000);
}
} catch (err) {
setUploadProgress("✗ Upload failed");
setTimeout(() => setUploadProgress(""), 3000);
}
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) Array.from(files).forEach(uploadFile);
e.target.value = "";
};
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) Array.from(files).forEach(uploadFile);
};
const createFolder = async () => {
if (!newFolderName.trim()) return;
try {
const res = await fetch("/api/assets", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, folderName: newFolderName, parentPath: currentPath }),
});
const data = await res.json();
if (data.success) {
setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath);
} else { alert(data.error || "Failed to create folder"); }
} catch { alert("Connection error creating folder"); }
};
const deleteFile = async (filePath: string, fileName: string) => {
if (!confirm(`Delete "${fileName}"? This cannot be undone.`)) return;
try {
const res = await fetch("/api/assets", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, filePath }),
});
const data = await res.json();
if (data.success) await fetchAssets(currentPath);
else alert(data.error);
} catch { alert("Failed to delete file"); }
};
const insertAsset = (item: AssetItem) => {
if (item.type === "folder") return;
const url = item.publicUrl || `/applications/${slug}/${item.path}`;
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})`;
}
onInsert(syntax);
onClose();
};
const copyPath = (item: AssetItem) => {
const url = item.publicUrl || `/applications/${slug}/${item.path}`;
navigator.clipboard.writeText(url);
setCopiedPath(item.path);
setTimeout(() => setCopiedPath(null), 1500);
};
const filteredItems = searchQuery
? items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()))
: items;
const renderThumbnail = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} className="text-purple-400/70" /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
if (item.mediaType === "video") return <div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={24} className="text-blue-400/60" /></div>;
if (item.mediaType === "model") return <div className="w-full h-full flex items-center justify-center bg-purple-500/5"><Box size={24} className="text-purple-400/60" /></div>;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
const typeBadge = (mediaType?: string) => {
const styles: Record<string, string> = {
image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
video: "bg-blue-500/10 text-blue-400 border-blue-500/20",
model: "bg-purple-500/10 text-purple-400 border-purple-500/20",
document: "bg-amber-500/10 text-amber-400 border-amber-500/20",
};
return styles[mediaType || ""] || "bg-white/5 text-[#86868B] border-white/10";
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md">
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden"
onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}>
{isDragging && (
<div className="absolute inset-0 z-50 bg-purple-500/10 border-2 border-dashed border-purple-500/50 rounded-[2rem] flex items-center justify-center backdrop-blur-sm">
<div className="text-center">
<ArrowUpFromLine size={48} className="text-purple-400 mx-auto mb-3 animate-bounce" />
<p className="text-purple-400 font-medium text-lg">Drop files to upload</p>
<p className="text-[#86868B] text-sm mt-1">to /applications/{slug}/{currentPath || "root"}</p>
</div>
</div>
)}
{/* Header */}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/15 rounded-xl text-purple-400"><FolderOpen size={20} /></div>
<div>
<h3 className="text-lg font-medium text-white">Asset Manager</h3>
<p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/applications/{slug}/</p>
</div>
</div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl transition-all"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">
{breadcrumbs.map((crumb, idx) => (
<span key={idx} className="flex items-center shrink-0">
{idx > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}
<button onClick={() => navigateTo(crumb.path)} className={`px-2 py-1 rounded-lg transition-colors text-xs ${idx === breadcrumbs.length - 1 ? "text-purple-400 bg-purple-500/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`}>{crumb.name}</button>
</span>
))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" />
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none focus:border-purple-500/50" />
</div>
<div className="flex bg-white/5 rounded-lg border border-white/10 overflow-hidden">
<button onClick={() => setViewMode("grid")} className={`p-1.5 transition-colors ${viewMode === "grid" ? "bg-purple-500/20 text-purple-400" : "text-[#86868B] hover:text-white"}`}><Grid3X3 size={14} /></button>
<button onClick={() => setViewMode("list")} className={`p-1.5 transition-colors ${viewMode === "list" ? "bg-purple-500/20 text-purple-400" : "text-[#86868B] hover:text-white"}`}><LayoutList size={14} /></button>
</div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg hover:bg-white/10 transition-all"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-white bg-purple-500 rounded-lg hover:bg-purple-400 transition-all disabled:opacity-50 font-medium"><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.mp4,.webm,.mov,.glb,.gltf,.usdz,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5">
<FolderPlus size={14} className="text-purple-400 shrink-0" />
<input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name (lowercase, hyphens ok)" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:border-purple-500 outline-none font-mono" />
<button onClick={createFolder} className="px-3 py-1.5 text-xs bg-purple-500 text-white rounded-lg hover:bg-purple-400 font-medium">Create</button>
<button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B] hover:text-white">Cancel</button>
</div>
)}
{uploadProgress && (
<div className="mt-3 pt-3 border-t border-white/5">
<div className="flex items-center gap-2 text-xs">
{isUploading && <Loader2 size={12} className="animate-spin text-purple-400" />}
<span className={isUploading ? "text-purple-400" : uploadProgress.startsWith("✓") ? "text-emerald-400" : "text-red-400"}>{uploadProgress}</span>
</div>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? (
<div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin text-purple-400" /></div>
) : error ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<AlertCircle size={32} className="text-red-400/50 mb-3" />
<p className="text-red-400/80 text-sm mb-1">{error}</p>
<p className="text-[#86868B] text-xs">Make sure <code className="bg-white/5 px-1.5 py-0.5 rounded text-purple-400">/api/assets/route.ts</code> exists.</p>
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<FolderOpen size={48} className="text-[#86868B]/20 mb-4" />
{searchQuery ? (
<p className="text-[#86868B] text-sm">No files matching &quot;{searchQuery}&quot;</p>
) : (
<>
<p className="text-[#86868B] text-sm mb-2">This directory is empty</p>
<p className="text-[#86868B]/60 text-xs">Upload files or create subfolders to organize your assets</p>
<div className="flex gap-2 mt-4">
{["images", "videos", "models"].map(folder => (
<button key={folder} onClick={async () => {
await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, folderName: folder, parentPath: currentPath }) });
fetchAssets(currentPath);
}} className="px-3 py-2 text-xs text-purple-400 bg-purple-500/10 border border-purple-500/20 rounded-lg hover:bg-purple-500/20 transition-colors">+ {folder}/</button>
))}
</div>
</>
)}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{filteredItems.map((item) => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-purple-500/30 transition-all cursor-pointer" onClick={() => item.type === "folder" ? navigateTo(item.path) : insertAsset(item)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumbnail(item)}</div>
<div className="p-2">
<p className="text-[11px] text-white truncate font-medium">{item.name}</p>
<div className="flex items-center justify-between mt-1">
{item.type === "folder" ? (
<span className="text-[9px] text-[#86868B]">{item.childCount} items</span>
) : (
<span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>
)}
{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}
</div>
</div>
{item.type === "file" && (
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={(e) => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-white transition-colors" title="Copy path">
{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
</button>
<button onClick={(e) => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-red-400 transition-colors" title="Delete"><Trash2 size={11} /></button>
</div>
)}
</div>
))}
</div>
) : (
<div className="space-y-1">
{filteredItems.map((item) => (
<div key={item.path} className="group flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-white/[0.03] transition-colors cursor-pointer" onClick={() => item.type === "folder" ? navigateTo(item.path) : insertAsset(item)}>
<div className="w-8 h-8 rounded-lg overflow-hidden shrink-0 border border-white/5">
{item.type === "folder" ? <div className="w-full h-full flex items-center justify-center bg-purple-500/10"><FolderOpen size={14} className="text-purple-400" /></div>
: item.mediaType === "image" && item.publicUrl ? <img src={item.publicUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
: <div className={`w-full h-full flex items-center justify-center ${item.mediaType === "video" ? "bg-blue-500/10" : item.mediaType === "model" ? "bg-purple-500/10" : "bg-white/5"}`}>
{item.mediaType === "video" ? <Video size={12} className="text-blue-400" /> : item.mediaType === "model" ? <Box size={12} className="text-purple-400" /> : <File size={12} className="text-[#86868B]" />}
</div>}
</div>
<span className="text-sm text-white flex-1 truncate">{item.name}</span>
{item.type === "file" && <span className={`text-[9px] px-2 py-0.5 rounded border shrink-0 ${typeBadge(item.mediaType)}`}>{item.extension}</span>}
{item.type === "folder" && <span className="text-[9px] text-[#86868B] shrink-0">{item.childCount} items</span>}
{item.size && <span className="text-[10px] text-[#86868B] w-16 text-right shrink-0">{item.size}</span>}
{item.type === "file" && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={(e) => { e.stopPropagation(); copyPath(item); }} className="p-1.5 rounded-lg text-[#86868B] hover:text-white hover:bg-white/10 transition-colors">{copiedPath === item.path ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}</button>
<button onClick={(e) => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 rounded-lg text-[#86868B] hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 size={12} /></button>
</div>
)}
{item.type === "folder" && <ChevronRight size={14} className="text-[#86868B]/50 shrink-0" />}
</div>
))}
</div>
)}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20">
<span>{filteredItems.length} items{searchQuery ? " (filtered)" : ""} Click a file to insert into editor</span>
<span className="font-mono">Drag & drop supported</span>
</div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 🔥 MARKDOWN EDITOR — Rich Toolbar + Asset Manager
// ─────────────────────────────────────────────────────────────────────────────
interface MarkdownToolbarProps {
name: string;
defaultValue?: string;
required?: boolean;
rows?: number;
placeholder?: string;
slug?: string;
}
function MarkdownEditor({ name, defaultValue = "", required, rows = 12, placeholder, slug }: MarkdownToolbarProps) {
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 handleClickOutside = (e: MouseEvent) => {
if (insertMenuRef.current && !insertMenuRef.current.contains(e.target as Node)) setShowInsertMenu(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const historyTimeout = useRef<NodeJS.Timeout | null>(null);
const pushHistory = useCallback((newValue: string) => {
if (historyTimeout.current) clearTimeout(historyTimeout.current);
historyTimeout.current = setTimeout(() => {
setHistory(prev => [...prev.slice(0, historyIndex + 1), newValue].slice(-50));
setHistoryIndex(prev => Math.min(prev + 1, 49));
}, 500);
}, [historyIndex]);
const handleChange = (newValue: string) => { setValue(newValue); pushHistory(newValue); };
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 = (newText: string, cursorOffset?: number) => {
const { before, after } = getSelection();
handleChange(before + newText + after);
const pos = cursorOffset !== undefined ? before.length + cursorOffset : before.length + newText.length;
setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(pos, pos); } }, 0);
};
const wrapSelection = (prefix: string, suffix: string) => {
const { selected } = getSelection();
if (selected) { replaceSelection(`${prefix}${selected}${suffix}`, prefix.length + selected.length + suffix.length); }
else { replaceSelection(`${prefix}text${suffix}`, prefix.length); }
};
const insertAtCursor = (text: string, cursorOffset?: number) => { replaceSelection(text, cursorOffset); };
const handleAssetInsert = (markdownSyntax: string) => { insertAtCursor(`\n${markdownSyntax}\n`); };
const prependLine = (prefix: string) => {
const { start, selected } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const before = value.substring(0, lineStart);
const currentLine = selected || value.substring(lineStart).split('\n')[0];
const afterLine = value.substring(lineStart + currentLine.length);
handleChange(before + prefix + currentLine + afterLine);
setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(lineStart + prefix.length, lineStart + prefix.length + currentLine.length); } }, 0);
};
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 1 |\n| Data 3 | Data 4 | Value 2 |\n", 2),
image: () => { insertAtCursor(`\n![Image description](/applications/${slug || "your-slug"}/image-name.jpg)\n`, 3); },
video: () => { insertAtCursor(`\n[VIDEO:/applications/${slug || "your-slug"}/videos/video-name.mp4]\n`, 8); },
model3d: () => { insertAtCursor(`\n[3D:/applications/${slug || "your-slug"}/models/model-name.glb]\n`, 5); },
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isMod = e.metaKey || e.ctrlKey;
if (isMod && e.key === 'b') { e.preventDefault(); actions.bold(); }
if (isMod && e.key === 'i') { e.preventDefault(); actions.italic(); }
if (isMod && 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 (⌘B)" onClick={actions.bold} />
<ToolBtn icon={Italic} label="Italic (⌘I)" onClick={actions.italic} />
<Divider />
<ToolBtn icon={Heading1} label="Heading 1" onClick={actions.h1} />
<ToolBtn icon={Heading2} label="Heading 2" onClick={actions.h2} />
<ToolBtn icon={Heading3} label="Heading 3" onClick={actions.h3} />
<Divider />
<ToolBtn icon={List} label="Bullet List" onClick={actions.ul} />
<ToolBtn icon={ListOrdered} label="Numbered List" onClick={actions.ol} />
<ToolBtn icon={Quote} label="Blockquote" onClick={actions.quote} />
<ToolBtn icon={Table} label="Insert Table" onClick={actions.table} />
<ToolBtn icon={Minus} label="Horizontal Rule" 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-purple-400 hover:text-purple-300 hover:bg-purple-500/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 animate-in fade-in slide-in-from-top-2 duration-150">
<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-sm text-white/80 hover:text-white hover:bg-white/5 rounded-lg transition-colors 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-sm text-white/80 hover:text-white hover:bg-white/5 rounded-lg transition-colors 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 file</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-sm text-white/80 hover:text-white hover:bg-white/5 rounded-lg transition-colors 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 (AR)</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-sm text-white/80 hover:text-white hover:bg-white/5 rounded-lg transition-colors 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-highlighted last column</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" title="Browse & upload media files"><FolderOpen size={14} /> Assets</button></>)}
<div className="flex-1" />
<ToolBtn icon={RotateCcw} label="Undo (⌘Z)" onClick={undo} className={historyIndex <= 0 ? "opacity-30 pointer-events-none" : ""} />
<ToolBtn icon={RotateCw} label="Redo (⌘⇧Z)" onClick={redo} className={historyIndex >= history.length - 1 ? "opacity-30 pointer-events-none" : ""} />
<Divider />
<ToolBtn icon={isExpanded ? Minimize2 : Maximize2} label={isExpanded ? "Exit Fullscreen" : "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-purple-500 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 Bold</span><span className="opacity-60">I Italic</span><span className="opacity-60">Tab Indent</span></div>
</div>
{!isExpanded && (
<div className="bg-purple-500/10 border border-purple-500/20 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed mt-3">
<p className="text-purple-400 font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
<p><strong># Title 1</strong> &nbsp;|&nbsp; <strong>## Title 2</strong> &nbsp;|&nbsp; <strong>**Bold**</strong></p>
<p className="mt-1"><strong>- List Item</strong> &nbsp;|&nbsp; <strong>&gt; Blockquote</strong></p>
<p className="mt-1 text-purple-400"><strong>![Image Description](/applications/{slug || "slug"}/image.jpg)</strong></p>
<div className="mt-3 pt-3 border-t border-purple-500/10">
<p className="mb-1"><strong>Media Assets <span className="text-emerald-400">(use the Assets button to browse & upload)</span>:</strong></p>
<p className="text-emerald-400/80">![alt](/applications/{slug || "slug"}/images/photo.jpg) Image</p>
<p className="text-blue-400/80 mt-1">[VIDEO:/applications/{slug || "slug"}/videos/clip.mp4] Video</p>
<p className="text-purple-400/80 mt-1">[3D:/applications/{slug || "slug"}/models/machine.glb] 3D Model</p>
</div>
</div>
)}
{slug && <AssetManager slug={slug} isOpen={isAssetManagerOpen} onClose={() => setIsAssetManagerOpen(false)} onInsert={handleAssetInsert} />}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 🔥 MAIN PAGE — Applications Manager (Knowledge Base)
// ─────────────────────────────────────────────────────────────────────────────
export default function ApplicationsManager() {
const router = useRouter();
const [apps, setApps] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingApp, setEditingApp] = useState<any | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"basic" | "advantages" | "sections" | "dashboard">("basic");
const [advantages, setAdvantages] = useState<any[]>([]);
const [sections, setSections] = useState<any[]>([]);
const [dashboardMetrics, setDashboardMetrics] = useState<any[]>([]);
const fetchApps = async () => { setIsLoading(true); const res = await getApplications(); if (res.success && res.apps) setApps(res.apps); setIsLoading(false); };
useEffect(() => { fetchApps(); }, []);
const openEditModal = (app: any) => {
setEditingApp(app); setActiveTab("basic");
try { setAdvantages(JSON.parse(app.advantagesJson || "[]")); } catch { setAdvantages([]); }
try { setSections(JSON.parse(app.sectionsJson || "[]")); } catch { setSections([]); }
try { setDashboardMetrics(JSON.parse(app.dashboardMetricsJson || "[]")); } catch { setDashboardMetrics([]); }
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true);
const formData = new FormData(e.currentTarget);
formData.append("advantagesJson", JSON.stringify(advantages));
formData.append("sectionsJson", JSON.stringify(sections));
formData.append("dashboardMetricsJson", JSON.stringify(dashboardMetrics));
const res = await updateApplicationData(formData);
if (res.error) { alert("Error saving data: " + res.error); }
else { setEditingApp(null); await fetchApps(); router.refresh(); }
setIsSubmitting(false);
};
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true);
await createApplication(new FormData(e.currentTarget));
setIsCreateModalOpen(false); fetchApps(); setIsSubmitting(false);
};
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-purple-400 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"><Layers className="text-purple-400" /> Knowledge Base</h1>
<p className="text-[#86868B] mt-2">Manage the technical literature and general specifications of your application categories.</p>
</div>
<button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2 bg-purple-500/10 text-purple-400 border border-purple-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-purple-500 hover:text-white transition-all"><Plus size={18} /> New Application</button>
</div>
</div>
<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">Application Sector</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold">Visibility</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
<tbody>
{isLoading ? (
<tr><td colSpan={4} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading database...</td></tr>
) : apps.map((app) => {
const isPopulated = app.heroDescription && app.heroDescription.length > 10;
return (
<tr key={app.slug} className={`border-b border-white/5 transition-colors group ${app.isActive ? 'hover:bg-white/[0.02]' : 'opacity-50'}`}>
<td className="p-6"><p className="font-medium text-white">{app.title}</p><p className="text-xs text-[#86868B] font-mono mt-1">/{app.slug}</p></td>
<td className="p-6">{isPopulated ? <span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit font-semibold"><CheckCircle2 size={12} /> Populated</span> : <span className="bg-white/5 text-[#86868B] border border-white/10 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit"><AlertCircle size={12} /> Pending Setup</span>}</td>
<td className="p-6"><button onClick={() => {toggleApplication(app.slug, app.isActive); fetchApps();}} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${app.isActive ? 'bg-purple-500/10 text-purple-400' : 'bg-red-500/10 text-red-400'}`}>{app.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={() => openEditModal(app)} className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all ${isPopulated ? 'bg-white/5 text-white hover:bg-white/10' : 'bg-purple-500 text-white hover:bg-purple-400'}`}><DatabaseZap size={14} /> Manage Core</button><button onClick={() => { if(confirm("Delete this application forever?")) { deleteApplication(app.slug); fetchApps(); } }} className="text-[#86868B] hover:text-red-400 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Trash2 size={18}/></button></div></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{isCreateModalOpen && (
<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">
<button onClick={() => setIsCreateModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<h3 className="text-2xl font-light mb-6 text-purple-400">Add New Application</h3>
<form onSubmit={handleCreate} className="space-y-4">
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Title</label><input name="title" required placeholder="e.g. Digital Printing" className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Subtitle</label><input name="subtitle" required placeholder="e.g. Inkjet Drying" className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Category Label</label><input name="category" required placeholder="e.g. Graphic Arts" className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
<div className="bg-gradient-to-r from-purple-500/10 to-transparent border border-purple-500/20 p-4 rounded-xl flex items-center justify-between mt-6">
<div className="flex items-center gap-3"><div className="p-2 bg-purple-500/20 rounded-lg text-purple-400"><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">Generates Base Locales</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 peer-focus:outline-none 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-purple-500"></div></label>
</div>
<button type="submit" disabled={isSubmitting} className="w-full bg-purple-500 text-white py-3 mt-2 rounded-xl text-sm font-semibold hover:bg-purple-400 flex items-center justify-center gap-2">{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : "Create Application"}</button>
</form>
</div>
</div>
)}
{editingApp && (
<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 shrink-0">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-purple-500 to-transparent"></div>
<button onClick={() => setEditingApp(null)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors"><X size={20} /></button>
<h3 className="text-2xl font-light mb-1 text-purple-400">Data Management Core</h3>
<p className="text-[#86868B] text-[10px] uppercase font-mono tracking-widest mb-6">Route: /{editingApp.slug.toUpperCase()}</p>
<div className="flex gap-6 border-b border-white/10 overflow-x-auto">
{[{ id: "basic", label: "Overview", icon: FileText },{ id: "advantages", label: "Advantages", icon: CheckCircle2 },{ id: "sections", label: "Tech Sections", icon: AlignLeft },{ id: "dashboard", label: "Dashboard UI", icon: LayoutTemplate }].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-purple-400 border-purple-400" : "text-[#86868B] border-transparent hover:text-white"}`}><t.icon size={14} /> {t.label}</button>
))}
</div>
</div>
<form id="edit-app-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
<input type="hidden" name="slug" value={editingApp.slug} />
<div className={activeTab === "basic" ? "space-y-6 animate-in fade-in" : "hidden"}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Display Title</label><input name="title" defaultValue={editingApp.title} required className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Subtitle</label><input name="subtitle" defaultValue={editingApp.subtitle} required className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
</div>
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Category Label</label><input name="category" defaultValue={editingApp.category} required className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
<div><label className="block text-[10px] uppercase text-purple-400 mb-1 flex items-center justify-between"><span>External Card Description (Homepage)</span><span className="text-[#86868B]">Max 150 chars</span></label><textarea name="shortDescription" defaultValue={editingApp.shortDescription} required rows={2} placeholder="Short summary for the public homepage cards..." className="w-full bg-purple-500/5 border border-purple-500/20 rounded-xl p-3 text-white focus:border-purple-500 outline-none resize-none" /></div>
<div>
<label className="block text-[10px] uppercase text-[#86868B] mb-2 flex justify-between items-center"><span>Deep Page Story (Markdown)</span><span className="text-purple-400/60 text-[9px] font-normal normal-case">Rich Editor + Asset Manager</span></label>
<MarkdownEditor name="heroDescription" defaultValue={editingApp.heroDescription} required rows={14} placeholder="Write the application's deep technical content here using Markdown..." slug={editingApp.slug} />
</div>
</div>
<div className={activeTab === "advantages" ? "space-y-4 animate-in fade-in" : "hidden"}>
{advantages.map((adv, idx) => (
<div key={idx} className="bg-black/40 border border-white/10 p-4 rounded-xl relative group">
<button type="button" onClick={() => setAdvantages(advantages.filter((_, i) => i !== idx))} className="absolute top-4 right-4 text-[#86868B] hover:text-red-400"><Trash2 size={16}/></button>
<input value={adv.title} onChange={e => { const n = [...advantages]; n[idx].title = e.target.value; setAdvantages(n); }} placeholder="Advantage Title" className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white mb-3 focus:border-purple-500 outline-none" />
<textarea value={adv.description} onChange={e => { const n = [...advantages]; n[idx].description = e.target.value; setAdvantages(n); }} placeholder="Description..." rows={2} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:border-purple-500 outline-none resize-none" />
</div>
))}
<button type="button" onClick={() => setAdvantages([...advantages, { title: "", description: "" }])} className="w-full border border-dashed border-white/20 text-[#86868B] hover:text-white hover:border-purple-500 py-3 rounded-xl flex items-center justify-center gap-2"><Plus size={16}/> Add Advantage</button>
</div>
<div className={activeTab === "sections" ? "space-y-6 animate-in fade-in" : "hidden"}>
{sections.map((sec, secIdx) => (
<div key={secIdx} className="bg-black/40 border border-white/10 p-6 rounded-2xl relative">
<button type="button" onClick={() => setSections(sections.filter((_, i) => i !== secIdx))} className="absolute top-4 right-4 text-[#86868B] hover:text-red-400"><Trash2 size={16}/></button>
<div className="flex gap-4 mb-4">
<div className="flex-1"><input value={sec.title} onChange={e => { const n = [...sections]; n[secIdx].title = e.target.value; setSections(n); }} placeholder="Section Title" className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:border-purple-500 outline-none" /></div>
<label className="flex items-center gap-2 text-sm text-white mt-2 cursor-pointer"><input type="checkbox" checked={sec.isMainTech} onChange={e => { const n = [...sections]; n[secIdx].isMainTech = e.target.checked; setSections(n); }} className="accent-purple-500" /> Main Tech</label>
</div>
<div className="pl-4 border-l-2 border-white/10 space-y-3">
{sec.items.map((item: any, itemIdx: number) => (
<div key={itemIdx} className="flex gap-2">
<input value={item.label} onChange={e => { const n = [...sections]; n[secIdx].items[itemIdx].label = e.target.value; setSections(n); }} placeholder="Label" className="w-1/3 bg-black/40 border border-white/10 rounded-lg p-2 text-xs text-white outline-none" />
<input value={item.content} onChange={e => { const n = [...sections]; n[secIdx].items[itemIdx].content = e.target.value; setSections(n); }} placeholder="Content" className="flex-1 bg-black/40 border border-white/10 rounded-lg p-2 text-xs text-white outline-none" />
<button type="button" onClick={() => { const n = [...sections]; n[secIdx].items.splice(itemIdx, 1); setSections(n); }} className="text-[#86868B] hover:text-red-400 p-2"><Trash2 size={14}/></button>
</div>
))}
<button type="button" onClick={() => { const n = [...sections]; n[secIdx].items.push({ label: "", content: "" }); setSections(n); }} className="text-xs text-purple-400 hover:text-white flex items-center gap-1 mt-2"><Plus size={12}/> Add Row</button>
</div>
</div>
))}
<button type="button" onClick={() => setSections([...sections, { title: "", isMainTech: false, items: [] }])} className="w-full border border-dashed border-white/20 text-[#86868B] hover:text-white py-3 rounded-xl flex items-center justify-center gap-2"><Plus size={16}/> Add Section</button>
</div>
<div className={activeTab === "dashboard" ? "space-y-4 animate-in fade-in" : "hidden"}>
{dashboardMetrics.map((metric, idx) => (
<div key={idx} className="bg-black/40 border border-white/10 p-4 rounded-xl relative grid grid-cols-3 gap-3">
<button type="button" onClick={() => setDashboardMetrics(dashboardMetrics.filter((_, i) => i !== idx))} className="absolute -top-2 -right-2 text-[#86868B] hover:text-red-400 bg-black rounded-full p-1"><Trash2 size={14}/></button>
<input value={metric.label} onChange={e => { const n = [...dashboardMetrics]; n[idx].label = e.target.value; setDashboardMetrics(n); }} placeholder="Label" className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white outline-none" />
<input value={metric.value} onChange={e => { const n = [...dashboardMetrics]; n[idx].value = e.target.value; setDashboardMetrics(n); }} placeholder="Value (e.g. 5kW)" className="w-full bg-black/40 border border-[#00F0FF]/30 rounded-lg p-2 text-sm text-[#00F0FF] outline-none" />
<input value={metric.subtext} onChange={e => { const n = [...dashboardMetrics]; n[idx].subtext = e.target.value; setDashboardMetrics(n); }} placeholder="Subtext" className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-[#86868B] outline-none" />
</div>
))}
<button type="button" onClick={() => setDashboardMetrics([...dashboardMetrics, { label: "", value: "", subtext: "" }])} className="w-full border border-dashed border-white/20 text-[#86868B] hover:text-white py-3 rounded-xl flex items-center justify-center gap-2"><Plus size={16}/> Add Metric</button>
</div>
<div className="bg-gradient-to-r from-purple-500/10 to-transparent border border-purple-500/20 p-4 rounded-xl flex items-center justify-between mt-10">
<div className="flex items-center gap-3"><div className="p-2 bg-purple-500/20 rounded-lg text-purple-400"><Sparkles size={18} /></div><div><p className="text-sm text-white font-medium">Flux AI JSON Translation</p><p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">Translates Markdown & Complex Data Arrays</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 peer-focus:outline-none 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-purple-500"></div></label>
</div>
</form>
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3 shrink-0">
<button type="button" onClick={() => setEditingApp(null)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white">Cancel</button>
<button onClick={() => (document.getElementById("edit-app-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="flex items-center gap-2 bg-purple-500 text-white px-8 py-3 rounded-xl text-sm font-semibold hover:bg-purple-400 disabled:opacity-50 transition-colors shadow-[0_0_15px_rgba(168,85,247,0.3)]">{isSubmitting ? <><Loader2 size={18} className="animate-spin" /> Processing AI...</> : "Deploy Changes"}</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,113 @@
"use server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
// 1. MÉTRICAS DEL SISTEMA (RAM y Uptime)
export async function getSystemMetrics() {
try {
await prisma.$queryRaw`SELECT 1`; // Ping a Postgres
const mem = process.memoryUsage();
return {
success: true,
dbStatus: "Connected - Optimal",
uptime: process.uptime(),
memory: {
used: Math.round(mem.heapUsed / 1024 / 1024),
total: Math.round(mem.heapTotal / 1024 / 1024),
}
};
} catch (error) {
return {
success: false,
dbStatus: "Disconnected",
uptime: process.uptime(),
memory: { used: 0, total: 0 }
};
}
}
// 2. EXPORTAR BASE DE DATOS (SNAPSHOT)
export async function exportDatabase() {
try {
const data = {
adminUsers: await prisma.adminUser.findMany(),
globalNodes: await prisma.globalNode.findMany(),
applications: await prisma.application.findMany(),
timelineEvents: await prisma.timelineEvent.findMany(),
newsArticles: await prisma.newsArticle.findMany(),
heritageSections: await prisma.heritageSection.findMany(),
spareParts: await prisma.sparePart.findMany(),
operationsSignals: await prisma.operationsSignal.findMany(),
notificationRoutes: await prisma.notificationRoute.findMany(),
pageContents: await prisma.pageContent.findMany(),
};
// Retornamos el JSON como string
return { success: true, data: JSON.stringify(data) };
} catch (error) {
console.error("Export Error:", error);
return { error: "Failed to generate database snapshot." };
}
}
// 3. RESTAURAR BASE DE DATOS (DANGER ZONE)
export async function restoreDatabase(formData: FormData) {
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const confirm = formData.get("confirm") as string;
const jsonString = formData.get("jsonString") as string;
if (confirm !== "CONFIRM-RESTORE") return { error: "Security phrase is incorrect." };
if (!username || !password || !jsonString) return { error: "Missing required fields." };
try {
// A. Validar Identidad del Administrador
const admin = await prisma.adminUser.findUnique({ where: { username: username.toLowerCase().trim() } });
if (!admin) return { error: "Invalid credentials or unauthorized access." };
const isValid = await bcrypt.compare(password, admin.passwordHash);
if (!isValid) return { error: "Invalid credentials or unauthorized access." };
// B. Parsear el JSON y revivir las Fechas (Prisma necesita objetos Date, no strings)
const dateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const data = JSON.parse(jsonString, (key, value) => {
if (typeof value === 'string' && dateRegex.test(value)) return new Date(value);
return value;
});
if (!data.adminUsers || !data.applications) return { error: "Invalid backup file structure." };
// C. Ejecutar Restauración en Transacción (Todo o Nada)
await prisma.$transaction(async (tx: any) => {
// Borrado Total
await tx.adminUser.deleteMany();
await tx.globalNode.deleteMany();
await tx.application.deleteMany();
await tx.timelineEvent.deleteMany();
await tx.newsArticle.deleteMany();
await tx.heritageSection.deleteMany();
await tx.sparePart.deleteMany();
await tx.operationsSignal.deleteMany();
await tx.notificationRoute.deleteMany();
await tx.pageContent.deleteMany();
// Sembrado de Datos
if (data.adminUsers.length) await tx.adminUser.createMany({ data: data.adminUsers });
if (data.globalNodes.length) await tx.globalNode.createMany({ data: data.globalNodes });
if (data.applications.length) await tx.application.createMany({ data: data.applications });
if (data.timelineEvents.length) await tx.timelineEvent.createMany({ data: data.timelineEvents });
if (data.newsArticles.length) await tx.newsArticle.createMany({ data: data.newsArticles });
if (data.heritageSections.length) await tx.heritageSection.createMany({ data: data.heritageSections });
if (data.spareParts.length) await tx.sparePart.createMany({ data: data.spareParts });
if (data.operationsSignals.length) await tx.operationsSignal.createMany({ data: data.operationsSignals });
if (data.notificationRoutes.length) await tx.notificationRoute.createMany({ data: data.notificationRoutes });
if (data.pageContents.length) await tx.pageContent.createMany({ data: data.pageContents });
});
return { success: true };
} catch (error) {
console.error("Restore Error:", error);
return { error: "Database restore failed. The backup file might be corrupted." };
}
}
@@ -0,0 +1,219 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import {
ArrowLeft, Server, Activity, Database, HardDrive,
DownloadCloud, ShieldAlert, UploadCloud, Loader2, CheckCircle2
} from "lucide-react";
import { getSystemMetrics, exportDatabase, restoreDatabase } from "./actions";
export default function SystemHealth() {
const [metrics, setMetrics] = useState<any>(null);
const [isExporting, setIsExporting] = useState(false);
// Restore State
const [isRestoring, setIsRestoring] = useState(false);
const [restoreError, setRestoreError] = useState("");
const [restoreSuccess, setRestoreSuccess] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchMetrics = async () => {
const res = await getSystemMetrics();
if (res.success) setMetrics(res);
};
useEffect(() => {
fetchMetrics();
// Actualizar métricas cada 10 segundos
const interval = setInterval(fetchMetrics, 10000);
return () => clearInterval(interval);
}, []);
const formatUptime = (seconds: number) => {
const d = Math.floor(seconds / (3600 * 24));
const h = Math.floor(seconds % (3600 * 24) / 3600);
const m = Math.floor(seconds % 3600 / 60);
return `${d}d ${h}h ${m}m`;
};
const handleExport = async () => {
setIsExporting(true);
const res = await exportDatabase();
if (res.success && res.data) {
// Crear un Blob y forzar la descarga del JSON
const blob = new Blob([res.data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `flux-db-snapshot-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
alert(res.error || "Export failed.");
}
setIsExporting(false);
};
const handleRestore = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!selectedFile) return setRestoreError("Please select a JSON backup file.");
setIsRestoring(true);
setRestoreError("");
try {
const reader = new FileReader();
reader.onload = async (event) => {
const jsonString = event.target?.result as string;
const formData = new FormData(e.currentTarget as HTMLFormElement);
formData.append("jsonString", jsonString);
const res = await restoreDatabase(formData);
if (res.error) {
setRestoreError(res.error);
} else {
setRestoreSuccess(true);
// Recargar para forzar la re-lectura de la BD
setTimeout(() => window.location.href = "/hq-command/dashboard", 3000);
}
setIsRestoring(false);
};
reader.readAsText(selectedFile);
} catch (error) {
setRestoreError("Failed to read the file.");
setIsRestoring(false);
}
};
return (
<div className="min-h-screen p-6 md:p-12 max-w-5xl mx-auto">
{/* HEADER */}
<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>
<h1 className="text-3xl font-light text-white flex items-center gap-3">
<Server className="text-blue-400" /> System Health & Vault
</h1>
<p className="text-[#86868B] mt-2">Server telemetry, data snapshots, and disaster recovery protocols.</p>
</div>
</div>
{/* TELEMETRÍA (MÉTRICAS) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div className="bg-[#111] border border-white/5 p-6 rounded-3xl relative overflow-hidden">
<div className="absolute -right-4 -bottom-4 opacity-5"><Activity size={100} /></div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-2">Node.js Uptime</span>
<p className="text-2xl font-mono text-white">
{metrics ? formatUptime(metrics.uptime) : "Loading..."}
</p>
</div>
<div className="bg-[#111] border border-white/5 p-6 rounded-3xl relative overflow-hidden">
<div className="absolute -right-4 -bottom-4 opacity-5"><HardDrive size={100} /></div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-2">Heap Memory Usage</span>
<p className="text-2xl font-mono text-white">
{metrics ? `${metrics.memory.used} MB ` : "0 MB "}
<span className="text-sm text-[#86868B]">/ {metrics ? metrics.memory.total : 0} MB</span>
</p>
</div>
<div className="bg-[#111] border border-white/5 p-6 rounded-3xl relative overflow-hidden">
<div className="absolute -right-4 -bottom-4 opacity-5"><Database size={100} /></div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-2">Postgres Connection</span>
<div className="flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
</span>
<p className="text-lg font-medium text-emerald-400">
{metrics ? metrics.dbStatus : "Connecting..."}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* ZONA VERDE: EXPORTAR (BACKUP) */}
<div className="bg-[#111] border border-white/5 rounded-3xl p-8 flex flex-col">
<div className="flex items-center gap-3 text-emerald-400 mb-4">
<DownloadCloud size={24} />
<h3 className="text-xl font-medium">Data Snapshot</h3>
</div>
<p className="text-sm text-[#86868B] leading-relaxed mb-8 flex-1">
Download a complete JSON snapshot of your PostgreSQL database. This file contains all configurations, users, and content needed to perfectly clone or restore your environment via Docker.
</p>
<button
onClick={handleExport}
disabled={isExporting}
className="w-full bg-white text-black py-4 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-gray-200 transition-colors disabled:opacity-50"
>
{isExporting ? <><Loader2 size={18} className="animate-spin" /> Compiling Data...</> : "Download Secure Backup"}
</button>
</div>
{/* ZONA ROJA: RESTAURAR (DANGER ZONE) */}
<div className="bg-rose-500/5 border border-rose-500/20 rounded-3xl p-8 relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-rose-500 to-transparent"></div>
{restoreSuccess ? (
<div className="flex flex-col items-center justify-center h-full text-center animate-in fade-in zoom-in duration-500">
<CheckCircle2 size={48} className="text-emerald-400 mb-4" />
<h3 className="text-2xl font-light text-white mb-2">Restoration Complete</h3>
<p className="text-[#86868B]">The database has been successfully overwritten. Rebooting session...</p>
</div>
) : (
<>
<div className="flex items-center gap-3 text-rose-500 mb-4">
<ShieldAlert size={24} />
<h3 className="text-xl font-medium">Disaster Recovery</h3>
</div>
<p className="text-xs text-rose-400/80 leading-relaxed mb-6">
<strong>WARNING:</strong> Uploading a snapshot will instantaneously erase all current database records and overwrite them. This process is irreversible.
</p>
<form onSubmit={handleRestore} className="space-y-4">
<div className="bg-black/40 border border-white/5 p-3 rounded-xl flex items-center justify-between">
<span className="text-xs text-[#86868B] truncate max-w-[200px]">
{selectedFile ? selectedFile.name : "No backup selected"}
</span>
<button type="button" onClick={() => fileInputRef.current?.click()} className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors">
<UploadCloud size={14} className="inline mr-1" /> Select JSON
</button>
<input type="file" accept=".json" ref={fileInputRef} onChange={e => setSelectedFile(e.target.files?.[0] || null)} className="hidden" />
</div>
<div className="grid grid-cols-2 gap-3">
<input required name="username" placeholder="Admin Username" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm outline-none focus:border-rose-500" />
<input required name="password" type="password" placeholder="Admin Password" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm outline-none focus:border-rose-500" />
</div>
<input required name="confirm" placeholder="Type CONFIRM-RESTORE" className="w-full bg-black/60 border border-rose-500/30 rounded-xl px-4 py-3 text-rose-400 font-mono text-sm text-center outline-none focus:border-rose-500" />
{restoreError && <div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-lg text-rose-400 text-xs text-center">{restoreError}</div>}
<button
type="submit"
disabled={isRestoring || !selectedFile}
className="w-full bg-rose-600 text-white py-4 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-rose-500 transition-colors disabled:opacity-50"
>
{isRestoring ? <><Loader2 size={18} className="animate-spin" /> Overwriting Database...</> : "Execute Destructive Restore"}
</button>
</form>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// 🔥 IMPORTAMOS EL TRADUCTOR DE IA
import { translateContentForCMS } from "@/lib/aiTranslator";
export async function getHeritageSections() {
try {
const sections = await prisma.heritageSection.findMany({
orderBy: { order: 'asc' }
});
return { success: true, sections };
} catch (error) {
return { error: "Failed to fetch heritage data." };
}
}
export async function createHeritageSection(formData: FormData) {
try {
const type = formData.get("type") as string;
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const mediaUrl = formData.get("mediaUrl") as string;
const order = parseInt(formData.get("order") as string) || 0;
// 🔥 Capturamos el switch de la IA
const autoTranslate = formData.get("autoTranslate") === "on";
let translationsJson = "{}";
if (autoTranslate && (title || content)) {
const aiResult = await translateContentForCMS({
title: title || "",
content: content || ""
});
if (aiResult) translationsJson = JSON.stringify(aiResult);
}
await prisma.heritageSection.create({
data: { type, title, content, mediaUrl, order, translationsJson }
});
revalidatePath("/hq-command/dashboard/heritage");
revalidatePath("/[locale]/heritage", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to add section to heritage." };
}
}
export async function updateHeritageSection(formData: FormData) {
try {
const id = formData.get("id") as string;
const type = formData.get("type") as string;
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const mediaUrl = formData.get("mediaUrl") as string;
const order = parseInt(formData.get("order") as string) || 0;
const autoTranslate = formData.get("autoTranslate") === "on";
let updateData: any = { type, title, content, mediaUrl, order };
// 🔥 LA MAGIA DE LA IA EN LA EDICIÓN 🔥
if (autoTranslate && (title || content)) {
const aiResult = await translateContentForCMS({
title: title || "",
content: content || ""
});
if (aiResult) updateData.translationsJson = JSON.stringify(aiResult);
}
await prisma.heritageSection.update({
where: { id },
data: updateData
});
revalidatePath("/hq-command/dashboard/heritage");
revalidatePath("/[locale]/heritage", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to update section." };
}
}
export async function deleteHeritageSection(id: string) {
try {
await prisma.heritageSection.delete({ where: { id } });
revalidatePath("/hq-command/dashboard/heritage");
revalidatePath("/[locale]/heritage", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to delete section." };
}
}
@@ -0,0 +1,210 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
// 🔥 Agregamos Sparkles
import { ArrowLeft, BookOpen, Plus, Trash2, Loader2, X, Image as ImageIcon, FileText, Video, Edit3, Sparkles } from "lucide-react";
import { getHeritageSections, createHeritageSection, updateHeritageSection, deleteHeritageSection } from "./actions";
export default function HeritageManager() {
const [sections, setSections] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editingSec, setEditingSec] = useState<any | null>(null);
const [sectionType, setSectionType] = useState("text");
const fetchSections = async () => {
setIsLoading(true);
const res = await getHeritageSections();
if (res.success && res.sections) setSections(res.sections);
setIsLoading(false);
};
useEffect(() => { fetchSections(); }, []);
const openCreateModal = () => {
setEditingSec(null);
setSectionType("text");
setIsModalOpen(true);
};
const openEditModal = (sec: any) => {
setEditingSec(sec);
setSectionType(sec.type);
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formData = new FormData(e.currentTarget);
if (editingSec) {
await updateHeritageSection(formData);
} else {
await createHeritageSection(formData);
}
setIsModalOpen(false);
fetchSections();
setIsSubmitting(false);
};
const handleDelete = async (id: string) => {
if (confirm("Remove this section from the Heritage page?")) {
await deleteHeritageSection(id); fetchSections();
}
};
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-white 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">
<BookOpen className="text-white" /> The FLUX Heritage
</h1>
<p className="text-[#86868B] mt-2">Build Patrizio's deep story page block by block (Text, Images, Video).</p>
</div>
<button onClick={openCreateModal} className="flex items-center gap-2 bg-white text-black px-5 py-2.5 rounded-xl font-medium hover:bg-gray-200 transition-all">
<Plus size={18} /> Add Content Block
</button>
</div>
</div>
<div className="space-y-4">
{isLoading ? (
<div className="py-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading...</div>
) : sections.length === 0 ? (
<div className="p-12 text-center border border-white/10 rounded-3xl bg-black/20 text-[#86868B]">The Heritage page is currently empty. Add the first text block.</div>
) : (
sections.map((sec) => (
<div key={sec.id} className="bg-[#111] border border-white/5 p-6 rounded-2xl flex items-start justify-between group hover:bg-white/[0.02] transition-colors shadow-lg">
<div className="flex gap-4 w-full">
<div className="mt-1 text-[#86868B] shrink-0">
{sec.type === 'text' ? <FileText size={20}/> : sec.type === 'image' ? <ImageIcon size={20}/> : <Video size={20}/>}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] uppercase tracking-widest text-white/50 border border-white/10 px-2 py-0.5 rounded bg-black/40">Order: {sec.order}</span>
<span className="text-sm font-medium text-white">{sec.title || "Untitled Block"}</span>
</div>
{sec.content && <p className="text-xs text-[#86868B] max-w-2xl line-clamp-2 mt-2 leading-relaxed">{sec.content}</p>}
{sec.mediaUrl && (
<p className="text-xs text-[#00F0FF] mt-2 font-mono">
{sec.type === 'video' ? `/heritage/videos/${sec.mediaUrl}` : `/heritage/${sec.mediaUrl}`}
</p>
)}
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-all shrink-0 ml-4">
<button onClick={() => openEditModal(sec)} className="text-[#86868B] hover:text-white p-2 bg-white/5 rounded-lg"><Edit3 size={16}/></button>
<button onClick={() => handleDelete(sec.id)} className="text-[#86868B] hover:text-red-400 p-2 bg-red-500/10 rounded-lg"><Trash2 size={16}/></button>
</div>
</div>
))
)}
</div>
{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-xl 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 shrink-0">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white to-transparent"></div>
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors"><X size={20} /></button>
<h3 className="text-2xl font-light text-white">{editingSec ? "Edit Story Block" : "Add Story Block"}</h3>
</div>
{/* 🔥 LE DAMOS UN ID AL FORMULARIO 🔥 */}
<form id="heritage-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 space-y-5 [scrollbar-width:none]">
<input type="hidden" name="id" value={editingSec?.id || ""} />
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Block Type</label>
<select name="type" value={sectionType} onChange={(e) => setSectionType(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-white outline-none">
<option value="text">📝 Text (Markdown)</option>
<option value="image">🖼️ Large Image</option>
<option value="video">▶️ Local Video (.mp4)</option>
</select>
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Display Order (1, 2...)</label>
<input name="order" type="number" required defaultValue={editingSec?.order || 1} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" />
</div>
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Title (Optional)</label>
<input name="title" type="text" defaultValue={editingSec?.title} placeholder="e.g., The Early Days in Italy" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" />
</div>
{sectionType === "text" && (
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex justify-between items-center">
<span>Story Content (Markdown)</span>
</label>
<textarea name="content" defaultValue={editingSec?.content} required rows={10} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-4 text-white text-sm focus:border-white outline-none resize-none leading-relaxed mb-3" placeholder="Write the history here..." />
<div className="bg-white/5 border border-white/10 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed">
<p className="text-white font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
<p><strong># Title 1</strong> &nbsp;|&nbsp; <strong>## Title 2</strong> &nbsp;|&nbsp; <strong>**Bold**</strong></p>
<p className="mt-1"><strong>- List Item</strong> &nbsp;|&nbsp; <strong>&gt; Blockquote</strong></p>
<p className="mt-1 text-[#00F0FF]"><strong>![Image Description](/heritage/image-name.jpg)</strong></p>
<div className="mt-3 pt-3 border-t border-white/10">
<p className="mb-1"><strong>Tables (Last column highlights automatically):</strong></p>
<p>| Year | Milestone | Innovation |</p>
<p>|---|---|---|</p>
<p>| 1980 | First Patent | Radiofrequency |</p>
</div>
</div>
</div>
)}
{sectionType !== "text" && (
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">
{sectionType === "image" ? "Image Filename (in /public/heritage/)" : "Video Filename (in /public/heritage/videos/)"}
</label>
<input name="mediaUrl" type="text" defaultValue={editingSec?.mediaUrl} required placeholder={sectionType === "image" ? "e.g., patrizio-1980.jpg" : "e.g., history-1980.mp4"} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm focus:border-white outline-none" />
</div>
)}
{/* 🔥 SWITCH DE LA IA AÑADIDO 🔥 */}
<div className="bg-gradient-to-r from-white/10 to-transparent border border-white/20 p-4 rounded-xl flex items-center justify-between mt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg text-white"><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">Translates Markdown to 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 peer-focus:outline-none 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-white"></div>
</label>
</div>
</form>
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3 shrink-0">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white transition-colors">Cancel</button>
{/* 🔥 APUNTAMOS AL FORMULARIO 🔥 */}
<button onClick={() => (document.getElementById("heritage-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="w-full md:w-auto bg-white text-black py-3 px-8 rounded-xl text-sm font-semibold hover:bg-gray-200 transition-colors disabled:opacity-50 flex justify-center items-center gap-2">
{isSubmitting ? <Loader2 className="animate-spin mx-auto" size={18}/> : (editingSec ? "Save Changes" : "Add to Heritage Page")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,188 @@
// /src/app/hq-command/dashboard/inbox/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import fs from "fs";
import path from "path";
import { sendEmail } from "@/lib/mailer";
// 1. GET ALL SIGNALS
export async function getSignals() {
try {
const signals = await prisma.operationsSignal.findMany({
orderBy: { createdAt: "desc" },
});
return { success: true, signals };
} catch (error) {
return { error: "Error loading Operations Inbox." };
}
}
// 2. GET ALL CLIENTS (CRM) 🔥 NUEVO
export async function getClients() {
try {
const clients = await prisma.clientUser.findMany({
include: {
signals: {
orderBy: { createdAt: "desc" }
}
},
orderBy: { createdAt: "desc" }
});
return { success: true, clients };
} catch (error) {
return { error: "Error loading Client Directory." };
}
}
// 3. APPROVE ACCESS REQUEST 🔥 NUEVO
export async function approveAccessRequest(signalId: string) {
try {
const signal = await prisma.operationsSignal.findUnique({ where: { id: signalId } });
if (!signal || !signal.clientId) return { error: "Ticket or Client not found." };
// Aprobar al cliente
await prisma.clientUser.update({
where: { id: signal.clientId },
data: { isApproved: true }
});
// Enviar correo de Bienvenida
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com";
const html = `
<div style="background-color: #F5F5F7; padding: 40px 20px; width: 100%; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 8px 32px rgba(0,0,0,0.06);">
<div style="padding: 40px 32px 32px 32px; text-align: center; border-bottom: 1px solid #E5E5EA;">
<img src="${appUrl}/logoEmail.png" alt="FLUX SRL" style="height: 50px; width: auto; object-fit: contain; margin-bottom: 24px;" />
<p style="color: #10B981; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin: 0; font-weight: 700;">Account Approved</p>
<h1 style="margin: 12px 0 16px 0; font-size: 26px; font-weight: 300; color: #1D1D1F; letter-spacing: -0.5px;">Welcome to FLUX B2B</h1>
</div>
<div style="padding: 32px; color: #1D1D1F; text-align: center;">
<p style="font-size: 16px; line-height: 1.6; color: #1D1D1F; margin-bottom: 24px;">Hello <strong>${signal.clientName}</strong>,</p>
<p style="font-size: 15px; line-height: 1.6; color: #86868B; margin-bottom: 32px;">Your corporate account for <strong>${signal.clientCompany}</strong> has been successfully verified and approved by our engineering team.</p>
<p style="font-size: 15px; line-height: 1.6; color: #86868B; margin-bottom: 32px;">You now have full access to our exclusive Component Matrix, technical datasheets, and direct engineering support.</p>
<a href="${appUrl}/parts" style="display: inline-block; padding: 14px 28px; background-color: #0066CC; color: #ffffff; text-decoration: none; border-radius: 12px; font-weight: 600; font-size: 14px;">Access B2B Portal</a>
</div>
<div style="background-color: #FAFAFA; border-top: 1px solid #E5E5EA; padding: 24px; font-size: 12px; color: #86868B; text-align: center;">
<p style="margin: 0;">Automated by <strong>FLUX Operations Command</strong></p>
</div>
</div>
</div>
`;
const emailResult = await sendEmail({
to: [signal.clientEmail],
subject: "Your FLUX B2B Account is Approved",
html,
});
// Marcar ticket como resuelto
await prisma.operationsSignal.update({
where: { id: signalId },
data: {
status: "RESOLVED",
emailSentTo: emailResult.sentTo.join(", "),
emailSentAt: emailResult.sentAt,
emailError: emailResult.error,
}
});
revalidatePath("/hq-command/dashboard/inbox");
return { success: true, emailSent: emailResult.success };
} catch (error: any) {
return { error: "Failed to approve client." };
}
}
// 4. UPDATE STATUS
export async function updateSignalStatus(id: string, newStatus: string) {
try {
await prisma.operationsSignal.update({ where: { id }, data: { status: newStatus } });
revalidatePath("/hq-command/dashboard/inbox");
return { success: true };
} catch (error) { return { error: "Failed to update status." }; }
}
// 5. RESOLVE & CLEAN FILES
export async function resolveAndCleanSignal(id: string) {
try {
const signal = await prisma.operationsSignal.findUnique({ where: { id } });
if (!signal) return { error: "Ticket not found." };
let filesCleaned = false;
if (signal.attachedFiles && signal.attachedFiles !== "[]") {
const files = JSON.parse(signal.attachedFiles);
if (files.length > 0) {
const firstFileUrl = files[0];
const parts = firstFileUrl.split('/');
if (parts.length >= 3 && parts[1] === 'operations-inbox') {
const folderName = parts[2];
const dirPath = path.join(process.cwd(), "public", "operations-inbox", folderName);
if (path.resolve(dirPath).startsWith(path.resolve(process.cwd(), "public", "operations-inbox"))) {
if (fs.existsSync(dirPath)) { fs.rmSync(dirPath, { recursive: true, force: true }); filesCleaned = true; }
}
}
}
}
await prisma.operationsSignal.update({ where: { id }, data: { status: "RESOLVED", attachedFiles: "[]" } });
revalidatePath("/hq-command/dashboard/inbox");
return { success: true, filesCleaned };
} catch (error) { return { error: "Failed to clean files." }; }
}
// 6. GET/UPDATE NOTIFICATION ROUTES
export async function getNotificationRoutes() {
try { const routes = await prisma.notificationRoute.findMany(); return { success: true, routes }; }
catch (error) { return { error: "Failed to load routing configurations." }; }
}
export async function updateNotificationRoute(routeType: string, emails: string) {
try { await prisma.notificationRoute.upsert({ where: { routeType }, update: { emails }, create: { routeType, emails, isActive: true } }); return { success: true }; }
catch (error) { return { error: "Failed to save email routing." }; }
}
// 7. DELETE RESOLVED SIGNAL
export async function deleteSignal(id: string) {
try { await prisma.operationsSignal.delete({ where: { id } }); revalidatePath("/hq-command/dashboard/inbox"); return { success: true }; }
catch (error) { return { error: "Failed to delete ticket." }; }
}
// 8. RESEND SIGNAL EMAIL
export async function resendSignalEmail(id: string) {
try {
const signal = await prisma.operationsSignal.findUnique({ where: { id } });
if (!signal) return { error: "Ticket not found." };
const route = await prisma.notificationRoute.findUnique({ where: { routeType: signal.type } });
let targetEmails = ["info@fluxsrl.com"];
if (route && route.isActive && route.emails) targetEmails = route.emails.split(",").map((e: any) => e.trim()).filter(Boolean);
// ... Lógica de HTML omitida por brevedad (Usa el mismo generador de HTML de antes)
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com";
const html = `... Tu HTML actual de reenvío ...`; // Usa el html que tenías en este script
const emailResult = await sendEmail({ to: targetEmails, subject: `[REMINDER: ${signal.type}] Signal from ${signal.clientCompany}`, html, replyTo: signal.clientEmail });
await prisma.operationsSignal.update({ where: { id }, data: { emailSentTo: emailResult.sentTo.join(", "), emailSentAt: emailResult.sentAt, emailError: emailResult.error } });
revalidatePath("/hq-command/dashboard/inbox");
return { success: true, emailSent: emailResult.success, error: emailResult.error };
} catch (error: any) { return { error: error.message || "Failed to resend email." }; }
}
// 9. DELETE CLIENT (CRM)
export async function deleteClient(id: string) {
try {
// Primero: Desvinculamos sus señales para no perder el historial contable/órdenes
await prisma.operationsSignal.updateMany({
where: { clientId: id },
data: { clientId: null }
});
// Segundo: Borramos al cliente de la base de datos
await prisma.clientUser.delete({
where: { id }
});
revalidatePath("/hq-command/dashboard/inbox");
return { success: true };
} catch (error) {
return { error: "Failed to delete client." };
}
}
+310
View File
@@ -0,0 +1,310 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import {
ArrowLeft, Radar, Inbox, CheckCircle2, Clock, AlertCircle,
Package, Video, Sparkles, Building2, Mail, Phone, ShieldAlert,
Trash2, Loader2, Settings, X, MailCheck, MailX, Users, Key
} from "lucide-react";
import { getSignals, updateSignalStatus, resolveAndCleanSignal, getNotificationRoutes, updateNotificationRoute, deleteSignal, resendSignalEmail, getClients, approveAccessRequest, deleteClient } from "./actions";
export default function OperationsInbox() {
const [viewMode, setViewMode] = useState<"SIGNALS" | "CLIENTS">("SIGNALS");
const [signals, setSignals] = useState<any[]>([]);
const [clients, setClients] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeSignal, setActiveSignal] = useState<any | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [routes, setRoutes] = useState({ ORDER: "sales@fluxsrl.com", DIAGNOSTIC: "support@fluxsrl.com", CONSULTATION: "engineering@fluxsrl.com" });
const [filterType, setFilterType] = useState<string>("ALL");
const [filterStatus, setFilterStatus] = useState<string>("ALL");
const fetchInitialData = async () => {
setIsLoading(true);
const [resSignals, resRoutes, resClients] = await Promise.all([getSignals(), getNotificationRoutes(), getClients()]);
if (resSignals.success) setSignals(resSignals.signals);
if (resClients.success) setClients(resClients.clients);
if (resRoutes.success && resRoutes.routes) {
const routeMap: any = { ...routes };
resRoutes.routes.forEach((r: any) => { routeMap[r.routeType] = r.emails; });
setRoutes(routeMap);
}
setIsLoading(false);
};
useEffect(() => { fetchInitialData(); }, []);
const handleSaveRoute = async (type: string, emails: string) => { await updateNotificationRoute(type, emails); alert(`Routing for ${type} updated.`); };
const handleStatusChange = async (id: string, status: string) => { setIsProcessing(true); await updateSignalStatus(id, status); await fetchInitialData(); if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, status })); setIsProcessing(false); };
const handleResolveAndClean = async (id: string) => { if (!confirm("Permanently delete attached files and mark as resolved?")) return; setIsProcessing(true); const res = await resolveAndCleanSignal(id); if (res.success) { await fetchInitialData(); setActiveSignal(null); } setIsProcessing(false); };
const handleDelete = async (id: string) => { if (!confirm("Permanently delete this ticket?")) return; setIsProcessing(true); await deleteSignal(id); await fetchInitialData(); setActiveSignal(null); setIsProcessing(false); };
const handleResendEmail = async (id: string) => { setIsProcessing(true); const res = await resendSignalEmail(id); if (res.success) { alert("Email reminder sent."); await fetchInitialData(); if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, emailSentAt: new Date(), emailError: null })); } else { alert("Failed: " + res.error); } setIsProcessing(false); };
// APROBAR CLIENTE
const handleApproveClient = async (signalId: string) => {
if (!confirm("Approve this client for B2B portal access? They will receive an email.")) return;
setIsProcessing(true);
const res = await approveAccessRequest(signalId);
if (res.success) {
alert("Client approved and email sent!");
await fetchInitialData();
setActiveSignal(null);
} else {
alert("Failed: " + res.error);
}
setIsProcessing(false);
};
// BORRAR CLIENTE
const handleDeleteClient = async (clientId: string) => {
if (!confirm("Permanently delete this client? Their past tickets will be kept but unlinked from their account.")) return;
setIsProcessing(true);
const res = await deleteClient(clientId);
if (res.success) {
await fetchInitialData();
} else {
alert("Failed: " + res.error);
}
setIsProcessing(false);
};
const parseJSON = (str: string | null) => { try { return JSON.parse(str || "[]"); } catch { return []; } };
const getTypeStyle = (type: string) => {
switch (type) { case "ORDER": return "bg-amber-500/10 text-amber-400 border-amber-500/20"; case "DIAGNOSTIC": return "bg-rose-500/10 text-rose-400 border-rose-500/20"; case "CONSULTATION": return "bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20"; case "ACCESS_REQUEST": return "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"; default: return "bg-white/5 text-white/70"; }
};
const getStatusIcon = (status: string) => {
switch (status) { case "PENDING": return <AlertCircle size={14} className="text-rose-400" />; case "REVIEWING": return <Clock size={14} className="text-amber-400" />; case "RESOLVED": return <CheckCircle2 size={14} className="text-emerald-400" />; default: return null; }
};
const filtered = signals.filter(s => {
if (filterType !== "ALL" && s.type !== filterType) return false;
if (filterStatus !== "ALL" && s.status !== filterStatus) return false;
return true;
});
const pendingCount = signals.filter(s => s.status === "PENDING").length;
return (
<div className="min-h-screen p-6 md:p-12 max-w-[1400px] mx-auto flex flex-col h-screen">
<div className="mb-8 shrink-0 flex justify-between items-end">
<div>
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-rose-400 transition-colors mb-6 group"><ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center</Link>
<div className="flex items-center gap-4 mb-2">
<h1 className="text-3xl font-light text-white flex items-center gap-3"><Radar className="text-rose-500" /> Hub</h1>
<button onClick={() => setIsSettingsOpen(true)} className="p-2 bg-white/5 hover:bg-white/10 text-[#86868B] hover:text-white rounded-xl transition-all" title="Email Routing"><Settings size={20} /></button>
</div>
<div className="flex gap-2 p-1 bg-white/5 rounded-xl w-fit mt-4">
<button onClick={() => setViewMode("SIGNALS")} className={`px-5 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 ${viewMode === "SIGNALS" ? "bg-white/10 text-white" : "text-[#86868B] hover:text-white"}`}><Inbox size={16}/> Operations Inbox</button>
<button onClick={() => { setViewMode("CLIENTS"); setActiveSignal(null); }} className={`px-5 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 ${viewMode === "CLIENTS" ? "bg-white/10 text-white" : "text-[#86868B] hover:text-white"}`}><Users size={16}/> Client Directory</button>
</div>
</div>
</div>
<div className="flex flex-col md:flex-row gap-6 flex-1 min-h-0 overflow-hidden">
{viewMode === "SIGNALS" && (
<>
<div className="w-full md:w-1/3 flex flex-col bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl h-full">
<div className="p-5 border-b border-white/10 bg-black/40 shrink-0">
<div className="flex justify-between items-center mb-3">
<h2 className="text-sm font-semibold uppercase tracking-widest text-[#86868B] flex items-center gap-2"><Inbox size={16} /> Signals</h2>
<span className="bg-rose-500 text-white text-xs font-bold px-2 py-0.5 rounded-full">{pendingCount}</span>
</div>
<div className="flex gap-1.5 flex-wrap">
{["ALL", "ORDER", "DIAGNOSTIC", "CONSULTATION", "ACCESS_REQUEST"].map(t => (
<button key={t} onClick={() => setFilterType(t)} className={`text-[9px] uppercase tracking-widest font-bold px-2 py-1 rounded-lg border transition-all ${filterType === t ? "bg-white/10 border-white/20 text-white" : "border-transparent text-[#86868B] hover:text-white"}`}>{t === "ALL" ? "All Types" : t === "ACCESS_REQUEST" ? "B2B ACCESS" : t}</button>
))}
</div>
<div className="flex gap-1.5 mt-2">
{["ALL", "PENDING", "REVIEWING", "RESOLVED"].map(s => (
<button key={s} onClick={() => setFilterStatus(s)} className={`text-[9px] uppercase tracking-widest font-bold px-2 py-1 rounded-lg border transition-all ${filterStatus === s ? "bg-white/10 border-white/20 text-white" : "border-transparent text-[#86868B] hover:text-white"}`}>{s === "ALL" ? "All Status" : s}</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto [scrollbar-width:none] p-3 space-y-2">
{isLoading ? <div className="p-10 text-center text-[#86868B]"><Loader2 size={24} className="animate-spin mx-auto mb-2" /></div>
: filtered.length === 0 ? <div className="p-10 text-center text-[#86868B] text-sm">No signals match filters.</div>
: filtered.map(signal => (
<button key={signal.id} onClick={() => setActiveSignal(signal)} className={`w-full text-left p-4 rounded-2xl border transition-all ${activeSignal?.id === signal.id ? 'bg-white/10 border-white/20' : 'bg-black/20 border-transparent hover:bg-white/5'}`}>
<div className="flex justify-between items-start mb-2">
<span className={`text-[9px] uppercase tracking-widest font-bold px-2 py-0.5 rounded border ${getTypeStyle(signal.type)}`}>{signal.type === "ACCESS_REQUEST" ? "B2B ACCESS" : signal.type}</span>
<div className="flex items-center gap-1.5">
{/* 🔥 FIX APLICADO: Título movido al <span> para evitar el error de Typescript con Lucide */}
{signal.emailSentAt ? (
<span title={`Email sent to ${signal.emailSentTo}`} className="flex items-center">
<MailCheck size={11} className="text-emerald-400" />
</span>
) : signal.emailError ? (
<span title={signal.emailError} className="flex items-center">
<MailX size={11} className="text-red-400" />
</span>
) : null}
{getStatusIcon(signal.status)}
</div>
</div>
<h3 className="text-sm font-medium text-white truncate">{signal.clientName}</h3>
<p className="text-xs text-[#86868B] truncate">{signal.clientCompany}</p>
<p className="text-[10px] text-[#86868B]/60 mt-2 font-mono">{signal.ticketId} · {new Date(signal.createdAt).toLocaleString()}</p>
</button>
))}
</div>
</div>
<div className="w-full md:w-2/3 bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl flex flex-col h-full relative">
{!activeSignal ? (
<div className="flex-1 flex flex-col items-center justify-center text-[#86868B]"><Radar size={48} className="opacity-20 mb-4" /><p>Select a signal to view details.</p></div>
) : (
<>
<div className="p-6 border-b border-white/10 bg-black/40 shrink-0 flex justify-between items-center">
<div>
<h2 className="text-xl font-light text-white mb-1">Ticket: {activeSignal.ticketId}</h2>
<div className="flex gap-2 items-center flex-wrap">
<span className={`text-[10px] uppercase tracking-widest font-bold px-2 py-0.5 rounded border ${getTypeStyle(activeSignal.type)}`}>{activeSignal.type === "ACCESS_REQUEST" ? "B2B PORTAL ACCESS" : activeSignal.type}</span>
<span className="text-xs text-[#86868B] flex items-center gap-1">{getStatusIcon(activeSignal.status)} {activeSignal.status}</span>
{activeSignal.emailSentAt ? (
<span className="text-[9px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded flex items-center gap-1"><MailCheck size={10} /> Sent to {activeSignal.emailSentTo}</span>
) : activeSignal.emailError ? (
<span className="text-[9px] text-red-400 bg-red-500/10 border border-red-500/20 px-2 py-0.5 rounded flex items-center gap-1" title={activeSignal.emailError}><MailX size={10} /> Email failed</span>
) : (
<span className="text-[9px] text-[#86868B] bg-white/5 px-2 py-0.5 rounded">No email record</span>
)}
</div>
</div>
<div className="flex gap-2">
{activeSignal.type === "ACCESS_REQUEST" && activeSignal.status === "PENDING" && (
<button disabled={isProcessing} onClick={() => handleApproveClient(activeSignal.id)} className="px-5 py-2 bg-emerald-500 text-black hover:bg-emerald-400 text-xs font-bold uppercase tracking-wider rounded-xl flex items-center gap-2"><Key size={14}/> Approve & Notify</button>
)}
{activeSignal.type !== "ACCESS_REQUEST" && <button disabled={isProcessing} onClick={() => handleResendEmail(activeSignal.id)} className="px-4 py-2 bg-[#00F0FF]/10 text-[#00F0FF] hover:bg-[#00F0FF]/20 text-xs font-semibold uppercase tracking-wider rounded-xl flex items-center gap-2"><Mail size={14} /> Resend</button>}
{activeSignal.status === "PENDING" && <button disabled={isProcessing} onClick={() => handleStatusChange(activeSignal.id, "REVIEWING")} className="px-4 py-2 bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 text-xs font-semibold uppercase tracking-wider rounded-xl">Reviewing</button>}
{activeSignal.status !== "RESOLVED" && <button disabled={isProcessing} onClick={() => handleResolveAndClean(activeSignal.id)} className="px-4 py-2 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 border border-emerald-500/20 text-xs font-semibold uppercase tracking-wider rounded-xl flex items-center gap-2"><ShieldAlert size={14} /> Resolve</button>}
{activeSignal.status === "RESOLVED" && <button disabled={isProcessing} onClick={() => handleDelete(activeSignal.id)} className="px-4 py-2 bg-red-500/10 text-red-400 hover:bg-red-500/20 border border-red-500/20 text-xs font-semibold uppercase tracking-wider rounded-xl flex items-center gap-2"><Trash2 size={14} /> Delete</button>}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8 [scrollbar-width:none]">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 bg-black/20 p-5 rounded-2xl border border-white/5">
<div className="min-w-0"><span className="text-[10px] uppercase text-[#86868B] block mb-1">Client</span><span className="text-sm text-white font-medium block truncate" title={activeSignal.clientName}>{activeSignal.clientName}</span></div>
<div className="min-w-0"><span className="text-[10px] uppercase text-[#86868B] block mb-1 flex items-center gap-1"><Building2 size={10}/> Company</span><span className="text-sm text-white block truncate" title={activeSignal.clientCompany}>{activeSignal.clientCompany}</span></div>
<div className="min-w-0"><span className="text-[10px] uppercase text-[#86868B] block mb-1 flex items-center gap-1"><Mail size={10}/> Email</span><a href={`mailto:${activeSignal.clientEmail}`} title={activeSignal.clientEmail} className="text-sm text-[#00F0FF] hover:underline block truncate">{activeSignal.clientEmail}</a></div>
<div className="min-w-0"><span className="text-[10px] uppercase text-[#86868B] block mb-1 flex items-center gap-1"><Phone size={10}/> Phone</span><span className="text-sm text-white font-mono block truncate" title={activeSignal.clientPhone || "N/A"}>{activeSignal.clientPhone || "N/A"}</span></div>
</div>
{activeSignal.message && <div><h3 className="text-[10px] font-bold uppercase tracking-widest text-[#86868B] mb-3 border-b border-white/10 pb-2">Notes</h3><p className="text-sm text-white/90 bg-white/5 p-4 rounded-xl border border-white/5 whitespace-pre-wrap">{activeSignal.message}</p></div>}
{activeSignal.aiAnalysis && <div><h3 className="text-[10px] font-bold uppercase tracking-widest text-[#00F0FF] mb-3 border-b border-[#00F0FF]/20 pb-2 flex items-center gap-2"><Sparkles size={12}/> AI Context</h3><div className="text-sm text-[#00F0FF]/90 bg-[#00F0FF]/5 p-4 rounded-xl border border-[#00F0FF]/20 whitespace-pre-wrap font-mono leading-relaxed">{activeSignal.aiAnalysis}</div></div>}
{activeSignal.type === "CONSULTATION" && activeSignal.aiAnalysis && (() => {
const lines = (activeSignal.aiAnalysis || "").split("\n");
const ext = (tag: string) => { const l = lines.find((l: string) => l.startsWith(`[${tag}]`)); return l ? l.replace(`[${tag}] `, "").trim() : null; };
const extBlock = (tag: string) => { const si = lines.findIndex((l: string) => l.startsWith(`[${tag}]`)); if (si === -1) return []; const r: string[] = []; for (let i = si+1; i < lines.length; i++) { const l = lines[i].trim(); if (l.startsWith("[") || l === "") break; r.push(l.replace(/^[•→]\s*/, "")); } return r; };
const industry = ext("INDUSTRY"), savings = ext("ESTIMATED SAVINGS"), volume = ext("PRODUCTION VOLUME"), preferred = ext("PREFERRED CONTACT"), timeframe = ext("TIMEFRAME");
const insights = extBlock("AI DISCUSSION POINTS"), topics = extBlock("SUGGESTED ENGINEERING TOPICS");
return (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{industry && <div className="bg-[#00F0FF]/5 border border-[#00F0FF]/10 p-3 rounded-xl"><span className="text-[9px] uppercase text-[#00F0FF] block mb-1">Industry</span><span className="text-sm text-white font-medium">{industry}</span></div>}
{savings && <div className="bg-emerald-500/5 border border-emerald-500/10 p-3 rounded-xl"><span className="text-[9px] uppercase text-emerald-400 block mb-1">Savings</span><span className="text-sm text-emerald-400 font-medium">{savings}</span></div>}
{volume && <div className="bg-white/5 border border-white/5 p-3 rounded-xl"><span className="text-[9px] uppercase text-[#86868B] block mb-1">Volume</span><span className="text-sm text-white">{volume}</span></div>}
{preferred && <div className="bg-white/5 border border-white/5 p-3 rounded-xl"><span className="text-[9px] uppercase text-[#86868B] block mb-1">Contact · {timeframe || ""}</span><span className="text-sm text-white">{preferred}</span></div>}
</div>
{insights.length > 0 && <div><h3 className="text-[10px] font-bold uppercase tracking-widest text-[#00F0FF] mb-3 border-b border-[#00F0FF]/20 pb-2 flex items-center gap-2"><Sparkles size={12}/> Discussion Points</h3><div className="space-y-2">{insights.map((item: string, i: number) => (<div key={i} className="flex items-start gap-2 bg-[#00F0FF]/5 px-4 py-2.5 rounded-xl border border-[#00F0FF]/10"><div className="w-1.5 h-1.5 rounded-full bg-[#00F0FF] mt-1.5 shrink-0" /><span className="text-sm text-white/90">{item}</span></div>))}</div></div>}
{topics.length > 0 && <div><h3 className="text-[10px] font-bold uppercase tracking-widest text-amber-500 mb-3 border-b border-amber-500/20 pb-2 flex items-center gap-2"><Package size={12}/> Prep Topics</h3><div className="space-y-2">{topics.map((t: string, i: number) => (<div key={i} className="flex items-start gap-2 bg-amber-500/5 px-4 py-2.5 rounded-xl border border-amber-500/10"><span className="text-amber-400 font-mono text-xs mt-0.5 shrink-0">{i+1}.</span><span className="text-sm text-white/90">{t}</span></div>))}</div></div>}
</div>
);
})()}
{activeSignal.type === "ORDER" && (
<div><h3 className="text-[10px] font-bold uppercase tracking-widest text-amber-500 mb-3 border-b border-amber-500/20 pb-2 flex items-center gap-2"><Package size={12}/> Requested Components</h3><div className="bg-black/40 border border-white/5 rounded-2xl overflow-hidden">{parseJSON(activeSignal.cartPayload).map((item: any, idx: number) => (<div key={idx} className="flex justify-between items-center p-4 border-b border-white/5 last:border-0"><div className="min-w-0 pr-4"><p className="text-sm font-medium text-white truncate">{item.title}</p><p className="text-[10px] font-mono text-amber-400 mt-1">SKU: {item.sku}</p></div><div className="text-right shrink-0"><span className="text-xs text-[#86868B] uppercase tracking-widest">QTY</span><p className="text-lg font-mono text-white">{item.quantity}</p></div></div>))}</div></div>
)}
{parseJSON(activeSignal.attachedFiles).length > 0 && (
<div><h3 className="text-[10px] font-bold uppercase tracking-widest text-rose-400 mb-3 border-b border-rose-500/20 pb-2 flex items-center gap-2"><Video size={12}/> Diagnostic Files</h3><div className="grid grid-cols-1 md:grid-cols-2 gap-4">{parseJSON(activeSignal.attachedFiles).map((fileUrl: string, idx: number) => { const isVideo = fileUrl.endsWith(".mp4") || fileUrl.endsWith(".mov"); return (<div key={idx} className="bg-black/60 border border-white/10 rounded-xl overflow-hidden group relative">{isVideo ? <video src={fileUrl} controls className="w-full h-48 object-cover bg-black" /> : <a href={fileUrl} target="_blank" rel="noreferrer" className="block w-full h-48"><img src={fileUrl} alt="Diagnostic" className="w-full h-full object-cover" /></a>}<div className="absolute top-2 right-2 bg-black/80 text-[9px] text-white px-2 py-1 rounded font-mono border border-white/20">{isVideo ? "VIDEO" : "IMAGE"}</div></div>); })}</div></div>
)}
</div>
</>
)}
</div>
</>
)}
{viewMode === "CLIENTS" && (
<div className="w-full bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl flex flex-col h-full">
<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">Client / Company</th>
<th className="p-6 font-semibold">Email</th>
<th className="p-6 font-semibold">Status</th>
<th className="p-6 font-semibold text-center">Purchase History</th>
<th className="p-6 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? <tr><td colSpan={5} className="p-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" /></td></tr> :
clients.length === 0 ? <tr><td colSpan={5} className="p-12 text-center text-[#86868B]">No clients registered yet.</td></tr> :
clients.map(client => {
const orders = client.signals.filter((s:any) => s.type === "ORDER");
return (
<tr key={client.id} className="border-b border-white/5 hover:bg-white/[0.02] transition-colors">
<td className="p-6"><p className="text-sm font-medium text-white">{client.fullName}</p><p className="text-xs text-[#86868B] mt-1 flex items-center gap-1"><Building2 size={12}/> {client.companyName}</p></td>
<td className="p-6 text-sm text-[#00F0FF]">{client.email}</td>
<td className="p-6">
{client.isApproved ? <span className="px-2 py-1 bg-emerald-500/10 text-emerald-400 text-[10px] uppercase tracking-widest font-bold rounded">Approved</span> : <span className="px-2 py-1 bg-rose-500/10 text-rose-400 text-[10px] uppercase tracking-widest font-bold rounded">Pending</span>}
</td>
<td className="p-6 text-center">
<div className="text-xs text-[#86868B]">
<span className="font-bold text-white">{orders.length}</span> Orders placed
</div>
</td>
<td className="p-6 text-right">
<button
disabled={isProcessing}
onClick={() => handleDeleteClient(client.id)}
className="p-2 text-[#86868B] hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
title="Delete Client"
>
<Trash2 size={16} />
</button>
</td>
</tr>
)})}
</tbody>
</table>
</div>
</div>
)}
</div>
{isSettingsOpen && (
<div className="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-[#111] border border-white/10 w-full max-w-lg rounded-[2rem] p-8 relative shadow-2xl">
<button onClick={() => setIsSettingsOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<div className="flex items-center gap-3 mb-2"><Mail className="text-rose-500" size={24} /><h3 className="text-2xl font-light text-white">Email Routing</h3></div>
<p className="text-sm text-[#86868B] mb-6">Configure which team members receive alerts. Separate emails with commas.</p>
<div className="space-y-5">
{[{ id: "ORDER", label: "Spare Part Orders", color: "text-amber-500" }, { id: "DIAGNOSTIC", label: "Tech Support / Diagnostics", color: "text-rose-500" }, { id: "CONSULTATION", label: "Engineering Consultations", color: "text-[#00F0FF]" }].map(route => (
<div key={route.id} className="bg-black/40 border border-white/5 rounded-xl p-4">
<label className={`block text-[10px] uppercase tracking-widest font-bold mb-2 ${route.color}`}>{route.label}</label>
<div className="flex gap-2">
<input type="text" value={(routes as any)[route.id]} onChange={e => setRoutes({...routes, [route.id]: e.target.value})} className="flex-1 bg-transparent border-b border-white/20 text-white text-sm pb-1 outline-none focus:border-white font-mono" placeholder="e.g. sales@flux.com, ceo@flux.com" />
<button onClick={() => handleSaveRoute(route.id, (routes as any)[route.id])} className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1 rounded-lg font-medium">Save</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,147 @@
//src/app/hq-command/dashboard/network/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// 🔥 Importamos el motor de traducción robusto
import { translateContentForCMS } from "@/lib/aiTranslator";
// 1. OBTENER TODOS LOS NODOS
export async function getNodes() {
try {
const nodes = await prisma.globalNode.findMany({
orderBy: { createdAt: "desc" }
});
return { success: true, nodes };
} catch (error) {
return { error: "Failed to fetch map nodes." };
}
}
// 2. CREAR UN NUEVO NODO
export async function createNode(formData: FormData) {
try {
const title = formData.get("title") as string;
const location = formData.get("location") as string;
const nodeType = formData.get("nodeType") as string;
const application = formData.get("application") as string;
const stats = formData.get("stats") as string;
const lat = parseFloat(formData.get("lat") as string);
const lon = parseFloat(formData.get("lon") as string);
if (!title || !location || !application || !stats || isNaN(lat) || isNaN(lon) || !nodeType) {
return { error: "All fields are required and coordinates must be valid numbers." };
}
await prisma.globalNode.create({
data: {
title, location, nodeType, application, stats, lat, lon, isActive: true
}
});
revalidatePath("/hq-command/dashboard/network");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to create map node." };
}
}
// 3. ELIMINAR UN NODO
export async function deleteNode(id: string) {
try {
await prisma.globalNode.delete({ where: { id } });
revalidatePath("/hq-command/dashboard/network");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to delete node." };
}
}
// 4. CAMBIAR ESTADO
export async function toggleNodeStatus(id: string, currentStatus: boolean) {
try {
await prisma.globalNode.update({
where: { id },
data: { isActive: !currentStatus }
});
revalidatePath("/hq-command/dashboard/network");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to update node status." };
}
}
// 5. ACTUALIZAR GEO-BLOG CON IA
export async function updateNodeCaseStudy(formData: FormData) {
try {
const id = formData.get("id") as string;
const projectOverview = formData.get("projectOverview") as string;
const energySavings = formData.get("energySavings") as string;
const mediaFileName = formData.get("mediaFileName") as string;
const eventDateStr = formData.get("eventDate") as string;
const galleryJson = formData.get("galleryJson") as string;
const videosJson = formData.get("videosJson") as string;
const rendersJson = formData.get("rendersJson") as string;
const specificDatasheetJson = formData.get("specificDatasheetJson") as string;
const model3DPath = formData.get("model3DPath") as string;
const model3DDimsJson = formData.get("model3DDimsJson") as string;
// 🔥 Atrapamos si Patrizio activó la IA 🔥
const autoTranslate = formData.get("autoTranslate") === "on";
const eventDate = eventDateStr ? new Date(eventDateStr) : null;
let updateData: any = {
projectOverview, energySavings, mediaFileName, eventDate,
galleryJson: galleryJson || "[]",
videosJson: videosJson || "[]",
rendersJson: rendersJson || "[]",
specificDatasheetJson: specificDatasheetJson || "[]",
model3DPath,
model3DDimsJson: model3DDimsJson || null
};
// 🔥 LA MAGIA DE LA IA 🔥
if (autoTranslate) {
// Solo le pasamos a OpenAI los textos que el usuario realmente va a leer (Markdown y Métrica)
const aiResult = await translateContentForCMS({
projectOverview: projectOverview || "",
energySavings: energySavings || ""
});
if (aiResult) {
updateData.translationsJson = JSON.stringify(aiResult);
}
}
await prisma.globalNode.update({ where: { id }, data: updateData });
revalidatePath("/hq-command/dashboard/network");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
console.error("Error updating chronicle:", error);
return { error: "Failed to update case study data." };
}
}
// 6. BÚSQUEDA SATELITAL
export async function searchSatelliteLocation(query: string) {
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&featuretype=city&limit=5`, {
headers: { 'User-Agent': 'FluxCMS-Architecture/1.0 (davidherran@dreamhousestudios.co)' }
});
if (!res.ok) throw new Error("Satellite rejected request");
const data = await res.json();
return { success: true, data };
} catch (error) {
console.error("Satellite search failed:", error);
return { error: "Satellite connection failed" };
}
}
@@ -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 = "![" + 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 + ")";
}
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![Image description](" + basePath + "/images/photo.jpg)\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> &nbsp;|&nbsp; <strong>## Title 2</strong> &nbsp;|&nbsp; <strong>**Bold**</strong></p>
<p className="mt-1"><strong>- List Item</strong> &nbsp;|&nbsp; <strong>&gt; Blockquote</strong></p>
<p className="mt-1 text-white"><strong>![Image](/cases/{slug || "slug"}/images/photo.jpg)</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>
);
}
@@ -0,0 +1,105 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// 🔥 Importamos nuestra nueva IA traductora
import { translateContentForCMS } from "@/lib/aiTranslator";
export async function getNewsArticles() {
try {
const articles = await prisma.newsArticle.findMany({
orderBy: [{ order: 'asc' }, { publishedAt: 'desc' }]
});
return { success: true, articles };
} catch (error: any) {
return { error: error.message };
}
}
export async function createNewsArticle(formData: FormData) {
try {
const title = formData.get("title") as string;
const excerpt = formData.get("excerpt") as string;
const content = formData.get("content") as string;
const coverImage = formData.get("coverImage") as string;
const category = formData.get("category") as string;
const linkedinUrl = formData.get("linkedinUrl") as string;
const order = parseInt(formData.get("order") as string) || 0;
const galleryJson = formData.get("galleryJson") as string;
// Capturamos si Patrizio pidió traducción por IA
const autoTranslate = formData.get("autoTranslate") === "on";
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
// 🔥 LA MAGIA DE LA IA 🔥
let translationsJson = "{}";
if (autoTranslate) {
// Solo le mandamos a la IA los textos que se deben leer (no mandamos URLs ni números de orden)
const aiResult = await translateContentForCMS({ title, excerpt, content, category });
if (aiResult) {
translationsJson = JSON.stringify(aiResult);
}
}
await prisma.newsArticle.create({
data: {
title, slug, excerpt, content, coverImage, category,
linkedinUrl, order, galleryJson, translationsJson
}
});
revalidatePath("/news");
revalidatePath("/[locale]/news", "layout");
return { success: true };
} catch (error: any) {
return { error: error.message };
}
}
export async function updateNewsArticle(formData: FormData) {
try {
const id = formData.get("id") as string;
const title = formData.get("title") as string;
const excerpt = formData.get("excerpt") as string;
const content = formData.get("content") as string;
const coverImage = formData.get("coverImage") as string;
const category = formData.get("category") as string;
const linkedinUrl = formData.get("linkedinUrl") as string;
const order = parseInt(formData.get("order") as string) || 0;
const galleryJson = formData.get("galleryJson") as string;
const autoTranslate = formData.get("autoTranslate") === "on";
let updateData: any = {
title, excerpt, content, coverImage, category, linkedinUrl, order, galleryJson
};
// 🔥 LA MAGIA DE LA IA (Actualización) 🔥
if (autoTranslate) {
const aiResult = await translateContentForCMS({ title, excerpt, content, category });
if (aiResult) {
updateData.translationsJson = JSON.stringify(aiResult);
}
}
await prisma.newsArticle.update({ where: { id }, data: updateData });
revalidatePath("/news");
revalidatePath("/[locale]/news", "layout");
return { success: true };
} catch (error: any) {
return { error: error.message };
}
}
export async function deleteNewsArticle(id: string) {
try {
await prisma.newsArticle.delete({ where: { id } });
revalidatePath("/news");
revalidatePath("/[locale]/news", "layout");
return { success: true };
} catch (error: any) {
return { error: error.message };
}
}
+485
View File
@@ -0,0 +1,485 @@
//src/app/hq-command/dashboard/news/page.tsx
"use client";
export const dynamic = "force-dynamic";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import {
ArrowLeft, Newspaper, Plus, Trash2, Loader2, X, Linkedin, Edit3, 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, Image as ImageIcon, Video, Box, Search
} 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<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true); setError(null);
try {
const params = new URLSearchParams({ scope, slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) { setItems(data.items); setBreadcrumbs(data.breadcrumbs); setCurrentPath(dirPath); }
else setError(data.error || "Failed to load");
} catch { setError("Connection error — check /api/assets/route.ts"); }
setIsLoading(false);
}, [scope, slug]);
useEffect(() => { if (isOpen) { fetchAssets(initialPath || currentPath); setSearchQuery(""); } }, [isOpen]); // eslint-disable-line
const uploadFile = async (file: File) => {
setIsUploading(true); setUploadProgress(`Uploading ${file.name}...`);
try {
const fd = new FormData();
fd.append("scope", scope); fd.append("slug", slug); fd.append("path", currentPath); fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) { setUploadProgress("✓ " + data.file.name); await fetchAssets(currentPath); setTimeout(() => setUploadProgress(""), 2000); }
else { setUploadProgress("✗ " + data.error); setTimeout(() => setUploadProgress(""), 4000); }
} catch { setUploadProgress("✗ Failed"); setTimeout(() => setUploadProgress(""), 3000); }
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) Array.from(e.target.files).forEach(uploadFile); e.target.value = ""; };
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.files).forEach(uploadFile); };
const createFolder = async () => {
if (!newFolderName.trim()) return;
try {
const res = await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: newFolderName, parentPath: currentPath }) });
const data = await res.json();
if (data.success) { setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); }
else alert(data.error);
} catch { alert("Connection error"); }
};
const deleteFile = async (filePath: string, fileName: string) => {
if (!confirm('Delete "' + fileName + '"?')) return;
try {
const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePath }) });
const data = await res.json(); if (data.success) await fetchAssets(currentPath); else alert(data.error);
} catch { alert("Failed"); }
};
const handleSelect = (item: AssetItem, e?: React.MouseEvent) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (item.type === "folder") { fetchAssets(item.path); return; }
onSelect({ name: item.name, publicUrl: item.publicUrl || "/" + scope + "/" + slug + "/" + item.path, mediaType: item.mediaType || "unknown", path: item.path });
onClose();
};
const copyPath = (item: AssetItem) => {
navigator.clipboard.writeText(item.publicUrl || "/" + scope + "/" + slug + "/" + item.path);
setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500);
};
const filtered = searchQuery ? items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items;
const typeBadge = (mt?: string) => {
const s: Record<string, string> = { image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", video: "bg-blue-500/10 text-blue-400 border-blue-500/20", model: "bg-purple-500/10 text-purple-400 border-purple-500/20" };
return s[mt || ""] || "bg-white/5 text-[#86868B] border-white/10";
};
const renderThumb = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} style={{ color: accentColor, opacity: 0.7 }} /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
if (item.mediaType === "video") return <div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={24} className="text-blue-400/60" /></div>;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={(e) => e.stopPropagation()}>
{isDragging && (
<div className="absolute inset-0 z-50 border-2 border-dashed rounded-[2rem] flex items-center justify-center backdrop-blur-sm" style={{ borderColor: accentColor + "80", background: accentColor + "15" }}>
<div className="text-center"><ArrowUpFromLine size={48} style={{ color: accentColor }} className="mx-auto mb-3 animate-bounce" /><p style={{ color: accentColor }} className="font-medium text-lg">Drop files to upload</p></div>
</div>
)}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"><div className="p-2 rounded-xl" style={{ background: accentColor + "20", color: accentColor }}><FolderOpen size={20} /></div><div><h3 className="text-lg font-medium text-white">Asset Manager</h3><p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/{scope}/{slug}/</p></div></div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">
{breadcrumbs.map((crumb, idx) => (<span key={idx} className="flex items-center shrink-0">{idx > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}<button onClick={() => fetchAssets(crumb.path)} className={`px-2 py-1 rounded-lg text-xs ${idx === breadcrumbs.length - 1 ? "bg-white/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`} style={idx === breadcrumbs.length - 1 ? { color: accentColor } : {}}>{crumb.name}</button></span>))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative"><Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" /><input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none" /></div>
<div className="flex bg-white/5 rounded-lg border border-white/10 overflow-hidden">
<button onClick={() => setViewMode("grid")} className={`p-1.5 ${viewMode === "grid" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><Grid3X3 size={14} /></button>
<button onClick={() => setViewMode("list")} className={`p-1.5 ${viewMode === "list" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><LayoutList size={14} /></button>
</div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-black rounded-lg font-medium disabled:opacity-50" style={{ background: accentColor }}><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.mp4,.webm,.mov,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && (<div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5"><FolderPlus size={14} style={{ color: accentColor }} /><input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white outline-none font-mono" /><button onClick={createFolder} className="px-3 py-1.5 text-xs text-black rounded-lg font-medium" style={{ background: accentColor }}>Create</button><button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B]">Cancel</button></div>)}
{uploadProgress && <div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2 text-xs">{isUploading && <Loader2 size={12} className="animate-spin" style={{ color: accentColor }} />}<span className={uploadProgress.startsWith("✓") ? "text-emerald-400" : uploadProgress.startsWith("✗") ? "text-red-400" : ""} style={isUploading ? { color: accentColor } : {}}>{uploadProgress}</span></div>}
</div>
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? <div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin" style={{ color: accentColor }} /></div>
: error ? <div className="flex flex-col items-center justify-center py-20 text-center"><X size={32} className="text-red-400/50 mb-3" /><p className="text-red-400/80 text-sm">{error}</p></div>
: filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center"><FolderOpen size={48} className="text-[#86868B]/20 mb-4" />
{searchQuery ? <p className="text-[#86868B] text-sm">No matches</p> : <><p className="text-[#86868B] text-sm mb-2">Empty directory</p><div className="flex gap-2 mt-4">{["images", "gallery"].map(f => (<button key={f} onClick={async () => { await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: f, parentPath: currentPath }) }); fetchAssets(currentPath); }} className="px-3 py-2 text-xs border rounded-lg" style={{ color: accentColor, borderColor: accentColor + "30" }}>+ {f}/</button>))}</div></>}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{filtered.map(item => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-all cursor-pointer" onClick={(e) => handleSelect(item, e)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumb(item)}</div>
<div className="p-2"><p className="text-[11px] text-white truncate font-medium">{item.name}</p>
<div className="flex items-center justify-between mt-1">{item.type === "folder" ? <span className="text-[9px] text-[#86868B]">{item.childCount} items</span> : <span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>}{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}</div>
</div>
{item.type === "file" && <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"><button onClick={e => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-white">{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}</button><button onClick={e => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-red-400"><Trash2 size={11} /></button></div>}
</div>
))}
</div>
) : (
<div className="space-y-1">{filtered.map(item => (
<div key={item.path} className="group flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-white/[0.03] cursor-pointer" onClick={(e) => handleSelect(item, e)}>
<div className="w-8 h-8 rounded-lg overflow-hidden shrink-0 border border-white/5">{renderThumb(item)}</div>
<span className="text-sm text-white flex-1 truncate">{item.name}</span>
{item.type === "file" && <span className={`text-[9px] px-2 py-0.5 rounded border shrink-0 ${typeBadge(item.mediaType)}`}>{item.extension}</span>}
{item.type === "folder" && <><span className="text-[9px] text-[#86868B]">{item.childCount} items</span><ChevronRight size={14} className="text-[#86868B]/50" /></>}
{item.size && <span className="text-[10px] text-[#86868B] w-16 text-right shrink-0">{item.size}</span>}
</div>
))}</div>
)}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20"><span>{filtered.length} items Click to select</span><span className="font-mono">Drag & drop</span></div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MARKDOWN EDITOR — Cyan-themed for News articles
// ─────────────────────────────────────────────────────────────────────────────
function MarkdownEditorCyan({ name, defaultValue = "", required, rows = 12, 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 }) => {
const syntax = item.mediaType === "image" ? "![" + item.name + "](" + item.publicUrl + ")" : "[" + item.name + "](" + item.publicUrl + ")";
insertAtCursor("\n" + syntax + "\n");
};
const basePath = "/news/" + (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| Feature | Previous | FLUX Update |\n|---|---|---|\n| Speed | 10 mt/min | 20 mt/min |\n", 2),
image: () => insertAtCursor("\n![Image description](" + basePath + "/image.jpg)\n", 3),
};
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 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-52 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.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">Table</p><p className="text-[10px] text-[#86868B]">Auto-highlight</p></div></button>
</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 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> &nbsp;|&nbsp; <strong>## Title 2</strong> &nbsp;|&nbsp; <strong>**Bold**</strong></p>
<p className="mt-1"><strong>- List Item</strong> &nbsp;|&nbsp; <strong>&gt; Blockquote</strong></p>
<p className="mt-1 text-white"><strong>![Image](/news/{slug || "slug"}/image.jpg)</strong></p>
<div className="mt-3 pt-3 border-t border-[#00F0FF]/10">
<p><strong>Tables (Last column highlights):</strong></p>
<p>| Feature | Previous | FLUX Update |</p>
<p>|---|---|---|</p>
<p>| Speed | 10 mt/min | 20 mt/min |</p>
</div>
</div>
)}
{slug && <AssetManager scope="news" slug={slug} isOpen={isAssetManagerOpen} onClose={() => setIsAssetManagerOpen(false)} onSelect={handleAssetInsert} accentColor="#00F0FF" />}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MAIN PAGE — News Manager (Inside Flux / Editorial Desk)
// ─────────────────────────────────────────────────────────────────────────────
export default function NewsManager() {
const [articles, setArticles] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
const [editingArticle, setEditingArticle] = useState<any | null>(null);
const [gallery, setGallery] = useState<string[]>([]);
// Asset Manager states
const [coverAssetsOpen, setCoverAssetsOpen] = useState(false);
const [galleryAssetsOpen, setGalleryAssetsOpen] = useState(false);
// Derive slug for folder naming
const articleSlug = editingArticle?.title?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '') || "new-article";
const fetchArticles = async () => {
setIsLoading(true);
const res = await getNewsArticles();
if (res.success && res.articles) setArticles(res.articles);
setIsLoading(false);
};
useEffect(() => { fetchArticles(); }, []);
const openCreateModal = () => { setEditingArticle(null); setGallery([]); setIsModalOpen(true); };
const openEditModal = (article: any) => {
setEditingArticle(article);
try { setGallery(JSON.parse(article.galleryJson || "[]")); } catch { setGallery([]); }
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true); setError("");
const formData = new FormData(e.currentTarget);
formData.append("galleryJson", JSON.stringify(gallery.filter(img => img.trim() !== "")));
let res;
if (editingArticle) res = await updateNewsArticle(formData);
else res = await createNewsArticle(formData);
if (res.error) setError(res.error);
else { setIsModalOpen(false); fetchArticles(); }
setIsSubmitting(false);
};
const handleDelete = async (id: string) => {
if (confirm("Delete this article?")) { await deleteNewsArticle(id); fetchArticles(); }
};
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"><Newspaper className="text-[#00F0FF]" /> Inside Flux</h1><p className="text-[#86868B] mt-2">Manage company news, tech updates, and behind-the-scenes articles.</p></div>
<button onClick={openCreateModal} className="flex items-center gap-2 bg-[#00F0FF] text-black px-5 py-2.5 rounded-xl font-medium hover:bg-white transition-all"><Plus size={18} /> Write Article</button>
</div>
</div>
{error && <div className="mb-8 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">{error}</div>}
<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">Article / Date</th><th className="p-6 font-semibold text-center">Order</th><th className="p-6 font-semibold">Category</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
<tbody>
{isLoading ? <tr><td colSpan={4} className="p-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading editorial database...</td></tr>
: articles.length === 0 ? <tr><td colSpan={4} className="p-12 text-center text-[#86868B]">No articles published yet.</td></tr>
: articles.map(article => (
<tr key={article.id} className="border-b border-white/5 hover:bg-white/[0.02] transition-colors group">
<td className="p-6">
<div className="flex items-center gap-2 mb-1"><p className="font-medium text-white text-base">{article.title}</p>{article.linkedinUrl && <Linkedin size={14} className="text-[#0A66C2]" />}</div>
<p className="text-xs text-[#86868B] max-w-md truncate">{article.excerpt}</p>
<span className="text-[10px] text-white/30 uppercase tracking-widest mt-2 block font-mono">{new Date(article.publishedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</span>
</td>
<td className="p-6 text-center"><span className="text-white/50 bg-white/5 px-3 py-1 rounded font-mono text-sm">{article.order}</span></td>
<td className="p-6"><span className="bg-[#00F0FF]/10 text-[#00F0FF] border border-[#00F0FF]/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider">{article.category}</span></td>
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEditModal(article)} className="text-[#86868B] hover:text-[#00F0FF] hover:bg-[#00F0FF]/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Edit3 size={18} /></button><button onClick={() => handleDelete(article.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>
{/* EDITORIAL DESK 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-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">
<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-1 text-[#00F0FF] flex items-center gap-2"><Newspaper size={24} /> {editingArticle ? "Edit Article" : "Editorial Desk"}</h3>
</div>
<form id="news-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
<input type="hidden" name="id" value={editingArticle?.id || ""} />
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="md:col-span-3"><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Article Title</label><input name="title" defaultValue={editingArticle?.title} required 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><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Display Order</label><input name="order" type="number" defaultValue={editingArticle?.order || 0} required 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 text-center" /></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Category</label><select name="category" defaultValue={editingArticle?.category} 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"><option value="Inside Flux">Inside Flux</option><option value="Tech Update">Tech Update</option><option value="Event">Event / Tradeshow</option></select></div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Cover Image public/news</label>
<div className="flex gap-2">
<input name="coverImage" defaultValue={editingArticle?.coverImage} 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"><Linkedin size={10}/> LinkedIn URL</label><input name="linkedinUrl" defaultValue={editingArticle?.linkedinUrl} placeholder="https://linkedin.com/..." 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>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Short Excerpt (Summary)</label><textarea name="excerpt" defaultValue={editingArticle?.excerpt} required rows={2} 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 resize-none" /></div>
{/* RICH MARKDOWN EDITOR */}
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 flex justify-between"><span>Full Content (Markdown)</span><span className="text-[#00F0FF]/60 text-[9px] font-normal normal-case">Rich Editor + Assets</span></label>
<MarkdownEditorCyan name="content" defaultValue={editingArticle?.content || ""} required rows={12} placeholder="Write the article here..." slug={articleSlug} />
</div>
{/* MEDIA GALLERY */}
<div className="bg-black/20 border border-white/5 p-4 rounded-xl">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3">Media Gallery public/news</label>
<div className="space-y-3 mb-3">
{gallery.map((img, idx) => (
<div key={idx} className="flex gap-2">
<input value={img} onChange={e => { const n = [...gallery]; n[idx] = e.target.value; setGallery(n); }} className="flex-1 bg-black/60 border border-white/10 rounded-lg px-4 py-2 text-[#00F0FF] font-mono 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"><Trash2 size={14}/></button>
</div>
))}
</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 hover:border-[#00F0FF] py-3 rounded-lg flex justify-center items-center gap-2 text-xs uppercase tracking-widest"><Plus size={14} /> Add Gallery Image</button>
<button type="button" onClick={() => setGalleryAssetsOpen(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>
{/* AI SWITCH */}
<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>
</form>
{/* Asset Managers OUTSIDE the form */}
<AssetManager scope="news" slug={articleSlug} isOpen={coverAssetsOpen} onClose={() => setCoverAssetsOpen(false)} onSelect={(item) => {
const inp = document.querySelector('input[name="coverImage"]') as HTMLInputElement;
if (inp) { inp.value = item.name; inp.dispatchEvent(new Event('input', { bubbles: true })); }
}} accentColor="#00F0FF" />
<AssetManager scope="news" slug={articleSlug} isOpen={galleryAssetsOpen} onClose={() => setGalleryAssetsOpen(false)} onSelect={(item) => {
setGallery(prev => [...prev, item.name]);
}} accentColor="#00F0FF" />
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white">Cancel</button>
<button onClick={() => (document.getElementById("news-form") as HTMLFormElement) ?.requestSubmit()} disabled={isSubmitting} 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)]">{isSubmitting ? <><Loader2 size={18} className="animate-spin" /> Processing AI...</> : (editingArticle ? "Save & Sync" : "Publish to World")}</button>
</div>
</div>
</div>
)}
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
// src/app/hq-command/dashboard/page.tsx
// ✅ CORRECCIÓN: Forzamos renderizado dinámico para que Prisma funcione en producción
export const dynamic = "force-dynamic";
import Link from "next/link";
import {
Globe,
Layers,
Users,
ShieldCheck,
Activity,
History,
Newspaper,
BookOpen,
LogOut,
Radar,
Wrench,
Server
} from "lucide-react";
import { prisma } from "@/lib/prisma";
import { logoutAdmin } from "@/app/hq-command/login/actions";
export const revalidate = 0;
export default async function DashboardPage() {
const nodesCount = await prisma.globalNode.count({ where: { isActive: true } });
const appsCount = await prisma.application.count({ where: { isActive: true } });
const modules = [
{
title: "Global Network",
description: "Manage physical installations, nodes, and events on the 3D Holographic Map.",
icon: Globe,
href: "/hq-command/dashboard/network",
color: "text-[#00F0FF]",
bg: "bg-[#00F0FF]/10",
border: "hover:border-[#00F0FF]/50"
},
{
title: "Knowledge Base",
description: "Edit technical literature, datasheets, and applications content dynamically.",
icon: Layers,
href: "/hq-command/dashboard/applications",
color: "text-purple-400",
bg: "bg-purple-500/10",
border: "hover:border-purple-500/50"
},
{
title: "Company Legacy",
description: "Manage the historical timeline, milestones, and Patrizio's story.",
icon: History,
href: "/hq-command/dashboard/timeline",
color: "text-amber-400",
bg: "bg-amber-400/10",
border: "hover:border-amber-400/50"
},
{
title: "Inside Flux",
description: "Manage company news, tech updates, and broadcast to LinkedIn.",
icon: Newspaper,
href: "/hq-command/dashboard/news",
color: "text-[#0A66C2]",
bg: "bg-[#0A66C2]/10",
border: "hover:border-[#0A66C2]/50"
},
{
title: "Our Heritage",
description: "Build the deep history page block by block (Text, Images, Video).",
icon: BookOpen,
href: "/hq-command/dashboard/heritage",
color: "text-white",
bg: "bg-white/10",
border: "hover:border-white/50"
},
{
title: "Component Matrix",
description: "Manage the spare parts catalog, pricing, and SKUs.",
icon: Wrench,
href: "/hq-command/dashboard/parts",
color: "text-amber-500",
bg: "bg-amber-500/10",
border: "hover:border-amber-500/50"
},
{
title: "Signal Hub (Inbox)",
description: "Manage component orders, technical diagnostics, and AI consultations.",
icon: Radar,
href: "/hq-command/dashboard/inbox",
color: "text-rose-500",
bg: "bg-rose-500/10",
border: "hover:border-rose-500/50"
},
{
title: "Access Control",
description: "Create and manage administrator accounts and security credentials.",
icon: Users,
href: "/hq-command/dashboard/users",
color: "text-emerald-400",
bg: "bg-emerald-500/10",
border: "hover:border-emerald-500/50"
},
{
title: "System Health",
description: "Monitor server metrics, database connection, and manage secure data backups.",
icon: Server,
href: "/hq-command/dashboard/health",
color: "text-blue-400",
bg: "bg-blue-400/10",
border: "hover:border-blue-400/50"
}
];
return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 mb-10">
<div>
<div className="flex items-center gap-2 text-[#00F0FF] mb-2">
<ShieldCheck size={16} />
<span className="text-[10px] uppercase tracking-widest font-bold">Secure Connection</span>
</div>
<h1 className="text-3xl md:text-4xl font-light text-white tracking-tight">
Welcome back, <span className="font-medium">Davidherran.</span>
</h1>
<p className="text-[#86868B] mt-2">FLUX Central Command System is online.</p>
</div>
<form action={logoutAdmin}>
<button type="submit" className="flex items-center gap-2 text-sm text-[#86868B] hover:text-white border border-white/10 px-4 py-2 rounded-lg transition-colors hover:bg-white/5">
<LogOut size={16} /> Terminate Session
</button>
</form>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-12">
<div className="bg-[#111] border border-white/5 p-6 rounded-2xl flex items-center justify-between shadow-lg">
<div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">System Status</span>
<div className="flex items-center gap-2 text-emerald-400">
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
<span className="text-xl font-medium">Optimal</span>
</div>
</div>
<Activity size={32} className="text-white/5" />
</div>
<div className="bg-[#111] border border-white/5 p-6 rounded-2xl flex items-center justify-between shadow-lg">
<div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">Active Map Nodes</span>
<span className="text-xl font-medium text-white">{nodesCount} Deployments</span>
</div>
<Globe size={32} className="text-white/5" />
</div>
<div className="bg-[#111] border border-white/5 p-6 rounded-2xl flex items-center justify-between shadow-lg">
<div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">Total Applications</span>
<span className="text-xl font-medium text-white">{appsCount} Categories</span>
</div>
<Layers size={32} className="text-white/5" />
</div>
</div>
<div className="mb-6">
<span className="text-[10px] uppercase tracking-widest text-[#86868B] font-semibold">Core Modules</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{modules.map((module, idx) => (
<Link
key={idx}
href={module.href}
className="group bg-[#111] border border-white/5 p-8 rounded-3xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div className={`w-12 h-12 rounded-2xl ${module.bg} ${module.color} flex items-center justify-center mb-6 transition-transform group-hover:scale-110`}>
<module.icon size={24} />
</div>
<h3 className="text-xl font-medium text-white mb-2 flex items-center gap-2">
{module.title}
</h3>
<p className="text-sm text-[#86868B] leading-relaxed">
{module.description}
</p>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,152 @@
"use server";
import { prisma } from "@/lib/prisma";
import { translateContentForCMS } from "@/lib/aiTranslator";
import { revalidatePath } from "next/cache";
export async function getParts() {
try {
const parts = await prisma.sparePart.findMany({
orderBy: { createdAt: "desc" },
});
return { success: true, parts };
} catch (error) {
console.error("Error fetching parts:", error);
return { error: "Error loading component matrix." };
}
}
export async function createPart(formData: FormData) {
try {
const sku = formData.get("sku") as string;
const title = formData.get("title") as string;
if (!sku || !title) return { error: "SKU and Title are required" };
const newPart = await prisma.sparePart.create({
data: {
sku: sku.toUpperCase().replace(/\s+/g, '-'),
title,
description: "Draft description...", // Texto por defecto
},
});
revalidatePath("/hq-command/dashboard/parts");
return { success: true, part: newPart };
} catch (error: any) {
if (error.code === 'P2002') return { error: "A component with this SKU already exists." };
return { error: "Error creating component." };
}
}
export async function updatePart(formData: FormData) {
try {
const id = formData.get("id") as string;
const title = formData.get("title") as string;
const sku = formData.get("sku") as string;
const description = formData.get("description") as string;
// Lógica de Pricing
const rawPrice = formData.get("price") as string;
const price = rawPrice ? parseFloat(rawPrice) : null;
const showPrice = formData.get("showPrice") === "on";
const autoTranslate = formData.get("autoTranslate") === "on";
// JSONs
const mediaJson = formData.get("mediaJson") as string;
const specsJson = formData.get("specsJson") as string;
let updateData: any = {
title,
sku: sku.toUpperCase().replace(/\s+/g, '-'),
description,
price,
showPrice,
mediaJson,
specsJson,
};
// 🔥 MAGIA: Autotraducción con Flux AI si está activada
if (autoTranslate) {
const translations = await translateContentForCMS({ title, description });
if (translations) {
updateData.translationsJson = JSON.stringify(translations);
}
}
await prisma.sparePart.update({
where: { id },
data: updateData,
});
revalidatePath("/hq-command/dashboard/parts");
return { success: true };
} catch (error) {
console.error("Error updating part:", error);
return { error: "Failed to update component data." };
}
}
export async function deletePart(id: string) {
try {
await prisma.sparePart.delete({ where: { id } });
revalidatePath("/hq-command/dashboard/parts");
return { success: true };
} catch (error) {
return { error: "Failed to delete component." };
}
}
export async function togglePartStatus(id: string, currentStatus: boolean) {
try {
await prisma.sparePart.update({
where: { id },
data: { isActive: !currentStatus },
});
revalidatePath("/hq-command/dashboard/parts");
return { success: true };
} catch (error) {
return { error: "Failed to toggle status." };
}
}
// 6. OBTENER EL ENCABEZADO DEL CATÁLOGO
export async function getPartsCatalogHero() {
try {
const content = await prisma.pageContent.findUnique({ where: { slug: "parts-catalog" } });
return { success: true, content };
} catch (error) {
return { error: "Failed to load hero content." };
}
}
// 7. GUARDAR EL ENCABEZADO DEL CATÁLOGO (CON TRADUCCIÓN IA)
export async function updatePartsCatalogHero(formData: FormData) {
const title = formData.get("title") as string;
const subtitle = formData.get("subtitle") as string;
const description = formData.get("description") as string;
const autoTranslate = formData.get("autoTranslate") === "on";
try {
let updateData: any = { title, subtitle, description };
// Si la IA está activa, traducimos los 3 campos a los 4 idiomas
if (autoTranslate) {
const translations = await translateContentForCMS({ title, subtitle, description });
if (translations) updateData.translationsJson = JSON.stringify(translations);
}
// Usamos Upsert: Crea el registro si no existe, o lo actualiza si ya existe
await prisma.pageContent.upsert({
where: { slug: "parts-catalog" },
update: updateData,
create: { slug: "parts-catalog", ...updateData }
});
revalidatePath("/hq-command/dashboard/parts");
revalidatePath("/[locale]/parts", "page"); // Limpia la caché de la vista pública
return { success: true };
} catch (error) {
return { error: "Failed to update hero content." };
}
}
+428
View File
@@ -0,0 +1,428 @@
//src/app/hq-command/dashboard/parts/page.tsx
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import {
ArrowLeft, Wrench, Plus, Trash2, Loader2, X, Eye, EyeOff, Edit3, Sparkles, DollarSign,
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, Image as ImageIcon, Video, Box, Search, Tag
} 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<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string|null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid"|"list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string|null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true); setError(null);
try {
const params = new URLSearchParams({ scope, slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) { setItems(data.items); setBreadcrumbs(data.breadcrumbs); setCurrentPath(dirPath); }
else setError(data.error);
} catch { setError("Connection error"); }
setIsLoading(false);
}, [scope, slug]);
useEffect(() => { if (isOpen) { fetchAssets(initialPath || currentPath); setSearchQuery(""); } }, [isOpen]); // eslint-disable-line
const uploadFile = async (file: File) => {
setIsUploading(true); setUploadProgress(`Uploading ${file.name}...`);
try {
const fd = new FormData(); fd.append("scope", scope); fd.append("slug", slug); fd.append("path", currentPath); fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) { setUploadProgress("✓ " + data.file.name); await fetchAssets(currentPath); setTimeout(() => setUploadProgress(""), 2000); }
else { setUploadProgress("✗ " + data.error); setTimeout(() => setUploadProgress(""), 4000); }
} catch { setUploadProgress("✗ Failed"); setTimeout(() => setUploadProgress(""), 3000); }
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) Array.from(e.target.files).forEach(uploadFile); e.target.value = ""; };
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.files).forEach(uploadFile); };
const createFolder = async () => { if (!newFolderName.trim()) return; try { const res = await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: newFolderName, parentPath: currentPath }) }); const data = await res.json(); if (data.success) { setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); } else alert(data.error); } catch { alert("Error"); } };
const deleteFile = async (fp: string, fn: string) => { if (!confirm('Delete "'+fn+'"?')) return; try { const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePath: fp }) }); const data = await res.json(); if (data.success) await fetchAssets(currentPath); else alert(data.error); } catch { alert("Failed"); } };
const handleSelect = (item: AssetItem, e?: React.MouseEvent) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (item.type === "folder") { fetchAssets(item.path); return; }
onSelect({ name: item.name, publicUrl: item.publicUrl || "/"+scope+"/"+slug+"/"+item.path, mediaType: item.mediaType || "unknown", path: item.path });
onClose();
};
const copyPath = (item: AssetItem) => { navigator.clipboard.writeText(item.publicUrl || "/"+scope+"/"+slug+"/"+item.path); setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500); };
const filtered = searchQuery ? items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items;
const typeBadge = (mt?: string) => ({ image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", video: "bg-blue-500/10 text-blue-400 border-blue-500/20", model: "bg-purple-500/10 text-purple-400 border-purple-500/20" }[mt || ""] || "bg-white/5 text-[#86868B] border-white/10");
const renderThumb = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} style={{ color: accentColor, opacity: 0.7 }} /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md" onClick={e => { e.preventDefault(); e.stopPropagation(); }}>
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={e => e.stopPropagation()}>
{isDragging && <div className="absolute inset-0 z-50 border-2 border-dashed rounded-[2rem] flex items-center justify-center backdrop-blur-sm" style={{ borderColor: accentColor+"80", background: accentColor+"15" }}><div className="text-center"><ArrowUpFromLine size={48} style={{ color: accentColor }} className="mx-auto mb-3 animate-bounce" /><p style={{ color: accentColor }} className="font-medium text-lg">Drop files</p></div></div>}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"><div className="p-2 rounded-xl" style={{ background: accentColor+"20", color: accentColor }}><FolderOpen size={20} /></div><div><h3 className="text-lg font-medium text-white">Asset Manager</h3><p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/{scope}/{slug}/</p></div></div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">{breadcrumbs.map((c, i) => (<span key={i} className="flex items-center shrink-0">{i > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}<button onClick={() => fetchAssets(c.path)} className={`px-2 py-1 rounded-lg text-xs ${i === breadcrumbs.length-1 ? "bg-white/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`} style={i === breadcrumbs.length-1 ? { color: accentColor } : {}}>{c.name}</button></span>))}</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative"><Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" /><input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none" /></div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-black rounded-lg font-medium disabled:opacity-50" style={{ background: accentColor }}><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && <div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5"><FolderPlus size={14} style={{ color: accentColor }} /><input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white outline-none font-mono" /><button onClick={createFolder} className="px-3 py-1.5 text-xs text-black rounded-lg font-medium" style={{ background: accentColor }}>Create</button><button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B]">Cancel</button></div>}
{uploadProgress && <div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2 text-xs">{isUploading && <Loader2 size={12} className="animate-spin" style={{ color: accentColor }} />}<span className={uploadProgress.startsWith("✓") ? "text-emerald-400" : uploadProgress.startsWith("✗") ? "text-red-400" : ""} style={isUploading ? { color: accentColor } : {}}>{uploadProgress}</span></div>}
</div>
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? <div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin" style={{ color: accentColor }} /></div>
: error ? <div className="text-center py-20"><X size={32} className="text-red-400/50 mx-auto mb-3" /><p className="text-red-400/80 text-sm">{error}</p></div>
: filtered.length === 0 ? <div className="text-center py-20"><FolderOpen size={48} className="text-[#86868B]/20 mx-auto mb-4" />{searchQuery ? <p className="text-[#86868B] text-sm">No matches</p> : <p className="text-[#86868B] text-sm">Empty upload product photos here</p>}</div>
: <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">{filtered.map(item => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 cursor-pointer" onClick={e => handleSelect(item, e)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumb(item)}</div>
<div className="p-2"><p className="text-[11px] text-white truncate font-medium">{item.name}</p><div className="flex items-center justify-between mt-1">{item.type === "folder" ? <span className="text-[9px] text-[#86868B]">{item.childCount} items</span> : <span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>}{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}</div></div>
{item.type === "file" && <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100"><button onClick={e => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 rounded-lg text-[#86868B] hover:text-white">{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}</button><button onClick={e => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 rounded-lg text-[#86868B] hover:text-red-400"><Trash2 size={11} /></button></div>}
</div>
))}</div>}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20"><span>{filtered.length} items Click to select</span><span className="font-mono">Drag & drop</span></div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MARKDOWN EDITOR — Amber-themed for Parts descriptions
// ─────────────────────────────────────────────────────────────────────────────
function MarkdownEditorAmber({ name, defaultValue = "", required, rows = 8, 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 }) => { insertAtCursor("\n!["+item.name+"]("+item.publicUrl+")\n"); };
const actions = {
bold: () => wrapSelection("**","**"), italic: () => wrapSelection("*","*"),
h1: () => prependLine("# "), h2: () => prependLine("## "), h3: () => prependLine("### "),
quote: () => prependLine("> "),
ul: () => insertAtCursor("\n- Item 1\n- Item 2\n", 3),
ol: () => insertAtCursor("\n1. First\n2. Second\n", 4),
hr: () => insertAtCursor("\n---\n"),
table: () => insertAtCursor("\n| Spec | Value |\n|---|---|\n| Material | Steel |\n", 2),
image: () => insertAtCursor("\n![Photo](/parts/"+(slug||"sku")+"/photo.jpg)\n", 3),
};
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={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} /><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-amber-400 hover:bg-amber-500/10 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-52 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: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></div></button>
<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: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">Spec Table</p></div></button>
</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:bg-emerald-500/10 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-amber-500 outline-none resize-none leading-relaxed "+(isExpanded ? "flex-1 rounded-b-2xl text-base" : "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>
{slug && <AssetManager scope="parts" slug={slug} isOpen={isAssetManagerOpen} onClose={() => setIsAssetManagerOpen(false)} onSelect={handleAssetInsert} accentColor="#f59e0b" />}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MAIN PAGE — Parts Manager (Component Matrix CMS)
// ─────────────────────────────────────────────────────────────────────────────
export default function PartsManager() {
const [parts, setParts] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
const [editingPart, setEditingPart] = useState<any|null>(null);
const [media, setMedia] = useState<string[]>([]);
const [specs, setSpecs] = useState<{label:string;value:string}[]>([]);
const [mediaAssetsOpen, setMediaAssetsOpen] = useState(false);
// 🔥 ESTADOS PARA EL HERO MODAL
const [isHeroModalOpen, setIsHeroModalOpen] = useState(false);
const [heroData, setHeroData] = useState<any | null>(null);
// 🔥 FETCH INICIAL UNIFICADO
const fetchInitialData = async () => {
setIsLoading(true);
const [resParts, resHero] = await Promise.all([getParts(), getPartsCatalogHero()]);
if (resParts.success && resParts.parts) setParts(resParts.parts);
if (resHero.success && resHero.content) setHeroData(resHero.content);
setIsLoading(false);
};
useEffect(() => { fetchInitialData(); }, []);
const openEdit = (part: any) => {
setEditingPart(part);
try { setMedia(JSON.parse(part.mediaJson || "[]")); } catch { setMedia([]); }
try { setSpecs(JSON.parse(part.specsJson || "[]")); } catch { setSpecs([]); }
};
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true); setError("");
const res = await createPart(new FormData(e.currentTarget));
if (res.error) setError(res.error);
else { setIsCreateOpen(false); fetchInitialData(); }
setIsSubmitting(false);
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true); setError("");
const formData = new FormData(e.currentTarget);
formData.append("mediaJson", JSON.stringify(media.filter(m => m.trim())));
formData.append("specsJson", JSON.stringify(specs.filter(s => s.label.trim() || s.value.trim())));
const res = await updatePart(formData);
if (res.error) setError(res.error);
else { setEditingPart(null); fetchInitialData(); }
setIsSubmitting(false);
};
// 🔥 GUARDAR HERO
const handleSaveHero = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true); setError("");
const res = await updatePartsCatalogHero(new FormData(e.currentTarget));
if (res.error) setError(res.error);
else { setIsHeroModalOpen(false); fetchInitialData(); }
setIsSubmitting(false);
};
const partSlug = editingPart?.sku?.toLowerCase().replace(/\s+/g, '-') || "new-part";
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-amber-400 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"><Wrench className="text-amber-400" /> Component Matrix</h1><p className="text-[#86868B] mt-2">Manage spare parts, pricing, specs, and product media.</p></div>
<div className="flex items-center gap-3">
<button onClick={() => setIsHeroModalOpen(true)} className="flex items-center gap-2 bg-white/5 text-white border border-white/10 px-5 py-2.5 rounded-xl font-medium hover:bg-white/10 transition-all">
<Edit3 size={18} /> Edit Page Hero
</button>
<button onClick={() => setIsCreateOpen(true)} className="flex items-center gap-2 bg-amber-500/10 text-amber-400 border border-amber-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-amber-500 hover:text-black transition-all">
<Plus size={18} /> New Component
</button>
</div>
</div>
</div>
{error && <div className="mb-8 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">{error}</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">Component / SKU</th><th className="p-6 font-semibold">Price</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={4} className="p-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading matrix...</td></tr>
: parts.length === 0 ? <tr><td colSpan={4} className="p-12 text-center text-[#86868B]">No components registered.</td></tr>
: parts.map(part => (
<tr key={part.id} className={`border-b border-white/5 transition-colors group ${!part.isActive ? 'opacity-50' : 'hover:bg-white/[0.02]'}`}>
<td className="p-6"><p className="font-medium text-white">{part.title}</p><p className="text-xs text-amber-400/70 font-mono mt-1">{part.sku}</p></td>
<td className="p-6">{part.showPrice && part.price ? <span className="text-white font-mono">{part.price.toFixed(2)}</span> : <span className="text-[9px] text-[#86868B] uppercase tracking-widest">Quote Based</span>}</td>
<td className="p-6"><button onClick={async () => { await togglePartStatus(part.id, part.isActive); fetchInitialData(); }} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 ${part.isActive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>{part.isActive ? <><Eye size={12} /> Active</> : <><EyeOff size={12} /> Hidden</>}</button></td>
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEdit(part)} className="text-[#86868B] hover:text-amber-400 hover:bg-amber-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Edit3 size={18} /></button><button onClick={async () => { if (confirm("Delete?")) { await deletePart(part.id); fetchInitialData(); } }} className="text-[#86868B] hover:text-red-400 hover:bg-red-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Trash2 size={18} /></button></div></td>
</tr>
))}
</tbody></table></div></div>
{/* CREATE MODAL */}
{isCreateOpen && (
<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">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-amber-500 to-transparent"></div>
<button onClick={() => setIsCreateOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<h3 className="text-2xl font-light mb-6 text-amber-400 flex items-center gap-2"><Wrench size={24} /> New Component</h3>
<form onSubmit={handleCreate} className="space-y-4">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">SKU (Unique ID)</label><input name="sku" required placeholder="e.g. FLXD-60A-BELT" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-amber-400 font-mono text-sm focus:border-amber-500 outline-none uppercase" /></div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Component Name</label><input name="title" required placeholder="e.g. Drive Belt Assembly" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-amber-500 outline-none" /></div>
<button type="submit" disabled={isSubmitting} className="w-full bg-amber-500 text-black py-3 mt-2 rounded-xl text-sm font-semibold hover:bg-amber-400 flex items-center justify-center gap-2">{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : "Register Component"}</button>
</form>
</div>
</div>
)}
{/* EDIT MODAL — Component Data Editor */}
{editingPart && (
<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">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-amber-500 to-transparent"></div>
<button onClick={() => setEditingPart(null)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<h3 className="text-2xl font-light text-amber-400 flex items-center gap-2"><Wrench size={24} /> Component Editor</h3>
<p className="text-[#86868B] text-xs font-mono uppercase tracking-widest mt-1">SKU: {editingPart.sku}</p>
</div>
<form id="parts-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
<input type="hidden" name="id" value={editingPart.id} />
<div className="space-y-6">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Component Name</label><input name="title" defaultValue={editingPart.title} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-lg font-medium focus:border-amber-500 outline-none" /></div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">SKU</label><input name="sku" defaultValue={editingPart.sku} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-amber-400 font-mono text-sm focus:border-amber-500 outline-none uppercase" /></div>
</div>
{/* Pricing */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-1"><DollarSign size={10} /> Price (EUR)</label><input name="price" type="number" step="0.01" min="0" defaultValue={editingPart.price || ""} placeholder="e.g. 125.00" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white font-mono text-sm focus:border-amber-500 outline-none" /></div>
<div className="flex items-end pb-3"><label className="flex items-center gap-3 text-sm text-white cursor-pointer"><input type="checkbox" name="showPrice" defaultChecked={editingPart.showPrice} className="accent-amber-500 w-5 h-5" /><span>Show price publicly</span></label></div>
</div>
{/* Description with MarkdownEditor */}
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 flex justify-between"><span>Description (Markdown)</span><span className="text-amber-400/60 text-[9px] font-normal normal-case">Rich Editor + Assets</span></label>
<MarkdownEditorAmber name="description" defaultValue={editingPart.description || ""} required rows={8} placeholder="Technical description, compatibility notes..." slug={partSlug} />
</div>
{/* Product Media */}
<div className="bg-black/20 border border-white/5 p-4 rounded-xl">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3 flex items-center gap-2"><ImageIcon size={12} /> Product Photos public/parts/{partSlug}</label>
{media.map((img, idx) => (
<div key={idx} className="flex gap-2 mb-3">
<input value={img} onChange={e => { const n = [...media]; n[idx] = e.target.value; setMedia(n); }} placeholder="e.g. front-view.jpg" className="flex-1 bg-black/60 border border-white/10 rounded-lg px-4 py-2 text-amber-400 font-mono text-sm focus:border-amber-500 outline-none" />
<button type="button" onClick={() => setMedia(media.filter((_,i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-3 bg-black/40 rounded-lg"><Trash2 size={14} /></button>
</div>
))}
<div className="flex gap-2">
<button type="button" onClick={() => setMedia([...media, ""])} className="flex-1 border border-dashed border-white/20 text-[#86868B] hover:text-white hover:border-amber-500 py-3 rounded-lg flex justify-center items-center gap-2 text-xs"><Plus size={14} /> Add Photo</button>
<button type="button" onClick={() => 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>
{/* Technical Specs */}
<div className="bg-black/20 border border-white/5 p-4 rounded-xl">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3 flex items-center gap-2"><Tag size={12} /> Technical Specifications</label>
{specs.map((spec, idx) => (
<div key={idx} className="flex gap-2 mb-3">
<input value={spec.label} onChange={e => { const n = [...specs]; n[idx].label = e.target.value; setSpecs(n); }} placeholder="e.g. Material" className="w-1/3 bg-black/60 border border-white/10 rounded-lg px-3 py-2 text-[#86868B] text-xs uppercase tracking-wider font-semibold outline-none" />
<input value={spec.value} onChange={e => { const n = [...specs]; n[idx].value = e.target.value; setSpecs(n); }} placeholder="e.g. Stainless Steel 304" className="flex-1 bg-black/60 border border-white/10 rounded-lg px-3 py-2 text-white text-sm outline-none" />
<button type="button" onClick={() => setSpecs(specs.filter((_,i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-2"><Trash2 size={14} /></button>
</div>
))}
<button type="button" onClick={() => setSpecs([...specs, { label: "", value: "" }])} className="w-full border border-dashed border-white/20 text-[#86868B] hover:text-white hover:border-amber-500 py-3 rounded-lg flex justify-center items-center gap-2 text-xs"><Plus size={14} /> Add Spec</button>
</div>
{/* AI Switch */}
<div className="bg-gradient-to-r from-amber-500/10 to-transparent border border-amber-500/20 p-4 rounded-xl flex items-center justify-between">
<div className="flex items-center gap-3"><div className="p-2 bg-amber-500/20 rounded-lg text-amber-400"><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-amber-500"></div></label>
</div>
</div>
</form>
{/* Asset Manager OUTSIDE the form */}
<AssetManager scope="parts" slug={partSlug} isOpen={mediaAssetsOpen} onClose={() => setMediaAssetsOpen(false)} onSelect={(item) => { setMedia(prev => [...prev, item.name]); }} accentColor="#f59e0b" />
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3">
<button type="button" onClick={() => setEditingPart(null)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white">Cancel</button>
<button onClick={() => (document.getElementById("parts-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="flex items-center gap-2 bg-amber-500 text-black px-8 py-3 rounded-xl text-sm font-semibold hover:bg-amber-400 disabled:opacity-50 shadow-[0_0_15px_rgba(245,158,11,0.3)]">{isSubmitting ? <><Loader2 size={18} className="animate-spin" /> Processing...</> : "Save Component Data"}</button>
</div>
</div>
</div>
)}
{/* 🔥 MODAL PARA EDITAR EL ENCABEZADO DE LA PÁGINA 🔥 */}
{isHeroModalOpen && (
<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">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white to-transparent"></div>
<button onClick={() => setIsHeroModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<h3 className="text-2xl font-light mb-6 text-white flex items-center gap-2"><Edit3 size={24} /> Catalog Hero Text</h3>
<p className="text-xs text-[#86868B] mb-6">Modify the main title and description of the public Spare Parts page.</p>
<form onSubmit={handleSaveHero} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Title Prefix</label><input name="title" defaultValue={heroData?.title || "Component"} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" /></div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Title Emphasis</label><input name="subtitle" defaultValue={heroData?.subtitle || "Matrix."} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" /></div>
</div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Description</label><textarea name="description" defaultValue={heroData?.description || ""} rows={3} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none resize-none" /></div>
<div className="bg-gradient-to-r from-white/5 to-transparent border border-white/10 p-4 rounded-xl flex items-center justify-between mt-6">
<div className="flex items-center gap-3"><div className="p-2 bg-white/10 rounded-lg text-white"><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-white"></div></label>
</div>
<button type="submit" disabled={isSubmitting} className="w-full bg-white text-black py-3 mt-4 rounded-xl text-sm font-bold hover:bg-gray-200 flex items-center justify-center gap-2">{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : "Save Hero Content"}</button>
</form>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,123 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// 🔥 IMPORTAMOS EL TRADUCTOR DE IA
import { translateContentForCMS } from "@/lib/aiTranslator";
// 1. OBTENER HITOS HISTÓRICOS
export async function getTimelineEvents() {
try {
const events = await prisma.timelineEvent.findMany({
orderBy: { order: 'asc' }
});
return { success: true, events };
} catch (error) {
return { error: "Failed to fetch timeline events." };
}
}
// 2. CREAR NUEVO HITO CON IA
export async function createTimelineEvent(formData: FormData) {
try {
const year = formData.get("year") as string;
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const order = parseInt(formData.get("order") as string) || 0;
// Capturamos el switch de la IA
const autoTranslate = formData.get("autoTranslate") === "on";
if (!year || !title || !description) return { error: "All fields are required." };
let translationsJson = "{}";
// 🔥 LA MAGIA DE LA IA 🔥
if (autoTranslate) {
const aiResult = await translateContentForCMS({ title, description });
if (aiResult) translationsJson = JSON.stringify(aiResult);
}
await prisma.timelineEvent.create({
data: { year, title, description, order, isActive: true, translationsJson }
});
revalidatePath("/hq-command/dashboard/timeline");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to create milestone." };
}
}
// 3. ACTUALIZAR HITO EXISTENTE CON IA
export async function updateTimelineEvent(formData: FormData) {
try {
const id = formData.get("id") as string;
const year = formData.get("year") as string;
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const order = parseInt(formData.get("order") as string) || 0;
const autoTranslate = formData.get("autoTranslate") === "on";
if (!id || !year || !title || !description) return { error: "All fields are required." };
let updateData: any = { year, title, description, order };
// 🔥 LA MAGIA DE LA IA 🔥
if (autoTranslate) {
const aiResult = await translateContentForCMS({ title, description });
if (aiResult) updateData.translationsJson = JSON.stringify(aiResult);
}
await prisma.timelineEvent.update({
where: { id },
data: updateData
});
revalidatePath("/hq-command/dashboard/timeline");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to update milestone." };
}
}
// 4. ELIMINAR HITO
export async function deleteTimelineEvent(id: string) {
try {
await prisma.timelineEvent.delete({ where: { id } });
revalidatePath("/hq-command/dashboard/timeline");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to delete milestone." };
}
}
// 5. LA SEMILLA
export async function seedTimeline() {
try {
const count = await prisma.timelineEvent.count();
if (count > 0) return { error: "Timeline already has data." };
const milestones = [
{ year: "1978", title: "The Foundation", description: "Patrizio Grando establishes the core engineering principles that would define our solid-state RF technology.", order: 1 },
{ year: "1995", title: "Industrial Scale", description: "First global deployments of volumetric heating systems for the textile industry, setting new efficiency standards.", order: 2 },
{ year: "2010", title: "Sector Expansion", description: "Adapting our proprietary RF technology to food processing, rubber vulcanization, and advanced materials.", order: 3 },
{ year: "2026", title: "The Next Era", description: "FLUX introduces the next generation of smart, AI-monitored RF systems with unparalleled energy efficiency.", order: 4 }
];
for (const m of milestones) {
await prisma.timelineEvent.create({ data: m });
}
revalidatePath("/hq-command/dashboard/timeline");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to seed timeline." };
}
}
@@ -0,0 +1,209 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { ArrowLeft, History, Plus, Trash2, Loader2, X, DatabaseZap, Clock, Edit3, Sparkles } from "lucide-react";
import { getTimelineEvents, createTimelineEvent, updateTimelineEvent, deleteTimelineEvent, seedTimeline } from "./actions";
export default function TimelineManager() {
const [events, setEvents] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSeeding, setIsSeeding] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editingEvent, setEditingEvent] = useState<any | null>(null);
const [error, setError] = useState("");
const fetchEvents = async () => {
setIsLoading(true);
const res = await getTimelineEvents();
if (res.success && res.events) setEvents(res.events);
setIsLoading(false);
};
useEffect(() => { fetchEvents(); }, []);
const handleSeed = async () => {
setIsSeeding(true); setError("");
const res = await seedTimeline();
if (res.error) setError(res.error); else await fetchEvents();
setIsSeeding(false);
};
const openCreateModal = () => {
setEditingEvent(null);
setIsModalOpen(true);
};
const openEditModal = (event: any) => {
setEditingEvent(event);
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true); setError("");
const formData = new FormData(e.currentTarget);
let res;
if (editingEvent) {
res = await updateTimelineEvent(formData);
} else {
res = await createTimelineEvent(formData);
}
if (res.error) {
setError(res.error);
} else {
setIsModalOpen(false);
fetchEvents();
}
setIsSubmitting(false);
};
const handleDelete = async (id: string) => {
if (confirm("Are you sure you want to delete this historical milestone?")) {
await deleteTimelineEvent(id);
fetchEvents();
}
};
return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
{/* HEADER */}
<div className="mb-10">
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-amber-400 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">
<History className="text-amber-400" /> Company Legacy
</h1>
<p className="text-[#86868B] mt-2">Manage historical milestones and the timeline of FLUX.</p>
</div>
<div className="flex gap-3">
{!isLoading && events.length === 0 && (
<button onClick={handleSeed} disabled={isSeeding} className="flex items-center gap-2 bg-amber-500/10 text-amber-400 border border-amber-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-amber-500 hover:text-black transition-all">
{isSeeding ? <Loader2 size={18} className="animate-spin" /> : <DatabaseZap size={18} />} Seed History
</button>
)}
<button onClick={openCreateModal} className="flex items-center gap-2 bg-white text-black px-5 py-2.5 rounded-xl font-medium hover:bg-gray-200 transition-all">
<Plus size={18} /> Add Milestone
</button>
</div>
</div>
</div>
{error && <div className="mb-8 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">{error}</div>}
{/* TIMELINE LIST */}
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl p-6 md:p-10">
{isLoading ? (
<div className="py-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading legacy data...</div>
) : events.length === 0 ? (
<div className="py-12 text-center"><Clock size={48} className="mx-auto text-amber-400/30 mb-4" /><p className="text-[#86868B]">No milestones recorded yet.</p></div>
) : (
<div className="space-y-6 relative before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-white/10 before:to-transparent">
{events.map((event) => (
<div key={event.id} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active">
{/* Timeline Dot */}
<div className="flex items-center justify-center w-10 h-10 rounded-full border border-white/20 bg-[#111] text-amber-400 shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 relative z-10">
<div className="w-3 h-3 bg-amber-400 rounded-full"></div>
</div>
{/* Content Card */}
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-3rem)] bg-black/40 border border-white/5 p-6 rounded-2xl hover:border-amber-400/30 transition-colors relative">
{/* Botones de acción */}
<div className="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEditModal(event)} className="text-[#86868B] hover:text-amber-400 p-1"><Edit3 size={16}/></button>
<button onClick={() => handleDelete(event.id)} className="text-[#86868B] hover:text-red-400 p-1"><Trash2 size={16}/></button>
</div>
<span className="text-amber-400 font-mono text-sm tracking-widest bg-amber-400/10 px-3 py-1 rounded-full inline-block mb-3">{event.year}</span>
<h4 className="text-xl text-white font-medium mb-2">{event.title}</h4>
<p className="text-[#86868B] text-sm leading-relaxed line-clamp-3">{event.description}</p>
<p className="text-[10px] text-white/20 mt-4 uppercase tracking-widest">Order: {event.order}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* CREATE / EDIT 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-3xl 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 shrink-0">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-amber-400 to-transparent"></div>
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors"><X size={20} /></button>
<h3 className="text-2xl font-light text-amber-400">
{editingEvent ? "Edit Milestone" : "Add New Milestone"}
</h3>
</div>
<form id="timeline-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
{editingEvent && <input type="hidden" name="id" value={editingEvent.id} />}
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Year / Date</label>
<input name="year" type="text" defaultValue={editingEvent?.year} required placeholder="e.g., 2026" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-amber-400 font-mono text-sm focus:border-amber-400 outline-none" />
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Timeline Position (1, 2, 3)</label>
<input name="order" type="number" defaultValue={editingEvent?.order} required placeholder="e.g., 5" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-amber-400 outline-none" />
</div>
</div>
<div className="mb-6">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Milestone Title</label>
<input name="title" type="text" defaultValue={editingEvent?.title} required placeholder="e.g., Next-Gen E-Dryer" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-amber-400 outline-none" />
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 flex justify-between items-center">
<span>Description (Markdown Supported)</span>
</label>
<textarea name="description" defaultValue={editingEvent?.description} required rows={6} placeholder="Write the history here..." className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-4 text-white font-mono text-sm focus:border-amber-400 outline-none resize-none leading-relaxed mb-3" />
<div className="bg-amber-400/5 border border-amber-400/20 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed">
<p className="text-amber-400 font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
<p><strong>**Bold**</strong> &nbsp;|&nbsp; <strong>*Italic*</strong> &nbsp;|&nbsp; <strong>- List Item</strong></p>
<p className="mt-1"><strong>&gt; Blockquote</strong></p>
</div>
</div>
{/* 🔥 SWITCH DE LA IA 🔥 */}
<div className="bg-gradient-to-r from-amber-400/10 to-transparent border border-amber-400/20 p-4 rounded-xl flex items-center justify-between mt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-400/20 rounded-lg text-amber-400"><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">Translates Markdown to 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 peer-focus:outline-none 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-amber-400"></div>
</label>
</div>
</form>
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3 shrink-0">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white transition-colors">Cancel</button>
<button onClick={() => (document.getElementById("timeline-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="flex items-center gap-2 bg-amber-400 text-black px-8 py-3 rounded-xl text-sm font-semibold hover:bg-white transition-colors disabled:opacity-50 shadow-[0_0_15px_rgba(251,191,36,0.3)]">
{isSubmitting ? <><Loader2 size={18} className="animate-spin" /> Processing AI...</> : "Publish to Legacy"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,112 @@
"use server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
import { revalidatePath } from "next/cache";
// 1. OBTENER TODOS LOS USUARIOS
export async function getUsers() {
try {
const users = await prisma.adminUser.findMany({
select: {
id: true,
username: true,
email: true, // 🔥 Ahora también pedimos el correo
is2FAEnabled: true,
createdAt: true,
},
orderBy: { createdAt: "asc" }
});
return { success: true, users };
} catch (error) {
return { error: "Failed to fetch users." };
}
}
// 2. CREAR UN NUEVO USUARIO Y GENERAR SU QR
export async function createUser(formData: FormData) {
const username = formData.get("username") as string;
const email = formData.get("email") as string; // 🔥 Nuevo
const password = formData.get("password") as string;
if (!username || username.length < 4) return { error: "Username must be at least 4 characters." };
if (!password || password.length < 8) return { error: "Password must be at least 8 characters." };
try {
const existing = await prisma.adminUser.findUnique({ where: { username: username.toLowerCase().trim() } });
if (existing) return { error: "Username already exists." };
const hashedPassword = await bcrypt.hash(password, 12);
const secret = speakeasy.generateSecret({ name: `FLUX HQ (${username})` });
if (!secret.otpauth_url) throw new Error("Failed to generate OTP URL");
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url, {
color: { dark: '#000000', light: '#FFFFFF' },
margin: 2
});
await prisma.adminUser.create({
data: {
username: username.toLowerCase().trim(),
email: email ? email.toLowerCase().trim() : null, // 🔥 Guardamos el correo
passwordHash: hashedPassword,
twoFactorSecret: secret.base32,
is2FAEnabled: true,
}
});
revalidatePath("/hq-command/dashboard/users");
return { success: true, qrCodeUrl, secret: secret.base32 };
} catch (error) {
return { error: "An error occurred while creating the user." };
}
}
// 3. EDITAR USUARIO (Correo y/o Contraseña) 🔥 NUEVA FUNCIÓN
export async function updateUser(formData: FormData) {
const id = formData.get("id") as string;
const email = formData.get("email") as string;
const newPassword = formData.get("newPassword") as string;
try {
// Preparamos el objeto de datos a actualizar
const updateData: any = {
email: email ? email.toLowerCase().trim() : null,
};
// Si el administrador escribió una nueva contraseña, la encriptamos
if (newPassword && newPassword.trim().length > 0) {
if (newPassword.length < 8) return { error: "New password must be at least 8 characters." };
updateData.passwordHash = await bcrypt.hash(newPassword, 12);
}
await prisma.adminUser.update({
where: { id },
data: updateData,
});
revalidatePath("/hq-command/dashboard/users");
return { success: true };
} catch (error) {
console.error("Update User Error:", error);
return { error: "Failed to update user." };
}
}
// 4. ELIMINAR UN USUARIO
export async function deleteUser(id: string) {
try {
const count = await prisma.adminUser.count();
if (count <= 1) return { error: "Security Lock: You cannot delete the last administrator." };
await prisma.adminUser.delete({ where: { id } });
revalidatePath("/hq-command/dashboard/users");
return { success: true };
} catch (error) {
return { error: "Failed to delete user." };
}
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import Image from "next/image";
import { ArrowLeft, Users, Plus, Trash2, ShieldCheck, KeyRound, Loader2, X, Settings } from "lucide-react";
import { getUsers, createUser, deleteUser, updateUser } from "./actions";
export default function UsersManager() {
const [users, setUsers] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Estados para el Modal de Creación
const [isModalOpen, setIsModalOpen] = useState(false);
// Estados para el Modal de Edición 🔥 NUEVO
const [editingUser, setEditingUser] = useState<any | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
// Estados para el QR de éxito
const [newQr, setNewQr] = useState<string | null>(null);
const [newSecret, setNewSecret] = useState<string | null>(null);
const fetchUsers = async () => {
setIsLoading(true);
const res = await getUsers();
if (res.success && res.users) {
setUsers(res.users);
}
setIsLoading(false);
};
useEffect(() => {
fetchUsers();
}, []);
// -- FUNCIÓN DE CREACIÓN --
const handleCreateUser = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setError("");
const formData = new FormData(e.currentTarget);
const res = await createUser(formData);
if (res.error) {
setError(res.error);
} else if (res.success && res.qrCodeUrl) {
setNewQr(res.qrCodeUrl);
setNewSecret(res.secret || null);
fetchUsers();
}
setIsSubmitting(false);
};
// -- FUNCIÓN DE EDICIÓN 🔥 NUEVO --
const handleEditUser = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setError("");
const formData = new FormData(e.currentTarget);
const res = await updateUser(formData);
if (res.error) {
setError(res.error);
} else {
setEditingUser(null);
fetchUsers(); // Refrescamos la tabla para ver el nuevo correo
}
setIsSubmitting(false);
};
const handleDelete = async (id: string) => {
if (confirm("Are you sure you want to revoke this architect's access? This cannot be undone.")) {
const res = await deleteUser(id);
if (res.error) alert(res.error);
else fetchUsers();
}
};
const closeAndResetModal = () => {
setIsModalOpen(false);
setNewQr(null);
setNewSecret(null);
setError("");
};
return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
{/* HEADER Y NAVEGACIÓN */}
<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">
<Users className="text-emerald-400" /> Access Control
</h1>
<p className="text-[#86868B] mt-2">Manage system architects, emails, and 2FA credentials.</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-2 bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-emerald-500 hover:text-black transition-all"
>
<Plus size={18} /> Provision New Access
</button>
</div>
</div>
{/* TABLA DE USUARIOS */}
<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">Architect</th>
<th className="p-6 font-semibold">Email</th>
<th className="p-6 font-semibold">Security Level</th>
<th className="p-6 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={4} className="p-8 text-center text-[#86868B]">
<Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading secure data...
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="border-b border-white/5 hover:bg-white/[0.02] transition-colors group">
<td className="p-6 font-medium text-white flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs">
{user.username.charAt(0).toUpperCase()}
</div>
{user.username}
</td>
<td className="p-6 text-sm text-[#86868B]">
{user.email || <span className="italic opacity-50">Not set</span>}
</td>
<td className="p-6">
{user.is2FAEnabled ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-400 bg-emerald-400/10 px-3 py-1 rounded-full">
<ShieldCheck size={14} /> 2FA Active
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-red-400 bg-red-400/10 px-3 py-1 rounded-full">
Unsecured
</span>
)}
</td>
<td className="p-6 text-right">
{/* 🔥 BOTONERA DE ACCIONES 🔥 */}
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setEditingUser(user)}
className="text-[#86868B] hover:text-emerald-400 hover:bg-emerald-400/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
title="Edit User"
>
<Settings size={18} />
</button>
<button
onClick={() => handleDelete(user.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"
title="Revoke Access"
>
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* ────────────────────────────────────────────────────────── */}
{/* MODAL DE EDICIÓN DE USUARIO 🔥 NUEVO */}
{/* ────────────────────────────────────────────────────────── */}
{editingUser && (
<div className="fixed inset-0 z-[60] 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-md rounded-[2rem] p-8 relative shadow-2xl">
<button onClick={() => setEditingUser(null)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors">
<X size={20} />
</button>
<h3 className="text-2xl font-light mb-2 text-emerald-400 flex items-center gap-2">
<Settings size={22} /> Edit Profile
</h3>
<p className="text-[#86868B] text-sm mb-8">Update email or set a new password for <strong className="text-white">{editingUser.username}</strong>.</p>
<form onSubmit={handleEditUser} className="space-y-5">
<input type="hidden" name="id" value={editingUser.id} />
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Contact Email</label>
<input
name="email"
type="email"
defaultValue={editingUser.email || ""}
placeholder="e.g., admin@fluxsrl.com"
className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-emerald-400 focus:bg-black transition-all outline-none"
/>
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">New Password (Optional)</label>
<input
name="newPassword"
type="password"
placeholder="Leave blank to keep current"
className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-emerald-400 focus:bg-black transition-all outline-none"
/>
<p className="text-[10px] text-[#86868B] mt-2">Note: Changing the password does not invalidate the existing 2FA Google Authenticator code.</p>
</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} className="w-full flex items-center justify-center gap-2 bg-emerald-500 text-black py-3 mt-4 rounded-xl text-sm font-semibold hover:bg-emerald-400 transition-colors disabled:opacity-50">
{isSubmitting ? <Loader2 size={18} className="animate-spin" /> : "Save Changes"}
</button>
</form>
</div>
</div>
)}
{/* ────────────────────────────────────────────────────────── */}
{/* MODAL DE CREACIÓN / MOSTRAR QR */}
{/* ────────────────────────────────────────────────────────── */}
{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-md rounded-[2rem] p-8 relative shadow-2xl">
{!newQr && (
<button onClick={closeAndResetModal} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors">
<X size={20} />
</button>
)}
{newQr ? (
// PANTALLA DE ÉXITO (QR CODE)
<div className="flex flex-col items-center text-center">
<div className="w-16 h-16 rounded-full bg-emerald-500/10 text-emerald-400 flex items-center justify-center mb-6">
<KeyRound size={32} />
</div>
<h3 className="text-2xl font-light mb-2">Access Granted</h3>
<p className="text-[#86868B] text-sm mb-6">
Account created successfully. <strong className="text-white">Have the new architect scan this QR code immediately.</strong> It will never be shown again.
</p>
<div className="bg-white p-4 rounded-2xl mb-6">
<Image src={newQr} alt="2FA QR" width={200} height={200} className="rounded-lg" />
</div>
<div className="bg-black/50 border border-white/5 p-4 rounded-xl w-full mb-8">
<span className="text-[10px] text-[#86868B] uppercase tracking-widest block mb-1">Manual Key</span>
<code className="text-emerald-400 text-sm font-mono break-all">{newSecret}</code>
</div>
<button onClick={closeAndResetModal} className="w-full bg-white text-black py-3 rounded-xl font-semibold hover:bg-gray-200 transition-colors">
Acknowledge & Close
</button>
</div>
) : (
// PANTALLA DE FORMULARIO
<>
<h3 className="text-2xl font-light mb-2 text-emerald-400">Provision Access</h3>
<p className="text-[#86868B] text-sm mb-8">Create a new architect credential for the FLUX CMS.</p>
<form onSubmit={handleCreateUser} className="space-y-5">
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Username</label>
<input name="username" type="text" required placeholder="e.g., patrizio" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-emerald-400 focus:bg-black transition-all outline-none" />
</div>
<div>
{/* 🔥 NUEVO CAMPO EN CREACIÓN: Email Opcional */}
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Email (Optional)</label>
<input name="email" type="email" placeholder="e.g., patrizio@fluxsrl.com" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-emerald-400 focus:bg-black transition-all outline-none" />
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Temporary Password</label>
<input name="password" type="password" required placeholder="••••••••••••" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-emerald-400 focus:bg-black transition-all outline-none" />
</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} className="w-full flex items-center justify-center gap-2 bg-emerald-500 text-black py-3 mt-4 rounded-xl text-sm font-semibold hover:bg-emerald-400 transition-colors disabled:opacity-50">
{isSubmitting ? <Loader2 size={18} className="animate-spin" /> : "Generate Credentials"}
</button>
</form>
</>
)}
</div>
</div>
)}
</div>
);
}