mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-09 07:43:59 +02:00
fix: address Hacker News feedback (connection banner, API keys, etc.) and bump version to v0.3.0
Former-commit-id: db1722a99e3ea7fd8afb296f186f57799bf4b695
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
#0 building with "desktop-linux" instance using docker driver
|
||||
|
||||
#1 [internal] load build definition from Dockerfile
|
||||
#1 transferring dockerfile: 838B 0.0s done
|
||||
#1 DONE 0.0s
|
||||
|
||||
#2 [auth] library/node:pull token for registry-1.docker.io
|
||||
#2 DONE 0.0s
|
||||
|
||||
#3 [internal] load metadata for docker.io/library/node:18-alpine
|
||||
#3 DONE 0.6s
|
||||
|
||||
#4 [internal] load .dockerignore
|
||||
#4 transferring context: 207B 0.0s done
|
||||
#4 DONE 0.0s
|
||||
|
||||
#5 [base 1/1] FROM docker.io/library/node:18-alpine@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e
|
||||
#5 resolve docker.io/library/node:18-alpine@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e 0.0s done
|
||||
#5 DONE 0.0s
|
||||
|
||||
#6 [runner 2/8] RUN addgroup --system --gid 1001 nodejs
|
||||
#6 CACHED
|
||||
|
||||
#7 [builder 1/4] WORKDIR /app
|
||||
#7 CACHED
|
||||
|
||||
#8 [runner 3/8] RUN adduser --system --uid 1001 nextjs
|
||||
#8 CACHED
|
||||
|
||||
#9 [internal] load build context
|
||||
#9 transferring context: 3.49kB done
|
||||
#9 DONE 0.0s
|
||||
|
||||
#10 [deps 1/4] RUN apk add --no-cache libc6-compat
|
||||
#10 CACHED
|
||||
|
||||
#11 [deps 2/4] WORKDIR /app
|
||||
#11 CACHED
|
||||
|
||||
#12 [deps 3/4] COPY package*.json ./
|
||||
#12 CACHED
|
||||
|
||||
#13 [deps 4/4] RUN npm ci
|
||||
#13 CACHED
|
||||
|
||||
#14 [builder 2/4] COPY --from=deps /app/node_modules ./node_modules
|
||||
#14 CACHED
|
||||
|
||||
#15 [builder 3/4] COPY . .
|
||||
#15 DONE 0.0s
|
||||
|
||||
#16 [builder 4/4] RUN npm run build
|
||||
#16 1.391
|
||||
#16 1.391 > frontend@0.2.0 build
|
||||
#16 1.391 > next build
|
||||
#16 1.391
|
||||
#16 1.821 You are using Node.js 18.20.8. For Next.js, Node.js version ">=20.9.0" is required.
|
||||
#16 1.837 npm notice
|
||||
#16 1.837 npm notice New major version of npm available! 10.8.2 -> 11.11.0
|
||||
#16 1.837 npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.11.0
|
||||
#16 1.837 npm notice To update run: npm install -g npm@11.11.0
|
||||
#16 1.837 npm notice
|
||||
#16 ERROR: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
|
||||
------
|
||||
> [builder 4/4] RUN npm run build:
|
||||
1.391
|
||||
1.391 > frontend@0.2.0 build
|
||||
1.391 > next build
|
||||
1.391
|
||||
1.821 You are using Node.js 18.20.8. For Next.js, Node.js version ">=20.9.0" is required.
|
||||
1.837 npm notice
|
||||
1.837 npm notice New major version of npm available! 10.8.2 -> 11.11.0
|
||||
1.837 npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.11.0
|
||||
1.837 npm notice To update run: npm install -g npm@11.11.0
|
||||
1.837 npm notice
|
||||
------
|
||||
|
||||
5 warnings found (use docker --debug to expand):
|
||||
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 13)
|
||||
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 18)
|
||||
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 19)
|
||||
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 35)
|
||||
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 36)
|
||||
Dockerfile:14
|
||||
--------------------
|
||||
12 | COPY . .
|
||||
13 | ENV NEXT_TELEMETRY_DISABLED 1
|
||||
14 | >>> RUN npm run build
|
||||
15 |
|
||||
16 | FROM base AS runner
|
||||
--------------------
|
||||
ERROR: failed to build: failed to solve: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
|
||||
@@ -12,17 +12,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/mapbox-gl": "^3.4.1",
|
||||
"framer-motion": "^12.34.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mapbox-gl": "^3.19.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"satellite.js": "^6.0.2"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import SettingsPanel from "@/components/SettingsPanel";
|
||||
import MapLegend from "@/components/MapLegend";
|
||||
import ScaleBar from "@/components/ScaleBar";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import OnboardingModal, { useOnboarding } from "@/components/OnboardingModal";
|
||||
|
||||
// Use dynamic loads for Maplibre to avoid SSR window is not defined errors
|
||||
const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ssr: false });
|
||||
@@ -75,6 +76,10 @@ export default function Dashboard() {
|
||||
// Mouse coordinate + reverse geocoding state
|
||||
const [mouseCoords, setMouseCoords] = useState<{ lat: number, lng: number } | null>(null);
|
||||
const [locationLabel, setLocationLabel] = useState('');
|
||||
|
||||
// Onboarding & connection status
|
||||
const { showOnboarding, setShowOnboarding } = useOnboarding();
|
||||
const [backendStatus, setBackendStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
|
||||
const geocodeCache = useRef<Map<string, string>>(new Map());
|
||||
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -177,8 +182,9 @@ export default function Dashboard() {
|
||||
const headers: Record<string, string> = {};
|
||||
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers });
|
||||
if (res.status === 304) return; // Data unchanged, skip update
|
||||
if (res.status === 304) { setBackendStatus('connected'); return; }
|
||||
if (res.ok) {
|
||||
setBackendStatus('connected');
|
||||
fastEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
@@ -186,6 +192,7 @@ export default function Dashboard() {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching fast live data", e);
|
||||
setBackendStatus('disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -426,6 +433,23 @@ export default function Dashboard() {
|
||||
{/* MAP LEGEND */}
|
||||
<MapLegend isOpen={legendOpen} onClose={() => setLegendOpen(false)} />
|
||||
|
||||
{/* ONBOARDING MODAL */}
|
||||
{showOnboarding && (
|
||||
<OnboardingModal
|
||||
onClose={() => setShowOnboarding(false)}
|
||||
onOpenSettings={() => { setShowOnboarding(false); setSettingsOpen(true); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<span className="text-[10px] font-mono tracking-widest text-red-400">
|
||||
BACKEND OFFLINE — Cannot reach {API_BASE}. Start the backend server or check your connection.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap, Tooltip, CircleMarker, useMapEvents } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Fix standard leaflet icon path issues in React
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
// Create custom icons dynamically for the layers
|
||||
const createDivIcon = (svg: string, size = 16, rotate = 0) => {
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style="transform: rotate(${rotate}deg); width: ${size}px; height: ${size}px;">${svg}</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2]
|
||||
});
|
||||
};
|
||||
|
||||
const svgPlaneCyan = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#00d4ff"><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 svgPlaneOrange = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffaa00"><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 svgPlaneRed = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff3333"><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 svgShip = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#888888"><path d="M12 22V8" /><path d="M5 12H19" /><path d="M9 22H15" /><circle cx="12" cy="5" r="3" /><path d="M12 22C8 22 4 19 4 15V13M12 22C16 22 20 19 20 15V13" /></svg>`;
|
||||
const svgThreat = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffff00" stroke="#ff0000" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>`;
|
||||
const svgTriangleYellow = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffaa00" stroke="#000" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||
const svgTriangleRed = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff0000" stroke="#fff" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||
|
||||
// Helper component to center map when Find Locater is used
|
||||
function MapCenterControl({ location }: { location: { lat: number, lng: number } | null }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (location) {
|
||||
map.flyTo([location.lat, location.lng], 8, { duration: 1.5 });
|
||||
}
|
||||
}, [location, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Eavesdrop mode controller
|
||||
function ClickHandler({ isEavesdropping, onEavesdropClick }: any) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (!isEavesdropping) return;
|
||||
const cb = (e: any) => onEavesdropClick({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
map.on('click', cb);
|
||||
return () => { map.off('click', cb); };
|
||||
}, [isEavesdropping, map, onEavesdropClick]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map state tracker for LOD
|
||||
function MapStateTracker({ onStateChange }: { onStateChange: (zoom: number, bounds: L.LatLngBounds) => void }) {
|
||||
const map = useMapEvents({
|
||||
moveend: () => onStateChange(map.getZoom(), map.getBounds()),
|
||||
zoomend: () => onStateChange(map.getZoom(), map.getBounds()),
|
||||
});
|
||||
useEffect(() => {
|
||||
onStateChange(map.getZoom(), map.getBounds());
|
||||
}, [map, onStateChange]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LeafletViewer({ data, activeLayers, activeFilters, effects, onEntityClick, selectedEntity, flyToLocation, isEavesdropping, onEavesdropClick, onCameraMove }: any) {
|
||||
const [zoom, setZoom] = useState(3);
|
||||
const [bounds, setBounds] = useState<L.LatLngBounds | null>(null);
|
||||
|
||||
const handleMapState = (z: number, b: L.LatLngBounds) => {
|
||||
setZoom(z);
|
||||
setBounds(b);
|
||||
};
|
||||
|
||||
const isVisible = (lat: number, lng: number) => {
|
||||
if (!bounds) return true;
|
||||
return bounds.pad(0.2).contains([lat, lng]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: "100vw", height: "100vh", position: "fixed", top: 0, left: 0, zIndex: 0, background: "black" }}>
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={3}
|
||||
style={{ width: "100%", height: "100%", background: "#06080a" }} // Extremely dark ocean base
|
||||
zoomControl={false}
|
||||
minZoom={2}
|
||||
maxZoom={12}
|
||||
>
|
||||
<MapStateTracker onStateChange={handleMapState} />
|
||||
<MapCenterControl location={flyToLocation} />
|
||||
<ClickHandler isEavesdropping={isEavesdropping} onEavesdropClick={onEavesdropClick} />
|
||||
|
||||
{/* Dark Mode Satellite Stamen / CartoDB Voyager basemap substitute */}
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
maxZoom={19}
|
||||
/>
|
||||
|
||||
{/* --- COMMERCIAL FLIGHTS --- */}
|
||||
{activeLayers.flights && data?.commercial_flights?.map((f: any, idx: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`comm-${idx}`}
|
||||
center={[f.lat, f.lng]}
|
||||
radius={2}
|
||||
pathOptions={{ color: '#00d4ff', fillColor: '#00d4ff', fillOpacity: 0.8, weight: 1, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgPlaneCyan, 18, f.true_track || f.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`comm-${idx}`}
|
||||
position={[f.lat, f.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||
<div className="text-cyan-400 font-bold bg-black px-1 text-xs border border-cyan-500/50">{f.callsign || f.icao24}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- PRIVATE FLIGHTS --- */}
|
||||
{activeLayers.private && data?.private_flights?.map((f: any, idx: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`priv-${idx}`}
|
||||
center={[f.lat, f.lng]}
|
||||
radius={2}
|
||||
pathOptions={{ color: '#ffaa00', fillColor: '#ffaa00', fillOpacity: 0.8, weight: 1, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'private_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgPlaneOrange, 18, f.true_track || f.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`priv-${idx}`}
|
||||
position={[f.lat, f.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'private_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||
<div className="text-orange-400 font-bold bg-black px-1 text-xs border border-orange-500/50">{f.callsign || f.icao24}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- MILITARY FLIGHTS --- */}
|
||||
{activeLayers.military && data?.military_flights?.map((f: any, idx: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`mil-${idx}`}
|
||||
center={[f.lat, f.lng]}
|
||||
radius={3}
|
||||
pathOptions={{ color: '#ff3333', fillColor: '#ff3333', fillOpacity: 0.9, weight: 1, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'military_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgPlaneRed, 20, f.true_track || f.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`mil-${idx}`}
|
||||
position={[f.lat, f.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'military_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||
<div className="text-red-500 font-bold bg-black px-1 text-xs border border-red-500/50">{f.callsign || f.icao24}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- SHIPS --- */}
|
||||
{(activeLayers.ships_important || activeLayers.ships_civilian || activeLayers.ships_passenger) && data?.ships?.map((s: any, idx: number) => {
|
||||
if (s.lat == null || s.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(s.lat, s.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`ship-${idx}`}
|
||||
center={[s.lat, s.lng]}
|
||||
radius={1.5}
|
||||
pathOptions={{ color: '#888888', fillColor: '#888888', fillOpacity: 0.6, weight: 0.5, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'ship', id: idx, name: s.name }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgShip, 12, s.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`ship-${idx}`}
|
||||
position={[s.lat, s.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'ship', id: idx, name: s.name }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -5]} opacity={0.8}>
|
||||
<div className="text-gray-300 font-bold bg-black px-1 text-[10px] border border-gray-600/50">{s.name}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- GDELT GLOBAL INCIDENTS --- */}
|
||||
{activeLayers.global_incidents && data?.gdelt?.map((incident: any, idx: number) => {
|
||||
const geom = incident.geometry;
|
||||
if (!geom || geom.type !== 'Point' || !geom.coordinates) return null;
|
||||
const lng = geom.coordinates[0];
|
||||
const lat = geom.coordinates[1];
|
||||
if (!isVisible(lat, lng)) return null;
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`gdelt-${idx}`}
|
||||
center={[geom.coordinates[1], geom.coordinates[0]]}
|
||||
radius={8}
|
||||
pathOptions={{ color: '#ff0000', fillColor: '#ff8c00', fillOpacity: 0.6, weight: 2 }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'gdelt', id: idx }) }}
|
||||
>
|
||||
<Tooltip>
|
||||
<div className="text-orange-500 text-xs bg-black p-1 max-w-[200px] whitespace-normal">
|
||||
{incident.title}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- LIVEUAMAP INCIDENTS --- */}
|
||||
{activeLayers.global_incidents && data?.liveuamap?.map((incident: any, idx: number) => {
|
||||
if (incident.lat == null || incident.lng == null) return null;
|
||||
if (!isVisible(incident.lat, incident.lng)) return null;
|
||||
const isViolent = /bomb|missil|strike|attack|kill|destroy|fire|shoot|expl|raid/i.test(incident.title || "");
|
||||
const icon = createDivIcon(isViolent ? svgTriangleRed : svgTriangleYellow, 18);
|
||||
return (
|
||||
<Marker
|
||||
key={`liveua-${idx}`}
|
||||
position={[incident.lat, incident.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'liveuamap', id: incident.id, title: incident.title }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.95}>
|
||||
<div className="text-white font-bold bg-black p-1 text-[11px] border border-gray-600 max-w-[200px] whitespace-normal">
|
||||
<span className={isViolent ? "text-red-500" : "text-yellow-500"}>[LIVEUA]</span> {incident.title}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- RSS THREAT ALERTS --- */}
|
||||
{activeLayers.global_incidents && data?.news?.filter((n: any) => n.coordinates)?.map((n: any, idx: number) => {
|
||||
if (n.coordinates.lat == null || n.coordinates.lng == null) return null;
|
||||
if (!isVisible(n.coordinates.lat, n.coordinates.lng)) return null;
|
||||
const icon = createDivIcon(svgThreat, 24);
|
||||
return (
|
||||
<Marker
|
||||
key={`threat-${idx}`}
|
||||
position={[n.coordinates.lat, n.coordinates.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'news', id: idx }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -12]} opacity={1.0} permanent={true} className="bg-transparent border-0 shadow-none">
|
||||
<div className="text-red-500 font-bold bg-black/80 px-2 py-1 text-[10px] border border-red-500/50 backdrop-blur" style={{ textShadow: "0px 0px 4px #000" }}>
|
||||
!! LVL {n.threat_level} !!<br />
|
||||
<span className="text-yellow-400 font-normal">{n.title.substring(0, 30)}...</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio } from "lucide-react";
|
||||
|
||||
const STORAGE_KEY = "shadowbroker_onboarding_complete";
|
||||
|
||||
const API_GUIDES = [
|
||||
{
|
||||
name: "OpenSky Network",
|
||||
icon: <Radar size={14} className="text-cyan-400" />,
|
||||
required: true,
|
||||
description: "Flight tracking with global ADS-B coverage. Provides real-time aircraft positions.",
|
||||
steps: [
|
||||
"Create a free account at opensky-network.org",
|
||||
"Go to Dashboard → OAuth → Create Client",
|
||||
"Copy your Client ID and Client Secret",
|
||||
"Paste both into Settings → Aviation",
|
||||
],
|
||||
url: "https://opensky-network.org/index.php?option=com_users&view=registration",
|
||||
color: "cyan",
|
||||
},
|
||||
{
|
||||
name: "AIS Stream",
|
||||
icon: <Ship size={14} className="text-blue-400" />,
|
||||
required: true,
|
||||
description: "Real-time vessel tracking via AIS (Automatic Identification System).",
|
||||
steps: [
|
||||
"Register at aisstream.io",
|
||||
"Navigate to your API Keys page",
|
||||
"Generate a new API key",
|
||||
"Paste it into Settings → Maritime",
|
||||
],
|
||||
url: "https://aisstream.io/authenticate",
|
||||
color: "blue",
|
||||
},
|
||||
];
|
||||
|
||||
const FREE_SOURCES = [
|
||||
{ name: "ADS-B Exchange", desc: "Military & general aviation", icon: <Radar size={12} /> },
|
||||
{ name: "USGS Earthquakes", desc: "Global seismic data", icon: <Globe size={12} /> },
|
||||
{ name: "CelesTrak", desc: "2,000+ satellite orbits", icon: <Satellite size={12} /> },
|
||||
{ name: "GDELT Project", desc: "Global conflict events", icon: <Globe size={12} /> },
|
||||
{ name: "RainViewer", desc: "Weather radar overlay", icon: <Globe size={12} /> },
|
||||
{ name: "OpenMHz", desc: "Radio scanner feeds", icon: <Radio size={12} /> },
|
||||
{ name: "RSS Feeds", desc: "NPR, BBC, Reuters, AP", icon: <Globe size={12} /> },
|
||||
{ name: "Yahoo Finance", desc: "Defense stocks & oil", icon: <Globe size={12} /> },
|
||||
];
|
||||
|
||||
interface OnboardingModalProps {
|
||||
onClose: () => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSettings }: OnboardingModalProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
onClose();
|
||||
onOpenSettings();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[10000]"
|
||||
onClick={handleDismiss}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
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-[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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4 border-b border-gray-800/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>
|
||||
</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"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Indicators */}
|
||||
<div className="flex gap-2 px-6 pt-4">
|
||||
{["Welcome", "API Keys", "Free Sources"].map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setStep(i)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{label.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-6">
|
||||
{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">
|
||||
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">
|
||||
Real-time OSINT dashboard aggregating 12+ live intelligence sources.
|
||||
Flights, ships, satellites, earthquakes, conflicts, and more — all on one map.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-950/20 border border-yellow-500/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-950/20 border border-green-500/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
{API_GUIDES.map((api) => (
|
||||
<div key={api.name} className={`rounded-lg border border-${api.color}-900/30 bg-${api.color}-950/10 p-4`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{api.icon}
|
||||
<span className="text-xs font-mono text-white font-bold">{api.name}</span>
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">REQUIRED</span>
|
||||
</div>
|
||||
<a
|
||||
href={api.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`text-[10px] font-mono text-${api.color}-400 hover:text-${api.color}-300 flex items-center gap-1 transition-colors`}
|
||||
>
|
||||
GET KEY <ExternalLink size={10} />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 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">
|
||||
<span className={`text-[9px] font-mono text-${api.color}-500 font-bold mt-0.5 w-3 flex-shrink-0`}>{i + 1}.</span>
|
||||
<span className="text-[10px] text-gray-300 font-mono">{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handleOpenSettings}
|
||||
className="w-full py-3 rounded-lg bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/20 transition-colors text-[11px] font-mono tracking-widest flex items-center justify-center gap-2"
|
||||
>
|
||||
<Key size={14} />
|
||||
OPEN SETTINGS TO ENTER KEYS
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] text-gray-400 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 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>
|
||||
</div>
|
||||
<p className="text-[9px] text-gray-500 font-mono">{src.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-800/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"
|
||||
}`}
|
||||
disabled={step === 0}
|
||||
>
|
||||
PREV
|
||||
</button>
|
||||
|
||||
<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>
|
||||
|
||||
{step < 2 ? (
|
||||
<button
|
||||
onClick={() => setStep(step + 1)}
|
||||
className="px-4 py-2 rounded border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/10 text-[10px] font-mono tracking-widest transition-all"
|
||||
>
|
||||
NEXT
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-4 py-2 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 text-[10px] font-mono tracking-widest transition-all"
|
||||
>
|
||||
LAUNCH
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export function useOnboarding() {
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const done = localStorage.getItem(STORAGE_KEY);
|
||||
if (!done) {
|
||||
setShowOnboarding(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { showOnboarding, setShowOnboarding };
|
||||
}
|
||||
|
||||
export default OnboardingModal;
|
||||
@@ -3,7 +3,7 @@
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Settings, Eye, EyeOff, Copy, Check, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
interface ApiEntry {
|
||||
id: string;
|
||||
@@ -15,7 +15,7 @@ interface ApiEntry {
|
||||
has_key: boolean;
|
||||
env_key: string | null;
|
||||
value_obfuscated: string | null;
|
||||
value_plain: string | null;
|
||||
is_set: boolean;
|
||||
}
|
||||
|
||||
// Category colors for the tactical UI
|
||||
@@ -33,8 +33,6 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
|
||||
const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
const [apis, setApis] = useState<ApiEntry[]>([]);
|
||||
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -56,28 +54,9 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
||||
if (isOpen) fetchKeys();
|
||||
}, [isOpen, fetchKeys]);
|
||||
|
||||
const toggleReveal = (id: string) => {
|
||||
setRevealedKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = async (id: string, value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
} catch {
|
||||
// Clipboard API may fail in some contexts
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = (api: ApiEntry) => {
|
||||
setEditingId(api.id);
|
||||
setEditValue(api.value_plain || "");
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
const saveKey = async (api: ApiEntry) => {
|
||||
@@ -209,9 +188,15 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{api.has_key ? (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">
|
||||
KEY SET
|
||||
</span>
|
||||
api.is_set ? (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">
|
||||
KEY SET
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">
|
||||
MISSING
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-gray-700 text-gray-500">
|
||||
PUBLIC
|
||||
@@ -272,34 +257,10 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
||||
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"
|
||||
onClick={() => startEditing(api)}
|
||||
>
|
||||
<span className={revealedKeys.has(api.id) ? "text-cyan-300" : "text-gray-500 tracking-wider"}>
|
||||
{revealedKeys.has(api.id) ? api.value_plain : api.value_obfuscated}
|
||||
<span className="text-gray-500 tracking-wider">
|
||||
{api.is_set ? api.value_obfuscated : "Click to set key..."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Eye Toggle */}
|
||||
<button
|
||||
onClick={() => toggleReveal(api.id)}
|
||||
className={`w-7 h-7 rounded flex items-center justify-center border transition-all ${revealedKeys.has(api.id)
|
||||
? "border-cyan-500/40 text-cyan-400 bg-cyan-950/30"
|
||||
: "border-gray-700 text-gray-600 hover:text-gray-400 hover:border-gray-600"
|
||||
}`}
|
||||
title={revealedKeys.has(api.id) ? "Hide key" : "Reveal key"}
|
||||
>
|
||||
{revealedKeys.has(api.id) ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
|
||||
{/* Copy */}
|
||||
<button
|
||||
onClick={() => copyToClipboard(api.id, api.value_plain || "")}
|
||||
className={`w-7 h-7 rounded flex items-center justify-center border transition-all ${copiedId === api.id
|
||||
? "border-green-500/40 text-green-400 bg-green-950/30"
|
||||
: "border-gray-700 text-gray-600 hover:text-gray-400 hover:border-gray-600"
|
||||
}`}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedId === api.id ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user