feat: v0.4 — satellite imagery, KiwiSDR radio, LOCATE bar & security cleanup

New features:
- NASA GIBS (MODIS Terra) daily satellite imagery with 30-day time slider
- Esri World Imagery high-res satellite layer (sub-meter, zoom 18+)
- KiwiSDR SDR receivers on map with embedded radio tuner
- Sentinel-2 intel card — right-click for recent satellite photo popup
- LOCATE bar — search by coordinates or place name (Nominatim geocoding)
- SATELLITE style preset in bottom bar cycling
- v0.4 changelog modal on first launch

Fixes:
- Satellite imagery renders below data icons (imagery-ceiling anchor)
- Sentinel-2 opens full-res PNG directly (not STAC catalog JSON)
- Light/dark theme: UI stays dark, only map basemap changes

Security:
- Removed test files with hardcoded API keys from tracking
- Removed .git_backup directory from tracking
- Updated .gitignore to exclude test files, dev scripts, cache files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
anoracleofra-code
2026-03-09 17:46:33 -06:00
parent 85748a6fea
commit e89e992293
294 changed files with 1348 additions and 2456 deletions
+36 -11
View File
@@ -1,8 +1,40 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #000000;
--foreground: #ededed;
--bg-primary: #000000;
--bg-secondary: rgb(17, 24, 39);
--bg-tertiary: rgb(31, 41, 55);
--bg-panel: rgba(17, 24, 39, 0.8);
--border-primary: rgb(55, 65, 81);
--border-secondary: rgb(75, 85, 99);
--text-primary: rgb(243, 244, 246);
--text-secondary: rgb(156, 163, 175);
--text-muted: rgb(107, 114, 128);
--text-heading: rgb(236, 254, 255);
--hover-accent: rgba(8, 51, 68, 0.2);
--scrollbar-thumb: rgba(100, 116, 139, 0.3);
--scrollbar-thumb-hover: rgba(100, 116, 139, 0.5);
}
/* Light theme: only the map basemap changes — UI stays dark */
[data-theme="light"] {
--background: #000000;
--foreground: #ededed;
--bg-primary: #000000;
--bg-secondary: rgb(17, 24, 39);
--bg-tertiary: rgb(31, 41, 55);
--bg-panel: rgba(17, 24, 39, 0.8);
--border-primary: rgb(55, 65, 81);
--border-secondary: rgb(75, 85, 99);
--text-primary: rgb(243, 244, 246);
--text-secondary: rgb(156, 163, 175);
--text-muted: rgb(107, 114, 128);
--text-heading: rgb(236, 254, 255);
--hover-accent: rgba(8, 51, 68, 0.2);
--scrollbar-thumb: rgba(100, 116, 139, 0.3);
--scrollbar-thumb-hover: rgba(100, 116, 139, 0.5);
}
@theme inline {
@@ -12,13 +44,6 @@
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
@@ -35,12 +60,12 @@ body {
}
.styled-scrollbar::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.3);
background: var(--scrollbar-thumb);
border-radius: 10px;
}
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.5);
background: var(--scrollbar-thumb-hover);
}
.styled-scrollbar {
+3 -2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/lib/ThemeContext";
import "./globals.css";
const geistSans = Geist({
@@ -29,10 +30,10 @@ export default function RootLayout({
<link href="https://cesium.com/downloads/cesiumjs/releases/1.115/Build/Cesium/Widgets/widgets.css" rel="stylesheet" />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-black`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-[var(--bg-primary)]`}
suppressHydrationWarning
>
{children}
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
+149 -20
View File
@@ -16,10 +16,105 @@ import MapLegend from "@/components/MapLegend";
import ScaleBar from "@/components/ScaleBar";
import ErrorBoundary from "@/components/ErrorBoundary";
import OnboardingModal, { useOnboarding } from "@/components/OnboardingModal";
import ChangelogModal, { useChangelog } from "@/components/ChangelogModal";
// Use dynamic loads for Maplibre to avoid SSR window is not defined errors
const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ssr: false });
/* ── LOCATE BAR ── coordinate / place-name search above bottom status bar ── */
function LocateBar({ onLocate }: { onLocate: (lat: number, lng: number) => void }) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState('');
const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => { if (open) inputRef.current?.focus(); }, [open]);
// Parse raw coordinate input: "31.8, 34.8" or "31.8 34.8" or "-12.3, 45.6"
const parseCoords = (s: string): { lat: number; lng: number } | null => {
const m = s.trim().match(/^([+-]?\d+\.?\d*)[,\s]+([+-]?\d+\.?\d*)$/);
if (!m) return null;
const lat = parseFloat(m[1]), lng = parseFloat(m[2]);
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) return { lat, lng };
return null;
};
const handleSearch = async (q: string) => {
setValue(q);
// Check for raw coordinates first
const coords = parseCoords(q);
if (coords) {
setResults([{ label: `${coords.lat.toFixed(4)}, ${coords.lng.toFixed(4)}`, ...coords }]);
return;
}
// Geocode with Nominatim (debounced)
clearTimeout(timerRef.current);
if (q.trim().length < 2) { setResults([]); return; }
timerRef.current = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`, {
headers: { 'Accept-Language': 'en' },
});
const data = await res.json();
setResults(data.map((r: any) => ({ label: r.display_name, lat: parseFloat(r.lat), lng: parseFloat(r.lon) })));
} catch { setResults([]); }
setLoading(false);
}, 350);
};
const handleSelect = (r: { lat: number; lng: number }) => {
onLocate(r.lat, r.lng);
setOpen(false);
setValue('');
setResults([]);
};
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 bg-[var(--bg-primary)]/60 backdrop-blur-md border border-[var(--border-primary)] rounded-lg px-3 py-1.5 text-[9px] font-mono tracking-[0.15em] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
LOCATE
</button>
);
}
return (
<div className="relative w-[420px]">
<div className="flex items-center gap-2 bg-[var(--bg-primary)]/80 backdrop-blur-md border border-cyan-800/60 rounded-lg px-3 py-2 shadow-[0_0_20px_rgba(0,255,255,0.1)]">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-cyan-500 flex-shrink-0"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
ref={inputRef}
value={value}
onChange={(e) => handleSearch(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Escape') { setOpen(false); setValue(''); setResults([]); } if (e.key === 'Enter' && results.length > 0) handleSelect(results[0]); }}
placeholder="Enter coordinates (31.8, 34.8) or place name..."
className="flex-1 bg-transparent text-[10px] text-[var(--text-primary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
/>
{loading && <div className="w-3 h-3 border border-cyan-500 border-t-transparent rounded-full animate-spin" />}
<button onClick={() => { setOpen(false); setValue(''); setResults([]); }} className="text-[var(--text-muted)] hover:text-[var(--text-primary)]">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
{results.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-[var(--bg-secondary)]/95 backdrop-blur-md border border-[var(--border-primary)] rounded-lg overflow-hidden shadow-[0_-8px_30px_rgba(0,0,0,0.4)] max-h-[200px] overflow-y-auto styled-scrollbar">
{results.map((r, i) => (
<button key={i} onClick={() => handleSelect(r)} className="w-full text-left px-3 py-2 hover:bg-cyan-950/40 transition-colors border-b border-[var(--border-primary)]/50 last:border-0 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-cyan-500 flex-shrink-0"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
<span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{r.label}</span>
</button>
))}
</div>
)}
</div>
);
}
export default function Dashboard() {
const dataRef = useRef<any>({});
const [dataVersion, setDataVersion] = useState(0);
@@ -48,19 +143,33 @@ export default function Dashboard() {
global_incidents: true,
day_night: true,
gps_jamming: true,
gibs_imagery: false,
highres_satellite: false,
kiwisdr: false,
});
// NASA GIBS satellite imagery state
const [gibsDate, setGibsDate] = useState<string>(() => {
const d = new Date();
d.setDate(d.getDate() - 1);
return d.toISOString().slice(0, 10);
});
const [gibsOpacity, setGibsOpacity] = useState(0.6);
const [effects, setEffects] = useState({
bloom: true,
});
const [activeStyle, setActiveStyle] = useState('DEFAULT');
const stylesList = ['DEFAULT', 'FLIR', 'NVG', 'CRT'];
const stylesList = ['DEFAULT', 'SATELLITE', 'FLIR', 'NVG', 'CRT'];
const cycleStyle = () => {
setActiveStyle((prev) => {
const idx = stylesList.indexOf(prev);
return stylesList[(idx + 1) % stylesList.length];
const next = stylesList[(idx + 1) % stylesList.length];
// Auto-toggle High-Res Satellite layer with SATELLITE style
setActiveLayers((l: any) => ({ ...l, highres_satellite: next === 'SATELLITE' }));
return next;
});
};
@@ -79,6 +188,7 @@ export default function Dashboard() {
// Onboarding & connection status
const { showOnboarding, setShowOnboarding } = useOnboarding();
const { showChangelog, setShowChangelog } = useChangelog();
const [backendStatus, setBackendStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const geocodeCache = useRef<Map<string, string>>(new Map());
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -152,11 +262,19 @@ export default function Dashboard() {
setRegionDossierLoading(true);
setRegionDossier(null);
try {
const res = await fetch(`${API_BASE}/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`);
if (res.ok) {
const data = await res.json();
setRegionDossier(data);
const [dossierRes, sentinelRes] = await Promise.allSettled([
fetch(`${API_BASE}/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`),
fetch(`${API_BASE}/api/sentinel2/search?lat=${coords.lat}&lng=${coords.lng}`),
]);
let dossierData: any = {};
if (dossierRes.status === 'fulfilled' && dossierRes.value.ok) {
dossierData = await dossierRes.value.json();
}
let sentinelData = null;
if (sentinelRes.status === 'fulfilled' && sentinelRes.value.ok) {
sentinelData = await sentinelRes.value.json();
}
setRegionDossier({ ...dossierData, sentinel2: sentinelData });
} catch (e) {
console.error("Failed to fetch region dossier", e);
} finally {
@@ -228,7 +346,7 @@ export default function Dashboard() {
}, []);
return (
<main className="fixed inset-0 w-full h-full bg-black overflow-hidden font-sans">
<main className="fixed inset-0 w-full h-full bg-[var(--bg-primary)] overflow-hidden font-sans">
{/* MAPLIBRE WEBGL OVERLAY */}
<ErrorBoundary name="Map">
@@ -240,6 +358,8 @@ export default function Dashboard() {
onEntityClick={setSelectedEntity}
selectedEntity={selectedEntity}
flyToLocation={flyToLocation}
gibsDate={gibsDate}
gibsOpacity={gibsOpacity}
isEavesdropping={isEavesdropping}
onEavesdropClick={setEavesdropLocation}
onCameraMove={setCameraCenter}
@@ -274,10 +394,10 @@ export default function Dashboard() {
</div>
</div>
<div className="flex flex-col">
<h1 className="text-2xl font-bold tracking-[0.4em] text-white flex items-center gap-3" style={{ fontFamily: 'monospace' }}>
<h1 className="text-2xl font-bold tracking-[0.4em] text-[var(--text-primary)] flex items-center gap-3" style={{ fontFamily: 'monospace' }}>
S H A D O W <span className="text-cyan-400">B R O K E R</span>
</h1>
<span className="text-[9px] text-gray-500 font-mono tracking-[0.3em] mt-1 ml-1">GLOBAL THREAT INTERCEPT</span>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-[0.3em] mt-1 ml-1">GLOBAL THREAT INTERCEPT</span>
</div>
</motion.div>
@@ -287,7 +407,7 @@ export default function Dashboard() {
</div>
{/* SYSTEM METRICS TOP RIGHT */}
<div className="absolute top-2 right-6 text-[9px] flex flex-col items-end font-mono tracking-widest text-gray-600 z-[200] pointer-events-none">
<div className="absolute top-2 right-6 text-[9px] flex flex-col items-end font-mono tracking-widest text-[var(--text-muted)] z-[200] pointer-events-none">
<div>RTX</div>
<div>VSR</div>
</div>
@@ -295,7 +415,7 @@ export default function Dashboard() {
{/* LEFT HUD CONTAINER */}
<div className="absolute left-6 top-24 bottom-6 w-80 flex flex-col gap-6 z-[200] pointer-events-none">
{/* LEFT PANEL - DATA LAYERS */}
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} />
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} />
{/* LEFT BOTTOM - DISPLAY CONFIG */}
<WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} />
@@ -335,6 +455,7 @@ export default function Dashboard() {
setIsEavesdropping={setIsEavesdropping}
eavesdropLocation={eavesdropLocation}
cameraCenter={cameraCenter}
selectedEntity={selectedEntity}
/>
</div>
@@ -354,37 +475,40 @@ export default function Dashboard() {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1, duration: 1 }}
className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-auto"
className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-auto flex flex-col items-center gap-2"
>
{/* LOCATE BAR — search by coordinates or place name */}
<LocateBar onLocate={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} />
<div
className="bg-black/60 backdrop-blur-md border border-gray-800 rounded-xl px-6 py-2.5 flex items-center gap-6 shadow-[0_4px_30px_rgba(0,0,0,0.5)] border-b-2 border-b-cyan-900 cursor-pointer"
className="bg-[var(--bg-primary)]/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl px-6 py-2.5 flex items-center gap-6 shadow-[0_4px_30px_rgba(0,0,0,0.2)] border-b-2 border-b-cyan-900 cursor-pointer"
onClick={cycleStyle}
>
{/* Coordinates */}
<div className="flex flex-col items-center min-w-[120px]">
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">COORDINATES</div>
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">COORDINATES</div>
<div className="text-[11px] text-cyan-400 font-mono font-bold tracking-wide">
{mouseCoords ? `${mouseCoords.lat.toFixed(4)}, ${mouseCoords.lng.toFixed(4)}` : '0.0000, 0.0000'}
</div>
</div>
{/* Divider */}
<div className="w-px h-8 bg-gray-700" />
<div className="w-px h-8 bg-[var(--border-primary)]" />
{/* Location name */}
<div className="flex flex-col items-center min-w-[180px] max-w-[320px]">
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">LOCATION</div>
<div className="text-[10px] text-gray-300 font-mono truncate max-w-[320px]">
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">LOCATION</div>
<div className="text-[10px] text-[var(--text-secondary)] font-mono truncate max-w-[320px]">
{locationLabel || 'Hover over map...'}
</div>
</div>
{/* Divider */}
<div className="w-px h-8 bg-gray-700" />
<div className="w-px h-8 bg-[var(--border-primary)]" />
{/* Style preset (compact) */}
<div className="flex flex-col items-center">
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">STYLE</div>
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">STYLE</div>
<div className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div>
</div>
</div>
@@ -396,7 +520,7 @@ export default function Dashboard() {
{!uiVisible && (
<button
onClick={() => setUiVisible(true)}
className="absolute bottom-6 right-6 z-[200] bg-black/60 backdrop-blur-md border border-gray-800 rounded px-4 py-2 text-[10px] font-mono tracking-widest text-cyan-500 hover:text-cyan-300 hover:border-cyan-800 transition-colors pointer-events-auto"
className="absolute bottom-6 right-6 z-[200] bg-[var(--bg-primary)]/60 backdrop-blur-md border border-[var(--border-primary)] rounded px-4 py-2 text-[10px] font-mono tracking-widest text-cyan-500 hover:text-cyan-300 hover:border-cyan-800 transition-colors pointer-events-auto"
>
RESTORE UI
</button>
@@ -441,6 +565,11 @@ export default function Dashboard() {
/>
)}
{/* v0.4 CHANGELOG MODAL — shows once per version after onboarding */}
{!showOnboarding && showChangelog && (
<ChangelogModal onClose={() => setShowChangelog(false)} />
)}
{/* BACKEND DISCONNECTED BANNER */}
{backendStatus === 'disconnected' && (
<div className="absolute top-0 left-0 right-0 z-[9000] flex items-center justify-center py-2 bg-red-950/90 border-b border-red-500/40 backdrop-blur-sm">
+13 -13
View File
@@ -171,16 +171,16 @@ export default function AdvancedFilterModal({
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.2 }}
className={`bg-[#0a0e14]/95 backdrop-blur-xl border ${c.border} rounded-xl shadow-[0_8px_60px_rgba(0,0,0,0.8)] flex flex-col font-mono overflow-hidden`}
className={`bg-[var(--bg-secondary)]/95 backdrop-blur-xl border ${c.border} rounded-xl shadow-[0_8px_60px_rgba(0,0,0,0.3)] flex flex-col font-mono overflow-hidden`}
style={{ maxHeight: '70vh' }}
>
{/* ── Title Bar (Draggable) ── */}
<div
className="flex items-center justify-between px-4 py-3 cursor-grab active:cursor-grabbing border-b border-gray-800/60 select-none flex-shrink-0"
className="flex items-center justify-between px-4 py-3 cursor-grab active:cursor-grabbing border-b border-[var(--border-primary)]/60 select-none flex-shrink-0"
onMouseDown={handleMouseDown}
>
<div className="flex items-center gap-2.5">
<GripHorizontal size={14} className="text-gray-600" />
<GripHorizontal size={14} className="text-[var(--text-muted)]" />
{icon}
<span className={`text-[11px] ${c.text} tracking-[0.25em] font-semibold`}>{title}</span>
{totalSelected > 0 && (
@@ -189,14 +189,14 @@ export default function AdvancedFilterModal({
</span>
)}
</div>
<button onClick={onClose} className="text-gray-600 hover:text-white transition-colors p-1 rounded hover:bg-gray-800">
<button onClick={onClose} className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors p-1 rounded hover:bg-[var(--bg-tertiary)]">
<X size={14} />
</button>
</div>
{/* ── Tab Bar (for multi-field categories) ── */}
{fields.length > 1 && (
<div className="flex border-b border-gray-800/40 px-3 pt-2 gap-1 flex-shrink-0">
<div className="flex border-b border-[var(--border-primary)]/40 px-3 pt-2 gap-1 flex-shrink-0">
{fields.map(field => {
const isActive = activeTab === field.key;
const count = draft[field.key]?.size || 0;
@@ -257,7 +257,7 @@ export default function AdvancedFilterModal({
value={searchTerms[activeTab] || ''}
onChange={(e) => setSearchTerms(prev => ({ ...prev, [activeTab]: e.target.value }))}
placeholder={`Search ${activeField?.label.toLowerCase() || ''}...`}
className={`w-full bg-black/50 border border-gray-700/70 rounded-lg text-[11px] text-gray-300 pl-8 pr-8 py-2 font-mono tracking-wide focus:outline-none focus:${c.border} focus:ring-1 ${c.ring} placeholder-gray-600 transition-all`}
className={`w-full bg-[var(--bg-primary)]/50 border border-[var(--border-primary)]/70 rounded-lg text-[11px] text-[var(--text-secondary)] pl-8 pr-8 py-2 font-mono tracking-wide focus:outline-none focus:${c.border} focus:ring-1 ${c.ring} placeholder-[var(--text-muted)] transition-all`}
autoFocus
/>
{searchTerms[activeTab] && (
@@ -270,10 +270,10 @@ export default function AdvancedFilterModal({
)}
</div>
<div className="flex justify-between mt-1.5">
<span className="text-[8px] text-gray-600 tracking-widest">
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
{filteredOptions.length} AVAILABLE
</span>
<span className="text-[8px] text-gray-600 tracking-widest">
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
{draft[activeTab]?.size || 0} SELECTED
</span>
</div>
@@ -282,7 +282,7 @@ export default function AdvancedFilterModal({
{/* ── Scrollable Checkbox List ── */}
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2 styled-scrollbar" style={{ maxHeight: '35vh' }}>
{filteredOptions.length === 0 ? (
<div className="text-center py-8 text-gray-600 text-[10px] tracking-widest">
<div className="text-center py-8 text-[var(--text-muted)] text-[10px] tracking-widest">
NO MATCHING RESULTS
</div>
) : (
@@ -295,13 +295,13 @@ export default function AdvancedFilterModal({
onClick={() => toggleItem(activeTab, option)}
className={`flex items-center gap-2.5 px-3 py-1.5 rounded-md text-left transition-all group ${isChecked
? `${c.bg} ${c.text}`
: `text-gray-400 hover:bg-gray-800/50 hover:text-gray-200`
: `text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]/50 hover:text-[var(--text-primary)]`
}`}
>
{/* Checkbox */}
<div className={`w-3.5 h-3.5 rounded-[3px] border flex items-center justify-center flex-shrink-0 transition-all ${isChecked
? `${c.border} ${c.bg}`
: 'border-gray-700 group-hover:border-gray-500'
: 'border-[var(--border-primary)] group-hover:border-[var(--border-secondary)]'
}`}>
{isChecked && <Check size={9} strokeWidth={3} />}
</div>
@@ -316,7 +316,7 @@ export default function AdvancedFilterModal({
</div>
{/* ── Footer ── */}
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800/60 flex-shrink-0">
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-primary)]/60 flex-shrink-0">
<button
onClick={clearAll}
className="text-[9px] text-red-400/70 hover:text-red-300 tracking-widest transition-colors"
@@ -326,7 +326,7 @@ export default function AdvancedFilterModal({
<div className="flex gap-2">
<button
onClick={onClose}
className="text-[9px] text-gray-500 hover:text-gray-300 tracking-widest border border-gray-700 rounded-md px-4 py-1.5 hover:bg-gray-800/50 transition-all"
className="text-[9px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] tracking-widest border border-[var(--border-primary)] rounded-md px-4 py-1.5 hover:bg-[var(--bg-tertiary)]/50 transition-all"
>
CANCEL
</button>
+174
View File
@@ -0,0 +1,174 @@
"use client";
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Satellite, Radio, MapPin, Image, Layers, Bug } from "lucide-react";
const CURRENT_VERSION = "0.4";
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
const NEW_FEATURES = [
{
icon: <Satellite size={14} className="text-cyan-400" />,
title: "NASA GIBS Satellite Imagery",
desc: "Daily MODIS Terra true-color imagery with 30-day time slider, play/pause animation, and opacity control.",
color: "cyan",
},
{
icon: <Layers size={14} className="text-green-400" />,
title: "High-Res Satellite (Esri)",
desc: "Sub-meter resolution imagery — zoom into buildings and terrain. Toggle in Data Layers or cycle to SATELLITE style.",
color: "green",
},
{
icon: <Radio size={14} className="text-amber-400" />,
title: "KiwiSDR Radio Receivers",
desc: "500+ public SDR receivers plotted worldwide. Click any node to open a live radio tuner directly in the SIGINT panel.",
color: "amber",
},
{
icon: <Image size={14} className="text-blue-400" />,
title: "Sentinel-2 Intel Card",
desc: "Right-click anywhere — a floating intel card shows the latest Sentinel-2 satellite photo with capture date and cloud cover. Click to open full resolution.",
color: "blue",
},
{
icon: <MapPin size={14} className="text-purple-400" />,
title: "LOCATE Bar",
desc: "New search bar above coordinates — enter coordinates (31.8, 34.8) or place names (Tehran, Strait of Hormuz) to fly directly there.",
color: "purple",
},
{
icon: <Layers size={14} className="text-cyan-400" />,
title: "SATELLITE Style Preset",
desc: "STYLE button now cycles: DEFAULT → SATELLITE → FLIR → NVG → CRT. SATELLITE auto-enables high-res imagery.",
color: "cyan",
},
];
const BUG_FIXES = [
"Satellite imagery renders below all data icons — flights, ships, markers always visible on top",
"Sentinel-2 click now opens the actual high-res PNG image directly in browser",
"Light/dark theme fixed — UI stays dark, only the map basemap switches",
];
export function useChangelog() {
const [show, setShow] = useState(false);
useEffect(() => {
const seen = localStorage.getItem(STORAGE_KEY);
if (!seen) setShow(true);
}, []);
return { showChangelog: show, setShowChangelog: setShow };
}
interface ChangelogModalProps {
onClose: () => void;
}
const ChangelogModal = React.memo(function ChangelogModal({ onClose }: ChangelogModalProps) {
const handleDismiss = () => {
localStorage.setItem(STORAGE_KEY, "true");
onClose();
};
return (
<AnimatePresence>
<motion.div
key="changelog-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[10000]"
onClick={handleDismiss}
/>
<motion.div
key="changelog-modal"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed inset-0 z-[10001] flex items-center justify-center pointer-events-none"
>
<div
className="w-[560px] max-h-[85vh] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 rounded-xl shadow-[0_0_80px_rgba(0,200,255,0.08)] pointer-events-auto flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-5 pb-3 border-b border-[var(--border-primary)]/80">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<div className="px-2 py-1 rounded bg-cyan-500/15 border border-cyan-500/30 text-[10px] font-mono font-bold text-cyan-400 tracking-widest">
v{CURRENT_VERSION}
</div>
<h2 className="text-sm font-bold tracking-[0.15em] text-[var(--text-primary)] font-mono">
WHAT&apos;S NEW
</h2>
</div>
<p className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest mt-1">
SHADOWBROKER INTELLIGENCE PLATFORM UPDATE
</p>
</div>
<button
onClick={handleDismiss}
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
>
<X size={14} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-5 space-y-4">
{/* New Features */}
<div>
<div className="text-[9px] font-mono tracking-[0.2em] text-cyan-400 font-bold mb-3 flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
NEW CAPABILITIES
</div>
<div className="space-y-2">
{NEW_FEATURES.map((f) => (
<div key={f.title} className="flex items-start gap-3 p-3 rounded-lg border border-[var(--border-primary)]/50 bg-[var(--bg-primary)]/30 hover:border-[var(--border-secondary)] transition-colors">
<div className="mt-0.5 flex-shrink-0">{f.icon}</div>
<div>
<div className="text-[10px] font-mono text-[var(--text-primary)] font-bold">{f.title}</div>
<div className="text-[9px] font-mono text-[var(--text-muted)] leading-relaxed mt-0.5">{f.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* Bug Fixes */}
<div>
<div className="text-[9px] font-mono tracking-[0.2em] text-green-400 font-bold mb-3 flex items-center gap-2">
<Bug size={10} className="text-green-400" />
FIXES &amp; IMPROVEMENTS
</div>
<div className="space-y-1.5">
{BUG_FIXES.map((fix, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-1.5">
<span className="text-green-500 text-[10px] mt-0.5 flex-shrink-0">+</span>
<span className="text-[9px] font-mono text-[var(--text-secondary)] leading-relaxed">{fix}</span>
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-[var(--border-primary)]/80 flex items-center justify-center">
<button
onClick={handleDismiss}
className="px-8 py-2.5 rounded-lg bg-cyan-500/15 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/25 text-[10px] font-mono tracking-[0.2em] transition-all"
>
ACKNOWLEDGED
</button>
</div>
</div>
</motion.div>
</AnimatePresence>
);
});
export default ChangelogModal;
+1 -1
View File
@@ -34,7 +34,7 @@ class ErrorBoundary extends Component<Props, State> {
<div className="flex items-center justify-center p-4 bg-red-950/40 border border-red-800 rounded-lg m-2">
<div className="text-center font-mono">
<div className="text-red-400 text-xs tracking-widest mb-1"> SYSTEM ERROR</div>
<div className="text-gray-400 text-[10px]">{this.props.name || "Component"} failed to render</div>
<div className="text-[var(--text-secondary)] text-[10px]">{this.props.name || "Component"} failed to render</div>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="mt-2 px-3 py-1 text-[10px] bg-red-900/60 hover:bg-red-800/60 text-red-300 rounded border border-red-700 transition-colors"
+7 -7
View File
@@ -252,23 +252,23 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
initial={{ y: -30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto flex-shrink-0"
className="w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.2)] pointer-events-auto flex-shrink-0"
>
{/* Header Toggle */}
<div
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
className="flex justify-between items-center p-3 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex items-center gap-2">
<Filter size={12} className="text-cyan-500" />
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DATA FILTERS</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DATA FILTERS</span>
{activeCount > 0 && (
<span className="text-[9px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded-sm">
{activeCount} ACTIVE
</span>
)}
</div>
<button className="text-gray-500 hover:text-white transition-colors">
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <SlidersHorizontal size={14} /> : <ChevronUp size={14} />}
</button>
</div>
@@ -295,20 +295,20 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
return (
<div
key={section.key}
className={`border rounded-lg transition-all cursor-pointer group ${borderColors[section.color] || 'border-gray-800'} hover:bg-black/30`}
className={`border rounded-lg transition-all cursor-pointer group ${borderColors[section.color] || 'border-[var(--border-primary)]'} hover:bg-[var(--bg-primary)]/30`}
onClick={() => setOpenModal(section.key)}
>
<div className="flex items-center justify-between p-2.5 px-3">
<div className="flex items-center gap-2">
{section.icon}
<span className="text-[9px] text-gray-400 tracking-widest group-hover:text-gray-200 transition-colors">{section.title}</span>
<span className="text-[9px] text-[var(--text-secondary)] tracking-widest group-hover:text-[var(--text-primary)] transition-colors">{section.title}</span>
{count > 0 && (
<span className={`text-[8px] ${bgColors[section.color]} ${textColors[section.color]} px-1.5 py-0.5 rounded-sm`}>
{count}
</span>
)}
</div>
<SlidersHorizontal size={10} className="text-gray-600 group-hover:text-gray-400 transition-colors" />
<SlidersHorizontal size={10} className="text-[var(--text-muted)] group-hover:text-[var(--text-secondary)] transition-colors" />
</div>
</div>
);
+13 -13
View File
@@ -171,14 +171,14 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
return (
<div ref={containerRef} className="relative w-full pointer-events-auto">
<div className="flex items-center gap-2 bg-black/40 backdrop-blur-md border border-gray-800 rounded-lg px-3 py-2 focus-within:border-cyan-500/40 transition-colors">
<Search size={12} className="text-gray-500 flex-shrink-0" />
<div className="flex items-center gap-2 bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-lg px-3 py-2 focus-within:border-cyan-500/40 transition-colors">
<Search size={12} className="text-[var(--text-muted)] flex-shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
placeholder="Find aircraft or vessel..."
className="flex-1 bg-transparent text-[10px] text-gray-300 font-mono tracking-wider outline-none placeholder:text-gray-600"
className="flex-1 bg-transparent text-[10px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
@@ -186,11 +186,11 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
onFocus={() => setIsOpen(true)}
/>
{query && (
<button onClick={() => { setQuery(""); setIsOpen(false); }} className="text-gray-600 hover:text-white transition-colors">
<button onClick={() => { setQuery(""); setIsOpen(false); }} className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
<X size={10} />
</button>
)}
<Crosshair size={12} className="text-gray-600 flex-shrink-0" />
<Crosshair size={12} className="text-[var(--text-muted)] flex-shrink-0" />
</div>
<AnimatePresence>
@@ -199,21 +199,21 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="absolute top-full left-0 right-0 mt-1 bg-black/90 backdrop-blur-md border border-gray-800 rounded-lg overflow-hidden z-50 shadow-[0_8px_30px_rgba(0,0,0,0.6)]"
className="absolute top-full left-0 right-0 mt-1 bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-[var(--border-primary)] rounded-lg overflow-hidden z-50 shadow-[0_8px_30px_rgba(0,0,0,0.3)]"
>
<div className="max-h-[300px] overflow-y-auto styled-scrollbar">
{filtered.map((r, idx) => (
<button
key={`${r.id}-${idx}`}
onClick={() => handleSelect(r)}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-cyan-950/30 transition-colors text-left border-b border-gray-800/50 last:border-0 group"
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-[var(--hover-accent)] transition-colors text-left border-b border-[var(--border-primary)]/50 last:border-0 group"
>
<div className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded bg-gray-900 border border-gray-800 group-hover:border-cyan-800">
<div className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded bg-[var(--bg-secondary)] border border-[var(--border-primary)] group-hover:border-cyan-800">
{categoryIcons[r.category]}
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] text-gray-200 font-mono tracking-wide truncate">{r.label}</div>
<div className="text-[8px] text-gray-500 font-mono truncate">{r.sublabel}</div>
<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">{r.sublabel}</div>
</div>
<span className={`text-[7px] font-bold tracking-widest ${r.categoryColor} flex-shrink-0`}>
{r.category}
@@ -221,7 +221,7 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
</button>
))}
</div>
<div className="px-3 py-1.5 border-t border-gray-800 bg-black/50 text-[8px] text-gray-600 font-mono tracking-widest">
<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">
{filtered.length} RESULT{filtered.length !== 1 ? 'S' : ''} CLICK TO LOCATE
</div>
</motion.div>
@@ -231,9 +231,9 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="absolute top-full left-0 right-0 mt-1 bg-black/90 backdrop-blur-md border border-gray-800 rounded-lg z-50 p-4 text-center"
className="absolute top-full left-0 right-0 mt-1 bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-[var(--border-primary)] rounded-lg z-50 p-4 text-center"
>
<div className="text-[9px] text-gray-600 font-mono tracking-widest">NO MATCHING ASSETS</div>
<div className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">NO MATCHING ASSETS</div>
</motion.div>
)}
</AnimatePresence>
+13 -13
View File
@@ -217,10 +217,10 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] max-h-[80vh] bg-gray-950/95 backdrop-blur-xl border border-cyan-900/50 rounded-xl z-[9999] flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.8)]"
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] max-h-[80vh] bg-[var(--bg-secondary)]/95 backdrop-blur-xl border border-cyan-900/50 rounded-xl z-[9999] flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.3)]"
>
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-gray-800/80 flex-shrink-0">
<div className="flex items-center justify-between p-5 border-b border-[var(--border-primary)]/80 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="cyan" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -230,13 +230,13 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
</svg>
</div>
<div>
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">MAP LEGEND</h2>
<span className="text-[9px] text-gray-500 font-mono tracking-widest">ICON REFERENCE KEY</span>
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">MAP LEGEND</h2>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">ICON REFERENCE KEY</span>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg border border-gray-700 hover:border-red-500/50 flex items-center justify-center text-gray-500 hover:text-red-400 transition-all hover:bg-red-950/20"
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
>
<X size={14} />
</button>
@@ -247,16 +247,16 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
{LEGEND.map((cat) => {
const isCollapsed = collapsed.has(cat.name);
return (
<div key={cat.name} className="rounded-lg border border-gray-800/60 overflow-hidden">
<div key={cat.name} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
{/* Category Header */}
<button
onClick={() => toggle(cat.name)}
className="w-full flex items-center justify-between px-3 py-2 bg-gray-900/50 hover:bg-gray-900/80 transition-colors"
className="w-full flex items-center justify-between px-3 py-2 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
>
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${cat.color}`}>
{cat.name}
</span>
{isCollapsed ? <ChevronDown size={12} className="text-gray-500" /> : <ChevronUp size={12} className="text-gray-500" />}
{isCollapsed ? <ChevronDown size={12} className="text-[var(--text-muted)]" /> : <ChevronUp size={12} className="text-[var(--text-muted)]" />}
</button>
{/* Items */}
@@ -267,13 +267,13 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="border-t border-gray-800/40"
className="border-t border-[var(--border-primary)]/40"
>
<div className="grid grid-cols-1 gap-0">
{cat.items.map((item, idx) => (
<div key={idx} className="flex items-center gap-3 px-4 py-1.5 hover:bg-gray-900/30 transition-colors">
<div key={idx} className="flex items-center gap-3 px-4 py-1.5 hover:bg-[var(--bg-secondary)]/30 transition-colors">
<IconImg svg={item.svg} />
<span className="text-[11px] text-gray-300 font-mono">{item.label}</span>
<span className="text-[11px] text-[var(--text-secondary)] font-mono">{item.label}</span>
</div>
))}
</div>
@@ -286,8 +286,8 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
</div>
{/* Footer */}
<div className="p-3 border-t border-gray-800/80 flex-shrink-0">
<div className="text-[9px] text-gray-600 font-mono text-center tracking-wider">
<div className="p-3 border-t border-[var(--border-primary)]/80 flex-shrink-0">
<div className="text-[9px] text-[var(--text-muted)] font-mono text-center tracking-wider">
{LEGEND.reduce((sum, c) => sum + c.items.length, 0)} ICON DEFINITIONS ACROSS {LEGEND.length} CATEGORIES
</div>
</div>
+210 -27
View File
@@ -9,6 +9,7 @@ import ScaleBar from "@/components/ScaleBar";
import maplibregl from "maplibre-gl";
import { AlertTriangle } from "lucide-react";
import WikiImage from "@/components/WikiImage";
import { useTheme } from "@/lib/ThemeContext";
const svgPlaneCyan = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="cyan" stroke="black"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
const svgPlaneYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="yellow" stroke="black"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
@@ -150,13 +151,29 @@ const darkStyle = {
}
},
layers: [
{
id: 'carto-dark-layer',
{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 },
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }
]
};
const lightStyle = {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources: {
'carto-light': {
type: 'raster',
source: 'carto-dark',
minzoom: 0,
maxzoom: 22
tiles: [
"https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
"https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
"https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
"https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png"
],
tileSize: 256
}
},
layers: [
{ id: 'carto-light-layer', type: 'raster', source: 'carto-light', minzoom: 0, maxzoom: 22 },
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }
]
};
@@ -185,8 +202,10 @@ const MISSION_ICON_MAP: Record<string, string> = {
'commercial_imaging': 'sat-com', 'space_station': 'sat-station'
};
const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, selectedEntity, onMouseCoords, onRightClick, regionDossier, regionDossierLoading, onViewStateChange, measureMode, onMeasureClick, measurePoints }: any) => {
const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, selectedEntity, onMouseCoords, onRightClick, regionDossier, regionDossierLoading, onViewStateChange, measureMode, onMeasureClick, measurePoints, gibsDate, gibsOpacity }: any) => {
const mapRef = useRef<MapRef>(null);
const { theme } = useTheme();
const mapThemeStyle = useMemo(() => theme === 'light' ? lightStyle : darkStyle, [theme]);
const [viewState, setViewState] = useState<ViewState>({
longitude: 0,
@@ -369,6 +388,29 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
};
}, [activeLayers.cctv, data?.cctv, inView]);
// KiwiSDR receivers — clustered amber dots
const kiwisdrGeoJSON = useMemo(() => {
if (!activeLayers.kiwisdr || !data?.kiwisdr?.length) return null;
return {
type: 'FeatureCollection' as const,
features: data.kiwisdr.filter((k: any) => k.lat != null && k.lon != null && inView(k.lat, k.lon)).map((k: any, i: number) => ({
type: 'Feature' as const,
properties: {
id: i,
type: 'kiwisdr',
name: k.name || 'Unknown SDR',
url: k.url || '',
users: k.users || 0,
users_max: k.users_max || 0,
bands: k.bands || '',
antenna: k.antenna || '',
location: k.location || '',
},
geometry: { type: 'Point' as const, coordinates: [k.lon, k.lat] }
}))
};
}, [activeLayers.kiwisdr, data?.kiwisdr, inView]);
// Load Images into the Map Style once loaded
const onMapLoad = useCallback((e: any) => {
const map = e.target;
@@ -1102,7 +1144,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
frontlineGeoJSON && 'ukraine-frontline-layer',
earthquakesGeoJSON && 'earthquakes-layer',
satellitesGeoJSON && 'satellites-layer',
cctvGeoJSON && 'cctv-layer'
cctvGeoJSON && 'cctv-layer',
kiwisdrGeoJSON && 'kiwisdr-layer'
].filter(Boolean) as string[];
@@ -1134,7 +1177,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
evt.preventDefault();
onRightClick?.({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
}}
mapStyle={darkStyle as any}
mapStyle={mapThemeStyle as any}
mapLib={maplibregl}
onLoad={onMapLoad}
onIdle={updateBounds}
@@ -1162,6 +1205,50 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
}
}}
>
{/* Esri World Imagery — high-res static satellite (zoom 0-18+) */}
{activeLayers.highres_satellite && (
<Source
id="esri-world-imagery"
type="raster"
tiles={['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}']}
tileSize={256}
maxzoom={18}
attribution="Esri, Maxar, Earthstar Geographics"
>
<Layer
id="esri-world-imagery-layer"
type="raster"
beforeId="imagery-ceiling"
paint={{
'raster-opacity': 1,
'raster-fade-duration': 300
}}
/>
</Source>
)}
{/* NASA GIBS MODIS Terra — daily satellite imagery overlay */}
{activeLayers.gibs_imagery && gibsDate && (
<Source
key={`gibs-${gibsDate}`}
id="gibs-modis"
type="raster"
tiles={[`https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/MODIS_Terra_CorrectedReflectance_TrueColor/default/${gibsDate}/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg`]}
tileSize={256}
maxzoom={9}
>
<Layer
id="gibs-modis-layer"
type="raster"
beforeId="imagery-ceiling"
paint={{
'raster-opacity': gibsOpacity ?? 0.6,
'raster-fade-duration': 0
}}
/>
</Source>
)}
{/* SOLAR TERMINATOR — night overlay */}
{activeLayers.day_night && nightGeoJSON && (
<Source id="night-overlay" type="geojson" data={nightGeoJSON as any}>
@@ -1782,6 +1869,43 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Source>
)}
{/* KiwiSDR Receivers — clustered amber dots */}
{kiwisdrGeoJSON && (
<Source id="kiwisdr" type="geojson" data={kiwisdrGeoJSON as any} cluster={true} clusterRadius={50} clusterMaxZoom={14}>
<Layer
id="kiwisdr-clusters"
type="circle"
filter={['has', 'point_count']}
paint={{
'circle-color': '#f59e0b',
'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 50, 24, 200, 30],
'circle-opacity': 0.8,
'circle-stroke-width': 2,
'circle-stroke-color': '#d97706'
}}
/>
<Layer
id="kiwisdr-cluster-count"
type="symbol"
filter={['has', 'point_count']}
layout={{ 'text-field': '{point_count_abbreviated}', 'text-size': 12, 'text-allow-overlap': true }}
paint={{ 'text-color': '#ffffff', 'text-halo-color': '#000000', 'text-halo-width': 1 }}
/>
<Layer
id="kiwisdr-layer"
type="circle"
filter={['!', ['has', 'point_count']]}
paint={{
'circle-color': '#f59e0b',
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 2, 8, 4, 14, 6],
'circle-opacity': 0.9,
'circle-stroke-width': 1,
'circle-stroke-color': '#d97706'
}}
/>
</Source>
)}
{/* Satellite positions — mission-type icons */}
{satellitesGeoJSON && (
<Source id="satellites" type="geojson" data={satellitesGeoJSON as any}>
@@ -1851,7 +1975,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
Altitude: <span style={{ color: '#44ff88' }}>{sat.alt_km?.toLocaleString()} km</span>
</div>
{sat.wiki && (
<div className="mt-2 border-t border-gray-700/50 pt-2">
<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>
)}
@@ -1903,7 +2027,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</div>
)}
{uav.wiki && (
<div className="mt-2 border-t border-gray-700/50 pt-2">
<div className="mt-2 border-t border-[var(--border-primary)]/50 pt-2">
<WikiImage wikiUrl={uav.wiki} label={uav.callsign} maxH="max-h-28" accent="hover:border-red-500/50" />
</div>
)}
@@ -1922,25 +2046,25 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
anchor="bottom"
offset={15}
>
<div className="bg-black/90 backdrop-blur-md border border-orange-800 rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_rgba(255,140,0,0.4)] pointer-events-auto overflow-hidden w-[300px]">
<div className="bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-orange-800 rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_rgba(255,140,0,0.4)] pointer-events-auto overflow-hidden w-[300px]">
<div className="p-2 border-b border-orange-500/30 bg-orange-950/40 flex justify-between items-center">
<h2 className="text-[10px] tracking-widest font-bold text-orange-400 flex items-center gap-1">
<AlertTriangle size={12} className="text-orange-400" /> NEWS ON THE GROUND
</h2>
<button onClick={() => onEntityClick?.(null)} className="text-gray-400 hover:text-white"></button>
<button onClick={() => onEntityClick?.(null)} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"></button>
</div>
<div className="p-3 flex flex-col gap-2">
<div className="flex justify-between items-center border-b border-gray-800 pb-1">
<span className="text-gray-500 text-[9px]">LOCATION</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1">
<span className="text-[var(--text-muted)] text-[9px]">LOCATION</span>
<span className="text-white text-[10px] font-bold text-right ml-2 break-words max-w-[150px]">{data.gdelt[selectedEntity.id as number].properties?.name || 'UNKNOWN REGION'}</span>
</div>
<div className="flex flex-col gap-1 mt-1">
<span className="text-gray-500 text-[9px]">LATEST REPORTS: ({data.gdelt[selectedEntity.id as number].properties?.count || 1})</span>
<span className="text-[var(--text-muted)] text-[9px]">LATEST REPORTS: ({data.gdelt[selectedEntity.id as number].properties?.count || 1})</span>
<div className="flex flex-col gap-2 max-h-[200px] overflow-y-auto styled-scrollbar mt-1">
{(() => {
const urls: string[] = data.gdelt[selectedEntity.id as number].properties?._urls_list || [];
const headlines: string[] = data.gdelt[selectedEntity.id as number].properties?._headlines_list || [];
if (urls.length === 0) return <span className="text-gray-500 text-[9px]">No articles available.</span>;
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[9px]">No articles available.</span>;
return urls.map((url: string, idx: number) => (
<a
key={idx}
@@ -1948,7 +2072,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-orange-400 text-[9px] underline hover:text-orange-300 block py-1 border-b border-gray-800/50 last:border-0 cursor-pointer"
className="text-orange-400 text-[9px] underline hover:text-orange-300 block py-1 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer"
style={{ pointerEvents: 'all' }}
>
{headlines[idx] || url}
@@ -1976,19 +2100,19 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
anchor="bottom"
offset={15}
>
<div className="bg-black/90 backdrop-blur-md border border-yellow-800 rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_rgba(255,255,0,0.3)] pointer-events-auto overflow-hidden w-[280px]">
<div className="bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-yellow-800 rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_rgba(255,255,0,0.3)] pointer-events-auto overflow-hidden w-[280px]">
<div className="p-2 border-b border-yellow-500/30 bg-yellow-950/40 flex justify-between items-center">
<h2 className="text-[10px] tracking-widest font-bold text-yellow-400 flex items-center gap-1">
<AlertTriangle size={12} className="text-yellow-400" /> REGIONAL TACTICAL EVENT
</h2>
<button onClick={() => onEntityClick?.(null)} className="text-gray-400 hover:text-white"></button>
<button onClick={() => onEntityClick?.(null)} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"></button>
</div>
<div className="p-3 flex flex-col gap-2">
<div className="flex flex-col gap-1 border-b border-gray-800 pb-1">
<div className="flex flex-col gap-1 border-b border-[var(--border-primary)] pb-1">
<span className="text-yellow-400 text-[10px] font-bold leading-tight">{item.title}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-1 mt-1">
<span className="text-gray-500 text-[9px]">TIME</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1 mt-1">
<span className="text-[var(--text-muted)] text-[9px]">TIME</span>
<span className="text-white text-[9px] font-bold">{item.timestamp || 'UNKNOWN'}</span>
</div>
{item.link && (
@@ -2036,22 +2160,22 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
anchor="bottom"
offset={25}
>
<div className={`bg-black/90 backdrop-blur-md border ${borderColor} rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_${shadowColor}] pointer-events-auto overflow-hidden w-[280px]`}>
<div className={`bg-[var(--bg-secondary)]/90 backdrop-blur-md border ${borderColor} rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_${shadowColor}] pointer-events-auto overflow-hidden w-[280px]`}>
<div className={`p-2 border-b ${borderColor}/50 ${bgHeaderColor} flex justify-between items-center`}>
<h2 className={`text-[10px] tracking-widest font-bold ${threatColor} flex items-center gap-1`}>
<AlertTriangle size={12} className={threatColor} /> THREAT INTERCEPT
</h2>
<div className="flex items-center gap-2">
<span className={`text-[10px] ${threatColor} font-mono font-bold animate-pulse`}>LVL: {item.risk_score}/10</span>
<button onClick={() => onEntityClick?.(null)} className="text-gray-400 hover:text-white"></button>
<button onClick={() => onEntityClick?.(null)} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"></button>
</div>
</div>
<div className="p-3 flex flex-col gap-2">
<div className="flex flex-col gap-1 border-b border-gray-800 pb-1">
<div className="flex flex-col gap-1 border-b border-[var(--border-primary)] pb-1">
<span className={`text-[10px] font-bold leading-tight ${threatColor}`}>{item.title}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-1 mt-1">
<span className="text-gray-500 text-[9px]">SOURCE</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1 mt-1">
<span className="text-[var(--text-muted)] text-[9px]">SOURCE</span>
<span className="text-white text-[9px] font-bold text-right ml-2">{item.source || 'UNKNOWN'}</span>
</div>
{item.machine_assessment && (
@@ -2096,6 +2220,65 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Marker>
)}
{/* SENTINEL-2 IMAGERY — floating intel card on map near right-click */}
{selectedEntity?.type === 'region_dossier' && selectedEntity.extra && regionDossier?.sentinel2 && !regionDossierLoading && (
<Popup
longitude={selectedEntity.extra.lng}
latitude={selectedEntity.extra.lat}
anchor="top-left"
offset={[20, -10]}
closeButton={false}
closeOnClick={false}
className="sentinel-popup"
maxWidth="320px"
>
<div className="bg-black/90 backdrop-blur-md border border-blue-500/50 rounded-lg overflow-hidden shadow-[0_0_25px_rgba(59,130,246,0.3)] pointer-events-auto" style={{ width: 300 }}>
{/* Header bar */}
<div className="flex items-center justify-between px-3 py-1.5 bg-blue-950/60 border-b border-blue-500/30">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
<span className="text-[9px] text-blue-400 font-mono tracking-[0.2em] font-bold">SENTINEL-2 IMAGERY</span>
</div>
<span className="text-[8px] text-blue-300/60 font-mono">{selectedEntity.extra.lat.toFixed(3)}, {selectedEntity.extra.lng.toFixed(3)}</span>
</div>
{regionDossier.sentinel2.found ? (
<>
{/* Metadata row */}
<div className="flex items-center justify-between px-3 py-1.5 text-[9px] font-mono border-b border-blue-900/40">
<span className="text-blue-300">{regionDossier.sentinel2.platform}</span>
<span className="text-cyan-400 font-bold">{regionDossier.sentinel2.datetime?.slice(0, 10)}</span>
<span className="text-blue-300">{regionDossier.sentinel2.cloud_cover?.toFixed(0)}% cloud</span>
</div>
{/* Thumbnail */}
{regionDossier.sentinel2.thumbnail_url ? (
<a href={regionDossier.sentinel2.fullres_url || regionDossier.sentinel2.thumbnail_url} target="_blank" rel="noopener noreferrer">
<img
src={regionDossier.sentinel2.thumbnail_url}
alt="Sentinel-2 scene"
className="w-full block hover:brightness-110 transition-all cursor-pointer"
style={{ maxHeight: 220, objectFit: 'cover' }}
/>
</a>
) : (
<div className="px-3 py-4 text-[9px] text-blue-300/50 font-mono text-center">Scene found no preview available</div>
)}
{/* Footer */}
<div className="px-3 py-1 bg-blue-950/40 text-[7px] text-blue-400/50 font-mono tracking-widest text-center">
CLICK IMAGE TO OPEN FULL RESOLUTION
</div>
</>
) : (
<div className="px-3 py-4 text-[9px] text-blue-300/50 font-mono text-center">
No clear imagery in last 30 days
</div>
)}
</div>
</Popup>
)}
{/* MEASUREMENT LINES */}
{measurePoints && measurePoints.length >= 2 && (
<Source id="measure-lines" type="geojson" data={{
+7 -7
View File
@@ -15,15 +15,15 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
initial={{ y: -50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto flex-shrink-0"
className="w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.2)] pointer-events-auto flex-shrink-0"
>
{/* Header Toggle */}
<div
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
className="flex justify-between items-center p-3 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
onClick={() => setIsMinimized(!isMinimized)}
>
<span className="text-[10px] text-gray-500 font-mono tracking-widest">GLOBAL MARKETS</span>
<button className="text-gray-500 hover:text-white transition-colors">
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
@@ -36,7 +36,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
exit={{ height: 0, opacity: 0 }}
className="overflow-y-auto styled-scrollbar flex flex-col gap-4 p-4 pt-3 max-h-[400px]"
>
<div className="border-b border-gray-800 pb-3">
<div className="border-b border-[var(--border-primary)] pb-3">
<h2 className="text-xs font-bold tracking-widest text-cyan-400 flex items-center gap-2 mb-2">
<TrendingUp className="text-cyan-500" size={14} /> DEFENSE SEC TICKERS
</h2>
@@ -45,7 +45,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
<div key={ticker} className="flex items-center justify-between border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm relative group overflow-hidden">
<span className="font-bold text-cyan-300 z-10 text-[10px]">[{ticker}]</span>
<div className="flex items-center gap-3 text-right z-10">
<span className="text-gray-200 font-bold text-xs">${info.price.toFixed(2)}</span>
<span className="text-[var(--text-primary)] font-bold text-xs">${info.price.toFixed(2)}</span>
<span className={`flex items-center gap-0.5 w-12 justify-end text-[9px] ${info.up ? 'text-cyan-400' : 'text-red-400'}`}>
{info.up ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
{Math.abs(info.change_percent).toFixed(2)}%
@@ -65,7 +65,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
<div key={name} className="flex flex-col border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm justify-between">
<span className="font-bold text-cyan-500 text-[9px] uppercase mb-0.5">{name}</span>
<div className="flex items-center justify-between">
<span className="text-gray-200 font-bold text-[11px]">${info.price.toFixed(2)}</span>
<span className="text-[var(--text-primary)] font-bold text-[11px]">${info.price.toFixed(2)}</span>
<span className={`flex items-center gap-0.5 text-[9px] ${info.up ? 'text-cyan-400' : 'text-red-400'}`}>
{info.up ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
{Math.abs(info.change_percent).toFixed(2)}%
+160 -158
View File
@@ -199,7 +199,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
>
<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-gray-500">
<span className="text-[8px] text-[var(--text-muted)]">
{selectedEntity.extra ? `${selectedEntity.extra.lat.toFixed(3)}, ${selectedEntity.extra.lng.toFixed(3)}` : ''}
</span>
</div>
@@ -211,41 +211,43 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<div className="p-3 flex flex-col gap-1.5 max-h-[500px] overflow-y-auto styled-scrollbar text-[10px]">
{/* 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="flex justify-between"><span className="text-gray-500">COUNTRY</span><span className="text-white font-bold">{d.country?.name}</span></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-gray-500">OFFICIAL</span><span className="text-gray-300 text-right max-w-[180px]">{d.country.official_name}</span></div>
<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>
)}
<div className="flex justify-between"><span className="text-gray-500">LEADER</span><span className="text-emerald-400 font-bold">{d.country?.leader}</span></div>
<div className="flex justify-between"><span className="text-gray-500">GOVERNMENT</span><span className="text-white font-bold text-right max-w-[180px]">{d.country?.government_type}</span></div>
<div className="flex justify-between"><span className="text-gray-500">POPULATION</span><span className="text-white font-bold">{d.country?.population?.toLocaleString()}</span></div>
<div className="flex justify-between"><span className="text-gray-500">CAPITAL</span><span className="text-white font-bold">{d.country?.capital}</span></div>
<div className="flex justify-between"><span className="text-gray-500">LANGUAGES</span><span className="text-white text-right max-w-[180px]">{d.country?.languages?.join(', ')}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">LEADER</span><span className="text-emerald-400 font-bold">{d.country?.leader}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">GOVERNMENT</span><span className="text-[var(--text-primary)] font-bold text-right max-w-[180px]">{d.country?.government_type}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">POPULATION</span><span className="text-[var(--text-primary)] font-bold">{d.country?.population?.toLocaleString()}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">CAPITAL</span><span className="text-[var(--text-primary)] font-bold">{d.country?.capital}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">LANGUAGES</span><span className="text-[var(--text-primary)] text-right max-w-[180px]">{d.country?.languages?.join(', ')}</span></div>
{d.country?.currencies?.length > 0 && (
<div className="flex justify-between"><span className="text-gray-500">CURRENCY</span><span className="text-white text-right max-w-[180px]">{d.country.currencies.join(', ')}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">CURRENCY</span><span className="text-[var(--text-primary)] text-right max-w-[180px]">{d.country.currencies.join(', ')}</span></div>
)}
<div className="flex justify-between"><span className="text-gray-500">REGION</span><span className="text-white">{d.country?.subregion || d.country?.region}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">REGION</span><span className="text-[var(--text-primary)]">{d.country?.subregion || d.country?.region}</span></div>
{d.country?.area_km2 > 0 && (
<div className="flex justify-between"><span className="text-gray-500">AREA</span><span className="text-white">{d.country.area_km2.toLocaleString()} km²</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">AREA</span><span className="text-[var(--text-primary)]">{d.country.area_km2.toLocaleString()} km²</span></div>
)}
{/* 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>
{d.local.name && <div className="flex justify-between"><span className="text-gray-500">LOCALITY</span><span className="text-white font-bold">{d.local.name}</span></div>}
{d.local.state && <div className="flex justify-between"><span className="text-gray-500">STATE/PROVINCE</span><span className="text-white font-bold">{d.local.state}</span></div>}
{d.local.description && <div className="flex justify-between"><span className="text-gray-500">TYPE</span><span className="text-gray-300">{d.local.description}</span></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 rounded text-[9px] text-gray-300 leading-relaxed">
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 rounded text-[9px] 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>
)}
</>
)}
{/* Sentinel-2 imagery now shown as map popup — see MaplibreViewer */}
</div>
) : d?.error ? (
<div className="p-4 text-gray-400 text-[10px]">{d.error}</div>
<div className="p-4 text-[var(--text-secondary)] text-[10px]">{d.error}</div>
) : (
<div className="p-4 text-red-400 text-[10px]">INTEL UNAVAILABLE</div>
)}
@@ -263,34 +265,34 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
};
const alertBorderMap: Record<string, string> = {
'pink': 'border-pink-500/30', 'red': 'border-red-500/30',
'darkblue': 'border-blue-500/30', 'white': 'border-gray-500/30'
'darkblue': 'border-blue-500/30', 'white': 'border-[var(--border-primary)]/30'
};
const alertBgMap: Record<string, string> = {
'pink': 'bg-pink-950/40', 'red': 'bg-red-950/40',
'darkblue': 'bg-blue-950/40', 'white': 'bg-gray-900/40'
'darkblue': 'bg-blue-950/40', 'white': 'bg-[var(--bg-panel)]'
};
const ac = flight.alert_color || 'white';
const headerColor = alertColorMap[ac] || 'text-white';
const borderColor = alertBorderMap[ac] || 'border-gray-500/30';
const bgColor = alertBgMap[ac] || 'bg-gray-900/40';
const borderColor = alertBorderMap[ac] || 'border-[var(--border-primary)]/30';
const bgColor = alertBgMap[ac] || 'bg-[var(--bg-panel)]';
return (
<motion.div
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4 }}
className={`w-full bg-black/60 backdrop-blur-md border ${ac === 'pink' ? 'border-pink-800' : ac === 'red' ? 'border-red-800' : ac === 'darkblue' ? 'border-blue-800' : 'border-gray-600'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,20,147,0.2)] pointer-events-auto overflow-hidden flex-shrink-0`}
className={`w-full bg-black/60 backdrop-blur-md border ${ac === 'pink' ? 'border-pink-800' : ac === 'red' ? 'border-red-800' : ac === 'darkblue' ? 'border-blue-800' : 'border-[var(--border-secondary)]'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,20,147,0.2)] pointer-events-auto overflow-hidden flex-shrink-0`}
>
<div className={`p-3 border-b ${borderColor} ${bgColor} flex justify-between items-center`}>
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
TRACKED AIRCRAFT {flight.alert_category || "ALERT"}
</h2>
<span className="text-[10px] text-gray-500 font-mono">TRK: {callsign}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">TRK: {callsign}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">OPERATOR</span>
<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>
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? (
<a
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
@@ -307,7 +309,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
</div>
{/* Owner/Operator Wikipedia photo */}
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (
<div className="border-b border-gray-800 pb-2">
<div className="border-b border-[var(--border-primary)] pb-2">
<WikiImage
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
label={flight.alert_operator}
@@ -318,12 +320,12 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
)}
{/* Aircraft model Wikipedia photo */}
{aircraftImgUrl && (
<div className="border-b border-gray-800 pb-2">
<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 rounded border border-gray-700/50 ${ac === 'pink' ? 'hover:border-pink-500/50' : 'hover:border-cyan-500/50'} transition-colors`}
className={`w-full h-auto max-h-28 object-cover rounded border border-[var(--border-primary)]/50 ${ac === 'pink' ? 'hover:border-pink-500/50' : 'hover:border-cyan-500/50'} transition-colors`}
/>
</a>
{aircraftWikiUrl && (
@@ -334,65 +336,65 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
)}
</div>
)}
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">CATEGORY</span>
<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-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">AIRCRAFT</span>
<span className="text-white text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
<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>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">REGISTRATION</span>
<span className="text-white text-xs font-bold">{flight.registration || "N/A"}</span>
<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>
</div>
{flight.alert_tag1 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">INTEL TAG</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">INTEL TAG</span>
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_tag1}</span>
</div>
)}
{flight.alert_tag2 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">SECONDARY</span>
<span className="text-white text-xs font-bold">{flight.alert_tag2}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SECONDARY</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_tag2}</span>
</div>
)}
{flight.alert_tag3 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">DETAIL</span>
<span className="text-gray-400 text-xs">{flight.alert_tag3}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">DETAIL</span>
<span className="text-[var(--text-secondary)] text-xs">{flight.alert_tag3}</span>
</div>
)}
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">ALTITUDE</span>
<span className="text-white text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">ALTITUDE</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">GROUND SPEED</span>
<span className="text-white text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">GROUND SPEED</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">HEADING</span>
<span className="text-white text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">HEADING</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
</div>
{flight.squawk && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">SQUAWK</span>
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-white'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SQUAWK</span>
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
</div>
)}
{flight.alert_link && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">REFERENCE</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">REFERENCE</span>
<a href={flight.alert_link} target="_blank" rel="noreferrer" className={`text-xs font-bold underline ${headerColor} hover:opacity-80`}>
View Intel Source
</a>
</div>
)}
{flight.icao24 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">FLIGHT RECORD</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">FLIGHT RECORD</span>
<a href={`https://adsb.lol/?icao=${flight.icao24}`} target="_blank" rel="noreferrer" className={`${headerColor} hover:opacity-80 text-xs font-bold underline`}>
View History Log
</a>
@@ -451,34 +453,34 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className={`text-xs tracking-widest font-bold ${selectedEntity.type === 'military_flight' ? 'text-red-400' : selectedEntity.type === 'private_flight' ? 'text-orange-400' : selectedEntity.type === 'private_jet' ? 'text-purple-400' : 'text-cyan-400'} flex items-center gap-2`}>
{selectedEntity.type === 'military_flight' ? "MILITARY BOGEY INTERCEPT" : selectedEntity.type === 'private_flight' ? "PRIVATE TRANSPONDER" : selectedEntity.type === 'private_jet' ? "PRIVATE JET TRANSPONDER" : "COMMERCIAL TRANSPONDER"}
</h2>
<span className="text-[10px] text-gray-500 font-mono">TRK: {callsign}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">TRK: {callsign}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">OPERATOR</span>
<span className="text-white text-xs font-bold">{airline}</span>
<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>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">REGISTRATION</span>
<span className="text-white text-xs font-bold">{flight.registration || "N/A"}</span>
<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>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">AIRCRAFT MODEL</span>
<span className="text-white text-xs font-bold">{flight.model || "UNKNOWN"}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<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) && (
<div className="border-b border-gray-800 pb-3">
<div className="border-b border-[var(--border-primary)] pb-3">
{aircraftImgLoading && (
<div className="w-full h-24 rounded bg-gray-800/60 animate-pulse" />
<div className="w-full h-24 rounded bg-[var(--bg-tertiary)]/60 animate-pulse" />
)}
{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 rounded border border-gray-700/50 hover:border-cyan-500/50 transition-colors"
className="w-full h-auto max-h-32 object-cover rounded border border-[var(--border-primary)]/50 hover:border-cyan-500/50 transition-colors"
style={{ imageRendering: 'auto' }}
/>
</a>
@@ -491,31 +493,31 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
)}
</div>
)}
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">ALTITUDE</span>
<span className="text-white text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">ALTITUDE</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">GROUND SPEED</span>
<span className="text-white text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">GROUND SPEED</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">HEADING</span>
<span className="text-white text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">HEADING</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
</div>
{flight.squawk && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">SQUAWK</span>
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-white'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SQUAWK</span>
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
</div>
)}
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">ROUTE</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">ROUTE</span>
<span className="text-cyan-400 text-xs font-bold">{flight.origin_name !== "UNKNOWN" ? `[${flight.origin_name}] → [${flight.dest_name}]` : "UNKNOWN"}</span>
</div>
{flight.icao24 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">FLIGHT RECORD</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">FLIGHT RECORD</span>
<a href={`https://adsb.lol/?icao=${flight.icao24}`} target="_blank" rel="noreferrer" className="text-cyan-400 hover:text-cyan-300 text-xs font-bold underline">
View History Log
</a>
@@ -548,7 +550,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
'military_vessel': 'text-yellow-400',
'carrier': 'text-orange-400',
};
const headerColor = headerColorMap[ship.type] || 'text-gray-400';
const headerColor = headerColorMap[ship.type] || 'text-[var(--text-secondary)]';
const headerTitleMap: Record<string, string> = {
'tanker': 'AIS TANKER INTERCEPT',
@@ -571,49 +573,49 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
{headerTitle}
</h2>
<span className="text-[10px] text-gray-500 font-mono">MMSI: {ship.mmsi || 'N/A'}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">MMSI: {ship.mmsi || 'N/A'}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">VESSEL NAME</span>
<span className="text-white text-xs font-bold text-right ml-4">{ship.name || 'UNKNOWN'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">VESSEL NAME</span>
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{ship.name || 'UNKNOWN'}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">VESSEL TYPE</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">VESSEL TYPE</span>
<span className={`text-xs font-bold ${headerColor}`}>{typeLabel}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">FLAG STATE</span>
<span className="text-white text-xs font-bold">{ship.country || 'UNKNOWN'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">FLAG STATE</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.country || 'UNKNOWN'}</span>
</div>
{ship.callsign && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">CALLSIGN</span>
<span className="text-white text-xs font-bold">{ship.callsign}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">CALLSIGN</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.callsign}</span>
</div>
)}
{ship.imo > 0 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">IMO NUMBER</span>
<span className="text-white text-xs font-bold">{ship.imo}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">IMO NUMBER</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.imo}</span>
</div>
)}
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">DESTINATION</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">DESTINATION</span>
<span className={`text-xs font-bold ${ship.destination && ship.destination !== 'UNKNOWN' ? 'text-cyan-400' : 'text-orange-400'}`}>{ship.destination || 'UNKNOWN'}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">SPEED (SOG)</span>
<span className="text-white text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${ship.sog || 0} kts`}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SPEED (SOG)</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${ship.sog || 0} kts`}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">COURSE (COG)</span>
<span className="text-white text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${Math.round(ship.cog || 0)}°`}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">COURSE (COG)</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${Math.round(ship.cog || 0)}°`}</span>
</div>
{ship.mmsi && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">VESSEL RECORD</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">VESSEL RECORD</span>
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`} target="_blank" rel="noreferrer" className="text-cyan-400 hover:text-cyan-300 text-xs font-bold underline">
View on MarineTraffic
</a>
@@ -621,7 +623,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
)}
{/* Ship/Carrier Wikipedia photo */}
{(ship.wiki || VESSEL_TYPE_WIKI[ship.type]) && (
<div className="border-t border-gray-800 pt-2">
<div className="border-t border-[var(--border-primary)] pt-2">
<WikiImage
wikiUrl={ship.wiki || VESSEL_TYPE_WIKI[ship.type]}
label={ship.type === 'carrier' ? ship.name : typeLabel}
@@ -651,22 +653,22 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className="text-xs tracking-widest font-bold text-orange-400 flex items-center gap-2">
<AlertTriangle size={14} className="text-orange-400" /> MILITARY INCIDENT CLUSTER
</h2>
<span className="text-[10px] text-gray-500 font-mono">ID: {selectedEntity.id}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">ID: {selectedEntity.id}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">LOCATION</span>
<span className="text-white text-xs font-bold text-right ml-4">{props.name || 'UNKNOWN REGION'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">LOCATION</span>
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{props.name || 'UNKNOWN REGION'}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">ARTICLE COUNT</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">ARTICLE COUNT</span>
<span className="text-orange-400 text-xs font-bold">{props.count || 1}</span>
</div>
<div className="flex flex-col gap-2 mt-2">
<span className="text-gray-500 text-[10px]">LATEST REPORTS:</span>
<span className="text-[var(--text-muted)] text-[10px]">LATEST REPORTS:</span>
<div
className="text-white text-xs whitespace-normal [&_a]:text-orange-400 [&_a]:underline hover:[&_a]:text-orange-300 [&_br]:mb-2"
className="text-[var(--text-primary)] text-xs whitespace-normal [&_a]:text-orange-400 [&_a]:underline hover:[&_a]:text-orange-300 [&_br]:mb-2"
dangerouslySetInnerHTML={{ __html: props.html || 'No articles available.' }}
/>
</div>
@@ -690,25 +692,25 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className="text-xs tracking-widest font-bold text-yellow-400 flex items-center gap-2">
<AlertTriangle size={14} className="text-yellow-400" /> REGIONAL TACTICAL EVENT
</h2>
<span className="text-[10px] text-gray-500 font-mono">ID: {item.id}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">ID: {item.id}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">REGION</span>
<span className="text-white text-xs font-bold text-right ml-4">{item.region || 'UNKNOWN'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">REGION</span>
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{item.region || 'UNKNOWN'}</span>
</div>
<div className="flex flex-col gap-2 border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">DESCRIPTION</span>
<div className="flex flex-col gap-2 border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">DESCRIPTION</span>
<span className="text-yellow-400 text-xs font-bold leading-tight">{item.title}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2 mt-2">
<span className="text-gray-500 text-[10px]">REPORTED TIME</span>
<span className="text-white text-xs font-bold">{item.timestamp || 'UNKNOWN'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2 mt-2">
<span className="text-[var(--text-muted)] text-[10px]">REPORTED TIME</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{item.timestamp || 'UNKNOWN'}</span>
</div>
{item.link && (
<div className="flex justify-between items-center pb-2 mt-2">
<span className="text-gray-500 text-[10px]">SOURCE</span>
<span className="text-[var(--text-muted)] text-[10px]">SOURCE</span>
<a href={item.link} target="_blank" rel="noreferrer" className="text-yellow-400 hover:text-yellow-300 text-xs font-bold underline">
View Liveuamap Report
</a>
@@ -734,16 +736,16 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className="text-xs tracking-widest font-bold text-red-400 flex items-center gap-2">
<AlertTriangle size={14} className="text-red-400" /> THREAT INTERCEPT
</h2>
<span className="text-[10px] text-gray-500 font-mono">LVL: {item.risk_score}/10</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">LVL: {item.risk_score}/10</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">SOURCE</span>
<span className="text-white text-xs font-bold text-right ml-4">{item.source || 'UNKNOWN'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SOURCE</span>
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{item.source || 'UNKNOWN'}</span>
</div>
<div className="flex flex-col gap-2 border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">HEADLINE</span>
<div className="flex flex-col gap-2 border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">HEADLINE</span>
<span className="text-red-400 text-xs font-bold leading-tight">{item.title}</span>
</div>
{item.machine_assessment && (
@@ -755,7 +757,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
)}
{item.link && (
<div className="flex justify-between items-center pb-2 mt-2">
<span className="text-gray-500 text-[10px]">REFERENCE</span>
<span className="text-[var(--text-muted)] text-[10px]">REFERENCE</span>
<a href={item.link} target="_blank" rel="noreferrer" className="text-red-400 hover:text-red-300 text-xs font-bold underline">
View Source Article
</a>
@@ -781,20 +783,20 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
AERONAUTICAL HUB
</h2>
<span className="text-[10px] text-gray-500 font-mono">IATA: {apt.iata}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">IATA: {apt.iata}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">FACILITY NAME</span>
<span className="text-white text-[10px] font-bold text-right ml-4 break-words">{apt.name}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">FACILITY NAME</span>
<span className="text-[var(--text-primary)] text-[10px] font-bold text-right ml-4 break-words">{apt.name}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">COORDINATES</span>
<span className="text-white text-xs font-bold">{apt.lat.toFixed(4)}, {apt.lng.toFixed(4)}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">COORDINATES</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{apt.lat.toFixed(4)}, {apt.lng.toFixed(4)}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">STATUS</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">STATUS</span>
<span className="text-green-400 animate-pulse text-xs font-bold">OPERATIONAL</span>
</div>
</div>
@@ -817,7 +819,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
? new Date(selectedEntity.extra.last_updated + 'Z').toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short' }).toUpperCase() + ' — OPTIC INTERCEPT'
: 'OPTIC INTERCEPT'}
</h2>
<span className="text-[10px] text-gray-500 font-mono">ID: {selectedEntity.id}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">ID: {selectedEntity.id}</span>
</div>
<div className="relative w-full h-48 bg-black flex items-center justify-center p-1">
{(() => {
@@ -901,7 +903,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className={`w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
className={`w-full bg-[var(--bg-panel)] backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
>
<div
className="p-3 border-b border-cyan-500/20 bg-cyan-950/20 relative overflow-hidden cursor-pointer hover:bg-cyan-900/30 transition-colors"
@@ -911,7 +913,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<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-white transition-colors">
<button className="text-cyan-500 hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
@@ -969,14 +971,14 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
transition={{ delay: 0.1 + (idx * 0.05) }}
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-gray-400 uppercase tracking-widest">
<div className="flex items-center justify-between text-[8px] text-[var(--text-secondary)] uppercase tracking-widest">
<span className="font-bold flex items-center gap-1 text-cyan-600">
&gt;_ {item.source}
</span>
<span>[{item.published ? formatTime(item.published) : ''}]</span>
</div>
<a href={item.link} target="_blank" rel="noreferrer" className={`text-[11px] ${titleClass} hover:text-white transition-colors leading-tight`}>
<a href={item.link} target="_blank" rel="noreferrer" className={`text-[11px] ${titleClass} hover:text-[var(--text-primary)] transition-colors leading-tight`}>
{item.title}
</a>
@@ -994,12 +996,12 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
</span>
<div className="flex items-center gap-2">
{item.cluster_count > 1 && (
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-cyan-950/50 hover:text-white hover:bg-cyan-900 border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-cyan-950/50 hover:text-[var(--text-primary)] hover:bg-cyan-900 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-gray-500 font-mono tracking-tighter">
<span className="text-[8px] text-[var(--text-muted)] font-mono tracking-tighter">
{item.coords[0].toFixed(2)}, {item.coords[1].toFixed(2)}
</span>
)}
@@ -1016,7 +1018,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
>
{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] text-gray-500 uppercase font-bold">
<div className="flex items-center justify-between text-[7.5px] text-[var(--text-muted)] uppercase font-bold">
<span>&gt;_ {subItem.source}</span>
<span className={
subItem.risk_score >= 9 ? 'text-red-400' :
@@ -1025,7 +1027,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
'text-green-400'
}>LVL: {subItem.risk_score}/10</span>
</div>
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[10px] text-gray-400 hover:text-white transition-colors leading-tight">
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors leading-tight">
{subItem.title}
</a>
</div>
+19 -19
View File
@@ -89,24 +89,24 @@ const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSet
className="fixed inset-0 z-[10001] flex items-center justify-center pointer-events-none"
>
<div
className="w-[580px] max-h-[85vh] bg-gray-950/98 border border-cyan-900/50 rounded-xl shadow-[0_0_80px_rgba(0,200,255,0.08)] pointer-events-auto flex flex-col overflow-hidden"
className="w-[580px] max-h-[85vh] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 rounded-xl shadow-[0_0_80px_rgba(0,200,255,0.08)] pointer-events-auto flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-6 pb-4 border-b border-gray-800/80">
<div className="p-6 pb-4 border-b border-[var(--border-primary)]/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
<Shield size={20} className="text-cyan-400" />
</div>
<div>
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">MISSION BRIEFING</h2>
<span className="text-[9px] text-gray-500 font-mono tracking-widest">FIRST-TIME SETUP</span>
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">MISSION BRIEFING</h2>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">FIRST-TIME SETUP</span>
</div>
</div>
<button
onClick={handleDismiss}
className="w-8 h-8 rounded-lg border border-gray-700 hover:border-red-500/50 flex items-center justify-center text-gray-500 hover:text-red-400 transition-all hover:bg-red-950/20"
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
>
<X size={14} />
</button>
@@ -122,7 +122,7 @@ const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSet
className={`flex-1 py-1.5 text-[9px] font-mono tracking-widest rounded border transition-all ${
step === i
? "border-cyan-500/50 text-cyan-400 bg-cyan-950/20"
: "border-gray-800 text-gray-600 hover:border-gray-700 hover:text-gray-400"
: "border-[var(--border-primary)] text-[var(--text-muted)] hover:border-[var(--border-secondary)] hover:text-[var(--text-secondary)]"
}`}
>
{label.toUpperCase()}
@@ -135,10 +135,10 @@ const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSet
{step === 0 && (
<div className="space-y-4">
<div className="text-center py-4">
<div className="text-lg font-bold tracking-[0.3em] text-white font-mono mb-2">
<div className="text-lg font-bold tracking-[0.3em] text-[var(--text-primary)] font-mono mb-2">
S H A D O W <span className="text-cyan-400">B R O K E R</span>
</div>
<p className="text-[11px] text-gray-400 font-mono leading-relaxed max-w-md mx-auto">
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed max-w-md mx-auto">
Real-time OSINT dashboard aggregating 12+ live intelligence sources.
Flights, ships, satellites, earthquakes, conflicts, and more all on one map.
</p>
@@ -149,7 +149,7 @@ const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSet
<Key size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<div>
<p className="text-[11px] text-yellow-400 font-mono font-bold mb-1">API Keys Required</p>
<p className="text-[10px] text-gray-400 font-mono leading-relaxed">
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
Two API keys are needed for full functionality: <span className="text-cyan-400">OpenSky Network</span> (flights) and <span className="text-blue-400">AIS Stream</span> (ships).
Both are free. Without them, some panels will show no data.
</p>
@@ -162,7 +162,7 @@ const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSet
<Globe size={14} className="text-green-500 mt-0.5 flex-shrink-0" />
<div>
<p className="text-[11px] text-green-400 font-mono font-bold mb-1">8 Sources Work Immediately</p>
<p className="text-[10px] text-gray-400 font-mono leading-relaxed">
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
Military aircraft, satellites, earthquakes, global conflicts, weather radar, radio scanners, news, and market data all work out of the box no keys needed.
</p>
</div>
@@ -190,7 +190,7 @@ const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSet
GET KEY <ExternalLink size={10} />
</a>
</div>
<p className="text-[10px] text-gray-400 font-mono mb-3">{api.description}</p>
<p className="text-[10px] text-[var(--text-secondary)] font-mono mb-3">{api.description}</p>
<ol className="space-y-1.5">
{api.steps.map((s, i) => (
<li key={i} className="flex items-start gap-2">
@@ -214,17 +214,17 @@ const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSet
{step === 2 && (
<div className="space-y-3">
<p className="text-[10px] text-gray-400 font-mono mb-3">
<p className="text-[10px] text-[var(--text-secondary)] font-mono mb-3">
These data sources are completely free and require no API keys. They activate automatically on launch.
</p>
<div className="grid grid-cols-2 gap-2">
{FREE_SOURCES.map((src) => (
<div key={src.name} className="rounded-lg border border-gray-800/60 bg-gray-900/30 p-3 hover:border-gray-700 transition-colors">
<div key={src.name} className="rounded-lg border border-[var(--border-primary)]/60 bg-[var(--bg-secondary)]/30 p-3 hover:border-[var(--border-secondary)] transition-colors">
<div className="flex items-center gap-2 mb-1">
<span className="text-green-500">{src.icon}</span>
<span className="text-[10px] font-mono text-white font-medium">{src.name}</span>
<span className="text-[10px] font-mono text-[var(--text-primary)] font-medium">{src.name}</span>
</div>
<p className="text-[9px] text-gray-500 font-mono">{src.desc}</p>
<p className="text-[9px] text-[var(--text-muted)] font-mono">{src.desc}</p>
</div>
))}
</div>
@@ -233,13 +233,13 @@ const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSet
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-800/80 flex items-center justify-between">
<div className="p-4 border-t border-[var(--border-primary)]/80 flex items-center justify-between">
<button
onClick={() => setStep(Math.max(0, step - 1))}
className={`px-4 py-2 rounded border text-[10px] font-mono tracking-widest transition-all ${
step === 0
? "border-gray-800 text-gray-700 cursor-not-allowed"
: "border-gray-700 text-gray-400 hover:text-white hover:border-gray-600"
? "border-[var(--border-primary)] text-[var(--text-muted)] cursor-not-allowed"
: "border-[var(--border-primary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)]"
}`}
disabled={step === 0}
>
@@ -248,7 +248,7 @@ const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSet
<div className="flex gap-1.5">
{[0, 1, 2].map((i) => (
<div key={i} className={`w-1.5 h-1.5 rounded-full transition-colors ${step === i ? "bg-cyan-400" : "bg-gray-700"}`} />
<div key={i} className={`w-1.5 h-1.5 rounded-full transition-colors ${step === i ? "bg-cyan-400" : "bg-[var(--border-primary)]"}`} />
))}
</div>
@@ -5,7 +5,7 @@ import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { RadioReceiver, Activity, Play, Square, FastForward, ChevronDown, ChevronUp } from 'lucide-react';
export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesdropping, eavesdropLocation, cameraCenter }: { data: any, isEavesdropping?: boolean, setIsEavesdropping?: (val: boolean) => void, eavesdropLocation?: { lat: number, lng: number } | null, cameraCenter?: { lat: number, lng: number } | null }) {
export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesdropping, eavesdropLocation, cameraCenter, selectedEntity }: { data: any, isEavesdropping?: boolean, setIsEavesdropping?: (val: boolean) => void, eavesdropLocation?: { lat: number, lng: number } | null, cameraCenter?: { lat: number, lng: number } | null, selectedEntity?: { type: string, id: string | number, extra?: any } | null }) {
const [isMinimized, setIsMinimized] = useState(true);
const [feeds, setFeeds] = useState<any[]>([]);
const [activeFeed, setActiveFeed] = useState<any | null>(null);
@@ -249,7 +249,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 1, delay: 0.2 }}
className="w-full flex flex-col bg-black/40 backdrop-blur-md border border-cyan-900/50 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.5)] relative overflow-hidden max-h-full"
className="w-full flex flex-col bg-[var(--bg-primary)]/40 backdrop-blur-md border border-cyan-900/50 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] relative overflow-hidden max-h-full"
>
<div
className="flex items-center justify-between p-3 border-b border-cyan-900/50 cursor-pointer bg-cyan-950/20 hover:bg-cyan-900/30 transition-colors"
@@ -274,13 +274,13 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
className="flex flex-col overflow-hidden"
>
{/* Audio Player Controls */}
<div className="p-4 border-b border-cyan-900/40 bg-black/60">
<div className="p-4 border-b border-cyan-900/40 bg-[var(--bg-primary)]/60">
<div className="flex items-center justify-between mb-3">
<div className="flex flex-col">
<span className="text-xs text-cyan-300 font-mono tracking-wide">
{activeFeed ? activeFeed.name : "NO SIGNAL"}
</span>
<span className="text-[9px] text-gray-500 font-mono">
<span className="text-[9px] text-[var(--text-muted)] font-mono">
{activeFeed ? `LOCATION: ${activeFeed.location.toUpperCase()}` : "AWAITING TUNING..."}
</span>
</div>
@@ -347,6 +347,36 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
</div>
</div>
{/* KiwiSDR Tuner — appears when a KiwiSDR node is clicked on the map */}
{selectedEntity?.type === 'kiwisdr' && selectedEntity.extra?.url && (
<div className="p-3 border-b border-amber-900/40 bg-amber-950/10">
<div className="text-[9px] text-amber-400 font-mono tracking-widest mb-2 flex items-center gap-2">
<RadioReceiver size={10} />
SDR TUNER: {(selectedEntity.extra.name || 'REMOTE RECEIVER').toUpperCase().slice(0, 60)}
</div>
<div className="text-[8px] text-[var(--text-muted)] font-mono mb-2">
{selectedEntity.extra.location && <span>{selectedEntity.extra.location} · </span>}
{selectedEntity.extra.antenna && <span>{selectedEntity.extra.antenna.slice(0, 80)} · </span>}
{selectedEntity.extra.users !== undefined && <span>{selectedEntity.extra.users}/{selectedEntity.extra.users_max} users</span>}
</div>
<iframe
src={selectedEntity.extra.url}
className="w-full h-72 rounded border border-amber-900/50 bg-black"
allow="microphone"
sandbox="allow-scripts allow-same-origin"
title="KiwiSDR Tuner"
/>
<a
href={selectedEntity.extra.url}
target="_blank"
rel="noopener noreferrer"
className="text-[8px] text-amber-500 hover:text-amber-300 font-mono mt-1 inline-block"
>
OPEN IN NEW TAB
</a>
</div>
)}
{/* Feed List */}
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
{feeds.length === 0 ? (
@@ -359,10 +389,10 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
className={`p-2 mb-1 rounded cursor-pointer border-l-2 ${activeFeed?.id === feed.id ? 'bg-cyan-900/30 border-cyan-400' : 'border-transparent hover:bg-white/5'} flex justify-between items-center transition-colors`}
>
<div className="flex flex-col overflow-hidden pr-2">
<span className={`text-[11px] font-mono truncate ${activeFeed?.id === feed.id ? 'text-cyan-300' : 'text-gray-300'}`}>
<span className={`text-[11px] font-mono truncate ${activeFeed?.id === feed.id ? 'text-cyan-300' : 'text-[var(--text-secondary)]'}`}>
{feed.name}
</span>
<span className="text-[9px] text-gray-500 font-mono truncate">
<span className="text-[9px] text-[var(--text-muted)] font-mono truncate">
{feed.location} | {feed.category}
</span>
</div>
@@ -371,7 +401,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
<Activity size={10} />
{feed.listeners.toLocaleString()}
</span>
<span className="text-[8px] text-gray-600 font-mono mt-0.5">LSTN</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">LSTN</span>
</div>
</div>
))
+4 -4
View File
@@ -136,7 +136,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
{/* 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-gray-700 hover:border-cyan-500/50 text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20 uppercase"
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"
title={`Switch to ${unit === "mi" ? "Metric (km)" : "Imperial (mi)"}`}
>
{unit === "mi" ? "MI" : "KM"}
@@ -147,7 +147,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
onClick={onToggleMeasure}
className={`flex items-center gap-1 text-[8px] 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-gray-700 text-gray-500 hover:text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-950/20"
: "border-[var(--border-primary)] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-950/20"
}`}
title={measureMode ? "Exit measurement mode" : "Measure distance (click up to 3 points)"}
>
@@ -159,7 +159,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
{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-gray-700 text-gray-500 hover:text-red-400 hover:border-red-500/50 hover:bg-red-950/20 transition-all"
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"
title="Clear all waypoints"
>
<Trash2 size={10} />
@@ -172,7 +172,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
{segmentDistances.map((d, i) => (
<span key={i} className={`text-[9px] font-mono tracking-wider px-1.5 py-0.5 rounded border ${d.startsWith("Σ")
? "border-cyan-500/50 text-cyan-300 bg-cyan-950/30"
: "border-gray-700 text-gray-400"
: "border-[var(--border-primary)] text-[var(--text-secondary)]"
}`}>
{d}
</span>
+20 -20
View File
@@ -114,22 +114,22 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -300 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed left-0 top-0 bottom-0 w-[480px] bg-gray-950/95 backdrop-blur-xl border-r border-cyan-900/50 z-[9999] flex flex-col shadow-[4px_0_40px_rgba(0,0,0,0.8)]"
className="fixed left-0 top-0 bottom-0 w-[480px] bg-[var(--bg-secondary)]/95 backdrop-blur-xl border-r border-cyan-900/50 z-[9999] flex flex-col shadow-[4px_0_40px_rgba(0,0,0,0.3)]"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-800/80">
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]/80">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
<Settings size={16} className="text-cyan-400" />
</div>
<div>
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">SYSTEM CONFIG</h2>
<span className="text-[9px] text-gray-500 font-mono tracking-widest">API KEY REGISTRY</span>
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">API KEY REGISTRY</span>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg border border-gray-700 hover:border-red-500/50 flex items-center justify-center text-gray-500 hover:text-red-400 transition-all hover:bg-red-950/20"
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
>
<X size={14} />
</button>
@@ -139,7 +139,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
<div className="flex items-start gap-2">
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
<p className="text-[10px] text-gray-400 font-mono leading-relaxed">
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
</p>
</div>
@@ -152,21 +152,21 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
const isExpanded = expandedCategories.has(category);
return (
<div key={category} className="rounded-lg border border-gray-800/60 overflow-hidden">
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
{/* Category Header */}
<button
onClick={() => toggleCategory(category)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-gray-900/50 hover:bg-gray-900/80 transition-colors"
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
>
<div className="flex items-center gap-2">
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
{category.toUpperCase()}
</span>
<span className="text-[10px] text-gray-500 font-mono">
<span className="text-[10px] text-[var(--text-muted)] font-mono">
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
</span>
</div>
{isExpanded ? <ChevronUp size={12} className="text-gray-500" /> : <ChevronDown size={12} className="text-gray-500" />}
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
</button>
{/* APIs in Category */}
@@ -179,12 +179,12 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
transition={{ duration: 0.2 }}
>
{categoryApis.map((api) => (
<div key={api.id} className="border-t border-gray-800/40 px-4 py-3 hover:bg-gray-900/30 transition-colors">
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
{/* API Name + Status */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
{api.required && <Key size={10} className="text-yellow-500" />}
<span className="text-xs font-mono text-white font-medium">{api.name}</span>
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
</div>
<div className="flex items-center gap-1.5">
{api.has_key ? (
@@ -198,7 +198,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</span>
)
) : (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-gray-700 text-gray-500">
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">
PUBLIC
</span>
)}
@@ -207,7 +207,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
href={api.url}
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-cyan-400 transition-colors"
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={10} />
@@ -217,7 +217,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</div>
{/* Description */}
<p className="text-[10px] text-gray-500 font-mono leading-relaxed mb-2">
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">
{api.description}
</p>
@@ -245,7 +245,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</button>
<button
onClick={() => setEditingId(null)}
className="px-2 py-1.5 rounded border border-gray-700 text-gray-500 hover:text-white hover:border-gray-600 transition-colors text-[10px] font-mono"
className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono"
>
ESC
</button>
@@ -254,10 +254,10 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
/* Display Mode */
<div className="flex items-center gap-1.5">
<div
className="flex-1 bg-black/40 border border-gray-800 rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-gray-700 transition-colors select-none"
className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none"
onClick={() => startEditing(api)}
>
<span className="text-gray-500 tracking-wider">
<span className="text-[var(--text-muted)] tracking-wider">
{api.is_set ? api.value_obfuscated : "Click to set key..."}
</span>
</div>
@@ -276,8 +276,8 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-800/80">
<div className="flex items-center justify-between text-[9px] text-gray-600 font-mono">
<div className="p-4 border-t border-[var(--border-primary)]/80">
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
<span>{apis.length} REGISTERED APIs</span>
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
</div>
+2 -2
View File
@@ -49,14 +49,14 @@ export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent =
return (
<div className="pb-2">
{loading && (
<div className={`w-full h-20 rounded bg-gray-800/60 animate-pulse`} />
<div className={`w-full h-20 rounded bg-[var(--bg-tertiary)]/60 animate-pulse`} />
)}
{imgUrl && (
<a href={wikiUrl} target="_blank" rel="noopener noreferrer" className="block">
<img
src={imgUrl}
alt={label || title.replace(/_/g, ' ')}
className={`w-full h-auto ${maxH} object-cover rounded border border-gray-700/50 ${accent} transition-colors`}
className={`w-full h-auto ${maxH} object-cover rounded border border-[var(--border-primary)]/50 ${accent} transition-colors`}
/>
</a>
)}
+118 -33
View File
@@ -1,11 +1,39 @@
"use client";
import React, { useState } from "react";
import React, { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, BookOpen, Radio } from "lucide-react";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe } from "lucide-react";
import { useTheme } from "@/lib/ThemeContext";
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void }) {
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void }) {
const [isMinimized, setIsMinimized] = useState(false);
const { theme, toggleTheme } = useTheme();
const [gibsPlaying, setGibsPlaying] = useState(false);
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// GIBS time slider play/pause animation
useEffect(() => {
if (!gibsPlaying || !setGibsDate) {
if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current);
gibsIntervalRef.current = null;
return;
}
gibsIntervalRef.current = setInterval(() => {
if (!gibsDate) return;
const d = new Date(gibsDate + 'T00:00:00');
d.setDate(d.getDate() + 1);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
if (d > yesterday) {
const start = new Date();
start.setDate(start.getDate() - 30);
setGibsDate(start.toISOString().slice(0, 10));
} else {
setGibsDate(d.toISOString().slice(0, 10));
}
}, 1500);
return () => { if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current); };
}, [gibsPlaying, gibsDate, setGibsDate]);
// Compute ship category counts
const importantShipCount = data?.ships?.filter((s: any) => ['carrier', 'military_vessel', 'tanker', 'cargo'].includes(s.type))?.length || 0;
@@ -27,6 +55,9 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{ id: "global_incidents", name: "Global Incidents", source: "GDELT", count: data?.gdelt?.length || 0, icon: Activity },
{ id: "cctv", name: "CCTV Mesh", source: "CCTV Mesh + Street View", count: data?.cctv?.length || 0, icon: Cctv },
{ id: "gps_jamming", name: "GPS Jamming", source: "ADS-B NACp", count: data?.gps_jamming?.length || 0, icon: Radio },
{ id: "gibs_imagery", name: "MODIS Terra (Daily)", source: "NASA GIBS", count: null, icon: Globe },
{ id: "highres_satellite", name: "High-Res Satellite", source: "Esri World Imagery", count: null, icon: Satellite },
{ id: "kiwisdr", name: "KiwiSDR Receivers", source: "KiwiSDR.com", count: data?.kiwisdr?.length || 0, icon: Radio },
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
];
@@ -41,14 +72,21 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
>
{/* Header */}
<div className="mb-6 pointer-events-auto">
<div className="text-[10px] text-gray-400 font-mono tracking-widest mb-1">TOP SECRET // SI-TK // NOFORN</div>
<div className="text-[10px] text-gray-500 font-mono tracking-widest mb-4">KH11-4094 OPS-4168</div>
<div className="text-[10px] text-[var(--text-secondary)] font-mono tracking-widest mb-1">TOP SECRET // SI-TK // NOFORN</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-cyan-50">FLIR</h1>
<h1 className="text-2xl font-bold tracking-[0.2em] text-[var(--text-heading)]">FLIR</h1>
<button
onClick={toggleTheme}
className="w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)]"
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
</button>
{onSettingsClick && (
<button
onClick={onSettingsClick}
className="w-7 h-7 rounded-lg border border-gray-700 hover:border-cyan-500/50 flex items-center justify-center text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20 group"
className="w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)] group"
title="System Settings"
>
<Settings size={14} className="group-hover:rotate-90 transition-transform duration-300" />
@@ -57,7 +95,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{onLegendClick && (
<button
onClick={onLegendClick}
className="h-7 px-2 rounded-lg border border-gray-700 hover:border-cyan-500/50 flex items-center justify-center gap-1 text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20"
className="h-7 px-2 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center gap-1 text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)]"
title="Map Legend / Icon Key"
>
<BookOpen size={12} />
@@ -68,15 +106,15 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
</div>
{/* Data Layers Box */}
<div className="bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.5)] flex flex-col relative overflow-hidden max-h-full">
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] flex flex-col relative overflow-hidden max-h-full">
{/* Header / Toggle */}
<div
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
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"
onClick={() => setIsMinimized(!isMinimized)}
>
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DATA LAYERS</span>
<button className="text-gray-500 hover:text-white transition-colors">
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DATA LAYERS</span>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
@@ -95,31 +133,78 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
const active = activeLayers[layer.id as keyof typeof activeLayers] || false;
return (
<div
key={idx}
className="flex items-start justify-between group cursor-pointer"
onClick={() => setActiveLayers((prev: any) => ({ ...prev, [layer.id]: !active }))}
>
<div className="flex gap-3">
<div className={`mt-1 ${active ? 'text-cyan-400' : 'text-gray-600 group-hover:text-gray-400'} transition-colors`}>
{(['ships_important', 'ships_civilian', 'ships_passenger'].includes(layer.id)) ? shipIcon : <Icon size={16} strokeWidth={1.5} />}
<div key={idx} className="flex flex-col">
<div
className="flex items-start justify-between group cursor-pointer"
onClick={() => setActiveLayers((prev: any) => ({ ...prev, [layer.id]: !active }))}
>
<div className="flex gap-3">
<div className={`mt-1 ${active ? 'text-cyan-400' : 'text-gray-600 group-hover:text-gray-400'} transition-colors`}>
{(['ships_important', 'ships_civilian', 'ships_passenger'].includes(layer.id)) ? shipIcon : <Icon size={16} strokeWidth={1.5} />}
</div>
<div className="flex flex-col">
<span className={`text-sm font-medium ${active ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'} tracking-wide`}>{layer.name}</span>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? 'LIVE' : 'OFF'}</span>
</div>
</div>
<div className="flex flex-col">
<span className={`text-sm font-medium ${active ? 'text-white' : 'text-gray-400'} tracking-wide`}>{layer.name}</span>
<span className="text-[9px] text-gray-600 font-mono tracking-wider mt-0.5">{layer.source} · {active ? 'LIVE' : 'OFF'}</span>
<div className="flex items-center gap-3">
{active && layer.count > 0 && (
<span className="text-[10px] text-gray-300 font-mono">{layer.count.toLocaleString()}</span>
)}
<div className={`text-[9px] font-mono tracking-wider px-2 py-0.5 rounded-full border ${active
? 'border-cyan-500/50 text-cyan-400 bg-cyan-950/30 shadow-[0_0_10px_rgba(34,211,238,0.2)]'
: 'border-[var(--border-primary)] text-[var(--text-muted)] bg-transparent'
}`}>
{active ? 'ON' : 'OFF'}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{active && layer.count > 0 && (
<span className="text-[10px] text-gray-300 font-mono">{layer.count.toLocaleString()}</span>
)}
<div className={`text-[9px] font-mono tracking-wider px-2 py-0.5 rounded-full border ${active
? 'border-cyan-500/50 text-cyan-400 bg-cyan-950/30 shadow-[0_0_10px_rgba(34,211,238,0.2)]'
: 'border-gray-800 text-gray-600 bg-transparent'
}`}>
{active ? 'ON' : 'OFF'}
{/* GIBS Imagery inline controls: time slider + play/pause + opacity */}
{active && layer.id === 'gibs_imagery' && gibsDate && setGibsDate && setGibsOpacity && (
<div className="ml-7 mt-2 flex flex-col gap-2" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-2">
<button
onClick={() => setGibsPlaying(p => !p)}
className="w-5 h-5 flex items-center justify-center rounded border border-cyan-500/30 text-cyan-400 hover:bg-cyan-950/30 transition-colors"
>
{gibsPlaying ? <Pause size={10} /> : <Play size={10} />}
</button>
<input
type="range"
min={0}
max={29}
value={(() => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const selected = new Date(gibsDate + 'T00:00:00');
const diff = Math.round((yesterday.getTime() - selected.getTime()) / 86400000);
return 29 - Math.max(0, Math.min(29, diff));
})()}
onChange={e => {
const daysAgo = 29 - parseInt(e.target.value);
const d = new Date();
d.setDate(d.getDate() - 1 - daysAgo);
setGibsDate(d.toISOString().slice(0, 10));
}}
className="flex-1 h-1 accent-cyan-500 cursor-pointer"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[8px] text-cyan-400 font-mono">{gibsDate}</span>
<div className="flex items-center gap-1">
<span className="text-[8px] text-[var(--text-muted)] font-mono">OPC</span>
<input
type="range"
min={0}
max={100}
value={Math.round((gibsOpacity ?? 0.6) * 100)}
onChange={e => setGibsOpacity(parseInt(e.target.value) / 100)}
className="w-16 h-1 accent-cyan-500 cursor-pointer"
/>
</div>
</div>
</div>
</div>
)}
</div>
)
})}
+17 -17
View File
@@ -26,14 +26,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 1 }}
className={`w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 flex-shrink-0 ${isMinimized ? 'h-[50px]' : 'h-[320px]'}`}
className={`w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl z-10 flex flex-col font-mono shadow-[0_4px_30px_rgba(0,0,0,0.2)] pointer-events-auto overflow-hidden transition-all duration-300 flex-shrink-0 ${isMinimized ? 'h-[50px]' : 'h-[320px]'}`}
>
{/* Record / Orbit Tracker Header */}
<div className="flex items-center gap-3 mb-6 border border-gray-800 bg-black/40 backdrop-blur-md px-4 py-2 rounded-sm relative shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto">
<div className="absolute -top-1 -left-1 w-2 h-2 border-t border-l border-gray-500/50"></div>
<div className="absolute -bottom-1 -right-1 w-2 h-2 border-b border-r border-gray-500/50"></div>
<div className="flex items-center gap-3 mb-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]/40 backdrop-blur-md px-4 py-2 rounded-sm relative shadow-[0_4px_30px_rgba(0,0,0,0.2)] pointer-events-auto">
<div className="absolute -top-1 -left-1 w-2 h-2 border-t border-l border-[var(--text-muted)]/50"></div>
<div className="absolute -bottom-1 -right-1 w-2 h-2 border-b border-r border-[var(--text-muted)]/50"></div>
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
<div className="text-[10px] font-mono text-gray-400 tracking-wider">
<div className="text-[10px] font-mono text-[var(--text-secondary)] tracking-wider">
REC {currentTime.date} {currentTime.time}
<br />
ORB: 47696 PASS: DESC-284
@@ -41,15 +41,15 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
</div>
{/* Right side controls box */}
<div className="bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl pointer-events-auto border-r-2 border-r-cyan-900 flex flex-col relative overflow-hidden h-full">
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto border-r-2 border-r-cyan-900 flex flex-col relative overflow-hidden h-full">
{/* Header / Toggle */}
<div
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
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"
onClick={() => setIsMinimized(!isMinimized)}
>
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DISPLAY CONFIG</span>
<button className="text-gray-500 hover:text-white transition-colors">
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DISPLAY CONFIG</span>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
@@ -66,14 +66,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
{/* Bloom Toggle */}
<div
className={`flex items-center justify-between group cursor-pointer border rounded px-4 py-3 transition-colors ${effects.bloom ? 'border-yellow-900/50 bg-yellow-950/10' : 'border-gray-800'}`}
className={`flex items-center justify-between group cursor-pointer border rounded px-4 py-3 transition-colors ${effects.bloom ? 'border-yellow-900/50 bg-yellow-950/10' : 'border-[var(--border-primary)]'}`}
onClick={() => setEffects({ ...effects, bloom: !effects.bloom })}
>
<div className="flex items-center gap-3">
<span className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-gray-600'}`}></span>
<span className={`text-xs font-mono tracking-widest ${effects.bloom ? 'text-white' : 'text-gray-500'}`}>BLOOM</span>
<span className={`text-xs font-mono tracking-widest ${effects.bloom ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'}`}>BLOOM</span>
</div>
<span className="text-[9px] font-mono tracking-wider text-gray-500">{effects.bloom ? 'ON' : 'OFF'}</span>
<span className="text-[9px] font-mono tracking-wider text-[var(--text-muted)]">{effects.bloom ? 'ON' : 'OFF'}</span>
</div>
{/* Sharpen Slider */}
@@ -86,7 +86,7 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
<span className="text-xs font-mono tracking-widest text-cyan-400 font-bold">SHARPEN</span>
</div>
<div className="flex items-center justify-between gap-3 mt-1">
<div className="h-0.5 bg-gray-800 flex-1 relative rounded-full">
<div className="h-0.5 bg-[var(--border-primary)] flex-1 relative rounded-full">
<div className="absolute left-0 top-0 bottom-0 w-[49%] bg-cyan-500 shadow-[0_0_10px_rgba(34,211,238,0.5)]"></div>
<div className="absolute left-[49%] top-1/2 -translate-y-1/2 w-2 h-2 bg-white rounded-full"></div>
</div>
@@ -96,14 +96,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
{/* HUD Dropdown */}
<div className="flex flex-col gap-2 relative">
<div className="flex items-center gap-3 border border-gray-800 rounded px-4 py-3 text-gray-500 cursor-default">
<div className="flex items-center gap-3 border border-[var(--border-primary)] rounded px-4 py-3 text-[var(--text-muted)] cursor-default">
<span className="w-3 h-3 border border-gray-500 rounded-full flex items-center justify-center"></span>
<span className="text-xs font-mono tracking-widest">HUD</span>
</div>
<div className="flex items-center justify-between border border-gray-800 rounded px-4 py-2 mt-1 bg-black/50">
<span className="text-[10px] text-gray-500 font-mono">LAYOUT</span>
<span className="text-xs text-white tracking-widest border-b border-dashed border-gray-600 pb-0.5 cursor-pointer flex items-center gap-2">
<div className="flex items-center justify-between border border-[var(--border-primary)] rounded px-4 py-2 mt-1 bg-[var(--bg-primary)]/50">
<span className="text-[10px] text-[var(--text-muted)] font-mono">LAYOUT</span>
<span className="text-xs text-[var(--text-primary)] tracking-widest border-b border-dashed border-[var(--border-secondary)] pb-0.5 cursor-pointer flex items-center gap-2">
Tactical
</span>
</div>
+39
View File
@@ -0,0 +1,39 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
type Theme = "dark" | "light";
const ThemeContext = createContext<{ theme: Theme; toggleTheme: () => void }>({
theme: "dark",
toggleTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark");
useEffect(() => {
const saved = localStorage.getItem("sb-theme") as Theme | null;
if (saved === "light" || saved === "dark") {
setTheme(saved);
document.documentElement.setAttribute("data-theme", saved);
}
}, []);
const toggleTheme = () => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("sb-theme", next);
document.documentElement.setAttribute("data-theme", next);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}