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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user