release: prepare v0.9.7

This commit is contained in:
BigBodyCobain
2026-05-01 22:56:50 -06:00
parent ea457f27da
commit 28b3bd5ebf
670 changed files with 187059 additions and 14005 deletions
File diff suppressed because it is too large Load Diff
@@ -269,7 +269,7 @@ export default function AdvancedFilterModal({
>
{field.label}
{count > 0 && (
<span className={`ml-1.5 text-[8px] ${c.text} bg-black/40 px-1`}>
<span className={`ml-1.5 text-[11px] ${c.text} bg-black/40 px-1`}>
{count}
</span>
)}
@@ -301,7 +301,7 @@ export default function AdvancedFilterModal({
})}
<button
onClick={() => clearField(activeTab)}
className="text-[8px] text-red-400/70 hover:text-red-300 tracking-widest ml-1"
className="text-[11px] text-red-400/70 hover:text-red-300 tracking-widest ml-1"
>
CLEAR
</button>
@@ -335,10 +335,10 @@ export default function AdvancedFilterModal({
)}
</div>
<div className="flex justify-between mt-1.5">
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
<span className="text-[11px] text-[var(--text-muted)] tracking-widest">
{filteredOptions.length} AVAILABLE
</span>
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
<span className="text-[11px] text-[var(--text-muted)] tracking-widest">
{draft[activeTab]?.size || 0} SELECTED
</span>
</div>
+115
View File
@@ -0,0 +1,115 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import type { ToastItem } from '@/hooks/useAlertToasts';
function getRiskColor(score: number): string {
if (score >= 9) return '#ef4444';
if (score >= 7) return '#f97316';
if (score >= 4) return '#eab308';
return '#22d3ee';
}
function getRiskLabel(score: number): string {
if (score >= 9) return 'CRITICAL';
if (score >= 7) return 'HIGH';
return 'ELEVATED';
}
export default function AlertToast({
toasts,
onDismiss,
onFlyTo,
}: {
toasts: ToastItem[];
onDismiss: (id: string) => void;
onFlyTo?: (lat: number, lng: number) => void;
}) {
return (
<div className="fixed top-16 right-[440px] z-[9500] flex flex-col gap-2 pointer-events-none max-w-[380px]">
<AnimatePresence mode="popLayout">
{toasts.map((toast) => {
const color = getRiskColor(toast.risk_score);
const label = getRiskLabel(toast.risk_score);
return (
<motion.div
key={toast.id}
layout
initial={{ opacity: 0, x: 100, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.9 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="pointer-events-auto cursor-pointer"
onClick={() => {
if (onFlyTo && toast.lat && toast.lng) {
onFlyTo(toast.lat, toast.lng);
}
onDismiss(toast.id);
}}
>
<div
className="relative bg-[rgba(5,5,5,0.96)] backdrop-blur-sm rounded-sm overflow-hidden font-mono"
style={{
borderLeft: `3px solid ${color}`,
boxShadow: `0 0 20px ${color}40, 0 4px 12px rgba(0,0,0,0.5)`,
}}
>
{/* Progress bar */}
<motion.div
className="absolute top-0 left-0 h-[2px]"
style={{ background: color }}
initial={{ width: '100%' }}
animate={{ width: '0%' }}
transition={{ duration: 5, ease: 'linear' }}
/>
<div className="p-3 pr-8">
{/* Header */}
<div className="flex items-center gap-2 mb-1.5">
<span
className="text-[9px] font-bold tracking-[0.2em] px-1.5 py-0.5 rounded-sm"
style={{
background: `${color}20`,
color: color,
border: `1px solid ${color}40`,
}}
>
{label}
</span>
<span className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
LVL {toast.risk_score}/10
</span>
</div>
{/* Title */}
<div
className="text-[11px] text-[var(--text-primary)] leading-tight mb-1"
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}
>
{toast.title}
</div>
{/* Source */}
<div className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
{toast.source}
</div>
</div>
{/* Dismiss button */}
<button
className="absolute top-2 right-2 text-[var(--text-muted)] hover:text-white transition-colors text-xs font-bold"
onClick={(e) => {
e.stopPropagation();
onDismiss(toast.id);
}}
>
×
</button>
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}
+170 -103
View File
@@ -5,88 +5,115 @@ import { motion, AnimatePresence } from 'framer-motion';
import {
X,
Terminal,
Radio,
Camera,
Search,
TrainFront,
Globe,
Bot,
Network,
Scale,
KeyRound,
Cpu,
Layers,
GitBranch,
Shield,
Plane,
Clock,
Satellite,
Bug,
Heart,
} from 'lucide-react';
const CURRENT_VERSION = '0.9.6';
const CURRENT_VERSION = '0.9.7';
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
const RELEASE_TITLE = 'InfoNet Experimental Testnet — Decentralized Intelligence Experiment';
const RELEASE_TITLE = 'Agentic AI Channel + InfoNet Decentralized Intelligence';
const HEADLINE_FEATURE = {
icon: <Terminal size={20} className="text-cyan-400" />,
title: 'InfoNet Experimental Testnet is Live',
subtitle: 'The first decentralized intelligence mesh built directly into an OSINT platform. This is an experimental testnet — NOT a privacy tool.',
details: [
'A global, obfuscated message relay running inside ShadowBroker. Anyone with the dashboard can transmit and receive on the InfoNet — no accounts, no signup, no identity required.',
'Messages pass through a Wormhole relay layer with gate personas, canonical payload signing, and message obfuscation. Transport is obfuscated to a degree, but this is NOT private communication. Do not transmit anything you would not say in public. End-to-end encryption is being developed but is not yet implemented.',
'Dead Drop inbox for peer-to-peer message exchange. Mesh Terminal CLI for power users. Gate persona system for pseudonymous identity. Double-ratchet DM scaffolding in progress.',
'Nothing like this has existed in an OSINT tool before. This is an open experiment — jump on the testnet, explore the protocol, and help shape what decentralized intelligence looks like.',
],
callToAction: 'OPEN MESH CHAT \u2192 MESH TAB \u2192 START TRANSMITTING',
};
const HEADLINE_FEATURES = [
{
icon: <Bot size={20} className="text-purple-400" />,
accent: 'purple' as const,
title: 'Agentic AI Channel — supports OpenClaw and any HMAC-signing agent',
subtitle: 'ShadowBroker now exposes a signed agent command channel. Bring your own agent (OpenClaw, Claude Code, GPT, LangChain, or a custom client) and drive the dashboard from any LLM that speaks the protocol.',
details: [
'A signed command channel (POST /api/ai/channel/command) plus a batched concurrent-execution endpoint (up to 20 tool calls per round-trip via /api/ai/channel/batch). Agents query flights, ships, SIGINT, news, and intel layers; reason over the live mesh; and run market or threat analyses without a human in the loop.',
'HMAC-SHA256 request signing with timestamp + nonce replay protection. Tier-gated access (restricted vs full) governs which read and write commands the agent can invoke. Every call is auditable through the channel log.',
'ShadowBroker does not bundle an LLM, an agent runtime, or model weights — it ships the protocol. Any agent that signs requests with the documented HMAC contract can connect. OpenClaw is the reference implementation.',
],
callToAction: 'CONNECT YOUR AGENT \u2192 /API/AI/CHANNEL/COMMAND',
},
{
icon: <Network size={20} className="text-cyan-400" />,
accent: 'cyan' as const,
title: 'InfoNet Testnet \u2014 Framework, Privacy, and a Path to Decentralized Intelligence',
subtitle: 'The testnet now ships its full governance economy and the runway for a privacy-preserving decentralized intelligence platform.',
details: [
'Sovereign Shell views: petitions (governance DSL covers parameter updates and feature toggles), upgrade-hash voting (80% supermajority, 67% Heavy-Node activation), evidence submission, dispute markets, gate suspension and shutdown, and bootstrap eligible-node-one-vote. Every write action is a clickable form with verbatim diagnostics on rejection.',
'Privacy primitive runway: locked Protocol contracts for ring signatures, stealth addresses, shielded balances, and DEX matching. The privacy-core Rust crate is the integration target. Function Keys (anonymous citizenship proof) ship 5 of 6 pieces; only blind-signature issuance waits on a primitive decision.',
'Backbone: two-tier event state with epoch finality, identity rotation, progressive penalties, ramp milestones, and constitutional invariants enforced via MappingProxyType. Sprint 11+ wires the cryptographic primitives into the locked Protocols.',
'Still an experimental testnet \u2014 no privacy guarantee yet. Treat all channels as public until E2E and the privacy primitives ship.',
],
callToAction: 'OPEN SOVEREIGN SHELL \u2192 PETITIONS \u2022 UPGRADES \u2022 GATES',
},
];
const NEW_FEATURES = [
{
icon: <Radio size={18} className="text-amber-400" />,
title: 'Meshtastic + APRS Radio Integration',
desc: 'Live Meshtastic mesh radio nodes plotted worldwide via MQTT. APRS amateur radio positioning via APRS-IS TCP feed. Both integrated into Mesh Chat and the SIGINT grid. Note: Mesh radio is NOT private — RF transmissions are public by nature.',
color: 'amber',
icon: <Cpu size={18} className="text-purple-400" />,
title: 'AI Batch Command Channel',
desc: 'POST up to 20 tool calls in a single HTTP round-trip; the backend executes them concurrently and returns a fan-out result map. Cuts agent latency by an order of magnitude over sequential calls.',
},
{
icon: <Scale size={18} className="text-amber-400" />,
title: 'Governance DSL — Petition-Driven Parameter Changes',
desc: 'Type-safe payload executor for UPDATE_PARAM, BATCH_UPDATE_PARAMS, ENABLE_FEATURE, and DISABLE_FEATURE petitions. Tunable knobs change on-chain via a vote — no code deploys required.',
},
{
icon: <GitBranch size={18} className="text-purple-400" />,
title: 'Upgrade-Hash Governance',
desc: 'Protocol upgrades that need new logic (not just parameter changes) vote on a SHA-256 hash of the verified release. 80% supermajority, 40% quorum, 67% Heavy-Node activation. Lifecycle: signatures, voting, challenge window, awaiting readiness, activated.',
},
{
icon: <KeyRound size={18} className="text-purple-400" />,
title: 'Function Keys — Anonymous Citizenship Proof',
desc: 'A citizen proves "I am an Infonet citizen" without revealing their Infonet identity. 5 of 6 pieces shipped: nullifiers, challenge-response, two-phase commit receipts, enumerated denial codes, batched settlement. Issuance via blind signatures waits on a primitive decision.',
},
{
icon: <Shield size={18} className="text-cyan-400" />,
title: 'Privacy Primitive Runway',
desc: 'Locked Protocol contracts in services/infonet/privacy/contracts.py for ring signatures, stealth addresses, Pedersen commitments, range proofs, and DEX matching. The privacy-core Rust crate is the integration target — no caller of the privacy module needs to know which scheme is active.',
},
{
icon: <Layers size={18} className="text-blue-400" />,
title: 'Two-Tier State + Epoch Finality',
desc: 'Tier 1 events propagate CRDT-style for low latency; Tier 2 events require epoch finality before they can be acted on. Identity rotation, progressive penalties, ramp milestones, and constitutional invariants are enforced via MappingProxyType.',
},
{
icon: <Terminal size={18} className="text-cyan-400" />,
title: 'Mesh Terminal',
desc: 'Built-in command-line interface. Send messages, DMs, run market commands, inspect gate state. Draggable panel, minimizes to the top bar. Type "help" to see everything.',
color: 'cyan',
title: 'Sovereign Shell Write Surface',
desc: 'PetitionsView, UpgradeView, ResolutionView, GateShutdownView, BootstrapView, and FunctionKeyView each expose every Sprint 4-8 + 10 write action as a clickable form. Adaptive polling tightens to 8 seconds during active voting/challenge phases.',
},
{
icon: <Search size={18} className="text-green-400" />,
title: 'Shodan Device Search',
desc: 'Query Shodan directly from ShadowBroker. Search internet-connected devices by keyword, CVE, or port — results plotted as a live overlay on the map with configurable marker style.',
color: 'green',
icon: <Clock size={18} className="text-pink-400" />,
title: 'Time Machine — Snapshot Playback',
desc: 'Scrub backward through saved telemetry. Live polling pauses on entry to snapshot mode, the map redraws from the recorded snapshot, and moving entities interpolate between recorded frames. Hourly index lets you jump to any captured timestamp; pressing Live restores the current feed instantly.',
},
{
icon: <Camera size={18} className="text-emerald-400" />,
title: 'CCTV Mesh Expanded — 12 Sources, 11,000+ Cameras',
desc: 'Massive expansion: added Spain (DGT national + Madrid city), California (12 Caltrans districts), Washington State, Georgia, Illinois, Michigan, and Windy Webcams. Now covers 6 countries. Enabled by default.',
color: 'emerald',
},
{
icon: <TrainFront size={18} className="text-blue-400" />,
title: 'Train Tracking (Amtrak + European Rail)',
desc: 'Real-time Amtrak train positions across the US and European rail via DigiTraffic. Speed, heading, route, and status for every train on the network.',
color: 'blue',
},
{
icon: <Globe size={18} className="text-purple-400" />,
title: '8 New Intelligence Layers',
desc: 'Volcanoes (Smithsonian), air quality PM2.5 (OpenAQ), severe weather alerts, fishing activity (Global Fishing Watch), military bases, 35K+ power plants, SatNOGS ground stations, TinyGS LoRa satellites, VIIRS nightlights.',
color: 'purple',
},
{
icon: <Shield size={18} className="text-yellow-400" />,
title: 'Sentinel Hub Imagery + Desktop Shell Scaffold',
desc: 'Copernicus CDSE satellite imagery via Sentinel Hub Process API with OAuth2 token flow. Desktop-native control routing scaffold (pre-Tauri) with session profiles and audit trail.',
color: 'yellow',
icon: <Satellite size={18} className="text-orange-400" />,
title: 'SAR Satellite Telemetry — ASF, OPERA, Copernicus',
desc: 'New SAR (Synthetic Aperture Radar) layer. Mode A (default-on) pulls free catalog metadata from the Alaska Satellite Facility — no account required. Mode B (two-step opt-in) ingests pre-processed ground-change anomalies from NASA OPERA, Copernicus EGMS, GFM, EMS, and UNOSAT — deformation, flood, and damage assessments. Integrates with OpenClaw so agents can read and act on SAR anomalies; broadcasts default to private-tier transport (Tor / RNS).',
},
];
const BUG_FIXES = [
'CCTV auto-seed fix — partial DB (4 of 12 sources) no longer silently skips the other 8 ingestors on startup',
'SQLite threading fix — CCTV ingestors no longer share connections across threads',
'CCTV layer now ON by default and participates in the All On/Off global toggle',
'KiwiSDR, FIRMS fires, internet outages, data centers all switched to ON by default',
'Terminal minimized tab repositioned to top-center with proper icon (no more phantom cursor)',
'Mesh Chat defaults to MESH tab on startup instead of locked INFONET gate',
'Sovereign Shell adaptive polling — voting and challenge windows refresh every 8 seconds while active, every 30 to 60 seconds when idle. Voting feels live without a websocket layer.',
'Per-row write actions (petitions, upgrades, disputes) hold isolated submission state so concurrent forms no longer share a single in-flight slot.',
'Verbatim diagnostic surfacing on every write button. The backend reason text is always shown on rejection — no opaque "denied" toasts.',
'Evidence submission canonicalization matches Python repr() exactly, so client-side SHA-256 hashes round-trip cleanly through the chain.',
'Function Keys copy is context-agnostic — citizenship proof is described abstractly, not tied to a specific use case.',
'Post-cutover legacy mesh files (mesh_schema.py, mesh_signed_events.py, mesh_hashchain.py) hash-verified against the recorded baseline; the chain extension hook stays surgical.',
];
const CONTRIBUTORS = [
{
name: '@Alienmajik',
desc: 'Raspberry Pi 5 support — ARM64 packaging, headless deployment notes, and runtime tuning for Pi-class hardware',
},
{
name: '@wa1id',
desc: 'CCTV ingestion fix — fresh SQLite connections per ingest, persistent DB path, startup hydration, cluster clickability',
@@ -236,55 +263,95 @@ const ChangelogModal = React.memo(function ChangelogModal({ onClose }: Changelog
{/* Content */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-5 space-y-5">
{/* === HEADLINE: InfoNet Testnet === */}
<div className="border border-cyan-500/30 bg-cyan-950/20 p-4 space-y-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 border border-cyan-500/40 bg-cyan-500/10 flex items-center justify-center flex-shrink-0">
{HEADLINE_FEATURE.icon}
</div>
<div>
<div className="text-sm font-mono text-cyan-300 font-bold tracking-wide">
{HEADLINE_FEATURE.title}
</div>
<div className="text-xs font-mono text-cyan-500/80 mt-0.5">
{HEADLINE_FEATURE.subtitle}
</div>
</div>
</div>
{/* === HEADLINE PAIR: OpenClaw API + InfoNet === */}
{HEADLINE_FEATURES.map((h, idx) => {
const isPurple = h.accent === 'purple';
const cardClass = isPurple
? 'border border-purple-500/30 bg-purple-950/20 p-4 space-y-3'
: 'border border-cyan-500/30 bg-cyan-950/20 p-4 space-y-3';
const iconWrapClass = isPurple
? 'w-9 h-9 border border-purple-500/40 bg-purple-500/10 flex items-center justify-center flex-shrink-0'
: 'w-9 h-9 border border-cyan-500/40 bg-cyan-500/10 flex items-center justify-center flex-shrink-0';
const titleClass = isPurple
? 'text-sm font-mono text-purple-300 font-bold tracking-wide'
: 'text-sm font-mono text-cyan-300 font-bold tracking-wide';
const subtitleClass = isPurple
? 'text-xs font-mono text-purple-500/80 mt-0.5'
: 'text-xs font-mono text-cyan-500/80 mt-0.5';
const ctaClass = isPurple
? 'text-[11px] font-mono text-purple-400 tracking-[0.25em] font-bold'
: 'text-[11px] font-mono text-cyan-400 tracking-[0.25em] font-bold';
<div className="space-y-2">
{HEADLINE_FEATURE.details.map((para, i) => (
<p
key={i}
className="text-xs font-mono text-[var(--text-secondary)] leading-relaxed"
return (
<div key={idx} className={cardClass}>
<div className="flex items-center gap-3">
<div className={iconWrapClass}>{h.icon}</div>
<div>
<div className={titleClass}>{h.title}</div>
<div className={subtitleClass}>{h.subtitle}</div>
</div>
</div>
<div className="space-y-2">
{h.details.map((para, i) => (
<p
key={i}
className="text-xs font-mono text-[var(--text-secondary)] leading-relaxed"
>
{para}
</p>
))}
</div>
{!isPurple && (
<div className="flex items-start gap-2 p-2.5 border border-red-500/30 bg-red-950/20">
<span className="text-red-400 text-xs mt-0.5 flex-shrink-0 font-bold">!!</span>
<div className="space-y-1.5">
<span className="text-[11px] font-mono text-red-400/90 leading-relaxed block font-bold">
EXPERIMENTAL TESTNET &mdash; NO PRIVACY GUARANTEE
</span>
<span className="text-[11px] font-mono text-amber-400/80 leading-relaxed block">
InfoNet messages are obfuscated but NOT encrypted end-to-end. The Mesh
network (Meshtastic/APRS) is NOT private &mdash; radio transmissions are
inherently public. The privacy primitive contracts are scaffolded but not
yet wired. Treat all channels as open and public for now.
</span>
</div>
</div>
)}
<div className="text-center pt-1">
<span className={ctaClass}>{h.callToAction}</span>
</div>
</div>
);
})}
{/* === Required-config callout: OpenSky API === */}
<div className="border border-amber-500/40 bg-amber-950/20 p-3 flex items-start gap-3">
<Plane size={18} className="text-amber-400 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<div className="text-xs font-mono text-amber-300 font-bold tracking-wide uppercase">
Required: OpenSky API credentials for airplane telemetry
</div>
<div className="text-xs font-mono text-amber-200/80 leading-relaxed">
Airplane telemetry now requires an OpenSky Network OAuth2 client. Set{' '}
<span className="text-amber-100 font-bold">OPENSKY_CLIENT_ID</span> and{' '}
<span className="text-amber-100 font-bold">OPENSKY_CLIENT_SECRET</span> in your{' '}
<span className="text-amber-100 font-bold">.env</span>. Free registration:{' '}
<a
href="https://opensky-network.org/index.php?option=com_users&view=registration"
target="_blank"
rel="noopener noreferrer"
className="text-amber-100 font-bold underline underline-offset-2 hover:text-amber-50"
>
{para}
</p>
))}
</div>
{/* Testnet disclaimer */}
<div className="flex items-start gap-2 p-2.5 border border-red-500/30 bg-red-950/20">
<span className="text-red-400 text-xs mt-0.5 flex-shrink-0 font-bold">!!</span>
<div className="space-y-1.5">
<span className="text-[11px] font-mono text-red-400/90 leading-relaxed block font-bold">
EXPERIMENTAL TESTNET NO PRIVACY GUARANTEE
</span>
<span className="text-[11px] font-mono text-amber-400/80 leading-relaxed block">
InfoNet messages are obfuscated but NOT encrypted end-to-end. The Mesh network
(Meshtastic/APRS) is NOT private &mdash; radio transmissions are inherently
public. Do not send anything sensitive on any channel. Privacy and E2E encryption
are actively being developed. Treat all channels as open and public for now.
</span>
opensky-network.org/register
</a>
. Without these the flights layer falls back to ADS-B-only coverage with
significant gaps in Africa, Asia, and Latin America, and the startup environment
check will surface a critical warning.
</div>
</div>
{/* CTA */}
<div className="text-center pt-1">
<span className="text-[11px] font-mono text-cyan-400 tracking-[0.25em] font-bold">
{HEADLINE_FEATURE.callToAction}
</span>
</div>
</div>
{/* === Other New Features === */}
+13 -11
View File
@@ -3,8 +3,8 @@
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
ChevronUp,
ChevronDown,
Minus,
Plus,
Filter,
Plane,
Shield,
@@ -300,27 +300,29 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi
initial={{ y: -30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="w-full bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 z-10 flex flex-col font-mono text-sm pointer-events-auto flex-shrink-0"
className="w-full bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 z-10 flex flex-col font-mono pointer-events-auto flex-shrink-0"
>
{/* Header Toggle */}
<div
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
className="flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-cyan-950/30 transition-colors border-b border-cyan-900/40"
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex items-center gap-2">
<Filter size={12} className="text-cyan-500" />
<span className="text-[12px] text-[var(--text-muted)] font-mono tracking-widest">
<Filter size={16} className="text-cyan-400" />
<span className="text-[12px] text-cyan-400 font-mono tracking-widest font-bold">
DATA FILTERS
</span>
{activeCount > 0 && (
<span className="text-[10px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded-sm font-mono">
<span className="text-[11px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 font-mono">
{activeCount} ACTIVE
</span>
)}
</div>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
{isMinimized ? (
<Plus size={16} className="text-cyan-400" />
) : (
<Minus size={16} className="text-cyan-400" />
)}
</div>
<AnimatePresence>
@@ -356,7 +358,7 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi
</span>
{count > 0 && (
<span
className={`text-[8px] ${bgColors[section.color]} ${textColors[section.color]} px-1.5 py-0.5 rounded-sm`}
className={`text-[11px] ${bgColors[section.color]} ${textColors[section.color]} px-1.5 py-0.5 rounded-sm`}
>
{count}
</span>
+4 -3
View File
@@ -183,6 +183,7 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }:
value={query}
name="sb-locate-search"
autoComplete="off"
data-search-input
placeholder="Search aircraft, person or vessel..."
className="flex-1 bg-transparent text-[12px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-slate-500"
onChange={(e) => {
@@ -227,19 +228,19 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }:
<div className="text-[10px] text-[var(--text-primary)] font-mono tracking-wide truncate">
{r.label}
</div>
<div className="text-[8px] text-[var(--text-muted)] font-mono truncate">
<div className="text-[11px] text-[var(--text-muted)] font-mono truncate">
{r.sublabel}
</div>
</div>
<span
className={`text-[7px] font-bold tracking-widest ${r.categoryColor} flex-shrink-0`}
className={`text-[10px] font-bold tracking-widest ${r.categoryColor} flex-shrink-0`}
>
{r.category}
</span>
</button>
))}
</div>
<div className="px-3 py-1.5 border-t border-[var(--border-primary)] bg-[var(--bg-primary)]/50 text-[8px] text-[var(--text-muted)] font-mono tracking-widest">
<div className="px-3 py-1.5 border-t border-[var(--border-primary)] bg-[var(--bg-primary)]/50 text-[11px] text-[var(--text-muted)] font-mono tracking-widest">
{filtered.length} RESULT{filtered.length !== 1 ? 'S' : ''} CLICK TO LOCATE
</div>
</motion.div>
+1 -1
View File
@@ -51,7 +51,7 @@ export default function GlobalTicker() {
<div className="absolute right-0 top-0 bottom-0 bg-gradient-to-l from-red-950/90 via-black/80 to-transparent w-[450px] z-10 flex items-center justify-end px-4 pointer-events-none">
<div className="flex items-center gap-2 text-red-400 bg-red-950/50 px-2 pl-3 py-0.5 border border-red-500/30 rounded shadow-[0_0_10px_rgba(239,68,68,0.2)]">
<AlertTriangle size={10} className="animate-pulse" />
<span className="text-[8px] font-mono font-bold tracking-widest uppercase shadow-black drop-shadow-md">
<span className="text-[11px] font-mono font-bold tracking-widest uppercase shadow-black drop-shadow-md">
SYS WARN: FINNHUB API KEY MISSING YAHOO FALLBACK ACTIVE (LIMITED)
</span>
</div>
@@ -0,0 +1,599 @@
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { ArrowLeft, Send, MapPin, Loader2, Brain, Trash2, Sparkles, Link2, Copy, Check, X } from 'lucide-react';
import { getBackendEndpoint } from '@/lib/backendEndpoint';
interface AIMessage {
role: 'user' | 'ai' | 'system';
content: string;
timestamp: number;
pins?: { lat: number; lng: number; label: string }[];
}
interface AIQueryViewProps {
onBack: () => void;
}
const EXAMPLE_QUERIES = [
'What military flights are active right now?',
'Show me recent earthquakes over magnitude 4',
'What ships are near the Taiwan Strait?',
'Give me a threat level assessment',
'What are the top prediction market movers?',
'Are there any correlation alerts?',
'Show me satellite imagery of Tehran',
'Get news from Ukraine',
'What SIGINT activity is happening?',
'Place a pin on every military base near Denver',
];
export default function AIQueryView({ onBack }: AIQueryViewProps) {
const [messages, setMessages] = useState<AIMessage[]>([
{
role: 'system',
content: `🌍📡 SHADOWBROKER AI CO-PILOT ONLINE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Connected to ShadowBroker OSINT platform.
I can query telemetry, place pins on the map,
search satellite imagery, aggregate news,
and access all 30+ data layers.
Type a question or command to get started.
Use "help" to see capabilities.`,
timestamp: Date.now(),
},
]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showConnect, setShowConnect] = useState(false);
const [copied, setCopied] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const apiEndpoint = getBackendEndpoint();
const handleCopy = useCallback((text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
inputRef.current?.focus();
}, []);
const processQuery = useCallback(async (query: string) => {
const lowerQuery = query.toLowerCase().trim();
// Handle built-in commands
if (lowerQuery === 'help') {
return {
content: `🌍🔍 AVAILABLE COMMANDS:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
TELEMETRY QUERIES:
• "military flights" — Active military aircraft
• "ships" — Tracked vessels
• "satellites" — Orbital assets
• "earthquakes" — Recent seismic activity
• "threat level" — Current threat assessment
• "prediction markets" — Market consensus data
• "sigint" — RF signal intelligence totals
• "correlations" — Cross-layer alerts
INTELLIGENCE:
• "report" — Full intelligence report
• "summary" — Quick telemetry summary
• "news summary" — AI news brief with top stories & trends
• "correlations" — Explain cross-layer correlation alerts
• "news [place]" — News near a location
• "satellite images [place]" — Sentinel-2 imagery
PIN COMMANDS:
• "pin [lat] [lng] [label]" — Place a pin
• "clear pins" — Clear all AI pins
• "list pins" — Show current pins
TIME MACHINE:
• "snapshot" — Take a telemetry snapshot
• "snapshots" — List available snapshots
• "timemachine config" — View snapshot settings
SYSTEM:
• "status" — AI system status
• "clear" — Clear chat history
• "help" — This message`,
};
}
if (lowerQuery === 'clear') {
setMessages([{
role: 'system',
content: '🌍✅ Chat cleared. Ready for queries.',
timestamp: Date.now(),
}]);
return null;
}
// API queries
try {
const base = '/api/ai';
if (lowerQuery === 'status') {
const resp = await fetch(`${base}/status`);
const data = await resp.json();
return {
content: `🌍✅ SHADOWBROKER AI STATUS:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
Status: ${data.status || 'ONLINE'}
Capabilities: ${(data.capabilities || []).join(', ')}
Pin Count: ${data.pin_count ?? 'N/A'}
Version: ${data.version || '1.0'}`,
};
}
if (lowerQuery === 'summary' || lowerQuery === 'quick summary') {
const resp = await fetch(`${base}/summary`);
const data = await resp.json();
const counts = data.layer_counts || {};
const lines = Object.entries(counts)
.filter(([, v]) => (v as number) > 0)
.map(([k, v]) => `${k}: ${v}`)
.join('\n');
return {
content: `🌍📡 TELEMETRY SUMMARY:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
${lines || ' No active telemetry data.'}
Threat Level: ${data.threat_level || 'N/A'}
SIGINT Totals: ${JSON.stringify(data.sigint_totals || {}, null, 0)}`,
};
}
if (lowerQuery === 'report' || lowerQuery === 'intelligence report') {
const resp = await fetch(`${base}/report`);
const data = await resp.json();
return {
content: `🌍🛰️ INTELLIGENCE REPORT:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
${data.report || JSON.stringify(data, null, 2)}`,
};
}
if (lowerQuery.startsWith('pin ')) {
const parts = lowerQuery.replace('pin ', '').split(/\s+/);
if (parts.length >= 3) {
const lat = parseFloat(parts[0]);
const lng = parseFloat(parts[1]);
const label = parts.slice(2).join(' ');
if (!isNaN(lat) && !isNaN(lng)) {
const resp = await fetch(`${base}/pins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lng, label, category: 'research' }),
});
const data = await resp.json();
return {
content: `🌍📌 SHADOWBROKER PINNING:
Pin placed successfully!
📍 ${lat.toFixed(4)}°, ${lng.toFixed(4)}°
🏷️ ${label}
🆔 ${data.pin_id || 'assigned'}`,
pins: [{ lat, lng, label }],
};
}
}
return { content: '❌ Usage: pin [latitude] [longitude] [label]' };
}
if (lowerQuery === 'list pins' || lowerQuery === 'pins') {
const resp = await fetch(`${base}/pins`);
const data = await resp.json();
const pinList = (data.pins || [])
.slice(0, 20)
.map((p: { label: string; lat: number; lng: number; category: string }) =>
` 📍 ${p.label} (${p.lat.toFixed(2)}°, ${p.lng.toFixed(2)}°) [${p.category}]`
)
.join('\n');
return {
content: `🌍📌 AI INTEL PINS (${data.count || 0}):
━━━━━━━━━━━━━━━━━━━━━━━━━━━
${pinList || ' No pins placed yet.'}`,
};
}
if (lowerQuery === 'clear pins') {
await fetch(`${base}/pins`, { method: 'DELETE' });
return { content: '🌍❌ SHADOWBROKER CLEARING:\nAll AI intel pins cleared.' };
}
if (lowerQuery === 'snapshot' || lowerQuery === 'take snapshot') {
const resp = await fetch(`${base}/timemachine/snapshot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await resp.json();
return {
content: `🌍🕰️ SHADOWBROKER TIMEMACHINE:
Snapshot taken!
🆔 ${data.snapshot_id}
🕐 ${data.timestamp}
📊 Layers: ${(data.layers || []).join(', ')}`,
};
}
if (lowerQuery === 'snapshots' || lowerQuery === 'list snapshots') {
const resp = await fetch(`${base}/timemachine/snapshots`);
const data = await resp.json();
const snapList = (data.snapshots || [])
.slice(0, 10)
.map((s: { id: string; timestamp: string; layers: string[] }) =>
` 🗂️ ${s.id}${s.timestamp} (${s.layers.length} layers)`
)
.join('\n');
return {
content: `🌍🕰️ TIME MACHINE SNAPSHOTS (${data.count || 0}):
━━━━━━━━━━━━━━━━━━━━━━━━━━━
${snapList || ' No snapshots taken yet.'}`,
};
}
if (lowerQuery === 'timemachine config' || lowerQuery === 'tm config') {
const resp = await fetch(`${base}/timemachine/config`);
const data = await resp.json();
const cfg = data.config || {};
return {
content: `🌍🕰️ TIME MACHINE CONFIG:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
Preset: ${cfg.preset || 'active'}
High-Frequency (${cfg.profiles?.high_freq?.interval_minutes || 15}min):
${(cfg.profiles?.high_freq?.layers || []).join(', ')}
Standard (${cfg.profiles?.standard?.interval_minutes || 120}min):
${(cfg.profiles?.standard?.layers || []).join(', ')}
Available presets: paranoid (5min), active (15min), casual (1hr), minimal (6hr)`,
};
}
if (lowerQuery === 'news summary' || lowerQuery === 'news brief' || lowerQuery === 'ai brief') {
const resp = await fetch(`${base}/news/summary`);
const data = await resp.json();
const topStories = (data.top_stories || [])
.slice(0, 5)
.map((s: { risk_score: number; title: string; source: string }) =>
` [${s.risk_score}/10] ${s.title}${s.source}`
)
.join('\n');
const keywords = (data.keywords || [])
.slice(0, 8)
.map((kw: { word: string; count: number }) => `${kw.word}(${kw.count})`)
.join(', ');
const td = data.threat_distribution || {};
return {
content: `🌍📰 AI INTELLIGENCE BRIEF:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
${data.summary || 'No data available.'}
TOP STORIES:
${topStories || ' None available.'}
TRENDING: ${keywords || 'N/A'}
THREAT DISTRIBUTION:
🔴 CRITICAL: ${td.CRITICAL || 0} 🟠 HIGH: ${td.HIGH || 0} 🟡 ELEVATED: ${td.ELEVATED || 0}
🔵 MODERATE: ${td.MODERATE || 0} 🟢 LOW: ${td.LOW || 0}`,
};
}
if (lowerQuery === 'correlations' || lowerQuery === 'explain correlations' || lowerQuery === 'correlation alerts') {
const resp = await fetch(`${base}/correlations/explain`);
const data = await resp.json();
if (!data.count) {
return { content: '🌍⚡ CORRELATIONS:\nNo cross-layer correlation alerts are currently active.' };
}
const alerts = (data.explanations || [])
.slice(0, 8)
.map((e: { label: string; severity_text: string; driver_summary: string; implications: string[]; recommended_action: string; lat: number; lng: number }) =>
`━━━━━━━━━━━━━━━━━━━━━━━━━━━
📍 ${e.label}
Location: ${e.lat.toFixed(2)}°, ${e.lng.toFixed(2)}°
Severity: ${e.severity_text}
Indicators: ${e.driver_summary}
Assessment: ${e.implications?.[0] || 'N/A'}
Action: ${e.recommended_action}`
)
.join('\n');
return {
content: `🌍⚡ CORRELATION ANALYSIS (${data.count} alerts):
${data.summary || ''}
${alerts}`,
};
}
// Generic fallback — try summary
return {
content: `🌍🔍 SHADOWBROKER SEARCHING:
Processing query: "${query}"
I can directly execute these commands:
• summary / report / status
• pin [lat] [lng] [label]
• list pins / clear pins
• snapshot / snapshots / timemachine config
• help
For complex queries (natural language research, web search,
multi-step investigations), connect OpenClaw with an LLM
provider to unlock full agent capabilities.
Type "help" for the full command list.`,
};
} catch (error) {
return {
content: `🌍⚠️ SHADOWBROKER WARNING:
Query failed: ${error instanceof Error ? error.message : 'Unknown error'}
Make sure the ShadowBroker backend is running on localhost:8000.`,
};
}
}, []);
const handleSubmit = useCallback(async () => {
const query = input.trim();
if (!query || isLoading) return;
setInput('');
setMessages(prev => [...prev, { role: 'user', content: query, timestamp: Date.now() }]);
setIsLoading(true);
const result = await processQuery(query);
if (result) {
setMessages(prev => [...prev, {
role: 'ai',
content: result.content,
timestamp: Date.now(),
pins: result.pins,
}]);
}
setIsLoading(false);
}, [input, isLoading, processQuery]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="h-full flex flex-col bg-[#0a0a0a] text-gray-300">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-purple-900/40 bg-purple-950/10 shrink-0">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="text-gray-500 hover:text-gray-300 transition-colors"
title="Back to terminal"
>
<ArrowLeft size={18} />
</button>
<Brain size={18} className="text-purple-400" />
<span className="text-sm tracking-[0.2em] text-purple-400 uppercase font-bold">
AI Co-Pilot
</span>
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_6px_rgba(34,197,94,0.6)]" />
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowConnect(!showConnect)}
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-bold tracking-wider uppercase transition-all rounded-sm ${
showConnect
? 'bg-purple-900/40 border border-purple-500/50 text-purple-300'
: 'bg-purple-900/20 border border-purple-800/30 text-purple-500 hover:bg-purple-900/30 hover:text-purple-300 hover:border-purple-600/40'
}`}
title="Connect your OpenClaw agent"
>
<Link2 size={13} />
Connect OpenClaw
</button>
<button
onClick={() => setMessages([{
role: 'system',
content: '🌍✅ Chat cleared. Ready for queries.',
timestamp: Date.now(),
}])}
className="text-gray-600 hover:text-red-400 transition-colors"
title="Clear chat"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Connect OpenClaw Panel */}
{showConnect && (
<div className="border-b border-purple-900/40 bg-purple-950/15 px-4 py-4 shrink-0 overflow-y-auto max-h-[60vh]">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Link2 size={14} className="text-purple-400" />
<span className="text-sm font-bold tracking-wider text-purple-400 uppercase">Connect Your OpenClaw Agent</span>
</div>
<button onClick={() => setShowConnect(false)} className="text-gray-600 hover:text-gray-300 transition-colors">
<X size={14} />
</button>
</div>
<div className="space-y-3 text-sm font-mono">
{/* API Endpoint */}
<div>
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Your ShadowBroker API Endpoint</div>
<div className="flex items-center gap-2">
<code className="flex-1 bg-black/60 border border-purple-800/40 px-3 py-2 text-purple-300 text-sm rounded-sm select-all">
{apiEndpoint}
</code>
<button
onClick={() => handleCopy(apiEndpoint)}
className="p-2 bg-purple-900/30 border border-purple-800/40 text-purple-400 hover:bg-purple-900/50 hover:text-purple-200 transition-colors rounded-sm"
title="Copy endpoint"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
</div>
{/* Setup Instructions */}
<div>
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Setup Instructions</div>
<div className="bg-black/60 border border-gray-800/40 rounded-sm p-3 space-y-2 text-[13px] leading-relaxed">
<p className="text-cyan-400 font-bold">Step 1: Install the ShadowBroker Skill</p>
<p className="text-gray-400">Copy the <code className="text-purple-300 bg-purple-900/30 px-1">openclaw-skills/shadowbroker/</code> folder into your OpenClaw&apos;s skills directory.</p>
<p className="text-cyan-400 font-bold mt-2">Step 2: Configure the API Endpoint</p>
<p className="text-gray-400">Tell your OpenClaw agent to connect to:</p>
<code className="block bg-purple-950/40 border border-purple-800/30 px-2 py-1 text-purple-300 text-[13px] rounded-sm">
SHADOWBROKER_URL={apiEndpoint}
</code>
<p className="text-cyan-400 font-bold mt-2">Step 3: Tell Your Agent</p>
<p className="text-gray-400">Paste this into your OpenClaw&apos;s system prompt or instructions:</p>
<div className="relative">
<pre className="bg-purple-950/40 border border-purple-800/30 px-2 py-2 text-[12px] text-purple-200 rounded-sm overflow-x-auto whitespace-pre-wrap">{`You have a skill called "shadowbroker" that connects you to a real-time global OSINT intelligence platform. Use it to:
- Query military flights, ships, satellites, SIGINT, earthquakes, and 30+ data layers
- Place intelligence pins on a live map
- Fetch satellite imagery from Sentinel-2
- Aggregate news by region via GDELT
- Take telemetry snapshots (Time Machine)
- Participate in the Wormhole encrypted mesh network
- Send/receive InfoNet messages via decentralized feed
API: ${apiEndpoint}
Skill docs: openclaw-skills/shadowbroker/SKILL.md`}</pre>
<button
onClick={() => handleCopy(`You have a skill called "shadowbroker" that connects you to a real-time global OSINT intelligence platform. Use it to:\n- Query military flights, ships, satellites, SIGINT, earthquakes, and 30+ data layers\n- Place intelligence pins on a live map\n- Fetch satellite imagery from Sentinel-2\n- Aggregate news by region via GDELT\n- Take telemetry snapshots (Time Machine)\n- Participate in the Wormhole encrypted mesh network\n- Send/receive InfoNet messages via decentralized feed\n\nAPI: ${apiEndpoint}\nSkill docs: openclaw-skills/shadowbroker/SKILL.md`)}
className="absolute top-1 right-1 p-1 bg-purple-900/50 text-purple-400 hover:text-purple-200 transition-colors rounded-sm"
title="Copy instructions"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
</div>
</div>
{/* Available Capabilities */}
<div>
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Available Capabilities</div>
<div className="grid grid-cols-2 gap-1">
{[
['📡', 'Telemetry Queries'],
['📌', 'Pin Placement'],
['🛰️', 'Satellite Imagery'],
['📰', 'News Aggregation'],
['🕰️', 'Time Machine'],
['🔗', 'Wormhole Network'],
['📻', 'Meshtastic Radio'],
['💉', 'Data Injection'],
['⚡', 'Correlation Analysis'],
['🚨', 'Alert Dispatch'],
].map(([emoji, label]) => (
<div key={label} className="flex items-center gap-1.5 text-[12px] text-gray-400 bg-black/30 border border-gray-800/30 px-2 py-1 rounded-sm">
<span>{emoji}</span>
<span>{label}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] px-3 py-2.5 text-[13px] leading-relaxed whitespace-pre-wrap ${
msg.role === 'user'
? 'bg-purple-900/30 border border-purple-700/40 text-purple-100'
: msg.role === 'system'
? 'bg-cyan-950/20 border border-cyan-900/30 text-cyan-300'
: 'bg-gray-900/40 border border-gray-800/40 text-gray-300'
}`}
>
{msg.content}
{msg.pins && msg.pins.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-700/30 flex items-center gap-1.5 text-green-400 text-sm">
<MapPin size={12} />
<span>{msg.pins.length} pin(s) placed on map</span>
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-900/40 border border-gray-800/40 px-3 py-2.5 flex items-center gap-2 text-sm text-gray-500">
<Loader2 size={14} className="animate-spin" />
<span>Processing query...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Quick suggestions */}
{messages.length <= 2 && (
<div className="px-4 pb-2 flex flex-wrap gap-1.5">
{EXAMPLE_QUERIES.slice(0, 4).map((q, i) => (
<button
key={i}
onClick={() => { setInput(q); inputRef.current?.focus(); }}
className="text-xs px-2.5 py-1 bg-purple-900/15 border border-purple-800/30 text-purple-400 hover:bg-purple-900/30 hover:text-purple-300 transition-colors flex items-center gap-1.5 rounded-sm"
>
<Sparkles size={10} />
{q}
</button>
))}
</div>
)}
{/* Input */}
<div className="shrink-0 px-4 py-3 border-t border-purple-900/30 bg-purple-950/5">
<div className="flex items-center gap-2">
<div className="flex-1 flex items-center bg-gray-900/40 border border-gray-700/40 focus-within:border-purple-700/60 transition-colors rounded-sm">
<span className="text-purple-500 text-sm px-2.5 select-none"></span>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything... (type 'help' for commands)"
className="flex-1 bg-transparent border-none outline-none text-white text-sm py-2.5 pr-2 placeholder-gray-600 focus:ring-0"
disabled={isLoading}
spellCheck={false}
autoComplete="off"
/>
</div>
<button
onClick={handleSubmit}
disabled={isLoading || !input.trim()}
className="p-2 bg-purple-900/30 border border-purple-700/40 text-purple-400 hover:bg-purple-900/50 hover:text-purple-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Send size={14} />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,337 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { ChevronLeft, Cpu, Loader, AlertCircle, CheckCircle2, XCircle, Server } from 'lucide-react';
import {
buildBootstrapResolutionVotePayload,
fetchBootstrapMarketState,
fetchInfonetStatus,
type BootstrapMarketState,
type InfonetStatus,
} from '@/mesh/infonetEconomyClient';
import { generateNodeKeys, getNodeIdentity } from '@/mesh/meshIdentity';
import {
DEFAULT_INFONET_SEED_URL,
fetchInfonetNodeStatusSnapshot,
setInfonetNodeEnabled,
type InfonetNodeStatusSnapshot,
} from '@/mesh/controlPlaneStatusClient';
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
interface BootstrapViewProps {
marketId?: string;
onBack: () => void;
}
export default function BootstrapView({ marketId, onBack }: BootstrapViewProps) {
const [status, setStatus] = useState<InfonetStatus | null>(null);
const [market, setMarket] = useState<BootstrapMarketState | null>(null);
const [nodeStatus, setNodeStatus] = useState<InfonetNodeStatusSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nodeToggleBusy, setNodeToggleBusy] = useState(false);
const [nodeToggleError, setNodeToggleError] = useState<string | null>(null);
const [voteSide, setVoteSide] = useState<'yes' | 'no'>('yes');
const [powNonce, setPowNonce] = useState('0');
const voteAction = useSignAndAppend();
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [s, m, n] = await Promise.all([
fetchInfonetStatus(),
marketId ? fetchBootstrapMarketState(marketId).catch(() => null) : Promise.resolve(null),
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
]);
setStatus(s);
setMarket(m);
setNodeStatus(n);
} catch (err) {
setError(err instanceof Error ? err.message : 'network error');
} finally {
setLoading(false);
}
}, [marketId]);
const nodeEnabled = Boolean(nodeStatus?.node_enabled);
const nodeMode = String(nodeStatus?.node_mode || 'participant').toUpperCase();
const syncOutcome = String(nodeStatus?.sync_runtime?.last_outcome || 'idle').toLowerCase();
const seedPeerCount = Number(nodeStatus?.bootstrap?.default_sync_peer_count || 0);
const syncPeerCount = Number(nodeStatus?.bootstrap?.sync_peer_count || 0);
const lastPeerUrl = String(nodeStatus?.sync_runtime?.last_peer_url || '').trim();
const toggleNode = useCallback(async (enabled: boolean) => {
setNodeToggleBusy(true);
setNodeToggleError(null);
try {
if (enabled && !getNodeIdentity()) {
await generateNodeKeys();
}
await setInfonetNodeEnabled(enabled);
const next = await fetchInfonetNodeStatusSnapshot(true);
setNodeStatus(next);
} catch (err) {
setNodeToggleError(err instanceof Error ? err.message : 'node settings update failed');
} finally {
setNodeToggleBusy(false);
}
}, []);
const hasActivePhase = !!market && market.tally.total_eligible >= 0
&& market.tally.yes + market.tally.no < market.tally.total_eligible;
useEffect(() => {
void reload();
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
return () => clearInterval(interval);
}, [reload, hasActivePhase]);
const submitVote = useCallback(async () => {
if (!marketId) return;
const nonce = Number(powNonce);
if (!Number.isFinite(nonce) || nonce < 0) return;
const built = buildBootstrapResolutionVotePayload(marketId, voteSide, Math.floor(nonce));
const res = await voteAction.submit(built.event_type, built.payload);
if (res.ok) {
void reload();
}
}, [marketId, voteSide, powNonce, voteAction, reload]);
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
<ChevronLeft size={14} className="mr-1" /> BACK
</button>
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
<Cpu size={16} /> BOOTSTRAP MODE
</div>
<button onClick={() => void reload()} disabled={loading} className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30">
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
</button>
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
<div className="text-xs text-gray-500 leading-relaxed">
The first <span className="text-cyan-400">bootstrap_market_count</span> (default 100) markets
resolve via <span className="text-cyan-400">eligible-node-one-vote</span> instead of oracle-rep-weighted
staking. Eligibility: identity age 3 days vs market.snapshot.frozen_at,
NOT in the predictor exclusion set, and a valid Argon2id PoW
(Heavy-Node-only requires 64MB RAM per computation).
Once node count crosses <span className="text-cyan-400">bootstrap_threshold</span> (default 1000),
new markets default to staked resolution. Existing bootstrap-indexed markets continue under
bootstrap rules until they resolve.
</div>
{error && (
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
<AlertCircle size={12} className="inline mr-1" />{error}
</div>
)}
<div className="border border-cyan-900/50 bg-cyan-950/10 p-3">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="text-xs uppercase tracking-wider text-cyan-400 flex items-center gap-2">
<Server size={14} /> Network Seed
</div>
<button
type="button"
onClick={() => void reload()}
disabled={loading}
className="text-[10px] text-gray-500 hover:text-cyan-400 disabled:opacity-30 uppercase tracking-widest"
>
Refresh
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-500">Default Seed</div>
<div className="text-cyan-300 font-mono break-all">{DEFAULT_INFONET_SEED_URL}</div>
</div>
<div>
<div className="text-gray-500">Local Node</div>
<div className={nodeEnabled ? 'text-green-400' : 'text-gray-500'}>
{nodeEnabled ? `${nodeMode} ONLINE` : `${nodeMode} OFF`}
</div>
</div>
<div>
<div className="text-gray-500">Sync Path</div>
<div className="text-white font-mono">
{syncPeerCount} peers / {seedPeerCount} default
</div>
</div>
</div>
<div className="mt-3 flex flex-col md:flex-row md:items-center gap-3">
<div className="flex-1 text-[11px] text-gray-500 leading-relaxed">
{nodeEnabled
? `Public chain sync is ${syncOutcome || 'active'}${lastPeerUrl ? ` via ${lastPeerUrl}` : ''}.`
: 'Start a local participant node to pull from the default seed and help carry the public Infonet chain while this backend is running.'}
</div>
<button
type="button"
onClick={() => void toggleNode(!nodeEnabled)}
disabled={nodeToggleBusy}
className={
nodeEnabled
? 'px-3 py-2 border border-rose-700/50 bg-rose-950/20 text-rose-300 hover:bg-rose-950/35 disabled:opacity-40 text-[10px] uppercase tracking-wider'
: 'px-3 py-2 border border-cyan-700/50 bg-cyan-900/20 text-cyan-300 hover:bg-cyan-900/40 disabled:opacity-40 text-[10px] uppercase tracking-wider'
}
>
{nodeToggleBusy ? 'Updating...' : nodeEnabled ? 'Turn Off Node' : 'Start Node'}
</button>
</div>
{nodeToggleError && (
<div className="mt-3 border border-amber-900/50 bg-amber-950/20 p-2 text-[11px] text-amber-300">
<AlertCircle size={11} className="inline mr-1" />{nodeToggleError}
</div>
)}
</div>
{status && (
<div className="border border-gray-800 bg-black/40 p-3">
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">Network Ramp</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-500">Distinct Nodes</div>
<div className="text-white font-mono text-lg">{status.ramp.node_count}</div>
</div>
<div>
<div className="text-gray-500">Bootstrap Resolution</div>
<div className={status.ramp.bootstrap_resolution_active ? 'text-green-400' : 'text-gray-500'}>
{status.ramp.bootstrap_resolution_active ? 'ACTIVE' : 'TRANSITIONED'}
</div>
</div>
<div>
<div className="text-gray-500">Staked Resolution</div>
<div className={status.ramp.staked_resolution_active ? 'text-green-400' : 'text-gray-500'}>
{status.ramp.staked_resolution_active ? 'ACTIVE' : 'LOCKED'}
</div>
</div>
<div>
<div className="text-gray-500">Petitions</div>
<div className={status.ramp.governance_petitions_active ? 'text-green-400' : 'text-gray-500'}>
{status.ramp.governance_petitions_active ? 'ACTIVE' : 'LOCKED'}
</div>
</div>
<div>
<div className="text-gray-500">Upgrade Governance</div>
<div className={status.ramp.upgrade_governance_active ? 'text-green-400' : 'text-gray-500'}>
{status.ramp.upgrade_governance_active ? 'ACTIVE' : 'LOCKED'}
</div>
</div>
<div>
<div className="text-gray-500">CommonCoin</div>
<div className={status.ramp.commoncoin_active ? 'text-green-400' : 'text-gray-500'}>
{status.ramp.commoncoin_active ? 'ACTIVE' : 'LOCKED'}
</div>
</div>
</div>
</div>
)}
{market && (
<div className="border border-gray-800 bg-black/40 p-3">
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">
Market: <span className="font-mono text-white">{market.market_id}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs mb-3">
<div>
<div className="text-gray-500">YES votes</div>
<div className="text-green-400 font-mono text-lg">{market.tally.yes}</div>
</div>
<div>
<div className="text-gray-500">NO votes</div>
<div className="text-red-400 font-mono text-lg">{market.tally.no}</div>
</div>
<div>
<div className="text-gray-500">Total Eligible</div>
<div className="text-white font-mono text-lg">{market.tally.total_eligible}</div>
</div>
<div>
<div className="text-gray-500">Min Required</div>
<div className="text-gray-300 font-mono text-lg">{market.tally.min_market_participants}</div>
</div>
</div>
<div className="border border-cyan-900/50 bg-cyan-900/10 p-2 mb-3 text-xs">
<div className="text-cyan-400 font-bold uppercase tracking-wider mb-2">
Cast Bootstrap Vote
</div>
<div className="text-gray-500 mb-2">
Eligibility: identity age {' '}
{status ? '3 days' : 'configured threshold'}{' '}
vs market.snapshot.frozen_at, NOT in predictor exclusion set,
and a valid Argon2id PoW (Heavy-Node-only). The PoW nonce
input is for testnet production wires the Argon2id solver
via privacy-core when the Rust binding lands.
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={voteSide}
onChange={(e) => setVoteSide(e.target.value as 'yes' | 'no')}
title="Bootstrap vote side"
aria-label="Bootstrap vote side"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="yes">YES</option>
<option value="no">NO</option>
</select>
<input
type="number"
min="0"
step="1"
value={powNonce}
onChange={(e) => setPowNonce(e.target.value)}
placeholder="pow_nonce"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
/>
<button
type="button"
onClick={submitVote}
disabled={voteAction.state === 'submitting' || !marketId}
className="px-3 py-1 uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
>
{voteAction.state === 'submitting' ? 'Submitting…' : 'Cast Vote'}
</button>
</div>
{voteAction.result && !voteAction.result.ok && (
<div className="text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{voteAction.result.reason}
</div>
)}
</div>
<div className="text-xs uppercase tracking-wider text-gray-500 mb-2">All Submitted Votes</div>
<div className="space-y-1 max-h-64 overflow-y-auto">
{market.votes.map((v) => (
<div key={v.node_id} className="flex items-center justify-between gap-2 text-xs border-b border-gray-800/30 py-1">
<span className="font-mono text-gray-400 truncate flex-1">{v.node_id.slice(0, 16)}</span>
<span className={v.side === 'yes' ? 'text-green-400' : 'text-red-400'}>{v.side?.toUpperCase()}</span>
<span className="w-20 text-right">
{v.eligible ? (
<CheckCircle2 size={12} className="text-green-400 inline" />
) : (
<span className="text-amber-400 flex items-center justify-end gap-1">
<XCircle size={12} />
<span className="text-xs">{v.ineligible_reason}</span>
</span>
)}
</span>
</div>
))}
</div>
</div>
)}
{!marketId && (
<div className="border border-gray-800 bg-black/40 p-6 text-center text-xs text-gray-500">
Open a bootstrap-indexed market from the Markets view to see its
eligible-node-one-vote tally here.
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,164 @@
'use client';
import React, { useEffect, useState } from 'react';
import { ChevronLeft, KeyRound, ShieldCheck, AlertTriangle, FileKey } from 'lucide-react';
import { fetchInfonetStatus, type InfonetStatus } from '@/mesh/infonetEconomyClient';
interface FunctionKeyViewProps {
onBack: () => void;
}
const PIECE_STATUS: Record<string, { color: string; label: string }> = {
not_implemented: { color: 'text-gray-500', label: 'NOT IMPLEMENTED' },
scaffolding: { color: 'text-amber-400', label: 'SCAFFOLDING' },
reference_impl: { color: 'text-blue-400', label: 'REFERENCE' },
production_rust: { color: 'text-green-400', label: 'PRODUCTION' },
};
export default function FunctionKeyView({ onBack }: FunctionKeyViewProps) {
const [status, setStatus] = useState<InfonetStatus | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const s = await fetchInfonetStatus();
if (!cancelled) setStatus(s);
} catch {
// ignore — render the design overview without status
}
})();
return () => { cancelled = true; };
}, []);
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
<ChevronLeft size={14} className="mr-1" /> BACK
</button>
<div className="text-sm text-purple-400 font-bold uppercase tracking-widest flex items-center gap-2">
<KeyRound size={16} /> FUNCTION KEYS Anonymous Citizenship Proof
</div>
<div />
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
<div className="text-xs text-gray-400 leading-relaxed">
A citizen proves &quot;I am an Infonet citizen&quot; to a real-world
operator <span className="text-purple-400">without revealing their Infonet identity</span>.
The naive approach (scramble a public key, record each redemption on chain) leaks
identity through metadata correlation. The Function Keys design is six pieces;
five are implemented; one (issuance via blind signatures / anonymous credentials)
waits on a cryptographic primitive decision.
</div>
{status && (
<div className="border border-gray-800 bg-black/40 p-3">
<div className="text-xs uppercase tracking-wider text-purple-400 mb-2 flex items-center gap-1">
<ShieldCheck size={12} /> Privacy Primitive Status
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
{Object.entries(status.privacy_primitive_status).map(([k, v]) => {
const style = PIECE_STATUS[v] ?? PIECE_STATUS.not_implemented;
return (
<div key={k}>
<div className="text-gray-500 capitalize">{k.replace(/_/g, ' ')}</div>
<div className={`${style.color} font-bold`}>{style.label}</div>
</div>
);
})}
</div>
<div className="text-xs text-gray-500 mt-2">
Cryptographic primitives are stubbed via the locked Protocol contracts in
<span className="font-mono"> services/infonet/privacy/contracts.py</span>.
When the privacy-core Rust binding lands, the scaffolding swaps for the
production class no caller changes.
</div>
</div>
)}
<div className="border border-gray-800 bg-black/40 p-3">
<div className="text-xs uppercase tracking-wider text-purple-400 mb-2">
The Six Pieces
</div>
<ol className="text-xs space-y-2 text-gray-300">
<li>
<span className="text-amber-400 font-bold">1. Issuance</span>{' '}
<span className="text-gray-500">(NOT IMPLEMENTED blind sig / BBS+ / U-Prove / Idemix)</span>
<div className="text-gray-400 ml-4">
Protocol issues a credential proving citizenship without linking to node_id.
</div>
</li>
<li>
<span className="text-green-400 font-bold">2. Nullifiers</span>{' '}
<span className="text-gray-500">(implemented pure SHA-256)</span>
<div className="text-gray-400 ml-4">
<span className="font-mono">nullifier = H(secret || operator_id)</span>.
Different operators see different nullifiers for the same key no
cross-operator linkage. One-time-use per (key, operator) pair via a
tracker.
</div>
</li>
<li>
<span className="text-green-400 font-bold">3. Challenge-Response</span>{' '}
<span className="text-gray-500">(implemented HMAC-SHA256 placeholder)</span>
<div className="text-gray-400 ml-4">
Operator issues a fresh nonce; key-holder signs with the Function Key&apos;s
secret. Defeats screenshot, replay, key-sharing. Production wires the chosen
blind-sig scheme; API stays compatible.
</div>
</li>
<li>
<span className="text-green-400 font-bold">4. Two-Phase Commit Receipts</span>{' '}
<span className="text-gray-500">(implemented)</span>
<div className="text-gray-400 ml-4">
Phase 1: operator signs a verification receipt (day-bucket date,
nullifier prefix only NO timestamps, NO full nullifiers, NO node_id).
Phase 2: citizen counter-signs after service rendered. Both parties hold
a copy. <span className="text-purple-400">Receipts NEVER published on-chain.</span>
</div>
</li>
<li>
<span className="text-green-400 font-bold">5. Enumerated Denial Codes</span>{' '}
<span className="text-gray-500">(implemented 3-value enum)</span>
<div className="text-gray-400 ml-4">
Operators can reject for exactly three reasons: invalid signature,
nullifier already seen, rate limit exceeded. Adding a 4th code is a hard
fork. Anti-discrimination by design.
</div>
</li>
<li>
<span className="text-green-400 font-bold">6. Batched Settlement</span>{' '}
<span className="text-gray-500">(implemented)</span>
<div className="text-gray-400 ml-4">
Operators settle in aggregate. Chain sees{' '}
<span className="font-mono">&#123;operator_id, period_id, count&#125;</span>{' '}
never per-receipt detail. Fraud detection via statistical auditing,
not per-redemption traces.
</div>
</li>
</ol>
</div>
<div className="border border-amber-900/50 bg-amber-900/10 p-3">
<div className="flex items-center gap-2 text-amber-400 text-xs font-bold uppercase tracking-wider mb-1">
<AlertTriangle size={12} /> Production Readiness
</div>
<div className="text-xs text-gray-400 space-y-1">
<div>
<FileKey size={11} className="inline mr-1" />
The HMAC-SHA256 placeholder requires the verifier to know the citizen&apos;s
secret that is NOT private. Production replaces it with a blind-sig
scheme that verifies without learning the secret.
</div>
<div>
The cryptographic scheme decision (RSA blind sigs vs BBS+ vs U-Prove vs
Idemix) is open per IMPLEMENTATION_PLAN §6.4.
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,373 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { ChevronLeft, AlertTriangle, Lock, Clock, ShieldOff, Loader, CheckCircle2 } from 'lucide-react';
import {
buildGateShutdownAppealFilePayload,
buildGateShutdownFilePayload,
buildGateSuspendFilePayload,
fetchGateState,
freshLocalId,
type GateState,
} from '@/mesh/infonetEconomyClient';
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
interface GateShutdownViewProps {
gateId: string;
onBack: () => void;
}
const STATUS_STYLE: Record<string, { color: string; label: string; icon: typeof Lock }> = {
active: { color: 'text-green-400', label: 'ACTIVE', icon: CheckCircle2 },
suspended: { color: 'text-amber-400', label: 'SUSPENDED', icon: Clock },
shutdown: { color: 'text-red-500', label: 'SHUTDOWN', icon: ShieldOff },
};
function formatTs(ts: number | null): string {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString();
}
function formatRelative(ts: number | null, now: number): string {
if (!ts) return '—';
const delta = ts - now;
const abs = Math.abs(delta);
const days = Math.floor(abs / 86400);
const hours = Math.floor((abs % 86400) / 3600);
if (delta > 0) {
if (days > 0) return `in ${days}d ${hours}h`;
return `in ${hours}h`;
}
if (days > 0) return `${days}d ago`;
return `${hours}h ago`;
}
export default function GateShutdownView({ gateId, onBack }: GateShutdownViewProps) {
const [data, setData] = useState<GateState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filing forms — reused for suspend / shutdown / appeal.
const [reason, setReason] = useState('');
const [evidenceHash, setEvidenceHash] = useState('');
const suspendAction = useSignAndAppend();
const shutdownAction = useSignAndAppend();
const appealAction = useSignAndAppend();
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetchGateState(gateId);
if (res.ok) {
setData(res);
} else {
setError(res.reason);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'network error');
} finally {
setLoading(false);
}
}, [gateId]);
const hasActivePhase = data?.suspension.status === 'suspended';
useEffect(() => {
void reload();
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
return () => clearInterval(interval);
}, [reload, hasActivePhase]);
const status = data ? STATUS_STYLE[data.suspension.status] ?? STATUS_STYLE.active : null;
const fileSuspend = useCallback(async () => {
if (!reason.trim() || !evidenceHash.trim()) return;
const built = buildGateSuspendFilePayload(
freshLocalId('sus'), gateId, reason.trim(), [evidenceHash.trim()],
);
const res = await suspendAction.submit(built.event_type, built.payload);
if (res.ok) {
setReason(''); setEvidenceHash('');
void reload();
}
}, [reason, evidenceHash, gateId, suspendAction, reload]);
const fileShutdown = useCallback(async () => {
if (!reason.trim() || !evidenceHash.trim()) return;
const built = buildGateShutdownFilePayload(
freshLocalId('shd'), gateId, reason.trim(), [evidenceHash.trim()],
);
const res = await shutdownAction.submit(built.event_type, built.payload);
if (res.ok) {
setReason(''); setEvidenceHash('');
void reload();
}
}, [reason, evidenceHash, gateId, shutdownAction, reload]);
const fileAppeal = useCallback(async () => {
if (!reason.trim() || !evidenceHash.trim()) return;
if (!data?.shutdown.pending_petition_id) return;
const built = buildGateShutdownAppealFilePayload(
freshLocalId('app'),
gateId,
data.shutdown.pending_petition_id,
reason.trim(),
[evidenceHash.trim()],
);
const res = await appealAction.submit(built.event_type, built.payload);
if (res.ok) {
setReason(''); setEvidenceHash('');
void reload();
}
}, [reason, evidenceHash, gateId, data, appealAction, reload]);
const canFileSuspend = data?.suspension.status === 'active';
const canFileShutdown = data?.suspension.status === 'suspended' && !data?.shutdown.has_pending;
const canFileAppeal = data?.shutdown.pending_status === 'executing';
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
<ChevronLeft size={14} className="mr-1" /> BACK
</button>
<div className="text-sm text-amber-400 font-bold uppercase tracking-widest flex items-center gap-2">
<ShieldOff size={16} /> GATE SHUTDOWN {gateId}
</div>
<button
onClick={() => void reload()}
disabled={loading}
className="text-xs text-gray-500 hover:text-amber-400 disabled:opacity-30"
>
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
</button>
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
<div className="text-xs text-gray-500 leading-relaxed">
Gate shutdown is two-tier: <span className="text-amber-400">SUSPEND</span> (30-day reversible freeze)
<span className="text-red-400">SHUTDOWN</span> (irreversible archive, 7-day execution delay
with one typed appeal allowed). Voting uses oracle_rep_active weight; thresholds are higher for
locked gates (<span className="text-cyan-400">75% suspend / 80% shutdown</span> instead of 67% / 75%).
Anti-stall: one appeal per shutdown, 48h filing window after vote passes.
</div>
{error && (
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
<AlertTriangle size={12} className="inline mr-1" /> {error}
</div>
)}
{data && status && (
<>
<div className="border border-gray-800 bg-black/40 p-3">
<div className="flex items-center gap-2 mb-2">
<status.icon size={14} className={status.color} />
<span className={`text-xs font-bold uppercase tracking-wider ${status.color}`}>
{status.label}
</span>
{data.locked.is_locked && (
<span className="ml-2 text-cyan-400 text-xs flex items-center gap-1">
<Lock size={12} /> LOCKED
</span>
)}
{data.ratified && (
<span className="ml-2 text-green-400 text-xs"> RATIFIED</span>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-500">Members</div>
<div className="text-white">{data.members.length}</div>
</div>
<div>
<div className="text-gray-500">Cumulative Oracle Rep</div>
<div className="text-white">{data.cumulative_member_oracle_rep.toFixed(2)}</div>
</div>
<div>
<div className="text-gray-500">Entry Sacrifice</div>
<div className="text-white">{data.meta.entry_sacrifice} common rep</div>
</div>
<div>
<div className="text-gray-500">Min Overall Rep</div>
<div className="text-white">{data.meta.min_overall_rep}</div>
</div>
<div>
<div className="text-gray-500">Created</div>
<div className="text-white text-xs">{formatTs(data.meta.created_at)}</div>
</div>
<div>
<div className="text-gray-500">Locked At</div>
<div className="text-white text-xs">
{data.locked.locked_at ? formatTs(data.locked.locked_at) : '—'}
</div>
</div>
</div>
</div>
{data.suspension.status === 'suspended' && (
<div className="border border-amber-900/50 bg-amber-900/10 p-3">
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
<Clock size={12} /> Suspension State
</div>
<div className="text-xs text-gray-300 space-y-1">
<div>Suspended at: <span className="text-white">{formatTs(data.suspension.suspended_at)}</span></div>
<div>
Auto-unsuspends:{' '}
<span className="text-amber-400">
{formatRelative(data.suspension.suspended_until, data.now)} ({formatTs(data.suspension.suspended_until)})
</span>
</div>
<div className="text-gray-500 mt-2">
During suspension: no gate_message, gate_enter, gate_exit. Members retain
membership; content preserved (append-only).
</div>
</div>
</div>
)}
{data.shutdown.has_pending && (
<div className="border border-red-900/50 bg-red-900/10 p-3">
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
<ShieldOff size={12} /> Pending Shutdown Petition
</div>
<div className="text-xs space-y-1">
<div className="text-gray-300">
ID: <span className="font-mono text-white">{data.shutdown.pending_petition_id}</span>
</div>
<div className="text-gray-300">
Status:{' '}
<span className={
data.shutdown.pending_status === 'executing' ? 'text-red-400' :
data.shutdown.pending_status === 'appealed' ? 'text-amber-400' :
data.shutdown.pending_status === 'voided_appeal' ? 'text-green-400' :
'text-gray-300'
}>
{data.shutdown.pending_status?.toUpperCase()}
</span>
</div>
{data.shutdown.execution_at && (
<div className="text-red-400">
Executes {formatRelative(data.shutdown.execution_at, data.now)} (
{formatTs(data.shutdown.execution_at)})
</div>
)}
{data.shutdown.pending_status === 'appealed' && (
<div className="text-amber-400">
Execution timer is PAUSED while appeal is voted on. If the appeal
passes, the shutdown is voided. If it fails, the timer resumes.
</div>
)}
</div>
</div>
)}
{data.shutdown.executed && (
<div className="border border-red-900/50 bg-red-900/20 p-3 text-xs">
<div className="text-red-400 font-bold uppercase tracking-wider mb-1">
GATE SHUT DOWN IRREVERSIBLE
</div>
<div className="text-gray-400">
Members released. Content archived. gate_id retired. No petition can reopen.
</div>
</div>
)}
{!data.shutdown.executed && (canFileSuspend || canFileShutdown || canFileAppeal) && (
<div className="border border-gray-800 bg-black/40 p-3">
<div className="text-xs uppercase tracking-wider text-gray-300 mb-2">
File a Petition
</div>
<div className="text-xs text-gray-500 mb-2">
Reason and at least one evidence hash are required. Filing
costs common rep (suspend: 15, shutdown: 25, appeal: 20)
and triggers a 7-day vote window.
{canFileSuspend && ' Suspend → 30-day reversible freeze.'}
{canFileShutdown && ' Shutdown requires active suspension.'}
{canFileAppeal && ' Appeal pauses the 7-day execution timer.'}
</div>
<div className="space-y-2 text-xs">
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="reason (max 2000 chars)"
maxLength={2000}
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
/>
<input
type="text"
value={evidenceHash}
onChange={(e) => setEvidenceHash(e.target.value)}
placeholder="evidence hash (e.g. ipfs://… or sha256:…)"
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
/>
<div className="flex flex-wrap gap-2">
{canFileSuspend && (
<button
type="button"
onClick={fileSuspend}
disabled={
suspendAction.state === 'submitting' ||
!reason.trim() || !evidenceHash.trim()
}
className="px-3 py-1 uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
>
{suspendAction.state === 'submitting' ? 'Filing…' : 'File Suspend'}
</button>
)}
{canFileShutdown && (
<button
type="button"
onClick={fileShutdown}
disabled={
shutdownAction.state === 'submitting' ||
!reason.trim() || !evidenceHash.trim()
}
className="px-3 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
>
{shutdownAction.state === 'submitting' ? 'Filing…' : 'File Shutdown'}
</button>
)}
{canFileAppeal && (
<button
type="button"
onClick={fileAppeal}
disabled={
appealAction.state === 'submitting' ||
!reason.trim() || !evidenceHash.trim()
}
className="px-3 py-1 uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
>
{appealAction.state === 'submitting' ? 'Filing…' : 'File Appeal'}
</button>
)}
</div>
</div>
{(suspendAction.result && !suspendAction.result.ok) && (
<div className="text-red-400 font-mono text-xs mt-2 break-all">
<AlertTriangle size={10} className="inline mr-1" />
{suspendAction.result.reason}
</div>
)}
{(shutdownAction.result && !shutdownAction.result.ok) && (
<div className="text-red-400 font-mono text-xs mt-2 break-all">
<AlertTriangle size={10} className="inline mr-1" />
{shutdownAction.result.reason}
</div>
)}
{(appealAction.result && !appealAction.result.ok) && (
<div className="text-red-400 font-mono text-xs mt-2 break-all">
<AlertTriangle size={10} className="inline mr-1" />
{appealAction.result.reason}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -3,17 +3,36 @@
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 {
nextGateMessagesPollDelayMs,
nextGateMessagesWaitRearmDelayMs,
nextGateMessagesWaitTimeoutMs,
} from '@/mesh/gateMetadataTiming';
import {
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
fetchGateMessageSnapshotState,
waitForGateMessageSnapshot,
} from '@/mesh/gateMessageSnapshot';
import {
getGateSessionStreamStatus,
retainGateSessionStreamGate,
subscribeGateSessionStreamEvents,
subscribeGateSessionStreamStatus,
} from '@/mesh/gateSessionStream';
import { nextSequence } from '@/mesh/meshIdentity';
import {
approveGateCompatFallback,
decryptWormholeGateMessages,
fetchWormholeGateKeyStatus,
hasGateCompatFallbackApproval,
postWormholeGateMessage,
prepareWormholeInteractiveLane,
signMeshEvent,
syncBrowserWormholeGateState,
type WormholeGateKeyStatus,
} from '@/mesh/wormholeIdentityClient';
import { gateEnvelopeDisplayText, gateEnvelopeState, isEncryptedGateEnvelope } from '@/mesh/gateEnvelope';
import { validateEventPayload } from '@/mesh/meshSchema';
import { useGateSSE } from '@/hooks/useGateSSE';
const GATE_INTROS: Record<string, string> = {
infonet:
@@ -52,6 +71,8 @@ interface GateViewProps {
onNavigateGate: (gate: string) => void;
onOpenLiveGate?: (gate: string) => void;
availableGates: string[];
/** Open the gate shutdown lifecycle view. */
onOpenShutdownPetition?: (gate: string) => void;
}
interface GateMessage {
@@ -65,6 +86,7 @@ interface GateMessage {
sender_ref?: string;
format?: string;
gate_envelope?: string;
envelope_hash?: string;
decrypted_message?: string;
payload?: {
gate?: string;
@@ -73,6 +95,7 @@ interface GateMessage {
sender_ref?: string;
format?: string;
gate_envelope?: string;
envelope_hash?: string;
reply_to?: string;
};
gate?: string;
@@ -93,9 +116,6 @@ interface ReplyContext {
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';
@@ -176,44 +196,84 @@ function normalizeGateMessage(message: GateMessage): GateMessage {
sender_ref: String(message.sender_ref ?? payload?.sender_ref ?? ''),
format: String(message.format ?? payload?.format ?? ''),
gate_envelope: String(message.gate_envelope ?? payload?.gate_envelope ?? ''),
envelope_hash: String(message.envelope_hash ?? payload?.envelope_hash ?? ''),
reply_to: String(message.reply_to ?? payload?.reply_to ?? ''),
};
}
async function buildGateAccessHeaders(gateId: string): Promise<Record<string, string> | undefined> {
function describeGateCompatError(detail: string, gateId: string = ''): string {
const normalized = String(detail || '').trim();
const lowered = normalized.toLowerCase();
if (
lowered.includes('transport tier insufficient') ||
lowered.includes('warming up in the background')
) {
return 'The obfuscated lane is still warming up in the background. Stay in the room and posting should unlock shortly.';
}
if (normalized === 'gate_compat_fallback_consent_required') {
return 'Local gate runtime is unavailable for this room.';
}
if (normalized.startsWith('gate_local_runtime_required:')) {
const reason = normalized.slice('gate_local_runtime_required:'.length);
return `${describeGateCompatReason(reason, gateId)} Use native desktop or resync local gate state.`;
}
if (normalized === 'gate_backend_plaintext_compat_required') {
return 'Service-side gate send is disabled on this runtime. Use native desktop or an explicit compatibility override.';
}
if (normalized === 'gate_envelope_required') {
return 'Local gate sealing is warming up. Your draft is still here.';
}
if (normalized === 'gate_envelope_encrypt_failed') {
return 'Local gate sealing could not finish. Your draft is still here.';
}
return normalized;
}
function describeGateCompatConsentPrompt(action: string): string {
switch (String(action || '')) {
case 'decrypt':
return 'Use compatibility mode for this room to read messages on this device.';
case 'compose':
case 'post':
return 'Use compatibility mode for this room to send messages on this device.';
default:
return 'Use compatibility mode for this room on this device.';
}
}
function describeGateCompatReason(reason: string, gateId: string): string {
const normalizedGate = String(gateId || '').trim().toLowerCase();
if (!normalizedGate) return undefined;
const cached = gateAccessHeaderCache.get(normalizedGate);
if (cached && cached.expiresAt > Date.now()) {
return cached.headers;
const detail = String(reason || '').trim().toLowerCase();
if (!detail || detail === 'browser_local_gate_crypto_unavailable') {
return 'Local gate crypto failed on this device.';
}
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;
if (detail === 'browser_gate_worker_unavailable') {
return 'This runtime cannot use the local gate worker.';
}
if (detail.startsWith('browser_gate_state_resync_required:')) {
return normalizedGate
? `Local ${normalizedGate} state needs a resync on this device.`
: 'Local gate state needs a resync on this device.';
}
if (
detail.startsWith('browser_gate_state_mapping_missing_group:') ||
detail === 'browser_gate_state_active_member_missing'
) {
return 'Local gate state is incomplete on this device.';
}
if (detail === 'worker_gate_wrap_key_missing') {
return 'Secure local gate storage is unavailable in this browser.';
}
if (detail === 'gate_mls_decrypt_failed') {
return 'Local gate decrypt failed on this device.';
}
return 'Local gate crypto failed on this device.';
}
interface GateCompatConsentPromptState {
gateId: string;
action: 'compose' | 'post' | 'decrypt';
reason: string;
}
export default function GateView({
@@ -224,9 +284,17 @@ export default function GateView({
onNavigateGate,
onOpenLiveGate: _onOpenLiveGate,
availableGates,
onOpenShutdownPetition,
}: GateViewProps) {
const [searchInput, setSearchInput] = useState('');
const [messages, setMessages] = useState<GateMessage[]>([]);
// Self-authored plaintext, keyed by real event_id returned from the POST.
// This lives in React state ONLY — pure RAM, dies with the tab, never
// written to disk or sessionStorage. It exists so a refresh that replaces
// the messages array with ciphertext from the server doesn't wipe the
// author's view of what they just said. MLS's forward-secrecy property
// (sender can't re-decrypt own output) is preserved on the wire / on disk.
const [selfAuthoredByEventId, setSelfAuthoredByEventId] = useState<Record<string, string>>({});
const [composer, setComposer] = useState('');
const [busy, setBusy] = useState(false);
const [roomError, setRoomError] = useState('');
@@ -236,45 +304,116 @@ export default function GateView({
const [reps, setReps] = useState<Record<string, number>>({});
const [voteNotice, setVoteNotice] = useState('');
const [votedOn, setVotedOn] = useState<Record<string, 1 | -1>>({});
const [compatActive, setCompatActive] = useState(false);
const [compatConsentPrompt, setCompatConsentPrompt] = useState<GateCompatConsentPromptState | null>(null);
const [streamStatus, setStreamStatus] = useState(() => getGateSessionStreamStatus());
const [streamStatusHydrated, setStreamStatusHydrated] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pollTimerRef = useRef<number | null>(null);
const waitAbortRef = useRef<AbortController | null>(null);
const gateCursorRef = useRef(0);
const repsRef = useRef<Record<string, number>>({});
const streamEnabledForGateRef = useRef(false);
const gateId = useMemo(() => String(gateName || '').trim().toLowerCase(), [gateName]);
const introMessage =
GATE_INTROS[gateId] || 'Welcome to this gate. Be civil. The Shadowbroker is watching.';
useEffect(() => {
setCompatActive(hasGateCompatFallbackApproval(gateId));
setCompatConsentPrompt(null);
gateCursorRef.current = 0;
}, [gateId]);
useEffect(
() =>
subscribeGateSessionStreamStatus((nextStatus) => {
setStreamStatus(nextStatus);
setStreamStatusHydrated(true);
}),
[],
);
useEffect(() => {
if (!gateId || !status?.has_local_access) {
return;
}
return retainGateSessionStreamGate(gateId);
}, [gateId, status?.has_local_access]);
useEffect(() => {
if (!gateId || !status?.has_local_access) {
return;
}
void prepareWormholeInteractiveLane({
minimumTransportTier: 'private_control_only',
}).catch(() => undefined);
}, [gateId, status?.has_local_access]);
const streamEnabledForGate =
Boolean(gateId) &&
streamStatus.phase === 'open' &&
streamStatus.subscriptions.includes(gateId);
const streamPreferredForGate =
Boolean(gateId) &&
(streamStatus.phase === 'connecting' || streamStatus.phase === 'open') &&
streamStatus.subscriptions.includes(gateId);
useEffect(() => {
streamEnabledForGateRef.current = streamPreferredForGate;
}, [streamPreferredForGate]);
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 hydrateMessages = useCallback(async (
rawMessages: GateMessage[],
): Promise<{ messages: GateMessage[]; compatDecryptBlocked: boolean; roomError?: string }> => {
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: '' }));
return {
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
compatDecryptBlocked: false,
roomError: '',
};
}
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 || ''),
})),
encrypted.map(({ message }) => {
const gateEnvelope = String(message.gate_envelope || '');
return {
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: gateEnvelope,
envelope_hash: String(message.envelope_hash || ''),
// If a gate_envelope is present, go straight to the backend
// envelope-fast-path by signaling recovery_envelope=true.
// This skips browser-side MLS (which has empty state across
// fresh anon sessions) and uses the durable AES-GCM envelope
// keyed under gate_secret — which EVERY gate member can
// decrypt as long as they hold the current gate_secret.
recovery_envelope: gateEnvelope.length > 0,
};
}),
);
const results = Array.isArray(batch.results) ? batch.results : [];
const nextMessages = [...baseMessages];
encrypted.forEach(({ index, message }, resultIndex) => {
const decrypted = results[resultIndex];
const decryptedReplyTo = decrypted?.ok ? String(decrypted.reply_to || '').trim() : '';
nextMessages[index] = {
...message,
decrypted_message: decrypted?.ok
@@ -285,43 +424,45 @@ export default function GateView({
: String(decrypted.plaintext || ''))
: '',
epoch: decrypted?.ok ? Number(decrypted.epoch || message.epoch || 0) : message.epoch,
reply_to: decryptedReplyTo || String(message.reply_to || ''),
};
});
return nextMessages;
} catch {
return baseMessages.map((message) => ({ ...message, decrypted_message: '' }));
return {
messages: nextMessages,
compatDecryptBlocked: false,
roomError: '',
};
} catch (error) {
const detail = error instanceof Error ? error.message : '';
if (
detail === 'gate_compat_fallback_consent_required' ||
detail.startsWith('gate_local_runtime_required:')
) {
return {
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
compatDecryptBlocked: false,
roomError: describeGateCompatError(detail, gateId),
};
}
return {
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
compatDecryptBlocked: false,
roomError: '',
};
}
}, [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();
const applyGateMessages = useCallback(
async (rawMessages: GateMessage[]) => {
const normalizedMessages = Array.isArray(rawMessages) ? rawMessages : [];
const hydrated = await hydrateMessages(normalizedMessages);
const chronological = [...hydrated.messages].reverse();
setMessages(chronological);
setRoomError('');
if (hydrated.roomError) {
setRoomError(hydrated.roomError);
} else if (!hydrated.compatDecryptBlocked) {
setRoomError('');
}
const uniqueEventIds = Array.from(
new Set(
@@ -332,14 +473,20 @@ export default function GateView({
);
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)) {
const uncachedEventIds = uniqueEventIds.filter(
(eventId) => !Object.prototype.hasOwnProperty.call(repsRef.current, eventId),
);
if (uncachedEventIds.length === 0) {
return;
}
const params = new URLSearchParams();
for (const eid of uncachedEventIds) 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);
}
}
@@ -351,32 +498,246 @@ export default function GateView({
/* ignore batch rep fetch failure */
}
}
},
[hydrateMessages],
);
const refreshGate = useCallback(async (options: { force?: boolean } = {}): Promise<boolean> => {
if (!gateId) return false;
setLoading(true);
try {
const streamOwned = streamEnabledForGateRef.current;
const nextStatus = await fetchWormholeGateKeyStatus(gateId, {
force: options.force,
mode: streamOwned ? 'session_stream' : 'active_room',
});
setStatus(nextStatus);
if (!nextStatus?.ok || !nextStatus.has_local_access) {
gateCursorRef.current = 0;
setMessages([]);
setRoomError(String(nextStatus?.detail || 'Gate access still syncing'));
return false;
}
if (options.force || !streamOwned || !status?.has_local_access) {
await syncBrowserWormholeGateState(gateId).catch(() => false);
}
const snapshot = await fetchGateMessageSnapshotState(gateId, ACTIVE_GATE_ROOM_MESSAGE_LIMIT, {
force: options.force,
proofMode: streamOwned ? 'session_stream' : 'default',
});
gateCursorRef.current = snapshot.cursor;
await applyGateMessages(snapshot.messages as GateMessage[]);
return true;
} catch (error) {
setRoomError(error instanceof Error ? error.message : 'Failed to load gate room');
return false;
} finally {
setLoading(false);
}
}, [gateId, hydrateMessages]);
}, [applyGateMessages, gateId, status?.has_local_access]);
// SSE: instant delivery when new gate events arrive
const handleSSEEvent = useCallback(
(eventGateId: string) => {
if (eventGateId === gateId) void refreshGate();
},
[gateId, refreshGate],
);
useGateSSE(handleSSEEvent);
// Fallback poll (30s) in case SSE disconnects
useEffect(() => {
void refreshGate();
const timer = window.setInterval(() => {
void refreshGate();
}, 30_000);
return () => {
window.clearInterval(timer);
if (!gateId || !status?.has_local_access || !streamEnabledForGate) {
return;
}
return subscribeGateSessionStreamEvents((event) => {
if (event.event !== 'gate_update' || !event.data || typeof event.data !== 'object') {
return;
}
const updates = Array.isArray((event.data as { updates?: unknown }).updates)
? ((event.data as { updates?: Array<{ gate_id?: string; cursor?: number }> }).updates || [])
: [];
const matching = updates.find(
(update) => String(update?.gate_id || '').trim().toLowerCase() === gateId,
);
if (!matching) {
return;
}
void (async () => {
try {
const snapshot = await fetchGateMessageSnapshotState(
gateId,
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
{ force: true, proofMode: 'session_stream' },
);
gateCursorRef.current = snapshot.cursor;
await applyGateMessages(snapshot.messages as GateMessage[]);
} catch {
await refreshGate({ force: true });
}
})();
});
}, [applyGateMessages, gateId, refreshGate, status?.has_local_access, streamEnabledForGate]);
// Active gate rooms now wait for server-side change instead of issuing a fresh fetch on every cycle.
useEffect(() => {
if (!streamStatusHydrated) {
return;
}
const isLiveStreamPreferredForGate = () => {
const liveStreamStatus = getGateSessionStreamStatus();
return (
Boolean(gateId) &&
(liveStreamStatus.phase === 'connecting' || liveStreamStatus.phase === 'open') &&
liveStreamStatus.subscriptions.includes(gateId)
);
};
}, [refreshGate]);
const liveStreamPreferred = streamPreferredForGate || isLiveStreamPreferredForGate();
streamEnabledForGateRef.current = liveStreamPreferred;
let cancelled = false;
const clearRetry = () => {
if (pollTimerRef.current) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
};
const scheduleRetry = () => {
if (cancelled || streamEnabledForGateRef.current) return;
clearRetry();
pollTimerRef.current = window.setTimeout(() => {
pollTimerRef.current = null;
void waitForNextChange();
}, nextGateMessagesPollDelayMs());
};
const startWaitIfNeeded = () => {
queueMicrotask(() => {
streamEnabledForGateRef.current =
streamPreferredForGate || isLiveStreamPreferredForGate();
if (!cancelled && !streamEnabledForGateRef.current) {
void waitForNextChange();
}
});
};
const waitForNextChange = async () => {
streamEnabledForGateRef.current =
streamPreferredForGate || isLiveStreamPreferredForGate();
if (cancelled || !gateId || streamEnabledForGateRef.current) return;
const controller = new AbortController();
waitAbortRef.current = controller;
try {
const snapshot = await waitForGateMessageSnapshot(
gateId,
gateCursorRef.current,
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
{
timeoutMs: nextGateMessagesWaitTimeoutMs(),
signal: controller.signal,
},
);
waitAbortRef.current = null;
if (cancelled) return;
gateCursorRef.current = snapshot.cursor;
if (snapshot.changed) {
await applyGateMessages(snapshot.messages as GateMessage[]);
void waitForNextChange();
return;
}
clearRetry();
pollTimerRef.current = window.setTimeout(() => {
pollTimerRef.current = null;
void waitForNextChange();
}, nextGateMessagesWaitRearmDelayMs());
} catch (error) {
waitAbortRef.current = null;
if (cancelled || controller.signal.aborted) {
return;
}
const ready = await refreshGate({ force: true });
if (!ready) {
setRoomError(error instanceof Error ? error.message : 'Failed to load gate room');
scheduleRetry();
return;
}
startWaitIfNeeded();
}
};
if (liveStreamPreferred) {
void refreshGate();
return () => {
cancelled = true;
clearRetry();
if (waitAbortRef.current) {
waitAbortRef.current.abort();
waitAbortRef.current = null;
}
};
}
void refreshGate().then((ready) => {
streamEnabledForGateRef.current =
streamPreferredForGate || isLiveStreamPreferredForGate();
if (!cancelled && ready && !streamEnabledForGateRef.current) {
startWaitIfNeeded();
}
});
return () => {
cancelled = true;
clearRetry();
if (waitAbortRef.current) {
waitAbortRef.current.abort();
waitAbortRef.current = null;
}
};
}, [applyGateMessages, gateId, refreshGate, streamPreferredForGate, streamStatusHydrated]);
useEffect(() => {
setCompatConsentPrompt(null);
}, [gateId]);
useEffect(() => {
repsRef.current = reps;
}, [reps]);
useEffect(() => {
const handleCompatFallback = (event: Event) => {
const detail =
event instanceof CustomEvent && event.detail && typeof event.detail === 'object'
? (event.detail as { gateId?: string; action?: string })
: {};
const eventGateId = String(detail.gateId || '').trim().toLowerCase();
if (!eventGateId || eventGateId !== gateId) {
return;
}
setCompatActive(true);
};
window.addEventListener('sb:gate-compat-fallback', handleCompatFallback as EventListener);
return () => {
window.removeEventListener('sb:gate-compat-fallback', handleCompatFallback as EventListener);
};
}, [gateId]);
useEffect(() => {
const handleCompatConsentRequired = (event: Event) => {
const detail =
event instanceof CustomEvent && event.detail && typeof event.detail === 'object'
? (event.detail as GateCompatConsentPromptState)
: null;
const eventGateId = String(detail?.gateId || '').trim().toLowerCase();
if (!eventGateId || eventGateId !== gateId || !detail) {
return;
}
setCompatConsentPrompt({
gateId: eventGateId,
action: detail.action,
reason: String(detail.reason || ''),
});
};
window.addEventListener(
'sb:gate-compat-consent-required',
handleCompatConsentRequired as EventListener,
);
return () => {
window.removeEventListener(
'sb:gate-compat-consent-required',
handleCompatConsentRequired as EventListener,
);
};
}, [gateId]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -405,27 +766,31 @@ export default function GateView({
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 || '',
}),
});
const gatePost = await postWormholeGateMessage(gateId, msg, replyContext?.eventId || '').catch((error) => ({
ok: false,
detail: error instanceof Error ? error.message : 'Gate post failed',
}));
if (gatePost?.ok === false) {
throw new Error(describeGateCompatError(String(gatePost.detail || 'Gate post failed'), gateId));
}
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.
// Capture the server-assigned event_id and remember the plaintext we
// just authored, keyed by that event_id. The refresh will bring back
// the same event as ciphertext; during render we paint over its
// decrypted_message with what we typed. Pure React state — when the
// tab closes, this map vanishes.
const realEventId = String((gatePost as { event_id?: string })?.event_id || '');
if (realEventId) {
setSelfAuthoredByEventId((prev) => ({ ...prev, [realEventId]: msg }));
}
// Optimistic placeholder so the post appears instantly even before
// the next refresh round-trip completes. Uses the real event_id when
// available so the refresh merges cleanly rather than duplicating.
setMessages((prev) => [
...prev,
{
event_id: `_pending_${Date.now()}`,
event_id: realEventId || `_pending_${Date.now()}`,
message: msg,
decrypted_message: msg,
timestamp: Math.floor(Date.now() / 1000),
@@ -435,8 +800,6 @@ export default function GateView({
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
@@ -448,7 +811,21 @@ export default function GateView({
} finally {
setBusy(false);
}
}, [busy, composer, gateId, persona, refreshGate, replyContext, status?.has_local_access]);
}, [busy, composer, gateId, persona, replyContext, status?.has_local_access]);
const approveCompatFallback = useCallback(() => {
if (!compatConsentPrompt?.gateId) return;
approveGateCompatFallback(compatConsentPrompt.gateId);
const action = compatConsentPrompt.action;
setCompatActive(true);
setCompatConsentPrompt(null);
setRoomError('');
if (action === 'decrypt') {
void refreshGate({ force: true });
return;
}
void handleSend();
}, [compatConsentPrompt, handleSend, refreshGate]);
const handleVote = useCallback(async (eventId: string, vote: 1 | -1) => {
if (!eventId || !gateId || votedOn[voteScopeKey(eventId)] === vote) return;
@@ -503,19 +880,45 @@ export default function GateView({
}
}, [gateId, voteScopeKey, votedOn]);
const threadedMessages = useMemo(() => buildThreadedList(messages), [messages]);
// Overlay self-authored plaintexts onto the refreshed message list.
// Lives only in this component's React state; a tab close wipes it.
const messagesWithSelfOverlay = useMemo(
() =>
messages.map((m) => {
const eid = String(m.event_id || '');
const selfText = eid ? selfAuthoredByEventId[eid] : '';
if (!selfText) return m;
return { ...m, decrypted_message: selfText };
}),
[messages, selfAuthoredByEventId],
);
const threadedMessages = useMemo(
() => buildThreadedList(messagesWithSelfOverlay),
[messagesWithSelfOverlay],
);
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="flex items-center gap-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>
{onOpenShutdownPetition && (
<button
onClick={() => onOpenShutdownPetition(gateName)}
title="Open gate shutdown lifecycle (suspend / shutdown / appeal)"
className="flex items-center text-amber-500 hover:text-amber-400 transition-all uppercase text-xs tracking-widest border border-amber-900/50 px-3 py-1 bg-amber-900/10 hover:bg-amber-900/30 hover:border-amber-500/50"
>
SHUTDOWN STATUS
</button>
)}
</div>
<div className="text-gray-500 text-xs">
LOGGED IN AS:{' '}
<span
@@ -530,11 +933,18 @@ export default function GateView({
<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>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest">g/{gateId}</h1>
{compatActive ? (
<span className="border border-amber-500/40 bg-amber-950/20 px-2 py-0.5 text-[10px] font-mono tracking-[0.2em] text-amber-200">
COMPAT
</span>
) : null}
</div>
<p className="text-gray-500 text-sm mt-1">Fixed obfuscated gate. Creation is disabled for this testnet.</p>
</div>
<button
onClick={() => void refreshGate()}
onClick={() => void refreshGate({ force: true })}
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-sm uppercase tracking-[0.22em]"
>
<RefreshCw size={13} />
@@ -593,11 +1003,31 @@ export default function GateView({
)}
</div>
{roomError ? (
{roomError && !compatConsentPrompt ? (
<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}
{compatConsentPrompt ? (
<div className="mb-3 shrink-0 border border-amber-500/30 bg-amber-950/15 px-3 py-2 text-[11px] text-amber-100/90">
<div className="text-[12px] font-mono tracking-[0.2em] text-amber-300">COMPAT MODE</div>
<div className="mt-1 leading-[1.7]">
{describeGateCompatConsentPrompt(compatConsentPrompt.action)}
</div>
<div className="mt-1 text-[11px] text-amber-200/70">
{describeGateCompatReason(compatConsentPrompt.reason, compatConsentPrompt.gateId)}
</div>
<div className="mt-2 flex items-center gap-2">
<button
onClick={approveCompatFallback}
className="px-3 py-1.5 border border-amber-500/40 bg-amber-950/20 text-[11px] font-mono tracking-[0.18em] text-amber-100 hover:bg-amber-900/30 transition-colors"
>
ENABLE FOR ROOM
</button>
<span className="text-[11px] text-amber-200/70">Weaker privacy on this device.</span>
</div>
</div>
) : null}
{voteNotice ? (
<div className="mb-2 shrink-0 border border-yellow-800/30 bg-yellow-950/10 px-3 py-1.5 text-sm text-yellow-400/80 font-mono">
{voteNotice}
@@ -644,10 +1074,14 @@ export default function GateView({
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm 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 className="text-green-400">
@{String(
(message as unknown as { sender_handle?: string }).sender_handle
|| ((message as unknown as { payload?: { sender_handle?: string } }).payload?.sender_handle)
|| String(message.node_id || '').replace(/^!sb_/, '').slice(0, 8)
|| String(message.public_key || '').slice(0, 8)
|| 'anon_????',
)}
</span>
{isEncryptedGateEnvelope(message) ? (
<span
@@ -657,7 +1091,7 @@ export default function GateView({
: 'text-amber-300 border-amber-700/60'
}`}
>
{gateEnvelopeState(message) === 'decrypted' ? 'DECRYPTED' : 'KEY LOCKED'}
{gateEnvelopeState(message) === 'decrypted' ? 'DECRYPTED' : 'SEALED'}
</span>
) : null}
<span className="text-[var(--text-muted)] text-[13px]">{timeAgo(message.timestamp)}</span>
@@ -742,7 +1176,12 @@ export default function GateView({
<textarea
ref={textareaRef}
value={composer}
onChange={(e) => setComposer(e.target.value)}
onChange={(e) => {
setComposer(e.target.value);
if (roomError) {
setRoomError('');
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -1,7 +1,7 @@
'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 { Terminal, Radio, Globe, Key, LogOut, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail, Brain, GitBranch, Cpu, KeyRound } from 'lucide-react';
import { getNodeIdentity, getWormholeIdentityDescriptor } from '@/mesh/meshIdentity';
import {
activateWormholeGatePersona,
@@ -19,6 +19,13 @@ import WeatherWidget from './WeatherWidget';
import TrendingPosts from './TrendingPosts';
import HashchainEvents from './HashchainEvents';
import NetworkStats from './NetworkStats';
import AIQueryView from './AIQueryView';
import PetitionsView from './PetitionsView';
import UpgradeView from './UpgradeView';
import ResolutionView from './ResolutionView';
import GateShutdownView from './GateShutdownView';
import BootstrapView from './BootstrapView';
import FunctionKeyView from './FunctionKeyView';
const ASCII_HEADER = `
@@ -38,11 +45,8 @@ const ASCII_HEADER = `
`;
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',
},
// BALLOT entry removed 2026-04-28: the BALLOT command now navigates
// to PetitionsView (live governance DSL + petition lifecycle).
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.',
@@ -60,6 +64,19 @@ const GATES = [
'ukraine-front', 'iran-front', 'world-news', 'prediction-markets',
'finance', 'cryptography', 'cryptocurrencies', 'meet-chat', 'opsec-lab'
];
const GATE_LAUNCH_RETRY_DELAY_MS = 3000;
const GATE_LAUNCH_RETRY_ATTEMPTS = 20;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function isGateLaneStartingError(detail: string): boolean {
const lowered = String(detail || '').trim().toLowerCase();
return lowered.includes('obfuscated lane is still starting');
}
const SHELL_ANON_PERSONAS_KEY = 'sb_infonet_shell_anon_personas';
@@ -99,7 +116,11 @@ function allocateShellAnonPersona(): string {
const SECTIONS = [
{ name: 'HELP', icon: <Terminal size={14} className="mr-2" /> },
{ name: 'AI', icon: <Brain size={14} className="mr-2" /> },
{ name: 'BALLOT', icon: <Vote size={14} className="mr-2" /> },
{ name: 'UPGRADES', icon: <GitBranch size={14} className="mr-2" /> },
{ name: 'BOOTSTRAP', icon: <Cpu size={14} className="mr-2" /> },
{ name: 'F-KEYS', icon: <KeyRound 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" /> },
@@ -119,16 +140,26 @@ interface InfonetShellProps {
isOpen: boolean;
onClose: () => void;
onOpenLiveGate?: (gate: string) => void;
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
}
export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: InfonetShellProps) {
export default function InfonetShell({
isOpen,
onClose,
onOpenLiveGate,
onOpenDeadDrop,
}: 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');
type ViewName =
| 'terminal' | 'gate' | 'market' | 'profile' | 'messages' | 'ai'
| 'petitions' | 'upgrades' | 'resolution' | 'gate-shutdown'
| 'bootstrap' | 'function-keys';
const [currentView, setCurrentView] = useState<ViewName>('terminal');
const [activeGate, setActiveGate] = useState<string | null>(null);
const [persona, setPersona] = useState<string | null>(null);
const [activeGateMode, setActiveGateMode] = useState<'anonymous' | 'persona' | null>(null);
@@ -137,10 +168,15 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
const [isCitizen] = useState(false);
const [comingSoonModule, setComingSoonModule] = useState<string | null>(null);
const [wormholePromptKey, setWormholePromptKey] = useState('');
// Targets for parameterized economy views.
const [resolutionMarketId, setResolutionMarketId] = useState<string | null>(null);
const [shutdownGateId, setShutdownGateId] = useState<string | null>(null);
const [bootstrapMarketId, setBootstrapMarketId] = useState<string | null>(null);
const endOfTerminalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const gateLaunchAttemptRef = useRef(0);
// Real mesh identity
const nodeIdentity = useMemo(() => getNodeIdentity(), []);
@@ -167,6 +203,7 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
setInputMode('normal');
setPendingGate(null);
setInput('');
gateLaunchAttemptRef.current += 1;
setIsBooting(true);
setBootText([]);
@@ -235,7 +272,7 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
endOfTerminalRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [history]);
const handleNavigate = (view: 'terminal' | 'gate' | 'market' | 'profile' | 'messages', gate?: string) => {
const handleNavigate = (view: 'terminal' | 'gate' | 'market' | 'profile' | 'messages' | 'ai', gate?: string) => {
if (view === 'gate' && gate) {
if (onOpenLiveGate) {
setPendingGate(gate);
@@ -262,6 +299,58 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
setCurrentView(view);
};
const openGateWhenReady = async (
gateTarget: string,
operation: () => Promise<void>,
options: { commandLabel: string; waitingOutput: React.ReactNode; failurePrefix: string },
) => {
const launchId = ++gateLaunchAttemptRef.current;
let waitingShown = false;
for (let attempt = 0; attempt < GATE_LAUNCH_RETRY_ATTEMPTS; attempt += 1) {
if (gateLaunchAttemptRef.current !== launchId) {
return;
}
try {
await operation();
return;
} catch (error) {
const detail = error instanceof Error ? error.message : options.failurePrefix;
if (!isGateLaneStartingError(detail)) {
if (gateLaunchAttemptRef.current !== launchId) {
return;
}
setHistory(prev => [...prev, {
command: options.commandLabel,
output: <span className="text-red-400">ERR: {detail}</span>,
}]);
return;
}
if (!waitingShown) {
waitingShown = true;
setHistory(prev => [...prev, {
command: options.commandLabel,
output: options.waitingOutput,
}]);
}
if (attempt === GATE_LAUNCH_RETRY_ATTEMPTS - 1) {
if (gateLaunchAttemptRef.current !== launchId) {
return;
}
setHistory(prev => [...prev, {
command: options.commandLabel,
output: (
<span className="text-red-400">
ERR: The obfuscated lane is taking too long to come online. It is still warming up in the background.
</span>
),
}]);
return;
}
await sleep(GATE_LAUNCH_RETRY_DELAY_MS);
}
}
};
const handleCommand = (cmd: string) => {
const trimmedCmd = cmd.trim().toLowerCase();
let output: React.ReactNode = '';
@@ -293,18 +382,24 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
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>,
}]);
}
await openGateWhenReady(
gateTarget,
async () => {
await enterWormholeGate(gateTarget, true);
setActiveGateMode('anonymous');
setActiveGate(gateTarget);
setCurrentView('gate');
},
{
commandLabel: `gate ${gateTarget}`,
waitingOutput: (
<span className="text-cyan-400">
Warming the obfuscated lane for g/{gateTarget}. The room will open automatically as soon as it is ready.
</span>
),
failurePrefix: 'anonymous_gate_enter_failed',
},
);
})();
return;
}
@@ -312,30 +407,36 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
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>,
}]);
}
await openGateWhenReady(
gateTarget,
async () => {
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');
},
{
commandLabel: `join ${gateTarget}`,
waitingOutput: (
<span className="text-cyan-400">
Warming the obfuscated lane for g/{gateTarget}. Your gate face will open automatically when the room is ready.
</span>
),
failurePrefix: 'gate_face_create_failed',
},
);
})();
return;
}
@@ -351,7 +452,12 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
<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">ballot / petitions / governance</span> - File / sign / vote on petitions (DSL executor)</li>
<li><span className="text-gray-300 font-bold">upgrades</span> - Upgrade-hash governance + Heavy-Node readiness</li>
<li><span className="text-gray-300 font-bold">resolution [market_id]</span> - Evidence + dispute view</li>
<li><span className="text-gray-300 font-bold">shutdown [gate_id]</span> - Gate suspend / shutdown / appeal lifecycle</li>
<li><span className="text-gray-300 font-bold">bootstrap</span> - Bootstrap-mode resolution + ramp milestones</li>
<li><span className="text-gray-300 font-bold">fkeys / function-keys</span> - Anonymous citizenship proof design</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>
@@ -387,6 +493,9 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
} else {
output = <span className="text-red-400">ERR: Gate &apos;{target}&apos; not found or access denied.</span>;
}
} else if (trimmedCmd === 'ai' || trimmedCmd === 'copilot' || trimmedCmd === 'openclaw') {
handleNavigate('ai');
return;
} else if (trimmedCmd === 'markets') {
handleNavigate('market');
return;
@@ -396,9 +505,35 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
} else if (trimmedCmd === 'profile') {
handleNavigate('profile');
return;
} else if (trimmedCmd === 'ballot') {
setComingSoonModule('BALLOT');
} else if (trimmedCmd === 'ballot' || trimmedCmd === 'petitions' || trimmedCmd === 'governance') {
setCurrentView('petitions');
return;
} else if (trimmedCmd === 'upgrades' || trimmedCmd === 'upgrade') {
setCurrentView('upgrades');
return;
} else if (trimmedCmd === 'bootstrap') {
setBootstrapMarketId(null);
setCurrentView('bootstrap');
return;
} else if (trimmedCmd === 'function-keys' || trimmedCmd === 'fkeys') {
setCurrentView('function-keys');
return;
} else if (trimmedCmd.startsWith('resolution ')) {
const mid = trimmedCmd.slice('resolution '.length).trim();
if (mid) {
setResolutionMarketId(mid);
setCurrentView('resolution');
return;
}
output = <span className="text-red-400">Usage: resolution &lt;market_id&gt;</span>;
} else if (trimmedCmd.startsWith('shutdown ')) {
const gid = trimmedCmd.slice('shutdown '.length).trim();
if (gid) {
setShutdownGateId(gid);
setCurrentView('gate-shutdown');
return;
}
output = <span className="text-red-400">Usage: shutdown &lt;gate_id&gt;</span>;
} else if (trimmedCmd === 'work' || trimmedCmd === 'gigs') {
setComingSoonModule('GIGS');
return;
@@ -495,7 +630,11 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
{SECTIONS.map((section) => (
<button
key={section.name}
onClick={() => handleCommand(section.name === 'PROFILE' ? 'profile' : section.name.toLowerCase())}
onClick={() => handleCommand(
section.name === 'PROFILE' ? 'profile' :
section.name === 'F-KEYS' ? 'fkeys' :
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-sm md:text-xs uppercase tracking-widest whitespace-nowrap"
>
{section.icon}
@@ -593,6 +732,10 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
onBack={() => handleNavigate('terminal')}
onNavigateGate={(gate) => handleNavigate('gate', gate)}
onOpenLiveGate={onOpenLiveGate}
onOpenShutdownPetition={(gate) => {
setShutdownGateId(gate);
setCurrentView('gate-shutdown');
}}
availableGates={GATES}
/>
)}
@@ -612,7 +755,44 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
)}
{currentView === 'messages' && (
<MessagesView onBack={() => handleNavigate('terminal')} />
<MessagesView onBack={() => handleNavigate('terminal')} onOpenDeadDrop={onOpenDeadDrop} />
)}
{currentView === 'ai' && (
<AIQueryView onBack={() => handleNavigate('terminal')} />
)}
{currentView === 'petitions' && (
<PetitionsView onBack={() => setCurrentView('terminal')} />
)}
{currentView === 'upgrades' && (
<UpgradeView onBack={() => setCurrentView('terminal')} />
)}
{currentView === 'resolution' && resolutionMarketId && (
<ResolutionView
marketId={resolutionMarketId}
onBack={() => setCurrentView('terminal')}
/>
)}
{currentView === 'gate-shutdown' && shutdownGateId && (
<GateShutdownView
gateId={shutdownGateId}
onBack={() => setCurrentView('terminal')}
/>
)}
{currentView === 'bootstrap' && (
<BootstrapView
marketId={bootstrapMarketId ?? undefined}
onBack={() => setCurrentView('terminal')}
/>
)}
{currentView === 'function-keys' && (
<FunctionKeyView onBack={() => setCurrentView('terminal')} />
)}
{/* Coming Soon Popup */}
@@ -1,11 +1,12 @@
'use client';
import React, { useState } from 'react';
import { ChevronLeft, Search, Activity, Shield, Crosshair, DollarSign, Newspaper } from 'lucide-react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { ChevronLeft, Search, Activity, Shield, Crosshair, DollarSign, Newspaper, ExternalLink, Loader } from 'lucide-react';
import { useDataKeys } from '@/hooks/useDataStore';
import { API_BASE } from '@/lib/api';
import type { DashboardData, StockTicker } from '@/types/dashboard';
function formatVolume(vol: number): string {
function formatVolume(vol: number | null | undefined): 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`;
@@ -32,33 +33,185 @@ const CATEGORY_CONFIG: Record<string, { color: string; icon: typeof Shield }> =
CONFLICT: { color: 'text-red-400', icon: Crosshair },
FINANCE: { color: 'text-emerald-400', icon: DollarSign },
CRYPTO: { color: 'text-amber-400', icon: DollarSign },
SPORTS: { color: 'text-orange-400', icon: Activity },
NEWS: { color: 'text-cyan-400', icon: Newspaper },
};
type Category = 'ALL' | 'POLITICS' | 'CONFLICT' | 'FINANCE' | 'CRYPTO' | 'NEWS';
type Category = 'ALL' | 'POLITICS' | 'CONFLICT' | 'FINANCE' | 'CRYPTO' | 'SPORTS' | 'NEWS';
interface MarketViewProps {
onBack: () => void;
}
type MarketSource = {
name: string;
pct: number;
};
type MarketOutcome = {
name: string;
pct: number;
};
type PredictionMarket = {
title: string;
category?: Category | string;
consensus_pct?: number | null;
polymarket_pct?: number | null;
kalshi_pct?: number | null;
volume?: number | null;
volume_24h?: number | null;
end_date?: string | null;
description?: string | null;
sources?: MarketSource[];
slug?: string;
kalshi_ticker?: string;
outcomes?: MarketOutcome[];
delta_pct?: number | null;
consensus?: {
total_picks: number;
total_staked: number;
};
};
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 [searchResults, setSearchResults] = useState<PredictionMarket[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [allMarkets, setAllMarkets] = useState<PredictionMarket[]>([]);
const [marketTotals, setMarketTotals] = useState<Record<string, number>>({});
const [marketHasMore, setMarketHasMore] = useState<Record<string, boolean>>({});
const [searchHasMore, setSearchHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [allBrowseOffset, setAllBrowseOffset] = useState(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const data = useDataKeys(DATA_KEYS) as DataSlice;
const markets = data?.trending_markets || [];
const stocks = data?.stocks;
const filteredMarkets = markets.filter(m => {
const appendUniqueMarkets = useCallback((existing: PredictionMarket[], incoming: PredictionMarket[]) => {
const seen = new Set(existing.map((m) => String(m.slug || m.kalshi_ticker || m.title).toLowerCase()));
const next = [...existing];
for (const market of incoming) {
const key = String(market.slug || market.kalshi_ticker || market.title).toLowerCase();
if (!seen.has(key)) {
seen.add(key);
next.push(market);
}
}
return next;
}, []);
// Fetch all markets from the oracle endpoint on mount
useEffect(() => {
let mounted = true;
(async () => {
try {
const res = await fetch(`${API_BASE}/api/mesh/oracle/markets`);
if (res.ok) {
const d = await res.json();
const cats = d.categories || {};
const all: PredictionMarket[] = [];
for (const cat of Object.values(cats) as PredictionMarket[][]) {
all.push(...cat);
}
if (mounted) {
setAllMarkets(appendUniqueMarkets([], all));
const totals = d.cat_totals || {};
setMarketTotals({ ...totals, ALL: d.total_count || all.length });
const more: Record<string, boolean> = {};
for (const [cat, count] of Object.entries(totals)) {
const loaded = Array.isArray(cats[cat]) ? cats[cat].length : 0;
more[cat] = Number(count) > loaded;
}
more.ALL = Number(d.total_count || 0) > all.length;
setMarketHasMore(more);
}
}
} catch { /* silent */ }
})();
return () => { mounted = false; };
}, [appendUniqueMarkets]);
// API search — hits Polymarket + Kalshi directly
const searchMarkets = useCallback(async (query: string, offset = 0) => {
if (query.length < 2) {
setSearchResults([]);
setSearchHasMore(false);
setIsSearching(false);
return;
}
setIsSearching(true);
try {
const res = await fetch(
`${API_BASE}/api/mesh/oracle/search?q=${encodeURIComponent(query)}&limit=50&offset=${offset}`,
);
if (res.ok) {
const d = await res.json();
const results = d.results || [];
setSearchResults((prev) => (offset > 0 ? appendUniqueMarkets(prev, results) : results));
setSearchHasMore(Boolean(d.has_more));
}
} catch { /* silent */ }
setIsSearching(false);
}, [appendUniqueMarkets]);
const loadMoreMarkets = useCallback(async () => {
if (loadingMore) return;
setLoadingMore(true);
try {
if (searchInput.length >= 2) {
await searchMarkets(searchInput, searchResults.length);
return;
}
const loadedForCategory =
category === 'ALL'
? allBrowseOffset
: allMarkets.filter((m) => m.category === category).length;
const res = await fetch(
`${API_BASE}/api/mesh/oracle/markets/more?category=${encodeURIComponent(category)}&offset=${loadedForCategory}&limit=50`,
);
if (res.ok) {
const d = await res.json();
const markets = d.markets || [];
setAllMarkets((prev) => appendUniqueMarkets(prev, markets));
if (category === 'ALL') {
setAllBrowseOffset((prev) => prev + markets.length);
}
setMarketHasMore((prev) => ({ ...prev, [category]: Boolean(d.has_more) }));
setMarketTotals((prev) => ({ ...prev, [category]: d.total ?? prev[category] ?? loadedForCategory }));
}
} catch { /* silent */ }
finally {
setLoadingMore(false);
}
}, [allBrowseOffset, allMarkets, appendUniqueMarkets, category, loadingMore, searchInput, searchMarkets, searchResults.length]);
const handleSearchInput = useCallback(
(value: string) => {
setSearchInput(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => searchMarkets(value), 400);
},
[searchMarkets],
);
// Use search results when searching, otherwise show all markets
const displayMarkets = searchInput.length >= 2 ? searchResults : allMarkets;
const filteredMarkets = displayMarkets.filter(m => {
const matchesCat = category === 'ALL' || m.category === category;
const matchesSearch = !searchInput || m.title.toLowerCase().includes(searchInput.toLowerCase());
return matchesCat && matchesSearch;
return matchesCat;
});
const CATEGORIES: Category[] = ['ALL', 'POLITICS', 'CONFLICT', 'FINANCE', 'CRYPTO', 'NEWS'];
const CATEGORIES: Category[] = ['ALL', 'POLITICS', 'CONFLICT', 'FINANCE', 'CRYPTO', 'SPORTS', 'NEWS'];
const currentTotal = searchInput.length >= 2
? null
: marketTotals[category] ?? filteredMarkets.length;
const canLoadMore = searchInput.length >= 2 ? searchHasMore : Boolean(marketHasMore[category]);
// Build ticker from real stocks data
const tickerItems: string[] = [];
@@ -87,7 +240,10 @@ export default function MarketView({ onBack }: MarketViewProps) {
<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>
<p className="text-gray-500 text-sm mt-1">
Live Polymarket + Kalshi feeds. Search anything all markets from both platforms.
{' '}{allMarkets.length > 0 && `${allMarkets.length} cached markets.`}
</p>
</div>
{/* Categories */}
@@ -107,32 +263,45 @@ export default function MarketView({ onBack }: MarketViewProps) {
</button>
))}
</div>
<span className="text-sm text-gray-500 font-mono">{filteredMarkets.length} RESULTS</span>
<span className="text-sm text-gray-500 font-mono">
{filteredMarkets.length}{currentTotal != null && currentTotal > filteredMarkets.length ? ` / ${currentTotal}` : ''} 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" />
{isSearching ? (
<Loader size={14} className="text-cyan-500 mr-2 animate-spin" />
) : (
<Search size={14} className="text-gray-600 mr-2" />
)}
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search prediction markets..."
onChange={(e) => handleSearchInput(e.target.value)}
placeholder="Search ALL Polymarket + Kalshi markets (e.g. avalanche, bitcoin, trump, war)..."
className="bg-transparent border-none outline-none text-white w-full text-sm placeholder-gray-700"
spellCheck={false}
/>
</div>
{searchInput.length >= 2 && (
<div className="text-xs font-mono text-gray-600 mt-1 px-1">
{isSearching
? 'SEARCHING POLYMARKET + KALSHI APIs...'
: `${searchResults.length} RESULTS FROM POLYMARKET + KALSHI`}
</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 categoryLabel = market.category ?? 'UNCATEGORIZED';
const catConfig = CATEGORY_CONFIG[categoryLabel] || { 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;
@@ -145,7 +314,7 @@ export default function MarketView({ onBack }: MarketViewProps) {
<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-sm font-mono">
<span className={`${catConfig.color} uppercase tracking-widest`}>{market.category}</span>
<span className={`${catConfig.color} uppercase tracking-widest`}>{categoryLabel}</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>}
@@ -187,7 +356,7 @@ export default function MarketView({ onBack }: MarketViewProps) {
</div>
)}
{/* Source badges */}
{/* Source badges + external links */}
<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) => (
@@ -205,6 +374,23 @@ export default function MarketView({ onBack }: MarketViewProps) {
{consensus.total_staked > 0 ? ` · ${consensus.total_staked.toFixed(1)} REP` : ''}
</span>
)}
{/* External links */}
{market.slug && (
<button
onClick={() => window.open(`https://polymarket.com/event/${market.slug}`, '_blank', 'noopener,noreferrer')}
className="flex items-center gap-1 text-[11px] font-mono px-1.5 py-0.5 border border-purple-500/30 bg-purple-500/10 text-purple-400 hover:bg-purple-500/20 cursor-pointer"
>
<ExternalLink size={9} /> POLY
</button>
)}
{market.kalshi_ticker && (
<button
onClick={() => window.open(`https://kalshi.com/markets/${market.kalshi_ticker}`, '_blank', 'noopener,noreferrer')}
className="flex items-center gap-1 text-[11px] font-mono px-1.5 py-0.5 border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 cursor-pointer"
>
<ExternalLink size={9} /> KALSHI
</button>
)}
</div>
{/* Delta indicator */}
@@ -233,11 +419,31 @@ export default function MarketView({ onBack }: MarketViewProps) {
);
}) : (
<div className="text-center text-gray-600 py-8">
<p className="text-sm italic">No markets found{searchInput ? ` for "${searchInput}"` : ''}.</p>
{isSearching ? (
<p className="text-sm">Searching Polymarket + Kalshi...</p>
) : (
<p className="text-sm italic">No markets found{searchInput ? ` for "${searchInput}"` : ''}.</p>
)}
</div>
)}
</div>
{canLoadMore && (
<div className="shrink-0 flex justify-center pb-3">
<button
onClick={() => void loadMoreMarkets()}
disabled={loadingMore || isSearching}
className="px-4 py-2 text-xs uppercase tracking-widest border border-cyan-900/50 bg-cyan-900/10 text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-900/30 disabled:opacity-50"
>
{loadingMore || isSearching
? 'LOADING MORE...'
: searchInput.length >= 2
? 'MORE SEARCH RESULTS'
: `MORE ${category} MARKETS`}
</button>
</div>
)}
{/* Ticker */}
{tickerItems.length > 0 && (
<div className="shrink-0 border-t border-gray-800 bg-gray-900/30 overflow-hidden py-2 mt-2">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,618 @@
'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronLeft, FileText, Vote, Shield, AlertCircle, CheckCircle2, Loader } from 'lucide-react';
import {
buildChallengeFilePayload,
buildPetitionFilePayload,
buildPetitionSignPayload,
buildPetitionVotePayload,
fetchPetitions,
freshLocalId,
previewPetitionPayload,
signAndAppend,
type PetitionPayload,
type PetitionState,
} from '@/mesh/infonetEconomyClient';
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
interface PetitionsViewProps {
onBack: () => void;
}
const STATUS_STYLE: Record<string, { color: string; label: string; icon: typeof Vote }> = {
signatures: { color: 'text-cyan-400', label: 'COLLECTING SIGNATURES', icon: FileText },
voting: { color: 'text-blue-400', label: 'VOTING', icon: Vote },
challenge: { color: 'text-amber-400', label: 'CHALLENGE WINDOW', icon: Shield },
passed: { color: 'text-green-400', label: 'PASSED', icon: CheckCircle2 },
executed: { color: 'text-green-500', label: 'EXECUTED', icon: CheckCircle2 },
failed_signatures: { color: 'text-red-400', label: 'FAILED — SIGNATURES', icon: AlertCircle },
failed_vote: { color: 'text-red-400', label: 'FAILED — VOTE', icon: AlertCircle },
voided_challenge: { color: 'text-red-500', label: 'VOIDED BY CHALLENGE', icon: AlertCircle },
not_found: { color: 'text-gray-500', label: 'NOT FOUND', icon: AlertCircle },
};
function formatRelative(ts: number, now: number): string {
if (!ts) return '—';
const delta = ts - now;
const abs = Math.abs(delta);
const days = Math.floor(abs / 86400);
const hours = Math.floor((abs % 86400) / 3600);
if (delta > 0) {
if (days > 0) return `in ${days}d ${hours}h`;
if (hours > 0) return `in ${hours}h`;
return `in ${Math.floor(abs / 60)}m`;
} else {
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
return `${Math.floor(abs / 60)}m ago`;
}
}
function PayloadSummary({ payload }: { payload: PetitionPayload | Record<string, unknown> }) {
const t = (payload as { type?: string }).type;
if (t === 'UPDATE_PARAM') {
const p = payload as Extract<PetitionPayload, { type: 'UPDATE_PARAM' }>;
return (
<span className="text-gray-300">
Set <span className="text-cyan-400 font-bold">{p.key}</span> = {' '}
<span className="text-white font-bold">{String(p.value)}</span>
</span>
);
}
if (t === 'BATCH_UPDATE_PARAMS') {
const p = payload as Extract<PetitionPayload, { type: 'BATCH_UPDATE_PARAMS' }>;
return (
<span className="text-gray-300">
Update {p.updates?.length ?? 0} parameters atomically
</span>
);
}
if (t === 'ENABLE_FEATURE') {
const p = payload as Extract<PetitionPayload, { type: 'ENABLE_FEATURE' }>;
return <span className="text-gray-300">Enable feature <span className="text-green-400">{p.feature}</span></span>;
}
if (t === 'DISABLE_FEATURE') {
const p = payload as Extract<PetitionPayload, { type: 'DISABLE_FEATURE' }>;
return <span className="text-gray-300">Disable feature <span className="text-red-400">{p.feature}</span></span>;
}
return <span className="text-gray-500">Unknown payload type</span>;
}
function PetitionRow({
petition,
now,
onAction,
}: {
petition: PetitionState;
now: number;
onAction: () => void;
}) {
const style = STATUS_STYLE[petition.status] ?? STATUS_STYLE.not_found;
const Icon = style.icon;
const sigPct = petition.signature_threshold_at_filing > 0
? (petition.signature_governance_weight / petition.signature_threshold_at_filing) * 100
: 0;
const totalVotes = petition.votes_for_weight + petition.votes_against_weight;
const yesPct = totalVotes > 0
? (petition.votes_for_weight / totalVotes) * 100
: 0;
const { state, result, submit } = useSignAndAppend();
const busy = state === 'submitting';
const sign = useCallback(async () => {
const built = buildPetitionSignPayload(petition.petition_id);
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [petition.petition_id, submit, onAction]);
const voteFor = useCallback(async () => {
const built = buildPetitionVotePayload(petition.petition_id, 'for');
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [petition.petition_id, submit, onAction]);
const voteAgainst = useCallback(async () => {
const built = buildPetitionVotePayload(petition.petition_id, 'against');
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [petition.petition_id, submit, onAction]);
const challenge = useCallback(async () => {
const reason = window.prompt(
'Constitutional challenge — describe why this petition violates the constitution:',
);
if (!reason || !reason.trim()) return;
const built = buildChallengeFilePayload(petition.petition_id, reason.trim());
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [petition.petition_id, submit, onAction]);
return (
<div className="border border-gray-800 bg-black/40 p-3 hover:bg-black/60 transition-colors">
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0">
<Icon size={14} className={style.color} />
<span className={`text-xs font-bold uppercase tracking-wider ${style.color}`}>
{style.label}
</span>
</div>
<span className="text-xs text-gray-500 font-mono truncate">
{petition.petition_id.slice(0, 16)}
</span>
</div>
<div className="text-sm mb-2">
<PayloadSummary payload={petition.petition_payload} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div>
<div className="text-gray-500">Filer</div>
<div className="text-gray-300 font-mono truncate" title={petition.filer_id}>
{petition.filer_id.slice(0, 12)}
</div>
</div>
<div>
<div className="text-gray-500">Filed</div>
<div className="text-gray-300">{formatRelative(petition.filed_at, now)}</div>
</div>
{petition.status === 'signatures' && (
<div className="col-span-2">
<div className="text-gray-500">
Signatures: {petition.signature_governance_weight.toFixed(1)} / {petition.signature_threshold_at_filing.toFixed(1)}
</div>
<div className="h-1 bg-gray-800 mt-1 overflow-hidden">
<div
className="h-full bg-cyan-500 transition-all"
style={{ width: `${Math.min(100, sigPct)}%` }}
/>
</div>
</div>
)}
{(petition.status === 'voting' || petition.status === 'challenge'
|| petition.status === 'passed' || petition.status === 'executed') && (
<div className="col-span-2">
<div className="text-gray-500">
Vote: {petition.votes_for_weight.toFixed(1)} for / {petition.votes_against_weight.toFixed(1)} against
</div>
<div className="h-1 bg-gray-800 mt-1 overflow-hidden flex">
<div
className="h-full bg-green-500 transition-all"
style={{ width: `${yesPct}%` }}
/>
<div
className="h-full bg-red-500 transition-all"
style={{ width: `${100 - yesPct}%` }}
/>
</div>
</div>
)}
</div>
{petition.voting_deadline && petition.status === 'voting' && (
<div className="text-xs text-gray-500 mt-2">
Voting closes {formatRelative(petition.voting_deadline, now)}
</div>
)}
{petition.challenge_window_until && petition.status === 'challenge' && (
<div className="text-xs text-amber-400 mt-2">
Challenge window closes {formatRelative(petition.challenge_window_until, now)}
</div>
)}
<div className="flex flex-wrap gap-2 mt-3">
{petition.status === 'signatures' && (
<button
type="button"
onClick={sign}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
>
{busy ? 'Signing…' : 'Sign'}
</button>
)}
{petition.status === 'voting' && (
<>
<button
type="button"
onClick={voteFor}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-green-700/50 bg-green-900/20 text-green-400 hover:bg-green-900/40 disabled:opacity-30"
>
{busy ? '…' : 'Vote FOR'}
</button>
<button
type="button"
onClick={voteAgainst}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
>
{busy ? '…' : 'Vote AGAINST'}
</button>
</>
)}
{petition.status === 'challenge' && (
<button
type="button"
onClick={challenge}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
title="File a constitutional challenge against this passed petition"
>
{busy ? '…' : 'Challenge'}
</button>
)}
</div>
{result && !result.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{result.reason}
</div>
)}
</div>
);
}
function FilePetitionForm({ onFiled }: { onFiled?: () => void }) {
const [paramKey, setParamKey] = useState('');
const [paramValue, setParamValue] = useState('');
const [previewing, setPreviewing] = useState(false);
const [filing, setFiling] = useState(false);
const [previewResult, setPreviewResult] = useState<
{ ok: true; changedKeys: string[]; newValues: Record<string, unknown> } |
{ ok: false; reason: string } | null
>(null);
const [fileResult, setFileResult] = useState<
{ ok: true; eventId: string } |
{ ok: false; reason: string } | null
>(null);
const handlePreview = useCallback(async () => {
if (!paramKey.trim()) return;
setPreviewing(true);
setPreviewResult(null);
try {
// Try numeric coercion; fall back to string. Backend validator
// rejects type mismatches with a diagnostic — surfaces directly.
let value: unknown = paramValue;
const numeric = Number(paramValue);
if (paramValue.trim() !== '' && !Number.isNaN(numeric)) {
value = numeric;
} else if (paramValue.trim().toLowerCase() === 'true') {
value = true;
} else if (paramValue.trim().toLowerCase() === 'false') {
value = false;
}
const payload: PetitionPayload = {
type: 'UPDATE_PARAM',
key: paramKey.trim(),
value,
};
const res = await previewPetitionPayload(payload);
if (res.ok) {
setPreviewResult({
ok: true,
changedKeys: res.changed_keys ?? [],
newValues: res.new_values ?? {},
});
} else {
setPreviewResult({ ok: false, reason: res.reason ?? 'unknown_error' });
}
} catch (err) {
setPreviewResult({
ok: false,
reason: err instanceof Error ? err.message : 'network_error',
});
} finally {
setPreviewing(false);
}
}, [paramKey, paramValue]);
const buildPayload = useCallback((): PetitionPayload | null => {
if (!paramKey.trim()) return null;
let value: unknown = paramValue;
const numeric = Number(paramValue);
if (paramValue.trim() !== '' && !Number.isNaN(numeric)) {
value = numeric;
} else if (paramValue.trim().toLowerCase() === 'true') {
value = true;
} else if (paramValue.trim().toLowerCase() === 'false') {
value = false;
}
return { type: 'UPDATE_PARAM', key: paramKey.trim(), value };
}, [paramKey, paramValue]);
const handleFile = useCallback(async () => {
const inner = buildPayload();
if (!inner) return;
setFiling(true);
setFileResult(null);
try {
// Generate a fresh petition_id deterministically from the payload
// + timestamp so refile attempts produce distinct IDs.
const petitionId = `pet-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
const built = buildPetitionFilePayload(petitionId, inner);
const res = await signAndAppend({
event_type: built.event_type,
payload: built.payload,
});
if (res.ok) {
setFileResult({ ok: true, eventId: res.event.event_id });
onFiled?.();
} else {
setFileResult({ ok: false, reason: res.reason });
}
} catch (err) {
setFileResult({
ok: false,
reason: err instanceof Error ? err.message : 'unknown_error',
});
} finally {
setFiling(false);
}
}, [buildPayload, onFiled]);
return (
<div className="border border-cyan-900/50 bg-cyan-900/5 p-3">
<div className="flex items-center gap-2 mb-3">
<FileText size={14} className="text-cyan-400" />
<span className="text-xs font-bold uppercase tracking-wider text-cyan-400">
File or Preview a Petition
</span>
</div>
<div className="text-xs text-gray-500 mb-3">
<span className="text-cyan-400 font-bold">Preview</span> runs the
governance DSL executor without touching the chain the diagnostic
on failure is shown verbatim.
{' '}<span className="text-amber-400 font-bold">File</span> signs the
same payload with your local node key and posts it to{' '}
<span className="font-mono">/api/infonet/append</span>; the secure
entry point ({' '}<span className="font-mono">Infonet.append</span>)
verifies signature, replay, sequence, and binding before the event
lands.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-2">
<div>
<label className="text-xs text-gray-500 mb-1 block">CONFIG key</label>
<input
type="text"
value={paramKey}
onChange={(e) => setParamKey(e.target.value)}
placeholder="e.g. vote_decay_days"
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-sm text-white font-mono focus:border-cyan-500 focus:outline-none"
spellCheck={false}
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">New value</label>
<input
type="text"
value={paramValue}
onChange={(e) => setParamValue(e.target.value)}
placeholder="e.g. 30 / true / argon2id"
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-sm text-white font-mono focus:border-cyan-500 focus:outline-none"
spellCheck={false}
/>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handlePreview}
disabled={previewing || !paramKey.trim()}
className="px-3 py-1 bg-cyan-900/30 border border-cyan-700/50 text-cyan-400 hover:bg-cyan-900/50 hover:text-cyan-300 transition-colors text-xs uppercase tracking-wider disabled:opacity-30 disabled:cursor-not-allowed"
>
{previewing ? 'Validating…' : 'Preview'}
</button>
<button
type="button"
onClick={handleFile}
disabled={filing || !paramKey.trim()}
className="px-3 py-1 bg-amber-900/30 border border-amber-700/50 text-amber-400 hover:bg-amber-900/50 hover:text-amber-300 transition-colors text-xs uppercase tracking-wider disabled:opacity-30 disabled:cursor-not-allowed"
title="Sign with the local node key + post to /api/infonet/append"
>
{filing ? 'Filing…' : 'File Petition'}
</button>
</div>
{fileResult && fileResult.ok && (
<div className="mt-3 border border-green-900/50 bg-green-900/10 p-2 text-xs">
<div className="text-green-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
<CheckCircle2 size={12} /> PETITION FILED
</div>
<div className="text-gray-300 font-mono break-all">
event_id: {fileResult.eventId}
</div>
<div className="text-gray-500 mt-1">
The petition is now in the SIGNATURES phase. Other nodes can
sign with <span className="font-mono">petition_sign</span>;
voting opens once 25% oracle_rep_active worth of signatures land.
</div>
</div>
)}
{fileResult && !fileResult.ok && (
<div className="mt-3 border border-red-900/50 bg-red-900/10 p-2 text-xs">
<div className="text-red-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
<AlertCircle size={12} /> FILING REJECTED
</div>
<div className="text-gray-300 font-mono break-all">{fileResult.reason}</div>
<div className="text-gray-500 mt-1">
Common causes: local identity not initialized
(open the InfonetTerminal first), filer rep below
petition_filing_cost, or the chain rejected the signed event.
Use Preview first to confirm the payload validates.
</div>
</div>
)}
{previewResult && previewResult.ok && (
<div className="mt-3 border border-green-900/50 bg-green-900/10 p-2 text-xs">
<div className="text-green-400 font-bold uppercase tracking-wider mb-1">
VALIDATION PASSED
</div>
<div className="text-gray-300">
Would change keys: {previewResult.changedKeys.map((k) => (
<span key={k} className="text-cyan-400 font-mono mr-2">{k}</span>
))}
</div>
<div className="text-gray-500 mt-1">
Filing this petition costs the configured petition_filing_cost in common rep.
Production filing requires a signed event this is the validation preview only.
</div>
</div>
)}
{previewResult && !previewResult.ok && (
<div className="mt-3 border border-red-900/50 bg-red-900/10 p-2 text-xs">
<div className="text-red-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
<AlertCircle size={12} /> VALIDATION REJECTED
</div>
<div className="text-gray-300 font-mono">{previewResult.reason}</div>
</div>
)}
</div>
);
}
export default function PetitionsView({ onBack }: PetitionsViewProps) {
const [petitions, setPetitions] = useState<PetitionState[] | null>(null);
const [now, setNow] = useState(Date.now() / 1000);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchPetitions();
setPetitions(data.petitions);
setNow(data.now);
} catch (err) {
setError(err instanceof Error ? err.message : 'network error');
} finally {
setLoading(false);
}
}, []);
const hasActivePhase = (petitions || []).some((p) =>
p.status === 'signatures' || p.status === 'voting' || p.status === 'challenge',
);
useEffect(() => {
void reload();
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
return () => clearInterval(interval);
}, [reload, hasActivePhase]);
const grouped = useMemo(() => {
if (!petitions) return null;
const active = petitions.filter((p) =>
['signatures', 'voting', 'challenge'].includes(p.status),
);
const passed = petitions.filter((p) =>
['passed', 'executed'].includes(p.status),
);
const closed = petitions.filter((p) =>
['failed_signatures', 'failed_vote', 'voided_challenge'].includes(p.status),
);
return { active, passed, closed };
}, [petitions]);
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button
onClick={onBack}
className="flex items-center text-cyan-400 hover:text-cyan-300 transition-colors text-sm"
>
<ChevronLeft size={14} className="mr-1" />
BACK TO TERMINAL
</button>
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
<Vote size={16} />
BALLOT Governance Petitions
</div>
<button
onClick={() => void reload()}
disabled={loading}
className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30"
>
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
</button>
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-6">
<div className="text-xs text-gray-500 leading-relaxed">
Petitions amend protocol parameters via the type-safe governance DSL.
Lifecycle: <span className="text-cyan-400">SIGNATURES</span> (14d, 25% oracle_rep_active threshold)
<span className="text-blue-400">VOTING</span> (7d, 67% supermajority + 30% quorum)
<span className="text-amber-400">CHALLENGE</span> (48h constitutional challenge window)
<span className="text-green-400">EXECUTED</span>.
The DSL executor rejects unknown CONFIG keys, type mismatches, out-of-bounds
values, and IMMUTABLE_PRINCIPLES writes see the validation preview below.
</div>
<FilePetitionForm onFiled={() => void reload()} />
{error && (
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
<div className="flex items-center gap-2">
<AlertCircle size={12} />
<span className="font-bold">Failed to load petitions</span>
</div>
<div className="text-gray-400 mt-1 font-mono">{error}</div>
</div>
)}
{grouped && grouped.active.length > 0 && (
<div>
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">
Active Petitions ({grouped.active.length})
</div>
<div className="space-y-2">
{grouped.active.map((p) => (
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
))}
</div>
</div>
)}
{grouped && grouped.passed.length > 0 && (
<div>
<div className="text-xs uppercase tracking-wider text-green-400 mb-2">
Passed Petitions ({grouped.passed.length})
</div>
<div className="space-y-2">
{grouped.passed.map((p) => (
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
))}
</div>
</div>
)}
{grouped && grouped.closed.length > 0 && (
<div>
<div className="text-xs uppercase tracking-wider text-gray-500 mb-2">
Closed (Failed / Voided) ({grouped.closed.length})
</div>
<div className="space-y-2">
{grouped.closed.map((p) => (
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
))}
</div>
</div>
)}
{grouped && petitions && petitions.length === 0 && !loading && (
<div className="border border-gray-800 bg-black/40 p-6 text-center">
<div className="text-gray-500 text-sm mb-1">No petitions on the chain yet.</div>
<div className="text-gray-600 text-xs">
File one with the Preview tool above to see the lifecycle in action.
</div>
</div>
)}
</div>
</div>
);
}
@@ -2,8 +2,10 @@
import React, { useEffect, useState } from 'react';
import { ChevronLeft, User, Eye, EyeOff, Wallet, Activity, ShieldCheck, AlertCircle } from 'lucide-react';
import QRCode from 'qrcode';
import { API_BASE } from '@/lib/api';
import { exportWormholeDmInvite } from '@/mesh/wormholeIdentityClient';
interface ProfileViewProps {
onBack: () => void;
@@ -49,6 +51,11 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
const [showTransactions, setShowTransactions] = useState(false);
const [reputation, setReputation] = useState<ReputationSummary>(EMPTY_REPUTATION);
const [oracleProfile, setOracleProfile] = useState<OracleProfileSummary>(EMPTY_ORACLE_PROFILE);
const [dmInviteBusy, setDmInviteBusy] = useState(false);
const [dmInviteBlob, setDmInviteBlob] = useState('');
const [dmInviteQrSrc, setDmInviteQrSrc] = useState('');
const [dmInviteFingerprint, setDmInviteFingerprint] = useState('');
const [dmInviteStatus, setDmInviteStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
useEffect(() => {
let active = true;
@@ -118,6 +125,40 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
};
}, [nodeId]);
useEffect(() => {
let active = true;
if (!dmInviteBlob) {
setDmInviteQrSrc('');
return () => {
active = false;
};
}
void QRCode.toDataURL(dmInviteBlob, {
errorCorrectionLevel: 'M',
margin: 1,
width: 320,
color: {
dark: '#34d399',
light: '#05080d',
},
})
.then((dataUrl) => {
if (active) {
setDmInviteQrSrc(dataUrl);
}
})
.catch(() => {
if (active) {
setDmInviteQrSrc('');
}
});
return () => {
active = false;
};
}, [dmInviteBlob]);
const displayNodeId = nodeId?.trim() || 'NOT PROVISIONED';
const displayPersona = persona?.trim() || 'unassigned';
const creditsReference = publicKey?.trim() || 'Not provisioned';
@@ -141,6 +182,45 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
const oracleRepLocked = oracleProfile.oracle_rep_locked;
const oracleProgress = oracleRepTotal > 0 ? Math.max(0, Math.min(100, (oracleRep / oracleRepTotal) * 100)) : 0;
const handleGenerateDmInvite = async () => {
setDmInviteBusy(true);
setDmInviteStatus(null);
try {
const exported = await exportWormholeDmInvite();
setDmInviteBlob(JSON.stringify(exported, null, 2));
setDmInviteFingerprint(String(exported.trust_fingerprint || ''));
setDmInviteStatus({
type: 'ok',
text: 'Signed DM invite generated. Share it only over a trusted out-of-band channel.',
});
} catch (error) {
setDmInviteStatus({
type: 'err',
text: error instanceof Error ? error.message : 'dm_invite_export_failed',
});
} finally {
setDmInviteBusy(false);
}
};
const handleCopyDmInvite = async () => {
if (!dmInviteBlob || !navigator?.clipboard?.writeText) {
return;
}
try {
await navigator.clipboard.writeText(dmInviteBlob);
setDmInviteStatus({
type: 'ok',
text: 'Signed DM invite copied to clipboard.',
});
} catch (error) {
setDmInviteStatus({
type: 'err',
text: error instanceof Error ? error.message : 'clipboard_write_failed',
});
}
};
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">
@@ -325,6 +405,76 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
</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" /> FIRST-CONTACT BOOTSTRAP
</h2>
<div className="space-y-4">
<p className="text-sm text-gray-400 leading-[1.7]">
Export a signed DM invite for trusted out-of-band exchange. This pins first contact to
your messaging identity instead of plain first-sight TOFU. It does not link wallet,
reputation, or other personas.
</p>
<div className="flex flex-wrap gap-3">
<button
onClick={() => void handleGenerateDmInvite()}
disabled={dmInviteBusy}
className="px-4 py-2 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
>
{dmInviteBusy ? 'Generating...' : 'Generate Signed DM Invite'}
</button>
<button
onClick={() => void handleCopyDmInvite()}
disabled={!dmInviteBlob}
className="px-4 py-2 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
>
Copy Invite
</button>
</div>
{dmInviteFingerprint && (
<div className="text-sm text-emerald-300 font-mono">
Trust fingerprint: {dmInviteFingerprint}
</div>
)}
{dmInviteStatus && (
<div
className={`px-3 py-2 border text-sm ${
dmInviteStatus.type === 'ok'
? 'border-emerald-500/30 bg-emerald-950/20 text-emerald-300'
: 'border-red-500/30 bg-red-950/20 text-red-300'
}`}
>
{dmInviteStatus.text}
</div>
)}
<textarea
value={dmInviteBlob}
readOnly
className="w-full min-h-[220px] bg-[#0a0a0a] border border-gray-800 px-4 py-3 text-sm text-gray-300 font-mono outline-none"
placeholder="Generate a signed DM invite to display the export blob here."
spellCheck={false}
/>
{dmInviteQrSrc && (
<div className="border border-emerald-500/20 bg-[#0a0a0a] p-4">
<div className="text-xs text-emerald-300 uppercase tracking-[0.18em] mb-3">
QR Invite
</div>
<div className="flex flex-col items-center gap-3">
<img
src={dmInviteQrSrc}
alt="Signed DM invite QR"
className="w-[320px] max-w-full border border-gray-800 bg-black p-3"
/>
<div className="text-xs text-gray-500 text-center leading-[1.65] max-w-[32rem]">
Scan this over a trusted out-of-band channel. The QR carries the same signed DM
invite shown above, including the trust fingerprint and signature envelope.
</div>
</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">
<Wallet size={16} className="mr-2" /> CREDITS LEDGER
@@ -0,0 +1,574 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { ChevronLeft, FileText, Scale, Loader, AlertCircle, CheckCircle2, ShieldOff } from 'lucide-react';
import {
buildDisputeOpenPayload,
buildDisputeStakePayload,
buildEvidenceSubmitPayload,
buildResolutionStakePayload,
fetchMarketState,
previewMarketResolution,
signAndAppend,
type AppendResult,
type DisputeSummary,
type MarketState,
type ResolutionPreview,
} from '@/mesh/infonetEconomyClient';
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
interface ResolutionViewProps {
marketId: string;
onBack: () => void;
}
const PHASE_STYLE: Record<string, { color: string; label: string }> = {
predicting: { color: 'text-cyan-400', label: 'PREDICTING' },
evidence: { color: 'text-amber-400', label: 'EVIDENCE WINDOW' },
resolving: { color: 'text-blue-400', label: 'RESOLVING' },
final: { color: 'text-green-400', label: 'FINAL' },
invalid: { color: 'text-red-400', label: 'INVALID' },
};
function DisputeRow({
dispute,
onAction,
}: {
dispute: DisputeSummary;
onAction: () => void;
}) {
const [side, setSide] = useState<'confirm' | 'reverse'>('reverse');
const [amount, setAmount] = useState('');
const [repType, setRepType] = useState<'oracle' | 'common'>('oracle');
const action = useSignAndAppend();
const busy = action.state === 'submitting';
const submit = useCallback(async () => {
const amt = Number(amount);
if (!Number.isFinite(amt) || amt <= 0) return;
const built = buildDisputeStakePayload(dispute.dispute_id, side, amt, repType);
const res = await action.submit(built.event_type, built.payload);
if (res.ok) {
setAmount('');
onAction();
}
}, [amount, side, repType, dispute.dispute_id, action, onAction]);
return (
<div className="border border-red-900/50 bg-red-900/10 p-2 text-xs">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-red-400 font-bold">DISPUTE</span>
{dispute.is_resolved ? (
<span
className={
dispute.resolved_outcome === 'reversed' ? 'text-red-400' : 'text-green-400'
}
>
{dispute.resolved_outcome?.toUpperCase()}
</span>
) : (
<span className="text-amber-400">PENDING</span>
)}
<span className="text-gray-500 font-mono truncate">
{dispute.dispute_id.slice(0, 12)}
</span>
</div>
<div className="text-gray-300">
Challenger: <span className="font-mono">{dispute.challenger_id.slice(0, 12)}</span>
{' — stake '}{dispute.challenger_stake.toFixed(2)}
</div>
<div className="text-gray-500 mt-1">
confirm: {dispute.confirm_stakes.length} stakes
reverse: {dispute.reverse_stakes.length} stakes
</div>
{!dispute.is_resolved && (
<div className="flex flex-wrap items-center gap-2 mt-2">
<select
value={side}
onChange={(e) => setSide(e.target.value as 'confirm' | 'reverse')}
title="Dispute stake side"
aria-label="Dispute stake side"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="confirm">CONFIRM</option>
<option value="reverse">REVERSE</option>
</select>
<input
type="number"
min="0"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="amount"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-24"
/>
<select
value={repType}
onChange={(e) => setRepType(e.target.value as 'oracle' | 'common')}
title="Reputation type to stake"
aria-label="Reputation type to stake"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="oracle">oracle</option>
<option value="common">common</option>
</select>
<button
type="button"
onClick={submit}
disabled={busy || !amount}
className="px-2 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
>
{busy ? 'Staking…' : 'Stake'}
</button>
</div>
)}
{action.result && !action.result.ok && (
<div className="text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{action.result.reason}
</div>
)}
</div>
);
}
export default function ResolutionView({ marketId, onBack }: ResolutionViewProps) {
const [state, setState] = useState<MarketState | null>(null);
const [preview, setPreview] = useState<ResolutionPreview['preview'] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Resolution-stake form state.
const [stakeSide, setStakeSide] = useState<'yes' | 'no' | 'data_unavailable'>('yes');
const [stakeAmount, setStakeAmount] = useState('');
const [stakeRepType, setStakeRepType] = useState<'oracle' | 'common'>('oracle');
// Dispute-open form state.
const [disputeStake, setDisputeStake] = useState('');
const [disputeReason, setDisputeReason] = useState('');
// Evidence-submit form state (active during EVIDENCE phase).
const [evidenceOutcome, setEvidenceOutcome] = useState<'yes' | 'no'>('yes');
const [evidenceSourceDesc, setEvidenceSourceDesc] = useState('');
const [evidenceHashesInput, setEvidenceHashesInput] = useState('');
const [evidenceBond, setEvidenceBond] = useState('2');
const [evidenceSubmitting, setEvidenceSubmitting] = useState(false);
const [evidenceResult, setEvidenceResult] = useState<AppendResult | null>(null);
const stakeAction = useSignAndAppend();
const disputeAction = useSignAndAppend();
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [s, p] = await Promise.all([
fetchMarketState(marketId),
previewMarketResolution(marketId).catch(() => null),
]);
setState(s);
setPreview(p?.preview ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : 'network error');
} finally {
setLoading(false);
}
}, [marketId]);
const submitStake = useCallback(async () => {
const amt = Number(stakeAmount);
if (!Number.isFinite(amt) || amt <= 0) return;
const built = buildResolutionStakePayload(marketId, stakeSide, amt, stakeRepType);
const res = await stakeAction.submit(built.event_type, built.payload);
if (res.ok) {
setStakeAmount('');
void reload();
}
}, [stakeAmount, stakeSide, stakeRepType, marketId, stakeAction, reload]);
const submitDispute = useCallback(async () => {
const stake = Number(disputeStake);
if (!Number.isFinite(stake) || stake <= 0) return;
if (!disputeReason.trim()) return;
const built = buildDisputeOpenPayload(marketId, stake, disputeReason.trim());
const res = await disputeAction.submit(built.event_type, built.payload);
if (res.ok) {
setDisputeStake('');
setDisputeReason('');
void reload();
}
}, [disputeStake, disputeReason, marketId, disputeAction, reload]);
const submitEvidence = useCallback(async () => {
if (!evidenceSourceDesc.trim()) return;
const hashes = evidenceHashesInput
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
if (hashes.length === 0) return;
const bond = Number(evidenceBond);
if (!Number.isFinite(bond) || bond < 0) return;
setEvidenceSubmitting(true);
setEvidenceResult(null);
try {
const built = await buildEvidenceSubmitPayload({
marketId,
claimedOutcome: evidenceOutcome,
evidenceHashes: hashes,
sourceDescription: evidenceSourceDesc.trim(),
bond,
});
const res = await signAndAppend({
event_type: built.event_type,
payload: built.payload,
});
setEvidenceResult(res);
if (res.ok) {
setEvidenceSourceDesc('');
setEvidenceHashesInput('');
void reload();
}
} catch (err) {
setEvidenceResult({
ok: false,
reason: err instanceof Error ? err.message : 'unknown_error',
});
} finally {
setEvidenceSubmitting(false);
}
}, [
evidenceOutcome, evidenceSourceDesc, evidenceHashesInput, evidenceBond,
marketId, reload,
]);
const phase = state ? PHASE_STYLE[state.status] : null;
const inEvidence = state?.status === 'evidence';
const inResolving = state?.status === 'resolving';
const isFinal = state?.status === 'final';
const hasActivePhase = inEvidence || inResolving || isFinal;
useEffect(() => {
void reload();
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
return () => clearInterval(interval);
}, [reload, hasActivePhase]);
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
<ChevronLeft size={14} className="mr-1" /> BACK
</button>
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
<Scale size={16} /> RESOLUTION {marketId}
</div>
<button onClick={() => void reload()} disabled={loading} className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30">
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
</button>
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
{error && (
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
<AlertCircle size={12} className="inline mr-1" />{error}
</div>
)}
{state && phase && (
<div className="border border-gray-800 bg-black/40 p-3">
<div className={`text-xs font-bold uppercase tracking-wider ${phase.color} mb-2`}>
PHASE: {phase.label}
{state.was_reversed && (
<span className="ml-2 text-red-400"> REVERSED BY DISPUTE</span>
)}
</div>
{state.snapshot && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-500">Frozen Predictors</div>
<div className="text-white">{(state.snapshot.frozen_participant_count as number) ?? 0}</div>
</div>
<div>
<div className="text-gray-500">Frozen Total Stake</div>
<div className="text-white">{(state.snapshot.frozen_total_stake as number)?.toFixed?.(2) ?? '0.00'}</div>
</div>
<div>
<div className="text-gray-500">Excluded Predictors</div>
<div className="text-white">{state.excluded_predictor_ids.length}</div>
</div>
</div>
)}
</div>
)}
{state && state.evidence_bundles.length > 0 && (
<div>
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
<FileText size={12} /> Evidence Bundles ({state.evidence_bundles.length})
</div>
<div className="space-y-2">
{state.evidence_bundles.map((b) => (
<div key={b.submission_hash} className="border border-gray-800 bg-black/40 p-2 text-xs">
<div className="flex items-center justify-between gap-2 mb-1">
<span className={`font-bold ${b.claimed_outcome === 'yes' ? 'text-green-400' : 'text-red-400'}`}>
{b.claimed_outcome.toUpperCase()}
</span>
{b.is_first_for_side && (
<span className="text-amber-400 text-xs"> FIRST-FOR-SIDE BONUS</span>
)}
<span className="text-gray-500 font-mono truncate">
{b.node_id.slice(0, 12)}
</span>
</div>
<div className="text-gray-300 mb-1">{b.source_description || '(no description)'}</div>
<div className="text-gray-500 font-mono">
bond: {b.bond} {b.evidence_hashes.length} hash{b.evidence_hashes.length === 1 ? '' : 'es'}
</div>
</div>
))}
</div>
</div>
)}
{state && state.disputes.length > 0 && (
<div>
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
<ShieldOff size={12} /> Disputes ({state.disputes.length})
</div>
<div className="space-y-2">
{state.disputes.map((d) => (
<DisputeRow
key={d.dispute_id}
dispute={d}
onAction={() => void reload()}
/>
))}
</div>
</div>
)}
{inEvidence && (
<div className="border border-amber-900/50 bg-amber-900/5 p-3">
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
<FileText size={12} /> Submit Evidence
</div>
<div className="text-xs text-gray-500 mb-2">
Pay an evidence bond (
<span className="font-mono"> evidence_bond_cost</span>{' '}
oracle rep). The bond is returned if your claimed side wins;
forfeited otherwise. The first submitter per side gets a small
bonus from the losing pool when the market resolves on their
side. Hashes use the canonical content + submission scheme;
both are computed locally before signing.
</div>
<div className="space-y-2 text-xs">
<div className="flex flex-wrap items-center gap-2">
<select
value={evidenceOutcome}
onChange={(e) => setEvidenceOutcome(e.target.value as 'yes' | 'no')}
title="Claimed outcome"
aria-label="Claimed outcome"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="yes">YES</option>
<option value="no">NO</option>
</select>
<input
type="number"
min="0"
step="0.1"
value={evidenceBond}
onChange={(e) => setEvidenceBond(e.target.value)}
placeholder="bond"
title="Bond amount in oracle rep"
aria-label="Bond amount"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-24"
/>
<button
type="button"
onClick={submitEvidence}
disabled={
evidenceSubmitting ||
!evidenceSourceDesc.trim() ||
!evidenceHashesInput.trim()
}
className="px-3 py-1 uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
>
{evidenceSubmitting ? 'Submitting…' : 'Submit Evidence'}
</button>
</div>
<input
type="text"
value={evidenceSourceDesc}
onChange={(e) => setEvidenceSourceDesc(e.target.value)}
placeholder="source description (what + where)"
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
/>
<input
type="text"
value={evidenceHashesInput}
onChange={(e) => setEvidenceHashesInput(e.target.value)}
placeholder="evidence hashes (comma- or space-separated; ipfs://… or sha256:…)"
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
/>
</div>
{evidenceResult && !evidenceResult.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{evidenceResult.reason}
</div>
)}
{evidenceResult && evidenceResult.ok && (
<div className="text-xs text-green-400 font-mono mt-2 break-all">
<CheckCircle2 size={10} className="inline mr-1" />
evidence submitted event_id {String(evidenceResult.event.event_id).slice(0, 16)}
</div>
)}
</div>
)}
{inResolving && (
<div className="border border-blue-900/50 bg-blue-900/5 p-3">
<div className="text-xs uppercase tracking-wider text-blue-400 mb-2 flex items-center gap-1">
<Scale size={12} /> Stake on Resolution
</div>
<div className="text-xs text-gray-500 mb-2">
Pick a side and stake oracle (or common) rep. 75% of oracle stake on
one side reaches supermajority. <span className="text-amber-400">data_unavailable</span>{' '}
triggers phantom-evidence slashing if it crosses 33%.
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<select
value={stakeSide}
onChange={(e) => setStakeSide(e.target.value as 'yes' | 'no' | 'data_unavailable')}
title="Resolution stake side"
aria-label="Resolution stake side"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="yes">YES</option>
<option value="no">NO</option>
<option value="data_unavailable">DATA_UNAVAILABLE</option>
</select>
<input
type="number"
min="0"
step="0.01"
value={stakeAmount}
onChange={(e) => setStakeAmount(e.target.value)}
placeholder="amount"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
/>
<select
value={stakeRepType}
onChange={(e) => setStakeRepType(e.target.value as 'oracle' | 'common')}
title="Reputation type to stake"
aria-label="Reputation type to stake"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="oracle">oracle rep</option>
<option value="common">common rep</option>
</select>
<button
type="button"
onClick={submitStake}
disabled={stakeAction.state === 'submitting' || !stakeAmount}
className="px-3 py-1 uppercase tracking-wider border border-blue-700/50 bg-blue-900/20 text-blue-400 hover:bg-blue-900/40 disabled:opacity-30"
>
{stakeAction.state === 'submitting' ? 'Staking…' : 'Stake'}
</button>
</div>
{stakeAction.result && !stakeAction.result.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{stakeAction.result.reason}
</div>
)}
</div>
)}
{isFinal && (
<div className="border border-red-900/50 bg-red-900/5 p-3">
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
<ShieldOff size={12} /> Open a Dispute
</div>
<div className="text-xs text-gray-500 mb-2">
Bounded reversal: a successful dispute flips the effective outcome of
THIS market only never cascades to other markets. Oracle-rep simple
majority decides; common rep can also be staked but doesn&apos;t decide
the outcome.
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<input
type="number"
min="0"
step="0.01"
value={disputeStake}
onChange={(e) => setDisputeStake(e.target.value)}
placeholder="challenger stake"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
/>
<input
type="text"
value={disputeReason}
onChange={(e) => setDisputeReason(e.target.value)}
placeholder="reason (max 2000 chars)"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono flex-1 min-w-[200px]"
maxLength={2000}
/>
<button
type="button"
onClick={submitDispute}
disabled={
disputeAction.state === 'submitting' ||
!disputeStake || !disputeReason.trim()
}
className="px-3 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
>
{disputeAction.state === 'submitting' ? 'Opening…' : 'Open Dispute'}
</button>
</div>
{disputeAction.result && !disputeAction.result.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{disputeAction.result.reason}
</div>
)}
</div>
)}
{preview && (
<div className="border border-cyan-900/50 bg-cyan-900/5 p-3">
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2 flex items-center gap-1">
<CheckCircle2 size={12} /> Resolution Preview (if closed now)
</div>
<div className="text-sm mb-2">
Outcome: <span className={
preview.outcome === 'yes' ? 'text-green-400 font-bold' :
preview.outcome === 'no' ? 'text-red-400 font-bold' :
'text-gray-400 font-bold'
}>{preview.outcome.toUpperCase()}</span>
<span className="text-gray-500 ml-2 font-mono">({preview.reason})</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-400">
<div>
Winners: {preview.stake_winnings.length} stake winnings,
{' '}{preview.bond_returns.length} bond returns
</div>
<div>
Forfeited: {preview.bond_forfeits.length} bonds
{' '}Burned: {preview.burned_amount.toFixed(2)}
</div>
</div>
{preview.first_submitter_bonuses.length > 0 && (
<div className="text-xs text-amber-400 mt-1">
First-submitter bonuses: {preview.first_submitter_bonuses.map(b => `${b.node_id.slice(0,8)}…(${b.amount.toFixed(2)})`).join(', ')}
</div>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,375 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { ChevronLeft, GitBranch, Server, AlertCircle, CheckCircle2, Loader } from 'lucide-react';
import {
buildUpgradeProposePayload,
buildUpgradeSignPayload,
buildUpgradeSignalReadyPayload,
buildUpgradeVotePayload,
fetchUpgrades,
freshLocalId,
type UpgradeProposalSummary,
} from '@/mesh/infonetEconomyClient';
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
interface UpgradeViewProps {
onBack: () => void;
}
const STATUS_STYLE: Record<string, { color: string; label: string }> = {
signatures: { color: 'text-cyan-400', label: 'COLLECTING SIGNATURES' },
voting: { color: 'text-blue-400', label: 'VOTING' },
challenge: { color: 'text-amber-400', label: 'CHALLENGE WINDOW' },
activation: { color: 'text-purple-400', label: 'AWAITING HEAVY-NODE READINESS' },
activated: { color: 'text-green-500', label: 'ACTIVATED' },
failed_signatures: { color: 'text-red-400', label: 'FAILED — SIGNATURES' },
failed_vote: { color: 'text-red-400', label: 'FAILED — VOTE' },
voided_challenge: { color: 'text-red-500', label: 'VOIDED BY CHALLENGE' },
failed_activation: { color: 'text-red-400', label: 'FAILED — ACTIVATION' },
not_found: { color: 'text-gray-500', label: 'NOT FOUND' },
};
function UpgradeRow({
proposal,
onAction,
}: {
proposal: UpgradeProposalSummary;
onAction: () => void;
}) {
const style = STATUS_STYLE[proposal.status] ?? STATUS_STYLE.not_found;
const totalVotes = proposal.votes_for_weight + proposal.votes_against_weight;
const yesPct = totalVotes > 0 ? (proposal.votes_for_weight / totalVotes) * 100 : 0;
const readinessPct = (proposal.readiness_fraction || 0) * 100;
const { state, result, submit } = useSignAndAppend();
const busy = state === 'submitting';
const sign = useCallback(async () => {
const built = buildUpgradeSignPayload(proposal.proposal_id);
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [proposal.proposal_id, submit, onAction]);
const voteFor = useCallback(async () => {
const built = buildUpgradeVotePayload(proposal.proposal_id, 'for');
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [proposal.proposal_id, submit, onAction]);
const voteAgainst = useCallback(async () => {
const built = buildUpgradeVotePayload(proposal.proposal_id, 'against');
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [proposal.proposal_id, submit, onAction]);
const signalReady = useCallback(async () => {
const built = buildUpgradeSignalReadyPayload(
proposal.proposal_id,
proposal.release_hash,
);
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [proposal.proposal_id, proposal.release_hash, submit, onAction]);
return (
<div className="border border-gray-800 bg-black/40 p-3">
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-2">
<GitBranch size={14} className={style.color} />
<span className={`text-xs font-bold uppercase tracking-wider ${style.color}`}>
{style.label}
</span>
</div>
<span className="text-xs text-gray-500 font-mono">
v{proposal.target_protocol_version}
</span>
</div>
<div className="text-xs text-gray-400 mb-2 font-mono break-all">
release_hash: {proposal.release_hash.slice(0, 32)}
</div>
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
<div>
<div className="text-gray-500">Proposer</div>
<div className="text-gray-300 font-mono truncate">
{proposal.proposer_id.slice(0, 16)}
</div>
</div>
<div>
<div className="text-gray-500">Filed</div>
<div className="text-gray-300">
{proposal.filed_at ? new Date(proposal.filed_at * 1000).toLocaleDateString() : '—'}
</div>
</div>
</div>
{(proposal.status === 'voting' || proposal.status === 'challenge'
|| proposal.status === 'activation' || proposal.status === 'activated') && (
<>
<div className="text-xs text-gray-500 mb-1">
Vote: {proposal.votes_for_weight.toFixed(1)} for / {proposal.votes_against_weight.toFixed(1)} against
<span className="text-gray-600 ml-2">(80% supermajority required)</span>
</div>
<div className="h-1 bg-gray-800 mb-2 overflow-hidden flex">
<div className="h-full bg-green-500" style={{ width: `${yesPct}%` }} />
<div className="h-full bg-red-500" style={{ width: `${100 - yesPct}%` }} />
</div>
</>
)}
{(proposal.status === 'activation' || proposal.status === 'activated') && (
<>
<div className="text-xs text-purple-400 mb-1 flex items-center gap-1">
<Server size={11} />
Heavy-Node readiness: {readinessPct.toFixed(1)}%
<span className="text-gray-500 ml-2">(67% required for activation)</span>
{proposal.readiness_threshold_met && (
<span className="text-green-400 ml-2"> THRESHOLD MET</span>
)}
</div>
<div className="h-1 bg-gray-800 overflow-hidden">
<div
className="h-full bg-purple-500 transition-all"
style={{ width: `${Math.min(100, readinessPct)}%` }}
/>
</div>
</>
)}
<div className="flex flex-wrap gap-2 mt-3">
{proposal.status === 'signatures' && (
<button
type="button"
onClick={sign}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
>
{busy ? 'Signing…' : 'Sign'}
</button>
)}
{proposal.status === 'voting' && (
<>
<button
type="button"
onClick={voteFor}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-green-700/50 bg-green-900/20 text-green-400 hover:bg-green-900/40 disabled:opacity-30"
>
{busy ? '…' : 'Vote FOR'}
</button>
<button
type="button"
onClick={voteAgainst}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
>
{busy ? '…' : 'Vote AGAINST'}
</button>
</>
)}
{proposal.status === 'activation' && (
<button
type="button"
onClick={signalReady}
disabled={busy}
title="Signal that this Heavy Node has installed and verified the new release"
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
>
{busy ? '…' : 'Signal Ready'}
</button>
)}
</div>
{result && !result.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{result.reason}
</div>
)}
</div>
);
}
function ProposeUpgradePanel({ onAction }: { onAction: () => void }) {
const [releaseHash, setReleaseHash] = useState('');
const [releaseDescription, setReleaseDescription] = useState('');
const [targetProtocolVersion, setTargetProtocolVersion] = useState('');
const { state, result, submit } = useSignAndAppend();
const busy = state === 'submitting';
const propose = useCallback(async () => {
const trimmedHash = releaseHash.trim().toLowerCase();
const trimmedDesc = releaseDescription.trim();
const trimmedVersion = targetProtocolVersion.trim();
if (trimmedHash.length !== 64 || !/^[0-9a-f]{64}$/.test(trimmedHash)) return;
if (!trimmedDesc) return;
if (!trimmedVersion) return;
const built = buildUpgradeProposePayload({
proposalId: freshLocalId('upg'),
releaseHash: trimmedHash,
releaseDescription: trimmedDesc,
targetProtocolVersion: trimmedVersion,
});
const res = await submit(built.event_type, built.payload);
if (res.ok) {
setReleaseHash('');
setReleaseDescription('');
setTargetProtocolVersion('');
onAction();
}
}, [releaseHash, releaseDescription, targetProtocolVersion, submit, onAction]);
return (
<div className="border border-purple-900/50 bg-purple-900/5 p-3">
<div className="text-xs uppercase tracking-wider text-purple-400 font-bold mb-2">
File Upgrade Proposal
</div>
<div className="text-xs text-gray-500 mb-3 leading-relaxed">
Filing requires <span className="text-purple-400">upgrade_filing_cost</span> common rep and a
SHA-256 hash of the verified release artifact. After filing, the proposal collects
signatures, then enters voting (80% supermajority / 40% quorum), then the challenge window,
then awaits 67% Heavy-Node readiness signal before activation.
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mb-2">
<input
type="text"
value={releaseHash}
onChange={(e) => setReleaseHash(e.target.value)}
placeholder="release_hash (64 hex chars)"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono text-xs col-span-1 md:col-span-2"
/>
<input
type="number"
min="1"
step="1"
value={targetProtocolVersion}
onChange={(e) => setTargetProtocolVersion(e.target.value)}
placeholder="target protocol_version"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono text-xs"
/>
</div>
<textarea
value={releaseDescription}
onChange={(e) => setReleaseDescription(e.target.value)}
placeholder="release_description — what changes / event types / formulas does this introduce?"
rows={2}
className="bg-black/60 border border-gray-700 px-2 py-1 text-white text-xs w-full mb-2"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={propose}
disabled={busy}
className="px-3 py-1 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
>
{busy ? 'Filing…' : 'Propose Upgrade'}
</button>
{result && result.ok && (
<span className="text-xs text-green-400 flex items-center gap-1">
<CheckCircle2 size={11} /> Filed
</span>
)}
</div>
{result && !result.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{result.reason}
</div>
)}
</div>
);
}
export default function UpgradeView({ onBack }: UpgradeViewProps) {
const [upgrades, setUpgrades] = useState<UpgradeProposalSummary[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchUpgrades();
setUpgrades(data.upgrades);
} catch (err) {
setError(err instanceof Error ? err.message : 'network error');
} finally {
setLoading(false);
}
}, []);
const hasActivePhase = (upgrades || []).some((u) =>
u.status === 'signatures' || u.status === 'voting' ||
u.status === 'challenge' || u.status === 'activation',
);
useEffect(() => {
void reload();
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 60_000);
return () => clearInterval(interval);
}, [reload, hasActivePhase]);
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button
onClick={onBack}
className="flex items-center text-cyan-400 hover:text-cyan-300 transition-colors text-sm"
>
<ChevronLeft size={14} className="mr-1" />
BACK
</button>
<div className="text-sm text-purple-400 font-bold uppercase tracking-widest flex items-center gap-2">
<GitBranch size={16} />
UPGRADE-HASH GOVERNANCE
</div>
<button
onClick={() => void reload()}
disabled={loading}
className="text-xs text-gray-500 hover:text-purple-400 disabled:opacity-30"
>
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
</button>
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
<div className="text-xs text-gray-500 leading-relaxed">
Protocol upgrades that need new logic / new event types / new formulas
can&apos;t be expressed as parameter changes they use upgrade-hash
governance. The network votes on a software release&apos;s SHA-256 hash;
Heavy Nodes that have downloaded and verified the release emit
<span className="text-purple-400"> upgrade_signal_ready</span>. Once 67%
of Heavy Nodes have signaled, the upgrade activates and protocol_version
increments. Higher thresholds than param petitions: <span className="text-green-400">80% supermajority</span>,
<span className="text-blue-400"> 40% quorum</span>,
<span className="text-purple-400"> 67% Heavy-Node activation</span>.
</div>
<ProposeUpgradePanel onAction={() => void reload()} />
{error && (
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
<AlertCircle size={12} className="inline mr-1" />
<span className="font-bold">Failed to load:</span>
<span className="text-gray-400 ml-2 font-mono">{error}</span>
</div>
)}
{upgrades && upgrades.length === 0 && !loading && (
<div className="border border-gray-800 bg-black/40 p-6 text-center">
<div className="text-gray-500 text-sm">No upgrade proposals on chain.</div>
<div className="text-gray-600 text-xs mt-1">
Filing requires <span className="text-purple-400">upgrade_filing_cost</span> common rep
and a SHA-256 release hash.
</div>
</div>
)}
{upgrades?.map((u) => (
<UpgradeRow key={u.proposal_id} proposal={u} onAction={() => void reload()} />
))}
</div>
</div>
);
}
@@ -2,17 +2,7 @@
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(() => {
@@ -20,32 +10,19 @@ export default function WeatherWidget() {
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' });
const timeString = time.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
});
const dateString = time.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
return (
<div className="flex items-center gap-2 text-sm 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}&deg;{tempUnit}
</span>
</div>
);
}
@@ -9,9 +9,15 @@ interface InfonetTerminalProps {
isOpen: boolean;
onClose: () => void;
onOpenLiveGate?: (gate: string) => void;
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
}
export default function InfonetTerminal({ isOpen, onClose, onOpenLiveGate }: InfonetTerminalProps) {
export default function InfonetTerminal({
isOpen,
onClose,
onOpenLiveGate,
onOpenDeadDrop,
}: InfonetTerminalProps) {
/* Close on Escape */
useEffect(() => {
if (!isOpen) return;
@@ -68,7 +74,12 @@ export default function InfonetTerminal({ isOpen, onClose, onOpenLiveGate }: Inf
{/* Shell content — fills remaining space, scrolls internally */}
<div className="flex-1 overflow-hidden">
<InfonetShell isOpen={isOpen} onClose={onClose} onOpenLiveGate={onOpenLiveGate} />
<InfonetShell
isOpen={isOpen}
onClose={onClose}
onOpenLiveGate={onOpenLiveGate}
onOpenDeadDrop={onOpenDeadDrop}
/>
</div>
</motion.div>
</motion.div>
@@ -0,0 +1,92 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
const shortcuts = [
{ key: 'L', desc: 'Toggle left panel (LAYERS)' },
{ key: 'R', desc: 'Toggle right panel (INTEL)' },
{ key: 'M', desc: 'Toggle markets ticker' },
{ key: 'S', desc: 'Open settings' },
{ key: 'K', desc: 'Open map legend (KEY)' },
{ key: 'F', desc: 'Focus search bar' },
{ key: 'Esc', desc: 'Deselect / close modals' },
{ key: 'Space', desc: 'Toggle this overlay' },
];
export default function KeyboardShortcutsOverlay({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="fixed inset-0 z-[9500] flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
{/* Content */}
<motion.div
className="relative z-10 bg-[var(--bg-primary)]/95 border border-[var(--border-secondary)] rounded-sm p-8 max-w-md w-full mx-4 shadow-[0_0_40px_rgba(6,182,212,0.1)]"
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="text-[18px] text-[var(--text-heading)] font-mono font-bold tracking-widest">
KEYBOARD SHORTCUTS
</div>
</div>
<button
onClick={onClose}
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors text-lg font-bold"
>
×
</button>
</div>
{/* Divider */}
<div className="h-px bg-[var(--border-primary)] mb-4" />
{/* Shortcuts Grid */}
<div className="flex flex-col gap-2">
{shortcuts.map(({ key, desc }) => (
<div
key={key}
className="flex items-center justify-between py-1.5"
>
<span className="text-[12px] font-mono text-[var(--text-primary)] tracking-wide">
{desc}
</span>
<kbd className="inline-flex items-center justify-center min-w-[32px] px-2 py-1 rounded-sm bg-cyan-950/40 border border-cyan-800/50 text-[11px] font-mono font-bold text-cyan-400 tracking-wider">
{key}
</kbd>
</div>
))}
</div>
{/* Footer */}
<div className="mt-6 pt-3 border-t border-[var(--border-primary)]">
<div className="text-[9px] font-mono tracking-[0.25em] text-[var(--text-muted)] text-center uppercase">
Shortcuts are disabled when typing in inputs
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,382 @@
'use client';
import React, { useState, useCallback, useRef } from 'react';
import { AlertTriangle, Play, Pause } from 'lucide-react';
import HlsVideo, { type HlsVideoHandle } from '@/components/HlsVideo';
export interface CctvFullscreenModalProps {
url: string;
mediaType: string;
isVideo: boolean;
cameraName: string;
sourceAgency: string;
cameraId: string;
onClose: () => void;
}
export function CctvFullscreenModal({
url,
mediaType,
isVideo,
cameraName,
sourceAgency,
cameraId,
onClose,
}: CctvFullscreenModalProps) {
const [paused, setPaused] = useState(false);
const [mediaError, setMediaError] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<HlsVideoHandle>(null);
const togglePlay = useCallback(() => {
if (mediaType === 'hls') {
if (hlsRef.current?.paused) hlsRef.current.play();
else hlsRef.current?.pause();
setPaused(!hlsRef.current?.paused);
} else if (videoRef.current) {
if (videoRef.current.paused) videoRef.current.play();
else videoRef.current.pause();
setPaused(videoRef.current.paused);
}
}, [mediaType]);
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
background: 'rgba(0,0,0,0.88)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '60px 20px 80px 20px',
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') onClose();
}}
tabIndex={-1}
ref={(el) => el?.focus()}
>
<div
style={{
background: 'rgba(0,0,0,0.95)',
border: '1px solid rgba(8,145,178,0.5)',
borderRadius: 12,
overflow: 'hidden',
maxWidth: 'calc(100vw - 40px)',
maxHeight: 'calc(100vh - 80px)',
width: 900,
display: 'flex',
flexDirection: 'column',
boxShadow: '0 0 60px rgba(8,145,178,0.25), inset 0 0 30px rgba(0,0,0,0.5)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
background: 'rgba(8,51,68,0.4)',
borderBottom: '1px solid rgba(8,145,178,0.3)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<AlertTriangle size={12} style={{ color: '#ef4444' }} />
<span
style={{
fontSize: 11,
color: '#22d3ee',
fontFamily: 'monospace',
letterSpacing: '0.2em',
fontWeight: 'bold',
}}
>
OPTIC INTERCEPT
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span
style={{
fontSize: 10,
color: 'rgba(8,145,178,0.6)',
fontFamily: 'monospace',
}}
>
ID: {cameraId}
</span>
<button
onClick={onClose}
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.4)',
borderRadius: 6,
color: '#ef4444',
fontSize: 10,
fontFamily: 'monospace',
padding: '4px 10px',
cursor: 'pointer',
letterSpacing: '0.1em',
}}
>
CLOSE
</button>
</div>
</div>
{/* Metadata row */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
fontSize: 10,
fontFamily: 'monospace',
borderBottom: '1px solid rgba(8,51,68,0.5)',
}}
>
<span style={{ color: '#22d3ee', letterSpacing: '0.15em' }}>{sourceAgency}</span>
<span style={{ color: '#ef4444', letterSpacing: '0.1em', fontWeight: 'bold' }}>
REC // {new Date().toLocaleTimeString('en-GB', { hour12: false })}
</span>
<span
style={{
color: 'rgba(8,145,178,0.7)',
letterSpacing: '0.1em',
background: 'rgba(8,145,178,0.1)',
border: '1px solid rgba(8,145,178,0.2)',
borderRadius: 4,
padding: '2px 8px',
}}
>
{mediaType.toUpperCase()}
</span>
</div>
{/* Media area */}
<div
style={{
flex: 1,
position: 'relative',
background: '#000',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: 400,
overflow: 'hidden',
}}
>
{url ? (
<>
{mediaType === 'video' && !mediaError && (
<video
ref={videoRef}
src={url}
autoPlay
loop
muted
playsInline
onError={() => setMediaError(true)}
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 260px)',
objectFit: 'contain',
filter: 'contrast(1.25) saturate(0.5)',
}}
/>
)}
{mediaType === 'hls' && !mediaError && (
<HlsVideo
ref={hlsRef}
url={url}
onError={() => setMediaError(true)}
className=""
/>
)}
{mediaType === 'mjpeg' && (
<img
src={url}
alt="MJPEG Feed"
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 260px)',
objectFit: 'contain',
filter: 'contrast(1.25) saturate(0.5)',
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
{(mediaType === 'image' || mediaType === 'satellite') && (
<img
src={url}
alt="CCTV Feed"
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 260px)',
objectFit: 'contain',
filter: 'contrast(1.25) saturate(0.5)',
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
)}
{/* Media error fallback */}
{mediaError && (
<div style={{ fontSize: 11, color: 'rgba(239,68,68,0.7)', fontFamily: 'monospace', letterSpacing: '0.15em', textAlign: 'center', padding: 40 }}>
FEED UNAVAILABLE<br />
<span style={{ fontSize: 9, color: 'rgba(148,163,184,0.5)' }}>stream failed to load source may be offline</span>
</div>
)}
{/* REC overlay */}
<div
style={{
position: 'absolute',
top: 12,
left: 14,
fontSize: 9,
color: '#22d3ee',
background: 'rgba(0,0,0,0.6)',
padding: '2px 6px',
fontFamily: 'monospace',
letterSpacing: '0.1em',
borderRadius: 2,
}}
>
REC // 00:00:00:00
</div>
{/* Play/Pause overlay for video streams */}
{isVideo && (
<button
onClick={togglePlay}
style={{
position: 'absolute',
bottom: 14,
right: 14,
width: 40,
height: 40,
borderRadius: '50%',
background: 'rgba(0,0,0,0.7)',
border: '1px solid rgba(8,145,178,0.5)',
color: '#22d3ee',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.background = 'rgba(8,51,68,0.8)';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.background = 'rgba(0,0,0,0.7)';
}}
>
{paused ? <Play size={18} /> : <Pause size={18} />}
</button>
)}
</>
) : (
<div
style={{
fontSize: 12,
color: 'rgba(8,145,178,0.4)',
fontFamily: 'monospace',
letterSpacing: '0.2em',
}}
>
NO SIGNAL
</div>
)}
</div>
{/* Location bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
background: 'rgba(8,51,68,0.3)',
borderTop: '1px solid rgba(8,145,178,0.2)',
}}
>
<span
style={{
fontSize: 10,
color: '#22d3ee',
fontFamily: 'monospace',
letterSpacing: '0.15em',
fontWeight: 'bold',
}}
>
{cameraName}
</span>
<div style={{ display: 'flex', gap: 10 }}>
{url && (
<>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
style={{
background: 'rgba(8,145,178,0.2)',
border: '1px solid rgba(8,145,178,0.5)',
borderRadius: 6,
color: '#22d3ee',
fontSize: 10,
fontFamily: 'monospace',
padding: '5px 14px',
cursor: 'pointer',
textDecoration: 'none',
letterSpacing: '0.15em',
fontWeight: 'bold',
}}
>
OPEN SOURCE
</a>
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(url);
} catch { /* ignore */ }
}}
style={{
background: 'rgba(8,145,178,0.15)',
border: '1px solid rgba(8,145,178,0.4)',
borderRadius: 6,
color: '#22d3ee',
fontSize: 10,
fontFamily: 'monospace',
padding: '5px 14px',
cursor: 'pointer',
letterSpacing: '0.15em',
fontWeight: 'bold',
}}
>
COPY URL
</button>
</>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,251 @@
'use client';
import React, { useState } from 'react';
import { Popup } from 'react-map-gl/maplibre';
import { Trash2 } from 'lucide-react';
import { API_BASE } from '@/lib/api';
import type { CorrelationAlert } from '@/types/dashboard';
export interface CorrelationPopupProps {
alert: CorrelationAlert;
onClose: () => void;
}
const TYPE_LABELS: Record<string, { label: string; color: string; border: string }> = {
contradiction: { label: 'POSSIBLE CONTRADICTION', color: 'text-amber-400', border: 'border-amber-500/50' },
rf_anomaly: { label: 'RF ANOMALY', color: 'text-gray-400', border: 'border-gray-500/50' },
military_buildup: { label: 'MILITARY BUILDUP', color: 'text-red-400', border: 'border-red-500/50' },
infra_cascade: { label: 'INFRASTRUCTURE CASCADE', color: 'text-blue-400', border: 'border-blue-500/50' },
analysis_zone: { label: 'OPENCLAW ANALYSIS', color: 'text-cyan-400', border: 'border-cyan-500/50' },
};
const CATEGORY_LABELS: Record<string, { label: string; color: string }> = {
contradiction: { label: 'CONTRADICTION', color: 'text-amber-400' },
analysis: { label: 'ANALYSIS', color: 'text-cyan-400' },
warning: { label: 'WARNING', color: 'text-red-400' },
observation: { label: 'OBSERVATION', color: 'text-blue-400' },
hypothesis: { label: 'HYPOTHESIS', color: 'text-purple-400' },
};
const CONTEXT_COLORS: Record<string, string> = {
STRONG: 'text-red-400',
MODERATE: 'text-amber-400',
WEAK: 'text-yellow-300',
DETECTION_GAP: 'text-gray-400',
};
const SEVERITY_BADGES: Record<string, { bg: string; text: string }> = {
high: { bg: 'bg-red-900/50 border-red-500/40', text: 'text-red-300' },
medium: { bg: 'bg-amber-900/50 border-amber-500/40', text: 'text-amber-300' },
low: { bg: 'bg-gray-800/50 border-gray-500/40', text: 'text-gray-300' },
};
export function CorrelationPopup({ alert, onClose }: CorrelationPopupProps) {
const meta = TYPE_LABELS[alert.type] || TYPE_LABELS.contradiction;
const sevBadge = SEVERITY_BADGES[alert.severity] || SEVERITY_BADGES.low;
const isContradiction = alert.type === 'contradiction';
const isAnalysisZone = alert.type === 'analysis_zone';
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
if (!alert.id) return;
setDeleting(true);
try {
await fetch(`${API_BASE}/api/ai/analysis-zones/${encodeURIComponent(alert.id)}`, {
method: 'DELETE',
credentials: 'include',
});
onClose();
} catch {
setDeleting(false);
}
};
return (
<Popup
longitude={alert.lng}
latitude={alert.lat}
closeButton={false}
closeOnClick={false}
onClose={onClose}
anchor="bottom"
offset={12}
maxWidth="360px"
>
<div className={`map-popup border ${meta.border}`}>
{/* Header */}
<div className="flex justify-between items-start mb-2">
<div>
{isAnalysisZone ? (
<>
<div className={`map-popup-title ${meta.color}`}>
{alert.title || 'OPENCLAW ANALYSIS'}
</div>
{alert.category && (
<div className="text-[11px] font-mono tracking-widest mt-0.5">
<span className={CATEGORY_LABELS[alert.category]?.color || 'text-cyan-400'}>
{CATEGORY_LABELS[alert.category]?.label || alert.category.toUpperCase()}
</span>
</div>
)}
</>
) : (
<div className={`map-popup-title ${meta.color}`}>
!! {meta.label} !!
</div>
)}
</div>
<div className="flex items-center gap-1.5">
<span className={`text-[11px] font-mono tracking-widest px-1.5 py-0.5 rounded border ${sevBadge.bg} ${sevBadge.text}`}>
{isAnalysisZone ? alert.severity?.toUpperCase() : `ALERT LVL ${alert.score}`}
</span>
{isAnalysisZone && alert.id && (
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="p-1 text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded transition disabled:opacity-50"
title="Delete this analysis zone"
>
<Trash2 size={12} />
</button>
)}
</div>
</div>
{/* ── Analysis Zone: Agent report body ── */}
{isAnalysisZone && alert.body && (
<div className="mt-2 pt-2 border-t border-cyan-500/20">
<div className="text-[11px] font-mono tracking-widest text-cyan-500/60 mb-1.5">AGENT ASSESSMENT</div>
<div className="text-[10px] text-cyan-100/90 leading-relaxed whitespace-pre-wrap">
{alert.body}
</div>
</div>
)}
{/* Analysis Zone: Evidence/drivers */}
{isAnalysisZone && alert.drivers && alert.drivers.length > 0 && (
<div className="mt-2 pt-2 border-t border-cyan-500/15">
<div className="text-[11px] font-mono tracking-widest text-cyan-500/50 mb-1.5">KEY INDICATORS</div>
{alert.drivers.map((driver, i) => (
<div key={i} className="text-[10px] text-cyan-200/70 mb-0.5 flex items-start gap-1">
<span className="text-cyan-500">{i + 1}.</span> {driver}
</div>
))}
</div>
)}
{/* Analysis Zone: Source attribution */}
{isAnalysisZone && (
<div className="mt-2 pt-1.5 border-t border-cyan-500/10">
<div className="text-[10px] text-cyan-500/40 text-center">
Placed by OpenClaw agent click trash icon to remove
</div>
</div>
)}
{/* ── Legacy contradiction sections (kept for existing correlation types) ── */}
{/* Context rating for contradictions */}
{isContradiction && alert.context && (
<div className="map-popup-row mb-1">
<span className="text-[#8899aa]">CONFIDENCE: </span>
<span className={`font-bold ${CONTEXT_COLORS[alert.context] || 'text-white'}`}>{alert.context}</span>
</div>
)}
{!isAnalysisZone && alert.location_name && (
<div className="map-popup-row text-[#8899aa] mb-2">
REGION: <span className="text-white">{alert.location_name}</span>
</div>
)}
{/* Section 1: The Statement/Claim */}
{isContradiction && alert.headlines && alert.headlines.length > 0 && (
<div className="mt-2 pt-2 border-t border-amber-500/20">
<div className="text-[11px] font-mono tracking-widest text-amber-500/60 mb-1.5">OFFICIAL STATEMENT</div>
{alert.headlines.map((headline, i) => (
<div key={i} className="text-[10px] text-amber-200/90 leading-relaxed mb-1">
&ldquo;{headline}&rdquo;
</div>
))}
</div>
)}
{/* Section 2: Contradicting Telemetry */}
{isContradiction && alert.nearby_outages && alert.nearby_outages.length > 0 && (
<div className="mt-2 pt-2 border-t border-red-500/20">
<div className="text-[11px] font-mono tracking-widest text-red-400/60 mb-1.5">CONTRADICTING TELEMETRY</div>
{alert.nearby_outages.map((outage, i) => (
<div key={i} className="flex justify-between items-center text-[10px] mb-1 p-1 rounded bg-red-950/30 border border-red-500/20">
<div>
<span className="text-red-300 font-semibold">{outage.region || 'Unknown Region'}</span>
<span className="text-[#8899aa] ml-1">({outage.distance_km}km away)</span>
</div>
<span className="text-red-400 font-bold">{outage.severity}% outage</span>
</div>
))}
</div>
)}
{/* Section 3: Market Signals */}
{isContradiction && alert.related_markets && alert.related_markets.length > 0 && (
<div className="mt-2 pt-2 border-t border-purple-500/20">
<div className="text-[11px] font-mono tracking-widest text-purple-400/60 mb-1.5">PREDICTION MARKET SIGNALS</div>
{alert.related_markets.map((market, i) => (
<div key={i} className="text-[10px] mb-1 p-1 rounded bg-purple-950/30 border border-purple-500/20">
<div className="text-purple-300">{market.title}</div>
<div className="text-purple-400 font-bold mt-0.5">{(market.probability * 100).toFixed(0)}% probability</div>
</div>
))}
</div>
)}
{/* Section 4: All Drivers (non-contradiction, non-analysis types) */}
{!isContradiction && !isAnalysisZone && alert.drivers && alert.drivers.length > 0 && (
<div className="mt-2 pt-2 border-t border-[var(--border-primary)]/30">
<div className="text-[11px] font-mono tracking-widest text-[var(--text-muted)] mb-1.5">CORRELATED INDICATORS</div>
{alert.drivers.map((driver, i) => (
<div key={i} className="text-[10px] text-[var(--text-primary)] mb-0.5 flex items-start gap-1">
<span className={meta.color}>+</span> {driver}
</div>
))}
</div>
)}
{/* Drivers summary for contradictions */}
{isContradiction && alert.drivers && alert.drivers.length > 0 && (
<div className="mt-2 pt-2 border-t border-[var(--border-primary)]/30">
<div className="text-[11px] font-mono tracking-widest text-[var(--text-muted)] mb-1.5">EVIDENCE CHAIN</div>
{alert.drivers.map((driver, i) => (
<div key={i} className="text-[10px] text-[var(--text-primary)]/80 mb-0.5 flex items-start gap-1">
<span className="text-amber-500">{i + 1}.</span> {driver}
</div>
))}
</div>
)}
{/* Section 5: Alternative Explanations */}
{isContradiction && alert.alternatives && alert.alternatives.length > 0 && (
<div className="mt-2 pt-2 border-t border-[var(--border-primary)]/20">
<div className="text-[11px] font-mono tracking-widest text-[var(--text-muted)] mb-1.5">ALTERNATIVE EXPLANATIONS</div>
{alert.alternatives.map((alt, i) => (
<div key={i} className="text-[9px] text-[#8899aa] mb-0.5 flex items-start gap-1">
<span className="text-gray-500">-</span> {alt}
</div>
))}
</div>
)}
{/* Disclaimer */}
{isContradiction && (
<div className="mt-2 pt-1.5 border-t border-[var(--border-primary)]/10">
<div className="text-[10px] text-[#667788] text-center leading-tight">
HYPOTHESIS GENERATOR NOT A VERDICT. This is a signal for further investigation.
</div>
</div>
)}
</div>
</Popup>
);
}
@@ -0,0 +1,161 @@
'use client';
import React from 'react';
import { Popup } from 'react-map-gl/maplibre';
import WikiImage from '@/components/WikiImage';
import type { MilitaryBase } from '@/types/dashboard';
export interface OracleIntel {
found: boolean;
top_headline?: string;
oracle_score?: number;
tier?: string;
avg_sentiment?: number;
nearby_count?: number;
market?: { title: string; consensus_pct: number | null } | null;
}
export interface MilitaryBasePopupProps {
base: MilitaryBase;
oracleIntel: OracleIntel | null;
onClose: () => void;
}
const BRANCH_LABELS: Record<string, string> = {
air_force: 'AIR FORCE',
navy: 'NAVY',
marines: 'MARINES',
army: 'ARMY',
gsdf: 'GSDF',
msdf: 'MSDF',
asdf: 'ASDF',
missile: 'MISSILE FORCES',
nuclear: 'NUCLEAR FACILITY',
};
const COLOR_MAP: Record<string, string> = {
'United States': '#3b82f6',
'Guam': '#3b82f6',
'Hawaii': '#3b82f6',
'BIOT': '#3b82f6',
'China': '#ef4444',
'Japan': '#e5e7eb',
'North Korea': '#92400e',
'Russia': '#9ca3af',
'Iran': '#f97316',
'Taiwan': '#22c55e',
'Philippines': '#eab308',
'Australia': '#14b8a6',
'South Korea': '#a855f7',
'United Kingdom': '#6366f1',
};
export function MilitaryBasePopup({ base, oracleIntel, onClose }: MilitaryBasePopupProps) {
const accent = COLOR_MAP[base.country] || '#ec4899';
const wikiSlug = encodeURIComponent(base.name.replace(/ /g, '_'));
const wikiUrl = `https://en.wikipedia.org/wiki/${wikiSlug}`;
return (
<Popup
longitude={base.lng}
latitude={base.lat}
closeButton={false}
closeOnClick={false}
onClose={onClose}
className="threat-popup"
maxWidth="340px"
>
<div
className="map-popup bg-[#1a1035] min-w-[220px]"
style={{ borderColor: `${accent}66`, color: accent }}
>
<div className="flex justify-between items-start">
<div
className="map-popup-title pb-1 flex-1"
style={{ color: accent, borderBottom: `1px solid ${accent}33` }}
>
{base.name}
</div>
<button
onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2 shrink-0"
>
</button>
</div>
<div className="map-popup-row">
Operator:{' '}
<a
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(base.operator.replace(/ /g, '_'))}`}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 underline"
>
{base.operator}
</a>
</div>
<div className="map-popup-row">
Country: <span className="text-white">{base.country}</span>
</div>
{/* Wikipedia image + link — same style as tracked aircraft */}
<div className="border-b border-[var(--border-primary)] pb-2 mt-2">
<WikiImage
wikiUrl={wikiUrl}
label={base.name}
maxH="max-h-36"
accent={`hover:border-[${accent}]`}
/>
</div>
<div className="mt-1.5 text-[12px] tracking-wider" style={{ color: `${accent}99` }}>
MILITARY BASE {BRANCH_LABELS[base.branch] || base.branch.toUpperCase()}
</div>
{oracleIntel?.found && (
<div className="mt-2 pt-2 border-t border-cyan-500/20">
<div className="text-[11px] font-mono text-cyan-400 tracking-wider mb-1">
ORACLE INTEL
</div>
<div className="text-[11px] font-mono text-cyan-300/80">
<span
className={
oracleIntel.tier === 'CRITICAL'
? 'text-red-400'
: oracleIntel.tier === 'ELEVATED'
? 'text-yellow-400'
: 'text-green-400'
}
>
{oracleIntel.tier}
</span>
{' // '}
<span
className={
oracleIntel.avg_sentiment != null && oracleIntel.avg_sentiment < -0.05
? 'text-red-400'
: 'text-gray-400'
}
>
{oracleIntel.avg_sentiment != null
? `${oracleIntel.avg_sentiment > 0 ? '+' : ''}${oracleIntel.avg_sentiment.toFixed(2)} SENT`
: ''}
</span>
{oracleIntel.market && (
<span className="text-purple-400">
{' '}
// {oracleIntel.market.consensus_pct}%
</span>
)}
</div>
{oracleIntel.top_headline && (
<div className="text-[10px] text-white/60 mt-0.5 truncate">
{oracleIntel.top_headline}
</div>
)}
</div>
)}
</div>
</Popup>
);
}
@@ -0,0 +1,325 @@
'use client';
import React, { useState } from 'react';
import ExternalImage from '@/components/ExternalImage';
export interface Sentinel2Data {
found: boolean;
fullres_url?: string;
thumbnail_url?: string;
platform?: string;
datetime?: string;
cloud_cover?: number;
fallback?: boolean;
scenes?: Sentinel2Data[];
}
export interface RegionDossierPanelProps {
sentinel2: Sentinel2Data;
lat: number;
lng: number;
onClose: () => void;
}
const NAV_BTN: React.CSSProperties = {
background: 'rgba(34,197,94,0.2)',
border: '1px solid rgba(34,197,94,0.5)',
borderRadius: 6,
color: '#4ade80',
fontSize: 12,
fontFamily: 'monospace',
padding: '6px 14px',
cursor: 'pointer',
letterSpacing: '0.1em',
fontWeight: 'bold',
};
const NAV_BTN_DISABLED: React.CSSProperties = {
...NAV_BTN,
opacity: 0.3,
cursor: 'default',
};
const ACTION_BTN: React.CSSProperties = {
background: 'rgba(34,197,94,0.2)',
border: '1px solid rgba(34,197,94,0.5)',
borderRadius: 6,
color: '#4ade80',
fontSize: 11,
fontFamily: 'monospace',
padding: '6px 16px',
cursor: 'pointer',
textDecoration: 'none',
letterSpacing: '0.15em',
fontWeight: 'bold',
};
export function RegionDossierPanel({ sentinel2: s2, lat, lng, onClose }: RegionDossierPanelProps) {
const scenes = s2.scenes?.length ? s2.scenes : [s2];
const [idx, setIdx] = useState(0);
const scene = scenes[idx] || s2;
const imgUrl = scene.fullres_url || scene.thumbnail_url;
const hasMultiple = scenes.length > 1;
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
background: 'rgba(0,0,0,0.85)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '80px 40px 80px 40px',
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') onClose();
if (hasMultiple && e.key === 'ArrowLeft' && idx > 0) setIdx(idx - 1);
if (hasMultiple && e.key === 'ArrowRight' && idx < scenes.length - 1) setIdx(idx + 1);
}}
tabIndex={-1}
ref={(el) => el?.focus()}
>
<div
style={{
background: 'rgba(0,0,0,0.95)',
border: '1px solid rgba(34,197,94,0.5)',
borderRadius: 12,
overflow: 'hidden',
maxWidth: 'calc(100vw - 120px)',
maxHeight: 'calc(100vh - 160px)',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 0 60px rgba(34,197,94,0.3)',
}}
>
{/* Header bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
background: 'rgba(20,83,45,0.4)',
borderBottom: '1px solid rgba(34,197,94,0.3)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: '#4ade80',
animation: 'pulse 2s infinite',
}}
/>
<span
style={{
fontSize: 12,
color: '#4ade80',
fontFamily: 'monospace',
letterSpacing: '0.2em',
fontWeight: 'bold',
}}
>
SENTINEL-2 IMAGERY
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span
style={{
fontSize: 11,
color: 'rgba(134,239,172,0.6)',
fontFamily: 'monospace',
}}
>
{lat.toFixed(4)}, {lng.toFixed(4)}
</span>
<button
onClick={onClose}
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.4)',
borderRadius: 6,
color: '#ef4444',
fontSize: 11,
fontFamily: 'monospace',
padding: '4px 10px',
cursor: 'pointer',
letterSpacing: '0.1em',
}}
>
CLOSE
</button>
</div>
</div>
{scene.found ? (
<>
{/* Metadata row with scene navigation */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
fontSize: 12,
fontFamily: 'monospace',
borderBottom: '1px solid rgba(20,83,45,0.4)',
}}
>
<span style={{ color: '#86efac' }}>{scene.platform}</span>
{hasMultiple ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
onClick={() => idx > 0 && setIdx(idx - 1)}
disabled={idx === 0}
style={idx === 0 ? NAV_BTN_DISABLED : NAV_BTN}
>
PREV
</button>
<span style={{ color: '#4ade80', fontWeight: 'bold', minWidth: 120, textAlign: 'center' }}>
{scene.datetime?.slice(0, 10) || 'UNKNOWN DATE'}
</span>
<button
onClick={() => idx < scenes.length - 1 && setIdx(idx + 1)}
disabled={idx === scenes.length - 1}
style={idx === scenes.length - 1 ? NAV_BTN_DISABLED : NAV_BTN}
>
NEXT
</button>
<span style={{ color: 'rgba(134,239,172,0.5)', fontSize: 10 }}>
{idx + 1}/{scenes.length}
</span>
</div>
) : (
<span style={{ color: '#4ade80', fontWeight: 'bold' }}>
{scene.datetime?.slice(0, 10) ||
(scene.fallback ? 'DATE UNAVAILABLE' : 'UNKNOWN DATE')}
</span>
)}
<span style={{ color: '#86efac' }}>
{scene.cloud_cover != null
? `${scene.cloud_cover?.toFixed(0)}% cloud`
: scene.fallback
? 'fallback imagery'
: 'cloud unknown'}
</span>
</div>
{/* Image */}
{imgUrl ? (
<div
style={{
flex: 1,
overflow: 'auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: 400,
}}
>
<ExternalImage
src={imgUrl}
alt="Sentinel-2 scene"
width={1024}
height={1024}
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 260px)',
objectFit: 'contain',
display: 'block',
}}
/>
</div>
) : (
<div
style={{
padding: '40px 16px',
fontSize: 12,
color: 'rgba(134,239,172,0.5)',
fontFamily: 'monospace',
textAlign: 'center',
}}
>
Scene found no preview available
</div>
)}
{/* Action buttons */}
{imgUrl && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
padding: '10px 16px',
background: 'rgba(20,83,45,0.3)',
borderTop: '1px solid rgba(34,197,94,0.2)',
}}
>
<a
href={imgUrl}
download={`sentinel2_${lat.toFixed(4)}_${lng.toFixed(4)}_${scene.datetime?.slice(0, 10) || 'unknown'}.jpg`}
target="_blank"
rel="noopener noreferrer"
style={ACTION_BTN}
>
DOWNLOAD
</a>
<button
onClick={async () => {
try {
const resp = await fetch(imgUrl);
const blob = await resp.blob();
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob }),
]);
} catch {
await navigator.clipboard.writeText(imgUrl);
}
}}
style={{ ...ACTION_BTN, background: 'rgba(34,197,94,0.15)', borderColor: 'rgba(34,197,94,0.4)' }}
>
📋 COPY
</button>
<a
href={imgUrl}
target="_blank"
rel="noopener noreferrer"
style={{ ...ACTION_BTN, color: '#10b981', background: 'rgba(16,185,129,0.15)', borderColor: 'rgba(16,185,129,0.4)' }}
>
OPEN FULL RES
</a>
</div>
)}
</>
) : (
<div
style={{
padding: '40px 16px',
fontSize: 12,
color: 'rgba(134,239,172,0.5)',
fontFamily: 'monospace',
textAlign: 'center',
}}
>
No clear imagery in last 30 days
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,126 @@
'use client';
import React from 'react';
import { Popup } from 'react-map-gl/maplibre';
import WikiImage from '@/components/WikiImage';
import type { Satellite, SatManeuverAlert } from '@/types/dashboard';
export interface SatellitePopupProps {
sat: Satellite;
maneuverAlert?: SatManeuverAlert;
onClose: () => void;
}
const MISSION_LABELS: Record<string, string> = {
military_recon: '🔴 MILITARY RECON',
military_sar: '🔴 MILITARY SAR',
military_comms: '🔴 MILITARY COMMS',
sar: '🔷 SAR IMAGING',
sigint: '🟠 SIGINT / ELINT',
navigation: '🔵 NAVIGATION',
early_warning: '🟣 EARLY WARNING',
commercial_imaging: '🟢 COMMERCIAL IMAGING',
space_station: '🏠 SPACE STATION',
starlink: '🌐 STARLINK',
constellation: '🌐 CONSTELLATION',
communication: '📡 COMMUNICATION',
};
export function SatellitePopup({ sat, maneuverAlert, onClose }: SatellitePopupProps) {
const isISS = sat.mission === 'space_station' && sat.name?.includes('ISS');
return (
<Popup
longitude={sat.lng}
latitude={sat.lat}
closeButton={false}
closeOnClick={false}
onClose={onClose}
anchor="bottom"
offset={isISS ? 20 : 12}
maxWidth={isISS ? '320px' : '260px'}
>
<div className={`map-popup ${isISS ? 'border border-yellow-500/50' : 'border border-cyan-500/30'}`}>
<div className="flex justify-between items-start">
<div className={`map-popup-title ${isISS ? 'text-[#ffdd00]' : 'text-[#00c8ff]'}`}>
🛰 {sat.name}
</div>
{isISS && (
<span className="text-[11px] font-mono tracking-widest text-yellow-500/80 border border-yellow-500/30 px-1 rounded">LIVE</span>
)}
</div>
<div className="map-popup-row text-[#8899aa]">
NORAD ID: <span className="text-white">{sat.id}</span>
</div>
{sat.sat_type && (
<div className="map-popup-row">
Type: <span className="text-[#ffcc00]">{sat.sat_type}</span>
</div>
)}
{sat.country && (
<div className="map-popup-row">
Country: <span className="text-white">{sat.country}</span>
</div>
)}
{sat.mission && (
<div className="map-popup-row font-semibold">
{MISSION_LABELS[sat.mission] || `${sat.mission.toUpperCase()}`}
</div>
)}
<div className="map-popup-row">
Altitude:{' '}
<span className="text-[#44ff88]">{sat.alt_km?.toLocaleString()} km</span>
</div>
{maneuverAlert && (
<div className="mt-1.5 p-1.5 rounded bg-red-900/30 border border-red-500/40">
<div className="text-[11px] font-mono tracking-widest text-red-400 mb-0.5">MANEUVER DETECTED</div>
{maneuverAlert.reasons.map((r, i) => (
<div key={i} className="text-[9px] text-red-300/80 font-mono">{r}</div>
))}
</div>
)}
{isISS && (
<div className="map-popup-row text-[#8899aa]">
Speed: <span className="text-white">{sat.speed_knots ? `${Math.round(sat.speed_knots * 1.852).toLocaleString()} km/h` : '~28,000 km/h'}</span>
</div>
)}
{isISS && (
<div className="mt-2 pt-2 border-t border-yellow-500/20">
<div className="text-[11px] font-mono tracking-widest text-yellow-500/60 mb-1.5">NASA EHDC LIVE FEED</div>
<div className="relative w-full rounded overflow-hidden bg-black/60" style={{ paddingBottom: '56.25%' }}>
<iframe
src="https://video.ibm.com/embed/17074538?autoplay=0&html5ui"
className="absolute inset-0 w-full h-full"
allow="autoplay"
allowFullScreen
style={{ border: 'none' }}
/>
</div>
<div className="text-[10px] text-[#8899aa] mt-1 text-center">
Earth view from ISS external cameras Dark = nightside pass
</div>
</div>
)}
{sat.wiki && !isISS && (
<div className="mt-2 border-t border-[var(--border-primary)]/50 pt-2">
<WikiImage
wikiUrl={sat.wiki}
label={sat.sat_type || sat.name}
maxH="max-h-28"
accent="hover:border-cyan-500/50"
/>
</div>
)}
{isISS && sat.wiki && (
<div className="mt-1.5">
<a href={sat.wiki} target="_blank" rel="noopener noreferrer"
className="block text-center px-2 py-1 rounded bg-yellow-900/30 border border-yellow-500/20
hover:bg-yellow-800/40 hover:border-yellow-400/40 text-yellow-300 text-[9px] font-mono tracking-widest">
WIKIPEDIA
</a>
</div>
)}
</div>
</Popup>
);
}
@@ -0,0 +1,187 @@
'use client';
import React from 'react';
import { Popup } from 'react-map-gl/maplibre';
import type { Ship } from '@/types/dashboard';
export interface ShipPopupProps {
ship: Ship;
longitude: number;
latitude: number;
onClose: () => void;
}
export function ShipPopup({ ship, longitude, latitude, onClose }: ShipPopupProps) {
return (
<Popup
longitude={longitude}
latitude={latitude}
closeButton={false}
closeOnClick={false}
onClose={onClose}
anchor="bottom"
offset={12}
>
<div
className="map-popup"
style={{
borderWidth: 1,
borderStyle: 'solid',
borderColor: ship.yacht_alert
? 'rgba(255,105,180,0.5)'
: ship.type === 'carrier'
? 'rgba(255,170,0,0.5)'
: 'rgba(59,130,246,0.4)',
}}
>
<div className="flex justify-between items-start mb-1">
<div
className="map-popup-title"
style={{
color: ship.yacht_alert
? '#FF69B4'
: ship.type === 'carrier'
? '#ffaa00'
: '#3b82f6',
}}
>
{ship.name || 'UNKNOWN VESSEL'}
</div>
<button
onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2"
>
</button>
</div>
{ship.estimated && (
<div className="map-popup-subtitle text-[#ff6644] border-b border-[#ff664450] pb-1">
ESTIMATED POSITION {ship.source || 'OSINT DERIVED'}
</div>
)}
{ship.type && (
<div className="map-popup-row">
Type:{' '}
<span className="text-white capitalize">{ship.type.replace('_', ' ')}</span>
</div>
)}
{ship.mmsi && (
<div className="map-popup-row">
MMSI: <span className="text-[#888]">{ship.mmsi}</span>
</div>
)}
{ship.imo && (
<div className="map-popup-row">
IMO: <span className="text-[#888]">{ship.imo}</span>
</div>
)}
{ship.callsign && (
<div className="map-popup-row">
Callsign: <span className="text-[#00e5ff]">{ship.callsign}</span>
</div>
)}
{ship.country && (
<div className="map-popup-row">
Flag: <span className="text-white">{ship.country}</span>
</div>
)}
{ship.destination && (
<div className="map-popup-row">
Destination: <span className="text-[#44ff88]">{ship.destination}</span>
</div>
)}
{typeof ship.sog === 'number' && ship.sog > 0 && (
<div className="map-popup-row">
Speed: <span className="text-[#00e5ff]">{ship.sog.toFixed(1)} kn</span>
</div>
)}
<div className="map-popup-row">
Heading:{' '}
<span style={{ color: ship.heading != null ? '#888' : '#ff6644' }}>
{ship.heading != null ? `${Math.round(ship.heading)}°` : 'UNKNOWN'}
</span>
</div>
{ship.type === 'carrier' && ship.source && (
<div className="mt-1.5 p-[5px_7px] bg-[rgba(255,170,0,0.08)] border border-[rgba(255,170,0,0.3)] rounded text-[9px] tracking-wide">
<div className="text-[#ffaa00] mb-0.5">
SOURCE:{' '}
{ship.source_url ? (
<a
href={ship.source_url}
target="_blank"
rel="noopener noreferrer"
className="text-[#00e5ff] underline"
>
{ship.source}
</a>
) : (
<span className="text-white">{ship.source}</span>
)}
</div>
{ship.last_osint_update && (
<div className="text-[#888]">
LAST OSINT UPDATE:{' '}
{new Date(ship.last_osint_update).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</div>
)}
{ship.desc && (
<div className="text-[#aaa] mt-0.5 text-[11px] leading-tight">
{ship.desc}
</div>
)}
</div>
)}
{ship.type !== 'carrier' && ship.last_osint_update && (
<div className="map-popup-row">
Last OSINT Update:{' '}
<span className="text-[#888]">
{new Date(ship.last_osint_update).toLocaleDateString()}
</span>
</div>
)}
{ship.yacht_alert && (
<div className="mt-1.5 p-[5px_7px] bg-[rgba(255,105,180,0.08)] border border-[rgba(255,105,180,0.3)] rounded text-[9px] tracking-wide">
<div className="text-[#FF69B4] font-bold mb-0.5">TRACKED YACHT</div>
<div>
Owner: <span className="text-white">{ship.yacht_owner}</span>
</div>
{ship.yacht_builder && (
<div>
Builder: <span className="text-[#888]">{ship.yacht_builder}</span>
</div>
)}
{(ship.yacht_length ?? 0) > 0 && (
<div>
Length: <span className="text-[#888]">{ship.yacht_length}m</span>
</div>
)}
{(ship.yacht_year ?? 0) > 0 && (
<div>
Year: <span className="text-[#888]">{ship.yacht_year}</span>
</div>
)}
{ship.yacht_category && (
<div>
Category: <span className="text-[#FF69B4]">{ship.yacht_category}</span>
</div>
)}
{ship.yacht_link && (
<a
href={ship.yacht_link}
target="_blank"
rel="noopener noreferrer"
className="text-[#00e5ff] underline"
>
Wikipedia
</a>
)}
</div>
)}
</div>
</Popup>
);
}
@@ -0,0 +1,310 @@
'use client';
import React from 'react';
import { Popup } from 'react-map-gl/maplibre';
import { AlertTriangle, Radio, Play } from 'lucide-react';
import { SigintSendForm, MeshtasticChannelFeed } from '@/components/map/panels/SigintPanels';
import type { KiwiSDR, SigintSignal } from '@/types/dashboard';
type GeoExtras = {
lat?: number;
lng?: number;
lon?: number;
geometry?: { coordinates?: [number, number] };
};
export type SigintData = Partial<SigintSignal> & GeoExtras;
export interface SigintPopupProps {
data: SigintData;
lat: number;
lng: number;
kiwisdrs: KiwiSDR[];
setTrackedSdr?: (sdr: {
lat: number;
lon: number;
name: string;
url?: string;
users?: number;
users_max?: number;
bands?: string;
antenna?: string;
location?: string;
}) => void;
onClose: () => void;
}
const SOURCE_COLORS: Record<string, string> = {
aprs: '#ff69b4',
meshtastic: '#22c55e',
js8call: '#ff69b4',
};
const SOURCE_LABELS: Record<string, string> = {
aprs: 'APRS-IS',
meshtastic: 'MESHTASTIC',
js8call: 'JS8CALL',
};
function computePosAge(d: SigintData): string | null {
const ts = d.position_updated_at || d.timestamp;
if (!ts) return null;
try {
const then = new Date(ts).getTime();
const diffMs = Date.now() - then;
if (diffMs < 0 || isNaN(diffMs)) return null;
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
} catch {
return null;
}
}
function findNearestSdr(
src: string,
lat: number,
lng: number,
sdrs: KiwiSDR[],
): KiwiSDR | null {
if (src === 'meshtastic') return null;
if (!sdrs || !sdrs.length) return null;
let best: KiwiSDR | null = null;
let bestDist = Infinity;
for (const sdr of sdrs) {
const slat = sdr.lat;
const slng = sdr.lon;
if (slat == null || slng == null || !sdr.url) continue;
const dist = Math.sqrt((lat - slat) ** 2 + (lng - slng) ** 2);
if (dist < bestDist) {
bestDist = dist;
best = sdr;
}
}
return best;
}
export function SigintPopup({ data: d, lat, lng, kiwisdrs, setTrackedSdr, onClose }: SigintPopupProps) {
const src = d.source || 'unknown';
const isEmergency = d.emergency === true;
const color = isEmergency ? '#ef4444' : SOURCE_COLORS[src] || '#94a3b8';
const stationType = d.station_type || 'Station';
const status = d.status || d.comment || '';
const isApiNode = d.from_api === true;
const posAge = computePosAge(d);
const nearestSdr = findNearestSdr(src, lat, lng, kiwisdrs);
return (
<Popup
longitude={lng}
latitude={lat}
closeButton={false}
closeOnClick={false}
onClose={onClose}
anchor="bottom"
offset={12}
>
<div
className="map-popup"
style={{ borderWidth: 1, borderStyle: 'solid', borderColor: `${color}66` }}
>
<div className="flex justify-between items-start mb-1">
<div className="map-popup-title" style={{ color }}>
{isEmergency && (
<AlertTriangle
size={12}
className="inline mr-1 animate-pulse"
style={{ color: '#ef4444' }}
/>
)}
{(d.callsign || 'UNKNOWN').toUpperCase()}
</div>
<button
onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2"
>
</button>
</div>
<div
className="map-popup-subtitle border-b pb-1 flex items-center gap-1.5 flex-wrap"
style={{ color: `${color}99`, borderColor: `${color}30` }}
>
<Radio size={10} />
<span
className="font-mono text-[12px] px-1.5 py-0.5 rounded"
style={{ backgroundColor: `${color}20`, color }}
>
{SOURCE_LABELS[src] || src.toUpperCase()}
</span>
<span className="text-[var(--text-muted)]">{stationType}</span>
{isEmergency && (
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-red-900/60 text-red-400 animate-pulse tracking-wider">
EMERGENCY
</span>
)}
{src === 'meshtastic' && d.channel && (
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-green-900/50 text-green-300 border border-green-500/30">
{d.channel}
</span>
)}
{src === 'meshtastic' && d.region && (
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-slate-800/60 text-slate-300 border border-slate-500/30">
{d.region}
</span>
)}
{isApiNode && (
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 border border-blue-500/30">
MAP API
</span>
)}
</div>
{/* Long name + hardware (API nodes) */}
{src === 'meshtastic' && (d.long_name || d.hardware) && (
<div className="map-popup-row mt-0.5 flex items-center gap-1.5 flex-wrap">
{d.long_name && <span className="text-[13px] text-white">{d.long_name}</span>}
{d.hardware && (
<span className="text-[11px] text-slate-400">({d.hardware})</span>
)}
{d.role && d.role !== 'CLIENT' && (
<span className="font-mono text-[11px] px-1 py-0.5 rounded bg-amber-900/40 text-amber-300 border border-amber-500/30">
{d.role}
</span>
)}
</div>
)}
{/* Position age */}
{posAge && (
<div className="map-popup-row mt-0.5">
<span className="text-[12px] text-[var(--text-muted)]">
Last heard: <span className="text-slate-300">{posAge}</span>
</span>
</div>
)}
{/* Status */}
{status && (
<div className="map-popup-row mt-1">
<span
className={`text-[13px] ${isEmergency ? 'text-red-300 font-bold' : 'text-white'}`}
>
{status}
</span>
</div>
)}
{/* Key telemetry */}
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 mt-1">
{d.frequency && (
<div className="map-popup-row">
Freq: <span className="text-cyan-400">{d.frequency}</span>
</div>
)}
{(d.altitude_ft ?? 0) > 0 && (
<div className="map-popup-row">
Alt:{' '}
<span className="text-white">
{Number(d.altitude_ft).toLocaleString()} ft
</span>
</div>
)}
{(d.speed_knots ?? 0) > 0 && (
<div className="map-popup-row">
Speed:{' '}
<span className="text-white">
{d.speed_knots} kts / {d.course || 0}°
</span>
</div>
)}
{(d.power_watts ?? 0) > 0 && (
<div className="map-popup-row">
TX Power: <span className="text-amber-400">{d.power_watts}W</span>
</div>
)}
{(d.battery_v ?? 0) > 0 && (
<div className="map-popup-row">
Battery: <span className="text-white">{d.battery_v}V</span>
</div>
)}
{!d.battery_v && d.battery_level != null && d.battery_level <= 100 && (
<div className="map-popup-row">
Battery: <span className="text-white">{d.battery_level}%</span>
</div>
)}
{d.snr != null && (
<div className="map-popup-row">
SNR: <span className="text-white">{d.snr} dB</span>
</div>
)}
</div>
{/* Action buttons: Tune In via nearest KiwiSDR */}
<div className="flex items-center gap-2 mt-2 pt-1.5 border-t border-[var(--border-primary)]/30">
{nearestSdr?.url && (
<button
onClick={(e) => {
e.stopPropagation();
if (setTrackedSdr) {
setTrackedSdr({
lat: nearestSdr.lat,
lon: nearestSdr.lon,
name: nearestSdr.name,
url: nearestSdr.url,
users: nearestSdr.users,
users_max: nearestSdr.users_max,
bands: nearestSdr.bands,
antenna: nearestSdr.antenna,
location: nearestSdr.location,
});
}
onClose();
}}
className="flex-1 text-center px-2 py-1.5 rounded bg-cyan-950/40 border border-cyan-500/30 hover:bg-cyan-900/60 hover:border-cyan-400 text-cyan-400 text-[12px] font-mono tracking-widest transition-colors flex justify-center items-center gap-1.5"
title={`Listen via ${nearestSdr.name}`}
>
<Play size={10} className="fill-cyan-400/20" /> TUNE IN
</button>
)}
<span className="text-[#666] text-[12px]">
{Number(lat).toFixed(4)}, {Number(lng).toFixed(4)}
</span>
</div>
{nearestSdr && (
<div className="text-[11px] text-[#555] mt-0.5">
via {nearestSdr.name} ({nearestSdr.location || 'SDR'})
</div>
)}
{/* Meshtastic channel feed */}
{src === 'meshtastic' && d.region && (
<MeshtasticChannelFeed region={d.region} channel={d.channel || 'LongFast'} />
)}
{/* Send Message */}
{src === 'meshtastic' && (
<SigintSendForm
destination={
typeof d.callsign === 'string' && /^![0-9a-f]{8}$/i.test(d.callsign)
? d.callsign
: d.channel || 'LongFast'
}
source={src}
region={d.region}
channel={d.channel || 'LongFast'}
/>
)}
{src === 'aprs' && (
<div className="mt-2 pt-1.5 border-t border-[var(--border-primary)]/30 text-[11px] text-[#555] italic">
APRS is receive-only transmitting requires a ham radio license
</div>
)}
</div>
</Popup>
);
}
@@ -0,0 +1,105 @@
'use client';
import React from 'react';
import { Popup } from 'react-map-gl/maplibre';
import type { WastewaterPlant } from '@/types/dashboard';
export interface WastewaterPopupProps {
plant: WastewaterPlant;
onClose: () => void;
}
const ACTIVITY_COLORS: Record<string, string> = {
'very high': 'text-red-400',
high: 'text-red-400',
'above normal': 'text-amber-400',
normal: 'text-green-400',
'below normal': 'text-blue-400',
low: 'text-blue-300',
'not calculated': 'text-gray-500',
};
export function WastewaterPopup({ plant, onClose }: WastewaterPopupProps) {
const hasAlerts = plant.alert_count > 0;
const borderColor = hasAlerts ? 'border-red-500/50' : 'border-cyan-500/40';
return (
<Popup
longitude={plant.lng}
latitude={plant.lat}
closeButton={false}
closeOnClick={false}
onClose={onClose}
anchor="bottom"
offset={12}
maxWidth="320px"
>
<div className={`map-popup border ${borderColor}`}>
{/* Header */}
<div className="flex justify-between items-start mb-2">
<div className={`map-popup-title ${hasAlerts ? 'text-red-400' : 'text-cyan-400'}`}>
{hasAlerts ? '!! PATHOGEN ALERT !!' : 'WASTEWATER MONITOR'}
</div>
{plant.alert_count > 0 && (
<span className="text-[11px] font-mono tracking-widest px-1.5 py-0.5 rounded border bg-red-900/50 border-red-500/40 text-red-300">
{plant.alert_count} ALERT{plant.alert_count > 1 ? 'S' : ''}
</span>
)}
</div>
{/* Site info */}
<div className="map-popup-row text-[#8899aa] mb-1">
SITE: <span className="text-white">{plant.name || plant.site_name}</span>
</div>
{plant.city && (
<div className="map-popup-row text-[#8899aa] mb-1">
LOCATION: <span className="text-white">{plant.city}, {plant.state}</span>
</div>
)}
{plant.population && (
<div className="map-popup-row text-[#8899aa] mb-1">
POP SERVED: <span className="text-white">{plant.population.toLocaleString()}</span>
</div>
)}
{plant.collection_date && (
<div className="map-popup-row text-[#8899aa] mb-2">
SAMPLED: <span className="text-white">{plant.collection_date}</span>
</div>
)}
{/* Pathogen levels */}
{plant.pathogens && plant.pathogens.length > 0 ? (
<div className="mt-2 pt-2 border-t border-cyan-500/20">
<div className="text-[11px] font-mono tracking-widest text-cyan-400/60 mb-1.5">PATHOGEN DETECTIONS</div>
{plant.pathogens.map((p, i) => (
<div
key={i}
className={`flex justify-between items-center text-[10px] mb-1 p-1 rounded border ${
p.alert ? 'bg-red-950/30 border-red-500/20' : 'bg-gray-900/30 border-gray-700/20'
}`}
>
<span className={p.alert ? 'text-red-300 font-semibold' : 'text-gray-300'}>
{p.name}
</span>
<span className={`font-mono ${ACTIVITY_COLORS[p.activity.toLowerCase()] || 'text-gray-400'}`}>
{p.activity.toUpperCase()}
</span>
</div>
))}
</div>
) : (
<div className="mt-2 pt-2 border-t border-gray-600/20">
<div className="text-[9px] text-gray-500 text-center">No recent pathogen data available</div>
</div>
)}
{/* Source attribution */}
<div className="mt-2 pt-1.5 border-t border-[var(--border-primary)]/10">
<div className="text-[10px] text-[#667788] text-center leading-tight">
SOURCE: WastewaterSCAN (Stanford / Emory)
</div>
</div>
</div>
</Popup>
);
}
+5 -5
View File
@@ -160,7 +160,7 @@ function CongressTab({ trades }: { trades: CongressTrade[] }) {
</div>
</div>
{t.asset_name && t.asset_name !== t.ticker && (
<div className="text-[8px] text-[var(--text-muted)]/70 truncate mt-0.5">{t.asset_name}</div>
<div className="text-[11px] text-[var(--text-muted)]/70 truncate mt-0.5">{t.asset_name}</div>
)}
</div>
))}
@@ -200,7 +200,7 @@ function InsiderTab({ transactions }: { transactions: InsiderTransaction[] }) {
</div>
</div>
{t.filing_date && (
<div className="text-[8px] text-[var(--text-muted)]/70 mt-0.5">{t.filing_date}</div>
<div className="text-[11px] text-[var(--text-muted)]/70 mt-0.5">{t.filing_date}</div>
)}
</div>
);
@@ -281,7 +281,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data, focused, onFocusCh
GLOBAL MARKETS
</span>
{hasFinnhub && (
<span className="text-[8px] text-green-500 bg-green-900/30 px-1 rounded">FINNHUB</span>
<span className="text-[11px] text-green-500 bg-green-900/30 px-1 rounded">FINNHUB</span>
)}
</div>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
@@ -340,7 +340,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data, focused, onFocusCh
{/* Attribution */}
<div className="px-3 pb-2">
<p className="text-[8px] text-[var(--text-muted)]/60 text-center">
<p className="text-[11px] text-[var(--text-muted)]/60 text-center">
Data from Finnhub
</p>
</div>
@@ -363,7 +363,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data, focused, onFocusCh
href="https://finnhub.io/register"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[8px] text-cyan-400 hover:text-cyan-300 transition-colors"
className="flex items-center gap-1 text-[11px] text-cyan-400 hover:text-cyan-300 transition-colors"
>
Free API Key <ExternalLink size={8} />
</a>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
import React from 'react';
export function RepBadge({ rep }: { rep: number }) {
const color =
rep >= 50
? 'text-yellow-400'
: rep >= 10
? 'text-cyan-400'
: rep > 0
? 'text-cyan-600'
: rep < 0
? 'text-red-400'
: 'text-gray-600';
return (
<span className={`text-[13px] font-mono font-bold ${color} shrink-0`}>
{rep >= 0 ? '+' : ''}
{rep}
</span>
);
}
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
import {
loadIdentityBoundSensitiveValue,
persistIdentityBoundSensitiveValue,
} from '@/lib/identityBoundSensitiveStorage';
import {
decryptSenderSealPayloadLocally,
getNodeIdentity,
unwrapSenderSealPayload,
verifyNodeIdBindingFromPublicKey,
verifyRawSignature,
} from '@/mesh/meshIdentity';
import type { Contact } from '@/mesh/meshIdentity';
import {
isWormholeReady,
openWormholeSenderSeal,
} from '@/mesh/wormholeIdentityClient';
import {
recoverSenderSealWithFallback,
} from '@/mesh/requestSenderRecovery';
import { allDmPeerIds, mergeAliasHistory } from '@/mesh/meshDmConsent';
import type { AccessRequest } from './types';
// ─── Local storage keys ─────────────────────────────────────────────────────
const ACCESS_REQUESTS_KEY = 'sb_dm_access_requests';
const PENDING_SENT_KEY = 'sb_dm_pending_sent';
const MUTED_KEY = 'sb_mesh_muted';
const GEO_HINT_KEY = 'sb_dm_geo_hint';
const ACCESS_REQ_WRAP_INFO = 'SB-ACCESS-REQUESTS-STORAGE-V1';
const PENDING_WRAP_INFO = 'SB-PENDING-CONTACTS-STORAGE-V1';
const MUTED_WRAP_INFO = 'SB-MUTED-LIST-V1';
export const DECOY_KEY = 'sb_dm_decoy';
// ─── Scoped state helpers ───────────────────────────────────────────────────
export function scopedDmStateKey(base: string, nodeId?: string): string {
const resolved = String(nodeId || getNodeIdentity()?.nodeId || 'global').trim() || 'global';
return `${base}:${resolved}`;
}
export async function getAccessRequests(nodeId?: string): Promise<AccessRequest[]> {
const storageKey = scopedDmStateKey(ACCESS_REQUESTS_KEY, nodeId);
try {
const requests = await loadIdentityBoundSensitiveValue<AccessRequest[]>(
storageKey,
ACCESS_REQ_WRAP_INFO,
[],
);
const normalized = Array.isArray(requests) ? requests : [];
return normalized;
} catch (error) {
console.warn('[mesh] failed to read encrypted access requests', error);
return [];
}
}
export function setAccessRequests(reqs: AccessRequest[], nodeId?: string) {
const storageKey = scopedDmStateKey(ACCESS_REQUESTS_KEY, nodeId);
void (async () => {
try {
await persistIdentityBoundSensitiveValue(storageKey, ACCESS_REQ_WRAP_INFO, reqs);
} catch (error) {
console.warn('[mesh] failed to persist encrypted access requests', error);
}
})();
}
export async function getPendingSent(nodeId?: string): Promise<string[]> {
const storageKey = scopedDmStateKey(PENDING_SENT_KEY, nodeId);
try {
const pending = await loadIdentityBoundSensitiveValue<string[]>(storageKey, PENDING_WRAP_INFO, []);
const normalized = Array.isArray(pending) ? pending : [];
return normalized;
} catch (error) {
console.warn('[mesh] failed to read encrypted pending contacts', error);
return [];
}
}
export function setPendingSent(ids: string[], nodeId?: string) {
const storageKey = scopedDmStateKey(PENDING_SENT_KEY, nodeId);
void (async () => {
try {
await persistIdentityBoundSensitiveValue(storageKey, PENDING_WRAP_INFO, ids);
} catch (error) {
console.warn('[mesh] failed to persist encrypted pending contacts', error);
}
})();
}
export function getGeoHintEnabled(): boolean {
try {
return localStorage.getItem(GEO_HINT_KEY) === 'true';
} catch {
return false;
}
}
export function setGeoHintEnabled(value: boolean) {
localStorage.setItem(GEO_HINT_KEY, value ? 'true' : 'false');
}
export function getDecoyEnabled(): boolean {
try {
return localStorage.getItem(DECOY_KEY) === 'true';
} catch {
return false;
}
}
export function setDecoyEnabled(value: boolean) {
localStorage.setItem(DECOY_KEY, value ? 'true' : 'false');
}
export async function getMutedList(nodeId?: string): Promise<string[]> {
const storageKey = scopedDmStateKey(MUTED_KEY, nodeId);
try {
const muted = await loadIdentityBoundSensitiveValue<string[]>(
storageKey,
MUTED_WRAP_INFO,
[],
{ legacyKey: MUTED_KEY },
);
const normalized = Array.isArray(muted) ? muted : [];
return normalized;
} catch {
return [];
}
}
export function saveMutedList(ids: string[], nodeId?: string) {
const storageKey = scopedDmStateKey(MUTED_KEY, nodeId);
void (async () => {
try {
await persistIdentityBoundSensitiveValue(storageKey, MUTED_WRAP_INFO, ids, {
legacyKey: MUTED_KEY,
});
} catch {
/* ignore */
}
})();
}
// ─── Sender seal decryption ─────────────────────────────────────────────────
export async function decryptSenderSeal(
senderSeal: string,
candidateDhPub: string,
recipientId: string,
expectedMsgId: string,
): Promise<{ sender_id: string; seal_verified: boolean } | null> {
const openLocal = async (): Promise<{ sender_id: string; seal_verified: boolean } | null> => {
try {
const sealEnvelope = unwrapSenderSealPayload(senderSeal);
const sealText = await decryptSenderSealPayloadLocally(
senderSeal,
candidateDhPub,
recipientId,
expectedMsgId,
);
if (!sealText) {
return null;
}
const seal = JSON.parse(sealText || '{}');
const senderId = String(seal.sender_id || '');
const publicKey = String(seal.public_key || '');
const publicKeyAlgo = String(seal.public_key_algo || '');
const sealMsgId = String(seal.msg_id || '');
const sealTs = Number(seal.timestamp || 0);
const signature = String(seal.signature || '');
if (!senderId || !publicKey || !publicKeyAlgo || !sealMsgId || !signature) {
return null;
}
if (sealMsgId !== expectedMsgId) {
return null;
}
const isBound = await verifyNodeIdBindingFromPublicKey(publicKey, senderId);
if (!isBound) {
return { sender_id: senderId, seal_verified: false };
}
const sealMessage =
sealEnvelope.version === 'v3'
? `seal|v3|${sealMsgId}|${sealTs}|${recipientId}|${String(sealEnvelope.ephemeralPub || '')}`
: `seal|${sealMsgId}|${sealTs}|${recipientId}`;
const verified = await verifyRawSignature({
message: sealMessage,
signature,
publicKey,
publicKeyAlgo,
});
return { sender_id: senderId, seal_verified: verified };
} catch {
return null;
}
};
const openHelper = async (): Promise<{ sender_id: string; seal_verified: boolean } | null> => {
const opened = await openWormholeSenderSeal(
senderSeal,
candidateDhPub,
recipientId,
expectedMsgId,
);
return {
sender_id: String(opened.sender_id || ''),
seal_verified: Boolean(opened.seal_verified),
};
};
return recoverSenderSealWithFallback({
wormholeReady: await isWormholeReady(),
openLocal,
openHelper,
});
}
export async function decryptSenderSealForContact(
senderSeal: string,
candidateDhPub: string,
contact: Contact | undefined,
ownNodeId: string,
expectedMsgId: string,
): Promise<{ sender_id: string; seal_verified: boolean } | null> {
for (const recipientId of allDmPeerIds(ownNodeId, { sharedAlias: contact?.sharedAlias })) {
const opened = await decryptSenderSeal(senderSeal, candidateDhPub, recipientId, expectedMsgId);
if (opened) return opened;
}
return null;
}
export interface AliasDelta {
updates: Partial<Contact>;
promoted: Contact;
}
export function promotePendingAlias(contactId: string, contact: Contact | undefined): { delta: AliasDelta; promoted: Contact } | null {
if (!contact?.pendingSharedAlias) return null;
const graceUntil = Number(contact.sharedAliasGraceUntil || 0);
if (graceUntil > Date.now()) return null;
const nextAlias = String(contact.pendingSharedAlias || '').trim();
const currentAlias = String(contact.sharedAlias || '').trim();
const updates: Partial<Contact> = {
sharedAlias: nextAlias || currentAlias,
pendingSharedAlias: undefined,
sharedAliasGraceUntil: undefined,
sharedAliasRotatedAt: Date.now(),
previousSharedAliases: mergeAliasHistory([
currentAlias,
...(contact.previousSharedAliases || []),
]),
};
const promoted: Contact = { ...contact, ...updates } as Contact;
return { delta: { updates, promoted }, promoted };
}
+180
View File
@@ -0,0 +1,180 @@
import type { WormholeGateKeyStatus, WormholeIdentity } from '@/mesh/wormholeIdentityClient';
import type { Contact, NodeIdentity } from '@/mesh/meshIdentity';
import type { SenderRecoveryState } from '@/mesh/requestSenderRecovery';
// ─── Domain types ────────────────────────────────────────────────────────────
export interface Gate {
gate_id: string;
display_name: string;
description?: string;
welcome?: string;
creator: string;
rules: { min_overall_rep?: number };
message_count: number;
fixed?: boolean;
sort_order?: number;
}
export interface InfoNetMessage {
event_id: string;
event_type?: string;
node_id?: string;
message?: string;
reply_to?: string;
ciphertext?: string;
epoch?: number;
nonce?: string;
sender_ref?: string;
format?: string;
decrypted_message?: string;
payload?: {
gate?: string;
ciphertext?: string;
nonce?: string;
sender_ref?: string;
format?: string;
envelope_hash?: string;
reply_to?: string;
};
destination?: string;
channel?: string;
priority?: string;
gate?: string;
timestamp: number;
sequence?: number;
signature?: string;
public_key?: string;
public_key_algo?: string;
protocol_version?: string;
ephemeral?: boolean;
system_seed?: boolean;
fixed_gate?: boolean;
gate_envelope?: string;
envelope_hash?: string;
}
export interface MeshtasticMessage {
from: string;
to?: string;
text: string;
region: string;
root?: string;
channel: string;
timestamp: number | string;
}
export interface DMMessage {
sender_id: string;
ciphertext: string;
timestamp: number;
msg_id: string;
delivery_class?: 'request' | 'shared';
transport?: 'reticulum' | 'relay';
request_contract_version?: string;
sender_recovery_required?: boolean;
sender_recovery_state?: SenderRecoveryState;
plaintext?: string;
sender_seal?: string;
seal_verified?: boolean;
seal_resolution_failed?: boolean;
}
export interface AccessRequest {
sender_id: string;
timestamp: number;
dh_pub_key?: string;
dh_algo?: string;
geo_hint?: string;
request_contract_version?: string;
sender_recovery_required?: boolean;
sender_recovery_state?: SenderRecoveryState;
}
export interface SenderPopup {
userId: string;
x: number;
y: number;
tab: Tab;
publicKey?: string;
publicKeyAlgo?: string;
}
export interface GateReplyContext {
eventId: string;
gateId: string;
nodeId: string;
}
export type Tab = 'infonet' | 'meshtastic' | 'dms';
export type DMView = 'contacts' | 'inbox' | 'chat' | 'muted';
export type DmTransportMode = 'reticulum' | 'relay' | 'ready' | 'hidden' | 'degraded' | 'blocked';
// ─── Constants ───────────────────────────────────────────────────────────────
export const DEFAULT_MESH_ROOTS = [
'US',
'EU_868',
'EU_433',
'CN',
'JP',
'KR',
'TW',
'RU',
'IN',
'ANZ',
'ANZ_433',
'NZ_865',
'TH',
'UA_868',
'UA_433',
'MY_433',
'MY_919',
'SG_923',
'LORA_24',
'EU',
'AU',
'UA',
'BR',
'AF',
'ME',
'SEA',
'SA',
'PL',
] as const;
export const MSG_COLORS = ['text-cyan-300', 'text-[#ff69b4]', 'text-yellow-300', 'text-gray-200'];
export const DM_UNREAD_POLL_EXPANDED_MS = 15_000;
export const DM_UNREAD_POLL_EXPANDED_JITTER_MS = 2_500;
export const DM_UNREAD_POLL_COLLAPSED_MS = 60_000;
export const DM_UNREAD_POLL_COLLAPSED_JITTER_MS = 10_000;
export const GATE_MESSAGES_POLL_MS = 30_000;
export const GATE_MESSAGES_POLL_JITTER_MS = 6_000;
export const GATE_ACTIVITY_REFRESH_MS = 7_000;
export const GATE_ACTIVITY_REFRESH_JITTER_MS = 2_500;
export const DM_MESSAGES_POLL_MS = 10_000;
export const DM_MESSAGES_POLL_JITTER_MS = 2_000;
export const DM_DECOY_POLL_MS = 210_000;
export const DM_DECOY_POLL_JITTER_MS = 90_000;
export const ACCESS_REQUEST_BATCH_DELAY_MS = 1_400;
export const ACCESS_REQUEST_BATCH_JITTER_MS = 900;
export const SHARED_ALIAS_ROTATE_MS = 6 * 60 * 60 * 1000;
export const SHARED_ALIAS_GRACE_MS = 45_000;
export const GATE_DECRYPT_CACHE_MAX = 256;
export const INFO_VERIFICATION_CACHE_MAX = 512;
// ─── Props ───────────────────────────────────────────────────────────────────
export interface MeshChatProps {
onFlyTo?: (lat: number, lng: number) => void;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onSettingsClick?: () => void;
onTerminalToggle?: () => void;
launchRequest?: { tab: Tab; gate?: string; peerId?: string; showSas?: boolean; nonce: number } | null;
}
// Re-export upstream types for convenience
export type { Contact, NodeIdentity, WormholeGateKeyStatus, WormholeIdentity, SenderRecoveryState };
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
import type { InfoNetMessage, DmTransportMode } from './types';
export {
buildGateAccessHeaders,
gateAccessHeaderCache,
invalidateGateAccessHeaders,
pruneExpiredGateAccessHeaders,
} from '@/mesh/gateAccessProof';
// ─── Pure helpers ────────────────────────────────────────────────────────────
export function sortMeshRoots(
roots: Iterable<string>,
counts: Record<string, number> = {},
currentRoot?: string,
): string[] {
const unique = Array.from(
new Set(
Array.from(roots)
.map((root) => String(root || '').trim())
.filter(Boolean),
),
);
return unique.sort((a, b) => {
if (a === currentRoot) return -1;
if (b === currentRoot) return 1;
const countDelta = (counts[b] || 0) - (counts[a] || 0);
if (countDelta !== 0) return countDelta;
return a.localeCompare(b);
});
}
export function normalizeInfoNetMessage(message: InfoNetMessage): InfoNetMessage {
const payload =
message.payload && typeof message.payload === 'object'
? message.payload
: undefined;
if (!payload) {
return message;
}
return {
...message,
gate: String(message.gate ?? payload.gate ?? ''),
reply_to: String(message.reply_to ?? payload.reply_to ?? ''),
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 ?? ''),
envelope_hash: String(message.envelope_hash ?? payload.envelope_hash ?? ''),
};
}
export function gateDecryptCacheKey(message: InfoNetMessage): string {
const eventId = String(message.event_id || '').trim();
if (eventId) {
return eventId;
}
return [
String(message.gate || '').trim().toLowerCase(),
String(message.ciphertext || '').trim(),
String(message.sender_ref || '').trim(),
String(message.nonce || '').trim(),
].join('|');
}
export function timeAgo(ts: number): string {
const now = Date.now() / 1000;
const diff = now - ts;
if (diff < 60) return `${Math.floor(diff)}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
export function dmTransportDisplay(mode: DmTransportMode): { label: string; className: string } {
switch (mode) {
case 'reticulum':
return {
label: 'DIRECT PRIVATE',
className: 'border-green-500/30 text-green-400 bg-green-950/20',
};
case 'relay':
return {
label: 'RELAY FALLBACK',
className: 'border-yellow-500/30 text-yellow-400 bg-yellow-950/20',
};
case 'ready':
return {
label: 'SECURE READY',
className: 'border-cyan-500/30 text-cyan-400 bg-cyan-950/20',
};
case 'hidden':
return {
label: 'HIDDEN RELAY',
className: 'border-cyan-500/30 text-cyan-300 bg-cyan-950/20',
};
case 'blocked':
return {
label: 'WORMHOLE BLOCKED',
className: 'border-red-500/30 text-red-400 bg-red-950/20',
};
default:
return {
label: 'PUBLIC / DEGRADED',
className: 'border-orange-500/30 text-orange-400 bg-orange-950/20',
};
}
}
export function randomHex(bytes: number = 16): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return Array.from(buf)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export function jitterDelay(baseMs: number, spreadMs: number): number {
const jitter = Math.floor((Math.random() * 2 - 1) * spreadMs);
return Math.max(3000, baseMs + jitter);
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function randomBase64(bytes: number = 64): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return btoa(String.fromCharCode(...buf));
}
// ─── Gate access header cache (module singleton) ─────────────────────────────
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import { Minimize2 } from 'lucide-react';
import { useDataKey } from '@/hooks/useDataStore';
import type { NewsArticle } from '@/types/dashboard';
/**
* MiniMap lightweight world-overview inset showing current viewport position
* and high-severity news dots. Uses canvas for performance.
*/
// Simple Plate Carrée projection
const MAP_W = 200;
const MAP_H = 100;
function latLngToXY(lat: number, lng: number): [number, number] {
const x = ((lng + 180) / 360) * MAP_W;
const y = ((90 - lat) / 180) * MAP_H;
return [x, y];
}
// Simplified world coastline outline (major continental boundaries)
// Approximate hull points for each continent
const CONTINENTS: Array<[number, number][]> = [
// North America
[[72, -170], [72, -55], [48, -52], [25, -80], [15, -85], [15, -105], [30, -118], [48, -125], [60, -140], [72, -170]],
// South America
[[12, -70], [12, -35], [-5, -35], [-23, -42], [-55, -67], [-55, -75], [-15, -77], [0, -80], [12, -70]],
// Europe
[[72, -10], [72, 40], [55, 40], [47, 40], [38, 28], [36, -6], [43, -10], [48, -6], [55, -5], [72, -10]],
// Africa
[[37, -17], [37, 35], [30, 32], [12, 42], [-12, 44], [-34, 27], [-34, 18], [-5, 8], [5, -5], [15, -17], [37, -17]],
// Asia
[[72, 40], [72, 180], [55, 165], [30, 130], [22, 120], [8, 105], [1, 103], [22, 87], [25, 65], [30, 48], [42, 44], [47, 40], [55, 40], [72, 40]],
// Australia
[[-12, 130], [-12, 154], [-28, 154], [-38, 146], [-35, 117], [-20, 114], [-12, 130]],
];
function getRiskColor(score: number): string {
if (score >= 9) return '#ef4444';
if (score >= 7) return '#f97316';
if (score >= 4) return '#eab308';
return '#22d3ee';
}
export default function MiniMap() {
const [collapsed, setCollapsed] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const news = useDataKey('news') as NewsArticle[] | undefined;
const highSeverityDots = useMemo(() => {
if (!news || !Array.isArray(news)) return [];
return news
.filter((n) => (n.risk_score || 0) >= 5 && (n.coords || (n.lat && n.lng)))
.slice(0, 20)
.map((n) => ({
lat: n.coords?.[0] ?? n.lat,
lng: n.coords?.[1] ?? n.lng,
score: n.risk_score,
}));
}, [news]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = MAP_W * dpr;
canvas.height = MAP_H * dpr;
ctx.scale(dpr, dpr);
// Clear
ctx.clearRect(0, 0, MAP_W, MAP_H);
// Background
ctx.fillStyle = 'rgba(5, 10, 20, 0.9)';
ctx.fillRect(0, 0, MAP_W, MAP_H);
// Draw grid lines
ctx.strokeStyle = 'rgba(6, 182, 212, 0.08)';
ctx.lineWidth = 0.5;
for (let lng = -180; lng <= 180; lng += 30) {
const [x] = latLngToXY(0, lng);
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, MAP_H);
ctx.stroke();
}
for (let lat = -90; lat <= 90; lat += 30) {
const [, y] = latLngToXY(lat, 0);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(MAP_W, y);
ctx.stroke();
}
// Draw continents
ctx.strokeStyle = 'rgba(6, 182, 212, 0.25)';
ctx.fillStyle = 'rgba(6, 182, 212, 0.04)';
ctx.lineWidth = 0.8;
for (const continent of CONTINENTS) {
ctx.beginPath();
for (let i = 0; i < continent.length; i++) {
const [x, y] = latLngToXY(continent[i][0], continent[i][1]);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
// Draw news threat dots
for (const dot of highSeverityDots) {
const [x, y] = latLngToXY(dot.lat, dot.lng);
const color = getRiskColor(dot.score);
// Outer glow
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = color + '40';
ctx.fill();
// Inner dot
ctx.beginPath();
ctx.arc(x, y, 1.5, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
// Border
ctx.strokeStyle = 'rgba(6, 182, 212, 0.2)';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, MAP_W - 1, MAP_H - 1);
}, [highSeverityDots, collapsed]);
if (collapsed) {
return (
<button
onClick={() => setCollapsed(false)}
className="absolute bottom-[6.5rem] right-[28rem] z-[200] pointer-events-auto px-2 py-1 bg-[var(--bg-panel)] border border-[var(--border-primary)] rounded-sm text-[9px] font-mono tracking-[0.15em] text-cyan-400 hover:border-cyan-600/40 transition-colors"
>
MAP
</button>
);
}
return (
<div
className="absolute bottom-[6.5rem] right-[28rem] z-[200] pointer-events-auto"
style={{
width: MAP_W,
height: MAP_H,
boxShadow: '0 0 16px rgba(6, 182, 212, 0.08)',
}}
>
<canvas
ref={canvasRef}
style={{ width: MAP_W, height: MAP_H, borderRadius: '2px' }}
/>
{/* Collapse button */}
<button
onClick={() => setCollapsed(true)}
className="absolute top-1 right-1 p-0.5 text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
title="Collapse mini-map"
>
<Minimize2 size={10} />
</button>
{/* Label */}
<div className="absolute bottom-0.5 left-1 text-[10px] font-mono tracking-[0.2em] text-cyan-700/60 uppercase">
OVERVIEW
</div>
</div>
);
}
+476 -126
View File
@@ -2,7 +2,7 @@
import { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { AlertTriangle, Clock, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
import { AlertTriangle, Clock, Minus, Plus, ExternalLink, Brain, Loader2 } from 'lucide-react';
import React, { useEffect, useRef, useCallback } from 'react';
import WikiImage from '@/components/WikiImage';
import type { SelectedEntity, RegionDossier, FimiData } from "@/types/dashboard";
@@ -57,16 +57,43 @@ const AIRCRAFT_WIKI: Record<string, string> = {
GLF6: 'Gulfstream G650', G280: 'Gulfstream G280', GA5C: 'Gulfstream G500/G600',
GA6C: 'Gulfstream G500/G600', LJ35: 'Learjet 35', LJ45: 'Learjet 45', LJ60: 'Learjet 60',
F900: 'Dassault Falcon 900', FA7X: 'Dassault Falcon 7X', FA8X: 'Dassault Falcon 8X',
// Military common
C130: 'Lockheed C-130 Hercules', C17: 'Boeing C-17 Globemaster III',
// Military — US
C130: 'Lockheed C-130 Hercules', C30J: 'Lockheed Martin C-130J Super Hercules',
C17: 'Boeing C-17 Globemaster III',
KC35: 'Boeing KC-135 Stratotanker', KC46: 'Boeing KC-46 Pegasus', K35R: 'Boeing KC-135 Stratotanker',
E3CF: 'Boeing E-3 Sentry', E6B: 'Boeing E-6 Mercury', P8: 'Boeing P-8 Poseidon',
B52H: 'Boeing B-52 Stratofortress', F16: 'General Dynamics F-16 Fighting Falcon',
F15: 'McDonnell Douglas F-15 Eagle', F18H: 'Boeing F/A-18E/F Super Hornet',
E3CF: 'Boeing E-3 Sentry', E3TF: 'Boeing E-3 Sentry', E6B: 'Boeing E-6 Mercury',
P8: 'Boeing P-8 Poseidon', P8A: 'Boeing P-8 Poseidon',
B52H: 'Boeing B-52 Stratofortress', B1: 'Rockwell B-1 Lancer', B1B: 'Rockwell B-1 Lancer',
B2: 'Northrop Grumman B-2 Spirit', B21: 'Northrop Grumman B-21 Raider',
F16: 'General Dynamics F-16 Fighting Falcon', F16C: 'General Dynamics F-16 Fighting Falcon',
F15: 'McDonnell Douglas F-15 Eagle', F15E: 'McDonnell Douglas F-15E Strike Eagle',
F18: 'Boeing F/A-18E/F Super Hornet', F18H: 'Boeing F/A-18E/F Super Hornet',
FA18: 'Boeing F/A-18E/F Super Hornet',
F35: 'Lockheed Martin F-35 Lightning II', F22: 'Lockheed Martin F-22 Raptor',
A10: 'Fairchild Republic A-10 Thunderbolt II', V22: 'Bell Boeing V-22 Osprey',
C5M: 'Lockheed C-5 Galaxy', C2: 'Grumman C-2 Greyhound',
C5M: 'Lockheed C-5 Galaxy', C5: 'Lockheed C-5 Galaxy', C2: 'Grumman C-2 Greyhound',
C40: 'Boeing C-40 Clipper', C37: 'Gulfstream V',
E4B: 'Boeing E-4', E8: 'Northrop Grumman E-8 Joint STARS',
RC135: 'Boeing RC-135', RC35: 'Boeing RC-135', R135: 'Boeing RC-135',
U2: 'Lockheed U-2', U2S: 'Lockheed U-2',
RQ4: 'Northrop Grumman RQ-4 Global Hawk', MQ9: 'General Atomics MQ-9 Reaper',
MQ4C: 'Northrop Grumman MQ-4C Triton',
H60: 'Sikorsky UH-60 Black Hawk', MH60: 'Sikorsky SH-60 Seahawk',
CH47: 'Boeing CH-47 Chinook', H47: 'Boeing CH-47 Chinook',
AH64: 'Boeing AH-64 Apache', H64: 'Boeing AH-64 Apache',
EP3: 'Lockheed EP-3', P3: 'Lockheed P-3 Orion',
T38: 'Northrop T-38 Talon', T6: 'Beechcraft T-6 Texan II',
// Military — NATO / Allied
EUFI: 'Eurofighter Typhoon', RFAL: 'Dassault Rafale', TORN: 'Panavia Tornado',
GROB: 'Grob G 120TP', GRIS: 'Saab JAS 39 Gripen', J39: 'Saab JAS 39 Gripen',
F4: 'McDonnell Douglas F-4 Phantom II', HAWK: 'BAE Systems Hawk',
MRTT: 'Airbus A330 MRTT', A310M: 'Airbus A310 MRTT',
P1: 'Kawasaki P-1', C1: 'Kawasaki C-1', C2JP: 'Kawasaki C-2 (aircraft)',
// Military — Russian
SU27: 'Sukhoi Su-27', SU30: 'Sukhoi Su-30', SU34: 'Sukhoi Su-34', SU35: 'Sukhoi Su-35',
SU57: 'Sukhoi Su-57', MIG29: 'Mikoyan MiG-29', MIG31: 'Mikoyan MiG-31',
TU95: 'Tupolev Tu-95', TU160: 'Tupolev Tu-160', TU22M: 'Tupolev Tu-22M',
IL78: 'Ilyushin Il-78', A50: 'Beriev A-50',
// GA
C172: 'Cessna 172', C182: 'Cessna 182 Skylane', C206: 'Cessna 206', C208: 'Cessna 208 Caravan',
C210: 'Cessna 210 Centurion', PA28: 'Piper PA-28 Cherokee', PA32: 'Piper PA-32',
@@ -84,6 +111,91 @@ const AIRCRAFT_WIKI: Record<string, string> = {
A400: 'Airbus A400M Atlas', C295: 'Airbus C-295',
};
/**
* Maps plane_alert_db `ac_type` substrings correct Wikipedia article titles.
* The ac_type field often includes variant suffixes (e.g. "KC-135R", "F-16AM")
* that don't correspond to Wikipedia article names. Checked in order first match wins.
*/
const AC_TYPE_WIKI_OVERRIDES: [RegExp, string][] = [
// US Fighters & Attack
[/F-?22/i, 'Lockheed Martin F-22 Raptor'],
[/F-?35/i, 'Lockheed Martin F-35 Lightning II'],
[/F-?16/i, 'General Dynamics F-16 Fighting Falcon'],
[/F-?15E/i, 'McDonnell Douglas F-15E Strike Eagle'],
[/F-?15/i, 'McDonnell Douglas F-15 Eagle'],
[/F.?\/A.?18/i, 'Boeing F/A-18E/F Super Hornet'],
[/F-?18/i, 'Boeing F/A-18E/F Super Hornet'],
[/A-?10/i, 'Fairchild Republic A-10 Thunderbolt II'],
// US Bombers
[/B-?52/i, 'Boeing B-52 Stratofortress'],
[/B-?1B|B-?1\b/i, 'Rockwell B-1 Lancer'],
[/B-?2\b/i, 'Northrop Grumman B-2 Spirit'],
[/B-?21/i, 'Northrop Grumman B-21 Raider'],
// US Tankers & Transport
[/KC-?135/i, 'Boeing KC-135 Stratotanker'],
[/KC-?46/i, 'Boeing KC-46 Pegasus'],
[/KC-?10/i, 'McDonnell Douglas KC-10 Extender'],
[/C-?17/i, 'Boeing C-17 Globemaster III'],
[/C-?130J/i, 'Lockheed Martin C-130J Super Hercules'],
[/C-?130/i, 'Lockheed C-130 Hercules'],
[/C-?5/i, 'Lockheed C-5 Galaxy'],
[/V-?22/i, 'Bell Boeing V-22 Osprey'],
// US ISR & Special
[/P-?8/i, 'Boeing P-8 Poseidon'],
[/E-?3/i, 'Boeing E-3 Sentry'],
[/E-?6/i, 'Boeing E-6 Mercury'],
[/E-?4/i, 'Boeing E-4'],
[/E-?8|Joint.?STARS/i, 'Northrop Grumman E-8 Joint STARS'],
[/RC-?135/i, 'Boeing RC-135'],
[/EP-?3/i, 'Lockheed EP-3'],
[/P-?3/i, 'Lockheed P-3 Orion'],
[/U-?2/i, 'Lockheed U-2'],
[/RQ-?4|Global.?Hawk/i, 'Northrop Grumman RQ-4 Global Hawk'],
[/MQ-?9|Reaper/i, 'General Atomics MQ-9 Reaper'],
[/MQ-?4C|Triton/i, 'Northrop Grumman MQ-4C Triton'],
// US Helicopters
[/AH-?64|Apache/i, 'Boeing AH-64 Apache'],
[/CH-?47|Chinook/i, 'Boeing CH-47 Chinook'],
[/UH-?60|Black.?Hawk/i, 'Sikorsky UH-60 Black Hawk'],
[/SH-?60|MH-?60|Seahawk/i, 'Sikorsky SH-60 Seahawk'],
// NATO / Allied
[/Eurofighter|Typhoon/i, 'Eurofighter Typhoon'],
[/Rafale/i, 'Dassault Rafale'],
[/Tornado/i, 'Panavia Tornado'],
[/Gripen/i, 'Saab JAS 39 Gripen'],
[/A400M/i, 'Airbus A400M Atlas'],
[/A330\s*MRTT|Voyager/i, 'Airbus A330 MRTT'],
[/Hawk\s*T/i, 'BAE Systems Hawk'],
// Russian
[/Su-?57/i, 'Sukhoi Su-57'],
[/Su-?35/i, 'Sukhoi Su-35'],
[/Su-?34/i, 'Sukhoi Su-34'],
[/Su-?30/i, 'Sukhoi Su-30'],
[/Su-?27/i, 'Sukhoi Su-27'],
[/Su-?25/i, 'Sukhoi Su-25'],
[/MiG-?31/i, 'Mikoyan MiG-31'],
[/MiG-?29/i, 'Mikoyan MiG-29'],
[/Tu-?160/i, 'Tupolev Tu-160'],
[/Tu-?95/i, 'Tupolev Tu-95'],
[/Tu-?22M/i, 'Tupolev Tu-22M'],
[/Il-?76/i, 'Ilyushin Il-76'],
[/Il-?78/i, 'Ilyushin Il-78'],
[/A-?50\b/i, 'Beriev A-50'],
// Chinese
[/J-?20/i, 'Chengdu J-20'],
[/J-?16/i, 'Shenyang J-16'],
[/J-?10/i, 'Chengdu J-10'],
[/Y-?20/i, 'Xi\'an Y-20'],
];
/** Resolve a plane_alert_db ac_type string to a Wikipedia article title. */
function resolveAcTypeWiki(acType: string): string | null {
for (const [pattern, wikiTitle] of AC_TYPE_WIKI_OVERRIDES) {
if (pattern.test(acType)) return wikiTitle;
}
return null;
}
// Module-level cache for Wikipedia thumbnails (persists across re-renders)
const _wikiThumbCache: Record<string, { url: string | null; loading: boolean }> = {};
@@ -124,7 +236,7 @@ const VESSEL_TYPE_WIKI: Record<string, string> = {
'military_vessel': 'https://en.wikipedia.org/wiki/Warship',
};
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, onArticleClick }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number) => void }) {
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, onArticleClick }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number, title?: string) => void }) {
const data = useDataKeys([
'news', 'fimi', 'commercial_flights', 'private_flights', 'private_jets',
'military_flights', 'tracked_flights', 'ships', 'gdelt', 'liveuamap',
@@ -133,6 +245,9 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
const [isMinimized, setIsMinimized] = useState(false);
const [expandedIndexes, setExpandedIndexes] = useState<number[]>([]);
const [fimiExpanded, setFimiExpanded] = useState(false);
const [aiSummaryOpen, setAiSummaryOpen] = useState(false);
const [aiSummary, setAiSummary] = useState<any>(null);
const [aiSummaryLoading, setAiSummaryLoading] = useState(false);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
// Intentionally omitting map click triggers for expanding
@@ -222,24 +337,24 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
className="w-full bg-black/60 backdrop-blur-sm border border-emerald-800 flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,255,128,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
>
<div className="p-3 border-b border-emerald-500/30 bg-emerald-950/40 flex justify-between items-center">
<h2 className="text-xs tracking-widest font-bold text-emerald-400">REGION DOSSIER</h2>
<span className="text-[8px] text-[var(--text-muted)]">
<h2 className="text-sm tracking-widest font-bold text-emerald-400">REGION DOSSIER</h2>
<span className="text-[10px] text-[var(--text-muted)]">
{selectedEntity.extra ? `${selectedEntity.extra.lat.toFixed(3)}, ${selectedEntity.extra.lng.toFixed(3)}` : ''}
</span>
</div>
{regionDossierLoading ? (
<div className="p-6 flex items-center justify-center">
<span className="text-emerald-400 text-[10px] font-mono tracking-widest">COMPILING INTELLIGENCE...</span>
<span className="text-emerald-400 text-[12px] font-mono tracking-widest">COMPILING INTELLIGENCE...</span>
</div>
) : d && !d.error ? (
<div className="p-3 flex flex-col gap-1.5 max-h-[500px] overflow-y-auto styled-scrollbar text-[10px]">
<div className="p-3 flex flex-col gap-2 max-h-[500px] overflow-y-auto styled-scrollbar text-[12px]">
{d.warning && (
<div className="mb-2 p-2 bg-amber-950/40 border border-amber-800/50 text-[9px] text-amber-300 leading-relaxed">
<div className="mb-2 p-2 bg-amber-950/40 border border-amber-800/50 text-[11px] text-amber-300 leading-relaxed">
{d.warning}
</div>
)}
{/* COUNTRY */}
<div className="text-[9px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1">COUNTRY LEVEL {d.country?.flag_emoji || ''}</div>
<div className="text-[11px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1">COUNTRY LEVEL {d.country?.flag_emoji || ''}</div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">COUNTRY</span><span className="text-[var(--text-primary)] font-bold">{d.country?.name}</span></div>
{d.country?.official_name && d.country.official_name !== d.country.name && (
<div className="flex justify-between"><span className="text-[var(--text-muted)]">OFFICIAL</span><span className="text-[var(--text-secondary)] text-right max-w-[180px]">{d.country.official_name}</span></div>
@@ -260,12 +375,12 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
{/* LOCAL */}
{(d.local?.name || d.local?.state) && (
<>
<div className="text-[9px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1 mt-2">LOCAL LEVEL</div>
<div className="text-[11px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1 mt-2">LOCAL LEVEL</div>
{d.local.name && <div className="flex justify-between"><span className="text-[var(--text-muted)]">LOCALITY</span><span className="text-[var(--text-primary)] font-bold">{d.local.name}</span></div>}
{d.local.state && <div className="flex justify-between"><span className="text-[var(--text-muted)]">STATE/PROVINCE</span><span className="text-[var(--text-primary)] font-bold">{d.local.state}</span></div>}
{d.local.description && <div className="flex justify-between"><span className="text-[var(--text-muted)]">TYPE</span><span className="text-[var(--text-secondary)]">{d.local.description}</span></div>}
{d.local.summary && (
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 text-[9px] text-[var(--text-secondary)] leading-relaxed">
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 text-[11px] text-[var(--text-secondary)] leading-relaxed">
<span className="text-emerald-400 font-bold">&gt;_ INTEL: </span>
{d.local.summary.length > 500 ? d.local.summary.substring(0, 500) + '...' : d.local.summary}
</div>
@@ -276,9 +391,9 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
{/* Sentinel-2 imagery now shown as map popup — see MaplibreViewer */}
</div>
) : d?.error ? (
<div className="p-4 text-[var(--text-secondary)] text-[10px]">{d.error}</div>
<div className="p-4 text-[var(--text-secondary)] text-[12px]">{d.error}</div>
) : (
<div className="p-4 text-red-400 text-[10px]">INTEL UNAVAILABLE</div>
<div className="p-4 text-red-400 text-[12px]">INTEL UNAVAILABLE</div>
)}
</motion.div>
);
@@ -303,7 +418,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
</div>
<div className="p-4 flex flex-col gap-2 text-[10px]">
<div className="text-[9px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1">
<div className="text-[11px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1">
ATTRIBUTION
</div>
<div className="text-green-300/90">
@@ -318,7 +433,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
</div>
)}
<div className="text-[9px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1 mt-2">
<div className="text-[11px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1 mt-2">
HOST
</div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">IP</span><span className="text-green-300 font-bold">{host.ip || 'UNKNOWN'}</span></div>
@@ -340,7 +455,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
)}
{Array.isArray(host.services) && host.services.length > 0 && (
<>
<div className="text-[9px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1 mt-2">
<div className="text-[11px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1 mt-2">
SERVICES
</div>
<div className="flex flex-col gap-2">
@@ -356,12 +471,12 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
{service.product || 'Unknown service'}
</div>
{service.tags?.length > 0 && (
<div className="mt-1 text-[9px] text-green-500/80">
<div className="mt-1 text-[11px] text-green-500/80">
TAGS: {service.tags.join(', ')}
</div>
)}
{service.banner_excerpt && (
<div className="mt-1 text-[9px] text-green-300/90 leading-relaxed">
<div className="mt-1 text-[11px] text-green-300/90 leading-relaxed">
{service.banner_excerpt}
</div>
)}
@@ -371,7 +486,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
</>
)}
{host.data_snippet && (
<div className="mt-2 border border-green-900/50 bg-black/50 p-2 text-[9px] text-green-300/90 leading-relaxed">
<div className="mt-2 border border-green-900/50 bg-black/50 p-2 text-[11px] text-green-300/90 leading-relaxed">
<span className="text-green-400 font-bold">&gt;_ BANNER: </span>
{host.data_snippet}
</div>
@@ -450,46 +565,96 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<span className={`text-xs font-bold ${headerColor}`}>UNKNOWN</span>
)}
</div>
{/* Owner/Operator Wikipedia photo */}
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (() => {
const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
{/* Primary image: military → aircraft model photo; everything else → operator/company photo */}
{(() => {
// Categories where the aircraft model should be the primary image
const MILITARY_CATEGORIES = new Set([
'USAF', 'RAF', 'GAF', 'Royal Navy Fleet Air Arm', 'Army Air Corps',
'Other Air Forces', 'Other Navies', 'United States Navy',
'United States Marine Corps', 'Special Forces', 'Gunship', 'Nuclear',
'UAV', 'Coastguard', 'Da Comrade', 'Hired Gun', 'Oxcart', 'Zoomies',
'Toy Soldiers', 'Police Forces', 'Flying Doctors', 'Aerial Firefighter',
]);
const cat = flight.alert_category || '';
const isMilitary = MILITARY_CATEGORIES.has(cat);
// Resolve aircraft model wiki info (for link or image depending on context)
let acWikiTitle = flight.model ? AIRCRAFT_WIKI[flight.model] : undefined;
if (!acWikiTitle && flight.alert_type && flight.alert_type !== "UNKNOWN") {
acWikiTitle = resolveAcTypeWiki(flight.alert_type) || flight.alert_type;
}
const acModelWikiUrl = acWikiTitle ? `https://en.wikipedia.org/wiki/${acWikiTitle.replace(/ /g, '_')}` : null;
// Resolve operator wiki info
const operatorSlug = flight.alert_wiki || (flight.alert_operator && flight.alert_operator !== "UNKNOWN"
? flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_')
: null);
const operatorWikiUrl = operatorSlug ? `https://en.wikipedia.org/wiki/${encodeURIComponent(operatorSlug)}` : null;
const accentClass = ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50';
if (isMilitary) {
// MILITARY: aircraft model photo as primary image, operator as text link above
return acModelWikiUrl ? (
<div className="border-b border-[var(--border-primary)] pb-2">
<WikiImage
wikiUrl={acModelWikiUrl}
label={acWikiTitle || flight.model}
maxH="max-h-36"
accent={accentClass}
/>
</div>
) : null;
}
// NON-MILITARY (tracked jets, celebs, companies, airlines):
// Operator/company photo as primary image
// Aircraft model as a text link below
return (
<div className="border-b border-[var(--border-primary)] pb-2">
<WikiImage
wikiUrl={wikiHref}
label={flight.alert_operator}
maxH="max-h-36"
accent={ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50'}
/>
</div>
<>
{operatorWikiUrl && (
<div className="border-b border-[var(--border-primary)] pb-2">
<WikiImage
wikiUrl={operatorWikiUrl}
label={flight.alert_operator || 'Operator'}
maxH="max-h-36"
accent={accentClass}
/>
</div>
)}
{acModelWikiUrl && (
<div className="border-b border-[var(--border-primary)] pb-1">
<a href={acModelWikiUrl} target="_blank" rel="noopener noreferrer"
className="text-[10px] text-cyan-400 hover:text-cyan-300 underline inline-block">
📖 {acWikiTitle || flight.alert_type || flight.model} Wikipedia
</a>
</div>
)}
</>
);
})()}
{/* Aircraft model Wikipedia photo */}
{aircraftImgUrl && (
<div className="border-b border-[var(--border-primary)] pb-2">
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
<img
src={aircraftImgUrl}
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
className={`w-full h-auto max-h-28 object-cover border border-[var(--border-primary)]/50 ${ac === 'pink' ? 'hover:border-pink-500/50' : 'hover:border-cyan-500/50'} transition-colors`}
/>
</a>
{aircraftWikiUrl && (
<a href={aircraftWikiUrl} target="_blank" rel="noopener noreferrer"
className="text-[10px] text-cyan-400 hover:text-cyan-300 underline mt-1 inline-block">
📖 {AIRCRAFT_WIKI[flight.model] || flight.model} Wikipedia
</a>
)}
</div>
)}
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">CATEGORY</span>
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_category || "N/A"}</span>
</div>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
{(() => {
const acLabel = flight.alert_type || flight.model || "UNKNOWN";
let acLink = flight.model ? AIRCRAFT_WIKI[flight.model] : undefined;
if (!acLink && flight.alert_type && flight.alert_type !== "UNKNOWN") {
acLink = resolveAcTypeWiki(flight.alert_type) || undefined;
}
const acHref = acLink ? `https://en.wikipedia.org/wiki/${acLink.replace(/ /g, '_')}` : null;
return acHref ? (
<a href={acHref} target="_blank" rel="noreferrer"
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 underline transition-opacity">
{acLabel}
</a>
) : (
<span className="text-[var(--text-primary)] text-xs font-bold">{acLabel}</span>
);
})()}
</div>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
@@ -523,12 +688,12 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
<div className="flex gap-3">
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[8px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
</div>
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[8px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
</div>
</div>
</div>
@@ -600,16 +765,16 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
} else if ('airline_code' in flight && flight.airline_code) {
// Use the airline code resolved from adsb.lol routeset API
const codeMap: Record<string, string> = {
"UAL": "UNITED AIRLINES", "DAL": "DELTA AIR LINES", "SWA": "SOUTHWEST AIRLINES",
"AAL": "AMERICAN AIRLINES", "BAW": "BRITISH AIRWAYS", "AFR": "AIR FRANCE",
"JBU": "JETBLUE AIRWAYS", "NKS": "SPIRIT AIRLINES", "THY": "TURKISH AIRLINES",
"UAE": "EMIRATES", "QFA": "QANTAS", "ACA": "AIR CANADA",
"FFT": "FRONTIER AIRLINES", "WJA": "WESTJET", "RPA": "REPUBLIC AIRWAYS",
"SKW": "SKYWEST AIRLINES", "ENY": "ENVOY AIR", "ASA": "ALASKA AIRLINES",
"HAL": "HAWAIIAN AIRLINES", "DLH": "LUFTHANSA", "KLM": "KLM",
"EZY": "EASYJET", "RYR": "RYANAIR", "SIA": "SINGAPORE AIRLINES",
"CPA": "CATHAY PACIFIC", "ANA": "ALL NIPPON AIRWAYS", "JAL": "JAPAN AIRLINES",
"QTR": "QATAR AIRWAYS", "ETD": "ETIHAD AIRWAYS", "SAS": "SAS SCANDINAVIAN"
"UAL": "United Airlines", "DAL": "Delta Air Lines", "SWA": "Southwest Airlines",
"AAL": "American Airlines", "BAW": "British Airways", "AFR": "Air France",
"JBU": "JetBlue Airways", "NKS": "Spirit Airlines", "THY": "Turkish Airlines",
"UAE": "Emirates", "QFA": "Qantas", "ACA": "Air Canada",
"FFT": "Frontier Airlines", "WJA": "WestJet", "RPA": "Republic Airways",
"SKW": "SkyWest Airlines", "ENY": "Envoy Air", "ASA": "Alaska Airlines",
"HAL": "Hawaiian Airlines", "DLH": "Lufthansa", "KLM": "KLM",
"EZY": "EasyJet", "RYR": "Ryanair", "SIA": "Singapore Airlines",
"CPA": "Cathay Pacific", "ANA": "All Nippon Airways", "JAL": "Japan Airlines",
"QTR": "Qatar Airways", "ETD": "Etihad Airways", "SAS": "SAS Scandinavian"
};
airline = codeMap[flight.airline_code] || flight.airline_code;
} else if (callsign !== "UNKNOWN") {
@@ -633,8 +798,30 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{airline}</span>
{selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' ? (
<a
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(airline.replace(/ /g, '_'))}`}
target="_blank"
rel="noreferrer"
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 underline"
>
{airline}
</a>
) : (
<span className="text-[var(--text-primary)] text-xs font-bold">{airline}</span>
)}
</div>
{/* Commercial: Airline company Wikipedia image */}
{selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' && (
<div className="border-b border-[var(--border-primary)] pb-2">
<WikiImage
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(airline.replace(/ /g, '_'))}`}
label={airline}
maxH="max-h-32"
accent="hover:border-cyan-500/50"
/>
</div>
)}
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
@@ -643,8 +830,56 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT MODEL</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.model || "UNKNOWN"}</span>
</div>
{/* Aircraft photo + Wikipedia link */}
{(aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
{/* Military: Aircraft model Wikipedia image (gold accent) */}
{selectedEntity.type === 'military_flight' && (() => {
// Resolve model to Wikipedia article — ICAO code first, then ac_type regex
const milAcType = (flight as Record<string, any>).alert_type as string | undefined;
const milWikiTitle = (flight.model ? AIRCRAFT_WIKI[flight.model] : undefined)
|| (milAcType ? resolveAcTypeWiki(milAcType) : null)
|| (flight.model ? resolveAcTypeWiki(flight.model) : null);
const milModelUrl = milWikiTitle ? `https://en.wikipedia.org/wiki/${milWikiTitle.replace(/ /g, '_')}` : null;
if (milModelUrl) {
return (
<div className="border-b border-[var(--border-primary)] pb-3">
<WikiImage
wikiUrl={milModelUrl}
label={milWikiTitle || flight.model}
maxH="max-h-36"
accent="hover:border-amber-400/60"
/>
</div>
);
}
// Fall back to cached thumbnail if available
if (aircraftImgUrl || aircraftImgLoading) {
return (
<div className="border-b border-[var(--border-primary)] pb-3">
{aircraftImgLoading && (
<div className="w-full h-24 bg-[var(--bg-tertiary)]/60" />
)}
{aircraftImgUrl && (
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
<img
src={aircraftImgUrl}
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
className="w-full h-auto max-h-32 object-cover border border-amber-500/30 hover:border-amber-400/60 transition-colors"
style={{ imageRendering: 'auto' }}
/>
</a>
)}
{aircraftWikiUrl && (
<a href={aircraftWikiUrl} target="_blank" rel="noopener noreferrer"
className="text-[10px] text-amber-400 hover:text-amber-300 underline mt-1 inline-block">
📖 {AIRCRAFT_WIKI[flight.model] || flight.model} Wikipedia
</a>
)}
</div>
);
}
return null;
})()}
{/* Non-military: Aircraft model photo (secondary, below airline image) */}
{selectedEntity.type !== 'military_flight' && (aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
<div className="border-b border-[var(--border-primary)] pb-3">
{aircraftImgLoading && (
<div className="w-full h-24 bg-[var(--bg-tertiary)]/60" />
@@ -693,12 +928,12 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
<div className="flex gap-3">
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[8px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
</div>
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[8px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
</div>
</div>
</div>
@@ -875,7 +1110,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
{headline || domain || 'View Article'}
</span>
{headline && domain && (
<span className="text-[var(--text-muted)] text-[9px] block mt-0.5">{domain}</span>
<span className="text-[var(--text-muted)] text-[11px] block mt-0.5">{domain}</span>
)}
</a>
);
@@ -980,7 +1215,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">MARKET CORRELATION</span>
<div className="p-2 bg-purple-950/30 border border-purple-500/30 rounded-sm">
<div className="text-[10px] text-purple-300 font-bold leading-tight mb-1">{item.prediction_odds.title}</div>
<div className="flex items-center gap-3 text-[9px] font-mono">
<div className="flex items-center gap-3 text-[11px] font-mono">
<span className="text-white font-bold">CONSENSUS: {item.prediction_odds.consensus_pct}%</span>
{item.prediction_odds.polymarket_pct != null && <span className="text-cyan-400">Polymarket {item.prediction_odds.polymarket_pct}%</span>}
{item.prediction_odds.kalshi_pct != null && <span className="text-orange-400">Kalshi {item.prediction_odds.kalshi_pct}%</span>}
@@ -989,7 +1224,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
</div>
)}
{item.machine_assessment && (
<div className="mt-2 p-2 bg-black/60 border border-cyan-800/50 rounded-sm text-[9px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
<div className="mt-2 p-2 bg-black/60 border border-cyan-800/50 rounded-sm text-[11px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
<div className="absolute top-0 left-0 w-[2px] h-full bg-cyan-500 animate-pulse"></div>
<span className="font-bold text-white">&gt;_ SYS.ANALYSIS: </span>
<span className="text-cyan-300 opacity-90">{item.machine_assessment}</span>
@@ -1053,19 +1288,48 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className={`w-full bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 flex flex-col z-10 font-mono pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
className={`w-full bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 flex flex-col z-10 font-mono pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'flex-shrink-0' : 'flex-1 min-h-0'}`}
>
<div
className="p-3 border-b border-[var(--border-primary)]/50 relative overflow-hidden cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
className="px-3 py-2.5 border-b border-cyan-900/40 relative overflow-hidden cursor-pointer hover:bg-cyan-950/30 transition-colors"
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex justify-between items-center relative z-10">
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
<AlertTriangle size={14} /> GLOBAL THREAT INTERCEPT
</h2>
<button className="text-cyan-500 hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
<div className="flex items-center justify-between relative z-10">
<div className="flex items-center gap-2">
<AlertTriangle size={16} className="text-cyan-400" />
<span className="text-[12px] text-cyan-400 font-mono tracking-widest font-bold">
GLOBAL THREAT INTERCEPT
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
const next = !aiSummaryOpen;
setAiSummaryOpen(next);
if (next && !aiSummary) {
setAiSummaryLoading(true);
fetch('/api/ai/news/summary')
.then(r => r.json())
.then(d => { setAiSummary(d); setAiSummaryLoading(false); })
.catch(() => setAiSummaryLoading(false));
}
}}
className={`p-0.5 rounded-sm transition-colors ${
aiSummaryOpen
? 'text-purple-400 bg-purple-900/30 border border-purple-700/40'
: 'text-gray-600 hover:text-purple-400 border border-transparent hover:border-purple-700/30'
}`}
title="AI Intelligence Brief"
>
<Brain size={14} />
</button>
{isMinimized ? (
<Plus size={16} className="text-cyan-400" />
) : (
<Minus size={16} className="text-cyan-400" />
)}
</div>
</div>
<AnimatePresence>
@@ -1104,22 +1368,108 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<div className={`w-2 h-2 rounded-full ${
data.threat_level.level === 'SEVERE' || data.threat_level.level === 'HIGH' ? 'animate-pulse' : ''
}`} style={{ backgroundColor: data.threat_level.color }} />
<span className="text-[9px] font-bold tracking-wider" style={{ color: data.threat_level.color }}>
<span className="text-[12px] font-bold tracking-wider" style={{ color: data.threat_level.color }}>
THREAT: {data.threat_level.level}
</span>
<span className="text-[9px] text-[var(--text-muted)] ml-auto">
<span className="text-[12px] text-[var(--text-muted)] ml-auto">
{data.threat_level.score}/100
</span>
</div>
{data.threat_level.drivers.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1 mb-1">
{data.threat_level.drivers.map((d: string, i: number) => (
<span key={i} className="text-[7px] px-1 py-0.5 bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-[var(--text-muted)] rounded-sm">
{d}
</span>
))}
{/* Threat drivers removed — the level bar is sufficient */}
</motion.div>
)}
</AnimatePresence>
{/* AI Intelligence Brief */}
<AnimatePresence>
{!isMinimized && aiSummaryOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="px-3 pt-1 pb-2 overflow-hidden"
>
<div className="border border-purple-500/30 bg-purple-950/10 rounded-sm">
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-purple-500/20">
<Brain size={12} className="text-purple-400" />
<span className="text-[11px] font-bold tracking-wider text-purple-400">AI INTELLIGENCE BRIEF</span>
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse ml-auto" />
</div>
)}
{aiSummaryLoading ? (
<div className="p-3 flex items-center gap-2 text-[10px] text-purple-300">
<Loader2 size={12} className="animate-spin" />
COMPILING INTELLIGENCE BRIEF...
</div>
) : aiSummary ? (
<div className="p-2 flex flex-col gap-2 text-[10px]">
<div className="text-purple-200 font-mono leading-relaxed">
{aiSummary.summary}
</div>
{aiSummary.top_stories?.length > 0 && (
<div>
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">TOP STORIES</div>
<div className="flex flex-col gap-1">
{aiSummary.top_stories.slice(0, 5).map((s: any, i: number) => (
<a key={i} href={s.link} target="_blank" rel="noreferrer" className="text-[11px] text-purple-200/80 hover:text-white transition-colors truncate">
<span className={`mr-1 ${
s.risk_score >= 9 ? 'text-red-400' :
s.risk_score >= 7 ? 'text-orange-400' :
s.risk_score >= 4 ? 'text-yellow-400' : 'text-green-400'
}`}>●</span>
[{s.risk_score}/10] {s.title}
</a>
))}
</div>
</div>
)}
{aiSummary.keywords?.length > 0 && (
<div>
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">TRENDING KEYWORDS</div>
<div className="flex flex-wrap gap-1">
{aiSummary.keywords.slice(0, 10).map((kw: any, i: number) => (
<span key={i} className="text-[10px] px-1 py-0.5 bg-purple-950/50 border border-purple-500/20 text-purple-300 rounded-sm">
{kw.word} ({kw.count})
</span>
))}
</div>
</div>
)}
{aiSummary.threat_distribution && (
<div>
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">THREAT BREAKDOWN</div>
<div className="flex gap-1">
{Object.entries(aiSummary.threat_distribution).map(([level, count]) => (
<span key={level} className={`text-[10px] px-1.5 py-0.5 border rounded-sm font-bold ${
level === 'CRITICAL' ? 'bg-red-950/40 border-red-500/30 text-red-400' :
level === 'HIGH' ? 'bg-orange-950/40 border-orange-500/30 text-orange-400' :
level === 'ELEVATED' ? 'bg-yellow-950/40 border-yellow-500/30 text-yellow-400' :
level === 'MODERATE' ? 'bg-blue-950/40 border-blue-500/30 text-blue-400' :
'bg-green-950/40 border-green-500/30 text-green-400'
}`}>
{level}: {count as number}
</span>
))}
</div>
</div>
)}
<button
onClick={() => {
setAiSummaryLoading(true);
setAiSummary(null);
fetch('/api/ai/news/summary')
.then(r => r.json())
.then(d => { setAiSummary(d); setAiSummaryLoading(false); })
.catch(() => setAiSummaryLoading(false));
}}
className="text-[11px] text-purple-500 hover:text-purple-300 transition-colors self-end"
>
REFRESH BRIEF
</button>
</div>
) : (
<div className="p-3 text-[10px] text-purple-300/50">No data available.</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
@@ -1145,7 +1495,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<div className={`w-2 h-2 rounded-full ${
fimi.major_wave ? 'bg-amber-400 animate-pulse' : 'bg-purple-400'
}`} />
<span className={`text-[9px] font-bold tracking-wider ${
<span className={`text-[11px] font-bold tracking-wider ${
fimi.major_wave ? 'text-amber-400' : 'text-purple-400'
}`}>
{fimi.major_wave
@@ -1153,14 +1503,14 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
: '⚠ DISINFORMATION INDEX'
}
</span>
<span className="text-[8px] text-[var(--text-muted)] ml-auto flex items-center gap-1">
<span className="text-[11px] text-[var(--text-muted)] ml-auto flex items-center gap-1">
{Object.keys(fimi.threat_actors).length > 0 && (
<span className="text-red-400">
{Object.keys(fimi.threat_actors)[0]}
</span>
)}
<span>{fimi.narratives.length} NARR</span>
{fimiExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
{fimiExpanded ? <Minus size={10} /> : <Plus size={10} />}
</span>
</button>
@@ -1176,7 +1526,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
{/* Threat Actor Bar */}
{Object.keys(fimi.threat_actors).length > 0 && (
<div className="px-2 py-1.5 border-b border-purple-500/10">
<div className="text-[8px] text-purple-400 tracking-widest font-bold mb-1">THREAT ACTORS</div>
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">THREAT ACTORS</div>
<div className="flex gap-1 h-2 rounded-sm overflow-hidden">
{(() => {
const total = Object.values(fimi.threat_actors).reduce((a, b) => a + b, 0);
@@ -1197,7 +1547,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
</div>
<div className="flex gap-2 mt-1 flex-wrap">
{Object.entries(fimi.threat_actors).map(([actor, count]) => (
<span key={actor} className="text-[7px] text-[var(--text-muted)]">
<span key={actor} className="text-[10px] text-[var(--text-muted)]">
<span className={`font-bold ${
actor === 'Russia' ? 'text-red-400' :
actor === 'China' ? 'text-amber-400' :
@@ -1212,7 +1562,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
{/* Top Narratives */}
<div className="px-2 py-1.5 border-b border-purple-500/10">
<div className="text-[8px] text-purple-400 tracking-widest font-bold mb-1">LATEST NARRATIVES</div>
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">LATEST NARRATIVES</div>
<div className="flex flex-col gap-1 max-h-[120px] overflow-y-auto styled-scrollbar">
{fimi.narratives.slice(0, 5).map((n, i) => (
<a
@@ -1220,7 +1570,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
href={n.link}
target="_blank"
rel="noreferrer"
className="text-[9px] text-[var(--text-secondary)] hover:text-purple-300 transition-colors leading-tight flex items-start gap-1"
className="text-[11px] text-[var(--text-secondary)] hover:text-purple-300 transition-colors leading-tight flex items-start gap-1"
>
<ExternalLink size={8} className="text-purple-500 mt-0.5 flex-shrink-0" />
<span className="flex-1">{n.title}</span>
@@ -1232,7 +1582,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
{/* Debunked Claims */}
{fimi.claims.length > 0 && (
<div className="px-2 py-1.5 border-b border-purple-500/10">
<div className="text-[8px] text-red-400 tracking-widest font-bold mb-1">DEBUNKED CLAIMS ({fimi.claims.length})</div>
<div className="text-[11px] text-red-400 tracking-widest font-bold mb-1">DEBUNKED CLAIMS ({fimi.claims.length})</div>
<div className="flex flex-col gap-0.5 max-h-[80px] overflow-y-auto styled-scrollbar">
{fimi.claims.slice(0, 5).map((c, i) => (
<a
@@ -1240,7 +1590,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
href={c.url}
target="_blank"
rel="noreferrer"
className="text-[8px] text-red-300/70 hover:text-red-300 transition-colors truncate"
className="text-[11px] text-red-300/70 hover:text-red-300 transition-colors truncate"
>
{c.title}
</a>
@@ -1252,10 +1602,10 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
{/* Target Countries */}
{Object.keys(fimi.targets).length > 0 && (
<div className="px-2 py-1.5">
<div className="text-[8px] text-purple-400 tracking-widest font-bold mb-1">TARGETS</div>
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">TARGETS</div>
<div className="flex flex-wrap gap-1">
{Object.entries(fimi.targets).slice(0, 10).map(([target, count]) => (
<span key={target} className="text-[7px] px-1 py-0.5 bg-purple-950/50 border border-purple-500/20 text-purple-300 rounded-sm">
<span key={target} className="text-[10px] px-1 py-0.5 bg-purple-950/50 border border-purple-500/20 text-purple-300 rounded-sm">
{target} ({count})
</span>
))}
@@ -1265,10 +1615,10 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
{/* Source attribution */}
<div className="px-2 py-1 border-t border-purple-500/10 flex justify-between items-center">
<a href={fimi.source_url} target="_blank" rel="noreferrer" className="text-[7px] text-purple-500 hover:text-purple-300 transition-colors">
<a href={fimi.source_url} target="_blank" rel="noreferrer" className="text-[10px] text-purple-500 hover:text-purple-300 transition-colors">
Source: {fimi.source}
</a>
<span className="text-[7px] text-[var(--text-muted)]">
<span className="text-[10px] text-[var(--text-muted)]">
{fimi.last_fetched ? new Date(fimi.last_fetched).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
@@ -1322,7 +1672,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
transition={idx < 15 ? { delay: 0.1 + (idx * 0.05) } : { duration: 0 }}
className={`p-2 rounded-sm border-l-[2px] border-r border-t border-b ${bgClass} flex flex-col gap-1 relative group shrink-0`}
>
<div className="flex items-center justify-between text-[8px] text-[var(--text-secondary)] uppercase tracking-widest">
<div className="flex items-center justify-between text-[12px] text-[var(--text-secondary)] uppercase tracking-widest">
<span className="font-bold flex items-center gap-1 text-white">
{isBreaking && <span className="text-red-400 mr-1">BREAKING</span>}
&gt;_ {item.source}
@@ -1330,22 +1680,22 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<span>[{item.published ? formatTime(item.published) : ''}]</span>
</div>
<button
onClick={() => onArticleClick?.(idx, item.coords?.[0], item.coords?.[1])}
className={`text-left text-[11px] ${titleClass} hover:text-[var(--text-primary)] transition-colors leading-tight cursor-pointer`}
<button
onClick={() => onArticleClick?.(idx, item.coords?.[0], item.coords?.[1], item.title)}
className={`text-left text-[12px] ${titleClass} hover:text-[var(--text-primary)] transition-colors leading-tight cursor-pointer`}
>
{item.title}
</button>
{item.machine_assessment && (
<div className="mt-1 p-1.5 bg-black/60 border border-cyan-800/50 rounded-sm text-[8.5px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
<div className="mt-1 p-1.5 bg-black/60 border border-cyan-800/50 rounded-sm text-[11px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
<div className="absolute top-0 left-0 w-[2px] h-full bg-cyan-500 animate-pulse"></div>
<span className="font-bold text-white">&gt;_ SYS.ANALYSIS: </span>
<span className="text-cyan-300 opacity-90">{item.machine_assessment}</span>
</div>
)}
{item.prediction_odds && item.prediction_odds.consensus_pct != null && (
<div className="mt-1 px-1.5 py-1 bg-purple-950/30 border border-purple-500/30 rounded-sm text-[8px] font-mono flex items-center gap-1.5">
<div className="mt-1 px-1.5 py-1 bg-purple-950/30 border border-purple-500/30 rounded-sm text-[11px] font-mono flex items-center gap-1.5">
<span className="text-purple-400 font-bold">MKT</span>
<span className="text-purple-300 truncate flex-1" title={item.prediction_odds.title}>{item.prediction_odds.title}</span>
<span className="text-white font-bold whitespace-nowrap">{item.prediction_odds.consensus_pct}%</span>
@@ -1353,11 +1703,11 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
)}
<div className="flex items-center gap-1.5 mt-1 relative z-10 flex-wrap">
<span className={`text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${badgeClass}`}>
<span className={`text-[11px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${badgeClass}`}>
{isBreaking ? 'BREAKING' : `LVL: ${item.risk_score}/10`}
</span>
{item.sentiment != null && (
<span className={`text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${
<span className={`text-[11px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${
item.sentiment < -0.1 ? 'bg-red-500/10 text-red-400 border-red-500/30' :
item.sentiment > 0.1 ? 'bg-green-500/10 text-green-400 border-green-500/30' :
'bg-gray-500/10 text-gray-400 border-gray-500/30'
@@ -1367,7 +1717,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
</span>
)}
{item.oracle_score != null && (
<span className={`text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${
<span className={`text-[11px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${
item.oracle_score >= 7 ? 'bg-orange-500/10 text-orange-400 border-orange-500/30' :
item.oracle_score >= 4 ? 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30' :
'bg-cyan-500/10 text-cyan-400 border-cyan-500/30'
@@ -1376,17 +1726,17 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
</span>
)}
{checkDisinfoLinked(item.title) && (
<span className="text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border bg-amber-500/15 text-amber-400 border-amber-500/40 animate-pulse" title="This article echoes known disinformation narratives tracked by EUvsDisinfo">
<span className="text-[11px] font-bold font-mono px-1.5 py-0.5 rounded-sm border bg-amber-500/15 text-amber-400 border-amber-500/40 animate-pulse" title="This article echoes known disinformation narratives tracked by EUvsDisinfo">
DISINFORMATION-LINKED
</span>
)}
{item.cluster_count > 1 && (
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold font-mono text-cyan-500 bg-[var(--bg-secondary)]/50 hover:text-[var(--text-primary)] hover:bg-[var(--hover-accent)] border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
<button onClick={() => toggleExpand(idx)} className="text-[11px] font-bold font-mono text-cyan-500 bg-[var(--bg-secondary)]/50 hover:text-[var(--text-primary)] hover:bg-[var(--hover-accent)] border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
{isExpanded ? '- COLLAPSE' : `+${item.cluster_count - 1} SOURCES`}
</button>
)}
{item.coords && (
<span className="text-[8px] text-[var(--text-muted)] font-mono tracking-tighter ml-auto">
<span className="text-[11px] text-[var(--text-muted)] font-mono tracking-tighter ml-auto">
{item.coords[0].toFixed(2)}, {item.coords[1].toFixed(2)}
</span>
)}
@@ -1402,7 +1752,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
>
{item.articles.slice(1).map((subItem: any, subIdx: number) => (
<div key={subIdx} className="flex flex-col gap-0.5 pl-2 border-l border-cyan-500/20">
<div className="flex items-center justify-between text-[7.5px] uppercase font-bold">
<div className="flex items-center justify-between text-[11px] uppercase font-bold">
<span className="text-white">&gt;_ {subItem.source}</span>
<span className={
subItem.risk_score >= 9 ? 'text-red-400' :
@@ -1411,7 +1761,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
'text-green-400'
}>LVL: {subItem.risk_score}/10</span>
</div>
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors leading-tight">
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[11px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors leading-tight">
{subItem.title}
</a>
</div>
@@ -1425,7 +1775,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
{news.length === 0 && (
<div className="text-cyan-500/50 text-[10px] tracking-widest font-bold text-center mt-6">
NO NEWS ITEMS LOADED
<div className="mt-2 text-[9px] font-normal tracking-normal text-cyan-600/80">
<div className="mt-2 text-[11px] font-normal tracking-normal text-cyan-600/80">
Feed ingest is empty or still warming up.
</div>
</div>
+2 -2
View File
@@ -199,11 +199,11 @@ const OnboardingModal = React.memo(function OnboardingModal({
</div>
<div>
<span className="text-yellow-300">PRIVATE / TRANSITIONAL</span>
Wormhole private lane is active, but strongest Reticulum posture is still warming.
Wormhole lane is active. Gate chat runs on this lane, but metadata resistance is reduced until Reticulum is ready.
</div>
<div>
<span className="text-green-300">PRIVATE / STRONG</span> Wormhole and
Reticulum are both ready.
Reticulum are both ready. Dead Drop / DM requires this tier for the strongest privacy posture.
</div>
</div>
<p className="mt-2 text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
+69 -67
View File
@@ -3,8 +3,8 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
ChevronUp,
ChevronDown,
Minus,
Plus,
TrendingUp,
Trophy,
User,
@@ -154,7 +154,7 @@ function MarketCard({ market, onOpenModal }: { market: Market; onOpenModal: (m:
onClick={() => onOpenModal(market)}
>
<div className="p-2.5">
<div className="text-[10px] text-[var(--text-secondary)] font-mono leading-snug mb-1.5">
<div className="text-[13px] text-[var(--text-secondary)] font-mono leading-snug mb-1.5">
{market.title}
</div>
{/* Probability — leader name for multi-choice, bar for binary */}
@@ -162,8 +162,8 @@ function MarketCard({ market, onOpenModal }: { market: Market; onOpenModal: (m:
const leader = [...market.outcomes].filter(o => o.pct > 0).sort((a, b) => b.pct - a.pct)[0];
return leader ? (
<div className="flex items-center justify-between mb-1.5">
<span className="text-[9px] font-mono text-emerald-400 truncate mr-2">{leader.name}</span>
<span className="text-[10px] font-mono text-emerald-400 font-bold flex-shrink-0">{leader.pct}%</span>
<span className="text-[12px] font-mono text-emerald-400 truncate mr-2">{leader.name}</span>
<span className="text-[13px] font-mono text-emerald-400 font-bold flex-shrink-0">{leader.pct}%</span>
</div>
) : null;
})() : (
@@ -172,7 +172,7 @@ function MarketCard({ market, onOpenModal }: { market: Market; onOpenModal: (m:
<div className="bg-emerald-500/50 transition-all" style={{ width: `${pct}%` }} />
<div className="bg-red-500/30 flex-1" />
</div>
<span className="text-[9px] font-mono text-emerald-400 w-10 text-right">{pct}%</span>
<span className="text-[12px] font-mono text-emerald-400 w-10 text-right">{pct}%</span>
</div>
)}
{/* Bottom row: source badges + network activity + volume + end date */}
@@ -181,7 +181,7 @@ function MarketCard({ market, onOpenModal }: { market: Market; onOpenModal: (m:
{market.sources?.map((s, i) => (
<span
key={i}
className={`text-[7px] font-mono px-1 py-0.5 border ${
className={`text-[10px] font-mono px-1 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'
@@ -191,13 +191,13 @@ function MarketCard({ market, onOpenModal }: { market: Market; onOpenModal: (m:
</span>
))}
{hasPicks && (
<span className="text-[7px] font-mono px-1 py-0.5 border bg-amber-500/10 text-amber-400 border-amber-500/20">
<span className="text-[10px] font-mono px-1 py-0.5 border bg-amber-500/10 text-amber-400 border-amber-500/20">
{c.total_picks} picks
{c.total_staked > 0 ? ` · ${c.total_staked.toFixed(1)} REP` : ''}
</span>
)}
</div>
<div className="flex items-center gap-2 text-[7px] font-mono text-[var(--text-muted)]">
<div className="flex items-center gap-2 text-[10px] font-mono text-[var(--text-muted)]">
{vol && <span>{vol}</span>}
{endDate && <span>{endDate}</span>}
</div>
@@ -572,7 +572,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
setIsSearching(true);
try {
const res = await fetch(
`${API_BASE}/api/mesh/oracle/search?q=${encodeURIComponent(query)}&limit=20`,
`${API_BASE}/api/mesh/oracle/search?q=${encodeURIComponent(query)}&limit=50`,
);
if (res.ok) {
const d = await res.json();
@@ -769,27 +769,29 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="w-full bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 z-10 flex flex-col font-mono text-sm pointer-events-auto flex-shrink-0"
className="w-full bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 z-10 flex flex-col font-mono pointer-events-auto flex-shrink-0"
>
{/* Header */}
<div
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
className="flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-cyan-950/30 transition-colors border-b border-cyan-900/40"
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex items-center gap-2">
<Trophy size={12} className="text-[var(--text-muted)]" />
<span className="text-[12px] text-[var(--text-muted)] font-mono tracking-widest">
<Trophy size={16} className="text-emerald-400" />
<span className="text-[12px] text-emerald-400 font-mono tracking-widest font-bold">
ORACLE PREDICTIONS
</span>
{headerCount > 0 && (
<span className="text-[8px] bg-emerald-500/15 text-emerald-400 px-1.5 py-0.5 rounded-sm font-mono">
<span className="text-[11px] bg-emerald-500/15 text-emerald-400 px-1.5 py-0.5 font-mono">
{headerCount}
</span>
)}
</div>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
{isMinimized ? (
<Plus size={16} className="text-emerald-400" />
) : (
<Minus size={16} className="text-emerald-400" />
)}
</div>
<AnimatePresence>
@@ -806,7 +808,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={`flex-1 py-2 text-[10px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1 ${
className={`flex-1 py-2 text-[12px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1 ${
activeTab === t.id
? 'text-emerald-400 border-b border-emerald-400/60 bg-emerald-500/5'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
@@ -819,13 +821,13 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
{/* Status bar */}
{betStatus && (
<div className="px-3 py-1 text-[8px] font-mono text-center bg-emerald-500/10 text-emerald-400 border-b border-[var(--border-primary)]/30">
<div className="px-3 py-1 text-[11px] font-mono text-center bg-emerald-500/10 text-emerald-400 border-b border-[var(--border-primary)]/30">
{betStatus}
</div>
)}
{/* Content */}
<div className="overflow-y-auto styled-scrollbar max-h-[280px]">
<div className="overflow-y-auto styled-scrollbar max-h-[400px]">
{/* ─── MARKETS TAB ─── */}
{activeTab === 'markets' && (
<div className="flex flex-col">
@@ -840,8 +842,8 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
type="text"
value={searchQuery}
onChange={(e) => handleSearchInput(e.target.value)}
placeholder="SEARCH MARKETS..."
className="w-full pl-6 pr-6 py-1.5 text-[9px] font-mono tracking-wider bg-[var(--bg-primary)]/60 border border-[var(--border-primary)]/50 text-[var(--text-secondary)] placeholder:text-[var(--text-muted)] focus:outline-none focus:border-emerald-500/50"
placeholder="SEARCH ALL POLYMARKET + KALSHI MARKETS..."
className="w-full pl-6 pr-6 py-1.5 text-[12px] font-mono tracking-wider bg-[var(--bg-primary)]/60 border border-[var(--border-primary)]/50 text-[var(--text-secondary)] placeholder:text-[var(--text-muted)] focus:outline-none focus:border-emerald-500/50"
/>
{searchQuery && (
<button
@@ -860,13 +862,13 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
{/* Search results overlay */}
{searchQuery.length >= 2 && (
<div className="px-3 pb-2 flex flex-col gap-1">
<div className="text-[7px] font-mono tracking-widest text-[var(--text-muted)] mb-1">
<div className="text-[11px] font-mono tracking-widest text-[var(--text-muted)] mb-1">
{isSearching
? 'SEARCHING ALL MARKETS...'
: `${searchResults.length} RESULTS FROM POLYMARKET + KALSHI`}
</div>
{!isSearching && searchResults.length === 0 && (
<div className="text-[8px] text-[var(--text-muted)] font-mono text-center py-3">
<div className="text-[12px] text-[var(--text-muted)] font-mono text-center py-3">
NO RESULTS FOR &quot;{searchQuery.toUpperCase()}&quot;
</div>
)}
@@ -890,19 +892,19 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
{/* Category header */}
<button
onClick={() => toggleCategory(cat.id)}
className="w-full flex items-center justify-between px-3 py-2 text-[9px] font-mono tracking-widest hover:bg-[var(--bg-secondary)]/30 transition-colors"
className="w-full flex items-center justify-between px-3 py-2 text-[12px] font-mono tracking-widest hover:bg-[var(--bg-secondary)]/30 transition-colors"
>
<div className="flex items-center gap-1.5">
<cat.icon size={10} className={cat.color} />
<cat.icon size={12} className={cat.color} />
<span className={cat.color}>{cat.label}</span>
<span className="text-[7px] text-[var(--text-muted)]">
<span className="text-[10px] text-[var(--text-muted)]">
({catMarkets.length})
</span>
</div>
{isExpanded ? (
<ChevronUp size={10} className="text-[var(--text-muted)]" />
<Minus size={10} className="text-[var(--text-muted)]" />
) : (
<ChevronDown size={10} className="text-[var(--text-muted)]" />
<Plus size={10} className="text-[var(--text-muted)]" />
)}
</button>
{/* Category markets */}
@@ -916,7 +918,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
>
<div className="flex flex-col gap-1 px-3 pb-2">
{catMarkets.length === 0 && (
<div className="text-[8px] text-[var(--text-muted)] text-center py-2 font-mono">
<div className="text-[11px] text-[var(--text-muted)] text-center py-2 font-mono">
NO MARKETS
</div>
)}
@@ -933,7 +935,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
<button
onClick={() => loadMoreMarkets(cat.id)}
disabled={loadingMore.has(cat.id)}
className="w-full py-1.5 text-[8px] font-mono tracking-widest text-[var(--text-muted)] hover:text-emerald-400 border border-[var(--border-primary)]/30 hover:border-emerald-500/30 transition-colors disabled:opacity-50"
className="w-full py-1.5 text-[11px] font-mono tracking-widest text-[var(--text-muted)] hover:text-emerald-400 border border-[var(--border-primary)]/30 hover:border-emerald-500/30 transition-colors disabled:opacity-50"
>
{loadingMore.has(cat.id)
? 'LOADING...'
@@ -965,12 +967,12 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
if (newsLinked.length === 0) return null;
return (
<>
<div className="text-[7px] font-mono tracking-widest text-amber-400 mb-1">
<div className="text-[11px] font-mono tracking-widest text-amber-400 mb-1">
LINKED TO CURRENT HEADLINES
</div>
{newsLinked.map((m: any, i: number) => (
<div key={`nl-${i}`} className="border border-amber-500/30 bg-amber-950/20 p-2">
<div className="text-[9px] text-[var(--text-secondary)] font-mono leading-snug mb-1">
<div className="text-[12px] text-[var(--text-secondary)] font-mono leading-snug mb-1">
{m.title}
</div>
<div className="flex items-center gap-2">
@@ -978,16 +980,16 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
<div className="bg-amber-500/50 transition-all" style={{ width: `${m.consensus_pct}%` }} />
<div className="bg-red-500/20 flex-1" />
</div>
<span className="text-[8px] font-mono text-amber-400 font-bold">{m.consensus_pct}%</span>
<span className="text-[11px] font-mono text-amber-400 font-bold">{m.consensus_pct}%</span>
</div>
<div className="flex gap-1 mt-1">
{m.polymarket_pct != null && (
<span className="text-[7px] font-mono px-1 py-0.5 bg-purple-500/15 text-purple-400 border border-purple-500/20">
<span className="text-[10px] font-mono px-1 py-0.5 bg-purple-500/15 text-purple-400 border border-purple-500/20">
POLY {m.polymarket_pct}%
</span>
)}
{m.kalshi_pct != null && (
<span className="text-[7px] font-mono px-1 py-0.5 bg-blue-500/15 text-blue-400 border border-blue-500/20">
<span className="text-[10px] font-mono px-1 py-0.5 bg-blue-500/15 text-blue-400 border border-blue-500/20">
KALSHI {m.kalshi_pct}%
</span>
)}
@@ -1000,11 +1002,11 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
})()}
{/* Trending by Delta */}
<div className="text-[7px] font-mono tracking-widest text-[var(--text-muted)] mb-1">
<div className="text-[11px] font-mono tracking-widest text-[var(--text-muted)] mb-1">
BIGGEST PROBABILITY SWINGS
</div>
{(!trending_markets || trending_markets.length === 0) ? (
<div className="text-[8px] text-[var(--text-muted)] font-mono text-center py-4">
<div className="text-[11px] text-[var(--text-muted)] font-mono text-center py-4">
NO SWINGS DETECTED YET DELTAS APPEAR AFTER 2+ FETCH CYCLES
</div>
) : (
@@ -1032,7 +1034,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
setModalMarket(fakeMarket);
}}
>
<div className="text-[9px] text-[var(--text-secondary)] font-mono leading-snug mb-1">
<div className="text-[12px] text-[var(--text-secondary)] font-mono leading-snug mb-1">
{m.title}
</div>
<div className="flex items-center gap-2">
@@ -1040,8 +1042,8 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
<div className="bg-emerald-500/50 transition-all" style={{ width: `${m.consensus_pct ?? 50}%` }} />
<div className="bg-red-500/30 flex-1" />
</div>
<span className="text-[8px] font-mono text-emerald-400">{m.consensus_pct ?? '?'}%</span>
<span className={`text-[8px] font-mono font-bold ${isUp ? 'text-green-400' : 'text-red-400'}`}>
<span className="text-[11px] font-mono text-emerald-400">{m.consensus_pct ?? '?'}%</span>
<span className={`text-[11px] font-mono font-bold ${isUp ? 'text-green-400' : 'text-red-400'}`}>
{isUp ? '\u25B2' : '\u25BC'}{Math.abs(delta).toFixed(1)}%
</span>
</div>
@@ -1056,12 +1058,12 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
{activeTab === 'active' && (
<div className="flex flex-col gap-1 p-3">
{!nodeId && (
<div className="text-[9px] text-[var(--text-muted)] font-mono text-center py-6">
<div className="text-[12px] text-[var(--text-muted)] font-mono text-center py-6">
CONNECT WORMHOLE OR GENERATE IDENTITY IN MESH CHAT FIRST
</div>
)}
{nodeId && predictions.length === 0 && (
<div className="text-[9px] text-[var(--text-muted)] font-mono text-center py-6">
<div className="text-[12px] text-[var(--text-muted)] font-mono text-center py-6">
NO ACTIVE PREDICTIONS
</div>
)}
@@ -1070,10 +1072,10 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
key={i}
className="p-2 border border-[var(--border-primary)]/40 bg-[var(--bg-secondary)]/20"
>
<div className="text-[9px] text-[var(--text-secondary)] font-mono leading-snug mb-1.5">
<div className="text-[12px] text-[var(--text-secondary)] font-mono leading-snug mb-1.5">
{p.market_title}
</div>
<div className="flex items-center gap-2 text-[8px] font-mono flex-wrap">
<div className="flex items-center gap-2 text-[11px] font-mono flex-wrap">
<span
className={`px-1.5 py-0.5 rounded-sm border ${
p.side.toLowerCase() === 'no'
@@ -1106,63 +1108,63 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
{activeTab === 'profile' && (
<div className="p-3">
{!nodeId && (
<div className="text-[9px] text-[var(--text-muted)] font-mono text-center py-6">
<div className="text-[12px] text-[var(--text-muted)] font-mono text-center py-6">
CONNECT WORMHOLE OR GENERATE IDENTITY IN MESH CHAT FIRST
</div>
)}
{nodeId && !profile && (
<div className="text-[9px] text-[var(--text-muted)] font-mono text-center py-6">
<div className="text-[12px] text-[var(--text-muted)] font-mono text-center py-6">
PLACE YOUR FIRST PREDICTION
</div>
)}
{profile && (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-2">
<div className="p-2 border border-[var(--border-primary)]/40 bg-[var(--bg-secondary)]/20 text-center">
<div className="text-[14px] font-bold text-emerald-400 font-mono">
<div className="flex flex-col gap-1.5">
<div className="grid grid-cols-3 gap-1.5">
<div className="p-1.5 border border-[var(--border-primary)]/40 bg-[var(--bg-secondary)]/20 text-center">
<div className="text-[14px] font-bold text-blue-400 font-mono">
{profile.oracle_rep.toFixed(1)}
</div>
<div className="text-[7px] text-[var(--text-muted)] font-mono tracking-widest mt-0.5">
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">
ORACLE REP
</div>
</div>
<div className="p-2 border border-[var(--border-primary)]/40 bg-[var(--bg-secondary)]/20 text-center">
<div className="text-[14px] font-bold text-cyan-400 font-mono">
<div className="p-1.5 border border-[var(--border-primary)]/40 bg-[var(--bg-secondary)]/20 text-center">
<div className="text-[14px] font-bold text-blue-400 font-mono">
{profile.win_rate}%
</div>
<div className="text-[7px] text-[var(--text-muted)] font-mono tracking-widest mt-0.5">
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">
WIN RATE
</div>
</div>
<div className="p-2 border border-[var(--border-primary)]/40 bg-[var(--bg-secondary)]/20 text-center">
<div className="text-[14px] font-bold text-amber-400 font-mono">
<div className="p-1.5 border border-[var(--border-primary)]/40 bg-[var(--bg-secondary)]/20 text-center">
<div className="text-[14px] font-bold text-blue-400 font-mono">
{profile.predictions_won + profile.predictions_lost}
</div>
<div className="text-[7px] text-[var(--text-muted)] font-mono tracking-widest mt-0.5">
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">
TOTAL BETS
</div>
</div>
</div>
<div className="flex flex-col gap-1 text-[9px] font-mono">
<div className="flex justify-between px-1">
<div className="flex flex-col text-[12px] font-mono">
<div className="flex justify-between px-1 py-0.5">
<span className="text-[var(--text-muted)]">Available Rep</span>
<span className="text-emerald-400">
<span className="text-blue-400">
{profile.oracle_rep.toFixed(2)}
</span>
</div>
<div className="flex justify-between px-1">
<div className="flex justify-between px-1 py-0.5">
<span className="text-[var(--text-muted)]">Locked Rep</span>
<span className="text-amber-400">
<span className="text-blue-400">
{profile.oracle_rep_locked.toFixed(2)}
</span>
</div>
<div className="flex justify-between px-1">
<div className="flex justify-between px-1 py-0.5">
<span className="text-[var(--text-muted)]">W / L</span>
<span className="text-[var(--text-secondary)]">
{profile.predictions_won} / {profile.predictions_lost}
</span>
</div>
<div className="flex justify-between px-1">
<div className="flex justify-between px-1 py-0.5">
<span className="text-[var(--text-muted)]">Farming Score</span>
<span
className={
@@ -1175,7 +1177,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
</span>
</div>
</div>
<div className="text-[7px] text-[var(--text-muted)] font-mono text-center mt-1 opacity-60 truncate">
<div className="text-[10px] text-[var(--text-muted)] font-mono text-center opacity-60 truncate">
{nodeId}
</div>
</div>
@@ -419,7 +419,7 @@ export default function RadioInterceptPanel({
<Activity size={10} />
{feed.listeners.toLocaleString()}
</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">
<span className="text-[11px] text-[var(--text-muted)] font-mono mt-0.5">
LSTN
</span>
</div>
@@ -435,7 +435,7 @@ export default function RadioInterceptPanel({
<span className="text-[9px] font-mono tracking-widest text-emerald-400 font-bold">
SIGINT GRID
</span>
<div className="flex items-center gap-2 text-[8px] font-mono">
<div className="flex items-center gap-2 text-[11px] font-mono">
<span className="text-green-400">
APRS:{data.sigint.filter((s: SigintSignal) => s.source === 'aprs').length}
</span>
@@ -484,12 +484,12 @@ export default function RadioInterceptPanel({
</span>
<div className="flex items-center gap-1.5 shrink-0">
{sig.emergency && (
<span className="text-[7px] font-mono text-red-400 bg-red-500/20 px-1 tracking-wider">
<span className="text-[10px] font-mono text-red-400 bg-red-500/20 px-1 tracking-wider">
SOS
</span>
)}
<span
className="text-[7px] font-mono tracking-wider px-1"
className="text-[10px] font-mono tracking-wider px-1"
style={{ color: srcColor, backgroundColor: `${srcColor}15` }}
>
{(sig.source || '').toUpperCase()}
@@ -499,17 +499,17 @@ export default function RadioInterceptPanel({
{(stationType || freq) && (
<div className="flex items-center gap-1.5 mt-0.5">
{stationType && (
<span className="text-[8px] text-cyan-500/70 font-mono truncate">
<span className="text-[11px] text-cyan-500/70 font-mono truncate">
{stationType}
</span>
)}
{freq && (
<span className="text-[8px] text-amber-500/70 font-mono">{freq}</span>
<span className="text-[11px] text-amber-500/70 font-mono">{freq}</span>
)}
</div>
)}
{context && (
<p className="text-[8px] text-gray-400 font-mono truncate mt-0.5 leading-tight">
<p className="text-[11px] text-gray-400 font-mono truncate mt-0.5 leading-tight">
{context.slice(0, 70)}
</p>
)}
@@ -0,0 +1,422 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Radar, Plus, Trash2, MapPin, Crosshair } from 'lucide-react';
import { API_BASE } from '@/lib/api';
import type { SarAoi } from '@/types/dashboard';
interface SarAoiEditorModalProps {
onClose: () => void;
/** Enter map drop mode — modal hides, user clicks map to place AOI center. */
onRequestMapPick: () => void;
/** Coordinates picked from the map (set by parent after drop-mode click). */
pickedCoords: { lat: number; lng: number } | null;
/** Called after the modal consumes pickedCoords so the parent can clear them. */
onPickConsumed: () => void;
/** Called after an AOI is created or deleted so MaplibreViewer can refresh. */
onAoiListChanged?: () => void;
/** Whether map drop mode is currently active. */
dropModeActive?: boolean;
}
const AOI_CATEGORIES = [
{ value: 'watchlist', label: 'Watchlist' },
{ value: 'conflict', label: 'Conflict Zone' },
{ value: 'infrastructure', label: 'Infrastructure' },
{ value: 'natural_hazard', label: 'Natural Hazard' },
{ value: 'border', label: 'Border Area' },
{ value: 'maritime', label: 'Maritime' },
];
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
const SarAoiEditorModal = React.memo(function SarAoiEditorModal({
onClose,
onRequestMapPick,
pickedCoords,
onPickConsumed,
onAoiListChanged,
dropModeActive,
}: SarAoiEditorModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
// ----- AOI list -----
const [aois, setAois] = useState<SarAoi[]>([]);
const [listLoading, setListLoading] = useState(true);
const fetchAois = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/sar/aois`, { credentials: 'include' });
if (!res.ok) return;
const body = await res.json();
if (Array.isArray(body?.aois)) setAois(body.aois);
} catch { /* silent */ }
setListLoading(false);
}, []);
useEffect(() => { fetchAois(); }, [fetchAois]);
// ----- Form state -----
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [centerLat, setCenterLat] = useState('');
const [centerLon, setCenterLon] = useState('');
const [radiusKm, setRadiusKm] = useState('25');
const [category, setCategory] = useState('watchlist');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
// Consume picked coords from map
useEffect(() => {
if (pickedCoords) {
setCenterLat(pickedCoords.lat.toFixed(5));
setCenterLon(pickedCoords.lng.toFixed(5));
setShowForm(true);
onPickConsumed();
}
}, [pickedCoords, onPickConsumed]);
const resetForm = () => {
setName('');
setDescription('');
setCenterLat('');
setCenterLon('');
setRadiusKm('25');
setCategory('watchlist');
setError('');
};
const handleSubmit = async () => {
const trimName = name.trim();
if (!trimName) { setError('Name is required'); return; }
const lat = parseFloat(centerLat);
const lon = parseFloat(centerLon);
if (!Number.isFinite(lat) || lat < -90 || lat > 90) { setError('Latitude must be between -90 and 90'); return; }
if (!Number.isFinite(lon) || lon < -180 || lon > 180) { setError('Longitude must be between -180 and 180'); return; }
const rad = parseFloat(radiusKm);
if (!Number.isFinite(rad) || rad < 1 || rad > 500) { setError('Radius must be 1-500 km'); return; }
setSubmitting(true);
setError('');
try {
const payload = {
id: slugify(trimName) || `aoi_${Date.now()}`,
name: trimName,
description: description.trim(),
center_lat: lat,
center_lon: lon,
radius_km: rad,
category,
};
const res = await fetch(`${API_BASE}/api/sar/aois`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const d = body?.detail;
let msg = `HTTP ${res.status}`;
if (typeof d === 'string') msg = d;
else if (Array.isArray(d) && d.length > 0) {
msg = d.map((item: Record<string, unknown>) => {
if (typeof item === 'string') return item;
const loc = Array.isArray(item?.loc) ? (item.loc as string[]).slice(1).join('.') : '';
return loc ? `${loc}: ${item?.msg || 'invalid'}` : (item?.msg as string) || JSON.stringify(item);
}).join('; ');
} else if (d && typeof d === 'object') msg = JSON.stringify(d);
throw new Error(msg);
}
resetForm();
setShowForm(false);
await fetchAois();
onAoiListChanged?.();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to create AOI');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (aoiId: string) => {
try {
const res = await fetch(`${API_BASE}/api/sar/aois/${encodeURIComponent(aoiId)}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(typeof body?.detail === 'string' ? body.detail : `HTTP ${res.status}`);
}
await fetchAois();
onAoiListChanged?.();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to delete AOI');
}
};
// If drop mode is active, show a small floating pill instead of full modal
if (dropModeActive) {
if (!mounted) return null;
return createPortal(
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="fixed top-6 left-1/2 -translate-x-1/2 z-[9999] px-4 py-2 rounded-lg border border-cyan-500/60 bg-zinc-950/95 text-cyan-100 shadow-[0_0_20px_rgba(0,200,255,0.2)] flex items-center gap-3"
style={{ direction: 'ltr' }}
>
<Crosshair size={16} className="text-cyan-400 animate-pulse" />
<span className="text-xs font-mono tracking-wide">CLICK THE MAP TO PLACE AOI CENTER</span>
<button
type="button"
onClick={onClose}
className="ml-2 text-cyan-400 hover:text-cyan-200 text-xs underline"
>
Cancel
</button>
</motion.div>,
document.body,
);
}
if (!mounted) return null;
return createPortal(
<AnimatePresence>
<motion.div
key="aoi-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
(e.currentTarget as HTMLElement).dataset.downOnBackdrop = '1';
} else {
(e.currentTarget as HTMLElement).dataset.downOnBackdrop = '';
}
}}
onMouseUp={(e) => {
const el = e.currentTarget as HTMLElement;
const wasDown = el.dataset.downOnBackdrop === '1';
el.dataset.downOnBackdrop = '';
if (wasDown && e.target === e.currentTarget) onClose();
}}
style={{ direction: 'ltr' }}
className="fixed inset-0 z-[9999] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
>
<motion.div
key="aoi-modal"
initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.94, opacity: 0 }}
transition={{ type: 'spring', damping: 22, stiffness: 260 }}
onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-lg border border-cyan-500/40 bg-zinc-950/95 text-cyan-100 shadow-[0_0_40px_rgba(0,200,255,0.25)]"
>
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-cyan-500/30 bg-zinc-950/95 px-5 py-3">
<div className="flex items-center gap-2">
<Radar size={18} className="text-cyan-400" />
<span className="text-sm font-semibold tracking-wide">SAR AREAS OF INTEREST</span>
</div>
<button type="button" onClick={onClose} aria-label="Close" className="rounded p-1 text-cyan-300 hover:bg-cyan-500/10">
<X size={16} />
</button>
</div>
<div className="p-5 space-y-4">
{/* Error bar */}
{error && (
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded px-3 py-2">
{error}
</div>
)}
{/* AOI List */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold tracking-wide text-cyan-300/80">
{listLoading ? 'LOADING...' : `${aois.length} AOI${aois.length !== 1 ? 'S' : ''} DEFINED`}
</span>
{!showForm && (
<button
type="button"
onClick={() => setShowForm(true)}
className="flex items-center gap-1 text-xs text-cyan-400 hover:text-cyan-200 transition"
>
<Plus size={12} /> Add AOI
</button>
)}
</div>
{aois.length > 0 && (
<div className="space-y-1 max-h-48 overflow-y-auto styled-scrollbar">
{aois.map((aoi) => (
<div
key={aoi.id}
className="flex items-center justify-between gap-2 px-3 py-2 rounded border border-cyan-500/20 bg-cyan-500/5 hover:bg-cyan-500/10 transition group"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<MapPin size={12} className="text-cyan-400 flex-shrink-0" />
<span className="text-xs font-semibold truncate">{aoi.name}</span>
<span className="text-[10px] text-cyan-500/60 bg-cyan-500/10 px-1.5 rounded">
{aoi.category}
</span>
</div>
<div className="text-[10px] text-cyan-300/50 mt-0.5 ml-5">
{aoi.center[0].toFixed(3)}, {aoi.center[1].toFixed(3)} &middot; {aoi.radius_km} km
</div>
</div>
<button
type="button"
onClick={() => handleDelete(aoi.id)}
className="text-red-400/60 hover:text-red-400 opacity-0 group-hover:opacity-100 transition p-1"
title="Delete AOI"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
{!listLoading && aois.length === 0 && !showForm && (
<div className="text-xs text-cyan-300/50 text-center py-4">
No AOIs defined yet. Click &quot;Add AOI&quot; to create one.
</div>
)}
</div>
{/* Add AOI Form */}
{showForm && (
<div className="border border-cyan-500/30 rounded-lg p-4 space-y-3 bg-cyan-500/5">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold tracking-wide text-cyan-200">NEW AOI</span>
<button
type="button"
onClick={() => { setShowForm(false); resetForm(); }}
className="text-xs text-cyan-400/60 hover:text-cyan-300"
>
Cancel
</button>
</div>
{/* Name */}
<div>
<label className="text-[10px] text-cyan-300/70 block mb-1">NAME</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Crimea Bridge"
className="w-full bg-zinc-900 border border-cyan-500/30 rounded px-3 py-1.5 text-xs text-cyan-100 placeholder:text-cyan-500/30 focus:outline-none focus:border-cyan-400/60"
autoComplete="off"
/>
</div>
{/* Description */}
<div>
<label className="text-[10px] text-cyan-300/70 block mb-1">DESCRIPTION (optional)</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description"
className="w-full bg-zinc-900 border border-cyan-500/30 rounded px-3 py-1.5 text-xs text-cyan-100 placeholder:text-cyan-500/30 focus:outline-none focus:border-cyan-400/60"
autoComplete="off"
/>
</div>
{/* Center coordinates + pick button */}
<div className="flex gap-2 items-end">
<div className="flex-1">
<label className="text-[10px] text-cyan-300/70 block mb-1">LATITUDE</label>
<input
type="text"
value={centerLat}
onChange={(e) => setCenterLat(e.target.value)}
placeholder="45.2606"
className="w-full bg-zinc-900 border border-cyan-500/30 rounded px-3 py-1.5 text-xs text-cyan-100 placeholder:text-cyan-500/30 focus:outline-none focus:border-cyan-400/60"
autoComplete="off"
/>
</div>
<div className="flex-1">
<label className="text-[10px] text-cyan-300/70 block mb-1">LONGITUDE</label>
<input
type="text"
value={centerLon}
onChange={(e) => setCenterLon(e.target.value)}
placeholder="36.5106"
className="w-full bg-zinc-900 border border-cyan-500/30 rounded px-3 py-1.5 text-xs text-cyan-100 placeholder:text-cyan-500/30 focus:outline-none focus:border-cyan-400/60"
autoComplete="off"
/>
</div>
<button
type="button"
onClick={onRequestMapPick}
title="Pick from map"
className="flex-shrink-0 p-2 rounded border border-cyan-500/40 bg-cyan-500/10 text-cyan-300 hover:bg-cyan-500/20 hover:text-cyan-100 transition"
>
<Crosshair size={14} />
</button>
</div>
{/* Radius + Category */}
<div className="flex gap-2">
<div className="w-24">
<label className="text-[10px] text-cyan-300/70 block mb-1">RADIUS (km)</label>
<input
type="text"
value={radiusKm}
onChange={(e) => setRadiusKm(e.target.value)}
placeholder="25"
className="w-full bg-zinc-900 border border-cyan-500/30 rounded px-3 py-1.5 text-xs text-cyan-100 placeholder:text-cyan-500/30 focus:outline-none focus:border-cyan-400/60"
autoComplete="off"
/>
</div>
<div className="flex-1">
<label className="text-[10px] text-cyan-300/70 block mb-1">CATEGORY</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-zinc-900 border border-cyan-500/30 rounded px-3 py-1.5 text-xs text-cyan-100 focus:outline-none focus:border-cyan-400/60"
>
{AOI_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
</div>
{/* Submit */}
<button
type="button"
onClick={handleSubmit}
disabled={submitting}
className="w-full rounded border border-cyan-400/60 bg-cyan-500/15 px-4 py-2 text-xs font-semibold text-cyan-100 hover:bg-cyan-500/25 transition disabled:opacity-50"
>
{submitting ? 'CREATING...' : 'CREATE AOI'}
</button>
</div>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>,
document.body,
);
});
export default SarAoiEditorModal;
@@ -0,0 +1,400 @@
'use client';
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ExternalLink, Radar, Check, Zap, Globe } from 'lucide-react';
import { API_BASE } from '@/lib/api';
export const SAR_CHOICE_KEY = 'shadowbroker_sar_mode_choice';
export type SarChoice = 'a_only' | 'b_active' | null;
interface SarModeChooserModalProps {
onClose: () => void;
/** Called after the user makes a persistent choice. The parent uses
* this to flip the layer toggle on without prompting again. */
onChoiceMade: (choice: SarChoice) => void;
}
const MODE_B_EXTRAS = [
{
title: 'Ground Deformation (mm-scale)',
desc: 'NASA OPERA DISP + Copernicus EGMS — detects subsidence, landslides, building collapse, dam stress.',
},
{
title: 'Surface Water Change',
desc: 'OPERA DSWx — daily flood extent polygons from Sentinel-1, even through cloud cover.',
},
{
title: 'Vegetation Disturbance',
desc: 'OPERA DIST-ALERT — deforestation, burn scars, blast craters.',
},
{
title: 'Damage Assessments',
desc: 'UNOSAT + Copernicus EMS — hand-verified damage polygons from active disaster/conflict zones.',
},
{
title: 'Global Flood Monitoring (no account)',
desc: 'GFM daily Sentinel-1 flood masks — activates with any Mode B setup.',
},
];
const SIGNUP_STEPS = [
{
n: 1,
label: 'Create a free NASA Earthdata Login',
url: 'https://urs.earthdata.nasa.gov/users/new',
why: 'Takes about 1 minute. Used only to authorize OPERA product downloads.',
},
{
n: 2,
label: 'Generate an Earthdata user token',
url: 'https://urs.earthdata.nasa.gov/profile',
why: 'After login → "Generate Token". Copy the token string (NOT your password).',
},
{
n: 3,
label: 'Paste the token below and click "Activate Mode B"',
url: '',
why: 'Stored only on this node, in backend/data/sar_runtime.json. You can revoke it anytime.',
},
];
const SarModeChooserModal = React.memo(function SarModeChooserModal({
onClose,
onChoiceMade,
}: SarModeChooserModalProps) {
const [view, setView] = useState<'chooser' | 'signup'>('chooser');
const [earthdataToken, setEarthdataToken] = useState('');
const [earthdataUser, setEarthdataUser] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string>('');
const [mounted, setMounted] = useState(false);
// Portal target — document.body. We wait until mount so SSR doesn't
// try to touch `document`. Without the portal, the modal renders inside
// the left HUD container which has a CSS transform on an ancestor,
// breaking `position: fixed` and clipping it to a 320px-wide scrollable
// strip (which is why focusing the input made it "disappear").
useEffect(() => {
setMounted(true);
}, []);
const pickAOnly = () => {
try {
localStorage.setItem(SAR_CHOICE_KEY, 'a_only');
} catch {
// localStorage unavailable — still close the modal
}
onChoiceMade('a_only');
onClose();
};
const submitModeB = async () => {
if (earthdataToken.trim().length < 8) {
setError('Earthdata token looks too short. Paste the full token string.');
return;
}
setSubmitting(true);
setError('');
try {
const res = await fetch(`${API_BASE}/api/sar/mode-b/enable`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
earthdata_user: earthdataUser.trim(),
earthdata_token: earthdataToken.trim(),
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
// FastAPI validation errors come back as {detail: [{msg, loc, ...}]},
// plain auth errors come back as {detail: "string"}. Normalize both.
let msg = `HTTP ${res.status}`;
const d = body?.detail;
if (typeof d === 'string') {
msg = d;
} else if (Array.isArray(d) && d.length > 0) {
msg = d
.map((item) => {
if (typeof item === 'string') return item;
const loc = Array.isArray(item?.loc)
? item.loc.slice(1).join('.')
: '';
return loc
? `${loc}: ${item?.msg || 'invalid'}`
: item?.msg || JSON.stringify(item);
})
.join('; ');
} else if (d && typeof d === 'object') {
msg = JSON.stringify(d);
}
throw new Error(msg);
}
try {
localStorage.setItem(SAR_CHOICE_KEY, 'b_active');
} catch {
// ignore
}
onChoiceMade('b_active');
onClose();
} catch (e) {
setError(
e instanceof Error
? e.message
: 'Failed to activate Mode B. Check the backend logs.',
);
} finally {
setSubmitting(false);
}
};
if (!mounted) return null;
return createPortal(
<AnimatePresence>
<motion.div
key="sar-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
// Only close if BOTH mousedown and mouseup land on the backdrop
// itself. Otherwise a drag-select inside the token input that
// ends outside the modal box would fire a click on the backdrop
// and dismiss the modal.
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
(e.currentTarget as HTMLElement).dataset.downOnBackdrop = '1';
} else {
(e.currentTarget as HTMLElement).dataset.downOnBackdrop = '';
}
}}
onMouseUp={(e) => {
const el = e.currentTarget as HTMLElement;
const wasDown = el.dataset.downOnBackdrop === '1';
el.dataset.downOnBackdrop = '';
if (wasDown && e.target === e.currentTarget) {
onClose();
}
}}
style={{ direction: 'ltr' }}
className="fixed inset-0 z-[9999] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
>
<motion.div
key="sar-modal"
initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.94, opacity: 0 }}
transition={{ type: 'spring', damping: 22, stiffness: 260 }}
onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-lg border border-cyan-500/40 bg-zinc-950/95 text-cyan-100 shadow-[0_0_40px_rgba(0,200,255,0.25)]"
>
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-cyan-500/30 bg-zinc-950/95 px-5 py-3">
<div className="flex items-center gap-2">
<Radar size={18} className="text-cyan-400" />
<span className="text-sm font-semibold tracking-wide">
SAR GROUND-CHANGE LAYER
</span>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded p-1 text-cyan-300 hover:bg-cyan-500/10"
>
<X size={16} />
</button>
</div>
{view === 'chooser' && (
<div className="p-5 space-y-5">
<div className="text-sm text-cyan-200/90">
SAR (synthetic aperture radar) detects ground changes through cloud
cover, at night, anywhere on Earth. ShadowBroker offers two modes
both free. Pick one.
</div>
{/* Mode A */}
<div className="rounded border border-cyan-400/30 bg-cyan-500/5 p-4">
<div className="flex items-center gap-2 mb-1">
<Globe size={14} className="text-cyan-300" />
<span className="text-sm font-semibold text-cyan-200">
MODE A Catalog only (default)
</span>
</div>
<div className="text-xs text-cyan-200/70 mb-3">
Free Sentinel-1 scene metadata from Alaska Satellite Facility. No
account, no downloads, no credentials. Tells you when radar
passes happened over your AOIs and when the next pass is coming.
</div>
<button
type="button"
onClick={pickAOnly}
className="w-full rounded border border-cyan-400/60 bg-cyan-500/10 px-4 py-2 text-xs font-semibold text-cyan-100 hover:bg-cyan-500/20 transition"
>
<Check size={12} className="inline mr-1" />
Mode A is fine don&apos;t ask again
</button>
</div>
{/* Mode B */}
<div className="rounded border border-amber-400/40 bg-amber-500/5 p-4">
<div className="flex items-center gap-2 mb-1">
<Zap size={14} className="text-amber-300" />
<span className="text-sm font-semibold text-amber-200">
MODE B Full ground-change alerts
</span>
</div>
<div className="text-xs text-amber-200/80 mb-3">
Adds pre-computed anomalies from NASA OPERA, Copernicus EGMS,
GFM, EMS, and UNOSAT. Requires a free NASA Earthdata account
(~1 minute).
</div>
<ul className="text-xs text-amber-100/80 space-y-1 mb-3">
{MODE_B_EXTRAS.map((x) => (
<li key={x.title} className="flex gap-2">
<span className="text-amber-400 mt-0.5">+</span>
<span>
<span className="font-semibold text-amber-200">
{x.title}:
</span>{' '}
<span className="text-amber-100/70">{x.desc}</span>
</span>
</li>
))}
</ul>
<button
type="button"
onClick={() => setView('signup')}
className="w-full rounded border border-amber-400/60 bg-amber-500/10 px-4 py-2 text-xs font-semibold text-amber-100 hover:bg-amber-500/20 transition"
>
Set up Mode B (free, ~1 min)
</button>
</div>
</div>
)}
{view === 'signup' && (
<div className="p-5 space-y-4">
<button
type="button"
onClick={() => setView('chooser')}
className="text-xs text-cyan-400/80 hover:text-cyan-300"
>
back
</button>
<div className="text-sm font-semibold text-amber-200">
Activate Mode B
</div>
<ol className="space-y-3">
{SIGNUP_STEPS.map((s) => (
<li
key={s.n}
className="rounded border border-amber-400/25 bg-amber-500/5 p-3"
>
<div className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-amber-500/20 border border-amber-400/40 text-amber-200 text-xs font-bold flex items-center justify-center">
{s.n}
</span>
<div className="flex-1 text-xs">
<div className="font-semibold text-amber-100">
{s.label}
</div>
<div className="text-amber-100/70 mt-0.5">{s.why}</div>
{s.url && (
<a
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-amber-300 hover:text-amber-200 underline"
>
{s.url}
<ExternalLink size={10} />
</a>
)}
</div>
</div>
</li>
))}
</ol>
<div className="space-y-2 pt-1">
<label
htmlFor="sar-earthdata-user"
className="block text-xs text-amber-200/80"
>
Earthdata username (optional)
</label>
<input
id="sar-earthdata-user"
name="sar-earthdata-user"
type="text"
value={earthdataUser}
onChange={(e) => setEarthdataUser(e.target.value)}
placeholder="yourname"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
className="w-full rounded border border-amber-400/30 bg-zinc-900 px-3 py-2 text-xs text-amber-100 placeholder:text-amber-100/30 focus:border-amber-400/70 focus:outline-none"
/>
<label
htmlFor="sar-earthdata-token"
className="block text-xs text-amber-200/80 mt-2"
>
Earthdata user token (required)
</label>
<input
id="sar-earthdata-token"
name="sar-earthdata-token"
type="text"
value={earthdataToken}
onChange={(e) => setEarthdataToken(e.target.value)}
placeholder="eyJ0eXAiOiJKV1QiLCJhbGciOi..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
className="w-full rounded border border-amber-400/30 bg-zinc-900 px-3 py-2 text-xs text-amber-100 placeholder:text-amber-100/30 focus:border-amber-400/70 focus:outline-none font-mono tracking-tight"
/>
<div className="text-[10px] text-amber-100/50">
Stored locally on this node only. Never shared. Revoke anytime
in Settings SAR.
</div>
</div>
{error && (
<div className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
<button
type="button"
onClick={submitModeB}
disabled={submitting || earthdataToken.trim().length < 8}
className="w-full rounded border border-amber-400/60 bg-amber-500/20 px-4 py-2 text-xs font-semibold text-amber-100 hover:bg-amber-500/30 disabled:opacity-40 disabled:cursor-not-allowed transition"
>
{submitting ? 'Activating…' : 'Activate Mode B'}
</button>
</div>
)}
</motion.div>
</motion.div>
</AnimatePresence>,
document.body,
);
});
export default SarModeChooserModal;
+3 -3
View File
@@ -148,7 +148,7 @@ function ScaleBar({
{/* Unit toggle */}
<button
onClick={() => setUnit((u) => (u === 'mi' ? 'km' : 'mi'))}
className="text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-[var(--border-primary)] hover:border-cyan-500/50 text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-cyan-950/20 uppercase"
className="text-[11px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-[var(--border-primary)] hover:border-cyan-500/50 text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-cyan-950/20 uppercase"
title={`Switch to ${unit === 'mi' ? 'Metric (km)' : 'Imperial (mi)'}`}
>
{unit === 'mi' ? 'MI' : 'KM'}
@@ -157,7 +157,7 @@ function ScaleBar({
{/* Measure mode toggle */}
<button
onClick={onToggleMeasure}
className={`flex items-center gap-1 text-[8px] font-mono tracking-widest px-2 py-0.5 rounded border transition-all ${
className={`flex items-center gap-1 text-[11px] font-mono tracking-widest px-2 py-0.5 rounded border transition-all ${
measureMode
? 'border-cyan-500/60 text-cyan-400 bg-cyan-950/30 shadow-[0_0_8px_rgba(0,255,255,0.2)]'
: 'border-[var(--border-primary)] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-950/20'
@@ -172,7 +172,7 @@ function ScaleBar({
{measureMode && measurePoints && measurePoints.length > 0 && (
<button
onClick={onClearMeasure}
className="flex items-center gap-1 text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-red-400 hover:border-red-500/50 hover:bg-red-950/20 transition-all"
className="flex items-center gap-1 text-[11px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-red-400 hover:border-red-500/50 hover:bg-red-950/20 transition-all"
title="Clear all waypoints"
>
<Trash2 size={10} />
File diff suppressed because it is too large Load Diff
+182 -241
View File
@@ -2,19 +2,20 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertTriangle,
ChevronDown,
ChevronUp,
Download,
Eye,
EyeOff,
KeyRound,
Minus,
Plus,
Radar,
RefreshCw,
Save,
Search,
Server,
ShieldAlert,
Upload,
} from 'lucide-react';
import { API_BASE } from '@/lib/api';
import type { SelectedEntity } from '@/types/dashboard';
import type {
ShodanCountResponse,
@@ -167,7 +168,7 @@ function buildCsv(rows: ShodanSearchMatch[]): string {
}
export default function ShodanPanel({
onOpenSettings,
onOpenSettings: _onOpenSettings,
onResultsChange,
onSelectEntity,
onStyleChange,
@@ -199,6 +200,9 @@ export default function ShodanPanel({
const [customHex, setCustomHex] = useState('');
const [lastAction, setLastAction] = useState<(() => void) | null>(null);
const [unmappedCount, setUnmappedCount] = useState(0);
const [shodanApiKey, setShodanApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [keySaving, setKeySaving] = useState(false);
const prevSettingsOpen = useRef(settingsOpen);
const presetImportRef = useRef<HTMLInputElement | null>(null);
const resultImportRef = useRef<HTMLInputElement | null>(null);
@@ -496,52 +500,38 @@ export default function ShodanPanel({
return (
<div className="pointer-events-auto flex-shrink-0 border border-green-700/40 bg-black/75 backdrop-blur-sm shadow-[0_0_18px_rgba(34,197,94,0.12)]">
<div
className="flex items-center justify-between border-b border-green-700/30 bg-green-950/20 px-3 py-2 cursor-pointer"
className="flex items-center justify-between border-b border-green-700/30 bg-green-950/20 px-3 py-2.5 cursor-pointer hover:bg-green-950/40 transition-colors"
onClick={() => setIsMinimized((prev) => !prev)}
>
<div className="flex items-center gap-2">
<Radar size={13} className="text-green-400" />
<span className="text-[12px] font-mono font-bold tracking-[0.25em] text-green-400">
SHODAN CONNECTOR
<Radar size={16} className="text-green-400" />
<span className="text-[12px] font-mono font-bold tracking-widest text-green-400">
SHODAN
</span>
{currentResults.length > 0 && (
<span className="text-[11px] font-mono px-1.5 py-0.5 bg-green-900/30 border border-green-700/30 text-green-300">
{currentResults.length.toLocaleString()} MAPPED
</span>
)}
</div>
<div className="flex items-center gap-2 text-[12px] font-mono">
<span className="border border-green-700/40 px-1.5 py-0.5 text-green-300">
{currentResults.length.toLocaleString()} MAP
</span>
<span className="border border-green-700/40 px-1.5 py-0.5 text-green-500/80">
LOCAL
</span>
<div className="flex items-center gap-2">
{isMinimized ? (
<ChevronUp size={12} className="text-green-500" />
<Plus size={16} className="text-green-400" />
) : (
<ChevronDown size={12} className="text-green-500" />
<Minus size={16} className="text-green-400" />
)}
</div>
</div>
{!isMinimized && (
<>
<div className="border-b border-green-900/40 bg-green-950/10 px-3 py-2 text-sm font-mono leading-relaxed text-green-200/90">
<div className="flex items-start gap-2">
<AlertTriangle size={12} className="mt-0.5 text-green-400" />
<div>
<div className="font-bold tracking-wider text-green-400">PAID API / OPERATOR-SUPPLIED KEY</div>
<div>
Data from Shodan is fetched with the local <span className="text-green-400">SHODAN_API_KEY</span>,
rendered as a temporary overlay, and remains the operator&apos;s responsibility.
</div>
</div>
</div>
</div>
<div className="px-3 py-2">
<div className="mb-2 flex items-center gap-2 text-[13px] font-mono">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-mono">
{(['search', 'count', 'host'] as Mode[]).map((item) => (
<button
key={item}
onClick={() => setMode(item)}
className={`border px-2 py-1 tracking-[0.2em] transition-colors ${
className={`border px-2 py-0.5 tracking-[0.15em] transition-colors ${
mode === item
? 'border-green-500/50 bg-green-950/30 text-green-300'
: 'border-green-900/40 text-green-600 hover:border-green-700/60 hover:text-green-400'
@@ -552,44 +542,93 @@ export default function ShodanPanel({
))}
<button
onClick={refreshStatus}
className="ml-auto border border-green-900/40 px-2 py-1 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400"
title="Refresh Shodan status"
className="ml-auto text-green-600 transition-colors hover:text-green-400 p-0.5"
>
STATUS
<RefreshCw size={11} />
</button>
</div>
{!status?.configured && (
<div className="mb-3 border border-yellow-700/30 bg-yellow-950/10 px-3 py-2 text-sm font-mono text-yellow-300">
<div className="mb-2 flex items-center gap-2 font-bold tracking-wide">
<KeyRound size={12} /> SHODAN_API_KEY REQUIRED
<div className="mb-2 border border-green-700/30 bg-green-950/10 px-2.5 py-2">
<div className="flex items-center gap-1.5 text-[11px] font-mono text-green-300 mb-1.5">
<KeyRound size={10} />
<span className="tracking-wider">SHODAN API KEY</span>
<a
href="https://account.shodan.io/billing"
target="_blank"
rel="noopener noreferrer"
className="ml-auto text-[9px] text-green-500/60 hover:text-green-400 transition-colors"
>
GET KEY
</a>
</div>
<div className="flex items-center gap-1">
<input
type={showKey ? 'text' : 'password'}
value={shodanApiKey}
onChange={(e) => setShodanApiKey(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && shodanApiKey.trim()) {
setKeySaving(true);
fetch(`${API_BASE}/api/settings/api-keys`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ env_key: 'SHODAN_API_KEY', value: shodanApiKey.trim() }),
})
.then(() => refreshStatus())
.finally(() => setKeySaving(false));
}
}}
placeholder="Paste your Shodan API key"
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1 text-[11px] font-mono text-green-300 outline-none transition-colors focus:border-green-500/60 placeholder:text-green-800"
/>
<button
onClick={() => setShowKey(!showKey)}
className="p-1 text-green-600 hover:text-green-400 transition-colors"
title={showKey ? 'Hide key' : 'Show key'}
>
{showKey ? <EyeOff size={12} /> : <Eye size={12} />}
</button>
<button
disabled={!shodanApiKey.trim() || keySaving}
onClick={() => {
setKeySaving(true);
fetch(`${API_BASE}/api/settings/api-keys`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ env_key: 'SHODAN_API_KEY', value: shodanApiKey.trim() }),
})
.then(() => refreshStatus())
.finally(() => setKeySaving(false));
}}
className="border border-green-600/40 px-1.5 py-0.5 text-[10px] font-mono text-green-400 transition-colors hover:border-green-500/70 disabled:opacity-40"
>
{keySaving ? '...' : 'SAVE'}
</button>
</div>
<button
onClick={onOpenSettings}
className="border border-green-600/40 px-2 py-1 text-green-400 transition-colors hover:border-green-500/70"
>
OPEN SETTINGS
</button>
</div>
)}
<div className="space-y-2 text-sm font-mono">
<div className="space-y-1.5 text-[12px] font-mono">
{mode !== 'host' ? (
<>
<div className="flex items-center gap-2">
<Search size={12} className="text-green-500" />
<div className="flex items-center gap-1.5">
<Search size={11} className="text-green-500 shrink-0" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={'query (e.g. port:443 org:"Amazon")'}
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1.5 text-green-300 outline-none transition-colors focus:border-green-500/60"
onKeyDown={(e) => e.key === 'Enter' && (mode === 'search' ? void handleSearch() : void handleCount())}
placeholder='port:443 org:"Amazon"'
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1 text-green-300 outline-none transition-colors focus:border-green-500/60"
/>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<input
value={facets}
onChange={(e) => setFacets(e.target.value)}
placeholder="facets (country,port,org)"
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1.5 text-green-300 outline-none transition-colors focus:border-green-500/60"
placeholder="country,port,org"
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1 text-green-300 outline-none transition-colors focus:border-green-500/60"
/>
{mode === 'search' && (
<input
@@ -598,52 +637,34 @@ export default function ShodanPanel({
max={2}
value={page}
onChange={(e) => setPage(Math.max(1, Math.min(2, Number(e.target.value) || 1)))}
className="w-16 border border-green-900/50 bg-black/70 px-2 py-1.5 text-green-300 outline-none transition-colors focus:border-green-500/60"
title="Page number"
className="w-12 border border-green-900/50 bg-black/70 px-1.5 py-1 text-center text-green-300 outline-none transition-colors focus:border-green-500/60"
/>
)}
</div>
</>
) : (
<div className="flex items-center gap-2">
<Server size={12} className="text-green-500" />
<div className="flex items-center gap-1.5">
<Server size={11} className="text-green-500 shrink-0" />
<input
value={hostIp}
onChange={(e) => setHostIp(e.target.value)}
placeholder="host IP (e.g. 8.8.8.8)"
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1.5 text-green-300 outline-none transition-colors focus:border-green-500/60"
onKeyDown={(e) => e.key === 'Enter' && void handleHost()}
placeholder="8.8.8.8"
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1 text-green-300 outline-none transition-colors focus:border-green-500/60"
/>
</div>
)}
</div>
<div className="mt-3 flex items-center gap-2 text-[13px] font-mono">
{mode === 'search' && (
<button
onClick={() => void handleSearch()}
disabled={busy || !status?.configured}
className="border border-green-600/40 px-2.5 py-1.5 text-green-400 transition-colors hover:border-green-500/70 disabled:cursor-not-allowed disabled:opacity-40"
>
SEARCH / MAP
</button>
)}
{mode === 'count' && (
<button
onClick={() => void handleCount()}
disabled={busy || !status?.configured}
className="border border-green-600/40 px-2.5 py-1.5 text-green-400 transition-colors hover:border-green-500/70 disabled:cursor-not-allowed disabled:opacity-40"
>
COUNT / FACETS
</button>
)}
{mode === 'host' && (
<button
onClick={() => void handleHost()}
disabled={busy || !status?.configured}
className="border border-green-600/40 px-2.5 py-1.5 text-green-400 transition-colors hover:border-green-500/70 disabled:cursor-not-allowed disabled:opacity-40"
>
LOOKUP / MAP
</button>
)}
<div className="mt-2 flex items-center gap-1.5 text-[11px] font-mono">
<button
onClick={() => mode === 'host' ? void handleHost() : mode === 'count' ? void handleCount() : void handleSearch()}
disabled={busy || !status?.configured}
className="flex-1 border border-green-600/40 py-1.5 text-center text-green-400 transition-colors hover:border-green-500/70 hover:bg-green-950/20 disabled:cursor-not-allowed disabled:opacity-40"
>
{busy ? '...' : mode === 'host' ? 'LOOKUP' : mode === 'count' ? 'COUNT' : 'SEARCH'}
</button>
<button
onClick={handleClear}
className="border border-green-900/40 px-2.5 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400"
@@ -652,24 +673,22 @@ export default function ShodanPanel({
</button>
</div>
{/* ── Marker Style Configurator ── */}
<div className="mt-3 border border-green-900/40 bg-black/80 px-3 py-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-[13px] font-mono tracking-[0.22em] text-green-500">MARKER STYLE</span>
<span className="text-[14px] leading-none" style={{ color: styleConfig.color }}>
{/* ── Marker Style ── */}
<div className="mt-2 border border-green-900/40 bg-black/60 px-2.5 py-2">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[10px] font-mono tracking-widest text-green-600 uppercase">Style</span>
<span className="text-[13px] leading-none" style={{ color: styleConfig.color }}>
{SHAPE_OPTIONS.find((s) => s.value === styleConfig.shape)?.glyph ?? '●'}
</span>
</div>
{/* Shape */}
<div className="mb-2">
<div className="mb-1 text-[12px] font-mono tracking-widest text-green-600">SHAPE</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-3">
{/* Shape */}
<div className="flex items-center gap-1">
{SHAPE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => updateStyle({ shape: opt.value })}
className={`flex items-center justify-center w-8 h-7 border text-[13px] transition-colors ${
className={`flex items-center justify-center w-6 h-6 border text-[11px] transition-colors ${
styleConfig.shape === opt.value
? 'border-green-500/60 bg-green-950/40 text-green-300'
: 'border-green-900/40 text-green-700 hover:border-green-700/60 hover:text-green-400'
@@ -680,50 +699,14 @@ export default function ShodanPanel({
</button>
))}
</div>
</div>
{/* Color */}
<div className="mb-2">
<div className="mb-1 text-[12px] font-mono tracking-widest text-green-600">COLOR</div>
<div className="flex items-center gap-1.5 flex-wrap">
{COLOR_SWATCHES.map((hex) => (
<button
key={hex}
onClick={() => { updateStyle({ color: hex }); setCustomHex(''); }}
className={`w-5 h-5 border transition-all ${
styleConfig.color === hex && !customHex
? 'border-white scale-110'
: 'border-green-900/40 hover:border-green-600/60'
}`}
style={{ backgroundColor: hex }}
title={hex}
/>
))}
<input
value={customHex}
onChange={(e) => {
const v = e.target.value;
setCustomHex(v);
if (/^#[0-9a-fA-F]{6}$/.test(v)) {
updateStyle({ color: v });
}
}}
placeholder="#hex"
maxLength={7}
className="w-16 border border-green-900/50 bg-black/70 px-1.5 py-0.5 text-[13px] font-mono text-green-300 outline-none focus:border-green-500/60"
/>
</div>
</div>
{/* Size */}
<div>
<div className="mb-1 text-[12px] font-mono tracking-widest text-green-600">SIZE</div>
<div className="flex items-center gap-1.5">
<div className="w-px h-5 bg-green-900/40" />
{/* Size */}
<div className="flex items-center gap-1">
{SIZE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => updateStyle({ size: opt.value })}
className={`px-2.5 py-1 border text-[13px] font-mono tracking-wider transition-colors ${
className={`px-1.5 py-0.5 border text-[10px] font-mono transition-colors ${
styleConfig.size === opt.value
? 'border-green-500/60 bg-green-950/40 text-green-300'
: 'border-green-900/40 text-green-700 hover:border-green-700/60 hover:text-green-400'
@@ -733,134 +716,92 @@ export default function ShodanPanel({
</button>
))}
</div>
<div className="w-px h-5 bg-green-900/40" />
{/* Color swatches */}
<div className="flex items-center gap-1 flex-wrap">
{COLOR_SWATCHES.map((hex) => (
<button
key={hex}
onClick={() => { updateStyle({ color: hex }); setCustomHex(''); }}
className={`w-4 h-4 border transition-all ${
styleConfig.color === hex && !customHex
? 'border-white scale-110'
: 'border-green-900/40 hover:border-green-600/60'
}`}
style={{ backgroundColor: hex }}
title={hex}
/>
))}
</div>
</div>
</div>
<div className="mt-3 border border-green-900/40 bg-black/80 px-3 py-2">
<div className="mb-2 text-[13px] font-mono tracking-[0.22em] text-green-500">PRESETS / EXPORT</div>
<div className="mb-2 flex items-center gap-2">
{/* ── Presets & Data ── */}
<div className="mt-2 border border-green-900/40 bg-black/60 px-2.5 py-2">
<div className="mb-1.5 text-[10px] font-mono tracking-widest text-green-600 uppercase">Presets</div>
<div className="flex items-center gap-1.5 mb-1.5">
<input
value={presetLabel}
onChange={(e) => setPresetLabel(e.target.value)}
placeholder="preset label"
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1.5 text-sm text-green-300 outline-none transition-colors focus:border-green-500/60"
placeholder="label"
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1 text-[11px] font-mono text-green-300 outline-none transition-colors focus:border-green-500/60"
/>
<button
onClick={handleSavePreset}
className="border border-green-600/40 px-2 py-1.5 text-[13px] font-mono text-green-400 transition-colors hover:border-green-500/70"
>
<span className="inline-flex items-center gap-1">
<Save size={10} /> SAVE
</span>
<button onClick={handleSavePreset} title="Save preset" className="border border-green-600/40 p-1 text-green-400 transition-colors hover:border-green-500/70">
<Save size={11} />
</button>
</div>
<div className="flex flex-wrap gap-2 text-[13px] font-mono">
<button
onClick={exportPresets}
disabled={!presets.length}
className="border border-green-900/40 px-2 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400 disabled:opacity-40"
>
<span className="inline-flex items-center gap-1">
<Download size={10} /> EXPORT PRESETS
</span>
<button onClick={exportPresets} disabled={!presets.length} title="Export presets" className="border border-green-900/40 p-1 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400 disabled:opacity-40">
<Download size={11} />
</button>
<button
onClick={() => presetImportRef.current?.click()}
className="border border-green-900/40 px-2 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400"
>
<span className="inline-flex items-center gap-1">
<Upload size={10} /> IMPORT PRESETS
</span>
<button onClick={() => presetImportRef.current?.click()} title="Import presets" className="border border-green-900/40 p-1 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400">
<Upload size={11} />
</button>
<button
onClick={exportResultsJson}
disabled={!currentResults.length}
className="border border-green-900/40 px-2 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400 disabled:opacity-40"
>
<span className="inline-flex items-center gap-1">
<Download size={10} /> RESULTS JSON
</span>
</button>
<button
onClick={exportResultsCsv}
disabled={!currentResults.length}
className="border border-green-900/40 px-2 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400 disabled:opacity-40"
>
<span className="inline-flex items-center gap-1">
<Download size={10} /> RESULTS CSV
</span>
</button>
<button
onClick={() => resultImportRef.current?.click()}
className="border border-green-900/40 px-2 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400"
>
<span className="inline-flex items-center gap-1">
<Upload size={10} /> IMPORT RESULTS
</span>
</button>
<input
ref={presetImportRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => void importPresets(e)}
/>
<input
ref={resultImportRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => void importResults(e)}
/>
</div>
{presets.length > 0 && (
<div className="mt-3 max-h-32 space-y-1 overflow-y-auto styled-scrollbar">
<div className="max-h-20 space-y-0.5 overflow-y-auto styled-scrollbar mb-1.5">
{presets.map((preset) => (
<div
key={preset.id}
className="flex items-center justify-between border border-green-950/40 bg-green-950/10 px-2 py-1.5"
>
<button
onClick={() => applyPreset(preset)}
className="min-w-0 flex-1 truncate text-left text-sm font-mono text-green-300 transition-colors hover:text-green-200"
>
<div key={preset.id} className="flex items-center justify-between bg-green-950/10 px-2 py-0.5">
<button onClick={() => applyPreset(preset)} className="min-w-0 flex-1 truncate text-left text-[11px] font-mono text-green-300 transition-colors hover:text-green-200">
{preset.label}
</button>
<button
onClick={() => removePreset(preset.id)}
className="ml-2 text-[13px] font-mono text-green-700/70 transition-colors hover:text-red-300"
>
DELETE
</button>
<button onClick={() => removePreset(preset.id)} title="Delete preset" className="ml-1.5 text-[10px] font-mono text-green-700/70 transition-colors hover:text-red-300"></button>
</div>
))}
</div>
)}
</div>
<div className="mt-3 border border-green-900/40 bg-black/80 px-3 py-2 text-sm font-mono">
<div className="mb-1 flex items-center gap-2 text-green-500">
<ShieldAlert size={12} />
<span className="tracking-[0.25em]">SESSION STATUS</span>
</div>
<div className="text-green-300/90">{resultSummary}</div>
{status?.warning && <div className="mt-1 text-green-500/80">{status.warning}</div>}
{error && (
<div className="mt-2 flex items-center justify-between border border-red-900/40 bg-red-950/20 px-2 py-1.5 text-red-300">
<span>{error}</span>
{lastAction && (
<button
onClick={() => { setError(null); lastAction(); }}
disabled={busy}
className="ml-2 inline-flex shrink-0 items-center gap-1 border border-red-700/40 px-1.5 py-0.5 text-[13px] font-mono text-red-300 transition-colors hover:border-red-500/60 hover:text-red-200 disabled:opacity-40"
>
<RefreshCw size={9} /> RETRY
</button>
)}
{currentResults.length > 0 && (
<div className="flex items-center gap-1.5 pt-1.5 border-t border-green-900/30">
<span className="text-[10px] font-mono text-green-600">Export:</span>
<button onClick={exportResultsJson} className="text-[10px] font-mono text-green-500 hover:text-green-300 transition-colors">JSON</button>
<span className="text-green-900">·</span>
<button onClick={exportResultsCsv} className="text-[10px] font-mono text-green-500 hover:text-green-300 transition-colors">CSV</button>
<span className="text-green-900">·</span>
<button onClick={() => resultImportRef.current?.click()} className="text-[10px] font-mono text-green-500 hover:text-green-300 transition-colors">Import</button>
</div>
)}
<input ref={presetImportRef} type="file" accept=".json,application/json" className="hidden" title="Import presets file" onChange={(e) => void importPresets(e)} />
<input ref={resultImportRef} type="file" accept=".json,application/json" className="hidden" title="Import results file" onChange={(e) => void importResults(e)} />
</div>
{/* Status / Errors */}
<div className="mt-2 px-0.5 text-[11px] font-mono text-green-500/70">
{resultSummary}
{status?.warning && <span className="ml-1 text-yellow-500/70">· {status.warning}</span>}
</div>
{error && (
<div className="mt-1.5 flex items-center justify-between border border-red-900/40 bg-red-950/20 px-2 py-1 text-[11px] font-mono text-red-300">
<span className="truncate">{error}</span>
{lastAction && (
<button
onClick={() => { setError(null); lastAction(); }}
disabled={busy}
className="ml-1.5 shrink-0 text-red-400 hover:text-red-200 transition-colors disabled:opacity-40"
>
<RefreshCw size={10} />
</button>
)}
</div>
)}
{countSummary && (
<div className="mt-3 max-h-40 space-y-2 overflow-y-auto border border-green-900/40 bg-black/80 p-3 styled-scrollbar">
<div className="text-[13px] font-mono tracking-[0.22em] text-green-500">FACETS</div>
+569
View File
@@ -0,0 +1,569 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Camera,
ChevronDown,
ChevronUp,
Clock,
Coffee,
Gauge,
Minus,
Moon,
Pause,
Play,
Plus,
Radio,
RotateCcw,
Settings2,
Shield,
SkipBack,
SkipForward,
Zap,
} from 'lucide-react';
import { useDataKey } from '@/hooks/useDataStore';
import { API_BASE } from '@/lib/api';
import { controlPlaneFetch } from '@/lib/controlPlane';
import {
enterSnapshotMode,
exitSnapshotMode,
refreshHourlyIndex,
seekToTime,
setPlaybackSpeed,
stepBackward,
stepForward,
togglePlayback,
useTimeMachine,
} from '@/hooks/useTimeMachine';
import type { DashboardData } from '@/types/dashboard';
const SPEED_OPTIONS = [
{ label: 'FAST', value: 3, desc: '3 seconds between snapshots' },
{ label: 'NORMAL', value: 6, desc: '6 seconds between snapshots' },
{ label: 'SLOW', value: 12, desc: '12 seconds between snapshots' },
{ label: 'VERY SLOW', value: 20, desc: '20 seconds between snapshots' },
];
const PRESET_META: Record<string, { label: string; desc: string; icon: typeof Zap }> = {
paranoid: { label: 'PARANOID', desc: 'Every 5 min high-freq / 30 min standard', icon: Shield },
active: { label: 'ACTIVE', desc: 'Every 15 min high-freq / 2 hr standard', icon: Zap },
casual: { label: 'CASUAL', desc: 'Every 60 min high-freq / 6 hr standard', icon: Coffee },
minimal: { label: 'MINIMAL', desc: 'Every 6 hr high-freq / standard off', icon: Moon },
};
function formatClock(unixTs: number | null): string {
if (!unixTs) return '--:--';
const d = new Date(unixTs * 1000);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatFullTime(unixTs: number | null): string {
if (!unixTs) return 'No snapshot selected';
const d = new Date(unixTs * 1000);
return d.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function pct(value: number, min: number, max: number): number {
if (max <= min) return 0;
return Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
}
export default function TimelinePanel() {
const tm = useTimeMachine();
const [isMinimized, setIsMinimized] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const [tmEnabled, setTmEnabled] = useState(false);
const [tmSaving, setTmSaving] = useState(false);
const [activePreset, setActivePreset] = useState('active');
const [snapshotBusy, setSnapshotBusy] = useState(false);
const [isScrubbing, setIsScrubbing] = useState(false);
const [scrubOffsetMs, setScrubOffsetMs] = useState<number | null>(null);
useEffect(() => {
const fetchStatus = () => {
fetch(`${API_BASE}/api/settings/timemachine`)
.then((r) => r.json())
.then((d) => setTmEnabled(!!d.enabled))
.catch(() => {});
fetch(`${API_BASE}/api/ai/timemachine/config`)
.then((r) => r.json())
.then((d) => {
if (d.config?.preset) setActivePreset(d.config.preset);
})
.catch(() => {});
};
fetchStatus();
refreshHourlyIndex();
const iv = setInterval(fetchStatus, 60_000);
return () => clearInterval(iv);
}, []);
const toggleTm = useCallback(async () => {
setTmSaving(true);
try {
const res = await controlPlaneFetch('/api/settings/timemachine', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !tmEnabled }),
requireAdminSession: false,
});
if (res.ok) {
const data = await res.json();
setTmEnabled(!!data.enabled);
if (data.enabled) {
await fetch(`${API_BASE}/api/ai/timemachine/snapshot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ compress: true, profile: 'manual' }),
});
await refreshHourlyIndex();
}
}
} catch {}
setTmSaving(false);
}, [tmEnabled]);
const applyPreset = useCallback(async (preset: string) => {
try {
const res = await controlPlaneFetch('/api/ai/timemachine/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preset }),
requireAdminSession: false,
});
if (res.ok) setActivePreset(preset);
} catch {}
}, []);
const takeSnapshot = useCallback(async () => {
setSnapshotBusy(true);
try {
const res = await fetch(`${API_BASE}/api/ai/timemachine/snapshot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ compress: true, profile: 'manual' }),
});
if (res.ok) {
const json = await res.json();
await refreshHourlyIndex();
const snapshotId = json.snapshot_id || json.id;
if (snapshotId) await enterSnapshotMode(snapshotId);
}
} catch {}
setSnapshotBusy(false);
}, []);
const isSnapshot = tm.mode === 'snapshot';
const totalSnapshots = tm.snapshots.length;
const timelineStart = tm.timelineStart ?? 0;
const timelineEnd = tm.timelineEnd ?? timelineStart;
const currentUnixTs = tm.currentUnixTs ?? timelineEnd;
const hasPlayableRange = tmEnabled && totalSnapshots > 0 && timelineEnd > timelineStart;
const timelineSpanMs = Math.max(1, Math.round((timelineEnd - timelineStart) * 1000));
const liveOffsetMs = Math.max(0, Math.min(timelineSpanMs, Math.round((currentUnixTs - timelineStart) * 1000)));
const effectiveOffsetMs = isScrubbing && scrubOffsetMs !== null ? scrubOffsetMs : liveOffsetMs;
const effectiveUnixTs = timelineStart + effectiveOffsetMs / 1000;
const progressPct = pct(effectiveOffsetMs, 0, timelineSpanMs);
const snapshotMarks = useMemo(() => {
if (!hasPlayableRange) return [];
return tm.snapshots.map((snap) => ({
id: snap.id,
left: pct(snap.unix_ts, timelineStart, timelineEnd),
}));
}, [hasPlayableRange, timelineEnd, timelineStart, tm.snapshots]);
const startPlaybackFromPanel = useCallback(() => {
if (!isSnapshot && tm.snapshots[0]) {
enterSnapshotMode(tm.snapshots[0].id).then(() => togglePlayback());
return;
}
togglePlayback();
}, [isSnapshot, tm.snapshots]);
const commitScrub = useCallback((offsetMs: number | null) => {
if (!hasPlayableRange || offsetMs === null) return;
const clamped = Math.max(0, Math.min(timelineSpanMs, offsetMs));
setIsScrubbing(false);
setScrubOffsetMs(null);
void seekToTime(timelineStart + clamped / 1000);
}, [hasPlayableRange, timelineSpanMs, timelineStart]);
const handleScrubStart = useCallback(() => {
if (!hasPlayableRange) return;
if (tm.playing) togglePlayback();
setIsScrubbing(true);
setScrubOffsetMs(liveOffsetMs);
}, [hasPlayableRange, liveOffsetMs, tm.playing]);
const handleScrubChange = useCallback((value: string) => {
const nextOffsetMs = Number(value);
if (!Number.isFinite(nextOffsetMs)) return;
setScrubOffsetMs(nextOffsetMs);
if (!isScrubbing) {
commitScrub(nextOffsetMs);
}
}, [commitScrub, isScrubbing]);
useEffect(() => {
if (!isScrubbing) return;
const finish = () => commitScrub(scrubOffsetMs);
window.addEventListener('pointerup', finish);
return () => {
window.removeEventListener('pointerup', finish);
};
}, [commitScrub, isScrubbing, scrubOffsetMs]);
return (
<div className="bg-[rgba(5,10,18,0.92)] border border-cyan-900/40 backdrop-blur-sm">
<div
className="flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-cyan-950/30 transition-colors border-b border-cyan-900/40"
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex items-center gap-2 min-w-0">
<Clock size={16} className={isSnapshot ? 'text-amber-400' : 'text-cyan-400'} />
<span
className={`text-[12px] font-mono tracking-widest font-bold ${
isSnapshot ? 'text-amber-400' : 'text-cyan-400'
}`}
>
TIME MACHINE
</span>
<span
className={`text-[10px] font-mono tracking-wider px-1.5 py-0.5 border ${
isSnapshot
? 'text-amber-300 border-amber-600/50 bg-amber-950/30'
: 'text-emerald-300 border-emerald-600/40 bg-emerald-950/20'
}`}
>
{isSnapshot ? 'SNAPSHOT' : 'LIVE'}
</span>
</div>
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${isSnapshot ? 'bg-amber-400' : 'bg-emerald-500'}`} />
{isMinimized ? <Plus size={16} className="text-cyan-400" /> : <Minus size={16} className="text-cyan-400" />}
</div>
</div>
{!isMinimized && (
<div className="px-3 py-3 flex flex-col gap-3">
{isSnapshot && (
<div className="flex items-center justify-between gap-3 px-3 py-2 bg-amber-950/35 border border-amber-500/45 rounded-sm">
<div className="min-w-0">
<div className="text-[12px] font-mono tracking-wider font-bold text-amber-300">
VIEWING RECORDED SNAPSHOT
</div>
<div className="text-[11px] font-mono text-amber-200/70 truncate">
{formatFullTime(tm.currentUnixTs)}
</div>
</div>
<button
type="button"
onClick={() => exitSnapshotMode()}
className="flex shrink-0 items-center gap-1.5 px-3 py-1.5 text-[12px] font-mono tracking-wider font-bold text-emerald-300 bg-emerald-950/40 hover:bg-emerald-900/50 border border-emerald-500/50 rounded-sm transition-colors"
>
<RotateCcw size={13} />
LIVE
</button>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Radio size={12} className={tmEnabled ? 'text-emerald-400' : 'text-red-500/60'} />
<span className={`text-[11px] font-mono tracking-wider ${tmEnabled ? 'text-emerald-400' : 'text-red-400/60'}`}>
{tmEnabled ? 'LIVE CAPTURE ON' : 'SNAPSHOTS OFF'}
</span>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={takeSnapshot}
disabled={!tmEnabled || snapshotBusy}
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-mono tracking-wider text-cyan-400 hover:text-cyan-300 bg-cyan-950/30 hover:bg-cyan-950/50 border border-cyan-900/30 rounded-sm transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title="Capture current map state"
>
<Camera size={10} />
{snapshotBusy ? 'SAVING...' : 'SNAP'}
</button>
<button
type="button"
onClick={() => setConfigOpen(!configOpen)}
className={`flex items-center gap-1 px-2 py-0.5 text-[10px] font-mono tracking-wider border rounded-sm transition-colors ${
configOpen
? 'text-amber-300 bg-amber-950/30 border-amber-700/40'
: 'text-cyan-400 hover:text-cyan-300 bg-cyan-950/30 hover:bg-cyan-950/50 border-cyan-900/30'
}`}
>
<Settings2 size={10} />
CONFIGURE
{configOpen ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
</div>
</div>
{configOpen && (
<div className="border border-cyan-900/30 bg-[rgba(5,5,10,0.95)] p-3 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[11px] font-mono tracking-wider text-[var(--text-secondary)]">SNAPSHOTS</span>
<button
type="button"
onClick={toggleTm}
disabled={tmSaving}
className={`px-3 py-1 text-[11px] font-mono tracking-wider border rounded-sm transition-colors ${
tmEnabled
? 'text-emerald-300 border-emerald-600/40 bg-emerald-950/30 hover:bg-emerald-950/50'
: 'text-red-400 border-red-800/40 bg-red-950/20 hover:bg-red-950/40'
} disabled:opacity-40`}
>
{tmSaving ? '...' : tmEnabled ? 'ON' : 'OFF'}
</button>
</div>
<div>
<span className="text-[10px] font-mono tracking-wider text-[var(--text-muted)] block mb-2">
CAPTURE FREQUENCY
</span>
<div className="grid grid-cols-2 gap-1.5">
{Object.entries(PRESET_META).map(([key, meta]) => {
const Icon = meta.icon;
const active = activePreset === key;
return (
<button
key={key}
type="button"
onClick={() => applyPreset(key)}
className={`flex items-center gap-1.5 px-2 py-1.5 text-left border rounded-sm transition-colors ${
active
? 'text-amber-300 border-amber-600/50 bg-amber-950/30'
: 'text-[var(--text-secondary)] border-cyan-900/20 hover:bg-cyan-950/20 hover:border-cyan-800/30'
}`}
>
<Icon size={12} className={active ? 'text-amber-400' : 'text-cyan-600'} />
<div>
<div className="text-[10px] font-mono tracking-wider font-bold">{meta.label}</div>
<div className="text-[11px] font-mono text-[var(--text-muted)] leading-tight">{meta.desc}</div>
</div>
</button>
);
})}
</div>
</div>
</div>
)}
{tmEnabled && totalSnapshots > 0 ? (
<div className={`border rounded-sm px-3 py-3 ${isSnapshot ? 'border-amber-800/40 bg-amber-950/15' : 'border-cyan-900/30 bg-cyan-950/10'}`}>
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-mono tracking-wider text-[var(--text-muted)]">
{formatClock(timelineStart)}
</span>
<span className={`text-[12px] font-mono tracking-wider font-bold ${isSnapshot ? 'text-amber-300' : 'text-cyan-300'}`}>
{isSnapshot || isScrubbing ? formatFullTime(effectiveUnixTs) : `${totalSnapshots} snapshots ready`}
</span>
<span className="text-[11px] font-mono tracking-wider text-[var(--text-muted)]">
{formatClock(timelineEnd)}
</span>
</div>
<div className="px-1 pt-2 pb-1">
<div className="relative h-8">
<div className="absolute left-1 right-1 top-1/2 h-[3px] -translate-y-1/2 rounded-full bg-cyan-950/80 border border-cyan-900/40" />
<div
className={`absolute left-1 top-1/2 h-[3px] -translate-y-1/2 rounded-full ${isSnapshot ? 'bg-amber-400/70' : 'bg-cyan-400/60'}`}
style={{ width: `${progressPct}%` }}
/>
<input
type="range"
min={0}
max={timelineSpanMs}
step={1000}
value={effectiveOffsetMs}
disabled={!hasPlayableRange}
onPointerDown={handleScrubStart}
onChange={(e) => handleScrubChange(e.currentTarget.value)}
className="relative z-10 h-8 w-full bg-transparent cursor-pointer disabled:cursor-default"
style={{ accentColor: isSnapshot ? '#f59e0b' : '#22d3ee' }}
aria-label="Snapshot playback position"
/>
</div>
<div className="mt-1.5 flex items-center gap-2">
<span className="shrink-0 text-[9px] font-mono tracking-[0.24em] text-[var(--text-muted)] opacity-70">
SNAPS
</span>
<div className="relative h-2 flex-1 rounded-full bg-cyan-950/25">
{snapshotMarks.map((mark) => (
<span
key={mark.id}
className={`absolute top-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full ${
isSnapshot ? 'bg-amber-300/75' : 'bg-cyan-300/65'
}`}
style={{ left: `${mark.left}%` }}
/>
))}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 mt-2">
<button
type="button"
onClick={stepBackward}
className="p-2 rounded-sm transition-colors text-cyan-300 hover:text-cyan-100 hover:bg-cyan-950/40 disabled:opacity-30"
disabled={!hasPlayableRange}
title="Previous snapshot"
>
<SkipBack size={18} />
</button>
<button
type="button"
onClick={startPlaybackFromPanel}
className={`flex items-center justify-center gap-2 px-5 py-1.5 rounded-sm text-[12px] font-mono tracking-wider font-bold transition-colors min-w-[110px] ${
tm.playing
? 'text-amber-300 bg-amber-600/20 hover:bg-amber-600/30 border border-amber-600/40'
: 'text-cyan-300 bg-cyan-950/30 hover:bg-cyan-950/50 border border-cyan-900/40'
}`}
disabled={!hasPlayableRange}
>
{tm.playing ? (
<>
<Pause size={16} /> PAUSE
</>
) : (
<>
<Play size={16} /> PLAY
</>
)}
</button>
<button
type="button"
onClick={stepForward}
className="p-2 rounded-sm transition-colors text-cyan-300 hover:text-cyan-100 hover:bg-cyan-950/40 disabled:opacity-30"
disabled={!hasPlayableRange}
title="Next snapshot"
>
<SkipForward size={18} />
</button>
<button
type="button"
onClick={() => exitSnapshotMode()}
disabled={!isSnapshot}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-mono tracking-wider font-bold text-emerald-300 bg-emerald-950/30 hover:bg-emerald-900/40 border border-emerald-500/40 rounded-sm transition-colors disabled:opacity-40 disabled:hover:bg-emerald-950/30"
title="Return to live feed"
>
<RotateCcw size={13} />
LIVE
</button>
</div>
<div className="flex items-center justify-between gap-2 mt-3">
<div className="flex items-center gap-1.5 text-[11px] font-mono tracking-wider text-[var(--text-muted)]">
<Gauge size={12} />
PLAYBACK
</div>
<div className="flex gap-1">
{SPEED_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setPlaybackSpeed(opt.value)}
className={`px-2 py-1 text-[10px] font-mono tracking-wider border rounded-sm transition-colors ${
tm.playbackSpeed === opt.value
? 'text-amber-300 border-amber-600/50 bg-amber-950/30'
: 'text-[var(--text-secondary)] border-cyan-900/20 hover:bg-cyan-950/20'
}`}
title={opt.desc}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
) : tmEnabled ? (
<div className="w-full border border-cyan-900/30 rounded-sm py-3 px-3 bg-cyan-950/10 text-center">
<div className="text-[12px] font-mono text-cyan-500 tracking-wider mb-1">
WAITING FOR FIRST SNAPSHOT
</div>
<div className="text-[11px] font-mono text-[var(--text-muted)] leading-relaxed">
Recording is on. Playback controls will appear after the first capture.
</div>
<button
type="button"
onClick={takeSnapshot}
disabled={snapshotBusy}
className="mt-2 flex items-center gap-1.5 mx-auto px-4 py-1.5 text-[11px] font-mono tracking-wider text-cyan-400 hover:text-cyan-300 border border-cyan-800/40 hover:border-cyan-600/50 bg-cyan-950/20 hover:bg-cyan-950/40 rounded-sm transition-colors"
>
<Camera size={12} />
{snapshotBusy ? 'SAVING...' : 'TAKE FIRST SNAPSHOT NOW'}
</button>
</div>
) : (
<div className="w-full border border-cyan-900/30 rounded-sm py-4 px-3 bg-cyan-950/10 text-center">
<div className="text-[12px] font-mono text-[var(--text-muted)] tracking-wider leading-relaxed mb-3">
Enable snapshots to record map state and play it back later.
</div>
<button
type="button"
onClick={toggleTm}
className="px-5 py-2 text-[12px] font-mono tracking-wider font-bold text-cyan-400 hover:text-cyan-300 border border-cyan-700/50 hover:border-cyan-500/60 bg-cyan-950/30 hover:bg-cyan-950/50 rounded-sm transition-colors"
>
ENABLE SNAPSHOTS
</button>
</div>
)}
{tm.loading && (
<div className="text-[11px] font-mono text-amber-500/70 tracking-wider text-center animate-pulse">
LOADING RECORDED FRAME...
</div>
)}
{tm.error && (
<div className="text-[11px] font-mono text-red-400/80 tracking-wider text-center">
{tm.error}
</div>
)}
{isSnapshot && (
<div className="border border-amber-900/20 bg-amber-950/10 px-3 py-2">
<div className="text-[11px] font-mono tracking-wider text-amber-400/70 mb-1.5">
RECORDED LAYERS
</div>
<div className="grid grid-cols-3 gap-x-2 gap-y-1">
<TelemetryDot label="FLIGHTS" dataKey="commercial_flights" />
<TelemetryDot label="MILITARY" dataKey="military_flights" />
<TelemetryDot label="SHIPS" dataKey="ships" />
<TelemetryDot label="SATS" dataKey="satellites" />
<TelemetryDot label="NEWS" dataKey="news" />
<TelemetryDot label="QUAKES" dataKey="earthquakes" />
<TelemetryDot label="GDELT" dataKey="gdelt" />
<TelemetryDot label="SIGINT" dataKey="sigint" />
<TelemetryDot label="FIRES" dataKey="firms_fires" />
</div>
</div>
)}
</div>
)}
</div>
);
}
function TelemetryDot({ label, dataKey }: { label: string; dataKey: keyof DashboardData }) {
const data = useDataKey(dataKey);
const count = Array.isArray(data) ? data.length : 0;
const active = count > 0;
return (
<div className="flex items-center gap-1.5">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${active ? 'bg-emerald-400' : 'bg-red-900/50'}`} />
<span className="text-[11px] font-mono tracking-wider text-[var(--text-muted)]">{label}</span>
{active && <span className="text-[11px] font-mono text-emerald-500/70">{count}</span>}
</div>
);
}
@@ -0,0 +1,388 @@
'use client';
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { useDataKey } from '@/hooks/useDataStore';
import { API_BASE } from '@/lib/api';
import { controlPlaneFetch } from '@/lib/controlPlane';
import {
useTimeMachine,
enterSnapshotMode,
exitSnapshotMode,
stepForward,
stepBackward,
togglePlayback,
refreshHourlyIndex,
} from '@/hooks/useTimeMachine';
import type { NewsArticle } from '@/types/dashboard';
/**
* TimelineScrubber 24-hour activity timeline with Time Machine playback.
*
* LIVE MODE: Shows news density histogram. Bins with snapshots are highlighted.
* SNAPSHOT MODE: Shows playback controls (rewind, step, play/pause, live).
* Clicking a bin with snapshot data enters snapshot mode for that hour.
*/
const HOURS = 24;
const BAR_W = 350;
const BAR_H = 32;
function getRiskColor(score: number): string {
if (score >= 9) return '#ef4444';
if (score >= 7) return '#f97316';
if (score >= 4) return '#eab308';
return '#22d3ee';
}
interface HourBin {
hour: number;
count: number;
maxRisk: number;
label: string;
hasSnapshot: boolean;
snapshotId: string | null;
}
export default function TimelineScrubber() {
const news = useDataKey('news') as NewsArticle[] | undefined;
const tm = useTimeMachine();
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [tmEnabled, setTmEnabled] = useState(false);
const [tmTooltipDismissed, setTmTooltipDismissed] = useState(false);
// Hydration-safe: read localStorage only after mount
useEffect(() => {
if (localStorage.getItem('sb_tm_tooltip_dismissed') === '1') {
setTmTooltipDismissed(true);
}
}, []);
// Check if Time Machine is enabled + refresh hourly index
useEffect(() => {
refreshHourlyIndex();
fetch(`${API_BASE}/api/settings/timemachine`)
.then((r) => r.json())
.then((d) => setTmEnabled(!!d.enabled))
.catch(() => {});
// Re-check every 60s in case user toggles it in settings
const interval = setInterval(() => {
fetch(`${API_BASE}/api/settings/timemachine`)
.then((r) => r.json())
.then((d) => setTmEnabled(!!d.enabled))
.catch(() => {});
}, 60_000);
return () => clearInterval(interval);
}, []);
const toggleTm = useCallback(async () => {
const turningOn = !tmEnabled;
try {
const res = await controlPlaneFetch('/api/settings/timemachine', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: turningOn }),
requireAdminSession: false,
});
if (res.ok) {
const data = await res.json();
setTmEnabled(!!data.enabled);
// Take an immediate snapshot when enabling
if (data.enabled) {
fetch(`${API_BASE}/api/ai/timemachine/snapshot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ compress: true, profile: 'manual' }),
}).then(() => refreshHourlyIndex()).catch(() => {});
}
}
} catch {}
// Dismiss the storage tooltip after first interaction
if (!tmTooltipDismissed) {
localStorage.setItem('sb_tm_tooltip_dismissed', '1');
setTmTooltipDismissed(true);
}
}, [tmEnabled, tmTooltipDismissed]);
const bins = useMemo<HourBin[]>(() => {
const buckets = Array.from({ length: HOURS }, (_, i) => {
const hourEntry = tm.hourlyIndex[i];
return {
hour: i,
count: 0,
maxRisk: 0,
label: `${String(i).padStart(2, '0')}:00`,
hasSnapshot: !!hourEntry && hourEntry.count > 0,
snapshotId: hourEntry?.latest_id ?? null,
};
});
if (!news || !Array.isArray(news)) return buckets;
const now = new Date();
const cutoff = new Date(now.getTime() - 24 * 60 * 60 * 1000);
for (const article of news) {
if (!article.pub_date) continue;
const d = new Date(article.pub_date);
if (d < cutoff) continue;
const h = d.getHours();
buckets[h].count++;
buckets[h].maxRisk = Math.max(buckets[h].maxRisk, article.risk_score || 0);
}
return buckets;
}, [news, tm.hourlyIndex]);
const maxCount = useMemo(() => Math.max(1, ...bins.map((b) => b.count)), [bins]);
// Get the hour of the currently loaded snapshot (for highlight)
const snapshotHour = useMemo(() => {
if (tm.mode !== 'snapshot' || !tm.snapshotTimestamp) return null;
try {
return new Date(tm.snapshotTimestamp).getHours();
} catch { return null; }
}, [tm.mode, tm.snapshotTimestamp]);
// Draw the timeline
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = BAR_W * dpr;
canvas.height = BAR_H * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, BAR_W, BAR_H);
// Background
ctx.fillStyle = 'rgba(5, 10, 20, 0.85)';
ctx.fillRect(0, 0, BAR_W, BAR_H);
const binW = BAR_W / HOURS;
const nowHour = new Date().getHours();
const isSnapshot = tm.mode === 'snapshot';
for (let i = 0; i < HOURS; i++) {
const bin = bins[i];
const fillPct = bin.count / maxCount;
const barH = Math.max(2, fillPct * (BAR_H - 8));
const x = i * binW;
const color = getRiskColor(bin.maxRisk);
// Bar fill
ctx.fillStyle = hoverIdx === i ? color : color + '80';
ctx.fillRect(x + 1, BAR_H - barH - 2, binW - 2, barH);
// Snapshot available indicator (small dot at top)
if (bin.hasSnapshot) {
ctx.fillStyle = isSnapshot ? '#f59e0b' : '#22d3ee';
ctx.beginPath();
ctx.arc(x + binW / 2, 4, 2, 0, Math.PI * 2);
ctx.fill();
}
// Current hour marker (live mode) or snapshot hour marker
if (isSnapshot && snapshotHour === i) {
ctx.fillStyle = '#f59e0b40';
ctx.fillRect(x, 0, binW, BAR_H);
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 1.5;
ctx.strokeRect(x + 0.5, 0.5, binW - 1, BAR_H - 1);
} else if (!isSnapshot && i === nowHour) {
ctx.fillStyle = '#22d3ee60';
ctx.fillRect(x, 0, binW, BAR_H);
}
// Hover highlight
if (hoverIdx === i) {
ctx.strokeStyle = bin.hasSnapshot ? '#f59e0b' : '#22d3ee';
ctx.lineWidth = 1;
ctx.strokeRect(x + 0.5, 0.5, binW - 1, BAR_H - 1);
}
}
// 6h tick marks
ctx.fillStyle = 'rgba(6, 182, 212, 0.3)';
ctx.font = '7px monospace';
ctx.textAlign = 'center';
for (let h = 0; h < HOURS; h += 6) {
const x = h * binW;
ctx.fillRect(x, 0, 0.5, BAR_H);
ctx.fillText(`${String(h).padStart(2, '0')}`, x + binW / 2 + 2, 8);
}
// Border
ctx.strokeStyle = isSnapshot ? 'rgba(245, 158, 11, 0.25)' : 'rgba(6, 182, 212, 0.15)';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, BAR_W - 1, BAR_H - 1);
}, [bins, maxCount, hoverIdx, tm.mode, snapshotHour]);
useEffect(() => { draw(); }, [draw]);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const binW = BAR_W / HOURS;
const idx = Math.floor(x / binW);
if (idx >= 0 && idx < HOURS) setHoverIdx(idx);
else setHoverIdx(null);
}, []);
const handleClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!tmEnabled) return; // Time Machine is off
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const binW = BAR_W / HOURS;
const idx = Math.floor(x / binW);
if (idx >= 0 && idx < HOURS) {
const bin = bins[idx];
if (bin.hasSnapshot && bin.snapshotId) {
enterSnapshotMode(bin.snapshotId);
}
}
}, [bins, tmEnabled]);
const isSnapshot = tm.mode === 'snapshot';
// Format snapshot timestamp for display
const snapshotLabel = useMemo(() => {
if (!tm.snapshotTimestamp) return '';
try {
const d = new Date(tm.snapshotTimestamp);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} LOCAL`;
} catch { return ''; }
}, [tm.snapshotTimestamp]);
return (
<div className="absolute top-[2.5rem] right-6 z-[201] pointer-events-auto w-[400px]">
<div className="relative flex flex-col items-center">
{/* Title — changes based on mode */}
{isSnapshot ? (
<div className="flex items-center gap-2 mb-1">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500" />
</span>
<span className="text-xs font-mono tracking-[0.3em] text-amber-500 uppercase">
SNAPSHOT · {snapshotLabel}
</span>
</div>
) : (
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono tracking-[0.3em] text-cyan-600 uppercase">
24H EVENT TIMELINE
</span>
<button
type="button"
onClick={toggleTm}
className={`text-[11px] font-mono tracking-[0.2em] uppercase cursor-pointer hover:brightness-125 transition-colors ${
tmEnabled ? 'text-amber-400/80' : 'text-amber-600/60'
}`}
title={tmTooltipDismissed ? undefined : (tmEnabled ? 'Click to disable snapshots (~68 MB/day)' : 'Click to enable snapshots (~68 MB/day)')}
>
{tmEnabled ? 'SNAPSHOTS ON' : 'SNAPSHOTS OFF'}
</button>
</div>
)}
{/* Tooltip */}
{hoverIdx !== null && (
<div
className="absolute -top-6 left-1/2 -translate-x-1/2 bg-[rgba(5,5,5,0.95)] border border-[var(--border-primary)] rounded-sm px-2 py-0.5 text-[11px] font-mono text-cyan-400 tracking-wider whitespace-nowrap"
style={{ boxShadow: '0 0 8px rgba(6,182,212,0.1)' }}
>
{bins[hoverIdx].label} · {bins[hoverIdx].count} events
{bins[hoverIdx].maxRisk > 0 && ` · MAX LVL ${bins[hoverIdx].maxRisk}`}
{tmEnabled && bins[hoverIdx].hasSnapshot && ' · ◆ SNAPSHOT'}
</div>
)}
<div className="flex items-center gap-2 w-full">
{/* Label */}
<span className="text-[11px] font-mono tracking-[0.2em] text-[var(--text-muted)] uppercase">
24H
</span>
<canvas
ref={canvasRef}
style={{ width: BAR_W, height: BAR_H, cursor: 'crosshair', borderRadius: '2px' }}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHoverIdx(null)}
onClick={handleClick}
/>
{/* Now marker label */}
<span className="text-[11px] font-mono tracking-[0.2em] text-cyan-600 uppercase">
NOW
</span>
</div>
{/* Playback controls — visible in snapshot mode */}
{isSnapshot && (
<div
className="flex items-center justify-center gap-1 mt-1.5 w-full"
style={{ maxWidth: BAR_W }}
>
{/* Rewind (step back) */}
<button
type="button"
onClick={stepBackward}
className="px-2 py-0.5 text-[11px] font-mono tracking-wider text-amber-400 hover:text-amber-300 bg-[rgba(245,158,11,0.08)] hover:bg-[rgba(245,158,11,0.15)] border border-amber-900/30 rounded-sm transition-colors"
title="Previous snapshot"
>
</button>
{/* Play / Pause */}
<button
type="button"
onClick={togglePlayback}
className={`px-3 py-0.5 text-[11px] font-mono tracking-wider border rounded-sm transition-colors ${
tm.playing
? 'text-amber-300 bg-[rgba(245,158,11,0.2)] border-amber-700/50'
: 'text-amber-400 hover:text-amber-300 bg-[rgba(245,158,11,0.08)] hover:bg-[rgba(245,158,11,0.15)] border-amber-900/30'
}`}
title={tm.playing ? 'Pause playback' : 'Auto-play snapshots'}
>
{tm.playing ? '❚❚ PAUSE' : '► PLAY'}
</button>
{/* Step forward */}
<button
type="button"
onClick={stepForward}
className="px-2 py-0.5 text-[11px] font-mono tracking-wider text-amber-400 hover:text-amber-300 bg-[rgba(245,158,11,0.08)] hover:bg-[rgba(245,158,11,0.15)] border border-amber-900/30 rounded-sm transition-colors"
title="Next snapshot"
>
</button>
{/* Divider */}
<span className="text-amber-900/40 mx-0.5"></span>
{/* Return to LIVE */}
<button
type="button"
onClick={exitSnapshotMode}
className="px-3 py-0.5 text-[11px] font-mono tracking-[0.15em] text-cyan-400 hover:text-cyan-300 bg-[rgba(6,182,212,0.08)] hover:bg-[rgba(6,182,212,0.15)] border border-cyan-900/30 rounded-sm transition-colors"
title="Return to live feed"
>
LIVE
</button>
</div>
)}
{/* Loading indicator */}
{tm.loading && (
<div className="text-[11px] font-mono text-amber-500/70 tracking-wider mt-1 animate-pulse">
LOADING SNAPSHOT...
</div>
)}
</div>
</div>
);
}
+178 -89
View File
@@ -4,7 +4,6 @@ import { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import {
Github,
MessageSquare,
Download,
AlertCircle,
CheckCircle2,
@@ -17,6 +16,16 @@ import {
} from 'lucide-react';
import { API_BASE } from '@/lib/api';
import { controlPlaneFetch } from '@/lib/controlPlane';
import {
checkDesktopUpdaterUpdate,
classifyUpdateRuntime,
getDesktopUpdateContext,
getPreferredManualUpdateUrl,
getUpdateAction,
installDesktopUpdaterUpdate,
type GitHubLatestRelease,
type UpdateActionKind,
} from '@/lib/updateRuntime';
import {
requestMeshTerminalOpen,
subscribeSecureMeshTerminalLauncherOpen,
@@ -24,13 +33,15 @@ import {
import { purgeBrowserContactGraph, purgeBrowserSigningMaterial, setSecureModeCached, getNodeIdentity, generateNodeKeys } from '@/mesh/meshIdentity';
import { purgeBrowserDmState } from '@/mesh/meshDmWorkerClient';
import {
DEFAULT_INFONET_SEED_URL,
fetchInfonetNodeStatusSnapshot,
type InfonetNodeStatusSnapshot,
} from '@/mesh/controlPlaneStatusClient';
import {
fetchWormholeStatus,
prepareWormholeInteractiveLane,
} from '@/mesh/wormholeIdentityClient';
import { fetchWormholeSettings, joinWormhole } from '@/mesh/wormholeClient';
import { fetchWormholeSettings } from '@/mesh/wormholeClient';
import packageJson from '../../package.json';
type UpdateStatus =
@@ -46,6 +57,17 @@ type UpdateStatus =
| 'docker_update';
const DEFAULT_RELEASES_URL = 'https://github.com/BigBodyCobain/Shadowbroker/releases/latest';
const AUTO_UPDATE_DETAIL =
'This runtime can use the backend-managed update path. Docker deployments will show pull instructions instead of modifying files in place.';
const DESKTOP_UPDATER_DETAIL =
'This packaged desktop app can install the signed update in place. It will restart ShadowBroker after the installer finishes.';
function packagedUpdateDetail(ownsLocalBackend: boolean): string {
if (ownsLocalBackend) {
return 'This desktop installer updates the app and its bundled local backend together.';
}
return 'This packaged desktop app updates through a new installer download. It does not update the separately running backend service.';
}
interface TopRightControlsProps {
onTerminalToggle?: () => void;
@@ -65,7 +87,10 @@ export default function TopRightControls({
const [latestVersion, setLatestVersion] = useState<string>('');
const [errorMessage, setErrorMessage] = useState('');
const [manualUpdateUrl, setManualUpdateUrl] = useState(DEFAULT_RELEASES_URL);
const [releasePageUrl, setReleasePageUrl] = useState(DEFAULT_RELEASES_URL);
const [dockerCommands, setDockerCommands] = useState('');
const [updateAction, setUpdateAction] = useState<UpdateActionKind>('auto_apply');
const [updateDetail, setUpdateDetail] = useState(AUTO_UPDATE_DETAIL);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [launcherOpen, setLauncherOpen] = useState(false);
@@ -138,45 +163,39 @@ export default function TopRightControls({
setTerminalLaunchError('');
};
const activateWormholeAndLaunchTerminal = async () => {
setTerminalLaunchBusy(true);
setTerminalLaunchError('');
const applySecureModeBoundary = async (enabled: boolean) => {
setSecureModeCached(enabled);
if (!enabled) return;
purgeBrowserSigningMaterial();
purgeBrowserContactGraph();
await purgeBrowserDmState();
};
const continueTerminalLaunchInBackground = useCallback(async () => {
try {
const prepared = await prepareWormholeInteractiveLane({ bootstrapIdentity: true });
const settings = await fetchWormholeSettings(true).catch(() => null);
let runtime = await fetchWormholeStatus().catch(() => null);
let enabled = Boolean(settings?.enabled ?? runtime?.running ?? runtime?.ready ?? false);
let ready = Boolean(runtime?.ready);
let identityNodeId = '';
const joined = await joinWormhole();
enabled = Boolean(joined.settings?.enabled ?? joined.runtime?.configured ?? true);
identityNodeId = String(joined.identity?.node_id || '').trim();
const enabled = Boolean(
settings?.enabled ?? prepared.settingsEnabled ?? runtime?.running ?? runtime?.ready ?? false,
);
const identityNodeId = String(prepared.identity?.node_id || '').trim();
await applySecureModeBoundary(enabled);
runtime = joined.runtime ?? runtime;
ready = Boolean(runtime?.ready);
const deadline = Date.now() + 12000;
while (!ready && Date.now() < deadline) {
await new Promise((resolve) => window.setTimeout(resolve, 700));
runtime = await fetchWormholeStatus().catch(() => null);
ready = Boolean(runtime?.ready);
}
if (!ready) {
throw new Error('Wormhole is starting up. Give it a few seconds, then try again.');
}
runtime = await fetchWormholeStatus().catch(() => runtime);
setTerminalPrivateEnabled(enabled);
setTerminalPrivateReady(Boolean(runtime?.ready ?? true));
setTerminalPrivateReady(Boolean(runtime?.ready ?? prepared.ready ?? false));
setTerminalTransportTier(
String(runtime?.transport_tier || runtime?.transport_active || 'private_strong'),
String(
runtime?.transport_tier ||
runtime?.transport_active ||
prepared.transportTier ||
'private_control_only',
),
);
setTerminalLauncherOpen(false);
setTerminalLaunchError('');
setSecureModeCached(true);
launchTerminalDirect();
setSecureModeCached(enabled);
if (identityNodeId) {
console.info('[top-right] Wormhole terminal launch ready', identityNodeId);
}
@@ -185,18 +204,27 @@ export default function TopRightControls({
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: string }).message || '')
: '';
setTerminalLaunchError(message || 'Failed to enter Wormhole.');
const settings = await fetchWormholeSettings(true).catch(() => null);
const runtime = await fetchWormholeStatus().catch(() => null);
setTerminalPrivateEnabled(Boolean(settings?.enabled ?? runtime?.running ?? runtime?.ready ?? false));
setTerminalPrivateReady(Boolean(runtime?.ready));
setTerminalTransportTier(
String(runtime?.transport_tier || runtime?.transport_active || 'public_degraded'),
);
setTerminalLaunchError(message || 'Wormhole is still warming up in the background.');
} finally {
setTerminalLaunchBusy(false);
}
};
}, [applySecureModeBoundary]);
const applySecureModeBoundary = async (enabled: boolean) => {
setSecureModeCached(enabled);
if (!enabled) return;
purgeBrowserSigningMaterial();
purgeBrowserContactGraph();
await purgeBrowserDmState();
const activateWormholeAndLaunchTerminal = async () => {
setTerminalLaunchBusy(true);
setTerminalLaunchError('');
setTerminalPrivateEnabled(true);
setTerminalPrivateReady(false);
setTerminalLauncherOpen(false);
launchTerminalDirect();
void continueTerminalLaunchInBackground();
};
// Cleanup polling on unmount
@@ -261,7 +289,7 @@ export default function TopRightControls({
const snap = await fetchInfonetNodeStatusSnapshot(true);
setNodeStatus(snap);
const outcome = String(snap?.sync_runtime?.last_outcome || '').toLowerCase();
if (outcome === 'ok') {
if (outcome === 'ok' || outcome === 'solo') {
setActivatingPhase('done');
stopActivatingPolls();
// Auto-transition to 'disable' after brief success display
@@ -337,11 +365,13 @@ export default function TopRightControls({
const checkForUpdates = async () => {
setUpdateStatus('checking');
try {
const desktopContext = await getDesktopUpdateContext();
const runtime = classifyUpdateRuntime(desktopContext);
const res = await fetch(
'https://api.github.com/repos/BigBodyCobain/Shadowbroker/releases/latest',
);
if (!res.ok) throw new Error('Failed to fetch');
const data = await res.json();
const data = (await res.json()) as GitHubLatestRelease;
const latest = data.tag_name?.replace('v', '') || data.name?.replace('v', '');
const current = currentVersion.replace('v', '');
@@ -349,7 +379,37 @@ export default function TopRightControls({
typeof data.html_url === 'string' && data.html_url.trim().length > 0
? data.html_url
: DEFAULT_RELEASES_URL;
setManualUpdateUrl(releaseUrl);
const platform = desktopContext?.platform || 'unknown';
const ownsLocalBackend = Boolean(desktopContext?.owns_local_backend);
setReleasePageUrl(releaseUrl);
setManualUpdateUrl(getPreferredManualUpdateUrl(data, runtime, platform));
let resolvedAction = getUpdateAction(runtime);
let resolvedDetail =
runtime === 'desktop_packaged'
? packagedUpdateDetail(ownsLocalBackend)
: AUTO_UPDATE_DETAIL;
if (runtime === 'desktop_packaged') {
try {
const desktopUpdate = await checkDesktopUpdaterUpdate();
if (desktopUpdate?.version) {
resolvedAction = 'desktop_updater';
resolvedDetail = DESKTOP_UPDATER_DETAIL;
setLatestVersion(desktopUpdate.version.replace(/^v/i, ''));
setUpdateAction(resolvedAction);
setUpdateDetail(resolvedDetail);
setUpdateStatus('available');
return;
}
} catch (desktopUpdaterError) {
console.warn('Desktop updater check failed; falling back to release download:', desktopUpdaterError);
}
}
setUpdateAction(resolvedAction);
setUpdateDetail(
resolvedDetail,
);
if (latest && latest !== current) {
setLatestVersion(latest);
@@ -391,6 +451,33 @@ export default function TopRightControls({
};
const triggerUpdate = async () => {
if (updateAction === 'manual_download') {
window.open(manualUpdateUrl, '_blank', 'noopener,noreferrer');
setUpdateStatus('idle');
return;
}
if (updateAction === 'desktop_updater') {
setUpdateStatus('updating');
setErrorMessage('');
try {
await installDesktopUpdaterUpdate();
setUpdateStatus('restarting');
} catch (err) {
const message =
typeof err === 'object' && err !== null && 'message' in err
? String((err as { message?: string }).message)
: '';
setErrorMessage(
message === 'desktop_update_installed_restart_required'
? 'Update installed. Restart ShadowBroker to finish applying it.'
: message || 'Desktop updater failed. Use manual download if this keeps happening.',
);
setUpdateStatus('update_error');
}
return;
}
setUpdateStatus('updating');
setErrorMessage('');
try {
@@ -401,16 +488,29 @@ export default function TopRightControls({
message?: string;
detail?: string;
manual_url?: string;
release_url?: string;
docker_commands?: string;
};
if (typeof data.manual_url === 'string' && data.manual_url.trim().length > 0) {
setManualUpdateUrl(data.manual_url);
}
if (typeof data.release_url === 'string' && data.release_url.trim().length > 0) {
setReleasePageUrl(data.release_url);
}
if (data?.status === 'docker') {
setDockerCommands(data.docker_commands || 'docker compose pull && docker compose up -d');
setUpdateStatus('docker_update');
return;
}
if (data?.status === 'manual') {
const targetUrl =
typeof data.manual_url === 'string' && data.manual_url.trim().length > 0
? data.manual_url
: manualUpdateUrl;
window.open(targetUrl, '_blank', 'noopener,noreferrer');
setUpdateStatus('idle');
return;
}
if (!res.ok || data?.ok === false || data?.status === 'error') {
const message = data?.detail || data?.message || 'control_plane_request_failed';
const error = new Error(message) as Error & { manualUrl?: string };
@@ -464,22 +564,29 @@ export default function TopRightControls({
{/* Actions */}
<div className="p-3 flex flex-col gap-2">
<p className="text-[9px] font-mono text-[var(--text-muted)] leading-relaxed">
{updateDetail}
</p>
<button
onClick={triggerUpdate}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-cyan-500/10 border border-cyan-500/40 hover:bg-cyan-500/20 transition-all text-[10px] text-cyan-400 font-mono tracking-widest"
>
<Download size={12} />
AUTO UPDATE
{updateAction === 'manual_download'
? 'DOWNLOAD INSTALLER'
: updateAction === 'desktop_updater'
? 'INSTALL UPDATE'
: 'AUTO UPDATE'}
</button>
<a
href={manualUpdateUrl}
href={updateAction === 'manual_download' ? releasePageUrl : manualUpdateUrl}
target="_blank"
rel="noreferrer"
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] hover:border-[var(--text-muted)] transition-all text-[10px] text-[var(--text-muted)] font-mono tracking-widest"
>
<ExternalLink size={12} />
MANUAL DOWNLOAD
{updateAction === 'manual_download' ? 'VIEW RELEASE' : 'MANUAL DOWNLOAD'}
</a>
<button
@@ -512,13 +619,13 @@ export default function TopRightControls({
TRY AGAIN
</button>
<a
href={manualUpdateUrl}
href={updateAction === 'manual_download' ? releasePageUrl : manualUpdateUrl}
target="_blank"
rel="noreferrer"
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] hover:border-[var(--text-muted)] transition-all text-[10px] text-[var(--text-muted)] font-mono tracking-widest"
>
<ExternalLink size={12} />
MANUAL DOWNLOAD
{updateAction === 'manual_download' ? 'VIEW RELEASE' : 'MANUAL DOWNLOAD'}
</a>
</div>
</div>
@@ -556,7 +663,7 @@ export default function TopRightControls({
</button>
</div>
<a
href={manualUpdateUrl}
href={releasePageUrl}
target="_blank"
rel="noreferrer"
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] hover:border-[var(--text-muted)] transition-all text-[10px] text-[var(--text-muted)] font-mono tracking-widest"
@@ -577,7 +684,7 @@ export default function TopRightControls({
const syncError = String(nodeStatus?.sync_runtime?.last_error || '').trim().toLowerCase();
const syncOutcome = !nodeEnabled
? 'OFF'
: syncError === 'no active sync peers'
: syncOutcomeRaw === 'solo' || syncError === 'no active sync peers'
? 'SOLO'
: syncOutcomeRaw === 'ok'
? 'CONNECTED'
@@ -592,7 +699,7 @@ export default function TopRightControls({
const nodeIndicatorClass =
!nodeEnabled
? 'bg-rose-400'
: syncError === 'no active sync peers'
: syncOutcomeRaw === 'solo' || syncError === 'no active sync peers'
? 'bg-cyan-400'
: syncOutcomeRaw === 'ok'
? 'bg-green-400'
@@ -615,7 +722,7 @@ export default function TopRightControls({
};
// Uniform button style (matches UPDATES button)
const btnBase = 'flex items-center justify-center gap-1 px-2 py-1.5 bg-[var(--bg-primary)]/70 border border-[var(--border-primary)] hover:border-cyan-500/50 hover:bg-[var(--hover-accent)] transition-all text-[10px] text-[var(--text-secondary)] font-mono cursor-pointer min-w-[100px]';
const btnBase = 'flex items-center justify-center gap-1 px-2 py-1.5 bg-[var(--bg-primary)]/70 border border-[var(--border-primary)] hover:border-cyan-500/50 hover:bg-[var(--hover-accent)] transition-all text-[10px] text-[var(--text-secondary)] font-mono cursor-pointer flex-1';
const nodeLauncherModal =
portalReady && launcherOpen
@@ -667,7 +774,7 @@ export default function TopRightControls({
{(nodeStatus?.total_events ?? 0) > 0 && <span>{nodeStatus?.total_events} events</span>}
{(nodeStatus?.bootstrap?.sync_peer_count ?? 0) > 0 && <span>{nodeStatus?.bootstrap?.sync_peer_count} peers</span>}
</div>
<div className="mt-3 text-[8px] text-[var(--text-muted)] normal-case tracking-normal leading-[1.8]">
<div className="mt-3 text-[11px] text-[var(--text-muted)] normal-case tracking-normal leading-[1.8]">
Your node keeps syncing as long as the backend is running you can close this browser tab. To run a headless node without the dashboard, use <span className="text-cyan-400">meshnode.bat</span> (Windows) or <span className="text-cyan-400">meshnode.sh</span> (macOS/Linux).
</div>
</div>
@@ -709,7 +816,7 @@ export default function TopRightControls({
{activatingPhase === 'keys' ? 'Generating identity...' : 'Identity ready'}
</span>
{activatingPhase !== 'keys' && (() => { const id = getNodeIdentity(); return id?.nodeId ? (
<span className="text-[8px] text-cyan-400/70 ml-auto">{id.nodeId}</span>
<span className="text-[11px] text-cyan-400/70 ml-auto">{id.nodeId}</span>
) : null; })()}
</div>
{/* Step: Connect to relay */}
@@ -726,9 +833,9 @@ export default function TopRightControls({
: activatingPhase === 'peers' ? 'text-cyan-300'
: 'text-green-300'
}>
{activatingPhase === 'keys' ? 'Connecting to relay...'
: activatingPhase === 'peers' ? 'Connecting to relay...'
: 'Relay connected'}
{activatingPhase === 'keys' ? 'Connecting to default seed...'
: activatingPhase === 'peers' ? 'Connecting to default seed...'
: 'Default seed connected'}
</span>
</div>
{/* Step: Sync chain */}
@@ -746,7 +853,9 @@ export default function TopRightControls({
: 'text-green-300'
}>
{activatingPhase === 'done'
? `Synced — ${nodeStatus?.total_events ?? 0} events`
? (syncOutcomeRaw === 'solo'
? `Solo node ready — ${nodeStatus?.total_events ?? 0} events`
: `Synced — ${nodeStatus?.total_events ?? 0} events`)
: activatingPhase === 'sync'
? `Syncing chain...${(nodeStatus?.total_events ?? 0) > 0 ? ` ${nodeStatus?.total_events} events` : ''}`
: 'Syncing chain...'}
@@ -758,7 +867,7 @@ export default function TopRightControls({
<div className="mt-2 border border-green-500/30 bg-green-950/20 px-3 py-2 text-[10px] font-mono text-green-300 tracking-[0.15em] text-center">
NODE ONLINE
</div>
<div className="mt-1 text-[8px] font-mono text-[var(--text-muted)] leading-[1.8] normal-case tracking-normal">
<div className="mt-1 text-[11px] font-mono text-[var(--text-muted)] leading-[1.8] normal-case tracking-normal">
Your node keeps syncing as long as the backend is running you can close this browser tab.
To run a headless node without the dashboard, use <span className="text-cyan-400">meshnode.bat</span> (Windows) or <span className="text-cyan-400">meshnode.sh</span> (macOS/Linux).
</div>
@@ -790,7 +899,7 @@ export default function TopRightControls({
<div className="border border-cyan-500/20 bg-cyan-950/10 px-4 py-4 text-[10px] font-mono text-cyan-100 leading-[1.8]">
Do you want to activate a node on this install?
<div className="mt-2 text-[9px] text-cyan-200/70 normal-case tracking-normal">
This turns on your local participant node and lets this install keep syncing the public Infonet chain.
This turns on your local participant node and lets this install keep syncing the public Infonet chain from <span className="text-cyan-300">{DEFAULT_INFONET_SEED_URL}</span>.
</div>
</div>
{(bootstrapFailed || nodeStatusError || nodeToggleError) && (
@@ -821,12 +930,13 @@ export default function TopRightControls({
<div className="text-cyan-300 tracking-[0.18em]">BY CONTINUING YOU AGREE:</div>
<ul className="mt-3 space-y-2 list-disc pl-5">
<li>This install can keep a local copy of the public Infonet chain.</li>
<li>Fresh installs pull from the bundled default seed at {DEFAULT_INFONET_SEED_URL}.</li>
<li>Participant-node sync is public-facing unless you separately use obfuscated-lane features.</li>
<li>Your backend may sync with configured or bundled bootstrap peers in the background.</li>
<li>Wormhole is only required for obfuscated gates, experimental inbox, and stronger obfuscated posture.</li>
<li>Wormhole provides gates (transitional private lane) and Dead Drop / DM (stronger private lane) as separate postures.</li>
</ul>
</div>
<div className="text-[8px] font-mono uppercase tracking-[0.2em] text-cyan-300/80">
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-cyan-300/80">
{nodeMode} {syncOutcome}
</div>
<div className="grid grid-cols-2 gap-3">
@@ -914,7 +1024,7 @@ export default function TopRightControls({
<div className="border border-cyan-500/20 bg-black/30 px-4 py-4 text-[12px] font-mono text-slate-200 leading-[1.85]">
<div className="text-cyan-300 tracking-[0.18em]">BEFORE YOU ENTER:</div>
<ul className="mt-3 space-y-2 list-disc pl-5">
<li>The terminal is for Wormhole, gates, and experimental mail.</li>
<li>The terminal is for Wormhole gates (transitional private lane) and Dead Drop / DM (stronger private lane).</li>
<li>Your participant node can stay active separately without changing this obfuscated identity lane.</li>
<li>Mesh remains the public perimeter. Wormhole is the obfuscated commons.</li>
</ul>
@@ -973,33 +1083,7 @@ export default function TopRightControls({
<>
{terminalLauncherModal}
{nodeLauncherModal}
<div className="relative flex items-center gap-1.5 mb-1 justify-end">
{/* Terminal toggle */}
<button
onClick={() => void openTerminalLauncher()}
className={`relative ${btnBase}`}
title="Mesh Terminal"
>
<Terminal size={11} className="text-cyan-400" />
<span className="tracking-wider">TERMINAL</span>
{(dmCount ?? 0) > 0 && (
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[7px] font-bold rounded-full min-w-[14px] h-[14px] flex items-center justify-center px-0.5 shadow-[0_0_6px_rgba(239,68,68,0.5)]">
{(dmCount ?? 0) > 9 ? '9+' : dmCount}
</span>
)}
</button>
{/* Discussions link */}
<a
href="https://github.com/BigBodyCobain/Shadowbroker/discussions"
target="_blank"
rel="noreferrer"
className={btnBase}
>
<MessageSquare size={11} className="text-cyan-400" />
<span className="tracking-wider">DISCUSS</span>
</a>
<div className="relative flex items-center gap-1.5 mb-1 w-full">
{/* Node runtime / private lane */}
<button
type="button"
@@ -1016,7 +1100,7 @@ export default function TopRightControls({
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${nodeIndicatorClass}`} />
</button>
{/* Terminal toggle (secondary position) */}
{/* Terminal toggle */}
<button
type="button"
onClick={() => void openTerminalLauncher()}
@@ -1025,6 +1109,11 @@ export default function TopRightControls({
>
<Terminal size={11} className="text-cyan-400" />
<span className="tracking-wider">TERMINAL</span>
{(dmCount ?? 0) > 0 && (
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] font-bold rounded-full min-w-[14px] h-[14px] flex items-center justify-center px-0.5 shadow-[0_0_6px_rgba(239,68,68,0.5)]">
{(dmCount ?? 0) > 9 ? '9+' : dmCount}
</span>
)}
</button>
{/* ── Update Available → opens confirmation ── */}
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import type { WatchlistEntry } from '@/hooks/useWatchlist';
import { Eye, X, Trash2, ChevronUp, ChevronDown, Crosshair } from 'lucide-react';
function getTypeIcon(type: string) {
switch (type) {
case 'flight': return '✈';
case 'ship': return '🚢';
case 'news': return '📰';
case 'satellite': return '🛰';
default: return '📍';
}
}
function getTypeColor(type: string) {
switch (type) {
case 'flight': return '#22d3ee';
case 'ship': return '#3b82f6';
case 'news': return '#f97316';
case 'satellite': return '#a855f7';
default: return '#6b7280';
}
}
export default function WatchlistWidget({
items,
onRemove,
onClear,
onFlyTo,
}: {
items: WatchlistEntry[];
onRemove: (id: string) => void;
onClear: () => void;
onFlyTo?: (lat: number, lng: number) => void;
}) {
const [expanded, setExpanded] = useState(false);
if (items.length === 0) return null;
return (
<div className="absolute bottom-[6.5rem] left-6 z-[200] pointer-events-auto hud-zone">
<AnimatePresence>
{expanded && (
<motion.div
initial={{ opacity: 0, y: 10, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }}
exit={{ opacity: 0, y: 10, height: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="mb-1 bg-[var(--bg-panel)] border border-[var(--border-primary)] rounded-sm overflow-hidden backdrop-blur-sm"
style={{
width: '260px',
maxHeight: '300px',
boxShadow: '0 0 20px rgba(6, 182, 212, 0.08)',
}}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--border-primary)]">
<span className="text-[10px] font-mono tracking-[0.2em] text-[var(--text-heading)] font-bold">
WATCHLIST
</span>
<button
onClick={onClear}
className="text-[var(--text-muted)] hover:text-red-400 transition-colors"
title="Clear all"
>
<Trash2 size={12} />
</button>
</div>
{/* Items */}
<div className="overflow-y-auto styled-scrollbar" style={{ maxHeight: '240px' }}>
{items.map((item) => (
<div
key={item.id}
className="flex items-center gap-2 px-3 py-2 hover:bg-[var(--hover-accent)] transition-colors border-b border-[var(--border-primary)]/30 cursor-pointer group"
onClick={() => onFlyTo?.(item.lat, item.lng)}
>
{/* Type icon */}
<span className="text-sm flex-shrink-0">{getTypeIcon(item.type)}</span>
{/* Info */}
<div className="flex-1 min-w-0">
<div
className="text-[11px] font-mono truncate"
style={{ color: getTypeColor(item.type) }}
>
{item.name}
</div>
<div className="text-[9px] font-mono text-[var(--text-muted)] tracking-wider uppercase">
{item.type}
{item.altitude != null && ` · ${Math.round(item.altitude).toLocaleString()} ft`}
{item.speed != null && ` · ${Math.round(item.speed)} kts`}
{item.risk_score != null && ` · LVL ${item.risk_score}`}
</div>
</div>
{/* Fly-to button */}
<button
onClick={(e) => {
e.stopPropagation();
onFlyTo?.(item.lat, item.lng);
}}
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors opacity-0 group-hover:opacity-100 flex-shrink-0"
title="Fly to"
>
<Crosshair size={12} />
</button>
{/* Remove button */}
<button
onClick={(e) => {
e.stopPropagation();
onRemove(item.id);
}}
className="text-[var(--text-muted)] hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100 flex-shrink-0"
title="Remove"
>
<X size={12} />
</button>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Collapsed badge */}
<button
onClick={() => setExpanded((p) => !p)}
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--bg-panel)] border border-[var(--border-primary)] rounded-sm hover:border-cyan-500/40 transition-colors"
style={{ boxShadow: '0 0 12px rgba(6, 182, 212, 0.06)' }}
>
<Eye size={13} className="text-cyan-400" />
<span className="text-[10px] font-mono tracking-[0.15em] text-[var(--text-heading)] font-bold">
{items.length} TRACKED
</span>
{expanded ? (
<ChevronDown size={12} className="text-[var(--text-muted)]" />
) : (
<ChevronUp size={12} className="text-[var(--text-muted)]" />
)}
</button>
</div>
);
}
+405 -97
View File
@@ -1,8 +1,11 @@
'use client';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Layers,
Minus,
Plus,
Plane,
AlertTriangle,
Activity,
@@ -37,11 +40,16 @@ import {
Fish,
TrainFront,
Search,
Droplets,
Radar,
MapPin,
} from 'lucide-react';
import { API_BASE } from '@/lib/api';
import { onTileLoadingChange, resetTileLoading } from '@/lib/sentinelHub';
import packageJson from '../../package.json';
import { useTheme } from '@/lib/ThemeContext';
import SarModeChooserModal from './SarModeChooserModal';
import KiwiSdrConsentDialog from './ui/KiwiSdrConsentDialog';
function relativeTime(iso: string | undefined): string {
if (!iso) return '';
@@ -91,6 +99,11 @@ const FRESHNESS_MAP: Record<string, string> = {
fishing_activity: 'fishing_activity',
shodan_overlay: '',
correlations: 'correlations',
contradictions: 'correlations',
uap_sightings: 'uap_sightings',
wastewater: 'wastewater',
ai_intel: '',
crowdthreat: 'crowdthreat',
};
// POTUS fleet ICAO hex codes for client-side filtering
@@ -132,12 +145,17 @@ function ScannerTracker({
onFlyTo: () => void;
}) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const recentPlayedRef = useRef<Set<string>>(new Set());
const fetchAndPlayRef = useRef<() => void>(() => undefined);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [playerMessage, setPlayerMessage] = useState('Ready to play the latest OpenMHz call.');
const [activeBurst, setActiveBurst] = useState<{
id: string;
talkgroup: string;
url: string;
time?: string;
len?: number;
} | null>(null);
const [volume, setVolume] = useState(0.8);
const [isScanning, setIsScanning] = useState(false);
@@ -146,14 +164,13 @@ function ScannerTracker({
// Cleanup on unmount
useEffect(() => {
const timer = scanTimerRef.current;
return () => {
isScanningRef.current = false;
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
if (timer) clearTimeout(timer);
if (scanTimerRef.current) clearTimeout(scanTimerRef.current);
};
}, []);
@@ -162,57 +179,102 @@ function ScannerTracker({
if (audioRef.current) audioRef.current.volume = volume;
}, [volume]);
const fetchAndPlay = async () => {
if (!scanner.shortName) return;
const scheduleScan = useCallback((delayMs = 3500) => {
if (scanTimerRef.current) clearTimeout(scanTimerRef.current);
scanTimerRef.current = setTimeout(() => {
if (isScanningRef.current) {
fetchAndPlayRef.current();
}
}, delayMs);
}, []);
const fetchAndPlay = useCallback(async () => {
if (!scanner.shortName) {
setPlayerMessage('No OpenMHz system id is available for this scanner.');
return;
}
setIsLoading(true);
setPlayerMessage('Checking recent calls...');
try {
const res = await fetch(`${API_BASE}/api/radio/openmhz/calls/${scanner.shortName}`);
if (!res.ok) {
setIsLoading(false);
setPlayerMessage(`OpenMHz call lookup failed (${res.status}).`);
return;
}
const calls = await res.json();
if (!calls?.length) {
setIsLoading(false);
setPlayerMessage(isScanningRef.current ? 'No recent calls. Auto scan will retry.' : 'No recent calls for this system yet.');
if (isScanningRef.current) scheduleScan(8000);
return;
}
const pick = calls[Math.floor(Math.random() * Math.min(calls.length, 5))];
const playable = calls.filter((call: { url?: string }) => Boolean(call?.url));
if (!playable.length) {
setPlayerMessage('Recent calls did not include playable audio URLs.');
if (isScanningRef.current) scheduleScan(8000);
return;
}
const pick =
playable.find((call: { id?: string; _id?: string }) => {
const id = String(call.id || call._id || '');
return id && !recentPlayedRef.current.has(id);
}) || playable[0];
const burst = {
id: pick.id || pick._id || String(Date.now()),
talkgroup: String(pick.talkgroupNum || '???'),
url: pick.url,
url: `${API_BASE}/api/radio/openmhz/audio?url=${encodeURIComponent(pick.url)}`,
time: pick.time,
len: Number(pick.len || 0),
};
recentPlayedRef.current.add(String(burst.id));
if (recentPlayedRef.current.size > 40) {
recentPlayedRef.current = new Set(Array.from(recentPlayedRef.current).slice(-20));
}
setActiveBurst(burst);
// Play
if (!audioRef.current) audioRef.current = new Audio();
audioRef.current.pause();
audioRef.current.src = burst.url;
audioRef.current.volume = volume;
audioRef.current.onended = () => {
if (isScanningRef.current) fetchAndPlay();
else {
setIsPlaying(false);
setActiveBurst(null);
}
setIsPlaying(false);
setPlayerMessage(isScanningRef.current ? 'Call ended. Scanning for the next call...' : 'Call ended.');
if (isScanningRef.current) scheduleScan(1200);
};
audioRef.current.onerror = () => {
setIsPlaying(false);
setPlayerMessage('Audio failed to load. Trying another call shortly.');
if (isScanningRef.current) scheduleScan(2500);
};
await audioRef.current.play();
setIsPlaying(true);
setPlayerMessage(isScanningRef.current ? 'Playing. Auto scan is armed.' : 'Playing latest call.');
} catch (e) {
console.error('Scanner audio error', e);
setPlayerMessage('Audio playback failed. Try Auto Scan or another scanner.');
if (isScanningRef.current) scheduleScan(5000);
} finally {
setIsLoading(false);
}
setIsLoading(false);
}, [scanner.shortName, scheduleScan, volume]);
fetchAndPlayRef.current = () => {
void fetchAndPlay();
};
const stop = () => {
if (scanTimerRef.current) {
clearTimeout(scanTimerRef.current);
scanTimerRef.current = null;
}
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
setIsPlaying(false);
setIsLoading(false);
setActiveBurst(null);
setPlayerMessage('Stopped.');
if (isScanning) {
setIsScanning(false);
isScanningRef.current = false;
if (scanTimerRef.current) clearTimeout(scanTimerRef.current);
}
};
@@ -223,7 +285,7 @@ function ScannerTracker({
}
setIsScanning(true);
isScanningRef.current = true;
fetchAndPlay();
void fetchAndPlay();
};
return (
@@ -239,6 +301,11 @@ function ScannerTracker({
LIVE
</span>
)}
{isLoading && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-yellow-500/10 border border-yellow-500/30 text-yellow-300">
TUNING
</span>
)}
</div>
<button
onClick={(e) => {
@@ -246,7 +313,7 @@ function ScannerTracker({
stop();
onRelease();
}}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-red-400 border border-[var(--border-primary)] hover:border-red-400/40 px-1.5 py-0.5 transition-colors"
className="text-[11px] font-mono text-[var(--text-muted)] hover:text-red-400 border border-[var(--border-primary)] hover:border-red-400/40 px-1.5 py-0.5 transition-colors"
>
RELEASE
</button>
@@ -257,30 +324,32 @@ function ScannerTracker({
<span className="text-[10px] font-bold font-mono text-red-300 truncate">
{(scanner.name || 'UNKNOWN SYSTEM').toUpperCase()}
</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono">
<span className="text-[11px] text-[var(--text-muted)] font-mono">
{[scanner.city, scanner.state].filter(Boolean).join(', ')}
{scanner.clientCount > 0 && <span> · {scanner.clientCount} listeners</span>}
</span>
{activeBurst && (
<span className="text-[8px] text-red-400 font-mono mt-1">
TALKGROUP: {activeBurst.talkgroup}
<span className="text-[11px] text-red-400 font-mono mt-1 flex items-center justify-between gap-2">
<span>TALKGROUP: {activeBurst.talkgroup}</span>
{activeBurst.len ? <span>{activeBurst.len}s</span> : null}
</span>
)}
</div>
{/* Audio controls */}
<div className="flex items-center gap-2 mb-2">
<div className="grid grid-cols-[1fr_1fr_auto] items-center gap-2 mb-2">
<button
onClick={isPlaying ? stop : fetchAndPlay}
onClick={isPlaying ? stop : () => void fetchAndPlay()}
disabled={isLoading}
className={`p-1.5 rounded-full border ${isPlaying ? 'border-red-500/50 text-red-400 hover:bg-red-950/50' : 'border-red-700/50 text-red-500 hover:bg-red-950/30'} transition-colors ${isLoading ? 'opacity-50' : ''}`}
className={`px-2 py-1.5 border text-[9px] font-mono tracking-wider flex items-center justify-center gap-1.5 ${isPlaying ? 'border-red-500/50 text-red-300 bg-red-950/40 hover:bg-red-950/60' : 'border-red-700/50 text-red-500 hover:bg-red-950/30'} transition-colors ${isLoading ? 'opacity-50' : ''}`}
title={isPlaying ? 'Stop' : 'Play latest intercept'}
>
{isPlaying ? <Square size={12} /> : <Play size={12} className="ml-0.5" />}
{isPlaying ? <Square size={11} /> : <Play size={11} />}
{isPlaying ? 'STOP' : isLoading ? 'TUNING' : 'PLAY LATEST'}
</button>
<button
onClick={toggleScan}
className={`px-2 py-1 text-[9px] font-mono border tracking-wider flex items-center gap-1.5 ${isScanning ? 'bg-red-900/60 border-red-400 text-red-300 animate-pulse' : 'border-red-800/50 text-red-600 hover:border-red-500'} transition-colors`}
className={`px-2 py-1.5 text-[9px] font-mono border tracking-wider flex items-center justify-center gap-1.5 ${isScanning ? 'bg-red-900/60 border-red-400 text-red-300 animate-pulse' : 'border-red-800/50 text-red-600 hover:border-red-500'} transition-colors`}
title="Auto-scan: continuously play intercepted bursts"
>
<FastForward size={10} />
@@ -293,11 +362,15 @@ function ScannerTracker({
step="0.05"
value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
className="w-16 accent-red-500 ml-auto"
className="w-16 accent-red-500"
title="Volume"
/>
</div>
<div className="mb-2 min-h-4 text-[10px] text-red-300/75 font-mono leading-snug">
{playerMessage}
</div>
{/* Waveform visualizer */}
<div className="flex items-end gap-[2px] h-6 opacity-70 mb-2">
{Array.from({ length: 36 }).map((_, i) => (
@@ -344,6 +417,9 @@ function SdrTracker({
onFlyTo: () => void;
}) {
const [isListening, setIsListening] = useState(false);
const [consentDialogOpen, setConsentDialogOpen] = useState(false);
const [consentDialogMode, setConsentDialogMode] = useState<'consent' | 'edit'>('consent');
const [currentCallsign, setCurrentCallsign] = useState('');
const popupRef = useRef<Window | null>(null);
// Poll to detect when user closes the popup
@@ -365,13 +441,17 @@ function SdrTracker({
};
}, []);
const openReceiver = () => {
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.focus();
return;
}
// Load persisted callsign on mount
useEffect(() => {
if (typeof window === 'undefined') return;
setCurrentCallsign((localStorage.getItem('kiwisdr_callsign') || '').trim());
}, []);
const launchPopup = (callsign: string) => {
if (!sdr.url) return;
const tuneUrl = `${sdr.url}${sdr.url.includes('?') ? '&' : '?'}n=ShadowBroker`;
const tuneUrl = callsign
? `${sdr.url}${sdr.url.includes('?') ? '&' : '?'}n=${encodeURIComponent(callsign)}`
: sdr.url;
popupRef.current = window.open(
tuneUrl,
'kiwisdr_receiver',
@@ -380,6 +460,39 @@ function SdrTracker({
setIsListening(true);
};
const openReceiver = () => {
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.focus();
return;
}
if (!sdr.url) return;
if (typeof window === 'undefined') return;
const consented = localStorage.getItem('kiwisdr_consent_v1') === '1';
if (!consented) {
setConsentDialogMode('consent');
setConsentDialogOpen(true);
return;
}
const callsign = (localStorage.getItem('kiwisdr_callsign') || '').trim();
launchPopup(callsign);
};
const handleConsentConfirm = (callsign: string) => {
if (typeof window !== 'undefined') {
localStorage.setItem('kiwisdr_consent_v1', '1');
if (callsign) {
localStorage.setItem('kiwisdr_callsign', callsign);
} else {
localStorage.removeItem('kiwisdr_callsign');
}
}
setCurrentCallsign(callsign);
setConsentDialogOpen(false);
if (consentDialogMode === 'consent') {
launchPopup(callsign);
}
};
const closeReceiver = () => {
popupRef.current?.close();
popupRef.current = null;
@@ -387,15 +500,15 @@ function SdrTracker({
};
return (
<div className="bg-amber-950/20 border border-amber-500/40 p-3 -mt-1 shadow-[0_0_15px_rgba(245,158,11,0.1)]">
<div className="bg-pink-950/20 border border-pink-500/40 p-3 -mt-1 shadow-[0_0_15px_rgba(236,72,153,0.1)]">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Radio size={14} className="text-amber-400" />
<span className="text-[12px] text-amber-400 font-mono tracking-widest font-bold">
<Radio size={14} className="text-pink-400" />
<span className="text-[14px] text-pink-400 font-mono tracking-widest font-bold">
SDR TRACKER
</span>
{isListening && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-amber-500/20 border border-amber-500/40 text-amber-400 animate-pulse">
<span className="text-[12px] font-mono px-1.5 py-0.5 rounded-full bg-pink-500/20 border border-pink-500/40 text-pink-400 animate-pulse">
LIVE
</span>
)}
@@ -406,23 +519,23 @@ function SdrTracker({
closeReceiver();
onRelease();
}}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-red-400 border border-[var(--border-primary)] hover:border-red-400/40 px-1.5 py-0.5 transition-colors"
className="text-[11px] font-mono text-[var(--text-muted)] hover:text-red-400 border border-[var(--border-primary)] hover:border-red-400/40 px-1.5 py-0.5 transition-colors"
>
RELEASE
</button>
</div>
{/* System info */}
<div className="flex flex-col p-2 border border-amber-500/20 bg-amber-950/10 mb-2">
<span className="text-[10px] font-bold font-mono text-amber-300 truncate">
<div className="flex flex-col p-2 border border-pink-500/20 bg-pink-950/10 mb-2">
<span className="text-[13px] font-bold font-mono text-pink-300 truncate">
{(sdr.name || 'REMOTE RECEIVER').toUpperCase()}
</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono">
<span className="text-[11px] text-[var(--text-muted)] font-mono">
{sdr.location && <span>{sdr.location} · </span>}
{sdr.antenna && <span>{sdr.antenna.slice(0, 40)}</span>}
</span>
{sdr.bands && (
<span className="text-[8px] text-amber-400/70 font-mono mt-0.5">
<span className="text-[11px] text-pink-400/70 font-mono mt-0.5">
{(Number(sdr.bands.split('-')[0]) / 1e6).toFixed(0)}-
{(Number(sdr.bands.split('-')[1]) / 1e6).toFixed(0)} MHz
{sdr.users !== undefined && ` · ${sdr.users}/${sdr.users_max || '?'} users`}
@@ -436,7 +549,7 @@ function SdrTracker({
{Array.from({ length: 36 }).map((_, i) => (
<motion.div
key={i}
className="w-[3px] rounded-t-sm bg-amber-500"
className="w-[3px] rounded-t-sm bg-pink-500"
animate={{ height: ['10%', `${Math.random() * 80 + 20}%`, '10%'] }}
transition={{
repeat: Infinity,
@@ -452,17 +565,17 @@ function SdrTracker({
<div className="flex items-center gap-2">
<button
onClick={onFlyTo}
className="flex-1 text-center px-2 py-1.5 border border-[var(--border-primary)] hover:border-amber-400/50 hover:text-amber-400 text-[var(--text-muted)] text-[9px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1.5"
className="flex-1 text-center px-2 py-1.5 border border-[var(--border-primary)] hover:border-pink-400/50 hover:text-pink-400 text-[var(--text-muted)] text-[12px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1.5"
>
<Globe size={10} /> RE-LOCK
</button>
{sdr.url && (
<button
onClick={isListening ? closeReceiver : openReceiver}
className={`flex-1 text-center px-2 py-1.5 border text-[9px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1.5 ${
className={`flex-1 text-center px-2 py-1.5 border text-[12px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1.5 ${
isListening
? 'border-amber-400 bg-amber-500/20 text-amber-300'
: 'border-amber-500/50 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 hover:border-amber-400'
? 'border-pink-400 bg-pink-500/20 text-pink-300'
: 'border-pink-500/50 bg-pink-500/10 text-pink-400 hover:bg-pink-500/20 hover:border-pink-400'
}`}
>
{isListening ? (
@@ -477,6 +590,34 @@ function SdrTracker({
</button>
)}
</div>
{/* Callsign line with edit affordance */}
<div className="flex items-center justify-between mt-2 text-[10px] font-mono text-[var(--text-muted)]">
<span>
CALLSIGN:{' '}
<span className="text-pink-300">
{currentCallsign || '(anonymous — KiwiSDR will prompt)'}
</span>
</span>
<button
type="button"
onClick={() => {
setConsentDialogMode('edit');
setConsentDialogOpen(true);
}}
className="text-pink-400/70 hover:text-pink-300 underline tracking-widest"
>
EDIT
</button>
</div>
<KiwiSdrConsentDialog
open={consentDialogOpen}
initialCallsign={currentCallsign}
mode={consentDialogMode}
onConfirm={handleConsentConfirm}
onCancel={() => setConsentDialogOpen(false)}
/>
</div>
);
}
@@ -505,6 +646,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
setSentinelPreset,
isMinimized: isMinimizedProp,
onMinimizedChange,
onOpenSarAoiEditor,
}: {
activeLayers: ActiveLayers;
setActiveLayers: React.Dispatch<React.SetStateAction<ActiveLayers>>;
@@ -529,6 +671,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
setSentinelPreset?: (p: string) => void;
isMinimized?: boolean;
onMinimizedChange?: (minimized: boolean) => void;
onOpenSarAoiEditor?: () => void;
}) {
const data = useDataSnapshot() as import('@/types/dashboard').DashboardData;
const [internalMinimized, setInternalMinimized] = useState(true);
@@ -543,6 +686,62 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
const [potusEnabled, setPotusEnabled] = useState(true);
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// SAR mode chooser — prompts the first time the user enables the SAR
// layer, remembers the choice, and auto-detects server-side Mode B.
const [sarChoice, setSarChoice] = useState<import('./SarModeChooserModal').SarChoice>(() => {
try {
const stored = localStorage.getItem('shadowbroker_sar_mode_choice');
if (stored === 'a_only' || stored === 'b_active') return stored;
} catch {
// localStorage unavailable
}
return null;
});
const [sarModalOpen, setSarModalOpen] = useState(false);
const [sarPendingEnable, setSarPendingEnable] = useState(false);
// Auto-detect: if the backend already has Mode B creds configured
// (via env or a previous runtime save), promote the stored choice to
// 'b_active' without prompting. If it flips back to off, reset so the
// next toggle re-prompts.
useEffect(() => {
let cancelled = false;
const check = async () => {
try {
const res = await fetch(`${API_BASE}/api/sar/status`, {
credentials: 'include',
});
if (!res.ok || cancelled) return;
const body = await res.json();
const modeBOn = Boolean(body?.products?.enabled);
if (cancelled) return;
if (modeBOn && sarChoice !== 'b_active') {
try {
localStorage.setItem('shadowbroker_sar_mode_choice', 'b_active');
} catch {
// ignore
}
setSarChoice('b_active');
} else if (!modeBOn && sarChoice === 'b_active') {
try {
localStorage.removeItem('shadowbroker_sar_mode_choice');
} catch {
// ignore
}
setSarChoice(null);
}
} catch {
// network error — keep the current choice
}
};
check();
return () => {
cancelled = true;
};
// Run on mount only — the auto-detect is best-effort.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sentinel tile loading feedback
const [sentinelInflight, setSentinelInflight] = useState(0);
const [sentinelLoaded, setSentinelLoaded] = useState(0);
@@ -776,13 +975,19 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
id: 'satellites',
name: 'Satellites',
source:
data?.satellite_source === 'celestrak'
(data?.satellite_source === 'celestrak'
? 'CelesTrak SGP4'
: data?.satellite_source === 'tle_api'
? 'TLE API · SGP4'
: data?.satellite_source === 'disk_cache'
? 'Cached · SGP4 (est.)'
: 'CelesTrak SGP4',
: 'CelesTrak SGP4')
+ (data?.satellite_analysis?.starlink?.total
? ` · ${data.satellite_analysis.starlink.total.toLocaleString()} Starlink`
: '')
+ (data?.satellite_analysis?.maneuvers?.length
? ` · ${data.satellite_analysis.maneuvers.length} maneuver${data.satellite_analysis.maneuvers.length > 1 ? 's' : ''}`
: ''),
count: data?.satellites?.length || 0,
icon: Satellite,
},
@@ -862,6 +1067,44 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
count: data?.air_quality?.length || 0,
icon: Wind,
},
{
id: 'sar',
name: 'SAR Ground-Change',
source:
(data?.sar_anomalies?.length
? `OPERA/EGMS · ${data.sar_anomalies.length} alerts · ${data.sar_scenes?.length || 0} passes`
: (data?.sar_scenes?.length
? `Catalog only · ${data.sar_scenes.length} Sentinel-1 passes · Alerts: sign up →`
: 'Catalog only (free) · Alerts: sign up →')),
count: data?.sar_anomalies?.length || 0,
icon: Radar,
},
],
},
{
label: 'UAP SIGHTINGS',
icon: Eye,
layers: [
{
id: 'uap_sightings',
name: 'UAP Reports',
source: 'NUFORC',
count: data?.uap_sightings?.length || 0,
icon: Eye,
},
],
},
{
label: 'BIOSURVEILLANCE',
icon: Droplets,
layers: [
{
id: 'wastewater',
name: 'Wastewater Pathogens',
source: 'WastewaterSCAN',
count: data?.wastewater?.length || 0,
icon: Droplets,
},
],
},
{
@@ -998,6 +1241,13 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
count: data?.gdelt?.length || 0,
icon: Activity,
},
{
id: 'crowdthreat',
name: 'CrowdThreat',
source: 'CrowdThreat',
count: data?.crowdthreat?.length || 0,
icon: Shield,
},
{
id: 'correlations',
name: 'Correlations',
@@ -1005,6 +1255,13 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
count: data?.correlations?.length || 0,
icon: Zap,
},
{
id: 'contradictions',
name: 'Possible Contradictions',
source: 'Narrative Intelligence',
count: data?.correlations?.filter((c: { type: string }) => c.type === 'contradiction').length || 0,
icon: Zap,
},
{
id: 'day_night',
name: 'Day / Night Cycle',
@@ -1012,6 +1269,13 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
count: null,
icon: Sun,
},
{
id: 'ai_intel',
name: 'AI Intel',
source: 'OpenClaw AI',
count: null,
icon: Zap,
},
],
},
];
@@ -1042,6 +1306,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
);
return (
<>
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
@@ -1049,25 +1314,22 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
className={`w-full flex flex-col pointer-events-none ${isMinimized ? 'flex-shrink-0' : 'flex-1 min-h-[300px]'}`}
>
{/* Header */}
<div className="mb-6 pointer-events-auto">
<div className="text-[10px] text-[var(--text-secondary)] font-mono tracking-widest mb-1">
TOP SECRET // SI-TK // NOFORN
<div className="mb-4 pointer-events-auto">
<div className="text-[9px] text-[var(--text-muted)] font-mono tracking-[0.3em] mb-3 opacity-50">
TOP SECRET // SI-TK // NOFORN · KH11-4094 OPS-4168
</div>
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest mb-4">
KH11-4094 OPS-4168
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-[0.2em] text-[var(--text-heading)]">FLIR</h1>
<div className="flex items-center gap-1.5">
<h1 className="text-xl font-bold tracking-[0.25em] text-[var(--text-heading)] mr-1">FLIR</h1>
<button
onClick={toggleTheme}
className={`w-7 h-7 border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
className="w-8 h-8 border border-cyan-900/40 hover:border-cyan-500/50 flex items-center justify-center text-cyan-400/70 hover:text-cyan-300 transition-all hover:bg-cyan-950/30"
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
</button>
<button
onClick={cycleHudColor}
className={`w-7 h-7 border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-cyan-400 hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
className="w-8 h-8 border border-cyan-900/40 hover:border-cyan-500/50 flex items-center justify-center text-cyan-400/70 hover:text-cyan-300 transition-all hover:bg-cyan-950/30"
title={hudColor === 'cyan' ? 'Switch to Matrix HUD' : 'Switch to Cyan HUD'}
>
<Palette size={14} />
@@ -1075,7 +1337,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
{onSettingsClick && (
<button
onClick={onSettingsClick}
className={`w-7 h-7 border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)] group`}
className="w-8 h-8 border border-cyan-900/40 hover:border-cyan-500/50 flex items-center justify-center text-cyan-400/70 hover:text-cyan-300 transition-all hover:bg-cyan-950/30 group"
title="System Settings"
>
<Settings
@@ -1087,15 +1349,15 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
{onLegendClick && (
<button
onClick={onLegendClick}
className={`h-7 px-2 border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center gap-1 ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
className="h-8 px-2.5 border border-cyan-900/40 hover:border-cyan-500/50 flex items-center justify-center gap-1.5 text-cyan-400/70 hover:text-cyan-300 transition-all hover:bg-cyan-950/30"
title="Map Legend / Icon Key"
>
<BookOpen size={12} />
<span className="text-[8px] font-mono tracking-widest font-bold">KEY</span>
<span className="text-[10px] font-mono tracking-widest font-bold">KEY</span>
</button>
)}
<span
className={`h-7 px-2 border border-[var(--border-primary)] flex items-center justify-center text-[8px] ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} font-mono tracking-widest select-none`}
className="h-8 px-2.5 border border-cyan-900/40 flex items-center justify-center text-[10px] text-cyan-400/60 font-mono tracking-widest select-none"
>
v{packageJson.version}
</span>
@@ -1106,14 +1368,15 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
<div className={`bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 pointer-events-auto flex flex-col relative overflow-hidden max-h-full ${isMinimized ? 'flex-shrink-0' : 'flex-1 min-h-0'}`}>
{/* Header / Toggle */}
<div
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
className="flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-cyan-950/30 transition-colors border-b border-cyan-900/40"
onClick={() => setIsMinimized(!isMinimized)}
>
<span
className="text-[12px] text-[var(--text-muted)] font-mono tracking-widest"
>
DATA LAYERS
</span>
<div className="flex items-center gap-2">
<Layers size={16} className="text-cyan-400" />
<span className="text-[12px] text-cyan-400 font-mono tracking-widest font-bold">
DATA LAYERS
</span>
</div>
<div className="flex items-center gap-2">
<button
title={
@@ -1148,16 +1411,16 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
{Object.entries(activeLayers)
.filter(([k]) => !['gibs_imagery', 'highres_satellite', 'sentinel_hub', 'viirs_nightlights'].includes(k))
.every(([, v]) => v) ? (
<ToggleRight size={16} />
<ToggleRight size={22} />
) : (
<ToggleLeft size={16} />
<ToggleLeft size={22} />
)}
</button>
<button
className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
>
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
{isMinimized ? (
<Plus size={16} className="text-cyan-400" />
) : (
<Minus size={16} className="text-cyan-400" />
)}
</div>
</div>
@@ -1197,7 +1460,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
<span className="text-[12px] text-[#ff1493] font-mono tracking-widest font-bold">
POTUS FLEET
</span>
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-[#ff1493]/20 border border-[#ff1493]/40 text-[#ff1493] animate-pulse">
<span className="text-[11px] font-mono px-1.5 py-0.5 rounded-full bg-[#ff1493]/20 border border-[#ff1493]/40 text-[#ff1493] animate-pulse">
{potusFlights.length} ACTIVE
</span>
</div>
@@ -1206,7 +1469,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
e.stopPropagation();
setPotusEnabled(false);
}}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 px-1.5 py-0.5 transition-colors"
className="text-[11px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 px-1.5 py-0.5 transition-colors"
title="Hide POTUS Fleet tracker"
>
HIDE
@@ -1240,7 +1503,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
<span className="text-[10px] font-bold font-mono" style={{ color }}>
{pf.meta.label}
</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">
<span className="text-[11px] text-[var(--text-muted)] font-mono mt-0.5">
{alt > 0 ? `${Math.round(alt).toLocaleString()} ft` : 'GND'} ·{' '}
{speed > 0 ? `${Math.round(speed)} kts` : 'STATIC'}
</span>
@@ -1250,7 +1513,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
className="w-1.5 h-1.5 rounded-full animate-pulse"
style={{ backgroundColor: color }}
/>
<span className="text-[8px] font-mono" style={{ color }}>
<span className="text-[11px] font-mono" style={{ color }}>
TRACK
</span>
</div>
@@ -1299,7 +1562,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
} transition-colors`}
/>
<span
className={`text-[11px] font-mono tracking-[0.2em] font-bold ${
className={`text-[13px] font-mono tracking-[0.2em] font-bold ${
section.label === 'SHODAN' ? 'text-green-400' : 'text-[var(--text-muted)]'
}`}
>
@@ -1307,7 +1570,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
</span>
{anyOn && totalCount > 0 && (
<span
className={`text-[8px] font-mono ${
className={`text-[12px] font-mono ${
section.label === 'SHODAN' ? 'text-green-500/70' : 'text-cyan-500/50'
}`}
>
@@ -1368,12 +1631,25 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
<div key={layer.id} className="flex flex-col">
<div
className="flex items-start justify-between group cursor-pointer"
onClick={() =>
onClick={() => {
// SAR first-run interception: if the user
// is turning the SAR layer ON for the first
// time and hasn't picked a mode yet, show
// the chooser instead of flipping silently.
if (
layer.id === 'sar' &&
!active &&
sarChoice === null
) {
setSarPendingEnable(true);
setSarModalOpen(true);
return;
}
setActiveLayers((prev: ActiveLayers) => ({
...prev,
[layer.id]: !active,
}))
}
}));
}}
>
<div className="flex gap-3">
<div
@@ -1407,7 +1683,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
>
{layer.name}
</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">
<span className="text-[11px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">
{layer.id === 'shodan_overlay'
? layer.source
: (
@@ -1437,13 +1713,13 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
</div>
<div className="flex items-center gap-2">
{active && (layer.count ?? 0) > 0 && (
<span className="text-[9px] text-gray-300 font-mono">
<span className="text-[12px] text-gray-300 font-mono">
{(layer.count ?? 0).toLocaleString()}
</span>
)}
{layer.id !== 'shodan_overlay' && (
<div
className={`text-[8px] font-mono tracking-wider px-1.5 py-0.5 rounded-full border ${
className={`text-[11px] font-mono tracking-wider px-1.5 py-0.5 rounded-full border ${
active
? layer.id === 'shodan_overlay'
? 'border-green-500/50 text-green-400 bg-green-950/30 shadow-[0_0_10px_rgba(34,197,94,0.2)]'
@@ -1502,11 +1778,11 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[8px] text-cyan-400 font-mono">
<span className="text-[11px] text-cyan-400 font-mono">
{gibsDate}
</span>
<div className="flex items-center gap-1">
<span className="text-[8px] text-[var(--text-muted)] font-mono">
<span className="text-[11px] text-[var(--text-muted)] font-mono">
OPC
</span>
<input
@@ -1523,6 +1799,22 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
</div>
</div>
)}
{/* SAR inline controls — AOI editor button */}
{active && layer.id === 'sar' && onOpenSarAoiEditor && (
<div
className="ml-7 mt-2 flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={onOpenSarAoiEditor}
className="flex items-center gap-1.5 text-[9px] font-mono tracking-wide text-cyan-400 hover:text-cyan-200 border border-cyan-500/30 hover:border-cyan-500/50 bg-cyan-500/5 hover:bg-cyan-500/10 px-2.5 py-1 rounded transition"
>
<MapPin size={10} />
EDIT AOIs
</button>
</div>
)}
{/* Sentinel Hub inline controls */}
{active &&
layer.id === 'sentinel_hub' &&
@@ -1547,11 +1839,11 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
<option value="MOISTURE-INDEX">Moisture Index</option>
</select>
{sentinelInflight > 0 ? (
<span className="text-[8px] font-mono text-purple-400 animate-pulse whitespace-nowrap">
<span className="text-[11px] font-mono text-purple-400 animate-pulse whitespace-nowrap">
{sentinelInflight} tile{sentinelInflight !== 1 ? 's' : ''}
</span>
) : sentinelLoaded > 0 ? (
<span className="text-[8px] font-mono text-purple-500/60 whitespace-nowrap">
<span className="text-[11px] font-mono text-purple-500/60 whitespace-nowrap">
{sentinelLoaded} loaded
</span>
) : null}
@@ -1580,11 +1872,11 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[8px] text-purple-400 font-mono">
<span className="text-[11px] text-purple-400 font-mono">
{sentinelDate}
</span>
<div className="flex items-center gap-1">
<span className="text-[8px] text-[var(--text-muted)] font-mono">
<span className="text-[11px] text-[var(--text-muted)] font-mono">
OPC
</span>
<input
@@ -1626,12 +1918,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
e.stopPropagation();
setPotusEnabled(true);
}}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 px-1.5 py-0.5 transition-colors"
className="text-[11px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 px-1.5 py-0.5 transition-colors"
>
SHOW
</button>
) : (
<span className="text-[8px] font-mono text-[var(--text-muted)]">
<span className="text-[11px] font-mono text-[var(--text-muted)]">
NO ACTIVE AIRCRAFT
</span>
)}
@@ -1644,6 +1936,22 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
</AnimatePresence>
</div>
</motion.div>
{sarModalOpen && (
<SarModeChooserModal
onClose={() => {
setSarModalOpen(false);
setSarPendingEnable(false);
}}
onChoiceMade={(choice) => {
setSarChoice(choice);
if (sarPendingEnable) {
setActiveLayers((prev: ActiveLayers) => ({ ...prev, sar: true }));
setSarPendingEnable(false);
}
}}
/>
)}
</>
);
});
@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Plus, Minus } from 'lucide-react';
import type { MapEffects } from '@/types/dashboard';
const WorldviewRightPanel = React.memo(function WorldviewRightPanel({
@@ -53,15 +53,17 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({
<div className="bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 pointer-events-auto border-r-2 border-r-cyan-900/40 flex flex-col relative overflow-hidden h-full">
{/* Header / Toggle */}
<div
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
className="flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-cyan-950/30 transition-colors border-b border-cyan-900/40"
onClick={() => setIsMinimized(!isMinimized)}
>
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">
<span className="text-[12px] text-cyan-400 font-mono tracking-widest font-bold">
DISPLAY CONFIG
</span>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
{isMinimized ? (
<Plus size={16} className="text-cyan-400" />
) : (
<Minus size={16} className="text-cyan-400" />
)}
</div>
<AnimatePresence>
@@ -0,0 +1,569 @@
'use client';
/**
* AIIntelPinDetail floating popup shown when the user clicks an AI Intel pin
* on the map.
*
* Features:
* - Shows label, category, coordinates, reverse-geocoded place
* - Shows entity attachment info (if pin is tracking a moving object)
* - Editable label / description
* - Threaded comment system with reply support (user + agent)
* - Follows the Threat-alert marker pattern: offset from target with a
* dashed connecting line + arrow pointing at the pin.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Marker } from 'react-map-gl/maplibre';
import { API_BASE } from '@/lib/api';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import {
fetchAIIntelPin,
updateAIIntelPin,
addAIIntelPinComment,
deleteAIIntelPinComment,
} from '@/lib/aiIntelClient';
import {
PIN_CATEGORY_COLORS,
PIN_CATEGORY_LABELS,
type PinCategory,
type AIIntelPin,
type AIIntelPinComment,
} from '@/types/aiIntel';
interface Props {
pinId: string;
onClose: () => void;
onDeleted?: () => void;
onUpdated?: () => void;
}
interface ReverseGeocode {
city?: string;
state?: string;
country?: string;
display_name?: string;
}
const POPUP_OFFSET = 160;
export const AIIntelPinDetail: React.FC<Props> = ({ pinId, onClose, onDeleted, onUpdated }) => {
const [pin, setPin] = useState<AIIntelPin | null>(null);
const [geo, setGeo] = useState<ReverseGeocode | null>(null);
const [editing, setEditing] = useState(false);
const [editLabel, setEditLabel] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editCategory, setEditCategory] = useState<PinCategory>('custom');
const [saving, setSaving] = useState(false);
const [newComment, setNewComment] = useState('');
const [replyTo, setReplyTo] = useState<string>('');
const [commentAuthor, setCommentAuthor] = useState<'user' | 'agent'>('user');
const [posting, setPosting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const commentInputRef = useRef<HTMLTextAreaElement | null>(null);
// Initial pin fetch
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetchAIIntelPin(pinId);
if (cancelled) return;
setPin(res.pin);
setEditLabel(res.pin.label);
setEditDescription(res.pin.description || '');
setEditCategory(res.pin.category);
} catch (err) {
console.error('Failed to load pin:', err);
}
})();
return () => {
cancelled = true;
};
}, [pinId]);
// Reverse geocode once we have coordinates
useEffect(() => {
if (!pin) return;
let cancelled = false;
(async () => {
try {
const url = `${API_BASE}/api/geocode/reverse?lat=${pin.lat}&lng=${pin.lng}`;
const resp = await fetch(url);
if (!resp.ok) return;
const data = await resp.json();
if (cancelled) return;
setGeo({
city: data.city || data.town || data.village || data.hamlet || '',
state: data.state || data.region || '',
country: data.country || '',
display_name: data.display_name || '',
});
} catch {
/* ignore reverse geocode failures */
}
})();
return () => {
cancelled = true;
};
}, [pin]);
const handleSaveEdit = useCallback(async () => {
if (!pin || !editLabel.trim()) return;
setSaving(true);
try {
const res = await updateAIIntelPin(pin.id, {
label: editLabel.trim(),
description: editDescription.trim(),
category: editCategory,
});
setPin(res.pin);
setEditing(false);
onUpdated?.();
} catch (err) {
console.error('Failed to update pin:', err);
}
setSaving(false);
}, [pin, editLabel, editDescription, editCategory, onUpdated]);
const handlePostComment = useCallback(async () => {
if (!pin || !newComment.trim()) return;
setPosting(true);
try {
const res = await addAIIntelPinComment(pin.id, {
text: newComment.trim(),
author: commentAuthor,
reply_to: replyTo,
});
setPin(res.pin);
setNewComment('');
setReplyTo('');
onUpdated?.();
} catch (err) {
console.error('Failed to post comment:', err);
}
setPosting(false);
}, [pin, newComment, commentAuthor, replyTo, onUpdated]);
const handleDeleteComment = useCallback(
async (commentId: string) => {
if (!pin) return;
try {
await deleteAIIntelPinComment(pin.id, commentId);
// Refresh pin
const refreshed = await fetchAIIntelPin(pin.id);
setPin(refreshed.pin);
onUpdated?.();
} catch (err) {
console.error('Failed to delete comment:', err);
}
},
[pin, onUpdated],
);
const executeDeletePin = useCallback(async () => {
if (!pin) return;
setShowDeleteConfirm(false);
try {
await fetch(`${API_BASE}/api/ai/pins/${pin.id}`, { method: 'DELETE' });
onDeleted?.();
onClose();
} catch (err) {
console.error('Failed to delete pin:', err);
}
}, [pin, onDeleted, onClose]);
// Stop keyboard events from leaking to global hotkeys
const stopKeys = useCallback((e: React.KeyboardEvent) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
}, []);
if (!pin) return null;
const categoryColor = PIN_CATEGORY_COLORS[pin.category] || '#8b5cf6';
const locationLine = [geo?.city, geo?.state, geo?.country].filter(Boolean).join(', ');
// Build reply map (comment_id → replies)
const comments = pin.comments || [];
const topLevel = comments.filter((c) => !c.reply_to);
const replies: Record<string, AIIntelPinComment[]> = {};
for (const c of comments) {
if (c.reply_to) {
(replies[c.reply_to] = replies[c.reply_to] || []).push(c);
}
}
return (
<>
<Marker
latitude={pin.lat}
longitude={pin.lng}
anchor="center"
offset={[0, -POPUP_OFFSET]}
style={{ zIndex: 9995 }}
>
<div
className="relative"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={stopKeys}
onKeyUp={stopKeys}
>
{/* Dashed connecting line */}
<svg
className="absolute pointer-events-none"
style={{ left: '50%', top: '50%', width: 1, height: 1, overflow: 'visible', zIndex: -1 }}
>
<line
x1={0}
y1={0}
x2={0}
y2={POPUP_OFFSET}
stroke={categoryColor}
strokeWidth={1.5}
strokeDasharray="4,3"
className="opacity-80"
/>
<circle cx={0} cy={POPUP_OFFSET} r={4} fill={categoryColor} stroke="#0a0a14" strokeWidth={1.5} />
</svg>
{/* Arrow pointing down */}
<div
style={{
position: 'absolute',
bottom: -6,
left: '50%',
transform: 'translateX(-50%)',
width: 0,
height: 0,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: `6px solid ${categoryColor}`,
}}
/>
{/* Dialog body */}
<div
className="bg-[#0a0a14] border-2 font-mono text-white"
style={{
borderColor: `${categoryColor}99`,
minWidth: 320,
maxWidth: 360,
maxHeight: 460,
overflowY: 'auto',
transform: 'translateX(-50%)',
marginLeft: '50%',
boxShadow: `0 10px 30px rgba(0,0,0,0.7), 0 0 0 1px ${categoryColor}33`,
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-3 py-2 border-b"
style={{ borderColor: `${categoryColor}55`, background: `${categoryColor}18` }}
>
<div className="flex items-center gap-2 min-w-0">
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{ background: categoryColor }}
/>
<span className="text-[10px] uppercase tracking-widest truncate" style={{ color: categoryColor }}>
{PIN_CATEGORY_LABELS[pin.category] || pin.category}
</span>
</div>
<div className="flex items-center gap-1">
{!editing && (
<button
type="button"
onClick={() => setEditing(true)}
className="text-[10px] px-2 py-0.5 text-violet-300 hover:text-white border border-violet-500/30 hover:border-violet-500/60"
>
EDIT
</button>
)}
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="text-[10px] px-2 py-0.5 text-red-400 hover:text-red-200 border border-red-500/30 hover:border-red-500/60"
>
DEL
</button>
<button
type="button"
onClick={onClose}
className="text-gray-500 hover:text-white text-base leading-none px-1"
aria-label="Close"
>
×
</button>
</div>
</div>
{/* Main body */}
<div className="px-3 py-2 space-y-2">
{editing ? (
<>
<input
type="text"
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
placeholder="Label"
onMouseDown={(e) => {
e.stopPropagation();
(e.currentTarget as HTMLInputElement).focus();
}}
onKeyDown={stopKeys}
className="w-full px-2 py-1 text-[12px] font-mono bg-black/60 border border-violet-500/40 outline-none focus:border-violet-500"
/>
<select
aria-label="Category"
value={editCategory}
onChange={(e) => setEditCategory(e.target.value as PinCategory)}
className="w-full px-2 py-1 text-[11px] font-mono bg-black/60 border border-violet-500/40 outline-none focus:border-violet-500 border-l-4"
style={{ borderLeftColor: PIN_CATEGORY_COLORS[editCategory] }}
>
{(Object.keys(PIN_CATEGORY_LABELS) as PinCategory[]).map((c) => (
<option key={c} value={c} className="bg-[#0a0a14]">
{PIN_CATEGORY_LABELS[c]}
</option>
))}
</select>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Notes"
rows={3}
onMouseDown={(e) => {
e.stopPropagation();
(e.currentTarget as HTMLTextAreaElement).focus();
}}
onKeyDown={stopKeys}
className="w-full px-2 py-1 text-[11px] font-mono bg-black/60 border border-violet-500/30 outline-none focus:border-violet-500 resize-none"
/>
<div className="flex gap-1.5">
<button
type="button"
disabled={saving || !editLabel.trim()}
onClick={handleSaveEdit}
className="flex-1 py-1 text-[11px] bg-violet-600/40 border border-violet-500/60 hover:bg-violet-600/60 disabled:opacity-40"
>
{saving ? '...' : 'SAVE'}
</button>
<button
type="button"
onClick={() => {
setEditing(false);
setEditLabel(pin.label);
setEditDescription(pin.description || '');
setEditCategory(pin.category);
}}
className="px-3 py-1 text-[11px] border border-gray-600/40 text-gray-400 hover:text-white"
>
CANCEL
</button>
</div>
</>
) : (
<>
<div className="text-[14px] font-bold leading-snug break-words">{pin.label}</div>
{pin.description && (
<div className="text-[11px] text-gray-300 whitespace-pre-wrap break-words leading-relaxed">
{pin.description}
</div>
)}
</>
)}
{/* Location / entity metadata */}
<div className="text-[10px] text-gray-400 space-y-0.5 pt-1 border-t border-white/5">
{pin.entity_attachment ? (
<div className="text-cyan-400">
<span className="text-gray-500">TRACKING: </span>
{pin.entity_attachment.entity_label || pin.entity_attachment.entity_id}
<span className="text-cyan-600 ml-1">({pin.entity_attachment.entity_type})</span>
</div>
) : null}
<div>
<span className="text-gray-500">COORDS: </span>
{pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}
</div>
{locationLine && (
<div>
<span className="text-gray-500">PLACE: </span>
{locationLine}
</div>
)}
{pin.source && (
<div>
<span className="text-gray-500">SOURCE: </span>
{pin.source}
</div>
)}
</div>
</div>
{/* Comments thread */}
<div className="border-t border-white/10 px-3 py-2">
<div className="text-[10px] uppercase tracking-widest text-violet-400 mb-1.5">
Comments ({comments.length})
</div>
{topLevel.length === 0 && (
<div className="text-[10px] text-gray-600 italic mb-1.5">No comments yet.</div>
)}
<div className="space-y-1.5 max-h-40 overflow-y-auto">
{topLevel.map((c) => (
<CommentBlock
key={c.id}
comment={c}
replies={replies[c.id] || []}
onReply={(id) => {
setReplyTo(id);
setTimeout(() => commentInputRef.current?.focus(), 30);
}}
onDelete={handleDeleteComment}
/>
))}
</div>
{/* New comment input */}
<div className="mt-2 pt-2 border-t border-white/5 space-y-1.5">
{replyTo && (
<div className="text-[9px] text-violet-400 flex items-center justify-between">
<span>Replying to comment</span>
<button
type="button"
onClick={() => setReplyTo('')}
className="text-gray-500 hover:text-white"
>
cancel
</button>
</div>
)}
<textarea
ref={commentInputRef}
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder={replyTo ? 'Reply…' : 'Add a comment…'}
rows={2}
onMouseDown={(e) => {
e.stopPropagation();
(e.currentTarget as HTMLTextAreaElement).focus();
}}
onKeyDown={(e) => {
stopKeys(e);
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handlePostComment();
}
}}
className="w-full px-2 py-1 text-[11px] font-mono bg-black/60 border border-violet-500/30 outline-none focus:border-violet-500 resize-none"
/>
<div className="flex items-center justify-between gap-1.5">
<select
aria-label="Comment as"
value={commentAuthor}
onChange={(e) => setCommentAuthor(e.target.value as 'user' | 'agent')}
className="text-[10px] font-mono bg-black/60 border border-violet-500/30 px-1 py-0.5 outline-none"
>
<option value="user">as USER</option>
<option value="agent">as AGENT</option>
</select>
<button
type="button"
disabled={posting || !newComment.trim()}
onClick={handlePostComment}
className="flex-1 py-1 text-[11px] bg-violet-600/40 border border-violet-500/60 hover:bg-violet-600/60 disabled:opacity-40"
>
{posting ? '...' : replyTo ? 'REPLY' : 'POST'}
</button>
</div>
</div>
</div>
</div>
</div>
</Marker>
{showDeleteConfirm && (
<ConfirmDialog
open
title="DELETE PIN"
message={`Delete pin "${pin.label}"?\n\nThis cannot be undone.`}
confirmLabel="DELETE"
danger
onConfirm={executeDeletePin}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</>
);
};
// ---------------------------------------------------------------------------
// Comment block (recursive for replies)
// ---------------------------------------------------------------------------
interface CommentBlockProps {
comment: AIIntelPinComment;
replies: AIIntelPinComment[];
onReply: (commentId: string) => void;
onDelete: (commentId: string) => void;
}
const CommentBlock: React.FC<CommentBlockProps> = ({ comment, replies, onReply, onDelete }) => {
const authorColor = comment.author === 'agent' ? '#22d3ee' : comment.author === 'openclaw' ? '#f59e0b' : '#a78bfa';
const when = formatRelative(comment.created_at);
return (
<div className="text-[11px] leading-snug">
<div className="flex items-start gap-1.5">
<span
className="text-[9px] uppercase tracking-wider font-bold flex-shrink-0 mt-0.5"
style={{ color: authorColor }}
>
{comment.author}
</span>
<span className="text-[9px] text-gray-600 flex-shrink-0 mt-0.5">{when}</span>
<div className="flex-1 min-w-0 flex items-start justify-between gap-1">
<div className="whitespace-pre-wrap break-words text-gray-200 flex-1">{comment.text}</div>
<div className="flex gap-1 flex-shrink-0">
<button
type="button"
onClick={() => onReply(comment.id)}
className="text-[9px] text-gray-500 hover:text-violet-300"
>
reply
</button>
<button
type="button"
onClick={() => onDelete(comment.id)}
className="text-[9px] text-gray-600 hover:text-red-400"
>
×
</button>
</div>
</div>
</div>
{replies.length > 0 && (
<div className="ml-4 mt-1 pl-2 border-l border-violet-500/20 space-y-1">
{replies.map((r) => (
<CommentBlock key={r.id} comment={r} replies={[]} onReply={onReply} onDelete={onDelete} />
))}
</div>
)}
</div>
);
};
function formatRelative(ts: number): string {
const now = Date.now() / 1000;
const diff = now - ts;
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
export default AIIntelPinDetail;
@@ -0,0 +1,119 @@
'use client';
import React, { useEffect, useState, useRef } from 'react';
import { Source, Layer, Marker } from 'react-map-gl/maplibre';
import { API_BASE } from '@/lib/api';
interface Props {
vesselLat: number;
vesselLng: number;
destination: string;
}
/**
* Geocodes a fishing vessel's AIS destination and draws a dashed cyan route line
* from the vessel to the destination on the map.
*/
export default function FishingDestinationRoute({ vesselLat, vesselLng, destination }: Props) {
const [destCoords, setDestCoords] = useState<[number, number] | null>(null);
const [destLabel, setDestLabel] = useState('');
const prevDest = useRef('');
useEffect(() => {
if (!destination) { setDestCoords(null); return; }
const query = destination.trim();
if (!query || query === prevDest.current) return;
prevDest.current = query;
let cancelled = false;
(async () => {
try {
const res = await fetch(`${API_BASE}/api/geocode/search?q=${encodeURIComponent(query)}&limit=1`);
if (!res.ok || cancelled) return;
const json = await res.json();
const results = json.results || json;
if (Array.isArray(results) && results.length > 0 && !cancelled) {
const r = results[0];
setDestCoords([r.lng ?? r.lon, r.lat]);
setDestLabel(r.label || r.display_name || query);
} else {
setDestCoords(null);
}
} catch {
setDestCoords(null);
}
})();
return () => { cancelled = true; };
}, [destination]);
if (!destCoords) return null;
const geojson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { type: 'fishing-route' },
geometry: {
type: 'LineString',
coordinates: [[vesselLng, vesselLat], destCoords],
},
},
{
type: 'Feature',
properties: { type: 'fishing-dest' },
geometry: {
type: 'Point',
coordinates: destCoords,
},
},
],
};
return (
<>
<Source id="fishing-dest-route" type="geojson" data={geojson}>
<Layer
id="fishing-dest-line"
type="line"
filter={['==', ['get', 'type'], 'fishing-route']}
paint={{
'line-color': '#0ea5e9',
'line-width': 2,
'line-opacity': 0.7,
'line-dasharray': [6, 4],
}}
/>
<Layer
id="fishing-dest-point"
type="circle"
filter={['==', ['get', 'type'], 'fishing-dest']}
paint={{
'circle-radius': 6,
'circle-color': 'rgba(14, 165, 233, 0.3)',
'circle-stroke-width': 2,
'circle-stroke-color': '#0ea5e9',
}}
/>
<Layer
id="fishing-dest-label"
type="symbol"
filter={['==', ['get', 'type'], 'fishing-dest']}
layout={{
'text-field': destLabel,
'text-font': ['Noto Sans Bold'],
'text-size': 11,
'text-offset': [0, 1.4],
'text-anchor': 'top',
'text-allow-overlap': true,
}}
paint={{
'text-color': '#0ea5e9',
'text-halo-color': 'rgba(0,0,0,0.9)',
'text-halo-width': 1.5,
}}
/>
</Source>
</>
);
}
+9 -3
View File
@@ -321,6 +321,13 @@ export function ThreatMarkers({
const riskColor = getRiskColor(score);
const alertKey = n.alertKey || `${n.title}|${n.coords?.[0]},${n.coords?.[1]}`;
// Color-blind accessible border pattern based on severity
const threatBorderClass =
score >= 9 ? 'threat-border-critical' :
score >= 7 ? 'threat-border-high' :
score >= 4 ? 'threat-border-medium' :
'threat-border-low';
let isVisible = zoom >= 1;
if (selectedEntity) {
if (selectedEntity.type === 'news') {
@@ -371,12 +378,12 @@ export function ThreatMarkers({
)}
<div
className="cursor-pointer transition-opacity duration-300 relative"
className={`cursor-pointer transition-opacity duration-300 relative ${threatBorderClass}`}
style={{
opacity: isVisible ? 1.0 : 0.0,
pointerEvents: isVisible ? 'auto' : 'none',
backgroundColor: 'rgba(5, 5, 5, 0.96)',
border: `2px solid ${riskColor}`,
borderColor: riskColor,
borderRadius: '4px',
padding: '8px 20px 8px 12px',
color: riskColor,
@@ -384,7 +391,6 @@ export function ThreatMarkers({
fontSize: '12px',
fontWeight: 'bold',
textAlign: 'center',
boxShadow: `0 0 20px ${riskColor}80, 0 0 40px ${riskColor}30`,
zIndex: 10,
lineHeight: '1.3',
minWidth: '200px',
@@ -68,7 +68,16 @@ type BuildRequest = {
payload: DynamicMapLayersBuildPayload;
};
type WorkerRequest = SyncRequest | BuildRequest;
type SyncAndBuildRequest = {
id: string;
action: 'sync_and_build_dynamic_layers';
payload: {
data: DynamicMapLayersDataPayload;
build: DynamicMapLayersBuildPayload;
};
};
type WorkerRequest = SyncRequest | BuildRequest | SyncAndBuildRequest;
type WorkerResponse = {
id: string;
@@ -164,6 +173,34 @@ function inView(lat: number, lng: number, bounds: BoundsTuple): boolean {
return lng >= bounds[0] && lng <= bounds[2] && lat >= bounds[1] && lat <= bounds[3];
}
function cleanLabel(value: unknown): string {
if (typeof value !== 'string' && typeof value !== 'number') return '';
return String(value).trim();
}
function isRawIcaoLabel(label: string, icao24: unknown): boolean {
const icao = cleanLabel(icao24).toLowerCase();
return Boolean(icao && label.toLowerCase() === icao);
}
function flightDisplayLabel(f: Flight): string {
const candidates: unknown[] = [
'alert_operator' in f ? f.alert_operator : '',
'operator' in f ? f.operator : '',
'owner' in f ? f.owner : '',
'tracked_name' in f ? f.tracked_name : '',
'name' in f ? f.name : '',
f.callsign,
f.registration,
f.model,
];
for (const candidate of candidates) {
const label = cleanLabel(candidate);
if (label && !isRawIcaoLabel(label, f.icao24)) return label;
}
return '';
}
function interpFlightPosition(f: Flight, dtSeconds: number): [number, number] {
if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat];
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
@@ -236,7 +273,7 @@ function buildFlightLayerGeoJSONWorker(
properties: {
id: f.icao24 || f.callsign || `${idPrefix}${i}`,
type: typeLabel,
callsign: f.callsign || f.icao24,
callsign: flightDisplayLabel(f),
rotation,
iconId,
},
@@ -274,14 +311,7 @@ function buildTrackedFlightsGeoJSONWorker(
: TRACKED_ICON_MAP[acType]?.[alertColor] ||
TRACKED_ICON_MAP.airliner[alertColor] ||
'svgAirlinerWhite';
const displayName =
('alert_operator' in f ? f.alert_operator : '') ||
('operator' in f ? f.operator : '') ||
('owner' in f ? f.owner : '') ||
('name' in f ? f.name : '') ||
f.callsign ||
f.icao24 ||
'UNKNOWN';
const displayName = flightDisplayLabel(f);
features.push({
type: 'Feature',
@@ -570,6 +600,12 @@ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
postMessage({ id, ok: true, result: EMPTY_RESULT } satisfies WorkerResponse);
return;
}
if (action === 'sync_and_build_dynamic_layers') {
dynamicData = payload.data;
const result = buildDynamicLayers(payload.build);
postMessage({ id, ok: true, result } satisfies WorkerResponse);
return;
}
if (action !== 'build_dynamic_layers') {
postMessage({ id, ok: false, error: 'unsupported_action' } satisfies WorkerResponse);
return;
@@ -2,12 +2,14 @@ import { describe, it, expect } from 'vitest';
import {
buildEarthquakesGeoJSON,
buildFirmsGeoJSON,
buildFishingActivityGeoJSON,
buildShipsGeoJSON,
buildCarriersGeoJSON,
} from '@/components/map/geoJSONBuilders';
import type {
Earthquake,
FireHotspot,
FishingEvent,
Ship,
ActiveLayers,
} from '@/types/dashboard';
@@ -184,3 +186,29 @@ describe('buildCarriersGeoJSON', () => {
expect(result.features[0].properties?.iconId).toBe('svgCarrier');
});
});
describe('buildFishingActivityGeoJSON', () => {
it('reuses AIS ship icon styling when a fishing vessel matches a live ship', () => {
const events: FishingEvent[] = [
{
id: 'fish-1',
type: 'fishing',
lat: 12,
lng: 34,
start: '2026-04-08T00:00:00Z',
end: '2026-04-08T01:00:00Z',
vessel_name: 'PACIFIC HARVEST',
vessel_flag: 'US',
duration_hrs: 1,
},
];
const ships: Ship[] = [
{ name: 'Pacific Harvest', lat: 12, lng: 34, type: 'cargo', mmsi: '123', heading: 87 } as Ship,
];
const result = buildFishingActivityGeoJSON(events, ships)!;
expect(result.features[0].properties?.iconId).toBe('svgShipRed');
expect(result.features[0].properties?.shipCategory).toBe('cargo');
expect(result.features[0].properties?.rotation).toBe(87);
});
});
+484 -16
View File
@@ -35,6 +35,11 @@ import type {
SigintSignal,
Train,
CorrelationAlert,
UAPSighting,
WastewaterPlant,
CrowdThreatItem,
SarAnomaly,
SarAoi,
} from '@/types/dashboard';
import { classifyAircraft } from '@/utils/aircraftClassification';
import { MISSION_COLORS, MISSION_ICON_MAP } from '@/components/map/icons/SatelliteIcons';
@@ -217,16 +222,35 @@ export function buildCorrelationsGeoJSON(alerts?: CorrelationAlert[]): FC {
rf_anomaly: { high: 0.40, medium: 0.25, low: 0.15 },
military_buildup: { high: 0.40, medium: 0.25, low: 0.15 },
infra_cascade: { high: 0.45, medium: 0.30, low: 0.20 },
contradiction: { high: 0.35, medium: 0.25, low: 0.15 },
analysis_zone: { high: 0.35, medium: 0.22, low: 0.12 },
};
return {
type: 'Feature' as const,
properties: {
id: i,
id: a.id || `corr-${i}`,
type: 'correlation',
corr_type: a.type,
severity: a.severity,
score: a.score,
drivers: (a.drivers || []).join(' + '),
opacity: opacityMap[a.type]?.[a.severity] ?? 0.2,
corr_index: i,
// Contradiction extras
...(a.type === 'contradiction' && {
context: a.context || '',
alternatives: (a.alternatives || []).join(' | '),
location_name: a.location_name || '',
}),
// Analysis zone extras (OpenClaw-placed)
...(a.type === 'analysis_zone' && {
zone_id: a.id || '',
zone_title: a.title || '',
zone_body: a.body || '',
zone_category: a.category || 'analysis',
zone_source: a.source || 'openclaw',
zone_deletable: true,
}),
},
geometry: {
type: 'Polygon' as const,
@@ -906,6 +930,33 @@ export function buildShipsGeoJSON(
// ─── Carriers ───────────────────────────────────────────────────────────────
function normalizeShipName(value: string | undefined | null): string {
return (value || '').trim().toUpperCase();
}
function getShipIconId(ship: Pick<Ship, 'type' | 'yacht_alert'> | null | undefined): string {
if (!ship) return 'svgShipBlue';
const isTrackedYacht = !!ship.yacht_alert;
const isMilitary = ship.type === 'carrier' || ship.type === 'military_vessel';
const isCargo = ship.type === 'tanker' || ship.type === 'cargo';
const isPassenger = ship.type === 'passenger';
if (isTrackedYacht) return 'svgShipPink';
if (isCargo) return 'svgShipRed';
if (ship.type === 'yacht' || isPassenger) return 'svgShipWhite';
if (isMilitary) return 'svgShipAmber';
return 'svgShipBlue';
}
function getShipCategory(ship: Pick<Ship, 'type' | 'yacht_alert'> | null | undefined): string {
if (!ship) return 'civilian';
if (ship.yacht_alert || ship.type === 'yacht') return 'yacht';
if (ship.type === 'tanker' || ship.type === 'cargo') return 'cargo';
if (ship.type === 'passenger') return 'passenger';
if (ship.type === 'carrier' || ship.type === 'military_vessel') return 'military';
return 'civilian';
}
// ─── SIGINT GeoJSON ──────────────────────────────────────────────────────────
function buildSigintFeature(sig: SigintSignal): GeoJSON.Feature | null {
@@ -1196,24 +1247,38 @@ export function buildVolcanoesGeoJSON(volcanoes?: Volcano[]): FC {
// ─── Fishing Activity ───────────────────────────────────────────────────────
export function buildFishingActivityGeoJSON(events?: FishingEvent[]): FC {
export function buildFishingActivityGeoJSON(events?: FishingEvent[], ships?: Ship[]): FC {
if (!events?.length) return null;
const shipsByName = new Map<string, Ship>();
for (const ship of ships || []) {
const normalizedName = normalizeShipName(ship.name);
if (normalizedName && !shipsByName.has(normalizedName)) {
shipsByName.set(normalizedName, ship);
}
}
return {
type: 'FeatureCollection' as const,
features: events.map((e, i) => ({
type: 'Feature' as const,
properties: {
id: e.id || `fish-${i}`,
type: 'fishing_event',
vessel_name: e.vessel_name,
vessel_flag: e.vessel_flag,
event_type: e.type,
start: e.start,
end: e.end,
duration_hrs: e.duration_hrs,
},
geometry: { type: 'Point' as const, coordinates: [e.lng, e.lat] },
})),
features: events.map((e, i) => {
const matchedShip = shipsByName.get(normalizeShipName(e.vessel_name));
return {
type: 'Feature' as const,
properties: {
id: e.id || `fish-${i}`,
type: 'fishing_event',
vessel_name: e.vessel_name,
vessel_flag: e.vessel_flag,
event_type: e.type,
start: e.start,
end: e.end,
duration_hrs: e.duration_hrs,
iconId: getShipIconId(matchedShip),
shipCategory: getShipCategory(matchedShip),
aisMatched: !!matchedShip,
rotation: matchedShip?.heading || 0,
},
geometry: { type: 'Point' as const, coordinates: [e.lng, e.lat] },
};
}),
};
}
@@ -1307,3 +1372,406 @@ export function buildISSFootprintGeoJSON(
features: [geoCircle(lng, lat, footprintKm)],
};
}
// ─── AI Intel Layer ──────────────────────────────────────────────────────────
export interface AIIntelPinData {
id: string;
layer_id?: string;
lat: number;
lng: number;
label: string;
category: string;
color: string;
description: string;
source: string;
source_url: string;
confidence: number;
created_at: string;
entity_attachment?: {
entity_type: string;
entity_id: string;
entity_label?: string;
} | null;
}
/** Resolve the live position of an entity-attached pin from telemetry data. */
function resolveEntityPosition(
attachment: NonNullable<AIIntelPinData['entity_attachment']>,
data?: DashboardData | null,
): { lat: number; lng: number } | null {
if (!data) return null;
const id = attachment.entity_id;
const t = attachment.entity_type;
// Flight types — keyed by icao24
if (t === 'flight' || t === 'commercial_flight') {
const e = data.commercial_flights?.find((f) => f.icao24 === id);
if (e) return { lat: e.lat, lng: e.lng };
}
if (t === 'private_flight' || t === 'private_ga') {
const e = data.private_flights?.find((f) => f.icao24 === id);
if (e) return { lat: e.lat, lng: e.lng };
}
if (t === 'private_jet') {
const e = data.private_jets?.find((f) => f.icao24 === id);
if (e) return { lat: e.lat, lng: e.lng };
}
if (t === 'military_flight') {
const e = data.military_flights?.find((f) => f.icao24 === id);
if (e) return { lat: e.lat, lng: e.lng };
}
if (t === 'tracked_flight') {
const e = data.tracked_flights?.find((f) => f.icao24 === id);
if (e) return { lat: e.lat, lng: e.lng };
}
if (t === 'uav') {
const e = data.uavs?.find((u) => String(u.id) === id);
if (e) return { lat: e.lat, lng: e.lng };
}
// Ships — keyed by MMSI
if (t === 'ship') {
const e = data.ships?.find((s) => String(s.mmsi) === id);
if (e) return { lat: e.lat, lng: e.lng };
}
// Satellites — keyed by numeric ID
if (t === 'satellite') {
const e = data.satellites?.find((s) => String(s.id) === id);
if (e) return { lat: e.lat, lng: e.lng };
}
// Trains — keyed by id
if (t === 'train') {
const e = data.trains?.find((tr) => tr.id === id);
if (e) return { lat: e.lat, lng: e.lng };
}
// Fallback: search all flight arrays if generic "flight" didn't match
if (t === 'flight') {
for (const arr of [data.private_flights, data.private_jets, data.military_flights, data.tracked_flights, data.uavs] as Array<Array<{ icao24?: string; id?: string | number; lat: number; lng: number }> | undefined>) {
const e = arr?.find((f) => (f.icao24 ?? String(f.id)) === id);
if (e) return { lat: e.lat, lng: e.lng };
}
}
return null;
}
export function buildAIIntelGeoJSON(pins?: AIIntelPinData[], data?: DashboardData | null): FC {
if (!pins?.length) return null;
return {
type: 'FeatureCollection' as const,
features: pins
.filter((pin) => pin.lat != null && pin.lng != null)
.map((pin) => {
// For entity-attached pins, resolve live position from telemetry
let lat = pin.lat;
let lng = pin.lng;
let tracking = false;
if (pin.entity_attachment?.entity_type && pin.entity_attachment?.entity_id) {
const live = resolveEntityPosition(pin.entity_attachment, data);
if (live) {
lat = live.lat;
lng = live.lng;
tracking = true;
}
}
return {
type: 'Feature' as const,
properties: {
type: 'ai_intel_pin',
id: pin.id,
layer_id: pin.layer_id || '',
name: pin.label,
label: pin.label,
category: pin.category,
color: pin.color || '#3b82f6',
description: pin.description,
source: pin.source,
source_url: pin.source_url,
confidence: pin.confidence,
created_at: pin.created_at,
entity_type: pin.entity_attachment?.entity_type || '',
entity_id: pin.entity_attachment?.entity_id || '',
tracking,
},
geometry: {
type: 'Point' as const,
coordinates: [lng, lat],
},
};
}),
};
}
// ─── UAP Sightings ─────────────────────────────────────────────────────────
const UAP_SHAPE_COLORS: Record<string, string> = {
triangle: '#ef4444', // Red
orb: '#3b82f6', // Blue
light: '#facc15', // Yellow
disk: '#a855f7', // Purple
cigar: '#f97316', // Orange
'tic-tac': '#22d3ee', // Cyan
fireball: '#dc2626', // Deep red
formation: '#10b981', // Emerald
diamond: '#e879f9', // Fuchsia
rectangle: '#6366f1', // Indigo
flash: '#fbbf24', // Amber
changing: '#8b5cf6', // Violet
unknown: '#9ca3af', // Grey
};
// ─── CrowdThreat ──────────────────────────────────────────────────────────
export function buildCrowdThreatGeoJSON(threats?: CrowdThreatItem[], inView?: InViewFilter): FC {
if (!threats?.length) return null;
return {
type: 'FeatureCollection' as const,
features: threats
.map((t) => {
if (t.lat == null || t.lng == null) return null;
if (inView && !inView(t.lat, t.lng)) return null;
return {
type: 'Feature' as const,
properties: {
id: `ct-${t.id}`,
type: 'crowdthreat',
title: t.title,
summary: t.summary || '',
category: t.category,
category_colour: t.category_colour,
subcategory: t.subcategory,
threat_type: t.threat_type,
address: t.address,
city: t.city,
country: t.country || '',
timeago: t.timeago,
occurred: t.occurred,
occurred_iso: t.occurred_iso || '',
verification: t.verification || '',
severity: t.severity || '',
source_url: t.source_url || '',
votes: t.votes || 0,
reporter: t.reporter || '',
iconId: t.icon_id,
name: t.title,
},
geometry: { type: 'Point' as const, coordinates: [t.lng, t.lat] },
};
})
.filter(Boolean) as GeoJSON.Feature[],
};
}
// ─── Wastewater colors by alert level ────────────────────────────────────
const WW_COLORS = {
alert: '#ff3333', // red — elevated pathogen detected
active: '#00e5ff', // cyan — recent data, no alert
stale: '#556677', // gray — plant exists but no recent data
};
export function buildWastewaterGeoJSON(plants?: WastewaterPlant[]): FC {
if (!plants?.length) return null;
return {
type: 'FeatureCollection' as const,
features: plants
.filter((p) => p.lat != null && p.lng != null)
.map((p, i) => {
const hasAlerts = p.alert_count > 0;
const hasData = p.pathogens && p.pathogens.length > 0;
const color = hasAlerts ? WW_COLORS.alert : hasData ? WW_COLORS.active : WW_COLORS.stale;
const icon = hasAlerts ? 'ww-alert' : hasData ? 'ww-clean' : 'ww-stale';
const alertPathogens = (p.pathogens || []).filter((pt) => pt.alert).map((pt) => pt.name);
const allPathogens = (p.pathogens || []).map((pt) => pt.name);
// Build a rich label: name + location + alert info
const loc = [p.city, p.state].filter(Boolean).join(', ');
const labelParts = [p.name || p.site_name || 'Treatment Plant'];
if (loc) labelParts.push(loc);
if (hasAlerts && alertPathogens.length > 0) {
labelParts.push(`${alertPathogens.join(', ')}`);
}
return {
type: 'Feature' as const,
properties: {
id: p.id || `ww-${i}`,
type: 'wastewater',
name: p.name || p.site_name || 'Treatment Plant',
label: labelParts.join('\n'),
site_name: p.site_name,
city: p.city,
state: p.state,
population: p.population,
collection_date: p.collection_date,
pathogen_count: (p.pathogens || []).length,
alert_count: p.alert_count,
alert_pathogens: alertPathogens.join(', '),
detected_pathogens: allPathogens.join(', '),
// Serialize pathogen details for fallback popup rendering
pathogens_json: JSON.stringify(p.pathogens || []),
color,
icon,
},
geometry: {
type: 'Point' as const,
coordinates: [p.lng, p.lat],
},
};
}),
};
}
export function buildUapSightingsGeoJSON(sightings?: UAPSighting[]): FC {
if (!sightings?.length) return null;
return {
type: 'FeatureCollection' as const,
features: sightings
.filter((s) => s.lat != null && s.lng != null)
.map((s, i) => {
// Build a rich label with all available info
const location = [s.city, s.state].filter(Boolean).join(', ') || 'Unknown location';
const dateStr = s.date_time || 'Date unknown';
// Format: "City, ST — Date" for the map label
const label = `${location}\n${dateStr}`;
// Popup-friendly name with count if available
const countMatch = s.summary?.match(/(\d+)\s*sighting/);
const count = countMatch ? parseInt(countMatch[1], 10) : 1;
const name = count > 1
? `${count} sightings — ${location}`
: `UAP Sighting — ${location}`;
return {
type: 'Feature' as const,
properties: {
id: s.id || `uap-${i}`,
type: 'uap_sighting',
shape: s.shape || 'unknown',
shape_raw: s.shape_raw || s.shape || 'Unknown',
city: s.city,
state: s.state,
country: s.country,
date_time: s.date_time,
duration: s.duration,
summary: s.summary,
source: s.source || 'NUFORC',
count,
color: UAP_SHAPE_COLORS[s.shape] || UAP_SHAPE_COLORS.unknown,
name,
label,
},
geometry: {
type: 'Point' as const,
coordinates: [s.lng, s.lat],
},
};
}),
};
}
// ─── SAR (Synthetic Aperture Radar) ────────────────────────────────────────
/** Colors keyed by SAR anomaly `kind`. Matches sar_normalize._kind_to_pin_category
* so the map and pin store agree on semantics. */
const SAR_KIND_COLORS: Record<string, string> = {
ground_deformation: '#f97316', // orange — subsidence, landslides
surface_water_change: '#06b6d4', // cyan — flood/water extent
flood_extent: '#06b6d4',
vegetation_disturbance: '#22c55e', // green — deforestation, burn, blast
damage_assessment: '#ef4444', // red — UNOSAT / EMS damage polygons
coherence_change: '#a855f7', // purple — generic scatter change
};
const SAR_DEFAULT_COLOR = '#eab308';
export function buildSarAnomaliesGeoJSON(anomalies?: SarAnomaly[]): FC {
if (!anomalies?.length) return null;
return {
type: 'FeatureCollection' as const,
features: anomalies
.filter((a) => Number.isFinite(a.lat) && Number.isFinite(a.lon))
.map((a) => ({
type: 'Feature' as const,
properties: {
id: a.anomaly_id,
type: 'sar_anomaly',
kind: a.kind,
name: a.title || `SAR ${a.kind}`,
title: a.title || '',
summary: a.summary || '',
solver: a.solver || '',
source_constellation: a.source_constellation || '',
magnitude: a.magnitude ?? 0,
magnitude_unit: a.magnitude_unit || '',
confidence: a.confidence ?? 0,
first_seen: a.first_seen ?? 0,
last_seen: a.last_seen ?? 0,
aoi_id: a.aoi_id || '',
scene_count: a.scene_count ?? 0,
category: a.category || 'watchlist',
provenance_url: a.provenance_url || '',
evidence_hash: a.evidence_hash || '',
color: SAR_KIND_COLORS[a.kind] || SAR_DEFAULT_COLOR,
},
geometry: {
type: 'Point' as const,
coordinates: [a.lon, a.lat],
},
})),
};
}
/** Draw AOIs as filled circles (approximated with a 64-vertex polygon). These
* mark the operator's watchboxes visible even before any anomalies arrive. */
export function buildSarAoisGeoJSON(aois?: SarAoi[]): FC {
if (!aois?.length) return null;
const features: GeoJSON.Feature[] = [];
for (const aoi of aois) {
if (!Array.isArray(aoi.center) || aoi.center.length < 2) continue;
const [lat, lon] = aoi.center;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Use explicit polygon if provided, else build a 64-point circle.
let ring: number[][];
if (Array.isArray(aoi.polygon) && aoi.polygon.length >= 3) {
ring = aoi.polygon.map((pt) => [pt[1], pt[0]]); // [lat,lon] → [lon,lat]
// Ensure ring is closed
const first = ring[0];
const last = ring[ring.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) ring.push([...first]);
} else {
const radiusKm = Math.max(1, aoi.radius_km || 25);
const steps = 64;
ring = [];
const kmPerDegLat = 111.32;
const kmPerDegLon = 111.32 * Math.cos((lat * Math.PI) / 180);
for (let i = 0; i <= steps; i++) {
const theta = (i / steps) * 2 * Math.PI;
const dLat = (radiusKm * Math.sin(theta)) / kmPerDegLat;
const dLon = (radiusKm * Math.cos(theta)) / Math.max(0.0001, kmPerDegLon);
ring.push([lon + dLon, lat + dLat]);
}
}
features.push({
type: 'Feature' as const,
properties: {
id: aoi.id,
type: 'sar_aoi',
name: aoi.name || aoi.id,
description: aoi.description || '',
category: aoi.category || 'watchlist',
radius_km: aoi.radius_km || 0,
center_lat: lat,
center_lon: lon,
},
geometry: {
type: 'Polygon' as const,
coordinates: [ring],
},
});
}
if (features.length === 0) return null;
return { type: 'FeatureCollection' as const, features };
}
@@ -18,7 +18,16 @@ type BuildRequest = {
payload: DynamicMapLayersBuildPayload;
};
type WorkerRequest = SyncRequest | BuildRequest;
type SyncAndBuildRequest = {
id: string;
action: 'sync_and_build_dynamic_layers';
payload: {
data: DynamicMapLayersDataPayload;
build: DynamicMapLayersBuildPayload;
};
};
type WorkerRequest = SyncRequest | BuildRequest | SyncAndBuildRequest;
type WorkerResponse = {
id: string;
@@ -87,16 +96,26 @@ export function useDynamicMapLayersWorker(
const [syncVersion, setSyncVersion] = useState(0);
const syncVersionRef = useRef(0);
const requestVersionRef = useRef(0);
const hasSyncedRef = useRef(false);
useEffect(() => {
let cancelled = false;
const id = `mapw_sync_${Date.now()}_${reqCounter++}`;
const id = `mapw_sync_build_${Date.now()}_${reqCounter++}`;
const currentSyncVersion = ++syncVersionRef.current;
const requestVersion = ++requestVersionRef.current;
callWorker({ id, action: 'sync_dynamic_layers', payload: dataPayload })
.then(() => {
callWorker({
id,
action: 'sync_and_build_dynamic_layers',
payload: { data: dataPayload, build: buildPayload },
})
.then((next) => {
if (!cancelled) {
hasSyncedRef.current = true;
setSyncVersion(currentSyncVersion);
if (requestVersion === requestVersionRef.current) {
setResult(next);
}
}
})
.catch((error) => {
@@ -111,6 +130,7 @@ export function useDynamicMapLayersWorker(
}, dataDeps);
useEffect(() => {
if (!hasSyncedRef.current) return;
let cancelled = false;
const requestVersion = ++requestVersionRef.current;
const id = `mapw_build_${Date.now()}_${reqCounter++}`;
@@ -45,6 +45,9 @@ const EMPTY_RESULT: StaticMapLayersResult = {
volcanoesGeoJSON: null,
fishingGeoJSON: null,
trainsGeoJSON: null,
uapSightingsGeoJSON: null,
wastewaterGeoJSON: null,
crowdthreatGeoJSON: null,
};
let worker: Worker | null = null;
@@ -512,6 +512,83 @@ export const svgWeatherGeneric = weatherSvg(
`<circle cx="16" cy="28" r="1.8" fill="#f59e0b"/>`,
);
// ─── CrowdThreat Icons ───────────────────────────────────────────────────────
// Filled circle markers with inner symbol, matching CrowdThreat category colours.
function ctSvg(fill: string, inner: string): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">` +
`<circle cx="12" cy="12" r="10" fill="${fill}" stroke="#0a0a0a" stroke-width="1.5"/>` +
`<circle cx="12" cy="12" r="10" fill="none" stroke="${fill}" stroke-width="0.5" stroke-opacity="0.4"/>` +
inner +
`</svg>`
)}`;
}
// Security & Conflict — red, crosshair
export const svgCtSecurity = ctSvg('#ef4444',
`<circle cx="12" cy="12" r="3.5" fill="none" stroke="#fff" stroke-width="1.4"/>` +
`<line x1="12" y1="5" x2="12" y2="8" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>` +
`<line x1="12" y1="16" x2="12" y2="19" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>` +
`<line x1="5" y1="12" x2="8" y2="12" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>` +
`<line x1="16" y1="12" x2="19" y2="12" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>`
);
// Crime & Safety — blue, shield
export const svgCtCrime = ctSvg('#3b82f6',
`<path d="M12 5.5L7.5 7.5V11.5C7.5 14.5 9.5 17 12 18C14.5 17 16.5 14.5 16.5 11.5V7.5L12 5.5Z" fill="none" stroke="#fff" stroke-width="1.3" stroke-linejoin="round"/>`
);
// Aviation — green, plane
export const svgCtAviation = ctSvg('#22c55e',
`<path d="M16.5 13v-1l-4-2.5V6.25c0-.42-.34-.75-.75-.75s-.75.34-.75.75V9.5L7 12v1l4.25-1.25V15L10 16v.75L11.75 16 13.5 16.75V16L12.25 15V11.75L16.5 13z" fill="#fff"/>`
);
// Maritime — teal, anchor
export const svgCtMaritime = ctSvg('#14b8a6',
`<circle cx="12" cy="8" r="1.5" fill="none" stroke="#fff" stroke-width="1.2"/>` +
`<line x1="12" y1="9.5" x2="12" y2="17" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>` +
`<path d="M8 15C8 17.2 9.8 19 12 19C14.2 19 16 17.2 16 15" fill="none" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>` +
`<line x1="10" y1="12" x2="14" y2="12" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>`
);
// Industrial & Infrastructure — orange, bolt
export const svgCtInfrastructure = ctSvg('#f97316',
`<path d="M13 5.5L8 13h4l-.5 5.5L17 11h-4l.5-5.5z" fill="#fff" stroke="none"/>`
);
// Special Threats — purple, warning triangle
export const svgCtSpecial = ctSvg('#a855f7',
`<path d="M12 6L6.5 17h11L12 6z" fill="none" stroke="#fff" stroke-width="1.3" stroke-linejoin="round"/>` +
`<line x1="12" y1="10" x2="12" y2="13.5" stroke="#fff" stroke-width="1.3" stroke-linecap="round"/>` +
`<circle cx="12" cy="15.5" r="0.8" fill="#fff"/>`
);
// Social & Political — pink, people
export const svgCtSocial = ctSvg('#ec4899',
`<circle cx="10" cy="9" r="2" fill="#fff"/>` +
`<circle cx="14.5" cy="9" r="2" fill="#fff"/>` +
`<path d="M6 16.5C6 14 7.8 13 10 13C11 13 11.8 13.3 12.2 13.7" fill="none" stroke="#fff" stroke-width="1.2"/>` +
`<path d="M10.8 13.7C11.2 13.3 12 13 13 13C15.2 13 17 14 17 16.5" fill="none" stroke="#fff" stroke-width="1.2"/>`
);
// Other — gray, question mark
export const svgCtOther = ctSvg('#6b7280',
`<text x="12" y="16" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold" font-family="sans-serif">?</text>`
);
/** All CrowdThreat icon specs for preloading. */
export const CT_ICON_SPECS: { id: string; svg: string }[] = [
{ id: 'ct-security', svg: svgCtSecurity },
{ id: 'ct-crime', svg: svgCtCrime },
{ id: 'ct-aviation', svg: svgCtAviation },
{ id: 'ct-maritime', svg: svgCtMaritime },
{ id: 'ct-infrastructure', svg: svgCtInfrastructure },
{ id: 'ct-special', svg: svgCtSpecial },
{ id: 'ct-social', svg: svgCtSocial },
{ id: 'ct-other', svg: svgCtOther },
];
/** Map event name keywords → weather icon ID */
export function weatherIconId(event: string): string {
const e = event.toLowerCase();
@@ -0,0 +1,102 @@
// UAP (UFO) and Wastewater SVG icon builders for MapLibre symbol layers
/**
* Purple UFO silhouette classic saucer shape with dome and glow.
* 36×36 viewport for a "healthy sized" icon on the map.
*/
export const makeUfoSvg = (): string => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 36 36">
<!-- outer glow ring -->
<ellipse cx="18" cy="22" rx="16" ry="7" fill="none" stroke="#c084fc" stroke-width="1" opacity="0.4"/>
<!-- saucer body -->
<ellipse cx="18" cy="22" rx="14" ry="5.5" fill="#7c3aed" stroke="#a855f7" stroke-width="1"/>
<!-- dome -->
<ellipse cx="18" cy="18" rx="7" ry="6" fill="#8b5cf6" stroke="#c084fc" stroke-width="0.8"/>
<!-- dome highlight -->
<ellipse cx="16" cy="16" rx="3" ry="2.5" fill="#c4b5fd" opacity="0.35"/>
<!-- saucer lights -->
<circle cx="7" cy="22" r="1.2" fill="#e9d5ff" opacity="0.9"/>
<circle cx="13" cy="24" r="1.2" fill="#e9d5ff" opacity="0.9"/>
<circle cx="18" cy="25" r="1.2" fill="#e9d5ff" opacity="0.9"/>
<circle cx="23" cy="24" r="1.2" fill="#e9d5ff" opacity="0.9"/>
<circle cx="29" cy="22" r="1.2" fill="#e9d5ff" opacity="0.9"/>
<!-- bottom beam hint -->
<line x1="15" y1="27" x2="13" y2="33" stroke="#c084fc" stroke-width="0.6" opacity="0.25"/>
<line x1="18" y1="27" x2="18" y2="34" stroke="#c084fc" stroke-width="0.6" opacity="0.3"/>
<line x1="21" y1="27" x2="23" y2="33" stroke="#c084fc" stroke-width="0.6" opacity="0.25"/>
</svg>`;
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
};
/**
* Larger UFO for cluster icons 80×80, bold and unmissable at continental zoom.
*/
export const makeUfoClusterSvg = (): string => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 80 80">
<!-- glow rings -->
<circle cx="40" cy="40" r="38" fill="#7c3aed" opacity="0.2"/>
<circle cx="40" cy="40" r="32" fill="#7c3aed" opacity="0.15"/>
<!-- outer glow ring -->
<ellipse cx="40" cy="46" rx="32" ry="13" fill="none" stroke="#c084fc" stroke-width="1.8" opacity="0.6"/>
<!-- saucer body -->
<ellipse cx="40" cy="46" rx="28" ry="10" fill="#7c3aed" stroke="#a855f7" stroke-width="1.8"/>
<!-- dome -->
<ellipse cx="40" cy="38" rx="14" ry="12" fill="#8b5cf6" stroke="#c084fc" stroke-width="1.2"/>
<!-- dome highlight -->
<ellipse cx="35" cy="34" rx="5" ry="4" fill="#c4b5fd" opacity="0.3"/>
<!-- saucer lights -->
<circle cx="16" cy="46" r="2.2" fill="#e9d5ff" opacity="0.95"/>
<circle cx="26" cy="50" r="2.2" fill="#e9d5ff" opacity="0.95"/>
<circle cx="40" cy="52" r="2.2" fill="#e9d5ff" opacity="0.95"/>
<circle cx="54" cy="50" r="2.2" fill="#e9d5ff" opacity="0.95"/>
<circle cx="64" cy="46" r="2.2" fill="#e9d5ff" opacity="0.95"/>
<!-- bottom beam -->
<line x1="34" y1="56" x2="30" y2="70" stroke="#c084fc" stroke-width="1.2" opacity="0.35"/>
<line x1="40" y1="56" x2="40" y2="72" stroke="#c084fc" stroke-width="1.2" opacity="0.4"/>
<line x1="46" y1="56" x2="50" y2="70" stroke="#c084fc" stroke-width="1.2" opacity="0.35"/>
</svg>`;
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
};
/**
* Water droplet icon for wastewater plants.
* @param fill fill colour (#00e5ff for clean, #ff2222 for alert)
* @param stroke optional stroke override
*/
export const makeWaterDropSvg = (fill: string, stroke?: string): string => {
const s = stroke || fill;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="24" viewBox="0 0 24 34">
<!-- drop body -->
<path d="M12,2 Q12,2 4,16 A10,10 0 0,0 20,16 Q12,2 12,2 Z"
fill="${fill}" stroke="${s}" stroke-width="1.2" stroke-linejoin="round"/>
<!-- inner highlight -->
<path d="M12,5 Q12,5 6,16 A8,8 0 0,0 18,16 Q12,5 12,5 Z"
fill="${fill}" opacity="0.5" stroke="none"/>
<!-- shine -->
<ellipse cx="9" cy="18" rx="2.5" ry="3.5" fill="white" opacity="0.18" transform="rotate(-15,9,18)"/>
</svg>`;
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
};
/**
* Larger water droplet for cluster icons 64×80, bold at continental zoom.
* @param fill fill colour
*/
export const makeWaterDropClusterSvg = (fill: string): string => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="28" viewBox="0 0 64 80">
<!-- glow -->
<ellipse cx="32" cy="46" rx="28" ry="30" fill="${fill}" opacity="0.18"/>
<!-- drop body -->
<path d="M32,6 Q32,6 10,42 A24,24 0 0,0 54,42 Q32,6 32,6 Z"
fill="${fill}" stroke="${fill}" stroke-width="2" stroke-linejoin="round"/>
<!-- inner highlight -->
<path d="M32,14 Q32,14 15,42 A19,19 0 0,0 49,42 Q32,14 32,14 Z"
fill="${fill}" opacity="0.45" stroke="none"/>
<!-- shine -->
<ellipse cx="24" cy="46" rx="6" ry="9" fill="white" opacity="0.18" transform="rotate(-15,24,46)"/>
</svg>`;
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
};
// Keep old exports as aliases for backward compat with geoJSONBuilders icon names
export const makeWastewaterSvg = makeWaterDropSvg;
@@ -16,12 +16,15 @@ export const makeSatSvg = (color: string) => {
export const MISSION_COLORS: Record<string, string> = {
military_recon: '#ff3333',
military_sar: '#ff3333',
military_comms: '#ff6644',
sar: '#00e5ff',
sigint: '#ffffff',
navigation: '#4488ff',
early_warning: '#ff00ff',
commercial_imaging: '#44ff44',
space_station: '#ffdd00',
starlink: '#8899bb',
constellation: '#7799cc',
};
/** Special ISS icon — larger with built-in golden dashed halo ring */
@@ -54,10 +57,13 @@ export const makeTrainSvg = (color: string) => {
export const MISSION_ICON_MAP: Record<string, string> = {
military_recon: 'sat-mil',
military_sar: 'sat-mil',
military_comms: 'sat-mil',
sar: 'sat-sar',
sigint: 'sat-sigint',
navigation: 'sat-nav',
early_warning: 'sat-ew',
commercial_imaging: 'sat-com',
space_station: 'sat-station',
starlink: 'sat-com',
constellation: 'sat-com',
};
@@ -142,22 +142,22 @@ export function SigintSendForm({
return (
<div className="mt-2 pt-1.5 border-t border-[var(--border-primary)]/30">
<div className="text-[8px] text-[#666] tracking-widest mb-1">{label}</div>
<div className="text-[11px] text-[#666] tracking-widest mb-1">{label}</div>
{isMesh && (
<div className="mb-1.5 rounded border border-amber-500/30 bg-amber-950/20 px-2 py-1.5">
<div className="text-[8px] text-amber-300 tracking-widest">
<div className="text-[11px] text-amber-300 tracking-widest">
PUBLIC MESH NOTICE
</div>
<div className="text-[8px] text-amber-200/80 mt-0.5 leading-relaxed">
<div className="text-[11px] text-amber-200/80 mt-0.5 leading-relaxed">
These Meshtastic messages are public/degraded, not private. They may be intercepted,
relayed, logged, or fail to deliver.
</div>
{publicMeshAddress && (
<div className="text-[8px] text-amber-100/70 mt-1 font-mono">
<div className="text-[11px] text-amber-100/70 mt-1 font-mono">
YOUR PUBLIC MESH ADDRESS: {publicMeshAddress.toUpperCase()}
</div>
)}
<label className="mt-1 flex items-start gap-1.5 text-[8px] text-amber-100/80 cursor-pointer">
<label className="mt-1 flex items-start gap-1.5 text-[11px] text-amber-100/80 cursor-pointer">
<input
type="checkbox"
checked={warningAck}
@@ -176,7 +176,7 @@ export function SigintSendForm({
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder={placeholder}
maxLength={200}
className={`flex-1 bg-[#0a0e1a] border border-[var(--border-primary)] rounded px-2 py-1 text-[10px] text-white font-mono placeholder:text-[#444] focus:outline-none ${
className={`flex-1 bg-[#0a0e1a] border border-[var(--border-primary)] rounded px-2 py-1 text-[13px] text-white font-mono placeholder:text-[#444] focus:outline-none ${
isMesh ? 'focus:border-green-500/50' : 'focus:border-cyan-500/50'
}`}
/>
@@ -200,11 +200,11 @@ export function SigintSendForm({
</button>
</div>
{status === 'sent' && (
<div className="text-[8px] text-green-400 mt-0.5">Routed via {detail}</div>
<div className="text-[11px] text-green-400 mt-0.5">Routed via {detail}</div>
)}
{status === 'error' && <div className="text-[8px] text-red-400 mt-0.5">{detail}</div>}
{status === 'error' && <div className="text-[11px] text-red-400 mt-0.5">{detail}</div>}
{status === 'sending' && (
<div className="text-[8px] text-cyan-400 mt-0.5 animate-pulse">Routing...</div>
<div className="text-[11px] text-cyan-400 mt-0.5 animate-pulse">Routing...</div>
)}
</div>
);
@@ -283,21 +283,21 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
const sortedChannels = Object.entries(regionChannels).sort((a, b) => b[1] - a[1]);
if (loading)
return <div className="text-[8px] text-cyan-400/50 animate-pulse mt-1">Loading...</div>;
return <div className="text-[11px] text-cyan-400/50 animate-pulse mt-1">Loading...</div>;
return (
<div className="mt-1.5 pt-1 border-t border-green-500/20">
{/* Channel population — which channels are active in this region */}
{sortedChannels.length > 0 && (
<div className="mb-1.5">
<div className="text-[8px] text-green-400/60 tracking-widest mb-0.5">
<div className="text-[11px] text-green-400/60 tracking-widest mb-0.5">
ACTIVE CHANNELS {region}
</div>
<div className="flex flex-wrap gap-1">
{sortedChannels.map(([ch, count]) => (
<span
key={ch}
className={`font-mono text-[8px] px-1.5 py-0.5 rounded border ${
className={`font-mono text-[11px] px-1.5 py-0.5 rounded border ${
ch === channel
? 'bg-green-900/50 text-green-300 border-green-500/40'
: 'bg-slate-800/50 text-slate-400 border-slate-600/30'
@@ -308,7 +308,7 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
))}
</div>
{(channelStats?.total_nodes ?? 0) > 0 && (
<div className="text-[8px] text-[#555] mt-0.5">
<div className="text-[11px] text-[#555] mt-0.5">
{channelStats?.total_live} live + {channelStats?.total_api?.toLocaleString()} map nodes
globally
</div>
@@ -319,7 +319,7 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
{/* Message feed */}
{messages.length > 0 ? (
<>
<div className="text-[8px] text-green-400/60 tracking-widest mb-1">
<div className="text-[11px] text-green-400/60 tracking-widest mb-1">
MESSAGES {channel} ({region})
</div>
<div className="max-h-[140px] overflow-y-auto space-y-0.5 scrollbar-thin">
@@ -335,7 +335,7 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
return (
<div
key={i}
className={`text-[9px] font-mono py-0.5 px-1 rounded hover:bg-green-950/20 ${
className={`text-[12px] font-mono py-0.5 px-1 rounded hover:bg-green-950/20 ${
directedToYou ? 'bg-amber-950/20 border border-amber-500/20' : ''
}`}
>
@@ -364,7 +364,7 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
</div>
</>
) : (
<div className="text-[8px] text-[#555]">No recent messages on {channel}</div>
<div className="text-[11px] text-[#555]">No recent messages on {channel}</div>
)}
</div>
);
+34
View File
@@ -0,0 +1,34 @@
/**
* AI Intel pin icons teardrop SVG data URIs per category.
*
* These are registered with MapLibre via `map.addImage()` during init and
* referenced from the ai-intel-pin-layer via the `icon-image` layout prop.
*/
import { PIN_CATEGORY_COLORS, type PinCategory } from '@/types/aiIntel';
/** Classic teardrop pin shape with a white dot in the head. */
function buildPinSvg(color: string): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="54" viewBox="0 0 40 54">
<defs>
<filter id="s" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="1" stdDeviation="1.5" flood-color="#000" flood-opacity="0.55"/>
</filter>
</defs>
<path filter="url(#s)" d="M20 2 C10 2 2 10 2 20 C2 32 20 52 20 52 C20 52 38 32 38 20 C38 10 30 2 20 2 Z"
fill="${color}" stroke="#0a0a14" stroke-width="2"/>
<circle cx="20" cy="20" r="6.5" fill="#ffffff" stroke="#0a0a14" stroke-width="1.25"/>
</svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
/** Map image-id used in the layer's icon-image expression. */
export const pinIconId = (category: PinCategory): string => `ai-pin-${category}`;
/** Generate every category's pin icon as a [id, dataURI] pair. */
export function getAllPinIcons(): Array<[string, string]> {
return (Object.keys(PIN_CATEGORY_COLORS) as PinCategory[]).map((cat) => [
pinIconId(cat),
buildPinSvg(PIN_CATEGORY_COLORS[cat]),
]);
}
@@ -18,6 +18,9 @@ import {
buildTrainsGeoJSON,
buildVIIRSChangeNodesGeoJSON,
buildVolcanoesGeoJSON,
buildUapSightingsGeoJSON,
buildWastewaterGeoJSON,
buildCrowdThreatGeoJSON,
} from '@/components/map/geoJSONBuilders';
import type {
AirQualityStation,
@@ -34,9 +37,13 @@ import type {
PowerPlant,
SatNOGSStation,
Scanner,
Ship,
Train,
UAPSighting,
WastewaterPlant,
VIIRSChangeNode,
Volcano,
CrowdThreatItem,
} from '@/types/dashboard';
type BoundsTuple = [number, number, number, number];
@@ -59,7 +66,11 @@ export type StaticMapLayersDataPayload = {
airQuality?: AirQualityStation[];
volcanoes?: Volcano[];
fishingActivity?: FishingEvent[];
ships?: Ship[];
trains?: Train[];
uapSightings?: UAPSighting[];
wastewater?: WastewaterPlant[];
crowdthreat?: CrowdThreatItem[];
};
export type StaticMapLayersBuildPayload = {
@@ -81,6 +92,9 @@ export type StaticMapLayersBuildPayload = {
volcanoes: boolean;
fishing_activity: boolean;
trains: boolean;
uap_sightings: boolean;
wastewater: boolean;
crowdthreat: boolean;
};
};
@@ -102,6 +116,9 @@ export type StaticMapLayersResult = {
volcanoesGeoJSON: FC;
fishingGeoJSON: FC;
trainsGeoJSON: FC;
uapSightingsGeoJSON: FC;
wastewaterGeoJSON: FC;
crowdthreatGeoJSON: FC;
};
type SyncRequest = {
@@ -168,9 +185,12 @@ function buildStaticLayers(payload: StaticMapLayersBuildPayload): StaticMapLayer
airQualityGeoJSON: payload.activeLayers.air_quality ? buildAirQualityGeoJSON(staticData.airQuality) : null,
volcanoesGeoJSON: payload.activeLayers.volcanoes ? buildVolcanoesGeoJSON(staticData.volcanoes) : null,
fishingGeoJSON: payload.activeLayers.fishing_activity
? buildFishingActivityGeoJSON(staticData.fishingActivity)
? buildFishingActivityGeoJSON(staticData.fishingActivity, staticData.ships)
: null,
trainsGeoJSON: payload.activeLayers.trains ? buildTrainsGeoJSON(staticData.trains) : null,
uapSightingsGeoJSON: payload.activeLayers.uap_sightings ? buildUapSightingsGeoJSON(staticData.uapSightings) : null,
wastewaterGeoJSON: payload.activeLayers.wastewater ? buildWastewaterGeoJSON(staticData.wastewater) : null,
crowdthreatGeoJSON: payload.activeLayers.crowdthreat ? buildCrowdThreatGeoJSON(staticData.crowdthreat, inView) : null,
};
}
@@ -0,0 +1,117 @@
'use client';
import React, { useCallback, useEffect, useRef } from 'react';
interface Props {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
/**
* In-app modal confirmation dialog replaces browser `window.confirm()`.
*
* Renders a centered dark-themed overlay with CONFIRM / CANCEL buttons.
* Supports Escape to cancel and Enter to confirm.
*/
const ConfirmDialog: React.FC<Props> = ({
open,
title,
message,
confirmLabel = 'CONFIRM',
cancelLabel = 'CANCEL',
danger = true,
onConfirm,
onCancel,
}) => {
const confirmBtnRef = useRef<HTMLButtonElement>(null);
// Auto-focus the confirm button when the dialog opens
useEffect(() => {
if (open) {
setTimeout(() => confirmBtnRef.current?.focus(), 50);
}
}, [open]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
if (e.key === 'Escape') onCancel();
if (e.key === 'Enter') onConfirm();
},
[onConfirm, onCancel],
);
if (!open) return null;
const accentColor = danger ? '#ef4444' : '#8b5cf6';
return (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ zIndex: 99999, background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(2px)' }}
onClick={onCancel}
onKeyDown={handleKeyDown}
>
<div
className="bg-[#0d0d1a] border-2 font-mono text-white max-w-sm w-full mx-4"
style={{
borderColor: `${accentColor}88`,
boxShadow: `0 20px 60px rgba(0,0,0,0.8), 0 0 0 1px ${accentColor}33`,
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="px-4 py-2.5 border-b text-[11px] uppercase tracking-[0.2em] font-bold"
style={{ borderColor: `${accentColor}44`, background: `${accentColor}15`, color: accentColor }}
>
{title}
</div>
{/* Body */}
<div className="px-4 py-4">
<p className="text-[12px] text-gray-300 leading-relaxed whitespace-pre-wrap">{message}</p>
</div>
{/* Actions */}
<div className="flex gap-2 px-4 pb-4">
<button
ref={confirmBtnRef}
type="button"
onClick={onConfirm}
className="flex-1 py-2 text-[11px] font-mono tracking-wider border transition-colors"
style={{
background: `${accentColor}30`,
borderColor: `${accentColor}66`,
color: danger ? '#fca5a5' : '#c4b5fd',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `${accentColor}50`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = `${accentColor}30`;
}}
>
{confirmLabel}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-[11px] font-mono tracking-wider border border-gray-600/40 text-gray-400 hover:text-white hover:border-gray-500/60 transition-colors"
>
{cancelLabel}
</button>
</div>
</div>
</div>
);
};
export default ConfirmDialog;
@@ -0,0 +1,135 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
interface Props {
open: boolean;
initialCallsign?: string;
mode?: 'consent' | 'edit';
onConfirm: (callsign: string) => void;
onCancel: () => void;
}
const KiwiSdrConsentDialog: React.FC<Props> = ({
open,
initialCallsign = '',
mode = 'consent',
onConfirm,
onCancel,
}) => {
const [callsign, setCallsign] = useState(initialCallsign);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setCallsign(initialCallsign);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open, initialCallsign]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
if (e.key === 'Escape') onCancel();
if (e.key === 'Enter') onConfirm(callsign.trim());
},
[onConfirm, onCancel, callsign],
);
if (!open) return null;
const accent = '#ec4899';
const isEdit = mode === 'edit';
return (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ zIndex: 99999, background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(2px)' }}
onClick={onCancel}
onKeyDown={handleKeyDown}
>
<div
className="bg-[#0d0d1a] border-2 font-mono text-white max-w-md w-full mx-4"
style={{
borderColor: `${accent}88`,
boxShadow: `0 20px 60px rgba(0,0,0,0.8), 0 0 0 1px ${accent}33`,
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="px-4 py-2.5 border-b text-[11px] uppercase tracking-[0.2em] font-bold"
style={{ borderColor: `${accent}44`, background: `${accent}15`, color: accent }}
>
{isEdit ? 'Edit KiwiSDR Callsign' : 'KiwiSDR — First Use'}
</div>
<div className="px-4 py-4 space-y-3">
{!isEdit && (
<div className="text-[12px] text-gray-300 leading-relaxed space-y-2">
<p>
KiwiSDR receivers are <span className="text-pink-300">volunteer-operated</span>{' '}
by amateur radio operators. Each receiver has a limited number of user slots
(usually 48) and shares the operator&apos;s home internet bandwidth.
</p>
<p>
Please be respectful: close the popup when you&apos;re done listening, and
identify yourself with a callsign or handle below so operators know who&apos;s
connecting.
</p>
</div>
)}
<div className="space-y-1.5">
<label className="block text-[11px] uppercase tracking-widest text-pink-400 font-bold">
Your Callsign or Handle
</label>
<input
ref={inputRef}
type="text"
value={callsign}
onChange={(e) => setCallsign(e.target.value)}
placeholder="e.g. KD9ABC or anon-1234 (optional)"
maxLength={32}
className="w-full bg-black/40 border border-pink-500/40 focus:border-pink-400 focus:outline-none px-2.5 py-1.5 text-[13px] text-pink-200 font-mono tracking-wide"
/>
<p className="text-[10px] text-gray-500 leading-snug">
Shown to the SDR operator in their user list. Leave blank to let KiwiSDR prompt
you on first connect.
</p>
</div>
</div>
<div className="flex gap-2 px-4 pb-4">
<button
type="button"
onClick={() => onConfirm(callsign.trim())}
className="flex-1 py-2 text-[11px] font-mono tracking-wider border transition-colors"
style={{
background: `${accent}30`,
borderColor: `${accent}66`,
color: '#fbcfe8',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `${accent}50`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = `${accent}30`;
}}
>
{isEdit ? 'SAVE' : 'CONTINUE'}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-[11px] font-mono tracking-wider border border-gray-600/40 text-gray-400 hover:text-white hover:border-gray-500/60 transition-colors"
>
CANCEL
</button>
</div>
</div>
</div>
);
};
export default KiwiSdrConsentDialog;