Editors can now control the order applications appear in, the same way they already reorder Hero slides. - Application gains an `order Int @default(0)` column (additive migration 20260605120000_add_application_order, IF NOT EXISTS, safe for deploy) plus an (isActive, order) index. - New reorderApplications(orderedSlugs) server action — single $transaction renumbering, mirrors reorderHeroSlides. - HQ applications panel: rows are now draggable by a grip handle (HTML5 DnD, optimistic local reorder, persisted on drop, toast feedback). - All public-facing queries now order by [order asc, createdAt asc]: home ApplicationsDashboard + GlobalOperations, the footer apps list, and the HQ list itself. Existing rows default to 0 so current order is preserved until the editor drags something. Verified: production build compiles, TypeScript clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
483 lines
39 KiB
TypeScript
483 lines
39 KiB
TypeScript
//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, GripVertical
|
|
} from "lucide-react";
|
|
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication, reorderApplications } from "./actions";
|
|
import { useHqUi } from "@/components/hq/Toast";
|
|
|
|
|
|
// AssetBucketBrowser is the unified picker. The applications page uses an
|
|
// onInsert(markdownSyntax) callback, so we wrap with an adapter that maps
|
|
// the picker's onSelect(item) into the markdown syntax the editor expects.
|
|
import AssetBucketBrowser, { type SelectedAsset } from "@/components/hq/AssetBucketBrowser";
|
|
|
|
interface AssetManagerProps {
|
|
slug: string;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onInsert: (markdownSyntax: string) => void;
|
|
}
|
|
|
|
function AssetManager({ slug, isOpen, onClose, onInsert }: AssetManagerProps) {
|
|
const handleSelect = (item: SelectedAsset) => {
|
|
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})`;
|
|
}
|
|
onInsert(syntax);
|
|
onClose();
|
|
};
|
|
return (
|
|
<AssetBucketBrowser
|
|
slug={slug}
|
|
scope="applications"
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
onSelect={handleSelect}
|
|
accentColor="#9333EA"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 🔥 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 ui = useHqUi();
|
|
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 [draggedSlug, setDraggedSlug] = useState<string | null>(null);
|
|
|
|
const fetchApps = async () => { setIsLoading(true); const res = await getApplications(); if (res.success && res.apps) setApps(res.apps); setIsLoading(false); };
|
|
useEffect(() => { fetchApps(); }, []);
|
|
|
|
// Drag-to-reorder — same pattern as the Hero panel. Optimistic local
|
|
// reorder, then persist the new order to the DB.
|
|
const onDropApp = async (targetSlug: string) => {
|
|
if (!draggedSlug || draggedSlug === targetSlug) return;
|
|
const slugs = apps.map((a) => a.slug);
|
|
const from = slugs.indexOf(draggedSlug);
|
|
const to = slugs.indexOf(targetSlug);
|
|
if (from < 0 || to < 0) return;
|
|
const reordered = [...slugs];
|
|
reordered.splice(from, 1);
|
|
reordered.splice(to, 0, draggedSlug);
|
|
setApps((prev) => reordered.map((s) => prev.find((a) => a.slug === s)!));
|
|
setDraggedSlug(null);
|
|
const res = await reorderApplications(reordered);
|
|
if (res?.error) { ui.toast(res.error, "error"); fetchApps(); }
|
|
else ui.toast("Order updated.", "success");
|
|
};
|
|
|
|
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) {
|
|
ui.toast(`Error saving data: ${res.error}`, "error");
|
|
} else {
|
|
ui.toast("Application saved.", "success");
|
|
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 specifications. Drag rows by the handle to reorder how applications appear on the site.</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 w-10"></th><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={5} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading database...</td></tr>
|
|
) : apps.length === 0 ? (
|
|
<tr><td colSpan={5} className="p-8 text-center text-[#86868B]">No applications yet.</td></tr>
|
|
) : apps.map((app) => {
|
|
const isPopulated = app.heroDescription && app.heroDescription.length > 10;
|
|
return (
|
|
<tr
|
|
key={app.slug}
|
|
draggable
|
|
onDragStart={() => setDraggedSlug(app.slug)}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={() => onDropApp(app.slug)}
|
|
className={`border-b border-white/5 transition-colors group ${draggedSlug === app.slug ? 'opacity-40' : ''} ${app.isActive ? 'hover:bg-white/[0.02]' : 'opacity-50'}`}
|
|
>
|
|
<td className="p-6"><span className="cursor-grab text-[#86868B] hover:text-white inline-flex" title="Drag to reorder"><GripVertical size={16} /></span></td>
|
|
<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={async () => {
|
|
const ok = await ui.confirm({
|
|
title: "Delete application",
|
|
message: `Permanently remove "${app.title}" from the catalog. The asset folder on disk is preserved.`,
|
|
confirmLabel: "Delete forever",
|
|
destructive: true,
|
|
});
|
|
if (!ok) return;
|
|
await deleteApplication(app.slug);
|
|
ui.toast("Application deleted.", "success");
|
|
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>
|
|
);
|
|
} |