mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-19 17:07:22 +02:00
Infer country and military force (PLA, JSDF, ROK, ROC) from ICAO hex address blocks when the flag field is Unknown. Extract and extend aircraft model classification to cover East Asian fighters, cargo, recon, and tanker types with hyphen-normalized matching. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1095 lines
73 KiB
TypeScript
1095 lines
73 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { AlertTriangle, Clock, ChevronDown, ChevronUp } from 'lucide-react';
|
|
import React, { useEffect, useRef, useCallback } from 'react';
|
|
import Hls from 'hls.js';
|
|
import WikiImage from '@/components/WikiImage';
|
|
import type { DashboardData, SelectedEntity, RegionDossier } from "@/types/dashboard";
|
|
|
|
// HLS video player — uses hls.js on Chrome/Firefox, native on Safari
|
|
function HlsVideo({ url, className }: { url: string; className?: string }) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video || !url) return;
|
|
|
|
let hls: Hls | null = null;
|
|
|
|
if (Hls.isSupported()) {
|
|
hls = new Hls({ enableWorker: false, lowLatencyMode: true });
|
|
hls.loadSource(url);
|
|
hls.attachMedia(video);
|
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
// Safari native HLS
|
|
video.src = url;
|
|
}
|
|
|
|
return () => { hls?.destroy(); };
|
|
}, [url]);
|
|
|
|
return (
|
|
<video
|
|
ref={videoRef}
|
|
autoPlay
|
|
muted
|
|
playsInline
|
|
className={className}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Format time from pubish string "Tue, 24 Feb 2026 15:30:00 GMT" to "15:30"
|
|
function formatTime(pubDate: string) {
|
|
try {
|
|
const d = new Date(pubDate);
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
} catch {
|
|
return "00:00";
|
|
}
|
|
}
|
|
|
|
// ICAO type designator → Wikipedia article title
|
|
const AIRCRAFT_WIKI: Record<string, string> = {
|
|
// Boeing widebodies
|
|
B741: 'Boeing 747', B742: 'Boeing 747', B743: 'Boeing 747', B744: 'Boeing 747-400', B748: 'Boeing 747-8',
|
|
B752: 'Boeing 757', B753: 'Boeing 757', B762: 'Boeing 767', B763: 'Boeing 767', B764: 'Boeing 767',
|
|
B772: 'Boeing 777', B773: 'Boeing 777', B77L: 'Boeing 777', B77W: 'Boeing 777', B778: 'Boeing 777X',
|
|
B788: 'Boeing 787 Dreamliner', B789: 'Boeing 787 Dreamliner', B78X: 'Boeing 787 Dreamliner',
|
|
// Boeing narrowbodies
|
|
B712: 'Boeing 717', B731: 'Boeing 737', B732: 'Boeing 737', B733: 'Boeing 737', B734: 'Boeing 737',
|
|
B735: 'Boeing 737', B736: 'Boeing 737', B737: 'Boeing 737', B738: 'Boeing 737 Next Generation',
|
|
B739: 'Boeing 737 Next Generation', B37M: 'Boeing 737 MAX', B38M: 'Boeing 737 MAX', B39M: 'Boeing 737 MAX',
|
|
// Airbus widebodies
|
|
A306: 'Airbus A300', A310: 'Airbus A310', A332: 'Airbus A330', A333: 'Airbus A330', A338: 'Airbus A330neo',
|
|
A339: 'Airbus A330neo', A342: 'Airbus A340', A343: 'Airbus A340', A345: 'Airbus A340', A346: 'Airbus A340',
|
|
A359: 'Airbus A350', A35K: 'Airbus A350', A388: 'Airbus A380',
|
|
// Airbus narrowbodies
|
|
A318: 'Airbus A318', A319: 'Airbus A319', A320: 'Airbus A320', A321: 'Airbus A321',
|
|
A19N: 'Airbus A319neo', A20N: 'Airbus A320neo family', A21N: 'Airbus A321neo',
|
|
// Embraer
|
|
E135: 'Embraer ERJ 145 family', E145: 'Embraer ERJ 145 family', E170: 'Embraer E-Jet family',
|
|
E175: 'Embraer E-Jet family', E190: 'Embraer E-Jet family', E195: 'Embraer E-Jet family',
|
|
E290: 'Embraer E-Jet E2 family', E295: 'Embraer E-Jet E2 family',
|
|
// Bombardier / CRJ
|
|
CRJ1: 'Bombardier CRJ100/200', CRJ2: 'Bombardier CRJ100/200', CRJ7: 'Bombardier CRJ700 series',
|
|
CRJ9: 'Bombardier CRJ700 series', CRJX: 'Bombardier CRJ700 series',
|
|
// Turboprops
|
|
DH8A: 'De Havilland Canada Dash 8', DH8B: 'De Havilland Canada Dash 8',
|
|
DH8C: 'De Havilland Canada Dash 8', DH8D: 'De Havilland Canada Dash 8',
|
|
AT45: 'ATR 42', AT46: 'ATR 42', AT72: 'ATR 72', AT76: 'ATR 72',
|
|
// Bizjets
|
|
C56X: 'Cessna Citation Excel', C680: 'Cessna Citation Sovereign', C750: 'Cessna Citation X',
|
|
CL30: 'Bombardier Challenger 300', CL35: 'Bombardier Challenger 350',
|
|
CL60: 'Bombardier Challenger 600 series', GL5T: 'Bombardier Global 5000',
|
|
GLEX: 'Bombardier Global Express', GLF4: 'Gulfstream IV', GLF5: 'Gulfstream V',
|
|
GLF6: 'Gulfstream G650', G280: 'Gulfstream G280', GA5C: 'Gulfstream G500/G600',
|
|
GA6C: 'Gulfstream G500/G600', LJ35: 'Learjet 35', LJ45: 'Learjet 45', LJ60: 'Learjet 60',
|
|
F900: 'Dassault Falcon 900', FA7X: 'Dassault Falcon 7X', FA8X: 'Dassault Falcon 8X',
|
|
// Military common
|
|
C130: 'Lockheed C-130 Hercules', C17: 'Boeing C-17 Globemaster III',
|
|
KC35: 'Boeing KC-135 Stratotanker', KC46: 'Boeing KC-46 Pegasus', K35R: 'Boeing KC-135 Stratotanker',
|
|
E3CF: 'Boeing E-3 Sentry', E6B: 'Boeing E-6 Mercury', P8: 'Boeing P-8 Poseidon',
|
|
B52H: 'Boeing B-52 Stratofortress', F16: 'General Dynamics F-16 Fighting Falcon',
|
|
F15: 'McDonnell Douglas F-15 Eagle', F18H: 'Boeing F/A-18E/F Super Hornet',
|
|
F35: 'Lockheed Martin F-35 Lightning II', F22: 'Lockheed Martin F-22 Raptor',
|
|
A10: 'Fairchild Republic A-10 Thunderbolt II', V22: 'Bell Boeing V-22 Osprey',
|
|
C5M: 'Lockheed C-5 Galaxy', C2: 'Grumman C-2 Greyhound',
|
|
EUFI: 'Eurofighter Typhoon', RFAL: 'Dassault Rafale', TORN: 'Panavia Tornado',
|
|
// GA
|
|
C172: 'Cessna 172', C182: 'Cessna 182 Skylane', C206: 'Cessna 206', C208: 'Cessna 208 Caravan',
|
|
C210: 'Cessna 210 Centurion', PA28: 'Piper PA-28 Cherokee', PA32: 'Piper PA-32',
|
|
PA46: 'Piper PA-46 Malibu', BE36: 'Beechcraft Bonanza', BE9L: 'Beechcraft King Air',
|
|
BE20: 'Beechcraft Super King Air', B350: 'Beechcraft King Air 350', PC12: 'Pilatus PC-12',
|
|
PC24: 'Pilatus PC-24', TBM7: 'Daher TBM', TBM8: 'Daher TBM', TBM9: 'Daher TBM',
|
|
// Helicopters
|
|
R44: 'Robinson R44', R22: 'Robinson R22', R66: 'Robinson R66',
|
|
B06: 'Bell 206', B407: 'Bell 407', B412: 'Bell 412',
|
|
EC35: 'Airbus Helicopters H135', EC45: 'Airbus Helicopters H145',
|
|
S76: 'Sikorsky S-76', S92: 'Sikorsky S-92',
|
|
// Russian / other
|
|
SU95: 'Sukhoi Superjet 100', AN12: 'Antonov An-12', AN26: 'Antonov An-26',
|
|
IL76: 'Ilyushin Il-76', IL96: 'Ilyushin Il-96',
|
|
A400: 'Airbus A400M Atlas', C295: 'Airbus C-295',
|
|
};
|
|
|
|
// Module-level cache for Wikipedia thumbnails (persists across re-renders)
|
|
const _wikiThumbCache: Record<string, { url: string | null; loading: boolean }> = {};
|
|
|
|
function useAircraftImage(model: string | undefined): { imgUrl: string | null; wikiUrl: string | null; loading: boolean } {
|
|
const [, forceUpdate] = useState(0);
|
|
const wikiTitle = model ? AIRCRAFT_WIKI[model] : undefined;
|
|
const wikiUrl = wikiTitle ? `https://en.wikipedia.org/wiki/${wikiTitle.replace(/ /g, '_')}` : null;
|
|
|
|
useEffect(() => {
|
|
if (!wikiTitle) return;
|
|
const key = wikiTitle;
|
|
if (_wikiThumbCache[key]) return; // Already fetched or in-flight
|
|
_wikiThumbCache[key] = { url: null, loading: true };
|
|
fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(wikiTitle)}`)
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
_wikiThumbCache[key] = { url: d.thumbnail?.source || null, loading: false };
|
|
forceUpdate(n => n + 1);
|
|
})
|
|
.catch(() => {
|
|
_wikiThumbCache[key] = { url: null, loading: false };
|
|
forceUpdate(n => n + 1);
|
|
});
|
|
}, [wikiTitle]);
|
|
|
|
if (!wikiTitle) return { imgUrl: null, wikiUrl: null, loading: false };
|
|
const cached = _wikiThumbCache[wikiTitle];
|
|
return { imgUrl: cached?.url || null, wikiUrl, loading: cached?.loading || false };
|
|
}
|
|
|
|
|
|
// Vessel type → Wikipedia article for generic ships (carriers have their own wiki field)
|
|
const VESSEL_TYPE_WIKI: Record<string, string> = {
|
|
'tanker': 'https://en.wikipedia.org/wiki/Oil_tanker',
|
|
'cargo': 'https://en.wikipedia.org/wiki/Container_ship',
|
|
'passenger': 'https://en.wikipedia.org/wiki/Cruise_ship',
|
|
'yacht': 'https://en.wikipedia.org/wiki/Superyacht',
|
|
'military_vessel': 'https://en.wikipedia.org/wiki/Warship',
|
|
};
|
|
|
|
function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoading }: { data: DashboardData, selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean }) {
|
|
const [isMinimized, setIsMinimized] = useState(false);
|
|
const [expandedIndexes, setExpandedIndexes] = useState<number[]>([]);
|
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
|
|
// Intentionally omitting map click triggers for expanding
|
|
// as we now show a contextual pop-up on the map directly.
|
|
|
|
const toggleExpand = (idx: number) => {
|
|
if (expandedIndexes.includes(idx)) {
|
|
setExpandedIndexes(expandedIndexes.filter(i => i !== idx));
|
|
} else {
|
|
setExpandedIndexes([...expandedIndexes, idx]);
|
|
}
|
|
}
|
|
|
|
const news = data?.news || [];
|
|
|
|
// Determine the selected flight's model for Wikipedia thumbnail lookup
|
|
// (must call hook unconditionally — React rules of hooks)
|
|
const selectedFlightModel = (() => {
|
|
if (!selectedEntity) return undefined;
|
|
const { type, id } = selectedEntity;
|
|
let flight: any = null;
|
|
if (type === 'flight') flight = data?.commercial_flights?.[id as number];
|
|
else if (type === 'private_flight') flight = data?.private_flights?.[id as number];
|
|
else if (type === 'private_jet') flight = data?.private_jets?.[id as number];
|
|
else if (type === 'military_flight') flight = data?.military_flights?.[id as number];
|
|
else if (type === 'tracked_flight') flight = data?.tracked_flights?.[id as number];
|
|
return flight?.model;
|
|
})();
|
|
const { imgUrl: aircraftImgUrl, wikiUrl: aircraftWikiUrl, loading: aircraftImgLoading } = useAircraftImage(selectedFlightModel);
|
|
|
|
// Region Dossier (right-click intelligence)
|
|
if (selectedEntity?.type === 'region_dossier') {
|
|
const d = regionDossier;
|
|
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 border-emerald-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,255,128,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
|
>
|
|
<div className="p-3 border-b border-emerald-500/30 bg-emerald-950/40 flex justify-between items-center">
|
|
<h2 className="text-xs tracking-widest font-bold text-emerald-400">REGION DOSSIER</h2>
|
|
<span className="text-[8px] text-[var(--text-muted)]">
|
|
{selectedEntity.extra ? `${selectedEntity.extra.lat.toFixed(3)}, ${selectedEntity.extra.lng.toFixed(3)}` : ''}
|
|
</span>
|
|
</div>
|
|
{regionDossierLoading ? (
|
|
<div className="p-6 flex items-center justify-center">
|
|
<span className="text-emerald-400 text-[10px] font-mono animate-pulse tracking-widest">COMPILING INTELLIGENCE...</span>
|
|
</div>
|
|
) : d && !d.error ? (
|
|
<div className="p-3 flex flex-col gap-1.5 max-h-[500px] overflow-y-auto styled-scrollbar text-[10px]">
|
|
{/* 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-[var(--text-muted)]">COUNTRY</span><span className="text-[var(--text-primary)] font-bold">{d.country?.name}</span></div>
|
|
{d.country?.official_name && d.country.official_name !== d.country.name && (
|
|
<div className="flex justify-between"><span className="text-[var(--text-muted)]">OFFICIAL</span><span className="text-[var(--text-secondary)] text-right max-w-[180px]">{d.country.official_name}</span></div>
|
|
)}
|
|
<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-[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-[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-[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-[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-[var(--text-secondary)] leading-relaxed">
|
|
<span className="text-emerald-400 font-bold">>_ 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-[var(--text-secondary)] text-[10px]">{d.error}</div>
|
|
) : (
|
|
<div className="p-4 text-red-400 text-[10px]">INTEL UNAVAILABLE</div>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
if (selectedEntity?.type === 'tracked_flight') {
|
|
const flight = data?.tracked_flights?.find((f: any) => f.icao24 === selectedEntity.id);
|
|
if (flight) {
|
|
const callsign = flight.callsign || "UNKNOWN";
|
|
const alertColorMap: Record<string, string> = {
|
|
'#ff1493': 'text-[#ff1493]', pink: 'text-[#ff1493]', red: 'text-red-400', yellow: 'text-yellow-400',
|
|
blue: 'text-blue-400', orange: 'text-orange-400', '#32cd32': 'text-[#32cd32]', purple: 'text-purple-400',
|
|
black: 'text-gray-400', white: 'text-white'
|
|
};
|
|
const alertBorderMap: Record<string, string> = {
|
|
'#ff1493': 'border-[#ff1493]/30', pink: 'border-[#ff1493]/30', red: 'border-red-500/30', yellow: 'border-yellow-500/30',
|
|
blue: 'border-blue-500/30', orange: 'border-orange-500/30', '#32cd32': 'border-[#32cd32]/30', purple: 'border-purple-500/30',
|
|
black: 'border-gray-500/30', white: 'border-[var(--border-primary)]/30'
|
|
};
|
|
const alertBgMap: Record<string, string> = {
|
|
'#ff1493': 'bg-[#ff1493]/10', pink: 'bg-[#ff1493]/10', red: 'bg-red-950/40', yellow: 'bg-yellow-950/40',
|
|
blue: 'bg-blue-950/40', orange: 'bg-orange-950/40', '#32cd32': 'bg-lime-950/40', purple: 'bg-purple-950/40',
|
|
black: 'bg-gray-900/40', white: 'bg-[var(--bg-panel)]'
|
|
};
|
|
const ac = flight.alert_color || 'white';
|
|
const headerColor = alertColorMap[ac] || 'text-white';
|
|
const borderColor = alertBorderMap[ac] || 'border-[var(--border-primary)]/30';
|
|
const bgColor = alertBgMap[ac] || 'bg-[var(--bg-panel)]';
|
|
|
|
const shadowColor = (ac === 'pink' || ac === '#ff1493') ? 'rgba(255,20,147,0.4)'
|
|
: ac === 'red' ? 'rgba(255,32,32,0.2)'
|
|
: ac === 'yellow' ? 'rgba(255,255,0,0.2)'
|
|
: ac === 'blue' ? 'rgba(59,130,246,0.2)'
|
|
: ac === 'orange' ? 'rgba(255,140,0,0.3)'
|
|
: ac === '#32cd32' ? 'rgba(50,205,50,0.2)'
|
|
: ac === 'purple' ? 'rgba(155,89,182,0.2)'
|
|
: 'rgba(255,255,255,0.1)';
|
|
|
|
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' || ac === '#ff1493') ? 'border-[#ff1493]' : ac === 'red' ? 'border-red-800' : ac === 'yellow' ? 'border-yellow-800' : ac === 'blue' ? 'border-blue-800' : ac === 'orange' ? 'border-orange-800' : ac === '#32cd32' ? 'border-lime-800' : ac === 'purple' ? 'border-purple-800' : 'border-[var(--border-secondary)]'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_${shadowColor}] 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-[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-[var(--border-primary)] pb-2">
|
|
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
|
|
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? (() => {
|
|
const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
|
|
const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
|
|
return (
|
|
<a
|
|
href={wikiHref}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className={`text-xs font-bold underline ${headerColor} hover:opacity-80 transition-opacity`}
|
|
title={`Search Wikipedia for ${flight.alert_operator}`}
|
|
>
|
|
{flight.alert_operator}
|
|
</a>
|
|
);
|
|
})() : (
|
|
<span className={`text-xs font-bold ${headerColor}`}>UNKNOWN</span>
|
|
)}
|
|
</div>
|
|
{/* Owner/Operator Wikipedia photo */}
|
|
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (() => {
|
|
const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
|
|
const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
|
|
return (
|
|
<div className="border-b border-[var(--border-primary)] pb-2">
|
|
<WikiImage
|
|
wikiUrl={wikiHref}
|
|
label={flight.alert_operator}
|
|
maxH="max-h-36"
|
|
accent={ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50'}
|
|
/>
|
|
</div>
|
|
);
|
|
})()}
|
|
{/* Aircraft model Wikipedia photo */}
|
|
{aircraftImgUrl && (
|
|
<div className="border-b border-[var(--border-primary)] pb-2">
|
|
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
|
|
<img
|
|
src={aircraftImgUrl}
|
|
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
|
|
className={`w-full h-auto max-h-28 object-cover rounded border border-[var(--border-primary)]/50 ${ac === 'pink' ? 'hover:border-pink-500/50' : 'hover:border-cyan-500/50'} transition-colors`}
|
|
/>
|
|
</a>
|
|
{aircraftWikiUrl && (
|
|
<a href={aircraftWikiUrl} target="_blank" rel="noopener noreferrer"
|
|
className="text-[10px] text-cyan-400 hover:text-cyan-300 underline mt-1 inline-block">
|
|
📖 {AIRCRAFT_WIKI[flight.model] || flight.model} — Wikipedia →
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
|
<span className="text-[var(--text-muted)] text-[10px]">CATEGORY</span>
|
|
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_category || "N/A"}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
|
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT</span>
|
|
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
|
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
|
|
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
|
|
</div>
|
|
{flight.alert_tags && (
|
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
|
<span className="text-[var(--text-muted)] text-[10px]">INTEL TAGS</span>
|
|
<span className={`text-xs font-bold text-right max-w-[200px] ${headerColor}`}>{flight.alert_tags}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
|
<span className="text-[var(--text-muted)] text-[10px]">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-[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-[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-[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-[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-[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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|
|
}
|
|
|
|
if (selectedEntity?.type === 'flight' || selectedEntity?.type === 'military_flight' || selectedEntity?.type === 'private_flight' || selectedEntity?.type === 'private_jet') {
|
|
const flightsList = selectedEntity.type === 'flight' ? data?.commercial_flights
|
|
: selectedEntity.type === 'private_flight' ? data?.private_flights
|
|
: selectedEntity.type === 'private_jet' ? data?.private_jets
|
|
: data?.military_flights;
|
|
const flight = flightsList?.find((f: any) => f.icao24 === selectedEntity.id);
|
|
|
|
if (flight) {
|
|
const callsign = flight.callsign || "UNKNOWN";
|
|
let airline = "UNKNOWN";
|
|
|
|
if (selectedEntity.type === 'military_flight') {
|
|
const mil = flight as import('@/types/dashboard').MilitaryFlight;
|
|
const milCountry = mil.country;
|
|
airline = mil.force
|
|
? `${milCountry} ${mil.force}`.trim()
|
|
: (milCountry && milCountry !== 'Military Asset' && milCountry !== 'Unknown'
|
|
? milCountry : "MILITARY ASSET");
|
|
} else if (selectedEntity.type === 'private_jet') {
|
|
airline = "PRIVATE JET";
|
|
} else if (selectedEntity.type === 'private_flight') {
|
|
airline = "PRIVATE / GA";
|
|
} else if ('airline_code' in flight && flight.airline_code) {
|
|
// Use the airline code resolved from adsb.lol routeset API
|
|
const codeMap: Record<string, string> = {
|
|
"UAL": "UNITED AIRLINES", "DAL": "DELTA AIR LINES", "SWA": "SOUTHWEST AIRLINES",
|
|
"AAL": "AMERICAN AIRLINES", "BAW": "BRITISH AIRWAYS", "AFR": "AIR FRANCE",
|
|
"JBU": "JETBLUE AIRWAYS", "NKS": "SPIRIT AIRLINES", "THY": "TURKISH AIRLINES",
|
|
"UAE": "EMIRATES", "QFA": "QANTAS", "ACA": "AIR CANADA",
|
|
"FFT": "FRONTIER AIRLINES", "WJA": "WESTJET", "RPA": "REPUBLIC AIRWAYS",
|
|
"SKW": "SKYWEST AIRLINES", "ENY": "ENVOY AIR", "ASA": "ALASKA AIRLINES",
|
|
"HAL": "HAWAIIAN AIRLINES", "DLH": "LUFTHANSA", "KLM": "KLM",
|
|
"EZY": "EASYJET", "RYR": "RYANAIR", "SIA": "SINGAPORE AIRLINES",
|
|
"CPA": "CATHAY PACIFIC", "ANA": "ALL NIPPON AIRWAYS", "JAL": "JAPAN AIRLINES",
|
|
"QTR": "QATAR AIRWAYS", "ETD": "ETIHAD AIRWAYS", "SAS": "SAS SCANDINAVIAN"
|
|
};
|
|
airline = codeMap[flight.airline_code] || flight.airline_code;
|
|
} else if (callsign !== "UNKNOWN") {
|
|
airline = "COMMERCIAL FLIGHT";
|
|
}
|
|
|
|
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 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 flex-shrink-0"
|
|
>
|
|
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
|
<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-[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-[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-[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-[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-[var(--border-primary)] pb-3">
|
|
{aircraftImgLoading && (
|
|
<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-[var(--border-primary)]/50 hover:border-cyan-500/50 transition-colors"
|
|
style={{ imageRendering: 'auto' }}
|
|
/>
|
|
</a>
|
|
)}
|
|
{aircraftWikiUrl && (
|
|
<a href={aircraftWikiUrl} target="_blank" rel="noopener noreferrer"
|
|
className="text-[10px] text-cyan-400 hover:text-cyan-300 underline mt-1 inline-block">
|
|
📖 {AIRCRAFT_WIKI[flight.model] || flight.model} — Wikipedia →
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
|
<span className="text-[var(--text-muted)] text-[10px]">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-[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-[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-[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-[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-[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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|
|
}
|
|
|
|
if (selectedEntity?.type === 'ship') {
|
|
const ship = data?.ships?.find((s: any) => s.mmsi === selectedEntity.id);
|
|
if (ship) {
|
|
const vesselTypeLabels: Record<string, string> = {
|
|
'tanker': 'TANKER',
|
|
'cargo': 'CARGO VESSEL',
|
|
'passenger': 'PASSENGER / CRUISE',
|
|
'yacht': 'PRIVATE YACHT',
|
|
'military_vessel': 'MILITARY VESSEL',
|
|
'carrier': 'AIRCRAFT CARRIER',
|
|
};
|
|
const typeLabel = vesselTypeLabels[ship.type] || ship.type?.toUpperCase() || 'VESSEL';
|
|
|
|
const headerColorMap: Record<string, string> = {
|
|
'tanker': 'text-red-400',
|
|
'cargo': 'text-red-400',
|
|
'passenger': 'text-white',
|
|
'yacht': 'text-blue-400',
|
|
'military_vessel': 'text-yellow-400',
|
|
'carrier': 'text-orange-400',
|
|
};
|
|
const headerColor = headerColorMap[ship.type] || 'text-[var(--text-secondary)]';
|
|
|
|
const headerTitleMap: Record<string, string> = {
|
|
'tanker': 'AIS TANKER INTERCEPT',
|
|
'cargo': 'AIS CARGO INTERCEPT',
|
|
'passenger': 'AIS PASSENGER VESSEL',
|
|
'yacht': 'AIS YACHT SIGNAL',
|
|
'military_vessel': 'AIS MILITARY VESSEL',
|
|
'carrier': 'CARRIER STRIKE GROUP',
|
|
};
|
|
const headerTitle = headerTitleMap[ship.type] || 'AIS VESSEL SIGNAL';
|
|
|
|
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 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 flex-shrink-0"
|
|
>
|
|
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
|
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
|
|
{headerTitle}
|
|
</h2>
|
|
<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-[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-[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-[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-[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) > 0 && (
|
|
<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-[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-[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-[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-[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>
|
|
</div>
|
|
)}
|
|
{/* Ship/Carrier Wikipedia photo */}
|
|
{(ship.wiki || VESSEL_TYPE_WIKI[ship.type]) && (
|
|
<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}
|
|
maxH="max-h-32"
|
|
accent={ship.type === 'carrier' ? 'hover:border-orange-500/50' : 'hover:border-cyan-500/50'}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|
|
}
|
|
|
|
if (selectedEntity?.type === 'gdelt') {
|
|
const gdeltItem = data?.gdelt?.find((g: any) => (g.properties?.name || String(g.geometry?.coordinates)) === selectedEntity.id);
|
|
if (gdeltItem && gdeltItem.properties) {
|
|
const props = gdeltItem.properties;
|
|
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 border-orange-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,140,0,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
|
>
|
|
<div className="p-3 border-b border-orange-500/30 bg-orange-950/40 flex justify-between items-center">
|
|
<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-[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-[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-[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-[var(--text-muted)] text-[10px]">LATEST REPORTS:</span>
|
|
<div className="flex flex-col gap-1 max-h-[250px] overflow-y-auto styled-scrollbar">
|
|
{(() => {
|
|
const urls: string[] = props._urls_list || [];
|
|
const headlines: string[] = props._headlines_list || [];
|
|
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[10px]">No articles available.</span>;
|
|
return urls.map((url: string, idx: number) => {
|
|
const headline = headlines[idx] || '';
|
|
let domain = '';
|
|
try { domain = new URL(url).hostname.replace('www.', ''); } catch { domain = ''; }
|
|
return (
|
|
<a
|
|
key={idx}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block py-1.5 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer group"
|
|
>
|
|
<span className="text-orange-400 text-[11px] font-bold leading-tight group-hover:text-orange-300 block">
|
|
{headline || domain || 'View Article'}
|
|
</span>
|
|
{headline && domain && (
|
|
<span className="text-[var(--text-muted)] text-[9px] block mt-0.5">{domain}</span>
|
|
)}
|
|
</a>
|
|
);
|
|
});
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|
|
}
|
|
|
|
if (selectedEntity?.type === 'liveuamap') {
|
|
const item = data?.liveuamap?.find((l: any) => String(l.id) === String(selectedEntity.id));
|
|
if (item) {
|
|
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 border-yellow-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,255,0,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
|
>
|
|
<div className="p-3 border-b border-yellow-500/30 bg-yellow-950/40 flex justify-between items-center">
|
|
<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-[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-[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-[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-[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-[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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|
|
}
|
|
|
|
if (selectedEntity?.type === 'news') {
|
|
const item = data?.news?.[selectedEntity.id as number];
|
|
if (item) {
|
|
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 border-red-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,0,0,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
|
>
|
|
<div className="p-3 border-b border-red-500/30 bg-red-950/40 flex justify-between items-center">
|
|
<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-[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-[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-[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 && (
|
|
<div className="mt-2 p-2 bg-black/60 border border-cyan-800/50 rounded-sm text-[9px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
|
|
<div className="absolute top-0 left-0 w-[2px] h-full bg-cyan-500 animate-pulse"></div>
|
|
<span className="font-bold text-white">>_ SYS.ANALYSIS: </span>
|
|
<span className="text-cyan-300 opacity-90">{item.machine_assessment}</span>
|
|
</div>
|
|
)}
|
|
{item.link && (
|
|
<div className="flex justify-between items-center pb-2 mt-2">
|
|
<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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|
|
}
|
|
|
|
if (selectedEntity?.type === 'airport') {
|
|
const apt = data?.airports?.find((a: any) => String(a.id) === String(selectedEntity.id));
|
|
if (apt) {
|
|
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 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 flex-shrink-0"
|
|
>
|
|
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
|
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
|
AERONAUTICAL HUB
|
|
</h2>
|
|
<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-[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-[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-[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>
|
|
</motion.div>
|
|
)
|
|
}
|
|
}
|
|
|
|
if (selectedEntity?.type === 'cctv') {
|
|
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 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 flex-shrink-0"
|
|
>
|
|
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
|
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
|
<AlertTriangle size={14} className="text-red-400" /> {selectedEntity.extra?.last_updated
|
|
? 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-[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">
|
|
{(() => {
|
|
const url = selectedEntity.media_url || '';
|
|
const mt = selectedEntity.extra?.media_type || (
|
|
url.includes('.mp4') || url.includes('.webm') ? 'video' :
|
|
url.includes('.m3u8') || url.includes('hls') ? 'hls' :
|
|
url.includes('.mjpg') || url.includes('.mjpeg') || url.includes('mjpg') ? 'mjpeg' :
|
|
url.includes('embed') || url.includes('maps/embed') ? 'embed' :
|
|
url.includes('mapbox.com') ? 'satellite' : 'image'
|
|
);
|
|
|
|
if (mt === 'video') return (
|
|
<video
|
|
src={url}
|
|
autoPlay
|
|
loop
|
|
muted
|
|
playsInline
|
|
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
|
/>
|
|
);
|
|
if (mt === 'hls') return (
|
|
<HlsVideo
|
|
url={url}
|
|
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
|
/>
|
|
);
|
|
if (mt === 'embed') return (
|
|
<iframe
|
|
src={url}
|
|
allowFullScreen
|
|
loading="lazy"
|
|
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
|
/>
|
|
);
|
|
if (mt === 'mjpeg') return (
|
|
<img
|
|
src={url}
|
|
alt="MJPEG Feed"
|
|
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
target.src = "https://via.placeholder.com/400x300.png?text=FEED+UNAVAILABLE";
|
|
}}
|
|
/>
|
|
);
|
|
// satellite / image — standard img with referrer policy for external tiles
|
|
return (
|
|
<img
|
|
src={url}
|
|
alt="CCTV Feed"
|
|
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
target.src = "https://via.placeholder.com/400x300.png?text=NO+SIGNAL";
|
|
}}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
{/* Retro UI Overlay for the camera feed */}
|
|
<div className="absolute top-2 left-2 text-[8px] text-cyan-500 bg-black/50 px-1 py-0.5 rounded">
|
|
REC // 00:00:00:00
|
|
</div>
|
|
</div>
|
|
<div className="p-3 bg-black/40 text-[9px] text-cyan-500/70 font-mono tracking-widest flex justify-between items-center">
|
|
<span>{selectedEntity.name?.toUpperCase() || 'UNKNOWN MOUNT'}</span>
|
|
<span className="text-red-500 text-right">
|
|
{selectedEntity.extra?.last_updated
|
|
? 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' })
|
|
: ''}
|
|
</span>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ y: 50, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
transition={{ duration: 0.8, delay: 0.2 }}
|
|
className={`w-full bg-[var(--bg-primary)]/40 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-[var(--border-primary)]/50 relative overflow-hidden cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
|
|
onClick={() => setIsMinimized(!isMinimized)}
|
|
>
|
|
<div className="flex justify-between items-center relative z-10">
|
|
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
|
<AlertTriangle size={14} /> GLOBAL THREAT INTERCEPT
|
|
</h2>
|
|
<button className="text-cyan-500 hover:text-[var(--text-primary)] transition-colors">
|
|
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
|
</button>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{!isMinimized && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
className="text-[9px] text-cyan-500/80 mt-1 flex items-center justify-between font-bold relative z-10"
|
|
>
|
|
<span className="px-1 border border-cyan-500/30">SYS.STATUS: MONITORING</span>
|
|
<span className="flex items-center gap-1"><Clock size={10} /> {data?.last_updated ? formatTime(data.last_updated) : "SCANNING"}</span>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{!isMinimized && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="flex-1 overflow-y-auto p-3 flex flex-col gap-2 styled-scrollbar"
|
|
>
|
|
{news.map((item: any, idx: number) => {
|
|
let bgClass, titleClass, badgeClass;
|
|
if (item.risk_score >= 9) {
|
|
bgClass = "bg-red-950/20 border-red-500/30";
|
|
titleClass = "text-red-300 font-bold";
|
|
badgeClass = "bg-red-500/10 text-red-400 border-red-500/30";
|
|
} else if (item.risk_score >= 7) {
|
|
bgClass = "bg-orange-950/20 border-orange-500/30";
|
|
titleClass = "text-orange-300 font-bold";
|
|
badgeClass = "bg-orange-500/10 text-orange-400 border-orange-500/30";
|
|
} else if (item.risk_score >= 4) {
|
|
bgClass = "bg-yellow-950/20 border-yellow-500/30";
|
|
titleClass = "text-yellow-300 font-bold";
|
|
badgeClass = "bg-yellow-500/10 text-yellow-500 border-yellow-500/30";
|
|
} else {
|
|
bgClass = "bg-green-950/20 border-green-500/30";
|
|
titleClass = "text-green-300 font-medium";
|
|
badgeClass = "bg-green-500/10 text-green-400 border-green-500/30";
|
|
}
|
|
const isExpanded = expandedIndexes.includes(idx);
|
|
|
|
return (
|
|
<motion.div
|
|
key={idx}
|
|
ref={(el) => { itemRefs.current[idx] = el; }}
|
|
initial={idx < 15 ? { opacity: 0, x: -10 } : { opacity: 1, x: 0 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={idx < 15 ? { delay: 0.1 + (idx * 0.05) } : { duration: 0 }}
|
|
className={`p-2 rounded-sm border-l-[2px] border-r border-t border-b ${bgClass} flex flex-col gap-1 relative group shrink-0`}
|
|
>
|
|
<div className="flex items-center justify-between text-[8px] text-[var(--text-secondary)] uppercase tracking-widest">
|
|
<span className="font-bold flex items-center gap-1 text-cyan-600">
|
|
>_ {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-[var(--text-primary)] transition-colors leading-tight`}>
|
|
{item.title}
|
|
</a>
|
|
|
|
{item.machine_assessment && (
|
|
<div className="mt-1 p-1.5 bg-black/60 border border-cyan-800/50 rounded-sm text-[8.5px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
|
|
<div className="absolute top-0 left-0 w-[2px] h-full bg-cyan-500 animate-pulse"></div>
|
|
<span className="font-bold text-white">>_ SYS.ANALYSIS: </span>
|
|
<span className="text-cyan-300 opacity-90">{item.machine_assessment}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between items-end mt-1 relative z-10">
|
|
<span className={`text-[8px] font-bold px-1 rounded-sm border ${badgeClass}`}>
|
|
LVL: {item.risk_score}/10
|
|
</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-[var(--bg-secondary)]/50 hover:text-[var(--text-primary)] hover:bg-[var(--hover-accent)] border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
|
|
{isExpanded ? '[- COLLAPSE]' : `[+${item.cluster_count - 1} SOURCES]`}
|
|
</button>
|
|
)}
|
|
{item.coords && (
|
|
<span className="text-[8px] text-[var(--text-muted)] font-mono tracking-tighter">
|
|
{item.coords[0].toFixed(2)}, {item.coords[1].toFixed(2)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{isExpanded && item.articles && item.articles.length > 1 && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
className="mt-2 pt-2 border-t border-cyan-500/20 flex flex-col gap-2 overflow-hidden"
|
|
>
|
|
{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-[var(--text-muted)] uppercase font-bold">
|
|
<span>>_ {subItem.source}</span>
|
|
<span className={
|
|
subItem.risk_score >= 9 ? 'text-red-400' :
|
|
subItem.risk_score >= 7 ? 'text-orange-400' :
|
|
subItem.risk_score >= 4 ? 'text-yellow-500' :
|
|
'text-green-400'
|
|
}>LVL: {subItem.risk_score}/10</span>
|
|
</div>
|
|
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors leading-tight">
|
|
{subItem.title}
|
|
</a>
|
|
</div>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
)
|
|
})}
|
|
{news.length === 0 && (
|
|
<div className="text-cyan-500/50 text-[10px] tracking-widest font-bold text-center mt-6 animate-pulse">
|
|
INITIALIZING SECURE HANDSHAKE...
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
const NewsFeed = React.memo(NewsFeedInner);
|
|
export default NewsFeed;
|