mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-09 21:58:41 +02:00
v0.9.6: InfoNet hashchain, Wormhole gate encryption, mesh reputation, 16 community contributors
Gate messages now propagate via the Infonet hashchain as encrypted blobs — every node syncs them through normal chain sync while only Gate members with MLS keys can decrypt. Added mesh reputation system, peer push workers, voluntary Wormhole opt-in for node participation, fork recovery, killwormhole scripts, obfuscated terminology, and hardened the self-updater to protect encryption keys and chain state during updates. New features: Shodan search, train tracking, Sentinel Hub imagery, 8 new intelligence layers, CCTV expansion to 11,000+ cameras across 6 countries, Mesh Terminal CLI, prediction markets, desktop-shell scaffold, and comprehensive mesh test suite (215 frontend + backend tests passing). Community contributors: @wa1id, @AlborzNazari, @adust09, @Xpirix, @imqdcr, @csysp, @suranyami, @chr0n1x, @johan-martensson, @singularfailure, @smithbh, @OrfeoTerkuci, @deuza, @tm-const, @Elhard1, @ttulttul
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { ChevronLeft, Vote } from 'lucide-react';
|
||||
|
||||
export default function BallotView({ onBack }: { onBack: () => void }) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest flex items-center">
|
||||
<Vote className="mr-2 text-cyan-400" />
|
||||
OPEN BALLOT
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Governance is not live in this shell yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 pb-4">
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-6 md:p-8">
|
||||
<div className="border border-cyan-900/40 bg-cyan-950/10 px-6 py-10 text-center">
|
||||
<div className="text-3xl md:text-5xl font-bold tracking-[0.34em] text-cyan-300">
|
||||
DEMOCRACY FOR ALL SOON
|
||||
</div>
|
||||
<p className="mt-5 text-sm text-gray-300 max-w-3xl mx-auto leading-relaxed">
|
||||
There are no live referendums, petitions, or tallies being advertised here right now.
|
||||
This testnet shell should not imply outcomes, fake counts, or policy promises that do
|
||||
not exist yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<div className="border border-gray-800 bg-black/20 p-4">
|
||||
<div className="text-[10px] text-cyan-400 uppercase tracking-[0.22em]">
|
||||
Principle
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-300 leading-relaxed">
|
||||
Governance should be real, verifiable, and community-shaped before it appears in the shell.
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-gray-800 bg-black/20 p-4">
|
||||
<div className="text-[10px] text-cyan-400 uppercase tracking-[0.22em]">
|
||||
Current stance
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-300 leading-relaxed">
|
||||
No timeline, no fake proposals, and no synthetic vote counts are being presented in this build.
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-gray-800 bg-black/20 p-4">
|
||||
<div className="text-[10px] text-cyan-400 uppercase tracking-[0.22em]">
|
||||
Testnet focus
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-300 leading-relaxed">
|
||||
The priority right now is privacy posture, working nodes, real gates, and stable communication.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ChevronLeft, ArrowRightLeft, TrendingUp, TrendingDown, Activity, Wallet, ArrowDownToLine, ArrowUpFromLine, Copy, X } from 'lucide-react';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import type { DashboardData, StockTicker } from '@/types/dashboard';
|
||||
|
||||
interface ExchangeViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type DataSlice = Pick<DashboardData, 'stocks'>;
|
||||
const DATA_KEYS = ['stocks'] as const;
|
||||
|
||||
// Symbols we want to show as crypto trading pairs
|
||||
const CRYPTO_SYMBOLS = ['BTC', 'ETH', 'SOL', 'XRP', 'DOGE'];
|
||||
const CRYPTO_NAMES: Record<string, string> = {
|
||||
BTC: 'Bitcoin', ETH: 'Ethereum', SOL: 'Solana', XRP: 'Ripple', DOGE: 'Dogecoin',
|
||||
ZEC: 'Zcash', XMR: 'Monero', ADA: 'Cardano', DOT: 'Polkadot', AVAX: 'Avalanche',
|
||||
};
|
||||
|
||||
const FALLBACK_PAIRS = [
|
||||
{ symbol: 'BTC', name: 'Bitcoin', price: '—', change: '—', up: true },
|
||||
{ symbol: 'ETH', name: 'Ethereum', price: '—', change: '—', up: true },
|
||||
{ symbol: 'SOL', name: 'Solana', price: '—', change: '—', up: true },
|
||||
];
|
||||
|
||||
const MOCK_BALANCES = [
|
||||
{ symbol: 'CREDITS', name: 'Credits', balance: '12,540.00', value: '12,540.00' },
|
||||
{ symbol: 'BTC', name: 'Bitcoin', balance: '0.045', value: '56,025.00' },
|
||||
{ symbol: 'ETH', name: 'Ethereum', balance: '1.2', value: '101,040.60' },
|
||||
{ symbol: 'SOL', name: 'Solana', balance: '45.0', value: '184,511.25' },
|
||||
{ symbol: 'ZEC', name: 'Zcash', balance: '0.00', value: '0.00' },
|
||||
{ symbol: 'XMR', name: 'Monero', balance: '2.5', value: '72,251.87' },
|
||||
];
|
||||
|
||||
const ORDER_BOOK_BIDS = [
|
||||
{ price: '1,244,900.00', amount: '0.05', total: '62,245.00' },
|
||||
{ price: '1,244,850.00', amount: '0.12', total: '149,382.00' },
|
||||
{ price: '1,244,800.00', amount: '0.80', total: '995,840.00' },
|
||||
];
|
||||
|
||||
const ORDER_BOOK_ASKS = [
|
||||
{ price: '1,245,100.00', amount: '0.02', total: '24,902.00' },
|
||||
{ price: '1,245,150.00', amount: '0.15', total: '186,772.50' },
|
||||
{ price: '1,245,200.00', amount: '1.50', total: '1,867,800.00' },
|
||||
];
|
||||
|
||||
export default function ExchangeView({ onBack }: ExchangeViewProps) {
|
||||
const data = useDataKeys(DATA_KEYS) as DataSlice;
|
||||
const stocks = data?.stocks;
|
||||
|
||||
// Build live trading pairs from real stock data
|
||||
const PAIRS = useMemo(() => {
|
||||
if (!stocks) return FALLBACK_PAIRS;
|
||||
const entries = Object.entries(stocks as Record<string, StockTicker>)
|
||||
.filter(([k]) => !['last_updated', 'source'].includes(k));
|
||||
// Try crypto symbols first, then fill with whatever's available
|
||||
const pairs: { symbol: string; name: string; price: string; change: string; up: boolean }[] = [];
|
||||
for (const sym of CRYPTO_SYMBOLS) {
|
||||
const match = entries.find(([k]) => k.toUpperCase() === sym);
|
||||
if (match) {
|
||||
const [, val] = match;
|
||||
if (val && val.price != null) {
|
||||
pairs.push({
|
||||
symbol: sym,
|
||||
name: CRYPTO_NAMES[sym] || sym,
|
||||
price: val.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
||||
change: `${val.change_percent >= 0 ? '+' : ''}${val.change_percent.toFixed(1)}%`,
|
||||
up: val.change_percent >= 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we didn't find enough crypto, add other stock tickers
|
||||
if (pairs.length < 3) {
|
||||
for (const [k, val] of entries) {
|
||||
if (pairs.some(p => p.symbol === k.toUpperCase())) continue;
|
||||
if (val && val.price != null) {
|
||||
pairs.push({
|
||||
symbol: k.toUpperCase(),
|
||||
name: CRYPTO_NAMES[k.toUpperCase()] || k.toUpperCase(),
|
||||
price: val.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
||||
change: `${val.change_percent >= 0 ? '+' : ''}${val.change_percent.toFixed(1)}%`,
|
||||
up: val.change_percent >= 0,
|
||||
});
|
||||
if (pairs.length >= 8) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs.length > 0 ? pairs : FALLBACK_PAIRS;
|
||||
}, [stocks]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'trade' | 'funds'>('trade');
|
||||
const [selectedPair, setSelectedPair] = useState(PAIRS[0]);
|
||||
const [orderType, setOrderType] = useState<'BUY' | 'SELL'>('BUY');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [price, setPrice] = useState(selectedPair.price.replace(/,/g, ''));
|
||||
|
||||
const [depositAsset, setDepositAsset] = useState<typeof MOCK_BALANCES[0] | null>(null);
|
||||
const [withdrawAsset, setWithdrawAsset] = useState<typeof MOCK_BALANCES[0] | null>(null);
|
||||
const [withdrawAmount, setWithdrawAmount] = useState('');
|
||||
|
||||
const generateMockAddress = (symbol: string) => {
|
||||
const prefix = symbol === 'BTC' ? 'bc1q' : symbol === 'ETH' ? '0x' : symbol === 'SOL' ? '' : 't1';
|
||||
const randomHex = Array.from({length: 32}, () => Math.floor(Math.random()*16).toString(16)).join('');
|
||||
return `${prefix}${randomHex}`;
|
||||
};
|
||||
|
||||
const getNetworkFee = (symbol: string) => {
|
||||
switch(symbol) {
|
||||
case 'BTC': return 0.00015;
|
||||
case 'ETH': return 0.004;
|
||||
case 'SOL': return 0.005;
|
||||
case 'ZEC': return 0.001;
|
||||
case 'XMR': return 0.002;
|
||||
case 'CREDITS': return 5.00;
|
||||
default: return 0.01;
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdrawClick = (asset: typeof MOCK_BALANCES[0]) => {
|
||||
setWithdrawAsset(asset);
|
||||
setWithdrawAmount('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50 mb-4"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest flex items-center">
|
||||
<ArrowRightLeft className="mr-2 text-cyan-400" />
|
||||
DECENTRALIZED EXCHANGE
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Trade crypto assets against Credits. Zero KYC. Zero logs.</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-2 mb-4 shrink-0 border-b border-gray-800 pb-2 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('trade')}
|
||||
className={`flex items-center px-4 py-2 uppercase text-xs tracking-widest transition-colors whitespace-nowrap ${activeTab === 'trade' ? 'bg-gray-800/50 text-gray-300 border-b-2 border-cyan-400' : 'text-gray-500 hover:text-gray-400'}`}
|
||||
>
|
||||
<ArrowRightLeft size={14} className="mr-2" /> TRADE
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('funds')}
|
||||
className={`flex items-center px-4 py-2 uppercase text-xs tracking-widest transition-colors whitespace-nowrap ${activeTab === 'funds' ? 'bg-gray-800/50 text-gray-300 border-b-2 border-cyan-400' : 'text-gray-500 hover:text-gray-400'}`}
|
||||
>
|
||||
<Wallet size={14} className="mr-2" /> FUNDS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 flex flex-col md:flex-row gap-4 pb-4">
|
||||
|
||||
{/* TRADE TAB */}
|
||||
{activeTab === 'trade' && (
|
||||
<>
|
||||
{/* Left Column: Pairs & Chart */}
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
{/* Pairs List */}
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<Activity size={16} className="mr-2" /> TRADING PAIRS (vs CREDITS)
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{PAIRS.map(pair => (
|
||||
<div
|
||||
key={pair.symbol}
|
||||
onClick={() => { setSelectedPair(pair); setPrice(pair.price.replace(/,/g, '')); }}
|
||||
className={`flex justify-between items-center p-2 cursor-pointer transition-colors border ${selectedPair.symbol === pair.symbol ? 'border-cyan-400 bg-cyan-900/20' : 'border-gray-800 bg-[#0a0a0a] hover:border-gray-700'}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold text-gray-300 w-12">{pair.symbol}</span>
|
||||
<span className="text-gray-500 text-xs hidden sm:inline">{pair.name}</span>
|
||||
</div>
|
||||
<div className="text-right flex items-center gap-4">
|
||||
<span className="font-mono text-gray-400">{pair.price}</span>
|
||||
<span className={`text-xs flex items-center w-16 justify-end ${pair.up ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{pair.up ? <TrendingUp size={12} className="mr-1" /> : <TrendingDown size={12} className="mr-1" />}
|
||||
{pair.change}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simple Chart Area */}
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4 flex-1 flex flex-col">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex justify-between items-center">
|
||||
<span>{selectedPair.symbol}/CREDITS CHART</span>
|
||||
<span className="text-xs text-gray-500">1H | 4H | 1D | 1W</span>
|
||||
</h2>
|
||||
<div className="flex-1 flex items-end justify-between gap-1 pt-4 h-32">
|
||||
{Array.from({ length: 20 }).map((_, i) => {
|
||||
const height = 20 + Math.random() * 80;
|
||||
const isUp = Math.random() > 0.5;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-full ${isUp ? 'bg-green-500/50 border-t border-green-400' : 'bg-red-500/50 border-t border-red-400'}`}
|
||||
style={{ height: `${height}%` }}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Order Book & Trade Form */}
|
||||
<div className="w-full md:w-80 flex flex-col gap-4 shrink-0">
|
||||
{/* Trade Form */}
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setOrderType('BUY')}
|
||||
className={`flex-1 py-2 font-bold text-sm border transition-colors ${orderType === 'BUY' ? 'bg-green-900/50 border-green-400 text-green-400' : 'bg-black border-gray-800 text-gray-500 hover:border-gray-700'}`}
|
||||
>
|
||||
BUY {selectedPair.symbol}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOrderType('SELL')}
|
||||
className={`flex-1 py-2 font-bold text-sm border transition-colors ${orderType === 'SELL' ? 'bg-red-900/50 border-red-400 text-red-400' : 'bg-black border-gray-800 text-gray-500 hover:border-gray-700'}`}
|
||||
>
|
||||
SELL {selectedPair.symbol}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 uppercase tracking-widest mb-1 block">Price (Credits)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
className="w-full bg-black border border-gray-800 p-2 text-gray-300 font-mono outline-none focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 uppercase tracking-widest mb-1 block">Amount ({selectedPair.symbol})</label>
|
||||
<input
|
||||
type="text"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full bg-black border border-gray-800 p-2 text-gray-300 font-mono outline-none focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-gray-800 flex justify-between items-center">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest">Total</span>
|
||||
<span className="font-mono text-gray-300 font-bold">
|
||||
{amount && price && !isNaN(Number(amount)) && !isNaN(Number(price))
|
||||
? (Number(amount) * Number(price)).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
: '0.00'} CREDITS
|
||||
</span>
|
||||
</div>
|
||||
<button className={`w-full py-3 font-bold uppercase tracking-widest transition-colors ${orderType === 'BUY' ? 'bg-green-600 hover:bg-green-500 text-black' : 'bg-red-600 hover:bg-red-500 text-black'}`}>
|
||||
{orderType} {selectedPair.symbol}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Book */}
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4 flex-1">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2">ORDER BOOK</h2>
|
||||
<div className="flex justify-between text-xs text-gray-500 uppercase tracking-widest mb-2 px-1">
|
||||
<span>Price(CREDITS)</span>
|
||||
<span>Amt({selectedPair.symbol})</span>
|
||||
<span>Total</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-4">
|
||||
{ORDER_BOOK_ASKS.slice().reverse().map((ask, i) => (
|
||||
<div key={i} className="flex justify-between text-xs font-mono px-1 hover:bg-gray-800/50 cursor-pointer">
|
||||
<span className="text-red-400">{ask.price}</span>
|
||||
<span className="text-gray-300">{ask.amount}</span>
|
||||
<span className="text-gray-500">{ask.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="py-2 border-y border-gray-800 text-center font-mono font-bold text-gray-300 mb-4">
|
||||
{selectedPair.price} <span className="text-gray-500 text-xs font-sans">Spread: 100.00</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{ORDER_BOOK_BIDS.map((bid, i) => (
|
||||
<div key={i} className="flex justify-between text-xs font-mono px-1 hover:bg-gray-800/50 cursor-pointer">
|
||||
<span className="text-green-400">{bid.price}</span>
|
||||
<span className="text-gray-300">{bid.amount}</span>
|
||||
<span className="text-gray-500">{bid.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* FUNDS TAB */}
|
||||
{activeTab === 'funds' && (
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<Wallet size={16} className="mr-2" /> ASSET BALANCES
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{MOCK_BALANCES.map(asset => (
|
||||
<div key={asset.symbol} className="flex flex-col sm:flex-row justify-between items-start sm:items-center p-3 border border-gray-800 bg-[#0a0a0a] hover:border-gray-700 transition-colors gap-4">
|
||||
<div className="flex items-center gap-3 w-48">
|
||||
<div className="w-8 h-8 bg-gray-800/50 rounded-full flex items-center justify-center text-gray-300 font-bold">
|
||||
{asset.symbol.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-300">{asset.symbol}</div>
|
||||
<div className="text-xs text-gray-500">{asset.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 text-left sm:text-right">
|
||||
<div className="font-mono text-gray-300">{asset.balance}</div>
|
||||
<div className="text-xs text-gray-500 font-mono">≈ {asset.value} CREDITS</div>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full sm:w-auto mt-2 sm:mt-0">
|
||||
<button onClick={() => setDepositAsset(asset)} className="flex-1 sm:flex-none flex items-center justify-center px-3 py-1.5 bg-cyan-900/20 border border-cyan-900/50 text-cyan-400 hover:bg-cyan-900/40 transition-colors text-xs uppercase tracking-widest">
|
||||
<ArrowDownToLine size={14} className="mr-1" /> RECEIVE
|
||||
</button>
|
||||
<button onClick={() => handleWithdrawClick(asset)} className="flex-1 sm:flex-none flex items-center justify-center px-3 py-1.5 bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700 transition-colors text-xs uppercase tracking-widest">
|
||||
<ArrowUpFromLine size={14} className="mr-1" /> SEND
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deposit Modal */}
|
||||
{depositAsset && (
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-[#0a0a0a] border border-cyan-600 p-6 max-w-md w-full shadow-[0_0_30px_rgba(6,182,212,0.15)]">
|
||||
<div className="flex justify-between items-center mb-4 border-b border-gray-800 pb-2">
|
||||
<h2 className="text-cyan-500 text-lg font-bold flex items-center">
|
||||
<ArrowDownToLine className="mr-2" /> RECEIVE {depositAsset.symbol}
|
||||
</h2>
|
||||
<button onClick={() => setDepositAsset(null)} className="text-gray-500 hover:text-white"><X size={20}/></button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<div className="bg-white p-2 mb-4">
|
||||
<div className="w-40 h-40 grid grid-cols-8 grid-rows-8 gap-0.5 bg-white p-1">
|
||||
{Array.from({length: 64}).map((_, i) => (
|
||||
<div key={i} className={Math.random() > 0.4 ? 'bg-black' : 'bg-white'}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest mb-2 text-center">Scan QR code or copy address below</p>
|
||||
<div className="w-full flex items-center bg-black border border-gray-800 p-2">
|
||||
<span className="flex-1 font-mono text-xs text-gray-300 truncate select-all">
|
||||
{generateMockAddress(depositAsset.symbol)}
|
||||
</span>
|
||||
<button className="ml-2 text-cyan-400 hover:text-cyan-300"><Copy size={14} /></button>
|
||||
</div>
|
||||
<p className="text-xs text-red-400 mt-4 text-center">Send ONLY {depositAsset.name} ({depositAsset.symbol}) to this address. Sending any other asset will result in permanent loss.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Withdraw Modal */}
|
||||
{withdrawAsset && (
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-[#0a0a0a] border border-cyan-600 p-6 max-w-md w-full shadow-[0_0_30px_rgba(6,182,212,0.15)]">
|
||||
<div className="flex justify-between items-center mb-4 border-b border-gray-800 pb-2">
|
||||
<h2 className="text-cyan-500 text-lg font-bold flex items-center">
|
||||
<ArrowUpFromLine className="mr-2" /> SEND {withdrawAsset.symbol}
|
||||
</h2>
|
||||
<button onClick={() => setWithdrawAsset(null)} className="text-gray-500 hover:text-white"><X size={20}/></button>
|
||||
</div>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<label className="text-xs text-gray-500 uppercase tracking-widest">Available Balance</label>
|
||||
<span className="text-xs font-mono text-cyan-400">{withdrawAsset.balance} {withdrawAsset.symbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 uppercase tracking-widest mb-1 block">Destination Address</label>
|
||||
<input type="text" placeholder={`Enter ${withdrawAsset.symbol} address`} className="w-full bg-black border border-gray-800 p-2 text-gray-300 font-mono outline-none focus:border-cyan-400 text-sm" spellCheck={false} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 uppercase tracking-widest mb-1 block">Amount</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full bg-black border border-gray-800 p-2 text-gray-300 font-mono outline-none focus:border-cyan-400 text-sm pr-16"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setWithdrawAmount(withdrawAsset.balance)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-cyan-400 hover:text-cyan-300 uppercase tracking-widest font-bold"
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/30 border border-gray-800 p-3 mt-2">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest">Network Fee</span>
|
||||
<span className="text-xs font-mono text-gray-400">{getNetworkFee(withdrawAsset.symbol)} {withdrawAsset.symbol}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-t border-gray-800 pt-1 mt-1">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest">Total Deduction</span>
|
||||
<span className="text-xs font-mono text-white font-bold">
|
||||
{withdrawAmount && !isNaN(Number(withdrawAmount))
|
||||
? (Number(withdrawAmount) + getNetworkFee(withdrawAsset.symbol)).toFixed(withdrawAsset.symbol === 'CREDITS' ? 2 : 6)
|
||||
: '0.00'} {withdrawAsset.symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button className="w-full py-3 bg-cyan-900/50 border border-cyan-500 text-cyan-400 hover:bg-cyan-800 transition-colors font-bold uppercase tracking-widest">
|
||||
CONFIRM SEND
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, RefreshCw, Reply, Search, Send } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { controlPlaneJson } from '@/lib/controlPlane';
|
||||
import { nextSequence } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
decryptWormholeGateMessages,
|
||||
fetchWormholeGateKeyStatus,
|
||||
signMeshEvent,
|
||||
type WormholeGateKeyStatus,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
import { gateEnvelopeDisplayText, gateEnvelopeState, isEncryptedGateEnvelope } from '@/mesh/gateEnvelope';
|
||||
import { validateEventPayload } from '@/mesh/meshSchema';
|
||||
|
||||
const GATE_INTROS: Record<string, string> = {
|
||||
infonet:
|
||||
'Welcome to the Infonet general channel. This is the main commons — discuss anything related to the network, ask questions, share intel. Keep it civil.',
|
||||
'general-talk':
|
||||
"Off-topic discussion. Talk about whatever you want — just keep it respectful and don't post anything that'll get the gate burned.",
|
||||
'gathered-intel':
|
||||
'Post verified OSINT findings here. Unverified rumors go in general-talk. Cite your sources or get downvoted into oblivion.',
|
||||
'tracked-planes':
|
||||
"Military and private aviation tracking discussion. Share callsigns, unusual flight patterns, and transponder anomalies you've spotted on the map.",
|
||||
'ukraine-front':
|
||||
'Ukraine conflict monitoring. Frontline updates, satellite imagery analysis, and verified ground reports only. No propaganda.',
|
||||
'iran-front':
|
||||
'Iran and Middle East situational awareness. Missile activity, naval movements, diplomatic developments. Verified sources preferred.',
|
||||
'world-news':
|
||||
"Breaking world events and geopolitical developments. If it's happening right now and it matters, post it here.",
|
||||
'prediction-markets':
|
||||
'Discuss prediction market movements, arbitrage opportunities, and consensus shifts. Polymarket and Kalshi analysis welcome.',
|
||||
finance:
|
||||
'Markets, macro trends, sanctions tracking, and economic intelligence. No financial advice — just signal.',
|
||||
cryptography:
|
||||
'Encryption protocols, zero-knowledge proofs, post-quantum research, and implementation discussion. Show your math.',
|
||||
cryptocurrencies:
|
||||
'Crypto markets, DeFi protocols, chain analysis, and privacy coins. No shilling. No pump groups.',
|
||||
'meet-chat':
|
||||
'Find other sovereigns in your area. Coordinate local meetups, dead drops, or mesh node deployments. Practice good OPSEC.',
|
||||
'opsec-lab':
|
||||
'Operational security discussion. Share techniques, tools, and threat models. Help each other stay invisible.',
|
||||
};
|
||||
|
||||
interface GateViewProps {
|
||||
gateName: string;
|
||||
persona: string;
|
||||
entryMode?: 'anonymous' | 'persona' | null;
|
||||
onBack: () => void;
|
||||
onNavigateGate: (gate: string) => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
availableGates: string[];
|
||||
}
|
||||
|
||||
interface GateMessage {
|
||||
event_id: string;
|
||||
event_type?: string;
|
||||
node_id?: string;
|
||||
message?: string;
|
||||
ciphertext?: string;
|
||||
epoch?: number;
|
||||
nonce?: string;
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
gate_envelope?: string;
|
||||
decrypted_message?: string;
|
||||
payload?: {
|
||||
gate?: string;
|
||||
ciphertext?: string;
|
||||
nonce?: string;
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
gate_envelope?: string;
|
||||
reply_to?: string;
|
||||
};
|
||||
gate?: string;
|
||||
timestamp: number;
|
||||
sequence?: number;
|
||||
signature?: string;
|
||||
public_key?: string;
|
||||
public_key_algo?: string;
|
||||
protocol_version?: string;
|
||||
reply_to?: string;
|
||||
ephemeral?: boolean;
|
||||
system_seed?: boolean;
|
||||
fixed_gate?: boolean;
|
||||
}
|
||||
|
||||
interface ReplyContext {
|
||||
eventId: string;
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
const GATE_ACCESS_PROOF_TTL_MS = 45_000;
|
||||
const gateAccessHeaderCache = new Map<string, { headers: Record<string, string>; expiresAt: number }>();
|
||||
|
||||
function timeAgo(timestamp: number): string {
|
||||
const ts = Number(timestamp || 0);
|
||||
if (!ts) return 'just now';
|
||||
const delta = Math.max(0, Math.floor(Date.now() / 1000) - ts);
|
||||
if (delta < 60) return `${delta}s`;
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h`;
|
||||
return `${Math.floor(delta / 86400)}d`;
|
||||
}
|
||||
|
||||
interface ThreadedMessage {
|
||||
message: GateMessage;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/** Build a flat depth-ordered list: root messages first, then their replies indented beneath. */
|
||||
function buildThreadedList(messages: GateMessage[]): ThreadedMessage[] {
|
||||
const byId = new Map<string, GateMessage>();
|
||||
const childrenOf = new Map<string, GateMessage[]>();
|
||||
|
||||
for (const msg of messages) {
|
||||
const id = String(msg.event_id || '');
|
||||
if (id) byId.set(id, msg);
|
||||
const parent = String(msg.reply_to || '').trim();
|
||||
if (parent) {
|
||||
const siblings = childrenOf.get(parent) || [];
|
||||
siblings.push(msg);
|
||||
childrenOf.set(parent, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
const result: ThreadedMessage[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
function walk(msg: GateMessage, depth: number) {
|
||||
const id = String(msg.event_id || '');
|
||||
if (visited.has(id)) return;
|
||||
visited.add(id);
|
||||
result.push({ message: msg, depth: Math.min(depth, 4) });
|
||||
const children = childrenOf.get(id) || [];
|
||||
children.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
for (const child of children) {
|
||||
walk(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Roots: messages with no reply_to, or reply_to pointing to a missing parent
|
||||
const roots = messages.filter((msg) => {
|
||||
const parent = String(msg.reply_to || '').trim();
|
||||
return !parent || !byId.has(parent);
|
||||
});
|
||||
roots.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
for (const root of roots) {
|
||||
walk(root, 0);
|
||||
}
|
||||
// Any orphans not yet visited (shouldn't happen, but safety net)
|
||||
for (const msg of messages) {
|
||||
if (!visited.has(String(msg.event_id || ''))) {
|
||||
walk(msg, 0);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeGateMessage(message: GateMessage): GateMessage {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return {
|
||||
event_id: '',
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
const payload = message.payload && typeof message.payload === 'object' ? message.payload : undefined;
|
||||
return {
|
||||
...message,
|
||||
gate: String(message.gate ?? payload?.gate ?? ''),
|
||||
ciphertext: String(message.ciphertext ?? payload?.ciphertext ?? ''),
|
||||
nonce: String(message.nonce ?? payload?.nonce ?? ''),
|
||||
sender_ref: String(message.sender_ref ?? payload?.sender_ref ?? ''),
|
||||
format: String(message.format ?? payload?.format ?? ''),
|
||||
gate_envelope: String(message.gate_envelope ?? payload?.gate_envelope ?? ''),
|
||||
reply_to: String(message.reply_to ?? payload?.reply_to ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildGateAccessHeaders(gateId: string): Promise<Record<string, string> | undefined> {
|
||||
const normalizedGate = String(gateId || '').trim().toLowerCase();
|
||||
if (!normalizedGate) return undefined;
|
||||
const cached = gateAccessHeaderCache.get(normalizedGate);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.headers;
|
||||
}
|
||||
try {
|
||||
const proof = await controlPlaneJson<{ node_id?: string; ts?: number; proof?: string }>(
|
||||
'/api/wormhole/gate/proof',
|
||||
{
|
||||
requireAdminSession: false,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gate_id: normalizedGate }),
|
||||
},
|
||||
);
|
||||
const nodeId = String(proof.node_id || '').trim();
|
||||
const gateProof = String(proof.proof || '').trim();
|
||||
const gateTs = String(proof.ts || '').trim();
|
||||
if (!nodeId || !gateProof || !gateTs) return undefined;
|
||||
const headers = {
|
||||
'X-Wormhole-Node-Id': nodeId,
|
||||
'X-Wormhole-Gate-Proof': gateProof,
|
||||
'X-Wormhole-Gate-Ts': gateTs,
|
||||
};
|
||||
gateAccessHeaderCache.set(normalizedGate, {
|
||||
headers,
|
||||
expiresAt: Date.now() + GATE_ACCESS_PROOF_TTL_MS,
|
||||
});
|
||||
return headers;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default function GateView({
|
||||
gateName,
|
||||
persona,
|
||||
entryMode = null,
|
||||
onBack,
|
||||
onNavigateGate,
|
||||
onOpenLiveGate: _onOpenLiveGate,
|
||||
availableGates,
|
||||
}: GateViewProps) {
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [messages, setMessages] = useState<GateMessage[]>([]);
|
||||
const [composer, setComposer] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [roomError, setRoomError] = useState('');
|
||||
const [status, setStatus] = useState<WormholeGateKeyStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [replyContext, setReplyContext] = useState<ReplyContext | null>(null);
|
||||
const [reps, setReps] = useState<Record<string, number>>({});
|
||||
const [voteNotice, setVoteNotice] = useState('');
|
||||
const [votedOn, setVotedOn] = useState<Record<string, 1 | -1>>({});
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const gateId = useMemo(() => String(gateName || '').trim().toLowerCase(), [gateName]);
|
||||
const introMessage =
|
||||
GATE_INTROS[gateId] || 'Welcome to this gate. Be civil. The Shadowbroker is watching.';
|
||||
|
||||
const searchMatch = searchInput.startsWith('g/')
|
||||
? availableGates.find((g) => g.startsWith(searchInput.slice(2).toLowerCase()))
|
||||
: null;
|
||||
|
||||
const voteScopeKey = useCallback((targetId: string) => `${gateId}::${String(targetId || '').trim()}`, [gateId]);
|
||||
|
||||
const hydrateMessages = useCallback(async (rawMessages: GateMessage[]): Promise<GateMessage[]> => {
|
||||
const baseMessages = (Array.isArray(rawMessages) ? rawMessages : []).map(normalizeGateMessage);
|
||||
const encrypted = baseMessages
|
||||
.map((message, index) => ({ message, index }))
|
||||
.filter(({ message }) => isEncryptedGateEnvelope(message));
|
||||
|
||||
if (!encrypted.length) {
|
||||
return baseMessages.map((message) => ({ ...message, decrypted_message: '' }));
|
||||
}
|
||||
|
||||
try {
|
||||
const batch = await decryptWormholeGateMessages(
|
||||
encrypted.map(({ message }) => ({
|
||||
gate_id: String(message.gate || gateId),
|
||||
epoch: Number(message.epoch || 0),
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: String(message.gate_envelope || ''),
|
||||
})),
|
||||
);
|
||||
const results = Array.isArray(batch.results) ? batch.results : [];
|
||||
const nextMessages = [...baseMessages];
|
||||
encrypted.forEach(({ index, message }, resultIndex) => {
|
||||
const decrypted = results[resultIndex];
|
||||
nextMessages[index] = {
|
||||
...message,
|
||||
decrypted_message: decrypted?.ok
|
||||
? (decrypted.self_authored && !decrypted.plaintext
|
||||
? (decrypted.legacy
|
||||
? '[legacy gate message — pre-encryption-fix]'
|
||||
: '[your message — plaintext not cached]')
|
||||
: String(decrypted.plaintext || ''))
|
||||
: '',
|
||||
epoch: decrypted?.ok ? Number(decrypted.epoch || message.epoch || 0) : message.epoch,
|
||||
};
|
||||
});
|
||||
return nextMessages;
|
||||
} catch {
|
||||
return baseMessages.map((message) => ({ ...message, decrypted_message: '' }));
|
||||
}
|
||||
}, [gateId]);
|
||||
|
||||
const refreshGate = useCallback(async () => {
|
||||
if (!gateId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const nextStatus = await fetchWormholeGateKeyStatus(gateId);
|
||||
setStatus(nextStatus);
|
||||
if (!nextStatus?.ok || !nextStatus.has_local_access) {
|
||||
setMessages([]);
|
||||
setRoomError(String(nextStatus?.detail || 'Gate access still syncing'));
|
||||
return;
|
||||
}
|
||||
const headers = await buildGateAccessHeaders(gateId);
|
||||
if (!headers) {
|
||||
setMessages([]);
|
||||
setRoomError('Gate proof unavailable');
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ limit: '40', gate: gateId });
|
||||
const res = await fetch(`${API_BASE}/api/mesh/infonet/messages?${params}`, { headers });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setMessages([]);
|
||||
setRoomError(String(data?.detail || 'Failed to load gate room'));
|
||||
return;
|
||||
}
|
||||
const hydrated = await hydrateMessages(Array.isArray(data.messages) ? data.messages : []);
|
||||
const chronological = [...hydrated].reverse();
|
||||
setMessages(chronological);
|
||||
setRoomError('');
|
||||
|
||||
const uniqueEventIds = Array.from(
|
||||
new Set(
|
||||
chronological
|
||||
.map((message) => String(message.event_id || '').trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
if (uniqueEventIds.length > 0) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
for (const eid of uniqueEventIds) params.append('node_id', eid);
|
||||
const repRes = await fetch(`${API_BASE}/api/mesh/reputation/batch?${params}`);
|
||||
if (repRes.ok) {
|
||||
const repData = await repRes.json();
|
||||
const freshReps: Record<string, number> = {};
|
||||
if (repData.reputations && typeof repData.reputations === 'object') {
|
||||
for (const [k, v] of Object.entries(repData.reputations)) {
|
||||
freshReps[k] = Number(v || 0);
|
||||
}
|
||||
}
|
||||
if (Object.keys(freshReps).length > 0) {
|
||||
setReps((prev) => ({ ...prev, ...freshReps }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore batch rep fetch failure */
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setRoomError(error instanceof Error ? error.message : 'Failed to load gate room');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [gateId, hydrateMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshGate();
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshGate();
|
||||
}, 8000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [refreshGate]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
const target = searchInput.trim().toLowerCase();
|
||||
if (target.startsWith('g/')) {
|
||||
const nextGate = target.slice(2);
|
||||
if (availableGates.includes(nextGate)) {
|
||||
onNavigateGate(nextGate);
|
||||
setSearchInput('');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const msg = composer.trim();
|
||||
if (!msg || busy || !gateId) return;
|
||||
if (!status?.has_local_access) {
|
||||
setRoomError('Gate access still syncing');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setRoomError('');
|
||||
try {
|
||||
await controlPlaneJson<{ ok: boolean; detail?: string }>('/api/wormhole/gate/message/post', {
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_content',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
plaintext: msg,
|
||||
reply_to: replyContext?.eventId || '',
|
||||
}),
|
||||
});
|
||||
setComposer('');
|
||||
setReplyContext(null);
|
||||
// Optimistic: append a placeholder message so the user sees it immediately,
|
||||
// then let the next poll cycle (8s) hydrate it with the real encrypted copy.
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
event_id: `_pending_${Date.now()}`,
|
||||
message: msg,
|
||||
decrypted_message: msg,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
node_id: persona,
|
||||
gate: gateId,
|
||||
reply_to: replyContext?.eventId || '',
|
||||
ephemeral: true,
|
||||
} as GateMessage,
|
||||
]);
|
||||
// Non-blocking background refresh to pick up the real message
|
||||
void refreshGate();
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Gate post failed';
|
||||
// Suppress technical sequence/replay errors — just show a clean retry hint
|
||||
if (/replay|sequence/i.test(errMsg)) {
|
||||
setRoomError('Message could not be posted — try again');
|
||||
} else {
|
||||
setRoomError(errMsg);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, composer, gateId, persona, refreshGate, replyContext, status?.has_local_access]);
|
||||
|
||||
const handleVote = useCallback(async (eventId: string, vote: 1 | -1) => {
|
||||
if (!eventId || !gateId || votedOn[voteScopeKey(eventId)] === vote) return;
|
||||
setVotedOn((prev) => ({ ...prev, [voteScopeKey(eventId)]: vote }));
|
||||
try {
|
||||
const payload = { target_id: eventId, vote, gate: gateId };
|
||||
const valid = validateEventPayload('vote', payload);
|
||||
if (!valid.ok) {
|
||||
throw new Error(`invalid vote payload: ${valid.reason}`);
|
||||
}
|
||||
const sequence = nextSequence();
|
||||
const signed = await signMeshEvent('vote', payload, sequence, { gateId });
|
||||
const response = await fetch(`${API_BASE}/api/mesh/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
voter_id: signed.context.nodeId,
|
||||
target_id: eventId,
|
||||
vote,
|
||||
gate: gateId,
|
||||
voter_pubkey: signed.context.publicKey,
|
||||
public_key_algo: signed.context.publicKeyAlgo,
|
||||
voter_sig: signed.signature,
|
||||
sequence: signed.sequence,
|
||||
protocol_version: signed.protocolVersion,
|
||||
}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data?.ok === false) {
|
||||
throw new Error(String(data?.detail || 'Vote failed'));
|
||||
}
|
||||
// Use the real weight from the backend for the optimistic score update.
|
||||
// The next poll cycle (8s) will reconcile with the real backend score.
|
||||
const w = typeof data?.weight === 'number' ? data.weight : 1;
|
||||
setReps((prev) => ({
|
||||
...prev,
|
||||
[eventId]: Math.round(((prev[eventId] ?? 0) + vote * w) * 10) / 10,
|
||||
}));
|
||||
} catch (err) {
|
||||
// Revert vote state
|
||||
setVotedOn((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[voteScopeKey(eventId)];
|
||||
return next;
|
||||
});
|
||||
// Show brief notice for duplicate votes
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
if (/already set|one vote/i.test(msg)) {
|
||||
setVoteNotice('One vote per post');
|
||||
setTimeout(() => setVoteNotice(''), 3000);
|
||||
}
|
||||
}
|
||||
}, [gateId, voteScopeKey, votedOn]);
|
||||
|
||||
const threadedMessages = useMemo(() => buildThreadedList(messages), [messages]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<div className="text-gray-500 text-xs">
|
||||
LOGGED IN AS:{' '}
|
||||
<span
|
||||
className={
|
||||
persona === 'shadowbroker' ? 'text-red-500 animate-pulse font-bold' : 'text-green-400'
|
||||
}
|
||||
>
|
||||
{persona}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 mt-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest">g/{gateId}</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Fixed obfuscated gate. Creation is disabled for this testnet.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void refreshGate()}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 border border-cyan-500/30 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-900/30 transition-colors text-[10px] uppercase tracking-[0.22em]"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 border border-gray-800 bg-gray-900/20 text-xs text-gray-400">
|
||||
<p className="font-bold text-cyan-400 mb-1">=== GATE RULES ===</p>
|
||||
<p>1. FIXED LAUNCH CATALOG: no new gates can be created in this build.</p>
|
||||
<p>2. POSTS + REPLIES PERSIST ON THE OBFUSCATED GATE STORE FOR NODES THAT CARRY THIS GATE.</p>
|
||||
<p>3. GATE VOTES USE THE EXISTING PUBLIC LEDGER VOTE CONTRACT FOR RECORDKEEPING.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 border border-amber-900/30 bg-amber-950/10 text-[11px] text-amber-200/80 leading-relaxed">
|
||||
{entryMode === 'anonymous'
|
||||
? 'Anonymous session is active for this gate. The backend rotates a fresh gate-scoped public key here. You can read, post, reply, and cast the current gate-scoped votes from this room.'
|
||||
: 'Saved gate face is active for this room. Posts stay scoped to this gate while the room history persists on the obfuscated gate lane.'}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-[10px] font-mono text-cyan-400/85">
|
||||
{status?.has_local_access
|
||||
? `LIVE ROOM READY • ${status.identity_scope || entryMode || 'gate'} access`
|
||||
: loading
|
||||
? 'CONNECTING TO OBFUSCATED GATE LANE...'
|
||||
: String(status?.detail || 'Gate access still syncing')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 relative shrink-0">
|
||||
<div className="flex items-center border border-gray-800 bg-[#0a0a0a] p-2">
|
||||
<Search size={14} className="text-gray-600 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search posts or type g/[gate] to jump..."
|
||||
className="bg-transparent border-none outline-none text-white w-full text-sm placeholder-gray-700"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
{searchMatch && searchInput.length > 2 && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-[#0a0a0a] border border-gray-800 p-2 text-xs text-gray-400 z-20">
|
||||
Jump to:{' '}
|
||||
<span
|
||||
className="text-white font-bold cursor-pointer"
|
||||
onClick={() => {
|
||||
onNavigateGate(searchMatch);
|
||||
setSearchInput('');
|
||||
}}
|
||||
>
|
||||
g/{searchMatch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{roomError ? (
|
||||
<div className="mb-3 shrink-0 border border-red-900/30 bg-red-950/10 px-3 py-2 text-[11px] text-red-300">
|
||||
{roomError}
|
||||
</div>
|
||||
) : null}
|
||||
{voteNotice ? (
|
||||
<div className="mb-2 shrink-0 border border-yellow-800/30 bg-yellow-950/10 px-3 py-1.5 text-[10px] text-yellow-400/80 font-mono">
|
||||
{voteNotice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-3 pb-4 styled-scrollbar">
|
||||
{!messages.length && (
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-3">
|
||||
<div className="text-xs mb-1 text-gray-500">
|
||||
Posted by:{' '}
|
||||
<span className="text-red-500 font-bold animate-pulse drop-shadow-[0_0_5px_rgba(239,68,68,0.8)]">
|
||||
shadowbroker
|
||||
</span>
|
||||
<span className="text-gray-600 ml-2">PINNED</span>
|
||||
</div>
|
||||
<h2 className="text-sm md:text-base text-gray-300 leading-relaxed">{introMessage}</h2>
|
||||
<div className="mt-3 pt-2 border-t border-gray-800/50 text-[10px] text-amber-400/70 tracking-wider uppercase">
|
||||
Fixed launch gate for the testnet catalog. Dynamic gate creation is disabled.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{threadedMessages.map(({ message, depth }) =>
|
||||
message.system_seed ? (
|
||||
<div key={message.event_id} className="border border-cyan-900/30 bg-cyan-950/10 px-3 py-3 max-w-3xl">
|
||||
<div className="text-[8px] font-mono tracking-[0.28em] text-cyan-300/85">
|
||||
{message.fixed_gate ? 'FIXED GATE NOTICE' : 'GATE NOTICE'}
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] font-mono text-cyan-100/80 leading-[1.7]">
|
||||
{message.message}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={message.event_id}
|
||||
className="flex"
|
||||
style={{ paddingLeft: depth * 24 }}
|
||||
>
|
||||
{depth > 0 && (
|
||||
<div className="flex-shrink-0 w-[2px] bg-cyan-900/30 mr-3 self-stretch" />
|
||||
)}
|
||||
<div className={`flex-1 border ${depth > 0 ? 'border-gray-800/40 bg-black/10' : 'border-gray-800/70 bg-black/20'} px-3 py-3`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono">
|
||||
<span className="text-green-400" title={String(message.public_key || message.node_id || '')}>
|
||||
@{String(message.node_id || '').replace(/^!sb_/, '').slice(0, 8)
|
||||
|| String(message.public_key || '').slice(0, 8)
|
||||
|| 'unknown'}
|
||||
</span>
|
||||
{isEncryptedGateEnvelope(message) ? (
|
||||
<span
|
||||
className={`text-[8px] px-1 border ${
|
||||
gateEnvelopeState(message) === 'decrypted'
|
||||
? 'text-cyan-300 border-cyan-700/60'
|
||||
: 'text-amber-300 border-amber-700/60'
|
||||
}`}
|
||||
>
|
||||
{gateEnvelopeState(message) === 'decrypted' ? 'DECRYPTED' : 'KEY LOCKED'}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[var(--text-muted)] text-[9px]">{timeAgo(message.timestamp)}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`mt-2 text-[12px] leading-[1.7] whitespace-pre-wrap break-words ${
|
||||
isEncryptedGateEnvelope(message) && !String(message.decrypted_message || '').trim()
|
||||
? 'text-gray-500 italic'
|
||||
: 'text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{gateEnvelopeDisplayText(message)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setReplyContext({
|
||||
eventId: String(message.event_id || ''),
|
||||
nodeId: String(message.node_id || ''),
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[9px] uppercase tracking-[0.18em] border border-cyan-900/40 text-cyan-400 hover:bg-cyan-950/20"
|
||||
>
|
||||
<Reply size={11} />
|
||||
Reply
|
||||
</button>
|
||||
{message.event_id ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => void handleVote(String(message.event_id || ''), 1)}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 text-[9px] uppercase tracking-[0.18em] border ${
|
||||
votedOn[voteScopeKey(String(message.event_id || ''))] === 1
|
||||
? 'border-cyan-400/60 text-cyan-300 bg-cyan-950/20'
|
||||
: 'border-cyan-900/40 text-cyan-500 hover:bg-cyan-950/20'
|
||||
}`}
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleVote(String(message.event_id || ''), -1)}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 text-[9px] uppercase tracking-[0.18em] border ${
|
||||
votedOn[voteScopeKey(String(message.event_id || ''))] === -1
|
||||
? 'border-red-400/60 text-red-300 bg-red-950/20'
|
||||
: 'border-cyan-900/40 text-red-400 hover:bg-red-950/20'
|
||||
}`}
|
||||
>
|
||||
<ArrowDown size={11} />
|
||||
Down
|
||||
</button>
|
||||
<span className="text-[10px] font-mono text-cyan-400/70">
|
||||
SCORE {(() => { const s = reps[String(message.event_id || '')] ?? 0; return s % 1 === 0 ? s : s.toFixed(1); })()}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 pt-3 mt-2 border-t border-gray-800/50">
|
||||
{replyContext ? (
|
||||
<div className="mb-2 flex items-center justify-between gap-2 border border-amber-900/30 bg-amber-950/10 px-3 py-2 text-[10px] text-amber-200/80">
|
||||
<span>
|
||||
Replying to @{replyContext.eventId.slice(0, 8)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setReplyContext(null)}
|
||||
className="text-amber-300 hover:text-amber-100 uppercase tracking-[0.18em]"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={composer}
|
||||
onChange={(e) => setComposer(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Post into this gate..."
|
||||
className="flex-1 min-h-[72px] max-h-[140px] bg-black/40 border border-cyan-900/40 text-gray-100 px-3 py-2 outline-none resize-y placeholder:text-gray-700"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void handleSend()}
|
||||
disabled={busy || !composer.trim() || !status?.has_local_access}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-900/30 transition-colors text-[10px] uppercase tracking-[0.22em] disabled:opacity-40"
|
||||
>
|
||||
<Send size={13} />
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
|
||||
const ROADMAP_ITEMS = [
|
||||
{
|
||||
title: 'Obfuscated lane hardening',
|
||||
detail: 'Continue tightening identity boundaries, transport posture, and secure comms behavior without overstating guarantees.',
|
||||
status: 'ONGOING',
|
||||
type: 'PRIVACY',
|
||||
},
|
||||
{
|
||||
title: 'Participant-node federation',
|
||||
detail: 'Keep improving bootstrap, sync clarity, and real multi-node propagation for the public testnet.',
|
||||
status: 'TESTNET',
|
||||
type: 'NETWORK',
|
||||
},
|
||||
{
|
||||
title: 'Fixed-gate polish',
|
||||
detail: 'Wire the existing Wormhole gates cleanly, keep gate creation disabled, and focus on smooth participation.',
|
||||
status: 'IN PROGRESS',
|
||||
type: 'GATES',
|
||||
},
|
||||
];
|
||||
|
||||
export default function HashchainEvents() {
|
||||
return (
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-3 w-64 hidden lg:block shrink-0 h-fit">
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase border-b border-gray-800 pb-2">
|
||||
<Calendar size={14} className="mr-2" /> Privacy Roadmap
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{ROADMAP_ITEMS.map((item, i) => (
|
||||
<div key={i} className="group cursor-pointer">
|
||||
<div className="flex justify-between items-center mb-0.5">
|
||||
<span className="text-[10px] text-green-400 uppercase tracking-widest border border-gray-800 px-1">
|
||||
{item.type}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-cyan-400">
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-300 group-hover:text-white transition-colors mt-1">
|
||||
{item.title}
|
||||
</p>
|
||||
<div className="text-[10px] text-gray-500 mt-1 leading-relaxed">
|
||||
{item.detail}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Shield, RefreshCw, Trash2, Lock, Globe, MessageSquare, DoorOpen, User, Coins } from 'lucide-react';
|
||||
|
||||
type Domain = 'ROOT' | 'TRANSPORT' | 'DM_ALIAS' | 'GATE_SESSION' | 'GATE_PERSONA' | 'COIN';
|
||||
|
||||
interface DomainConfig {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
visibility: 'NEVER PUBLIC' | 'PUBLIC' | 'SEMI-OBFUSCATED' | 'GATE-SCOPED' | 'NEVER LINKED';
|
||||
color: string;
|
||||
}
|
||||
|
||||
const DOMAINS: Record<Domain, DomainConfig> = {
|
||||
ROOT: { name: 'ROOT', icon: <Lock size={14} />, visibility: 'NEVER PUBLIC', color: 'text-red-500' },
|
||||
TRANSPORT: { name: 'TRANSPORT', icon: <Globe size={14} />, visibility: 'PUBLIC', color: 'text-green-400' },
|
||||
DM_ALIAS: { name: 'DM_ALIAS', icon: <MessageSquare size={14} />, visibility: 'SEMI-OBFUSCATED', color: 'text-cyan-400' },
|
||||
GATE_SESSION: { name: 'GATE_SESSION', icon: <DoorOpen size={14} />, visibility: 'GATE-SCOPED', color: 'text-cyan-400' },
|
||||
GATE_PERSONA: { name: 'GATE_PERSONA', icon: <User size={14} />, visibility: 'GATE-SCOPED', color: 'text-cyan-400' },
|
||||
COIN: { name: 'COIN', icon: <Coins size={14} />, visibility: 'NEVER LINKED', color: 'text-red-400' },
|
||||
};
|
||||
|
||||
export default function IdentityHUD({ currentDomain = 'TRANSPORT' }: { currentDomain?: Domain }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const domain = DOMAINS[currentDomain];
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 right-4 z-[3] flex flex-col items-end">
|
||||
{isExpanded && (
|
||||
<div className="mb-2 w-64 bg-[#0a0a0a] border border-gray-800 p-3 shadow-[0_0_20px_rgba(6,182,212,0.1)]">
|
||||
<div className="flex justify-between items-center mb-3 border-b border-gray-800 pb-2">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest font-bold">Identity Domains</span>
|
||||
<button onClick={() => setIsExpanded(false)} className="text-gray-500 hover:text-white">×</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(Object.keys(DOMAINS) as Domain[]).map((key) => {
|
||||
const d = DOMAINS[key];
|
||||
const isActive = key === currentDomain;
|
||||
return (
|
||||
<div key={key} className={`p-2 border ${isActive ? 'border-cyan-500 bg-cyan-500/5' : 'border-gray-800 bg-gray-900/20'} flex items-center justify-between group`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={isActive ? 'text-cyan-400' : 'text-gray-600'}>{d.icon}</span>
|
||||
<div>
|
||||
<p className={`text-[10px] font-bold tracking-tighter ${isActive ? 'text-white' : 'text-gray-500'}`}>{d.name}</p>
|
||||
<p className="text-[8px] text-gray-600 uppercase">{d.visibility}</p>
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="flex gap-1">
|
||||
<button title="Rotate Identity" className="p-1 text-gray-600 hover:text-cyan-400 transition-colors">
|
||||
<RefreshCw size={10} />
|
||||
</button>
|
||||
<button title="Purge Domain Data" className="p-1 text-gray-600 hover:text-red-400 transition-colors">
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-2 border-t border-gray-800">
|
||||
<p className="text-[8px] text-red-500/70 uppercase leading-tight">
|
||||
CRITICAL: CROSS-DOMAIN LINKAGE IS PROTOCOL-FORBIDDEN.
|
||||
ROTATING IDENTITY PURGES ALL LOCAL SESSION CACHE.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={`flex items-center gap-3 px-4 py-2 border ${isExpanded ? 'border-cyan-500 bg-cyan-900/20' : 'border-gray-800 bg-gray-900/80'} backdrop-blur-md transition-all hover:border-cyan-400 group`}
|
||||
>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest leading-none mb-1">Active Domain</span>
|
||||
<span className={`text-xs font-bold tracking-widest ${domain.color} flex items-center gap-1`}>
|
||||
{domain.icon} {domain.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-8 w-[1px] bg-gray-800 group-hover:bg-cyan-500/50 transition-colors" />
|
||||
<Shield size={18} className={isExpanded ? 'text-cyan-400' : 'text-gray-500 group-hover:text-cyan-400'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Terminal, Radio, Globe, Key, LogOut, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail } from 'lucide-react';
|
||||
import { getNodeIdentity, getWormholeIdentityDescriptor } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
activateWormholeGatePersona,
|
||||
createWormholeGatePersona,
|
||||
enterWormholeGate,
|
||||
fetchWormholeIdentity,
|
||||
listWormholeGatePersonas,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
import GateView from './GateView';
|
||||
import MarketView from './MarketView';
|
||||
import ProfileView from './ProfileView';
|
||||
import MessagesView from './MessagesView';
|
||||
import TerminalDashboard from './TerminalDashboard';
|
||||
import WeatherWidget from './WeatherWidget';
|
||||
import TrendingPosts from './TrendingPosts';
|
||||
import HashchainEvents from './HashchainEvents';
|
||||
import NetworkStats from './NetworkStats';
|
||||
|
||||
|
||||
const ASCII_HEADER = `
|
||||
T H E
|
||||
██╗███╗ ██╗███████╗██████╗ ███╗ ██╗███████╗████████╗
|
||||
██║████╗ ██║██╔════╝██╔═══██╗████╗ ██║██╔════╝╚══██╔══╝
|
||||
██║██╔██╗ ██║█████╗ ██║ ██║██╔██╗ ██║█████╗ ██║
|
||||
██║██║╚██╗██║██╔══╝ ██║ ██║██║╚██╗██║██╔══╝ ██║
|
||||
██║██║ ╚████║██║ ╚██████╔╝██║ ╚████║███████╗ ██║
|
||||
╚═╝╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝ ╚═╝
|
||||
C O M M O N S
|
||||
|
||||
======================================
|
||||
INFONET SOVEREIGN SHELL v0.1.1 (TEST)
|
||||
TEST-NET CONNECTION ESTABLISHED
|
||||
======================================
|
||||
`;
|
||||
|
||||
const COMING_SOON_MODULES: Record<string, { title: string; desc: string; status: string }> = {
|
||||
BALLOT: {
|
||||
title: 'BALLOT — DEMOCRACY FOR ALL SOON',
|
||||
desc: 'Governance surfaces are not live in this testnet shell yet. When they arrive, they should reflect real community demand, clear rules, and verifiable participation instead of placeholder politics.',
|
||||
status: 'MODULE STATUS: HOLDING SCREEN ONLY — NO LIVE BALLOTS OR COUNTS',
|
||||
},
|
||||
GIGS: {
|
||||
title: 'GIGS — NETWORK BOUNTIES',
|
||||
desc: 'Decentralized work contracts, intelligence bounties, and mesh task allocation. Accept jobs, deliver payloads, and earn credits through verified proof-of-work completion.',
|
||||
status: 'MODULE STATUS: TESTNET ONLY — CONTRACT ENGINE IN DEVELOPMENT',
|
||||
},
|
||||
EXCHANGE: {
|
||||
title: 'EXCHANGE — DECENTRALIZED TRADING',
|
||||
desc: 'Zero-KYC peer-to-peer asset exchange. Trade crypto against credits with on-chain order books, stealth addresses, and privacy-preserving settlement.',
|
||||
status: 'MODULE STATUS: TESTNET ONLY — LIQUIDITY POOLS NOT YET ACTIVE',
|
||||
},
|
||||
};
|
||||
|
||||
const GATES = [
|
||||
'infonet', 'general-talk', 'gathered-intel', 'tracked-planes',
|
||||
'ukraine-front', 'iran-front', 'world-news', 'prediction-markets',
|
||||
'finance', 'cryptography', 'cryptocurrencies', 'meet-chat', 'opsec-lab'
|
||||
];
|
||||
|
||||
const SHELL_ANON_PERSONAS_KEY = 'sb_infonet_shell_anon_personas';
|
||||
|
||||
function readShellAnonPersonas(): string[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SHELL_ANON_PERSONAS_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed.map((value) => String(value || '').trim()).filter(Boolean) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeShellAnonPersonas(personas: string[]): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(SHELL_ANON_PERSONAS_KEY, JSON.stringify(personas));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function allocateShellAnonPersona(): string {
|
||||
const existing = readShellAnonPersonas();
|
||||
const used = new Set(existing.map((persona) => persona.toLowerCase()));
|
||||
for (let attempt = 0; attempt < 10_000; attempt += 1) {
|
||||
const candidate = `anon_${Math.floor(100 + Math.random() * 9_900)}`;
|
||||
if (used.has(candidate.toLowerCase())) continue;
|
||||
writeShellAnonPersonas([...existing, candidate]);
|
||||
return candidate;
|
||||
}
|
||||
const fallback = `anon_${Date.now()}`;
|
||||
writeShellAnonPersonas([...existing, fallback]);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const SECTIONS = [
|
||||
{ name: 'HELP', icon: <Terminal size={14} className="mr-2" /> },
|
||||
{ name: 'BALLOT', icon: <Vote size={14} className="mr-2" /> },
|
||||
{ name: 'GIGS', icon: <Briefcase size={14} className="mr-2" /> },
|
||||
{ name: 'MESH', icon: <Globe size={14} className="mr-2" /> },
|
||||
{ name: 'GATES', icon: <Key size={14} className="mr-2" /> },
|
||||
{ name: 'MARKETS', icon: <Activity size={14} className="mr-2" /> },
|
||||
{ name: 'EXCHANGE', icon: <ArrowRightLeft size={14} className="mr-2" /> },
|
||||
{ name: 'PROFILE', icon: <User size={14} className="mr-2" /> },
|
||||
{ name: 'MESSAGES', icon: <Mail size={14} className="mr-2" /> },
|
||||
{ name: 'EXIT', icon: <LogOut size={14} className="mr-2" /> },
|
||||
];
|
||||
|
||||
interface CommandHistory {
|
||||
command: string;
|
||||
output: React.ReactNode;
|
||||
}
|
||||
|
||||
interface InfonetShellProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
}
|
||||
|
||||
export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: InfonetShellProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [history, setHistory] = useState<CommandHistory[]>([]);
|
||||
const [isBooting, setIsBooting] = useState(true);
|
||||
const [bootText, setBootText] = useState<string[]>([]);
|
||||
|
||||
// Navigation & State
|
||||
const [currentView, setCurrentView] = useState<'terminal' | 'gate' | 'market' | 'profile' | 'messages'>('terminal');
|
||||
const [activeGate, setActiveGate] = useState<string | null>(null);
|
||||
const [persona, setPersona] = useState<string | null>(null);
|
||||
const [activeGateMode, setActiveGateMode] = useState<'anonymous' | 'persona' | null>(null);
|
||||
const [inputMode, setInputMode] = useState<'normal' | 'persona'>('normal');
|
||||
const [pendingGate, setPendingGate] = useState<string | null>(null);
|
||||
const [isCitizen] = useState(false);
|
||||
const [comingSoonModule, setComingSoonModule] = useState<string | null>(null);
|
||||
const [wormholePromptKey, setWormholePromptKey] = useState('');
|
||||
|
||||
const endOfTerminalRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Real mesh identity
|
||||
const nodeIdentity = useMemo(() => getNodeIdentity(), []);
|
||||
const wormholeDescriptor = useMemo(() => getWormholeIdentityDescriptor(), []);
|
||||
const promptHost = useMemo(
|
||||
() =>
|
||||
String(
|
||||
nodeIdentity?.publicKey || wormholePromptKey || wormholeDescriptor?.publicKey || 'no-public-key',
|
||||
).trim() || 'no-public-key',
|
||||
[nodeIdentity?.publicKey, wormholeDescriptor?.publicKey, wormholePromptKey],
|
||||
);
|
||||
const shellPrompt = `${isCitizen ? 'citizen' : 'sovereign'}@${promptHost}:~$`;
|
||||
|
||||
/* Reset + boot sequence when opened */
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Reset state
|
||||
setHistory([]);
|
||||
setCurrentView('terminal');
|
||||
setActiveGate(null);
|
||||
setPersona(null);
|
||||
setActiveGateMode(null);
|
||||
setInputMode('normal');
|
||||
setPendingGate(null);
|
||||
setInput('');
|
||||
setIsBooting(true);
|
||||
setBootText([]);
|
||||
|
||||
const bootLines = [
|
||||
'INITIALIZING KERNEL...',
|
||||
'LOADING MODULES: [OK]',
|
||||
'MOUNTING VFS: [OK]',
|
||||
'STARTING NETWORK INTERFACES...',
|
||||
'CONNECTING TO INFONET MESH...',
|
||||
'ESTABLISHING SECURE TUNNEL...',
|
||||
'HANDSHAKE COMPLETE.',
|
||||
'WELCOME SOVEREIGN.'
|
||||
];
|
||||
|
||||
let currentLine = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (currentLine < bootLines.length) {
|
||||
setBootText(prev => [...prev, bootLines[currentLine]]);
|
||||
currentLine++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setTimeout(() => setIsBooting(false), 500);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOpen]);
|
||||
|
||||
/* Focus input after boot — scoped to container */
|
||||
useEffect(() => {
|
||||
if (!isBooting && isOpen) {
|
||||
inputRef.current?.focus();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const handleGlobalClick = () => {
|
||||
if (window.getSelection()?.toString()) return;
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
container.addEventListener('click', handleGlobalClick);
|
||||
return () => container.removeEventListener('click', handleGlobalClick);
|
||||
}
|
||||
}, [isBooting, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isOpen || nodeIdentity?.publicKey) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const identity = await fetchWormholeIdentity();
|
||||
if (!cancelled) {
|
||||
setWormholePromptKey(String(identity?.public_key || '').trim());
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setWormholePromptKey('');
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, nodeIdentity?.publicKey]);
|
||||
|
||||
/* Scroll to bottom */
|
||||
useEffect(() => {
|
||||
endOfTerminalRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [history]);
|
||||
|
||||
const handleNavigate = (view: 'terminal' | 'gate' | 'market' | 'profile' | 'messages', gate?: string) => {
|
||||
if (view === 'gate' && gate) {
|
||||
if (onOpenLiveGate) {
|
||||
setPendingGate(gate);
|
||||
setInputMode('persona');
|
||||
setHistory(prev => [...prev, {
|
||||
command: `join ${gate}`,
|
||||
output: (
|
||||
<span className="text-cyan-400">
|
||||
Type a gate face label to open the encrypted room, or type
|
||||
{' '}
|
||||
<span className="font-bold text-white">anon</span>
|
||||
{' '}
|
||||
for a rotating obfuscated session that opens the room under a fresh gate-scoped key.
|
||||
{' '}
|
||||
<span className="text-red-400">'shadowbroker' is reserved.</span>
|
||||
</span>
|
||||
)
|
||||
}]);
|
||||
return;
|
||||
}
|
||||
setActiveGate(gate);
|
||||
setActiveGateMode(persona ? 'persona' : 'anonymous');
|
||||
}
|
||||
setCurrentView(view);
|
||||
};
|
||||
|
||||
const handleCommand = (cmd: string) => {
|
||||
const trimmedCmd = cmd.trim().toLowerCase();
|
||||
let output: React.ReactNode = '';
|
||||
|
||||
if (trimmedCmd === '') return;
|
||||
|
||||
if (inputMode === 'persona') {
|
||||
if (trimmedCmd === 'shadowbroker') {
|
||||
output = <span className="text-red-500 font-bold animate-pulse">ERR: Persona 'shadowbroker' is reserved and cannot be claimed.</span>;
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
return;
|
||||
}
|
||||
if (!pendingGate) {
|
||||
setInputMode('normal');
|
||||
output = <span className="text-red-400">ERR: No pending gate launch target.</span>;
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
return;
|
||||
}
|
||||
const chosenPersona = trimmedCmd === 'anon' ? allocateShellAnonPersona() : cmd.trim();
|
||||
setPersona(chosenPersona);
|
||||
setInputMode('normal');
|
||||
const gateTarget = pendingGate;
|
||||
if (trimmedCmd === 'anon') {
|
||||
output = (
|
||||
<span className="text-amber-300">
|
||||
Rotating anonymous gate key for g/{gateTarget}...
|
||||
</span>
|
||||
);
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
setPendingGate(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await enterWormholeGate(gateTarget, true);
|
||||
setActiveGateMode('anonymous');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'anonymous_gate_enter_failed';
|
||||
setHistory(prev => [...prev, {
|
||||
command: `gate ${gateTarget}`,
|
||||
output: <span className="text-red-400">ERR: {detail}</span>,
|
||||
}]);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
output = <span className="text-green-400">Creating gate face '{chosenPersona}' for g/{gateTarget}...</span>;
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
setPendingGate(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const personas = await listWormholeGatePersonas(gateTarget);
|
||||
const existing = Array.isArray(personas?.personas)
|
||||
? personas.personas.find(
|
||||
(candidate) =>
|
||||
String(candidate?.label || '').trim().toLowerCase() === chosenPersona.toLowerCase(),
|
||||
)
|
||||
: null;
|
||||
const result = existing?.persona_id
|
||||
? await activateWormholeGatePersona(gateTarget, existing.persona_id)
|
||||
: await createWormholeGatePersona(gateTarget, chosenPersona);
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.detail || 'gate_face_create_failed');
|
||||
}
|
||||
setActiveGateMode('persona');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'gate_face_create_failed';
|
||||
setHistory(prev => [...prev, {
|
||||
command: `join ${gateTarget}`,
|
||||
output: <span className="text-red-400">ERR: {detail}</span>,
|
||||
}]);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedCmd === 'help') {
|
||||
output = (
|
||||
<div className="text-gray-400">
|
||||
<p>AVAILABLE COMMANDS:</p>
|
||||
<ul className="list-disc list-inside ml-2 mt-1">
|
||||
<li><span className="text-gray-300 font-bold">help</span> - Display this message</li>
|
||||
<li><span className="text-gray-300 font-bold">clear</span> - Clear terminal output</li>
|
||||
<li><span className="text-gray-300 font-bold">mesh</span> - Access public mesh ledger</li>
|
||||
<li><span className="text-gray-300 font-bold">radio</span> - Open SIGINT / radio surfaces</li>
|
||||
<li><span className="text-gray-300 font-bold">messages</span> - Open Secure Comms</li>
|
||||
<li><span className="text-gray-300 font-bold">profile</span> - View sovereign identity & ledger</li>
|
||||
<li><span className="text-gray-300 font-bold">ballot</span> - View democratic proposals</li>
|
||||
<li><span className="text-gray-300 font-bold">gigs</span> - View network bounties & jobs</li>
|
||||
<li><span className="text-gray-300 font-bold">markets</span> - View prediction markets</li>
|
||||
<li><span className="text-gray-300 font-bold">exchange</span> - Decentralized crypto exchange</li>
|
||||
<li><span className="text-gray-300 font-bold">wormhole</span> - Check secure tunneling status</li>
|
||||
<li><span className="text-gray-300 font-bold">gates</span> - List available obfuscated gates</li>
|
||||
<li><span className="text-gray-300 font-bold">join [gate]</span> - Choose anonymous entry or a gate face, then enter the room</li>
|
||||
<li><span className="text-gray-300 font-bold">exit</span> - Disconnect from Infonet</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmedCmd === 'clear') {
|
||||
setHistory([]);
|
||||
return;
|
||||
} else if (trimmedCmd === 'gates') {
|
||||
output = (
|
||||
<div className="text-gray-400">
|
||||
<p>AVAILABLE OBFUSCATED GATES:</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mt-2">
|
||||
{GATES.map(gate => (
|
||||
<div key={gate} className="flex items-center cursor-pointer hover:text-gray-300 group" onClick={() => handleNavigate('gate', gate)}>
|
||||
<span className="text-gray-500 mr-2 group-hover:text-cyan-400 transition-colors">[{'>'}]</span>
|
||||
<span className="text-gray-300 group-hover:text-white transition-colors group-hover:drop-shadow-[0_0_5px_rgba(6,182,212,0.8)]">{gate}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmedCmd.startsWith('join ') || trimmedCmd.startsWith('g/')) {
|
||||
const target = trimmedCmd.startsWith('g/') ? trimmedCmd.slice(2) : trimmedCmd.split(' ')[1];
|
||||
if (GATES.includes(target)) {
|
||||
handleNavigate('gate', target);
|
||||
return;
|
||||
} else {
|
||||
output = <span className="text-red-400">ERR: Gate '{target}' not found or access denied.</span>;
|
||||
}
|
||||
} else if (trimmedCmd === 'markets') {
|
||||
handleNavigate('market');
|
||||
return;
|
||||
} else if (trimmedCmd === 'messages') {
|
||||
handleNavigate('messages');
|
||||
return;
|
||||
} else if (trimmedCmd === 'profile') {
|
||||
handleNavigate('profile');
|
||||
return;
|
||||
} else if (trimmedCmd === 'ballot') {
|
||||
setComingSoonModule('BALLOT');
|
||||
return;
|
||||
} else if (trimmedCmd === 'work' || trimmedCmd === 'gigs') {
|
||||
setComingSoonModule('GIGS');
|
||||
return;
|
||||
} else if (trimmedCmd === 'exchange') {
|
||||
setComingSoonModule('EXCHANGE');
|
||||
return;
|
||||
} else if (trimmedCmd === 'mesh') {
|
||||
output = (
|
||||
<div className="text-gray-400">
|
||||
<p>SYNCING PUBLIC MESH LEDGER...</p>
|
||||
<p className="text-gray-500 mt-1">Block: #894921 | Hash: 0x9f8a...2b1c</p>
|
||||
<p className="text-gray-500">Block: #894920 | Hash: 0x3e1d...9a4f</p>
|
||||
<p className="text-gray-500">Block: #894919 | Hash: 0x7c2b...1e8d</p>
|
||||
<p className="text-green-400 mt-2">Ledger synchronized.</p>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmedCmd === 'radio') {
|
||||
output = (
|
||||
<div className="text-gray-400">
|
||||
<p className="flex items-center"><Radio size={14} className="mr-2 animate-pulse text-red-400" /> SCANNING FREQUENCIES...</p>
|
||||
<p className="text-gray-500 mt-1">144.390 MHz - APRS traffic detected</p>
|
||||
<p className="text-gray-500">462.562 MHz - Encrypted burst</p>
|
||||
<p className="text-gray-500">8.992 MHz - EAM broadcast intercepted</p>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmedCmd === 'wormhole') {
|
||||
output = (
|
||||
<div className="text-gray-400">
|
||||
<p>OBFUSCATED LANE STATUS:</p>
|
||||
<p className="text-gray-500 mt-1">Status: <span className="text-green-400">ONLINE</span></p>
|
||||
<p className="text-gray-500">Active Tunnels: 3</p>
|
||||
<p className="text-gray-500 mt-2">Use <span className="text-gray-300 font-bold">join [gate]</span> to open an obfuscated gate room.</p>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmedCmd === 'whoami') {
|
||||
output = (
|
||||
<span className="text-gray-400">
|
||||
{`${persona || 'unassigned'}${nodeIdentity?.nodeId ? ` (${nodeIdentity.nodeId})` : ''}${nodeIdentity?.publicKey ? ` / ${nodeIdentity.publicKey}` : ''}`}
|
||||
</span>
|
||||
);
|
||||
} else if (trimmedCmd === 'date') {
|
||||
output = <span className="text-gray-400">{new Date().toISOString()}</span>;
|
||||
} else if (trimmedCmd === 'exit') {
|
||||
onClose();
|
||||
return;
|
||||
} else {
|
||||
output = <span className="text-red-400">Command not recognized: {trimmedCmd}. Type 'help' for available commands.</span>;
|
||||
}
|
||||
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (inputMode === 'normal' && input.startsWith('g/') && searchMatch) {
|
||||
handleCommand(`join ${searchMatch}`);
|
||||
} else {
|
||||
handleCommand(input);
|
||||
}
|
||||
setInput('');
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (inputMode === 'normal' && input.startsWith('g/') && searchMatch) {
|
||||
setInput(`g/${searchMatch}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Autocomplete logic
|
||||
const searchMatch = (inputMode === 'normal' && input.startsWith('g/'))
|
||||
? GATES.find(g => g.startsWith(input.slice(2).toLowerCase()))
|
||||
: null;
|
||||
|
||||
if (isBooting) {
|
||||
return (
|
||||
<div className="h-full bg-[#0a0a0a] text-gray-300 p-4 md:p-8 font-mono flex flex-col justify-end pb-20 overflow-hidden">
|
||||
<div className="space-y-1">
|
||||
{bootText.map((line, i) => (
|
||||
<div key={i} className="text-gray-400">{line}</div>
|
||||
))}
|
||||
<div className="animate-pulse w-2 h-4 bg-white mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full bg-[#0a0a0a] text-gray-300 p-4 md:p-8 font-mono relative flex flex-col overflow-hidden">
|
||||
{currentView === 'terminal' && (
|
||||
<>
|
||||
{/* Top Navigation / Quick Launch */}
|
||||
<div className="flex flex-row justify-between items-center gap-2 mb-6 border-b border-gray-800/50 pb-4 shrink-0 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<div className="flex flex-nowrap gap-1.5">
|
||||
{SECTIONS.map((section) => (
|
||||
<button
|
||||
key={section.name}
|
||||
onClick={() => handleCommand(section.name === 'PROFILE' ? 'profile' : section.name.toLowerCase())}
|
||||
className="flex items-center px-2 py-1 bg-cyan-900/10 border border-cyan-900/50 text-cyan-500 hover:bg-cyan-900/30 hover:text-cyan-400 hover:border-cyan-500/50 transition-all text-[10px] md:text-xs uppercase tracking-widest whitespace-nowrap"
|
||||
>
|
||||
{section.icon}
|
||||
{section.name === 'PROFILE' ? 'SOVEREIGN' : section.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<WeatherWidget />
|
||||
</div>
|
||||
|
||||
{/* Main Terminal Area */}
|
||||
<div className="flex-1 overflow-y-auto pr-4 pb-4">
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start gap-6 mb-8">
|
||||
<TrendingPosts />
|
||||
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<pre
|
||||
className="text-white drop-shadow-[0_0_8px_rgba(156,163,175,0.8)] text-[10px] sm:text-xs md:text-sm leading-tight select-none text-left inline-block"
|
||||
style={{ fontFamily: 'Consolas, "Courier New", monospace' }}
|
||||
>
|
||||
{ASCII_HEADER}
|
||||
</pre>
|
||||
<div className="text-gray-400/80 text-center mt-4">
|
||||
<p>Welcome to Infonet. Type <span className="text-green-400 font-bold">'help'</span> to see available commands.</p>
|
||||
<p>Type <span className="text-green-400 font-bold">'gates'</span> or <span className="text-green-400 font-bold">g/</span> to view available chatrooms.</p>
|
||||
</div>
|
||||
<NetworkStats />
|
||||
</div>
|
||||
|
||||
<HashchainEvents />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<TerminalDashboard onNavigate={(view) => handleNavigate(view)} onComingSoon={(mod) => setComingSoonModule(mod)} />
|
||||
|
||||
{history.map((entry, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center text-white">
|
||||
<span className="text-gray-500 mr-2 inline-block max-w-[45%] truncate" title={shellPrompt}>
|
||||
{shellPrompt}
|
||||
</span>
|
||||
<span>{entry.command}</span>
|
||||
</div>
|
||||
<div className="ml-4 text-gray-300">
|
||||
{entry.output}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={endOfTerminalRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="shrink-0 pt-4 mt-2 border-t border-gray-800/50 z-10 relative">
|
||||
{searchMatch && input.length > 2 && (
|
||||
<div className="absolute bottom-full left-0 mb-2 bg-[#0a0a0a] border border-gray-800 p-2 text-xs text-gray-400 z-20">
|
||||
Jump to: <span className="text-white font-bold">g/{searchMatch}</span> [Press Tab to autocomplete, Enter to join]
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center max-w-full">
|
||||
<span
|
||||
className={`text-gray-500 mr-2 ${inputMode === 'persona' ? 'whitespace-nowrap' : 'inline-block max-w-[45%] truncate'}`}
|
||||
title={inputMode === 'persona' ? 'Enter Persona:' : shellPrompt}
|
||||
>
|
||||
{inputMode === 'persona' ? 'Enter Persona: ' : shellPrompt}
|
||||
</span>
|
||||
<div className="relative flex-1 flex items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full bg-transparent border-none outline-none text-white placeholder-gray-800 focus:ring-0 caret-transparent"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
{/* Custom cursor */}
|
||||
<span
|
||||
className="absolute animate-pulse w-2 h-4 bg-white pointer-events-none"
|
||||
style={{ left: `${input.length}ch` }}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentView === 'gate' && activeGate && (
|
||||
<GateView
|
||||
gateName={activeGate}
|
||||
persona={persona || 'anon'}
|
||||
entryMode={activeGateMode}
|
||||
onBack={() => handleNavigate('terminal')}
|
||||
onNavigateGate={(gate) => handleNavigate('gate', gate)}
|
||||
onOpenLiveGate={onOpenLiveGate}
|
||||
availableGates={GATES}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'market' && (
|
||||
<MarketView onBack={() => handleNavigate('terminal')} />
|
||||
)}
|
||||
|
||||
{currentView === 'profile' && (
|
||||
<ProfileView
|
||||
onBack={() => handleNavigate('terminal')}
|
||||
persona={persona || 'unassigned'}
|
||||
isCitizen={isCitizen}
|
||||
nodeId={nodeIdentity?.nodeId}
|
||||
publicKey={nodeIdentity?.publicKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'messages' && (
|
||||
<MessagesView onBack={() => handleNavigate('terminal')} />
|
||||
)}
|
||||
|
||||
{/* Coming Soon Popup */}
|
||||
{comingSoonModule && COMING_SOON_MODULES[comingSoonModule] && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-[3px]">
|
||||
<div className="border border-cyan-500/30 bg-[#060a0f] shadow-[0_0_40px_rgba(6,182,212,0.1),inset_0_0_60px_rgba(6,182,212,0.03)] max-w-md w-full mx-4">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-cyan-900/40 bg-cyan-950/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse shadow-[0_0_6px_rgba(245,158,11,0.6)]" />
|
||||
<span className="text-[9px] tracking-[0.3em] text-amber-400/80 uppercase">System Notice</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setComingSoonModule(null)}
|
||||
className="text-gray-600 hover:text-white text-xs transition-colors"
|
||||
>
|
||||
[×]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="text-cyan-400 text-xs tracking-[0.25em] uppercase font-bold mb-4">
|
||||
{COMING_SOON_MODULES[comingSoonModule].title}
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-3 mb-4">
|
||||
<p className="text-[11px] text-gray-400 leading-relaxed">
|
||||
{COMING_SOON_MODULES[comingSoonModule].desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4 px-1">
|
||||
<span className="w-1 h-1 rounded-full bg-amber-500 animate-pulse" />
|
||||
<span className="text-[9px] tracking-[0.2em] text-amber-400/90 uppercase">
|
||||
{COMING_SOON_MODULES[comingSoonModule].status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 pt-4 flex items-center justify-between">
|
||||
<span className="text-[8px] text-gray-600 tracking-[0.2em] uppercase">
|
||||
Infonet Sovereign Shell v0.1.1 — Test-Net
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setComingSoonModule(null)}
|
||||
className="px-4 py-1.5 border border-cyan-900/50 bg-cyan-950/20 text-cyan-400 text-[10px] tracking-[0.2em] uppercase hover:bg-cyan-900/30 hover:border-cyan-500/40 transition-all"
|
||||
>
|
||||
Acknowledged
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import type { DashboardData } from '@/types/dashboard';
|
||||
|
||||
interface ActivityLog {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
gate: string;
|
||||
user: string;
|
||||
content: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'text-cyan-400',
|
||||
'text-fuchsia-400',
|
||||
'text-emerald-400',
|
||||
'text-violet-400',
|
||||
'text-rose-400',
|
||||
'text-blue-400',
|
||||
'text-lime-400',
|
||||
'text-amber-400',
|
||||
];
|
||||
|
||||
function pickColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return COLORS[Math.abs(hash) % COLORS.length];
|
||||
}
|
||||
|
||||
function timeStr(): string {
|
||||
return new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
type DataSlice = Pick<DashboardData, 'news' | 'trending_markets' | 'commercial_flights' | 'military_flights' | 'ships' | 'correlations' | 'threat_level' | 'liveuamap'>;
|
||||
const DATA_KEYS = ['news', 'trending_markets', 'commercial_flights', 'military_flights', 'ships', 'correlations', 'threat_level', 'liveuamap'] as const;
|
||||
|
||||
export default function LiveActivityLog() {
|
||||
const [logs, setLogs] = useState<ActivityLog[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const prevDataRef = useRef<DataSlice | null>(null);
|
||||
|
||||
const data = useDataKeys(DATA_KEYS) as DataSlice;
|
||||
|
||||
// Generate event logs from real data changes
|
||||
useEffect(() => {
|
||||
const prev = prevDataRef.current;
|
||||
if (!prev) {
|
||||
// First load — generate initial summary logs
|
||||
prevDataRef.current = data;
|
||||
const initial: ActivityLog[] = [];
|
||||
const now = timeStr();
|
||||
|
||||
if (data?.news?.length) {
|
||||
initial.push({ id: crypto.randomUUID(), timestamp: now, gate: 'SYS', user: 'SYSTEM', content: `Wire feed loaded: ${data.news.length} articles indexed`, color: 'text-gray-500' });
|
||||
}
|
||||
if (data?.trending_markets?.length) {
|
||||
initial.push({ id: crypto.randomUUID(), timestamp: now, gate: 'SYS', user: 'SYSTEM', content: `${data.trending_markets.length} prediction markets synced from Polymarket/Kalshi`, color: 'text-gray-500' });
|
||||
}
|
||||
if (data?.commercial_flights?.length || data?.military_flights?.length) {
|
||||
const total = (data.commercial_flights?.length || 0) + (data.military_flights?.length || 0);
|
||||
initial.push({ id: crypto.randomUUID(), timestamp: now, gate: 'tracked-planes', user: 'SYSTEM', content: `ADS-B: ${total} flights tracked`, color: 'text-gray-500' });
|
||||
}
|
||||
if (data?.ships?.length) {
|
||||
initial.push({ id: crypto.randomUUID(), timestamp: now, gate: 'SYS', user: 'SYSTEM', content: `AIS: ${data.ships.length} vessels tracked`, color: 'text-gray-500' });
|
||||
}
|
||||
if (data?.threat_level) {
|
||||
initial.push({ id: crypto.randomUUID(), timestamp: now, gate: 'SYS', user: 'SYSTEM', content: `Threat level: ${data.threat_level.level} (${data.threat_level.score}/100)`, color: pickColor('threat') });
|
||||
}
|
||||
|
||||
if (initial.length > 0) setLogs(initial);
|
||||
return;
|
||||
}
|
||||
|
||||
// Diff to generate new events
|
||||
const newLogs: ActivityLog[] = [];
|
||||
const now = timeStr();
|
||||
|
||||
// New articles
|
||||
if (data?.news && prev.news) {
|
||||
const prevIds = new Set(prev.news.map(n => n.id));
|
||||
const newArticles = data.news.filter(n => !prevIds.has(n.id));
|
||||
for (const article of newArticles.slice(0, 3)) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'world-news', user: article.source || 'WIRE',
|
||||
content: article.title,
|
||||
color: article.breaking ? 'text-red-400' : pickColor(article.source || 'news'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// New markets
|
||||
if (data?.trending_markets && prev.trending_markets) {
|
||||
const prevSlugs = new Set(prev.trending_markets.map(m => m.slug));
|
||||
const newMarkets = data.trending_markets.filter(m => !prevSlugs.has(m.slug));
|
||||
for (const market of newMarkets.slice(0, 2)) {
|
||||
const pct = market.consensus_pct ?? market.polymarket_pct ?? 0;
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'prediction-markets', user: 'ORACLE',
|
||||
content: `New market: "${market.title}" (${pct}% YES)`,
|
||||
color: 'text-amber-400',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Flight count changes
|
||||
const curFlights = (data?.commercial_flights?.length || 0) + (data?.military_flights?.length || 0);
|
||||
const prevFlights = (prev.commercial_flights?.length || 0) + (prev.military_flights?.length || 0);
|
||||
if (Math.abs(curFlights - prevFlights) > 5) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'tracked-planes', user: 'ADS-B',
|
||||
content: `Flight count ${curFlights > prevFlights ? 'increased' : 'decreased'}: ${prevFlights} → ${curFlights}`,
|
||||
color: 'text-cyan-400',
|
||||
});
|
||||
}
|
||||
|
||||
// Ship count changes
|
||||
const curShips = data?.ships?.length || 0;
|
||||
const prevShips = prev.ships?.length || 0;
|
||||
if (Math.abs(curShips - prevShips) > 3) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'SYS', user: 'AIS',
|
||||
content: `Vessel count updated: ${prevShips} → ${curShips}`,
|
||||
color: 'text-blue-400',
|
||||
});
|
||||
}
|
||||
|
||||
// Correlation alerts
|
||||
if (data?.correlations && prev.correlations) {
|
||||
const prevCount = prev.correlations.length;
|
||||
const curCount = data.correlations.length;
|
||||
if (curCount > prevCount) {
|
||||
const newCorrels = data.correlations.slice(prevCount);
|
||||
for (const corr of newCorrels.slice(0, 2)) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'gathered-intel', user: 'CORRELATION-ENGINE',
|
||||
content: `${corr.type.replace(/_/g, ' ').toUpperCase()} [${corr.severity}] — ${corr.drivers.slice(0, 2).join(', ')}`,
|
||||
color: 'text-fuchsia-400',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Threat level changes
|
||||
if (data?.threat_level && prev.threat_level && data.threat_level.level !== prev.threat_level.level) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'SYS', user: 'SYSTEM',
|
||||
content: `THREAT LEVEL CHANGE: ${prev.threat_level.level} → ${data.threat_level.level}`,
|
||||
color: data.threat_level.level === 'SEVERE' ? 'text-red-400' : 'text-yellow-400',
|
||||
});
|
||||
}
|
||||
|
||||
// LiveUAMap events
|
||||
if (data?.liveuamap && prev.liveuamap) {
|
||||
const prevLiveIds = new Set(prev.liveuamap.map(e => e.id));
|
||||
const newEvents = data.liveuamap.filter(e => !prevLiveIds.has(e.id));
|
||||
for (const evt of newEvents.slice(0, 2)) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'ukraine-front', user: 'LIVEUAMAP',
|
||||
content: evt.title || evt.description || 'New conflict event',
|
||||
color: 'text-rose-400',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newLogs.length > 0) {
|
||||
setLogs(prev => {
|
||||
const updated = [...prev, ...newLogs];
|
||||
if (updated.length > 50) return updated.slice(-50);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
prevDataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
return (
|
||||
<div className="mt-6 border border-gray-800 bg-[#0a0a0a] p-3 shrink-0 flex flex-col h-48">
|
||||
<div className="flex items-center justify-between mb-2 border-b border-gray-800 pb-2 shrink-0">
|
||||
<h3 className="text-cyan-400 font-bold flex items-center text-xs tracking-widest uppercase">
|
||||
<Activity size={14} className="mr-2 animate-pulse text-green-400" />
|
||||
Live Network Telemetry
|
||||
</h3>
|
||||
<span className="text-[10px] text-gray-500 font-mono">
|
||||
FEEDS: {logs.length} EVENTS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto font-mono text-[10px] sm:text-xs space-y-1.5 pr-2 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-gray-800"
|
||||
>
|
||||
{logs.length === 0 && (
|
||||
<div className="text-gray-600 italic text-center py-4">Waiting for data stream...</div>
|
||||
)}
|
||||
{logs.map(log => (
|
||||
<div key={log.id} className={`flex items-start gap-2 hover:bg-white/5 px-1 py-0.5 transition-colors ${log.color}`}>
|
||||
<span className="opacity-50 shrink-0">[{log.timestamp}]</span>
|
||||
<span className="opacity-75 shrink-0">[{log.gate}]</span>
|
||||
<span className="opacity-90 shrink-0">@{log.user}:</span>
|
||||
<span className="flex-1 break-words brightness-110">{log.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronLeft, Search, Activity, Shield, Crosshair, DollarSign, Newspaper } from 'lucide-react';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import type { DashboardData, StockTicker } from '@/types/dashboard';
|
||||
|
||||
function formatVolume(vol: number): string {
|
||||
if (!vol || vol <= 0) return '';
|
||||
if (vol >= 1_000_000) return `$${(vol / 1_000_000).toFixed(1)}M`;
|
||||
if (vol >= 1_000) return `$${(vol / 1_000).toFixed(0)}K`;
|
||||
return `$${vol.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function formatEndDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const days = Math.floor((d.getTime() - now.getTime()) / 86400000);
|
||||
if (days < 0) return 'EXPIRED';
|
||||
if (days === 0) return 'TODAY';
|
||||
if (days === 1) return '1d';
|
||||
if (days < 30) return `${days}d`;
|
||||
if (days < 365) return `${Math.floor(days / 30)}mo`;
|
||||
return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
const CATEGORY_CONFIG: Record<string, { color: string; icon: typeof Shield }> = {
|
||||
POLITICS: { color: 'text-blue-400', icon: Shield },
|
||||
CONFLICT: { color: 'text-red-400', icon: Crosshair },
|
||||
FINANCE: { color: 'text-emerald-400', icon: DollarSign },
|
||||
CRYPTO: { color: 'text-amber-400', icon: DollarSign },
|
||||
NEWS: { color: 'text-cyan-400', icon: Newspaper },
|
||||
};
|
||||
|
||||
type Category = 'ALL' | 'POLITICS' | 'CONFLICT' | 'FINANCE' | 'CRYPTO' | 'NEWS';
|
||||
|
||||
interface MarketViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type DataSlice = Pick<DashboardData, 'trending_markets' | 'stocks'>;
|
||||
const DATA_KEYS = ['trending_markets', 'stocks'] as const;
|
||||
|
||||
export default function MarketView({ onBack }: MarketViewProps) {
|
||||
const [category, setCategory] = useState<Category>('ALL');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
|
||||
const data = useDataKeys(DATA_KEYS) as DataSlice;
|
||||
const markets = data?.trending_markets || [];
|
||||
const stocks = data?.stocks;
|
||||
|
||||
const filteredMarkets = markets.filter(m => {
|
||||
const matchesCat = category === 'ALL' || m.category === category;
|
||||
const matchesSearch = !searchInput || m.title.toLowerCase().includes(searchInput.toLowerCase());
|
||||
return matchesCat && matchesSearch;
|
||||
});
|
||||
|
||||
const CATEGORIES: Category[] = ['ALL', 'POLITICS', 'CONFLICT', 'FINANCE', 'CRYPTO', 'NEWS'];
|
||||
|
||||
// Build ticker from real stocks data
|
||||
const tickerItems: string[] = [];
|
||||
if (stocks) {
|
||||
const entries = Object.entries(stocks as Record<string, StockTicker>).filter(([k]) => !['last_updated', 'source'].includes(k));
|
||||
for (const [symbol, val] of entries) {
|
||||
if (val && val.change_percent != null) {
|
||||
const up = val.change_percent >= 0;
|
||||
tickerItems.push(`${symbol.toUpperCase()} ${up ? '▲' : '▼'} ${Math.abs(val.change_percent).toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50 mb-4"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest flex items-center">
|
||||
<Activity className="mr-2 text-cyan-400 animate-pulse" />
|
||||
PREDICTION MARKETS
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Live Polymarket + Kalshi feeds. {markets.length} active markets tracked.</p>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4 shrink-0">
|
||||
<div className="flex gap-2 overflow-x-auto w-full md:w-auto pb-2 md:pb-0">
|
||||
{CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setCategory(cat)}
|
||||
className={`px-3 py-1 text-xs uppercase tracking-widest border whitespace-nowrap ${
|
||||
category === cat
|
||||
? 'bg-gray-800 text-white border-white'
|
||||
: 'bg-gray-900/30 text-gray-500 border-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-500 font-mono">{filteredMarkets.length} RESULTS</span>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4 shrink-0">
|
||||
<div className="flex items-center border border-gray-800 bg-[#0a0a0a] p-2">
|
||||
<Search size={14} className="text-gray-600 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search prediction markets..."
|
||||
className="bg-transparent border-none outline-none text-white w-full text-sm placeholder-gray-700"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markets List */}
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-3 pb-4">
|
||||
{filteredMarkets.length > 0 ? filteredMarkets.map((market, i) => {
|
||||
const pct = market.consensus_pct ?? market.polymarket_pct ?? market.kalshi_pct ?? 0;
|
||||
const catConfig = CATEGORY_CONFIG[market.category] || { color: 'text-gray-400' };
|
||||
const vol = formatVolume(market.volume);
|
||||
const vol24 = formatVolume(market.volume_24h);
|
||||
// Runtime-optional fields the backend may send but aren't in the strict TS type
|
||||
const raw = market as Record<string, unknown>;
|
||||
const endDate = formatEndDate(typeof raw.end_date === 'string' ? raw.end_date : null);
|
||||
const outcomes = market.outcomes && market.outcomes.length > 0 ? market.outcomes : null;
|
||||
const consensus = raw.consensus as { total_picks: number; total_staked: number } | undefined;
|
||||
|
||||
return (
|
||||
<div key={market.slug || i} className="border border-gray-800 bg-gray-900/10 p-4 hover:border-gray-600 transition-colors">
|
||||
{/* Title + Category */}
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-gray-300 font-bold text-sm md:text-base leading-snug">{market.title}</div>
|
||||
<div className="flex items-center gap-2 mt-1.5 text-[10px] font-mono">
|
||||
<span className={`${catConfig.color} uppercase tracking-widest`}>{market.category}</span>
|
||||
{vol && <span className="text-gray-500">VOL: {vol}</span>}
|
||||
{vol24 && <span className="text-gray-500">24H: {vol24}</span>}
|
||||
{endDate && <span className="text-gray-500">CLOSES: {endDate}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
{outcomes && outcomes.length > 0 ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-cyan-400 font-mono">{outcomes[0].pct}%</div>
|
||||
<div className="text-[9px] text-gray-400 uppercase truncate max-w-[100px]" title={outcomes[0].name}>{outcomes[0].name}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-emerald-400 font-mono">{pct}%</div>
|
||||
<div className="text-[9px] text-gray-500 uppercase">CONSENSUS</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Probability bar */}
|
||||
{outcomes && outcomes.length > 0 ? (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[9px] text-cyan-400 font-mono truncate max-w-[80px]" title={outcomes[0].name}>{outcomes[0].name}</span>
|
||||
<div className="flex-1 h-2 bg-gray-900 overflow-hidden flex">
|
||||
<div className="bg-cyan-500/60" style={{ width: `${outcomes[0].pct}%` }} />
|
||||
<div className="bg-gray-700/30 flex-1" />
|
||||
</div>
|
||||
<span className="text-[9px] text-cyan-400 font-mono w-8 text-right">{outcomes[0].pct}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[9px] text-green-400 font-mono w-8">YES</span>
|
||||
<div className="flex-1 h-2 bg-gray-900 overflow-hidden flex">
|
||||
<div className="bg-emerald-500/60" style={{ width: `${pct}%` }} />
|
||||
<div className="bg-red-500/30 flex-1" />
|
||||
</div>
|
||||
<span className="text-[9px] text-red-400 font-mono w-8 text-right">NO</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source badges */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{market.sources?.map((s, si) => (
|
||||
<span key={si} className={`text-[9px] font-mono px-1.5 py-0.5 border ${
|
||||
s.name === 'POLY'
|
||||
? 'bg-purple-500/15 text-purple-400 border-purple-500/20'
|
||||
: 'bg-blue-500/15 text-blue-400 border-blue-500/20'
|
||||
}`}>
|
||||
{s.name} {s.pct}%
|
||||
</span>
|
||||
))}
|
||||
{consensus && consensus.total_picks > 0 && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 border bg-amber-500/10 text-amber-400 border-amber-500/20">
|
||||
{consensus.total_picks} pick{consensus.total_picks !== 1 ? 's' : ''}
|
||||
{consensus.total_staked > 0 ? ` · ${consensus.total_staked.toFixed(1)} REP` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delta indicator */}
|
||||
{market.delta_pct != null && market.delta_pct !== 0 && (
|
||||
<span className={`text-[10px] font-mono font-bold ${market.delta_pct > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{market.delta_pct > 0 ? '▲' : '▼'} {Math.abs(market.delta_pct).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Multi-choice outcomes */}
|
||||
{outcomes && outcomes.length > 0 && (
|
||||
<div className="mt-3 pt-2 border-t border-gray-800 space-y-1">
|
||||
{outcomes.slice(0, 5).map((outcome, oi) => (
|
||||
<div key={oi} className="flex items-center gap-2 text-[10px]">
|
||||
<span className="text-gray-400 w-24 truncate">{outcome.name}</span>
|
||||
<div className="flex-1 h-1 bg-gray-900 overflow-hidden">
|
||||
<div className="bg-cyan-500/50 h-full" style={{ width: `${outcome.pct}%` }} />
|
||||
</div>
|
||||
<span className="text-cyan-400 font-mono w-8 text-right">{outcome.pct}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="text-center text-gray-600 py-8">
|
||||
<p className="text-sm italic">No markets found{searchInput ? ` for "${searchInput}"` : ''}.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ticker */}
|
||||
{tickerItems.length > 0 && (
|
||||
<div className="shrink-0 border-t border-gray-800 bg-gray-900/30 overflow-hidden py-2 mt-2">
|
||||
<div className="animate-ticker text-gray-400 font-bold text-sm tracking-widest whitespace-nowrap">
|
||||
{Array(10).fill(tickerItems.join(' | ')).join(' | ').split(' | ').map((item, i) => {
|
||||
const isUp = item.includes('▲');
|
||||
return (
|
||||
<span key={i} className="mx-4">
|
||||
{item.replace(/[▲▼]/, '')}
|
||||
<span className={isUp ? 'text-green-400 ml-1' : 'text-red-400 ml-1'}>
|
||||
{isUp ? '▲' : '▼'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { fetchInfonetNodeStatusSnapshot } from '@/mesh/controlPlaneStatusClient';
|
||||
|
||||
interface Stats {
|
||||
meshtastic: number;
|
||||
aprs: number;
|
||||
infonetNodes: number;
|
||||
infonetEvents: number;
|
||||
syncPeers: number;
|
||||
nodeEnabled: boolean;
|
||||
syncOutcome: string;
|
||||
}
|
||||
|
||||
const EMPTY: Stats = {
|
||||
meshtastic: 0, aprs: 0, infonetNodes: 0, infonetEvents: 0,
|
||||
syncPeers: 0, nodeEnabled: false, syncOutcome: 'offline',
|
||||
};
|
||||
|
||||
export default function NetworkStats() {
|
||||
const [stats, setStats] = useState<Stats>(EMPTY);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const [meshRes, channelsRes, infonet] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/mesh/status`).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
fetch(`${API_BASE}/api/mesh/channels`).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
|
||||
]);
|
||||
if (!alive) return;
|
||||
setStats({
|
||||
meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0),
|
||||
aprs: Number(meshRes?.signal_counts?.aprs || 0),
|
||||
infonetNodes: Number(infonet?.known_nodes || 0),
|
||||
infonetEvents: Number(infonet?.total_events || 0),
|
||||
syncPeers: Number(infonet?.bootstrap?.sync_peer_count || 0),
|
||||
nodeEnabled: Boolean(infonet?.node_enabled),
|
||||
syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
poll();
|
||||
const interval = setInterval(poll, 15000);
|
||||
return () => { alive = false; clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
const nodeColor = stats.syncOutcome === 'ok' ? 'text-green-400'
|
||||
: stats.nodeEnabled ? 'text-amber-400' : 'text-gray-600';
|
||||
const nodeLabel = stats.syncOutcome === 'ok' ? 'CONNECTED'
|
||||
: stats.syncOutcome === 'running' ? 'SYNCING'
|
||||
: stats.nodeEnabled ? 'SYNCING' : 'OFFLINE';
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-1 mt-5 text-[10px] font-mono text-gray-500">
|
||||
<span>NODE <span className={nodeColor}>{nodeLabel}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>MESH <span className={stats.meshtastic > 0 ? 'text-green-400' : 'text-gray-600'}>{stats.meshtastic.toLocaleString()}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>APRS <span className={stats.aprs > 0 ? 'text-green-400' : 'text-gray-600'}>{stats.aprs.toLocaleString()}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>INFONET NODES <span className="text-white">{stats.infonetNodes}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>EVENTS <span className="text-white">{stats.infonetEvents}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>PEERS <span className="text-white">{stats.syncPeers}</span></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, User, Eye, EyeOff, Wallet, Activity, ShieldCheck, AlertCircle } from 'lucide-react';
|
||||
|
||||
import { API_BASE } from '@/lib/api';
|
||||
|
||||
interface ProfileViewProps {
|
||||
onBack: () => void;
|
||||
persona: string;
|
||||
isCitizen: boolean;
|
||||
nodeId?: string | null;
|
||||
publicKey?: string | null;
|
||||
}
|
||||
|
||||
interface ReputationSummary {
|
||||
overall: number;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
}
|
||||
|
||||
interface OracleProfileSummary {
|
||||
oracle_rep: number;
|
||||
oracle_rep_total: number;
|
||||
oracle_rep_locked: number;
|
||||
predictions_won: number;
|
||||
predictions_lost: number;
|
||||
win_rate: number;
|
||||
}
|
||||
|
||||
const EMPTY_REPUTATION: ReputationSummary = {
|
||||
overall: 0,
|
||||
upvotes: 0,
|
||||
downvotes: 0,
|
||||
};
|
||||
|
||||
const EMPTY_ORACLE_PROFILE: OracleProfileSummary = {
|
||||
oracle_rep: 0,
|
||||
oracle_rep_total: 0,
|
||||
oracle_rep_locked: 0,
|
||||
predictions_won: 0,
|
||||
predictions_lost: 0,
|
||||
win_rate: 0,
|
||||
};
|
||||
|
||||
export default function ProfileView({ onBack, persona, isCitizen, nodeId, publicKey }: ProfileViewProps) {
|
||||
const [showWallet, setShowWallet] = useState(false);
|
||||
const [showBalance, setShowBalance] = useState(true);
|
||||
const [showTransactions, setShowTransactions] = useState(false);
|
||||
const [reputation, setReputation] = useState<ReputationSummary>(EMPTY_REPUTATION);
|
||||
const [oracleProfile, setOracleProfile] = useState<OracleProfileSummary>(EMPTY_ORACLE_PROFILE);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const loadProfileStats = async () => {
|
||||
if (!nodeId) {
|
||||
setReputation(EMPTY_REPUTATION);
|
||||
setOracleProfile(EMPTY_ORACLE_PROFILE);
|
||||
return;
|
||||
}
|
||||
|
||||
const [repResult, oracleResult] = await Promise.allSettled([
|
||||
fetch(`${API_BASE}/api/mesh/reputation?node_id=${encodeURIComponent(nodeId)}`, { cache: 'no-store' }),
|
||||
fetch(`${API_BASE}/api/mesh/oracle/profile?node_id=${encodeURIComponent(nodeId)}`, { cache: 'no-store' }),
|
||||
]);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (repResult.status === 'fulfilled' && repResult.value.ok) {
|
||||
try {
|
||||
const data = await repResult.value.json();
|
||||
if (active) {
|
||||
setReputation({
|
||||
overall: Number(data?.overall || 0),
|
||||
upvotes: Number(data?.upvotes || 0),
|
||||
downvotes: Number(data?.downvotes || 0),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setReputation(EMPTY_REPUTATION);
|
||||
}
|
||||
}
|
||||
} else if (active) {
|
||||
setReputation(EMPTY_REPUTATION);
|
||||
}
|
||||
|
||||
if (oracleResult.status === 'fulfilled' && oracleResult.value.ok) {
|
||||
try {
|
||||
const data = await oracleResult.value.json();
|
||||
if (active) {
|
||||
setOracleProfile({
|
||||
oracle_rep: Number(data?.oracle_rep || 0),
|
||||
oracle_rep_total: Number(data?.oracle_rep_total || 0),
|
||||
oracle_rep_locked: Number(data?.oracle_rep_locked || 0),
|
||||
predictions_won: Number(data?.predictions_won || 0),
|
||||
predictions_lost: Number(data?.predictions_lost || 0),
|
||||
win_rate: Number(data?.win_rate || 0),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setOracleProfile(EMPTY_ORACLE_PROFILE);
|
||||
}
|
||||
}
|
||||
} else if (active) {
|
||||
setOracleProfile(EMPTY_ORACLE_PROFILE);
|
||||
}
|
||||
};
|
||||
|
||||
void loadProfileStats();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [nodeId]);
|
||||
|
||||
const displayNodeId = nodeId?.trim() || 'NOT PROVISIONED';
|
||||
const displayPersona = persona?.trim() || 'unassigned';
|
||||
const creditsReference = publicKey?.trim() || 'Not provisioned';
|
||||
const creditsBalance = 0;
|
||||
const transactions: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
type: string;
|
||||
amount: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
status: string;
|
||||
}> = [];
|
||||
|
||||
const overallRep = reputation.overall;
|
||||
const repProgress = Math.max(0, Math.min(100, overallRep));
|
||||
const upvotes = reputation.upvotes;
|
||||
const downvotes = reputation.downvotes;
|
||||
const oracleRep = oracleProfile.oracle_rep;
|
||||
const oracleRepTotal = oracleProfile.oracle_rep_total;
|
||||
const oracleRepLocked = oracleProfile.oracle_rep_locked;
|
||||
const oracleProgress = oracleRepTotal > 0 ? Math.max(0, Math.min(100, (oracleRep / oracleRepTotal) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50 mb-4"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest flex items-center">
|
||||
<User className="mr-2 text-cyan-400" />
|
||||
{isCitizen ? 'CITIZEN' : 'SOVEREIGN'} PROFILE
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Identity, reputation, and credits ledger.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-6 pb-4">
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<User size={16} className="mr-2" /> IDENTITY
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest">Agent ID</p>
|
||||
<p className="text-sm text-cyan-400 font-mono">{displayNodeId}</p>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest mt-3">Current Persona</p>
|
||||
<p className="text-xl text-gray-300 font-bold">{displayPersona}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest">Citizenship Status</p>
|
||||
<p className={`text-xl font-bold ${isCitizen ? 'text-green-400' : 'text-amber-500'}`}>
|
||||
{isCitizen ? 'ACTIVE CITIZEN' : 'SOVEREIGN'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 border-t border-gray-800 pt-4 mt-2">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest">Common Rep (Public Reputation)</p>
|
||||
<p className="text-2xl text-cyan-400 font-bold">
|
||||
{overallRep} <span className="text-sm text-gray-600">net</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-right">
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Lit</p>
|
||||
<p className="text-lg font-bold text-green-400">{upvotes}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Dislikes</p>
|
||||
<p className="text-lg font-bold text-red-400">{downvotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3 w-full bg-gray-900 border border-gray-800 overflow-hidden relative">
|
||||
<div
|
||||
className="h-full bg-cyan-400 transition-all duration-500 shadow-[0_0_10px_rgba(6,182,212,0.5)]"
|
||||
style={{ width: `${repProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-gray-500 uppercase tracking-tighter">
|
||||
Reputation is derived from live lit/dislike activity. Net rep can drop below zero even when the bar is clamped at zero.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:col-span-2 mt-2">
|
||||
<div className="p-3 bg-gray-900/40 border border-gray-800">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Active Months</p>
|
||||
<p className="text-xl text-white font-bold">0 MONTHS</p>
|
||||
<p className="text-[9px] text-gray-600 mt-1 uppercase">No live citizenship accounting yet</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/40 border border-gray-800">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Citizenship History</p>
|
||||
<p className="text-xl text-gray-400 font-bold">0 MONTHS</p>
|
||||
<p className="text-[9px] text-gray-600 mt-1 uppercase">Placeholder totals removed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest mb-1">Oracle Rep (Truth)</p>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-xl text-cyan-400 font-bold">
|
||||
{oracleRep.toFixed(1)} <span className="text-xs text-gray-500 font-normal">AVAILABLE</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 uppercase">
|
||||
Win Rate {oracleProfile.win_rate}% • W {oracleProfile.predictions_won} / L {oracleProfile.predictions_lost}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-gray-900 border border-gray-800 overflow-hidden mb-1">
|
||||
<div
|
||||
className="h-full bg-cyan-500 transition-all duration-500 shadow-[0_0_10px_rgba(6,182,212,0.5)]"
|
||||
style={{ width: `${oracleProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-tighter">
|
||||
Available: {oracleRep.toFixed(1)} | Locked: {oracleRepLocked.toFixed(1)} | Total: {oracleRepTotal.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<ShieldCheck size={16} className="mr-2" /> NETWORK HEALTH (VCS ANALYSIS)
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="flex flex-col items-center justify-center p-4 border border-gray-800 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest mb-2">Vote Correlation</p>
|
||||
<div className="relative h-20 w-20">
|
||||
<svg className="h-full w-full" viewBox="0 0 36 36">
|
||||
<path className="stroke-gray-800 stroke-[3]" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
<path className="stroke-gray-500 stroke-[3] transition-all duration-1000" fill="none" strokeDasharray="0, 100" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-gray-400">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[8px] text-gray-500 mt-2 uppercase">NOT CALIBRATED</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Clustering Coefficient</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold">0.00</p>
|
||||
</div>
|
||||
<div className="h-1 w-full bg-gray-900 overflow-hidden">
|
||||
<div className="h-full bg-gray-500 w-0" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Temporal Burst Detection</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold">0.00</p>
|
||||
</div>
|
||||
<div className="h-1 w-full bg-gray-900 overflow-hidden">
|
||||
<div className="h-full bg-gray-500 w-0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 border border-gray-800 bg-gray-900/20 flex items-start gap-2">
|
||||
<AlertCircle size={14} className="text-gray-500 shrink-0 mt-0.5" />
|
||||
<p className="text-[9px] text-gray-500 uppercase leading-tight">
|
||||
Advanced network-health analytics are not calibrated for this profile yet. Live reputation above is authoritative; unresolved analytics stay zeroed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<User size={16} className="mr-2" /> IDENTITY DOMAINS (HARD SEPARATION)
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Root</p>
|
||||
<p className="text-xs text-red-400 font-bold">NEVER PUBLIC</p>
|
||||
</div>
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Transport</p>
|
||||
<p className="text-xs text-green-400 font-bold">PUBLIC MESH</p>
|
||||
</div>
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">DM Alias</p>
|
||||
<p className="text-xs text-cyan-400 font-bold">SEMI-OBFUSCATED</p>
|
||||
</div>
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Gate Session</p>
|
||||
<p className="text-xs text-cyan-400 font-bold">ANONYMOUS</p>
|
||||
</div>
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Gate Persona</p>
|
||||
<p className="text-xs text-cyan-400 font-bold">{displayPersona}</p>
|
||||
</div>
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Credits</p>
|
||||
<p className="text-xs text-gray-300 font-bold">0.00 AVAILABLE</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<Wallet size={16} className="mr-2" /> CREDITS LEDGER
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-[#0a0a0a] border border-gray-800">
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest mb-1">Credits Account Reference</p>
|
||||
<p className="text-sm md:text-base text-gray-300 font-mono tracking-wider">
|
||||
{showWallet ? creditsReference : '****************************************'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowWallet(!showWallet)}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors p-2"
|
||||
>
|
||||
{showWallet ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-[#0a0a0a] border border-gray-800">
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest mb-1">Available Credits</p>
|
||||
<p className="text-2xl text-green-400 font-mono font-bold">
|
||||
{showBalance ? `${creditsBalance.toFixed(2)} Credits` : '****.** Credits'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBalance(!showBalance)}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors p-2"
|
||||
>
|
||||
{showBalance ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowTransactions(!showTransactions)}
|
||||
className="flex items-center text-xs text-cyan-400 hover:text-cyan-300 transition-colors uppercase tracking-widest"
|
||||
>
|
||||
<Activity size={14} className="mr-1" />
|
||||
{showTransactions ? 'HIDE CREDITS HISTORY' : 'VIEW CREDITS HISTORY'}
|
||||
</button>
|
||||
|
||||
{showTransactions && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{transactions.length ? (
|
||||
transactions.map((tx) => (
|
||||
<div key={tx.id} className="flex justify-between items-center border border-gray-800 bg-gray-900/10 p-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 mr-4">{tx.date}</span>
|
||||
<span className="text-cyan-400 font-bold mr-2">{tx.type}</span>
|
||||
<span className="text-gray-500 text-xs">
|
||||
{tx.type === 'RECEIVED' ? `FROM: ${tx.from}` : `TO: ${tx.to}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`font-mono font-bold ${tx.amount.startsWith('+') ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{tx.amount} Credits
|
||||
</span>
|
||||
<div className="text-gray-500 text-xs">{tx.status}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-3 text-sm text-gray-500 uppercase tracking-widest">
|
||||
No credits activity recorded yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Activity, Newspaper, TrendingUp, Vote, ChevronRight } from 'lucide-react';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import type { DashboardData } from '@/types/dashboard';
|
||||
import LiveActivityLog from './LiveActivityLog';
|
||||
|
||||
function formatTime(pubDate: string): string {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const pub = new Date(pubDate).getTime();
|
||||
const diff = Math.floor((now - pub) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
const THREAT_COLORS: Record<string, { text: string; bg: string; border: string }> = {
|
||||
GREEN: { text: 'text-green-400', bg: 'bg-green-900/30', border: 'border-green-900/50' },
|
||||
GUARDED: { text: 'text-blue-400', bg: 'bg-blue-900/30', border: 'border-blue-900/50' },
|
||||
ELEVATED: { text: 'text-yellow-400', bg: 'bg-yellow-900/30', border: 'border-yellow-900/50' },
|
||||
HIGH: { text: 'text-orange-400', bg: 'bg-orange-900/30', border: 'border-orange-900/50' },
|
||||
SEVERE: { text: 'text-red-400', bg: 'bg-red-900/30', border: 'border-red-900/50' },
|
||||
};
|
||||
|
||||
interface TerminalDashboardProps {
|
||||
onNavigate: (view: 'market') => void;
|
||||
onComingSoon?: (module: string) => void;
|
||||
}
|
||||
|
||||
type DataSlice = Pick<DashboardData, 'news' | 'trending_markets' | 'threat_level' | 'commercial_flights' | 'military_flights' | 'ships' | 'satellites' | 'correlations'>;
|
||||
const DATA_KEYS = ['news', 'trending_markets', 'threat_level', 'commercial_flights', 'military_flights', 'ships', 'satellites', 'correlations'] as const;
|
||||
|
||||
export default function TerminalDashboard({ onNavigate, onComingSoon }: TerminalDashboardProps) {
|
||||
const [category, setCategory] = useState('ALL');
|
||||
const data = useDataKeys(DATA_KEYS) as DataSlice;
|
||||
|
||||
const news = data?.news || [];
|
||||
const markets = data?.trending_markets || [];
|
||||
const threat = data?.threat_level;
|
||||
const threatStyle = THREAT_COLORS[threat?.level || 'ELEVATED'] || THREAT_COLORS.ELEVATED;
|
||||
|
||||
// Count active data layers
|
||||
const flightCount = (data?.commercial_flights?.length || 0) + (data?.military_flights?.length || 0);
|
||||
const shipCount = data?.ships?.length || 0;
|
||||
const satCount = data?.satellites?.length || 0;
|
||||
const correlationCount = data?.correlations?.length || 0;
|
||||
|
||||
// Filter news by category
|
||||
const filteredNews = news.filter(article => {
|
||||
if (category === 'ALL') return true;
|
||||
const title = article.title?.toLowerCase() || '';
|
||||
if (category === 'CONFLICT') return article.risk_score >= 7 || title.includes('military') || title.includes('war') || title.includes('attack') || title.includes('strike');
|
||||
if (category === 'POLITICS') return title.includes('politic') || title.includes('election') || title.includes('government') || title.includes('president') || title.includes('senate');
|
||||
if (category === 'FINANCE') return title.includes('market') || title.includes('stock') || title.includes('economy') || title.includes('bank') || title.includes('trade');
|
||||
if (category === 'TECH') return title.includes('tech') || title.includes('cyber') || title.includes('ai') || title.includes('quantum') || title.includes('hack');
|
||||
return true;
|
||||
}).slice(0, 6);
|
||||
|
||||
// Top 3 markets for dashboard preview
|
||||
const topMarkets = markets.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-4 mb-6 shrink-0">
|
||||
{/* Dashboard Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 border-b border-gray-800 pb-3 gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-cyan-400 uppercase tracking-widest font-bold">GLOBAL THREAT INTERCEPT</span>
|
||||
{threat && (
|
||||
<span className={`text-[10px] px-2 py-0.5 ${threatStyle.bg} ${threatStyle.text} ${threatStyle.border} border animate-pulse font-bold`}>
|
||||
{threat.level}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="bg-[#0a0a0a] border border-gray-800 text-gray-300 text-xs p-1.5 outline-none uppercase tracking-widest cursor-pointer hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<option value="ALL">ALL TOPICS</option>
|
||||
<option value="CONFLICT">CONFLICT</option>
|
||||
<option value="POLITICS">POLITICS</option>
|
||||
<option value="FINANCE">FINANCE</option>
|
||||
<option value="TECH">TECH</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Content — 2x2 grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Top Stories (real RSS data) */}
|
||||
<div>
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase">
|
||||
<Newspaper size={14} className="mr-2" /> TOP STORIES
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{filteredNews.length > 0 ? filteredNews.map((article, i) => (
|
||||
<div key={article.id || i} className="group cursor-pointer">
|
||||
<div className="flex items-baseline gap-2 mb-0.5">
|
||||
<span className={`text-[10px] uppercase tracking-widest border border-gray-800 px-1 ${
|
||||
article.risk_score >= 7 ? 'text-red-400' :
|
||||
article.risk_score >= 4 ? 'text-yellow-400' : 'text-green-400'
|
||||
}`}>
|
||||
{article.risk_score >= 7 ? 'HIGH' : article.risk_score >= 4 ? 'MED' : 'LOW'}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-600 font-mono uppercase">{article.source}</span>
|
||||
<span className="text-[10px] text-gray-500 font-mono">{formatTime(article.pub_date)}</span>
|
||||
{article.breaking && (
|
||||
<span className="text-[10px] text-red-500 font-bold animate-pulse">BREAKING</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 group-hover:text-white transition-colors leading-snug">{article.title}</p>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-sm text-gray-600 italic">No stories in wire feed.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Popular Markets (real Polymarket/Kalshi data) */}
|
||||
<div>
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase">
|
||||
<TrendingUp size={14} className="mr-2" /> POPULAR MARKETS
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{topMarkets.length > 0 ? topMarkets.map((market, i) => {
|
||||
const outcomes = market.outcomes || [];
|
||||
const isMulti = outcomes.length > 2;
|
||||
const pct = market.consensus_pct ?? market.polymarket_pct ?? market.kalshi_pct ?? 0;
|
||||
return (
|
||||
<div key={market.slug || i} className="group flex flex-col sm:flex-row sm:items-center justify-between gap-2 border border-gray-800 bg-gray-900/20 p-2 hover:border-gray-600 transition-colors cursor-pointer" onClick={() => onNavigate('market')}>
|
||||
<span className="text-sm text-gray-300 group-hover:text-white transition-colors truncate pr-2">{market.title}</span>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{isMulti ? (
|
||||
<>
|
||||
<span className="text-xs px-2 py-1 bg-cyan-900/20 text-cyan-400 border border-cyan-900/50 truncate max-w-[140px]" title={outcomes[0].name}>
|
||||
{outcomes[0].name} {outcomes[0].pct}%
|
||||
</span>
|
||||
{outcomes[1] && (
|
||||
<span className="text-xs px-2 py-1 bg-gray-800/40 text-gray-400 border border-gray-700/50 truncate max-w-[100px]" title={outcomes[1].name}>
|
||||
{outcomes[1].name} {outcomes[1].pct}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs px-2 py-1 bg-green-900/20 text-green-400 border border-green-900/50">Y {pct}%</span>
|
||||
<span className="text-xs px-2 py-1 bg-red-900/20 text-red-400 border border-red-900/50">N {100 - pct}%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<p className="text-sm text-gray-600 italic">No market data available.</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('market')}
|
||||
className="mt-3 text-xs text-cyan-400 hover:text-cyan-300 uppercase tracking-widest flex items-center transition-colors"
|
||||
>
|
||||
View All Markets <ChevronRight size={12} className="ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Open ballot placeholder */}
|
||||
<div>
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase">
|
||||
<Vote size={14} className="mr-2" /> OPEN BALLOT
|
||||
</h3>
|
||||
<div
|
||||
className="border border-gray-800 bg-gray-900/20 p-5 cursor-pointer hover:border-gray-600 transition-colors"
|
||||
onClick={() => onComingSoon?.('BALLOT')}
|
||||
>
|
||||
<div className="text-center border border-cyan-900/40 bg-cyan-950/10 px-4 py-8">
|
||||
<div className="text-2xl md:text-3xl font-bold tracking-[0.32em] text-cyan-300">
|
||||
DEMOCRACY FOR ALL SOON
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-gray-400 uppercase tracking-[0.22em]">
|
||||
No live ballot counts or policy promises are being advertised in this shell yet.
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-[11px] text-gray-500 leading-relaxed">
|
||||
When governance arrives, it should be real, verifiable, and community-shaped instead of placeholder politics.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onComingSoon?.('BALLOT')}
|
||||
className="mt-3 text-xs text-cyan-400 hover:text-cyan-300 uppercase tracking-widest flex items-center transition-colors"
|
||||
>
|
||||
View Governance Note <ChevronRight size={12} className="ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Network Telemetry (real data) */}
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase">
|
||||
<Activity size={14} className="mr-2" /> D.I.N. TELEMETRY
|
||||
</h3>
|
||||
<div className="flex-1 border border-gray-800 bg-gray-900/20 p-3 flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center border-b border-gray-800/50 pb-1">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Tracked Flights</span>
|
||||
<span className="text-xs text-green-400 font-mono">{flightCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800/50 pb-1">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Tracked Vessels</span>
|
||||
<span className="text-xs text-cyan-400 font-mono">{shipCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800/50 pb-1">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Satellites</span>
|
||||
<span className="text-xs text-gray-300 font-mono">{satCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800/50 pb-1">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Active Markets</span>
|
||||
<span className="text-xs text-gray-300 font-mono">{markets.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800/50 pb-1">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Correlations</span>
|
||||
<span className="text-xs text-amber-400 font-mono">{correlationCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Threat Level</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 ${threatStyle.bg} ${threatStyle.text} ${threatStyle.border} border ${threat?.level === 'SEVERE' || threat?.level === 'HIGH' ? 'animate-pulse' : ''}`}>
|
||||
{threat?.level || 'UNKNOWN'} {threat?.score != null ? `(${threat.score})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Threat drivers */}
|
||||
{threat?.drivers && threat.drivers.length > 0 && (
|
||||
<div className="mt-3 pt-2 border-t border-gray-800">
|
||||
<span className="text-[8px] text-gray-500 uppercase tracking-widest block mb-1">THREAT DRIVERS</span>
|
||||
{threat.drivers.slice(0, 3).map((driver, i) => (
|
||||
<p key={i} className="text-[9px] text-gray-400 leading-tight">• {driver}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-gray-800">
|
||||
<div className="w-full bg-gray-900 h-1.5 rounded-full overflow-hidden flex">
|
||||
<div className="bg-cyan-500" style={{ width: `${Math.min((threat?.score || 50), 100)}%` }}></div>
|
||||
<div className="bg-green-500 flex-1"></div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-[8px] text-gray-500 uppercase">Threat Score</span>
|
||||
<span className="text-[8px] text-gray-500 uppercase">{threat?.score ?? '—'}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Live Network Telemetry Log */}
|
||||
<LiveActivityLog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
export default function TrendingPosts() {
|
||||
return (
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-3 w-64 hidden lg:block shrink-0 h-fit">
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase border-b border-gray-800 pb-2">
|
||||
<MessageSquare size={14} className="mr-2" /> Gates
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="text-[10px] text-gray-500 leading-relaxed">
|
||||
<p className="text-amber-400/80 font-bold mb-1">TEST-NET ACTIVE</p>
|
||||
<p>Gates are decentralized chatrooms running on the Infonet mesh. All messages are end-to-end encrypted via Wormhole.</p>
|
||||
<p className="mt-2">Type <span className="text-green-400 font-bold">gates</span> or <span className="text-green-400 font-bold">g/</span> to browse available rooms.</p>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-gray-800">
|
||||
<p className="text-red-500 font-bold text-xs mb-1">SHADOWBROKER ADVISORY</p>
|
||||
<p className="text-[11px] text-red-400/70 leading-relaxed">
|
||||
This is a <span className="text-red-400 font-bold">testnet</span>. Treat all communications as <span className="text-red-400 font-bold">obfuscated, not encrypted</span>. Do not assume full privacy, anonymity, or end-to-end encryption. Transport security is experimental and unaudited. Use at your own risk.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const LOCATIONS = [
|
||||
{ name: 'Night City', tz: 'America/Los_Angeles', tempC: 18 },
|
||||
{ name: 'Tokyo', tz: 'Asia/Tokyo', tempC: 22 },
|
||||
{ name: 'New York', tz: 'America/New_York', tempC: 25 },
|
||||
{ name: 'London', tz: 'Europe/London', tempC: 12 },
|
||||
{ name: 'Neo Seoul', tz: 'Asia/Seoul', tempC: 19 },
|
||||
];
|
||||
|
||||
export default function WeatherWidget() {
|
||||
const [locIdx, setLocIdx] = useState(0);
|
||||
const [isCelsius, setIsCelsius] = useState(false);
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const loc = LOCATIONS[locIdx];
|
||||
const temp = isCelsius ? loc.tempC : Math.round(loc.tempC * 9/5 + 32);
|
||||
const tempUnit = isCelsius ? 'C' : 'F';
|
||||
|
||||
const timeString = time.toLocaleTimeString('en-US', { timeZone: loc.tz, hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
const dateString = time.toLocaleDateString('en-US', { timeZone: loc.tz, month: 'short', day: 'numeric' });
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[10px] md:text-xs text-gray-400 border border-gray-800 bg-gray-900/30 px-2 py-1 shrink-0 font-mono tracking-widest uppercase whitespace-nowrap">
|
||||
<span>{dateString} {timeString}</span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span
|
||||
className="cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => setLocIdx((i) => (i + 1) % LOCATIONS.length)}
|
||||
title="Change Location & Timezone"
|
||||
>
|
||||
{loc.name}
|
||||
</span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span
|
||||
className="cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => setIsCelsius(!isCelsius)}
|
||||
title="Toggle C / F"
|
||||
>
|
||||
{temp}°{tempUnit}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronLeft, Briefcase, CheckCircle, Clock } from 'lucide-react';
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reward: number;
|
||||
status: 'Open' | 'In Progress' | 'Completed';
|
||||
timeLimit: string;
|
||||
}
|
||||
|
||||
const MOCK_JOBS: Job[] = [
|
||||
{
|
||||
id: 'JOB-901',
|
||||
title: 'Host Routing Node',
|
||||
description: 'Maintain 99.9% uptime for a Tier-2 mesh routing node for 24 hours. Bandwidth requirement: 10Gbps minimum.',
|
||||
reward: 300,
|
||||
status: 'Open',
|
||||
timeLimit: '24h'
|
||||
},
|
||||
{
|
||||
id: 'JOB-902',
|
||||
title: 'Find Prediction Market Source',
|
||||
description: 'Provide an unassailable, cryptographically signed news source confirming the outcome of the "Arasaka Merger" market.',
|
||||
reward: 500,
|
||||
status: 'Open',
|
||||
timeLimit: '12h'
|
||||
},
|
||||
{
|
||||
id: 'JOB-903',
|
||||
title: 'Smart Contract Audit',
|
||||
description: 'Find interesting things a blockchain might need fulfilled. Specifically looking for reentrancy vulnerabilities in the new Credits staking pool.',
|
||||
reward: 5000,
|
||||
status: 'Open',
|
||||
timeLimit: '72h'
|
||||
},
|
||||
{
|
||||
id: 'JOB-904',
|
||||
title: 'Data Courier to Sector 4',
|
||||
description: 'Physically transport an encrypted drive to a dead drop in Sector 4. High risk, high reward. No questions asked.',
|
||||
reward: 1500,
|
||||
status: 'Open',
|
||||
timeLimit: '4h'
|
||||
}
|
||||
];
|
||||
|
||||
export default function WorkView({ onBack }: { onBack: () => void }) {
|
||||
const [jobs, setJobs] = useState<Job[]>(MOCK_JOBS);
|
||||
|
||||
const handleAccept = (id: string) => {
|
||||
setJobs(jobs.map(job => job.id === id ? { ...job, status: 'In Progress' } : job));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50 mb-4"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest flex items-center">
|
||||
<Briefcase className="mr-2 text-cyan-400" />
|
||||
NETWORK WORK & BOUNTIES
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Earn Credits and Common Rep by fulfilling network contracts.</p>
|
||||
</div>
|
||||
|
||||
{/* Jobs List */}
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-4 pb-4">
|
||||
{jobs.map(job => (
|
||||
<div key={job.id} className="border border-gray-800 bg-gray-900/20 p-4 hover:border-gray-700 transition-colors">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest">CONTRACT ID: {job.id}</span>
|
||||
<span className={`text-xs font-bold px-2 py-1 border ${
|
||||
job.status === 'Open' ? 'text-green-400 border-green-900/50 bg-green-900/20' :
|
||||
job.status === 'In Progress' ? 'text-cyan-400 border-cyan-900/50 bg-cyan-900/20' :
|
||||
'text-cyan-400 border-cyan-900/50 bg-cyan-900/20'
|
||||
}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg text-gray-300 font-bold mb-2">{job.title}</h2>
|
||||
<p className="text-sm text-gray-400 mb-4 leading-relaxed">{job.description}</p>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-800 pt-3 mt-2">
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-gray-300 font-bold flex items-center">
|
||||
REWARD: <span className="text-green-400 ml-2">{job.reward} CREDITS</span>
|
||||
</span>
|
||||
<span className="text-gray-500 flex items-center">
|
||||
<Clock size={14} className="mr-1" /> {job.timeLimit}
|
||||
</span>
|
||||
</div>
|
||||
{job.status === 'Open' && (
|
||||
<button
|
||||
onClick={() => handleAccept(job.id)}
|
||||
className="flex items-center px-4 py-2 bg-gray-900/50 border border-gray-800 text-cyan-400 hover:bg-gray-800 hover:text-cyan-300 transition-colors text-xs uppercase tracking-widest"
|
||||
>
|
||||
<CheckCircle size={14} className="mr-2" /> ACCEPT CONTRACT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { X, Minus } from 'lucide-react';
|
||||
import InfonetShell from './InfonetShell';
|
||||
|
||||
interface InfonetTerminalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
}
|
||||
|
||||
export default function InfonetTerminal({ isOpen, onClose, onOpenLiveGate }: InfonetTerminalProps) {
|
||||
/* Close on Escape */
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[400] flex items-center justify-center bg-black/60 backdrop-blur-[2px]"
|
||||
>
|
||||
{/* Window container */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative flex flex-col w-[95vw] h-[90vh] max-w-[1400px] max-h-[900px] bg-[#0a0a0a] border border-cyan-900/40 shadow-[0_0_60px_rgba(6,182,212,0.08)] crt infonet-font"
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800/60 bg-[#080808] shrink-0 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-cyan-500/60 shadow-[0_0_6px_rgba(6,182,212,0.4)]" />
|
||||
<span className="text-[10px] tracking-[0.3em] text-gray-500 uppercase">
|
||||
Infonet Sovereign Shell v0.1.1
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-600 hover:text-gray-300 transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-600 hover:text-red-400 transition-colors"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shell content — fills remaining space, scrolls internally */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<InfonetShell isOpen={isOpen} onClose={onClose} onOpenLiveGate={onOpenLiveGate} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user