014a9eb094
Deploy to VPS / deploy (push) Has been cancelled
Every HQ Command panel had its own ~210-line AssetManager component
copy-pasted into the page file. Same UI, same API, four diverging
implementations — and no consistent metaphor for "where does this file
go?". Editors had to think about subfolder names (videos/, renders/)
that the front-end implicitly expects.
ONE COMPONENT. CLEAR BUCKETS. SAME PATHS.
src/components/hq/AssetBucketBrowser.tsx — the single picker. Takes
scope + slug, shows bucket tabs (Media / Videos / Renders / etc.) and
maps each to the on-disk path the public site already reads from:
cases: Media (root) | Videos (/videos) | Renders (/renders)
applications: Media (root) | Videos (/videos) | Renders (/renders)
news: Media (root)
parts: Media (root) | Renders (/renders)
footage: Hero Reel (root)
branding: Brand Assets (root)
Drop a file into the Videos tab → POSTs to /api/assets with path=videos
→ lands at /public/{scope}/{slug}/videos/<file> — exactly where
ApplicationClient.tsx and CaseStudyModal.tsx already look. Zero
front-end path changes, zero data migration.
UX upgrades the editor sees:
- Tabs make the bucket layout discoverable instead of buried in folder
navigation. Each tab has its own description and accent colour.
- Soft-warning hints flag obvious mismatches ("Videos bucket usually
holds .mp4 — this file may not display correctly") without blocking
the upload.
- Search + grid/list views.
- Hover actions per file: copy URL, delete (with confirm).
- Persistent on-screen path (/{scope}/{slug}/{bucket}) so editors can
always see the canonical location.
REPLACEMENT (4 page files)
- network/page.tsx: 719 → 513 lines (-206) — direct alias
- news/page.tsx: 484 → 313 lines (-171) — direct alias
- applications/page.tsx: 555 → 433 lines (-122) — adapter wraps the
picker's onSelect into the markdown-syntax onInsert callback this
panel uses. No call-site changes.
- parts/page.tsx: 413 → 320 lines (-93) — direct alias
Net: -592 lines of duplicated UI, +560 lines of single shared component.
Future bucket-layout changes live in one file instead of four.
NO PATH/API CHANGES — /api/assets is unchanged. The on-disk layout is
unchanged. Existing assets keep rendering on the public site. Existing
DB rows (mediaFileName, galleryJson, videosJson, rendersJson) are
unaffected because we never moved files.
314 lines
27 KiB
TypeScript
314 lines
27 KiB
TypeScript
//src/app/hq-command/dashboard/news/page.tsx
|
|
"use client";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import Link from "next/link";
|
|
import {
|
|
ArrowLeft, Newspaper, Plus, Trash2, Loader2, X, Linkedin, Edit3, Sparkles,
|
|
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus,
|
|
RotateCcw, RotateCw, Maximize2, Minimize2, ChevronDown,
|
|
FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine,
|
|
Grid3X3, LayoutList, Copy, Check, Image as ImageIcon, Video, Box, Search
|
|
} from "lucide-react";
|
|
import { getNewsArticles, createNewsArticle, updateNewsArticle, deleteNewsArticle } from "./actions";
|
|
|
|
// AssetBucketBrowser is the unified picker — single source of truth across HQ.
|
|
// Aliased to AssetManager so existing JSX call sites remain untouched.
|
|
import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser";
|
|
const AssetManager = AssetBucketBrowser;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// MARKDOWN EDITOR — Cyan-themed for News articles
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function MarkdownEditorCyan({ name, defaultValue = "", required, rows = 12, placeholder, slug }: {
|
|
name: string; defaultValue?: string; required?: boolean; rows?: number; placeholder?: string; slug?: string;
|
|
}) {
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const [value, setValue] = useState(defaultValue);
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [showInsertMenu, setShowInsertMenu] = useState(false);
|
|
const [history, setHistory] = useState<string[]>([defaultValue]);
|
|
const [historyIndex, setHistoryIndex] = useState(0);
|
|
const insertMenuRef = useRef<HTMLDivElement>(null);
|
|
const [isAssetManagerOpen, setIsAssetManagerOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const h = (e: MouseEvent) => { if (insertMenuRef.current && !insertMenuRef.current.contains(e.target as Node)) setShowInsertMenu(false); };
|
|
document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h);
|
|
}, []);
|
|
|
|
const historyTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
const pushHistory = useCallback((v: string) => {
|
|
if (historyTimeout.current) clearTimeout(historyTimeout.current);
|
|
historyTimeout.current = setTimeout(() => { setHistory(p => [...p.slice(0, historyIndex + 1), v].slice(-50)); setHistoryIndex(p => Math.min(p + 1, 49)); }, 500);
|
|
}, [historyIndex]);
|
|
|
|
const handleChange = (v: string) => { setValue(v); pushHistory(v); };
|
|
const undo = () => { if (historyIndex > 0) { const i = historyIndex - 1; setHistoryIndex(i); setValue(history[i]); } };
|
|
const redo = () => { if (historyIndex < history.length - 1) { const i = historyIndex + 1; setHistoryIndex(i); setValue(history[i]); } };
|
|
|
|
const getSelection = () => { const ta = textareaRef.current; if (!ta) return { start: 0, end: 0, selected: "", before: "", after: "" }; return { start: ta.selectionStart, end: ta.selectionEnd, selected: value.substring(ta.selectionStart, ta.selectionEnd), before: value.substring(0, ta.selectionStart), after: value.substring(ta.selectionEnd) }; };
|
|
const replaceSelection = (t: string, o?: number) => { const { before, after } = getSelection(); handleChange(before + t + after); const p = o !== undefined ? before.length + o : before.length + t.length; setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(p, p); } }, 0); };
|
|
const wrapSelection = (pre: string, suf: string) => { const { selected } = getSelection(); if (selected) replaceSelection(pre + selected + suf); else replaceSelection(pre + "text" + suf, pre.length); };
|
|
const insertAtCursor = (t: string, o?: number) => replaceSelection(t, o);
|
|
const prependLine = (pre: string) => { const { start, selected } = getSelection(); const ls = value.lastIndexOf('\n', start - 1) + 1; const bef = value.substring(0, ls); const line = selected || value.substring(ls).split('\n')[0]; const aft = value.substring(ls + line.length); handleChange(bef + pre + line + aft); setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(ls + pre.length, ls + pre.length + line.length); } }, 0); };
|
|
|
|
const handleAssetInsert = (item: { publicUrl: string; mediaType: string; name: string }) => {
|
|
const syntax = item.mediaType === "image" ? "" : "[" + item.name + "](" + item.publicUrl + ")";
|
|
insertAtCursor("\n" + syntax + "\n");
|
|
};
|
|
|
|
const basePath = "/news/" + (slug || "slug");
|
|
const actions = {
|
|
bold: () => wrapSelection("**", "**"), italic: () => wrapSelection("*", "*"),
|
|
h1: () => prependLine("# "), h2: () => prependLine("## "), h3: () => prependLine("### "),
|
|
quote: () => prependLine("> "),
|
|
ul: () => insertAtCursor("\n- Item 1\n- Item 2\n- Item 3\n", 3),
|
|
ol: () => insertAtCursor("\n1. First\n2. Second\n3. Third\n", 4),
|
|
hr: () => insertAtCursor("\n---\n"),
|
|
table: () => insertAtCursor("\n| Feature | Previous | FLUX Update |\n|---|---|---|\n| Speed | 10 mt/min | 20 mt/min |\n", 2),
|
|
image: () => insertAtCursor("\n\n", 3),
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
const m = e.metaKey || e.ctrlKey;
|
|
if (m && e.key === 'b') { e.preventDefault(); actions.bold(); }
|
|
if (m && e.key === 'i') { e.preventDefault(); actions.italic(); }
|
|
if (m && e.key === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
|
|
if (e.key === 'Tab') { e.preventDefault(); insertAtCursor(" "); }
|
|
};
|
|
|
|
const ToolBtn = ({ icon: Icon, label, onClick, className = "" }: { icon: any; label: string; onClick: () => void; className?: string }) => (
|
|
<button type="button" onClick={onClick} title={label} className={"p-1.5 rounded-lg text-[#86868B] hover:text-white hover:bg-white/10 transition-all active:scale-90 " + className}><Icon size={15} strokeWidth={2} /></button>
|
|
);
|
|
const Divider = () => <div className="w-px h-5 bg-white/10 mx-1" />;
|
|
|
|
return (
|
|
<div className={"flex flex-col " + (isExpanded ? "fixed inset-0 z-[60] bg-[#0A0A0A] p-6" : "")}>
|
|
<input type="hidden" name={name} value={value} />
|
|
<div className={"flex flex-wrap items-center gap-0.5 px-2 py-1.5 bg-black/60 border border-white/10 rounded-t-xl " + (isExpanded ? "border-b-0 rounded-t-2xl" : "")}>
|
|
<ToolBtn icon={Bold} label="Bold" onClick={actions.bold} />
|
|
<ToolBtn icon={Italic} label="Italic" onClick={actions.italic} />
|
|
<Divider />
|
|
<ToolBtn icon={Heading1} label="H1" onClick={actions.h1} />
|
|
<ToolBtn icon={Heading2} label="H2" onClick={actions.h2} />
|
|
<ToolBtn icon={Heading3} label="H3" onClick={actions.h3} />
|
|
<Divider />
|
|
<ToolBtn icon={List} label="Bullet" onClick={actions.ul} />
|
|
<ToolBtn icon={ListOrdered} label="Numbered" onClick={actions.ol} />
|
|
<ToolBtn icon={Quote} label="Quote" onClick={actions.quote} />
|
|
<ToolBtn icon={Table} label="Table" onClick={actions.table} />
|
|
<ToolBtn icon={Minus} label="HR" onClick={actions.hr} />
|
|
<Divider />
|
|
<div className="relative" ref={insertMenuRef}>
|
|
<button type="button" onClick={() => setShowInsertMenu(!showInsertMenu)} className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-[#00F0FF] hover:bg-[#00F0FF]/10 text-[11px] font-semibold uppercase tracking-wider">
|
|
<Plus size={13} /> Insert <ChevronDown size={11} className={"transition-transform " + (showInsertMenu ? "rotate-180" : "")} />
|
|
</button>
|
|
{showInsertMenu && (
|
|
<div className="absolute top-full left-0 mt-1 w-52 bg-[#1A1A1A] border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden">
|
|
<div className="p-1">
|
|
<button type="button" onClick={() => { actions.image(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-emerald-500/15 rounded-lg text-emerald-400"><ImageIcon size={14} /></div><div><p className="text-xs font-medium">Image</p><p className="text-[10px] text-[#86868B]">.jpg .png .webp</p></div></button>
|
|
<button type="button" onClick={() => { actions.table(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-amber-500/15 rounded-lg text-amber-400"><Table size={14} /></div><div><p className="text-xs font-medium">Table</p><p className="text-[10px] text-[#86868B]">Auto-highlight</p></div></button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{slug && (<><Divider /><button type="button" onClick={() => setIsAssetManagerOpen(true)} className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10 text-[11px] font-semibold uppercase tracking-wider"><FolderOpen size={14} /> Assets</button></>)}
|
|
<div className="flex-1" />
|
|
<ToolBtn icon={RotateCcw} label="Undo" onClick={undo} className={historyIndex <= 0 ? "opacity-30 pointer-events-none" : ""} />
|
|
<ToolBtn icon={RotateCw} label="Redo" onClick={redo} className={historyIndex >= history.length - 1 ? "opacity-30 pointer-events-none" : ""} />
|
|
<Divider />
|
|
<ToolBtn icon={isExpanded ? Minimize2 : Maximize2} label="Fullscreen" onClick={() => setIsExpanded(!isExpanded)} />
|
|
</div>
|
|
<textarea ref={textareaRef} value={value} onChange={e => handleChange(e.target.value)} onKeyDown={handleKeyDown} required={required} rows={isExpanded ? 40 : rows} placeholder={placeholder} className={"w-full bg-black/40 border border-white/10 border-t-0 p-4 text-white font-mono text-sm focus:border-[#00F0FF] outline-none resize-none leading-relaxed " + (isExpanded ? "flex-1 rounded-b-2xl text-base leading-loose" : "rounded-b-xl")} style={{ tabSize: 2 }} />
|
|
<div className="flex items-center justify-between mt-1.5 px-1">
|
|
<div className="flex items-center gap-3 text-[10px] text-[#86868B] font-mono"><span>{value.split('\n').length} lines</span><span className="text-white/10">|</span><span>{value.length} chars</span></div>
|
|
<div className="flex items-center gap-2 text-[10px] text-[#86868B]"><span className="opacity-60">⌘B</span><span className="opacity-60">⌘I</span><span className="opacity-60">Tab</span></div>
|
|
</div>
|
|
{!isExpanded && (
|
|
<div className="bg-[#00F0FF]/5 border border-[#00F0FF]/20 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed mt-3">
|
|
<p className="text-[#00F0FF] font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
|
|
<p><strong># Title 1</strong> | <strong>## Title 2</strong> | <strong>**Bold**</strong></p>
|
|
<p className="mt-1"><strong>- List Item</strong> | <strong>> Blockquote</strong></p>
|
|
<p className="mt-1 text-white"><strong></strong></p>
|
|
<div className="mt-3 pt-3 border-t border-[#00F0FF]/10">
|
|
<p><strong>Tables (Last column highlights):</strong></p>
|
|
<p>| Feature | Previous | FLUX Update |</p>
|
|
<p>|---|---|---|</p>
|
|
<p>| Speed | 10 mt/min | 20 mt/min |</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{slug && <AssetManager scope="news" slug={slug} isOpen={isAssetManagerOpen} onClose={() => setIsAssetManagerOpen(false)} onSelect={handleAssetInsert} accentColor="#00F0FF" />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// MAIN PAGE — News Manager (Inside Flux / Editorial Desk)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export default function NewsManager() {
|
|
const [articles, setArticles] = useState<any[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [editingArticle, setEditingArticle] = useState<any | null>(null);
|
|
const [gallery, setGallery] = useState<string[]>([]);
|
|
|
|
// Asset Manager states
|
|
const [coverAssetsOpen, setCoverAssetsOpen] = useState(false);
|
|
const [galleryAssetsOpen, setGalleryAssetsOpen] = useState(false);
|
|
|
|
// Derive slug for folder naming
|
|
const articleSlug = editingArticle?.title?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '') || "new-article";
|
|
|
|
const fetchArticles = async () => {
|
|
setIsLoading(true);
|
|
const res = await getNewsArticles();
|
|
if (res.success && res.articles) setArticles(res.articles);
|
|
setIsLoading(false);
|
|
};
|
|
|
|
useEffect(() => { fetchArticles(); }, []);
|
|
|
|
const openCreateModal = () => { setEditingArticle(null); setGallery([]); setIsModalOpen(true); };
|
|
const openEditModal = (article: any) => {
|
|
setEditingArticle(article);
|
|
try { setGallery(JSON.parse(article.galleryJson || "[]")); } catch { setGallery([]); }
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault(); setIsSubmitting(true); setError("");
|
|
const formData = new FormData(e.currentTarget);
|
|
formData.append("galleryJson", JSON.stringify(gallery.filter(img => img.trim() !== "")));
|
|
let res;
|
|
if (editingArticle) res = await updateNewsArticle(formData);
|
|
else res = await createNewsArticle(formData);
|
|
if (res.error) setError(res.error);
|
|
else { setIsModalOpen(false); fetchArticles(); }
|
|
setIsSubmitting(false);
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (confirm("Delete this article?")) { await deleteNewsArticle(id); fetchArticles(); }
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
|
|
<div className="mb-10">
|
|
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-[#00F0FF] transition-colors mb-6 group"><ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center</Link>
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
|
<div><h1 className="text-3xl font-light text-white flex items-center gap-3"><Newspaper className="text-[#00F0FF]" /> Inside Flux</h1><p className="text-[#86868B] mt-2">Manage company news, tech updates, and behind-the-scenes articles.</p></div>
|
|
<button onClick={openCreateModal} className="flex items-center gap-2 bg-[#00F0FF] text-black px-5 py-2.5 rounded-xl font-medium hover:bg-white transition-all"><Plus size={18} /> Write Article</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className="mb-8 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">{error}</div>}
|
|
|
|
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl"><div className="overflow-x-auto"><table className="w-full text-left border-collapse"><thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold">Article / Date</th><th className="p-6 font-semibold text-center">Order</th><th className="p-6 font-semibold">Category</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
|
|
<tbody>
|
|
{isLoading ? <tr><td colSpan={4} className="p-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading editorial database...</td></tr>
|
|
: articles.length === 0 ? <tr><td colSpan={4} className="p-12 text-center text-[#86868B]">No articles published yet.</td></tr>
|
|
: articles.map(article => (
|
|
<tr key={article.id} className="border-b border-white/5 hover:bg-white/[0.02] transition-colors group">
|
|
<td className="p-6">
|
|
<div className="flex items-center gap-2 mb-1"><p className="font-medium text-white text-base">{article.title}</p>{article.linkedinUrl && <Linkedin size={14} className="text-[#0A66C2]" />}</div>
|
|
<p className="text-xs text-[#86868B] max-w-md truncate">{article.excerpt}</p>
|
|
<span className="text-[10px] text-white/30 uppercase tracking-widest mt-2 block font-mono">{new Date(article.publishedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</span>
|
|
</td>
|
|
<td className="p-6 text-center"><span className="text-white/50 bg-white/5 px-3 py-1 rounded font-mono text-sm">{article.order}</span></td>
|
|
<td className="p-6"><span className="bg-[#00F0FF]/10 text-[#00F0FF] border border-[#00F0FF]/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider">{article.category}</span></td>
|
|
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEditModal(article)} className="text-[#86868B] hover:text-[#00F0FF] hover:bg-[#00F0FF]/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Edit3 size={18} /></button><button onClick={() => handleDelete(article.id)} className="text-[#86868B] hover:text-red-400 hover:bg-red-400/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Trash2 size={18} /></button></div></td>
|
|
</tr>
|
|
))}
|
|
</tbody></table></div></div>
|
|
|
|
{/* EDITORIAL DESK MODAL */}
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
|
|
<div className="bg-[#111] border border-white/10 w-full max-w-4xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]">
|
|
<div className="p-6 md:p-8 border-b border-white/10 relative">
|
|
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#00F0FF] to-transparent"></div>
|
|
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
|
|
<h3 className="text-2xl font-light mb-1 text-[#00F0FF] flex items-center gap-2"><Newspaper size={24} /> {editingArticle ? "Edit Article" : "Editorial Desk"}</h3>
|
|
</div>
|
|
|
|
<form id="news-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
|
|
<input type="hidden" name="id" value={editingArticle?.id || ""} />
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
|
<div className="md:col-span-3"><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Article Title</label><input name="title" defaultValue={editingArticle?.title} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-lg font-medium focus:border-[#00F0FF] outline-none" /></div>
|
|
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Display Order</label><input name="order" type="number" defaultValue={editingArticle?.order || 0} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-lg font-medium focus:border-[#00F0FF] outline-none text-center" /></div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Category</label><select name="category" defaultValue={editingArticle?.category} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none appearance-none"><option value="Inside Flux">Inside Flux</option><option value="Tech Update">Tech Update</option><option value="Event">Event / Tradeshow</option></select></div>
|
|
<div>
|
|
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Cover Image — public/news</label>
|
|
<div className="flex gap-2">
|
|
<input name="coverImage" defaultValue={editingArticle?.coverImage} className="flex-1 bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm focus:border-[#00F0FF] outline-none" />
|
|
<button type="button" onClick={() => setCoverAssetsOpen(true)} className="flex items-center gap-1.5 px-3 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-xl hover:bg-emerald-500/20 shrink-0"><FolderOpen size={14} /></button>
|
|
</div>
|
|
</div>
|
|
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-1"><Linkedin size={10}/> LinkedIn URL</label><input name="linkedinUrl" defaultValue={editingArticle?.linkedinUrl} placeholder="https://linkedin.com/..." className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none" /></div>
|
|
</div>
|
|
|
|
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Short Excerpt (Summary)</label><textarea name="excerpt" defaultValue={editingArticle?.excerpt} required rows={2} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none resize-none" /></div>
|
|
|
|
{/* RICH MARKDOWN EDITOR */}
|
|
<div>
|
|
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 flex justify-between"><span>Full Content (Markdown)</span><span className="text-[#00F0FF]/60 text-[9px] font-normal normal-case">Rich Editor + Assets</span></label>
|
|
<MarkdownEditorCyan name="content" defaultValue={editingArticle?.content || ""} required rows={12} placeholder="Write the article here..." slug={articleSlug} />
|
|
</div>
|
|
|
|
{/* MEDIA GALLERY */}
|
|
<div className="bg-black/20 border border-white/5 p-4 rounded-xl">
|
|
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3">Media Gallery — public/news</label>
|
|
<div className="space-y-3 mb-3">
|
|
{gallery.map((img, idx) => (
|
|
<div key={idx} className="flex gap-2">
|
|
<input value={img} onChange={e => { const n = [...gallery]; n[idx] = e.target.value; setGallery(n); }} className="flex-1 bg-black/60 border border-white/10 rounded-lg px-4 py-2 text-[#00F0FF] font-mono text-sm focus:border-[#00F0FF] outline-none" />
|
|
<button type="button" onClick={() => setGallery(gallery.filter((_, i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-3 bg-black/40 rounded-lg"><Trash2 size={14}/></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={() => setGallery([...gallery, ""])} className="flex-1 border border-dashed border-white/20 text-[#86868B] hover:text-white hover:border-[#00F0FF] py-3 rounded-lg flex justify-center items-center gap-2 text-xs uppercase tracking-widest"><Plus size={14} /> Add Gallery Image</button>
|
|
<button type="button" onClick={() => setGalleryAssetsOpen(true)} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-lg hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse Assets</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* AI SWITCH */}
|
|
<div className="bg-gradient-to-r from-[#00F0FF]/10 to-transparent border border-[#00F0FF]/20 p-4 rounded-xl flex items-center justify-between mt-6">
|
|
<div className="flex items-center gap-3"><div className="p-2 bg-[#00F0FF]/20 rounded-lg text-[#00F0FF]"><Sparkles size={18} /></div><div><p className="text-sm text-white font-medium">Flux AI Auto-Translate</p><p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">IT, VEC, ES, DE</p></div></div>
|
|
<label className="relative inline-flex items-center cursor-pointer"><input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" /><div className="w-11 h-6 bg-black/50 border border-white/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#00F0FF]"></div></label>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Asset Managers OUTSIDE the form */}
|
|
<AssetManager scope="news" slug={articleSlug} isOpen={coverAssetsOpen} onClose={() => setCoverAssetsOpen(false)} onSelect={(item) => {
|
|
const inp = document.querySelector('input[name="coverImage"]') as HTMLInputElement;
|
|
if (inp) { inp.value = item.name; inp.dispatchEvent(new Event('input', { bubbles: true })); }
|
|
}} accentColor="#00F0FF" />
|
|
<AssetManager scope="news" slug={articleSlug} isOpen={galleryAssetsOpen} onClose={() => setGalleryAssetsOpen(false)} onSelect={(item) => {
|
|
setGallery(prev => [...prev, item.name]);
|
|
}} accentColor="#00F0FF" />
|
|
|
|
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3">
|
|
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white">Cancel</button>
|
|
<button onClick={() => (document.getElementById("news-form") as HTMLFormElement) ?.requestSubmit()} disabled={isSubmitting} className="flex items-center gap-2 bg-[#00F0FF] text-black px-8 py-3 rounded-xl text-sm font-semibold hover:bg-white disabled:opacity-50 shadow-[0_0_15px_rgba(0,240,255,0.3)]">{isSubmitting ? <><Loader2 size={18} className="animate-spin" /> Processing AI...</> : (editingArticle ? "Save & Sync" : "Publish to World")}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |