mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-30 07:28:57 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { NOMINATIM_DEBOUNCE_MS } from '@/lib/constants';
|
||||
|
||||
/* ── LOCATE BAR ── coordinate / place-name search above bottom status bar ── */
|
||||
export function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number, lng: number) => void; onOpenChange?: (open: boolean) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => { onOpenChange?.(open); }, [open]);
|
||||
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> | null>(null);
|
||||
const searchAbortRef = useRef<AbortController | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Close when clicking outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setValue('');
|
||||
setResults([]);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [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)
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (searchAbortRef.current) searchAbortRef.current.abort();
|
||||
if (q.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
searchAbortRef.current = new AbortController();
|
||||
const signal = searchAbortRef.current.signal;
|
||||
try {
|
||||
// Try backend proxy first (has caching + rate-limit compliance)
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/geocode/search?q=${encodeURIComponent(q)}&limit=5`,
|
||||
{ signal },
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const mapped = (data?.results || []).map(
|
||||
(r: { label: string; lat: number; lng: number }) => ({
|
||||
label: r.label,
|
||||
lat: r.lat,
|
||||
lng: r.lng,
|
||||
}),
|
||||
);
|
||||
setResults(mapped);
|
||||
} else {
|
||||
// Backend proxy returned an error — fall back to direct Nominatim
|
||||
console.warn(`[Locate] Proxy returned HTTP ${res.status}, falling back to Nominatim`);
|
||||
const directRes = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
|
||||
{ headers: { 'Accept-Language': 'en' }, signal },
|
||||
);
|
||||
const data = await directRes.json();
|
||||
setResults(
|
||||
data.map((r: { display_name: string; lat: string; lon: string }) => ({
|
||||
label: r.display_name,
|
||||
lat: parseFloat(r.lat),
|
||||
lng: parseFloat(r.lon),
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name !== 'AbortError') {
|
||||
// Proxy completely failed — try direct Nominatim as last resort
|
||||
try {
|
||||
const directRes = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
|
||||
{ headers: { 'Accept-Language': 'en' } },
|
||||
);
|
||||
const data = await directRes.json();
|
||||
setResults(
|
||||
data.map((r: { display_name: string; lat: string; lon: string }) => ({
|
||||
label: r.display_name,
|
||||
lat: parseFloat(r.lat),
|
||||
lng: parseFloat(r.lon),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setResults([]);
|
||||
}
|
||||
} else {
|
||||
setResults([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, NOMINATIM_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
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-2 bg-[var(--bg-primary)]/80 border border-[var(--border-primary)] px-5 py-2 text-[11px] 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="13"
|
||||
height="13"
|
||||
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 ref={containerRef} className="relative w-[520px]">
|
||||
<div className="flex items-center gap-2 bg-[var(--bg-primary)] border border-cyan-800/60 px-4 py-2.5 shadow-[0_0_20px_rgba(0,255,255,0.1)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
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-[12px] 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)] border border-[var(--border-primary)] 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-[11px] text-[var(--text-secondary)] font-mono truncate">
|
||||
{r.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
/* ── SENTINEL HUB — first-time info modal ── */
|
||||
export function SentinelInfoModal({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/90"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative z-[10001] w-[520px] max-h-[80vh] bg-[var(--bg-secondary)] border border-purple-500/30 shadow-2xl shadow-purple-900/20 overflow-y-auto styled-scrollbar">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold tracking-wider text-purple-300 font-mono">
|
||||
SENTINEL HUB IMAGERY
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-muted)] hover:text-white transition-colors text-xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
You now have access to ESA Sentinel-2 satellite imagery directly on the map.
|
||||
This uses the Copernicus Data Space Ecosystem with your own credentials.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">AVAILABLE LAYERS</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ name: 'True Color', desc: 'Natural RGB — see terrain, cities, water' },
|
||||
{ name: 'False Color IR', desc: 'Near-infrared — vegetation in red' },
|
||||
{ name: 'NDVI', desc: 'Vegetation health index (green = healthy)' },
|
||||
{ name: 'Moisture Index', desc: 'Soil & vegetation moisture levels' },
|
||||
].map((l) => (
|
||||
<div key={l.name} className="p-2 border border-purple-900/30 bg-purple-950/10">
|
||||
<div className="text-[10px] font-mono text-white">{l.name}</div>
|
||||
<div className="text-[9px] text-[var(--text-muted)]">{l.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">USAGE LIMITS (FREE TIER)</h3>
|
||||
<div className="p-3 border border-[var(--border-primary)] bg-[var(--bg-primary)]/40 space-y-1.5">
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Monthly budget</span>
|
||||
<span className="text-purple-300">10,000 requests</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Cost per tile</span>
|
||||
<span className="text-purple-300">0.25 PU (256×256px)</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">~Viewport loads/month</span>
|
||||
<span className="text-purple-300">~500 (20 tiles each)</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Empty tiles</span>
|
||||
<span className="text-green-400">FREE (no data = no charge)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">HOW IT WORKS</h3>
|
||||
<ul className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed space-y-1 list-disc list-inside">
|
||||
<li>Sentinel-2 revisits every ~5 days — not every location has data every day</li>
|
||||
<li>The date slider picks the end of a time window; zoomed out uses wider windows</li>
|
||||
<li>Black patches = no satellite pass on that date range (normal)</li>
|
||||
<li>Best results at zoom 8-14 — closer = sharper imagery (10m resolution)</li>
|
||||
<li>Cloud filter auto-skips tiles with {'>'} 30% cloud cover</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-2.5 bg-purple-500/20 border border-purple-500/40 text-purple-300 hover:bg-purple-500/30 transition-colors text-[11px] font-mono tracking-wider"
|
||||
>
|
||||
GOT IT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,10 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean {
|
||||
if (joined === 'system/update') return true;
|
||||
if (pathSegments[0] === 'settings') return true;
|
||||
if (joined === 'mesh/infonet/ingest') return true;
|
||||
// mesh/peers and all tools/* use require_local_operator on the backend and
|
||||
// need X-Admin-Key injected on the server-side proxy leg.
|
||||
if (pathSegments[0] === 'mesh' && pathSegments[1] === 'peers') return true;
|
||||
if (pathSegments[0] === 'tools') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,11 @@ const NO_STORE_HEADERS = {
|
||||
Pragma: 'no-cache',
|
||||
};
|
||||
|
||||
function cookieOptions(req: NextRequest) {
|
||||
const host = req.headers.get('host') ?? '';
|
||||
const isLoopback = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
|
||||
function cookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict' as const,
|
||||
secure: process.env.NODE_ENV === 'production' && !isLoopback,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
};
|
||||
@@ -82,7 +80,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
const sessionToken = createAdminSessionToken(adminKey, COOKIE_MAX_AGE);
|
||||
const res = NextResponse.json({ ok: true }, { headers: NO_STORE_HEADERS });
|
||||
res.cookies.set(COOKIE_NAME, sessionToken, cookieOptions(req));
|
||||
res.cookies.set(COOKIE_NAME, sessionToken, cookieOptions());
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -93,7 +91,7 @@ export async function DELETE(req: NextRequest) {
|
||||
}
|
||||
const res = NextResponse.json({ ok: true }, { headers: NO_STORE_HEADERS });
|
||||
res.cookies.set(COOKIE_NAME, '', {
|
||||
...cookieOptions(req),
|
||||
...cookieOptions(),
|
||||
maxAge: 0,
|
||||
});
|
||||
return res;
|
||||
|
||||
@@ -136,19 +136,19 @@ textarea:disabled {
|
||||
background: rgba(10, 14, 20, 0.96);
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(8, 145, 178, 0.35);
|
||||
padding: 10px 14px;
|
||||
padding: 12px 16px;
|
||||
color: #d1d5db;
|
||||
font-family:
|
||||
'JetBrains Mono', var(--font-roboto-mono), 'Roboto Mono', monospace, 'Microsoft YaHei', 'PingFang SC',
|
||||
'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', sans-serif;
|
||||
font-size: 11px;
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
font-size: 13px;
|
||||
min-width: 240px;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.map-popup-title {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -159,12 +159,18 @@ textarea:disabled {
|
||||
}
|
||||
|
||||
.map-popup-subtitle {
|
||||
font-size: 9px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Hide MapLibre logo & attribution */
|
||||
.maplibregl-ctrl-logo,
|
||||
.maplibregl-ctrl-attrib {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* MapLibre Popup Overrides */
|
||||
.maplibregl-popup-content {
|
||||
background: transparent !important;
|
||||
@@ -458,3 +464,61 @@ textarea:disabled {
|
||||
.crt ::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* ── THREAT ALERT ACCESSIBILITY PATTERNS ── */
|
||||
/* Border patterns for color-blind differentiation beyond hue */
|
||||
.threat-border-critical {
|
||||
border-width: 3px !important;
|
||||
border-style: solid !important;
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.4), inset 0 0 12px rgba(239, 68, 68, 0.1);
|
||||
animation: threat-pulse-critical 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.threat-border-high {
|
||||
border-width: 2px !important;
|
||||
border-style: solid !important;
|
||||
}
|
||||
|
||||
.threat-border-medium {
|
||||
border-width: 2px !important;
|
||||
border-style: dashed !important;
|
||||
}
|
||||
|
||||
.threat-border-low {
|
||||
border-width: 1px !important;
|
||||
border-style: dotted !important;
|
||||
}
|
||||
|
||||
@keyframes threat-pulse-critical {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.4), inset 0 0 12px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.6), inset 0 0 20px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── FEED HEALTH INDICATOR DOTS ── */
|
||||
.feed-dot {
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
margin-right: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.feed-dot-healthy {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 4px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.feed-dot-stale {
|
||||
background: #eab308;
|
||||
box-shadow: 0 0 4px rgba(234, 179, 8, 0.5);
|
||||
}
|
||||
|
||||
.feed-dot-offline {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 4px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
+192
-369
@@ -11,7 +11,7 @@ import MarketsPanel from '@/components/MarketsPanel';
|
||||
import FilterPanel from '@/components/FilterPanel';
|
||||
import FindLocateBar from '@/components/FindLocateBar';
|
||||
import TopRightControls from '@/components/TopRightControls';
|
||||
import PredictionsPanel from '@/components/PredictionsPanel';
|
||||
import TimelinePanel from '@/components/TimelinePanel';
|
||||
import SettingsPanel from '@/components/SettingsPanel';
|
||||
import MapLegend from '@/components/MapLegend';
|
||||
import ScaleBar from '@/components/ScaleBar';
|
||||
@@ -19,19 +19,28 @@ import MeshTerminal from '@/components/MeshTerminal';
|
||||
import MeshChat from '@/components/MeshChat';
|
||||
import InfonetTerminal from '@/components/InfonetTerminal';
|
||||
import { leaveWormhole, fetchWormholeState } from '@/mesh/wormholeClient';
|
||||
import { teardownWormholeOnClose } from '@/lib/wormholeTeardown';
|
||||
import ShodanPanel from '@/components/ShodanPanel';
|
||||
import AIIntelPanel from '@/components/AIIntelPanel';
|
||||
import GlobalTicker from '@/components/GlobalTicker';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import OnboardingModal, { useOnboarding } from '@/components/OnboardingModal';
|
||||
import ChangelogModal, { useChangelog } from '@/components/ChangelogModal';
|
||||
import type { ActiveLayers, KiwiSDR, Scanner, SelectedEntity } from '@/types/dashboard';
|
||||
import type { ShodanSearchMatch } from '@/types/shodan';
|
||||
import { NOMINATIM_DEBOUNCE_MS } from '@/lib/constants';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { useDataPolling, LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling';
|
||||
import { useBackendStatus, useDataKey } from '@/hooks/useDataStore';
|
||||
import { useReverseGeocode } from '@/hooks/useReverseGeocode';
|
||||
import { useRegionDossier } from '@/hooks/useRegionDossier';
|
||||
import { useAgentActions } from '@/hooks/useAgentActions';
|
||||
import { useFeedHealth } from '@/hooks/useFeedHealth';
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||
import KeyboardShortcutsOverlay from '@/components/KeyboardShortcutsOverlay';
|
||||
import AlertToast from '@/components/AlertToast';
|
||||
import { useAlertToasts } from '@/hooks/useAlertToasts';
|
||||
import { useWatchlist } from '@/hooks/useWatchlist';
|
||||
import WatchlistWidget from '@/components/WatchlistWidget';
|
||||
import {
|
||||
requestSecureMeshTerminalLauncherOpen,
|
||||
subscribeMeshTerminalOpen,
|
||||
@@ -40,257 +49,15 @@ import {
|
||||
hasSentinelInfoBeenSeen,
|
||||
markSentinelInfoSeen,
|
||||
hasSentinelCredentials,
|
||||
getSentinelUsage,
|
||||
} from '@/lib/sentinelHub';
|
||||
import { LocateBar } from './LocateBar';
|
||||
import { SentinelInfoModal } from './SentinelInfoModal';
|
||||
import SarAoiEditorModal from '@/components/SarAoiEditorModal';
|
||||
|
||||
// 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, onOpenChange }: { onLocate: (lat: number, lng: number) => void; onOpenChange?: (open: boolean) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => { onOpenChange?.(open); }, [open]);
|
||||
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> | null>(null);
|
||||
const searchAbortRef = useRef<AbortController | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Close when clicking outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setValue('');
|
||||
setResults([]);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [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)
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (searchAbortRef.current) searchAbortRef.current.abort();
|
||||
if (q.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
searchAbortRef.current = new AbortController();
|
||||
const signal = searchAbortRef.current.signal;
|
||||
try {
|
||||
// Try backend proxy first (has caching + rate-limit compliance)
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/geocode/search?q=${encodeURIComponent(q)}&limit=5`,
|
||||
{ signal },
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const mapped = (data?.results || []).map(
|
||||
(r: { label: string; lat: number; lng: number }) => ({
|
||||
label: r.label,
|
||||
lat: r.lat,
|
||||
lng: r.lng,
|
||||
}),
|
||||
);
|
||||
setResults(mapped);
|
||||
} else {
|
||||
// Backend proxy returned an error — fall back to direct Nominatim
|
||||
console.warn(`[Locate] Proxy returned HTTP ${res.status}, falling back to Nominatim`);
|
||||
const directRes = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
|
||||
{ headers: { 'Accept-Language': 'en' }, signal },
|
||||
);
|
||||
const data = await directRes.json();
|
||||
setResults(
|
||||
data.map((r: { display_name: string; lat: string; lon: string }) => ({
|
||||
label: r.display_name,
|
||||
lat: parseFloat(r.lat),
|
||||
lng: parseFloat(r.lon),
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name !== 'AbortError') {
|
||||
// Proxy completely failed — try direct Nominatim as last resort
|
||||
try {
|
||||
const directRes = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
|
||||
{ headers: { 'Accept-Language': 'en' } },
|
||||
);
|
||||
const data = await directRes.json();
|
||||
setResults(
|
||||
data.map((r: { display_name: string; lat: string; lon: string }) => ({
|
||||
label: r.display_name,
|
||||
lat: parseFloat(r.lat),
|
||||
lng: parseFloat(r.lon),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setResults([]);
|
||||
}
|
||||
} else {
|
||||
setResults([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, NOMINATIM_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
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-2 bg-[var(--bg-primary)]/80 border border-[var(--border-primary)] px-5 py-2 text-[11px] 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="13"
|
||||
height="13"
|
||||
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 ref={containerRef} className="relative w-[520px]">
|
||||
<div className="flex items-center gap-2 bg-[var(--bg-primary)] border border-cyan-800/60 px-4 py-2.5 shadow-[0_0_20px_rgba(0,255,255,0.1)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
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-[12px] 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)] border border-[var(--border-primary)] 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-[11px] text-[var(--text-secondary)] font-mono truncate">
|
||||
{r.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// LocateBar and SentinelInfoModal extracted to page-local modules (Sprint 4B)
|
||||
|
||||
export default function Dashboard() {
|
||||
const viewBoundsRef = useRef<{ south: number; west: number; north: number; east: number } | null>(null);
|
||||
@@ -303,6 +70,11 @@ export default function Dashboard() {
|
||||
setSelectedEntity,
|
||||
);
|
||||
|
||||
// Agent can push satellite imagery to the same full-screen viewer as right-click,
|
||||
// and can fly the map to a point (e.g. sar_focus_aoi). The hook is invoked
|
||||
// below — after setFlyToLocation is declared — so the fly_to callback can
|
||||
// close over it without hitting a temporal dead zone.
|
||||
|
||||
const [uiVisible, setUiVisible] = useState(true);
|
||||
const [leftOpen, setLeftOpen] = useState(true);
|
||||
const [rightOpen, setRightOpen] = useState(true);
|
||||
@@ -331,12 +103,15 @@ export default function Dashboard() {
|
||||
}, [tickerOpen]);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [legendOpen, setLegendOpen] = useState(false);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalLaunchToken, setTerminalLaunchToken] = useState(0);
|
||||
const [infonetOpen, setInfonetOpen] = useState(false);
|
||||
const [meshChatLaunchRequest, setMeshChatLaunchRequest] = useState<{
|
||||
tab: 'infonet' | 'meshtastic' | 'dms';
|
||||
gate?: string;
|
||||
peerId?: string;
|
||||
showSas?: boolean;
|
||||
nonce: number;
|
||||
} | null>(null);
|
||||
const [dmCount, setDmCount] = useState(0);
|
||||
@@ -344,6 +119,14 @@ export default function Dashboard() {
|
||||
const [locateBarOpen, setLocateBarOpen] = useState(false);
|
||||
const [measureMode, setMeasureMode] = useState(false);
|
||||
const [measurePoints, setMeasurePoints] = useState<{ lat: number; lng: number }[]>([]);
|
||||
const [pinPlacementMode, setPinPlacementMode] = useState(false);
|
||||
|
||||
// SAR AOI editor + map drop mode
|
||||
const [sarAoiEditorOpen, setSarAoiEditorOpen] = useState(false);
|
||||
const [sarAoiDropMode, setSarAoiDropMode] = useState(false);
|
||||
const [sarAoiDroppedCoords, setSarAoiDroppedCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const sarAoiListChangedRef = useRef(0);
|
||||
const [sarAoiListVersion, setSarAoiListVersion] = useState(0);
|
||||
|
||||
const openMeshTerminal = useCallback(() => {
|
||||
setTerminalOpen(true);
|
||||
@@ -412,8 +195,18 @@ export default function Dashboard() {
|
||||
global_incidents: true,
|
||||
day_night: true,
|
||||
correlations: true,
|
||||
contradictions: true,
|
||||
uap_sightings: true,
|
||||
// Biosurveillance
|
||||
wastewater: true,
|
||||
// CrowdThreat
|
||||
crowdthreat: true,
|
||||
// Shodan
|
||||
shodan_overlay: false,
|
||||
// AI Intel
|
||||
ai_intel: true,
|
||||
// SAR (Synthetic Aperture Radar)
|
||||
sar: true,
|
||||
});
|
||||
const [shodanResults, setShodanResults] = useState<ShodanSearchMatch[]>([]);
|
||||
const [, setShodanQueryLabel] = useState('');
|
||||
@@ -421,6 +214,33 @@ export default function Dashboard() {
|
||||
useDataPolling();
|
||||
const backendStatus = useBackendStatus();
|
||||
const spaceWeather = useDataKey('space_weather');
|
||||
const feedHealth = useFeedHealth();
|
||||
|
||||
// Global keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
toggleLeft: () => setLeftOpen((p) => !p),
|
||||
toggleRight: () => setRightOpen((p) => !p),
|
||||
toggleMarkets: () => setTickerOpen((p) => !p),
|
||||
openSettings: () => setSettingsOpen(true),
|
||||
openLegend: () => setLegendOpen((p) => !p),
|
||||
openShortcuts: () => setShortcutsOpen((p) => !p),
|
||||
deselectEntity: () => {
|
||||
if (shortcutsOpen) { setShortcutsOpen(false); return; }
|
||||
if (settingsOpen) { setSettingsOpen(false); return; }
|
||||
if (legendOpen) { setLegendOpen(false); return; }
|
||||
setSelectedEntity(null);
|
||||
},
|
||||
focusSearch: () => {
|
||||
const el = document.querySelector<HTMLInputElement>('[data-search-input]');
|
||||
el?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
// Alert toast notifications for high-severity news
|
||||
const { toasts, dismiss: dismissToast } = useAlertToasts();
|
||||
|
||||
// Persistent entity watchlist
|
||||
const { items: watchlistItems, removeFromWatchlist, clearWatchlist } = useWatchlist();
|
||||
|
||||
// Notify backend of layer toggles so it can skip disabled fetchers / stop streams.
|
||||
// After the POST completes, dispatch a custom event so useDataPolling immediately
|
||||
@@ -459,17 +279,33 @@ export default function Dashboard() {
|
||||
const [leftMeshExpanded, setLeftMeshExpanded] = useState(true);
|
||||
const [leftShodanMinimized, setLeftShodanMinimized] = useState(true);
|
||||
|
||||
const launchMeshChatTab = useCallback((tab: 'infonet' | 'meshtastic' | 'dms', gate?: string) => {
|
||||
setLeftOpen(true);
|
||||
setLeftMeshExpanded(true);
|
||||
setMeshChatLaunchRequest({ tab, gate, nonce: Date.now() });
|
||||
}, []);
|
||||
const launchMeshChatTab = useCallback(
|
||||
(
|
||||
tab: 'infonet' | 'meshtastic' | 'dms',
|
||||
gate?: string,
|
||||
peerId?: string,
|
||||
showSas?: boolean,
|
||||
) => {
|
||||
setLeftOpen(true);
|
||||
setLeftMeshExpanded(true);
|
||||
setMeshChatLaunchRequest({ tab, gate, peerId, showSas, nonce: Date.now() });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const openLiveGateFromShell = useCallback((gate: string) => {
|
||||
setInfonetOpen(false);
|
||||
launchMeshChatTab('infonet', gate);
|
||||
}, [launchMeshChatTab]);
|
||||
|
||||
const openDeadDropFromShell = useCallback(
|
||||
(peerId: string, options?: { showSas?: boolean }) => {
|
||||
setInfonetOpen(false);
|
||||
launchMeshChatTab('dms', undefined, peerId, Boolean(options?.showSas));
|
||||
},
|
||||
[launchMeshChatTab],
|
||||
);
|
||||
|
||||
// Right panel: which panel is "focused" (expanded). null = none focused, all normal.
|
||||
const [rightFocusedPanel, setRightFocusedPanel] = useState<string | null>(null);
|
||||
|
||||
@@ -557,6 +393,13 @@ export default function Dashboard() {
|
||||
ts: number;
|
||||
} | null>(null);
|
||||
|
||||
// Agent fly_to handler (sar_focus_aoi etc.) — wired here now that
|
||||
// setFlyToLocation is in scope. show_image is routed through
|
||||
// useAgentActions at the top of Dashboard.
|
||||
useAgentActions(handleMapRightClick, ({ lat, lng }) => {
|
||||
setFlyToLocation({ lat, lng, ts: Date.now() });
|
||||
});
|
||||
|
||||
// Eavesdrop Mode State
|
||||
const [isEavesdropping] = useState(false);
|
||||
const [, setEavesdropLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
@@ -601,6 +444,15 @@ export default function Dashboard() {
|
||||
setTrackedScanner={setTrackedScanner}
|
||||
shodanResults={shodanResults}
|
||||
shodanStyle={shodanStyle}
|
||||
pinPlacementMode={pinPlacementMode}
|
||||
onPinPlaced={() => setPinPlacementMode(false)}
|
||||
sarAoiDropMode={sarAoiDropMode}
|
||||
onSarAoiDropped={(coords) => {
|
||||
setSarAoiDropMode(false);
|
||||
setSarAoiDroppedCoords(coords);
|
||||
setSarAoiEditorOpen(true);
|
||||
}}
|
||||
sarAoiListVersion={sarAoiListVersion}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -628,22 +480,18 @@ export default function Dashboard() {
|
||||
>
|
||||
S H A D O W <span className="text-cyan-400">B R O K E R</span>
|
||||
</h1>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-[0.3em] mt-1 ml-1">
|
||||
<span className="text-[11px] text-[var(--text-muted)] font-mono tracking-[0.3em] mt-1 ml-1">
|
||||
GLOBAL THREAT INTERCEPT
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* SYSTEM METRICS TOP LEFT */}
|
||||
<div className="absolute top-2 left-6 text-[8px] font-mono tracking-widest text-cyan-500/50 z-[200] pointer-events-none hud-zone">
|
||||
<div className="absolute top-2 left-6 text-[11px] font-mono tracking-widest text-cyan-500/50 z-[200] pointer-events-none hud-zone">
|
||||
OPTIC VIS:113 SRC:180 DENS:1.42 0.8ms
|
||||
</div>
|
||||
|
||||
{/* SYSTEM METRICS TOP RIGHT */}
|
||||
<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 hud-zone">
|
||||
<div>RTX</div>
|
||||
<div>VSR</div>
|
||||
</div>
|
||||
{/* SYSTEM METRICS TOP RIGHT — removed, label moved into TimelineScrubber */}
|
||||
|
||||
{/* LEFT HUD CONTAINER — mirrors right side: one scroll container, scrollbar on LEFT edge */}
|
||||
<motion.div
|
||||
@@ -661,6 +509,7 @@ export default function Dashboard() {
|
||||
shodanResultCount={shodanResults.length}
|
||||
onSettingsClick={() => setSettingsOpen(true)}
|
||||
onLegendClick={() => setLegendOpen(true)}
|
||||
onOpenSarAoiEditor={() => setSarAoiEditorOpen(true)}
|
||||
gibsDate={gibsDate}
|
||||
setGibsDate={setGibsDate}
|
||||
gibsOpacity={gibsOpacity}
|
||||
@@ -712,6 +561,15 @@ export default function Dashboard() {
|
||||
onMinimizedChange={setLeftShodanMinimized}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4. AI INTEL (Below Shodan) */}
|
||||
<div className="contents" style={{ direction: 'ltr' }}>
|
||||
<AIIntelPanel
|
||||
onFlyTo={handleFlyTo}
|
||||
pinPlacementMode={pinPlacementMode}
|
||||
onPinPlacementModeChange={setPinPlacementMode}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* LEFT SIDEBAR TOGGLE TAB — aligns with Data Layers section */}
|
||||
@@ -734,10 +592,10 @@ export default function Dashboard() {
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* RIGHT SIDEBAR TOGGLE TAB — aligns with Oracle Predictions section */}
|
||||
{/* RIGHT SIDEBAR TOGGLE TAB */}
|
||||
<motion.div
|
||||
className="absolute right-0 top-[12.5rem] z-[201] pointer-events-auto hud-zone"
|
||||
animate={{ x: rightOpen ? -344 : 0 }}
|
||||
animate={{ x: rightOpen ? -424 : 0 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
|
||||
>
|
||||
<button
|
||||
@@ -756,8 +614,8 @@ export default function Dashboard() {
|
||||
|
||||
{/* RIGHT HUD CONTAINER — slides off right edge when hidden */}
|
||||
<motion.div
|
||||
className="absolute right-6 top-24 bottom-9 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2 pl-2 hud-zone"
|
||||
animate={{ x: rightOpen ? 0 : 360 }}
|
||||
className="absolute right-6 top-24 bottom-9 w-[400px] flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2 pl-2 hud-zone"
|
||||
animate={{ x: rightOpen ? 0 : 440 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
|
||||
>
|
||||
<TopRightControls
|
||||
@@ -788,10 +646,10 @@ export default function Dashboard() {
|
||||
|
||||
{/* GLOBAL TICKER REPLACES MARKETS PANEL - RENDERED OUTSIDE THIS DIV */}
|
||||
|
||||
{/* ORACLE PREDICTIONS */}
|
||||
{/* EVENT TIMELINE */}
|
||||
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'predictions' ? 'hidden' : ''}`}>
|
||||
<ErrorBoundary name="PredictionsPanel">
|
||||
<PredictionsPanel />
|
||||
<ErrorBoundary name="TimelinePanel">
|
||||
<TimelinePanel />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
@@ -812,9 +670,14 @@ export default function Dashboard() {
|
||||
selectedEntity={selectedEntity}
|
||||
regionDossier={regionDossier}
|
||||
regionDossierLoading={regionDossierLoading}
|
||||
onArticleClick={(idx, lat, lng) => {
|
||||
onArticleClick={(idx, lat, lng, title) => {
|
||||
if (lat !== undefined && lng !== undefined) {
|
||||
setFlyToLocation({ lat, lng, ts: Date.now() });
|
||||
// Also highlight the corresponding map alert
|
||||
if (title) {
|
||||
const alertKey = `${title}|${lat},${lng}`;
|
||||
setSelectedEntity({ id: alertKey, type: 'news' });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -837,15 +700,15 @@ export default function Dashboard() {
|
||||
/>
|
||||
|
||||
<div
|
||||
className="bg-[#0a0a0a]/90 border border-cyan-900/40 px-5 py-1.5 flex items-center gap-5 border-b-2 border-b-cyan-800 cursor-pointer backdrop-blur-sm"
|
||||
className="bg-[#0a0a0a]/90 border border-cyan-900/40 px-6 py-2 flex items-center gap-6 border-b-2 border-b-cyan-800 cursor-pointer backdrop-blur-sm"
|
||||
onClick={cycleStyle}
|
||||
>
|
||||
{/* Coordinates */}
|
||||
<div className="flex flex-col items-center min-w-[120px]">
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
<div className="flex flex-col items-center min-w-[140px]">
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
COORDINATES
|
||||
</div>
|
||||
<div className="text-[11px] text-cyan-400 font-mono font-bold tracking-wide">
|
||||
<div className="text-[14px] text-cyan-400 font-mono font-bold tracking-wide">
|
||||
{mouseCoords
|
||||
? `${mouseCoords.lat.toFixed(4)}, ${mouseCoords.lng.toFixed(4)}`
|
||||
: '0.0000, 0.0000'}
|
||||
@@ -856,11 +719,11 @@ export default function Dashboard() {
|
||||
<div className="w-px h-6 bg-[var(--border-primary)]" />
|
||||
|
||||
{/* Location name */}
|
||||
<div className="flex flex-col items-center min-w-[160px] max-w-[280px]">
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
<div className="flex flex-col items-center min-w-[180px] max-w-[320px]">
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
LOCATION
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] font-mono truncate max-w-[280px]">
|
||||
<div className="text-[13px] text-[var(--text-secondary)] font-mono truncate max-w-[320px]">
|
||||
{locationLabel || 'Hover over map...'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -870,10 +733,10 @@ export default function Dashboard() {
|
||||
|
||||
{/* Style preset (compact) */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
STYLE
|
||||
</div>
|
||||
<div className="text-[11px] text-cyan-400 font-mono font-bold">
|
||||
<div className="text-[14px] text-cyan-400 font-mono font-bold">
|
||||
{activeStyle}
|
||||
</div>
|
||||
</div>
|
||||
@@ -889,11 +752,11 @@ export default function Dashboard() {
|
||||
className="flex flex-col items-center"
|
||||
title={`Kp Index: ${sw?.kp_index ?? 'N/A'}`}
|
||||
>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
SOLAR
|
||||
</div>
|
||||
<div
|
||||
className={`text-[11px] font-mono font-bold ${
|
||||
className={`text-[14px] font-mono font-bold ${
|
||||
(sw?.kp_index ?? 0) >= 5
|
||||
? 'text-red-400'
|
||||
: (sw?.kp_index ?? 0) >= 4
|
||||
@@ -906,6 +769,20 @@ export default function Dashboard() {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-6 bg-[var(--border-primary)]" />
|
||||
|
||||
{/* Feed Health */}
|
||||
<div className="flex items-center gap-3">
|
||||
{feedHealth.map((f) => (
|
||||
<div key={f.label} className="flex items-center gap-1 text-[10px] font-mono tracking-wider">
|
||||
<span className={`feed-dot feed-dot-${f.status}`} />
|
||||
<span className="text-[var(--text-muted)]">{f.label}</span>
|
||||
<span className="text-cyan-400 font-bold">{f.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -953,6 +830,15 @@ export default function Dashboard() {
|
||||
style={{ backgroundSize: '100% 4px' }}
|
||||
></div>
|
||||
|
||||
{/* WATCHLIST WIDGET */}
|
||||
<WatchlistWidget
|
||||
items={watchlistItems}
|
||||
onRemove={removeFromWatchlist}
|
||||
onClear={clearWatchlist}
|
||||
onFlyTo={handleFlyTo}
|
||||
/>
|
||||
|
||||
|
||||
{/* SETTINGS PANEL */}
|
||||
<ErrorBoundary name="SettingsPanel">
|
||||
<SettingsPanel isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
@@ -963,6 +849,16 @@ export default function Dashboard() {
|
||||
<MapLegend isOpen={legendOpen} onClose={() => setLegendOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* KEYBOARD SHORTCUTS OVERLAY */}
|
||||
<KeyboardShortcutsOverlay isOpen={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
|
||||
{/* ALERT TOAST NOTIFICATIONS */}
|
||||
<AlertToast
|
||||
toasts={toasts}
|
||||
onDismiss={dismissToast}
|
||||
onFlyTo={handleFlyTo}
|
||||
/>
|
||||
|
||||
{/* ONBOARDING MODAL */}
|
||||
{showOnboarding && (
|
||||
<OnboardingModal
|
||||
@@ -979,91 +875,21 @@ export default function Dashboard() {
|
||||
<ChangelogModal onClose={() => setShowChangelog(false)} />
|
||||
)}
|
||||
|
||||
{/* SENTINEL HUB — first-time info modal */}
|
||||
{/* SENTINEL HUB — first-time info modal (extracted to SentinelInfoModal.tsx) */}
|
||||
{showSentinelInfo && (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/90"
|
||||
onClick={() => setShowSentinelInfo(false)}
|
||||
/>
|
||||
<div className="relative z-[10001] w-[520px] max-h-[80vh] bg-[var(--bg-secondary)] border border-purple-500/30 shadow-2xl shadow-purple-900/20 overflow-y-auto styled-scrollbar">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold tracking-wider text-purple-300 font-mono">
|
||||
SENTINEL HUB IMAGERY
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowSentinelInfo(false)}
|
||||
className="text-[var(--text-muted)] hover:text-white transition-colors text-xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<SentinelInfoModal onClose={() => setShowSentinelInfo(false)} />
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
You now have access to ESA Sentinel-2 satellite imagery directly on the map.
|
||||
This uses the Copernicus Data Space Ecosystem with your own credentials.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">AVAILABLE LAYERS</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ name: 'True Color', desc: 'Natural RGB — see terrain, cities, water' },
|
||||
{ name: 'False Color IR', desc: 'Near-infrared — vegetation in red' },
|
||||
{ name: 'NDVI', desc: 'Vegetation health index (green = healthy)' },
|
||||
{ name: 'Moisture Index', desc: 'Soil & vegetation moisture levels' },
|
||||
].map((l) => (
|
||||
<div key={l.name} className="p-2 border border-purple-900/30 bg-purple-950/10">
|
||||
<div className="text-[10px] font-mono text-white">{l.name}</div>
|
||||
<div className="text-[9px] text-[var(--text-muted)]">{l.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">USAGE LIMITS (FREE TIER)</h3>
|
||||
<div className="p-3 border border-[var(--border-primary)] bg-[var(--bg-primary)]/40 space-y-1.5">
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Monthly budget</span>
|
||||
<span className="text-purple-300">10,000 requests</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Cost per tile</span>
|
||||
<span className="text-purple-300">0.25 PU (256×256px)</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">~Viewport loads/month</span>
|
||||
<span className="text-purple-300">~500 (20 tiles each)</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Empty tiles</span>
|
||||
<span className="text-green-400">FREE (no data = no charge)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">HOW IT WORKS</h3>
|
||||
<ul className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed space-y-1 list-disc list-inside">
|
||||
<li>Sentinel-2 revisits every ~5 days — not every location has data every day</li>
|
||||
<li>The date slider picks the end of a time window; zoomed out uses wider windows</li>
|
||||
<li>Black patches = no satellite pass on that date range (normal)</li>
|
||||
<li>Best results at zoom 8-14 — closer = sharper imagery (10m resolution)</li>
|
||||
<li>Cloud filter auto-skips tiles with {'>'} 30% cloud cover</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowSentinelInfo(false)}
|
||||
className="w-full py-2.5 bg-purple-500/20 border border-purple-500/40 text-purple-300 hover:bg-purple-500/30 transition-colors text-[11px] font-mono tracking-wider"
|
||||
>
|
||||
GOT IT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* SAR AOI EDITOR — portals to document.body internally */}
|
||||
{(sarAoiEditorOpen || sarAoiDropMode) && (
|
||||
<SarAoiEditorModal
|
||||
onClose={() => { setSarAoiEditorOpen(false); setSarAoiDropMode(false); }}
|
||||
onRequestMapPick={() => { setSarAoiEditorOpen(false); setSarAoiDropMode(true); }}
|
||||
pickedCoords={sarAoiDroppedCoords}
|
||||
onPickConsumed={() => setSarAoiDroppedCoords(null)}
|
||||
onAoiListChanged={() => setSarAoiListVersion((v) => v + 1)}
|
||||
dropModeActive={sarAoiDropMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MESH TERMINAL */}
|
||||
@@ -1081,13 +907,10 @@ export default function Dashboard() {
|
||||
onClose={() => {
|
||||
setInfonetOpen(false);
|
||||
// Shut down Wormhole when the terminal closes so it doesn't stay running
|
||||
fetchWormholeState(false)
|
||||
.then((s) => {
|
||||
if (s?.ready || s?.running) return leaveWormhole();
|
||||
})
|
||||
.catch(() => {});
|
||||
void teardownWormholeOnClose(fetchWormholeState, leaveWormhole);
|
||||
}}
|
||||
onOpenLiveGate={openLiveGateFromShell}
|
||||
onOpenDeadDrop={openDeadDropFromShell}
|
||||
/>
|
||||
|
||||
{/* BACKEND DISCONNECTED BANNER */}
|
||||
@@ -1099,9 +922,9 @@ export default function Dashboard() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* BOTTOM TICKER TOGGLE TAB — moved to right to avoid Shodan overlap */}
|
||||
{/* BOTTOM TICKER TOGGLE TAB — moved to center-right to avoid panel overlap */}
|
||||
<motion.div
|
||||
className={`absolute bottom-0 right-[22rem] z-[8001] pointer-events-auto hud-zone transition-opacity duration-300 ${tickerOpen ? 'opacity-100' : 'opacity-40 hover:opacity-100'}`}
|
||||
className={`absolute bottom-0 right-[28rem] z-[8001] pointer-events-auto hud-zone transition-opacity duration-300 ${tickerOpen ? 'opacity-100' : 'opacity-40 hover:opacity-100'}`}
|
||||
animate={{ y: tickerOpen ? -28 : 0 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user