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