658 lines
33 KiB
TypeScript
658 lines
33 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { useAccount } from "@/contexts/AccountContext";
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
type Msg = { id: string; from: string; text: string; ts: number; channel?: string };
|
|
type HandleRecord = {
|
|
voidCredits: number;
|
|
claimedInvoices: Record<string, number>;
|
|
unlockedTools: { toolId: string; unlockedAt: number; voidSpent: number }[];
|
|
};
|
|
type LedgerResponse = { ok: boolean; handles: Record<string, HandleRecord>; invoiceIndex: Record<string, string> };
|
|
type RaffleStats = { ok: boolean; drawAt: number; totalTickets: number; participants: number };
|
|
type RaffleEntry = { handle: string; tickets: number; boughtAt: number };
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function fmt(ms: number) {
|
|
if (ms <= 0) return "READY TO DRAW";
|
|
const s = Math.floor(ms / 1000);
|
|
const d = Math.floor(s / 86400);
|
|
const h = Math.floor((s % 86400) / 3600);
|
|
const m = Math.floor((s % 3600) / 60);
|
|
const sec = s % 60;
|
|
if (d > 0) return `${d}d ${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
|
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
|
}
|
|
|
|
function StatCard({ label, value, sub, accent }: { label: string; value: string | number; sub?: string; accent?: string }) {
|
|
return (
|
|
<div className="rounded-lg border border-[#00ff66]/20 bg-black/50 p-4">
|
|
<div className="text-[9px] uppercase tracking-widest text-zinc-500 mb-1">{label}</div>
|
|
<div className={`text-2xl font-black font-mono tabular-nums ${accent ?? "text-[#00ff66]"}`}>{value}</div>
|
|
{sub && <div className="text-[10px] text-zinc-600 mt-1">{sub}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type Tab = "overview" | "credits" | "raffle" | "comms" | "logs";
|
|
|
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
|
|
export default function VaultAdminDashboard() {
|
|
const { user, hydrated, signIn } = useAccount();
|
|
const [tab, setTab] = useState<Tab>("overview");
|
|
const [loginPass, setLoginPass] = useState("");
|
|
const [loginError, setLoginError] = useState("");
|
|
|
|
// BTC
|
|
const [btcUsd, setBtcUsd] = useState<number | null>(null);
|
|
|
|
// Ledger
|
|
const [handles, setHandles] = useState<Record<string, HandleRecord>>({});
|
|
const [invoiceIndex, setInvoiceIndex] = useState<Record<string, string>>({});
|
|
|
|
// Raffle
|
|
const [raffleStats, setRaffleStats] = useState<RaffleStats | null>(null);
|
|
const [raffleEntries, setRaffleEntries] = useState<RaffleEntry[]>([]);
|
|
const [raffleRemaining, setRaffleRemaining] = useState(0);
|
|
const [drawResult, setDrawResult] = useState<string | null>(null);
|
|
|
|
// Credit ops
|
|
const [creditHandle, setCreditHandle] = useState("");
|
|
const [creditAmt, setCreditAmt] = useState("");
|
|
const [creditMsg, setCreditMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
|
|
|
// Comms
|
|
const [messages, setMessages] = useState<Msg[]>([]);
|
|
const [broadcast, setBroadcast] = useState("");
|
|
|
|
// Logs
|
|
const [terminalLines, setTerminalLines] = useState<string[]>([
|
|
`[BOOT] CyberLux admin shell initialised.`,
|
|
`[AUTH] drjones authenticated via local session.`,
|
|
]);
|
|
const logRef = useRef<HTMLDivElement>(null);
|
|
|
|
// ── Fetchers ────────────────────────────────────────────────────────────────
|
|
|
|
const fetchBtc = useCallback(async () => {
|
|
try {
|
|
const r = await fetch("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd", { cache: "no-store" });
|
|
const j = await r.json() as { bitcoin?: { usd?: number } };
|
|
setBtcUsd(j.bitcoin?.usd ?? null);
|
|
} catch { /* ignore */ }
|
|
}, []);
|
|
|
|
const fetchLedger = useCallback(async () => {
|
|
try {
|
|
const r = await fetch("/api/admin/ledger");
|
|
if (!r.ok) return;
|
|
const d = await r.json() as LedgerResponse;
|
|
if (d.ok) { setHandles(d.handles); setInvoiceIndex(d.invoiceIndex); }
|
|
} catch { /* ignore */ }
|
|
}, []);
|
|
|
|
const fetchRaffle = useCallback(async () => {
|
|
try {
|
|
const r = await fetch("/api/raffle/buy");
|
|
const d = await r.json() as RaffleStats;
|
|
if (d.ok) setRaffleStats(d);
|
|
const r2 = await fetch("/api/admin/raffle");
|
|
if (r2.ok) {
|
|
const d2 = await r2.json() as { ok: boolean; entries: RaffleEntry[] };
|
|
if (d2.ok) setRaffleEntries(d2.entries);
|
|
}
|
|
} catch { /* ignore */ }
|
|
}, []);
|
|
|
|
const fetchMessages = useCallback(async () => {
|
|
try {
|
|
const r = await fetch("/api/messages?channel=all");
|
|
const d = await r.json() as { ok: boolean; messages: Msg[] };
|
|
if (d.ok) setMessages(d.messages);
|
|
} catch { /* ignore */ }
|
|
}, []);
|
|
|
|
const log = useCallback((line: string) => {
|
|
setTerminalLines((prev) => {
|
|
const next = [...prev.slice(-99), line];
|
|
return next;
|
|
});
|
|
setTimeout(() => { logRef.current?.scrollTo({ top: 9999, behavior: "smooth" }); }, 50);
|
|
}, []);
|
|
|
|
// ── Init ────────────────────────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
if (!user || user.username !== "drjones") return;
|
|
void fetchBtc();
|
|
void fetchLedger();
|
|
void fetchRaffle();
|
|
void fetchMessages();
|
|
const id = setInterval(() => { void fetchBtc(); void fetchLedger(); void fetchRaffle(); void fetchMessages(); }, 15_000);
|
|
return () => clearInterval(id);
|
|
}, [user, fetchBtc, fetchLedger, fetchRaffle, fetchMessages]);
|
|
|
|
// Raffle countdown
|
|
useEffect(() => {
|
|
if (!raffleStats) return;
|
|
const t = setInterval(() => setRaffleRemaining(raffleStats.drawAt - Date.now()), 1000);
|
|
return () => clearInterval(t);
|
|
}, [raffleStats]);
|
|
|
|
// Simulated terminal feed
|
|
useEffect(() => {
|
|
if (!user || user.username !== "drjones") return;
|
|
const lines = [
|
|
() => `[TOR] Circuit ping ${Math.floor(Math.random() * 80 + 20)}ms — relay healthy`,
|
|
() => `[NGINX] 127.0.0.1:8080 → next:3000 — ${Math.floor(Math.random() * 8 + 1)} req/s`,
|
|
() => `[SHIELD] Null-routing clearnet probe from ${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.x.x`,
|
|
() => `[BTC] Block height ${895000 + Math.floor(Math.random() * 500)} confirmed`,
|
|
() => `[LEDGER] Balances consistent — ${Object.keys(handles).length} handles tracked`,
|
|
];
|
|
const t = setInterval(() => log(lines[Math.floor(Math.random() * lines.length)]!()), 4000);
|
|
return () => clearInterval(t);
|
|
}, [user, handles, log]);
|
|
|
|
// ── Actions ──────────────────────────────────────────────────────────────────
|
|
|
|
const handleLogin = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setLoginError("");
|
|
const res = await signIn("drjones", loginPass);
|
|
if (!res.ok) setLoginError(res.error ?? "Access Denied");
|
|
};
|
|
|
|
const adminCredit = async () => {
|
|
const amt = parseFloat(creditAmt);
|
|
if (!creditHandle.trim() || isNaN(amt) || amt <= 0) {
|
|
setCreditMsg({ text: "Handle and positive amount required", ok: false });
|
|
return;
|
|
}
|
|
setCreditMsg(null);
|
|
try {
|
|
const r = await fetch("/api/admin/credit", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ handle: creditHandle.trim(), amount: Math.floor(amt) }),
|
|
});
|
|
const d = await r.json() as { ok: boolean; newBalance?: number; error?: string };
|
|
if (d.ok) {
|
|
setCreditMsg({ text: `✓ ${Math.floor(amt)} VOID → @${creditHandle.trim()} (new balance: ${d.newBalance})`, ok: true });
|
|
log(`[ADMIN] Manual credit: +${Math.floor(amt)} VOID → @${creditHandle.trim()}`);
|
|
void fetchLedger();
|
|
} else {
|
|
setCreditMsg({ text: d.error ?? "Failed", ok: false });
|
|
}
|
|
} catch { setCreditMsg({ text: "Network error", ok: false }); }
|
|
};
|
|
|
|
const doDraw = () => {
|
|
if (raffleEntries.length === 0) { setDrawResult("No entries yet."); return; }
|
|
const pool: string[] = [];
|
|
for (const e of raffleEntries) {
|
|
for (let i = 0; i < e.tickets; i++) pool.push(e.handle);
|
|
}
|
|
const winner = pool[Math.floor(Math.random() * pool.length)]!;
|
|
setDrawResult(`🎉 WINNER: @${winner} (${raffleEntries.find((e) => e.handle === winner)?.tickets ?? 1} tickets)`);
|
|
log(`[RAFFLE] Manual draw executed — winner: @${winner}`);
|
|
};
|
|
|
|
const handleBroadcast = async () => {
|
|
if (!broadcast.trim()) return;
|
|
try {
|
|
await fetch("/api/messages", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ from: "System Admin", text: `[BROADCAST] ${broadcast}`, channel: "global" }),
|
|
});
|
|
log(`[COMMS] Global broadcast sent: "${broadcast}"`);
|
|
setBroadcast("");
|
|
void fetchMessages();
|
|
} catch { log("[COMMS] Broadcast failed!"); }
|
|
};
|
|
|
|
const deleteMessage = async (id: string) => {
|
|
await fetch(`/api/messages?id=${id}`, { method: "DELETE" });
|
|
log(`[COMMS] Deleted message ${id}`);
|
|
void fetchMessages();
|
|
};
|
|
|
|
const nukeMessages = async () => {
|
|
if (!confirm("Nuke all comms?")) return;
|
|
await fetch(`/api/messages?all=true`, { method: "DELETE" });
|
|
log("[COMMS] All messages purged.");
|
|
void fetchMessages();
|
|
};
|
|
|
|
// ── Auth gate ────────────────────────────────────────────────────────────────
|
|
|
|
if (!hydrated) return (
|
|
<div className="flex min-h-screen items-center justify-center bg-[#050000] text-[#00ff66] font-mono text-sm">
|
|
Initializing…
|
|
</div>
|
|
);
|
|
|
|
if (!user || user.username !== "drjones") return (
|
|
<div className="flex min-h-screen flex-col items-center justify-center bg-[#050000] p-6 font-mono text-[#00ff66]">
|
|
<div className="w-full max-w-sm rounded-xl border border-[#00ff66]/25 bg-black/80 p-8 shadow-[0_0_40px_rgba(0,255,102,0.12)]">
|
|
<div className="mb-6 text-center">
|
|
<div className="text-[10px] uppercase tracking-[0.4em] text-[#00ff66]/40 mb-2">CYBERLUX NODE</div>
|
|
<h1 className="text-2xl font-black uppercase tracking-widest text-red-500">RESTRICTED</h1>
|
|
<p className="mt-2 text-[10px] text-zinc-600">Operator access only</p>
|
|
</div>
|
|
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
|
<input
|
|
type="password"
|
|
value={loginPass}
|
|
onChange={(e) => setLoginPass(e.target.value)}
|
|
className="w-full rounded border border-[#00ff66]/25 bg-black px-4 py-3 text-[#00ff66] text-sm placeholder:text-zinc-700 focus:border-[#00ff66]/60 focus:outline-none"
|
|
placeholder="passphrase"
|
|
autoFocus
|
|
/>
|
|
{loginError && <div className="text-xs text-red-500">{loginError}</div>}
|
|
<button type="submit" className="w-full rounded bg-[#00ff66]/15 py-3 text-xs font-bold uppercase tracking-widest text-[#00ff66] hover:bg-[#00ff66]/25 transition-colors">
|
|
AUTHORIZE
|
|
</button>
|
|
</form>
|
|
<Link href="/" className="mt-6 block text-center text-[10px] uppercase text-zinc-700 hover:text-zinc-500 transition-colors">
|
|
← Return to hub
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ── Computed stats ───────────────────────────────────────────────────────────
|
|
|
|
const allHandles = Object.entries(handles);
|
|
const totalVoidCirculating = allHandles.reduce((s, [, r]) => s + r.voidCredits, 0);
|
|
const totalInvoices = Object.keys(invoiceIndex).length;
|
|
const totalToolUnlocks = allHandles.reduce((s, [, r]) => s + r.unlockedTools.length, 0);
|
|
|
|
const TABS: { id: Tab; label: string; icon: string }[] = [
|
|
{ id: "overview", label: "Overview", icon: "◈" },
|
|
{ id: "credits", label: "Credit Ops", icon: "◆" },
|
|
{ id: "raffle", label: "Raffle", icon: "🎟" },
|
|
{ id: "comms", label: "Comms", icon: "📡" },
|
|
{ id: "logs", label: "System Log", icon: "▸" },
|
|
];
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#030008] font-mono text-[#00ff66]">
|
|
{/* Header */}
|
|
<header className="border-b border-[#00ff66]/20 bg-black/60 backdrop-blur px-6 py-3 flex items-center justify-between gap-4 sticky top-0 z-40">
|
|
<div className="flex items-center gap-3">
|
|
<span className="inline-block h-2 w-2 rounded-full bg-[#00ff66] shadow-[0_0_8px_#00ff66] animate-pulse" />
|
|
<span className="text-sm font-black uppercase tracking-widest text-white">CYBERLUX COMMAND</span>
|
|
<span className="hidden sm:inline text-[10px] text-zinc-600 uppercase tracking-widest">// OPERATOR CONSOLE</span>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-[10px] uppercase text-zinc-500">
|
|
<span>@drjones</span>
|
|
<span className="text-[#00ff66]">GOD MODE</span>
|
|
{btcUsd && <span className="text-yellow-500">BTC ${btcUsd.toLocaleString()}</span>}
|
|
<Link href="/" className="rounded border border-zinc-800 px-2 py-1 hover:border-zinc-600 transition-colors">← Hub</Link>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Tab bar */}
|
|
<nav className="border-b border-[#00ff66]/10 bg-black/40 px-6 flex gap-1">
|
|
{TABS.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setTab(t.id)}
|
|
className={`px-4 py-3 text-xs font-bold uppercase tracking-wider transition-colors border-b-2 ${
|
|
tab === t.id
|
|
? "border-[#00ff66] text-[#00ff66]"
|
|
: "border-transparent text-zinc-600 hover:text-zinc-400"
|
|
}`}
|
|
>
|
|
{t.icon} {t.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{/* Body */}
|
|
<main className="p-6 max-w-7xl mx-auto">
|
|
|
|
{/* ── OVERVIEW ─────────────────────────────────────────────────────── */}
|
|
{tab === "overview" && (
|
|
<div className="space-y-6">
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
<StatCard label="VOID Circulating" value={totalVoidCirculating} sub="across all handles" />
|
|
<StatCard label="Handles" value={allHandles.length} sub="registered" />
|
|
<StatCard label="Invoices" value={totalInvoices} sub="total created" />
|
|
<StatCard label="Tool Unlocks" value={totalToolUnlocks} sub="all time" />
|
|
<StatCard label="BTC / USD" value={btcUsd ? `$${btcUsd.toLocaleString()}` : "…"} sub="live oracle" accent="text-yellow-400" />
|
|
</div>
|
|
|
|
{/* Raffle snapshot */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<StatCard label="Raffle Tickets" value={raffleStats?.totalTickets ?? 0} sub="this round" accent="text-purple-400" />
|
|
<StatCard label="Participants" value={raffleStats?.participants ?? 0} sub="this round" accent="text-purple-400" />
|
|
<div className="rounded-lg border border-purple-500/20 bg-black/50 p-4">
|
|
<div className="text-[9px] uppercase tracking-widest text-zinc-500 mb-1">Next Draw</div>
|
|
<div className="text-2xl font-black font-mono tabular-nums text-purple-400">
|
|
{raffleStats ? fmt(raffleStats.drawAt - Date.now()) : "…"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top handles by VOID */}
|
|
<div className="rounded-xl border border-[#00ff66]/20 bg-black/50 p-5">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest mb-4 text-[#00ff66]">Top VOID Holders</h2>
|
|
{allHandles.length === 0 ? (
|
|
<div className="text-xs text-zinc-600">No handles registered yet.</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{[...allHandles].sort((a, b) => b[1].voidCredits - a[1].voidCredits).slice(0, 10).map(([handle, rec]) => (
|
|
<div key={handle} className="flex items-center gap-3">
|
|
<span className="text-xs text-zinc-400 w-32 truncate">@{handle}</span>
|
|
<div className="flex-1 h-2 rounded-full bg-zinc-900">
|
|
<div
|
|
className="h-2 rounded-full bg-[#00ff66]/60"
|
|
style={{ width: `${Math.min(100, (rec.voidCredits / Math.max(1, totalVoidCirculating)) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs font-bold text-[#00ff66] w-16 text-right tabular-nums">{rec.voidCredits} V</span>
|
|
<span className="text-[10px] text-zinc-600 w-20 text-right">{rec.unlockedTools.length} tools</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quick ops */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
{[
|
|
{ label: "VOID Tools", href: "/tools", desc: "Tool marketplace", icon: "🔓" },
|
|
{ label: "DarkHost", href: "/hosting", desc: "Hosting panel", icon: "🖥️" },
|
|
{ label: "Add Funds", href: "/account/add-funds", desc: "BTC deposit", icon: "₿" },
|
|
].map((l) => (
|
|
<Link key={l.href} href={l.href} className="flex items-center gap-3 rounded-lg border border-zinc-800 bg-black/30 p-4 hover:border-[#00ff66]/40 transition-colors">
|
|
<span className="text-xl">{l.icon}</span>
|
|
<div>
|
|
<div className="text-xs font-bold text-white">{l.label}</div>
|
|
<div className="text-[10px] text-zinc-600">{l.desc}</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── CREDIT OPS ───────────────────────────────────────────────────── */}
|
|
{tab === "credits" && (
|
|
<div className="space-y-6">
|
|
{/* Manual credit */}
|
|
<div className="rounded-xl border border-[#00ff66]/25 bg-black/50 p-6">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest mb-4 text-[#00ff66]">Manual VOID Credit</h2>
|
|
<div className="flex flex-wrap gap-3 items-end">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] uppercase text-zinc-500">Handle</label>
|
|
<input
|
|
value={creditHandle}
|
|
onChange={(e) => setCreditHandle(e.target.value)}
|
|
placeholder="username"
|
|
className="rounded border border-zinc-800 bg-black px-3 py-2 text-sm text-[#00ff66] placeholder:text-zinc-700 focus:border-[#00ff66]/50 focus:outline-none w-44"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] uppercase text-zinc-500">VOID Amount</label>
|
|
<input
|
|
value={creditAmt}
|
|
onChange={(e) => setCreditAmt(e.target.value)}
|
|
placeholder="50"
|
|
type="number"
|
|
min="1"
|
|
className="rounded border border-zinc-800 bg-black px-3 py-2 text-sm text-[#00ff66] placeholder:text-zinc-700 focus:border-[#00ff66]/50 focus:outline-none w-32"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={adminCredit}
|
|
className="rounded bg-[#00ff66]/15 border border-[#00ff66]/30 px-5 py-2 text-xs font-bold uppercase text-[#00ff66] hover:bg-[#00ff66]/25 transition-colors"
|
|
>
|
|
CREDIT
|
|
</button>
|
|
</div>
|
|
{creditMsg && (
|
|
<div className={`mt-3 text-xs ${creditMsg.ok ? "text-[#00ff66]" : "text-red-400"}`}>
|
|
{creditMsg.text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Full ledger table */}
|
|
<div className="rounded-xl border border-[#00ff66]/20 bg-black/50 p-5">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest mb-4 text-[#00ff66]">
|
|
Full VOID Ledger — {allHandles.length} handles
|
|
</h2>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-[10px] uppercase text-zinc-600 border-b border-zinc-800">
|
|
<th className="text-left py-2 pr-4">Handle</th>
|
|
<th className="text-right py-2 pr-4">VOID</th>
|
|
<th className="text-right py-2 pr-4">Invoices</th>
|
|
<th className="text-right py-2 pr-4">Tools</th>
|
|
<th className="text-right py-2">Spent (VOID)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{allHandles.length === 0 ? (
|
|
<tr><td colSpan={5} className="py-6 text-center text-zinc-600">No handles yet.</td></tr>
|
|
) : (
|
|
[...allHandles]
|
|
.sort((a, b) => b[1].voidCredits - a[1].voidCredits)
|
|
.map(([handle, rec]) => {
|
|
const spent = rec.unlockedTools.reduce((s, t) => s + t.voidSpent, 0);
|
|
return (
|
|
<tr key={handle} className="border-b border-zinc-900 hover:bg-white/2 transition-colors">
|
|
<td className="py-2 pr-4 text-white font-bold">@{handle}</td>
|
|
<td className="py-2 pr-4 text-right tabular-nums text-[#00ff66] font-bold">{rec.voidCredits}</td>
|
|
<td className="py-2 pr-4 text-right tabular-nums text-zinc-400">{Object.keys(rec.claimedInvoices).length}</td>
|
|
<td className="py-2 pr-4 text-right tabular-nums text-purple-400">{rec.unlockedTools.length}</td>
|
|
<td className="py-2 text-right tabular-nums text-zinc-500">{spent}</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Invoice index */}
|
|
<div className="rounded-xl border border-zinc-800 bg-black/40 p-5">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest mb-4 text-zinc-500">
|
|
Invoice Index — {totalInvoices} total
|
|
</h2>
|
|
<div className="overflow-x-auto max-h-48 overflow-y-auto">
|
|
<table className="w-full text-[10px]">
|
|
<thead>
|
|
<tr className="text-zinc-700 border-b border-zinc-900 uppercase">
|
|
<th className="text-left py-1 pr-4">Invoice ID</th>
|
|
<th className="text-left py-1">Handle</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{Object.entries(invoiceIndex).slice(-30).reverse().map(([inv, handle]) => (
|
|
<tr key={inv} className="border-b border-zinc-900/60">
|
|
<td className="py-1 pr-4 font-mono text-zinc-500">{inv.slice(0, 20)}…</td>
|
|
<td className="py-1 text-zinc-400">@{handle}</td>
|
|
</tr>
|
|
))}
|
|
{totalInvoices === 0 && (
|
|
<tr><td colSpan={2} className="py-4 text-center text-zinc-700">No invoices yet.</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── RAFFLE ───────────────────────────────────────────────────────── */}
|
|
{tab === "raffle" && (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
<StatCard label="Tickets Sold" value={raffleStats?.totalTickets ?? 0} accent="text-purple-400" />
|
|
<StatCard label="Participants" value={raffleStats?.participants ?? 0} accent="text-purple-400" />
|
|
<div className="rounded-lg border border-purple-500/20 bg-black/50 p-4 col-span-2">
|
|
<div className="text-[9px] uppercase tracking-widest text-zinc-500 mb-1">Time to Draw</div>
|
|
<div className="text-3xl font-black font-mono tabular-nums text-purple-400">{fmt(raffleRemaining)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Draw control */}
|
|
<div className="rounded-xl border border-purple-500/30 bg-[#0d0010]/60 p-6">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest mb-4 text-purple-400">Manual Draw</h2>
|
|
<p className="text-xs text-zinc-500 mb-4">
|
|
Executes a weighted random draw client-side for preview purposes. Winner is selected proportional to ticket count.
|
|
</p>
|
|
<button
|
|
onClick={doDraw}
|
|
className="rounded bg-purple-900/40 border border-purple-500/40 px-6 py-2 text-sm font-bold text-purple-300 hover:bg-purple-900/60 transition-colors"
|
|
>
|
|
🎲 EXECUTE DRAW
|
|
</button>
|
|
{drawResult && (
|
|
<div className="mt-4 rounded-lg border border-purple-500/30 bg-purple-900/20 p-4 text-sm font-bold text-purple-300">
|
|
{drawResult}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Entries */}
|
|
<div className="rounded-xl border border-[#00ff66]/20 bg-black/50 p-5">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest mb-4 text-[#00ff66]">Current Entries</h2>
|
|
{raffleEntries.length === 0 ? (
|
|
<div className="text-xs text-zinc-600 py-4">No entries yet.</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{[...raffleEntries].sort((a, b) => b.tickets - a.tickets).map((e) => {
|
|
const pct = raffleStats ? Math.round((e.tickets / raffleStats.totalTickets) * 100) : 0;
|
|
return (
|
|
<div key={e.handle} className="flex items-center gap-3">
|
|
<span className="text-xs text-zinc-400 w-32 truncate">@{e.handle}</span>
|
|
<div className="flex-1 h-2 rounded-full bg-zinc-900">
|
|
<div className="h-2 rounded-full bg-purple-500/60" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<span className="text-xs font-bold text-purple-300 w-16 text-right tabular-nums">{e.tickets} tickets</span>
|
|
<span className="text-[10px] text-zinc-600 w-10 text-right">{pct}%</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── COMMS ────────────────────────────────────────────────────────── */}
|
|
{tab === "comms" && (
|
|
<div className="space-y-6">
|
|
{/* Broadcast */}
|
|
<div className="rounded-xl border border-red-600/30 bg-[#1a0000]/50 p-6">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest mb-3 text-red-500">Global Network Broadcast</h2>
|
|
<textarea
|
|
value={broadcast}
|
|
onChange={(e) => setBroadcast(e.target.value)}
|
|
className="mb-3 h-20 w-full resize-none rounded border border-red-900/40 bg-black/60 p-3 text-xs text-red-300 placeholder:text-red-900 focus:border-red-600/50 focus:outline-none"
|
|
placeholder="Message to all connected nodes…"
|
|
/>
|
|
<button
|
|
onClick={handleBroadcast}
|
|
className="rounded bg-red-900/50 border border-red-700/50 px-6 py-2 text-xs font-bold uppercase text-white hover:bg-red-800/60 transition-colors shadow-[0_0_10px_rgba(255,0,0,0.2)]"
|
|
>
|
|
EXECUTE BROADCAST
|
|
</button>
|
|
</div>
|
|
|
|
{/* Message feed */}
|
|
<div className="rounded-xl border border-[#00ff66]/20 bg-black/50 p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest text-[#00ff66]">
|
|
Comms Intercept — {messages.length} messages
|
|
</h2>
|
|
<button
|
|
onClick={nukeMessages}
|
|
className="rounded border border-red-900/40 bg-red-900/20 px-3 py-1 text-[10px] font-bold text-red-500 hover:bg-red-900/40 transition-colors"
|
|
>
|
|
PURGE ALL
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2 max-h-96 overflow-y-auto pr-1">
|
|
{messages.length === 0 ? (
|
|
<div className="py-8 text-center text-xs text-zinc-600">No traffic detected.</div>
|
|
) : (
|
|
[...messages].reverse().map((msg) => (
|
|
<div key={msg.id} className="rounded bg-[#0a110d] border border-[#00ff66]/10 p-3">
|
|
<div className="flex items-center justify-between mb-1 flex-wrap gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-bold text-[#00ff66]">@{msg.from}</span>
|
|
<span className="text-[9px] text-zinc-600 bg-black px-1.5 rounded">#{msg.channel ?? "global"}</span>
|
|
<span className="text-[9px] text-zinc-700">{new Date(msg.ts).toLocaleString()}</span>
|
|
</div>
|
|
<button onClick={() => deleteMessage(msg.id)} className="text-[10px] text-red-500 hover:text-red-400 font-bold">
|
|
[DEL]
|
|
</button>
|
|
</div>
|
|
<div className="text-sm text-white/85 font-sans break-words">{msg.text}</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── LOGS ─────────────────────────────────────────────────────────── */}
|
|
{tab === "logs" && (
|
|
<div className="rounded-xl border border-[#00ff66]/20 bg-black/80 p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest text-[#00ff66]">System Log</h2>
|
|
<button
|
|
onClick={() => setTerminalLines([])}
|
|
className="text-[10px] text-zinc-600 hover:text-zinc-400 transition-colors"
|
|
>
|
|
CLEAR
|
|
</button>
|
|
</div>
|
|
<div
|
|
ref={logRef}
|
|
className="h-[60vh] overflow-y-auto rounded bg-[#020005] border border-white/5 p-4 text-[11px] leading-relaxed space-y-0.5"
|
|
>
|
|
{terminalLines.map((line, i) => (
|
|
<div
|
|
key={i}
|
|
className={
|
|
line.includes("[ADMIN]") ? "text-red-400" :
|
|
line.includes("[RAFFLE]") ? "text-purple-400" :
|
|
line.includes("[COMMS]") ? "text-yellow-400" :
|
|
line.includes("[SHIELD]") || line.includes("[AUTH]") ? "text-orange-400" :
|
|
line.includes("[BTC]") ? "text-yellow-300" :
|
|
"text-[#00ff66]/70"
|
|
}
|
|
>
|
|
<span className="text-zinc-700 select-none">{String(i + 1).padStart(4, " ")} │ </span>
|
|
{line}
|
|
</div>
|
|
))}
|
|
<div className="text-[#00ff66] animate-pulse">_</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|