mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-06 20:37:56 +02:00
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, X, Check, GripHorizontal } from 'lucide-react';
|
||||
|
||||
interface FilterField {
|
||||
key: string;
|
||||
label: string;
|
||||
options: string[];
|
||||
optionLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface AdvancedFilterModalProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
accentColor: string; // CSS color string e.g. '#00bcd4'
|
||||
accentColorName: string; // tailwind name e.g. 'cyan'
|
||||
fields: FilterField[];
|
||||
activeFilters: Record<string, string[]>;
|
||||
onApply: (filters: Record<string, string[]>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AdvancedFilterModal({
|
||||
title, icon, accentColor, accentColorName, fields, activeFilters, onApply, onClose
|
||||
}: AdvancedFilterModalProps) {
|
||||
// Local draft state — only committed on Apply
|
||||
const [draft, setDraft] = useState<Record<string, Set<string>>>(() => {
|
||||
const init: Record<string, Set<string>> = {};
|
||||
for (const field of fields) {
|
||||
init[field.key] = new Set(activeFilters[field.key] || []);
|
||||
}
|
||||
return init;
|
||||
});
|
||||
|
||||
const [searchTerms, setSearchTerms] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
for (const field of fields) init[field.key] = '';
|
||||
return init;
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState(fields[0]?.key || '');
|
||||
|
||||
// Dragging state
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef({ x: 0, y: 0, posX: 0, posY: 0 });
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Center on mount
|
||||
useEffect(() => {
|
||||
if (modalRef.current) {
|
||||
const rect = modalRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
x: (window.innerWidth - rect.width) / 2,
|
||||
y: (window.innerHeight - rect.height) / 2
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY, posX: position.x, posY: position.y };
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handleMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - dragStartRef.current.x;
|
||||
const dy = e.clientY - dragStartRef.current.y;
|
||||
setPosition({
|
||||
x: dragStartRef.current.posX + dx,
|
||||
y: dragStartRef.current.posY + dy
|
||||
});
|
||||
};
|
||||
const handleUp = () => setIsDragging(false);
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
window.addEventListener('mouseup', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
window.removeEventListener('mouseup', handleUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const toggleItem = (fieldKey: string, value: string) => {
|
||||
setDraft(prev => {
|
||||
const next = { ...prev };
|
||||
const s = new Set(prev[fieldKey]);
|
||||
if (s.has(value)) s.delete(value);
|
||||
else s.add(value);
|
||||
next[fieldKey] = s;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeChip = (fieldKey: string, value: string) => {
|
||||
setDraft(prev => {
|
||||
const next = { ...prev };
|
||||
const s = new Set(prev[fieldKey]);
|
||||
s.delete(value);
|
||||
next[fieldKey] = s;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearField = (fieldKey: string) => {
|
||||
setDraft(prev => ({ ...prev, [fieldKey]: new Set<string>() }));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
const cleared: Record<string, Set<string>> = {};
|
||||
for (const f of fields) cleared[f.key] = new Set<string>();
|
||||
setDraft(cleared);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [key, set] of Object.entries(draft)) {
|
||||
if (set.size > 0) result[key] = Array.from(set);
|
||||
}
|
||||
onApply(result);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const totalSelected = Object.values(draft).reduce((acc, s) => acc + s.size, 0);
|
||||
|
||||
const activeField = fields.find(f => f.key === activeTab);
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!activeField) return [];
|
||||
const term = (searchTerms[activeTab] || '').toLowerCase();
|
||||
const opts = activeField.options;
|
||||
if (!term) return opts;
|
||||
return opts.filter(o => {
|
||||
const displayLabel = activeField.optionLabels?.[o] || o;
|
||||
return displayLabel.toLowerCase().includes(term);
|
||||
});
|
||||
}, [activeField, activeTab, searchTerms]);
|
||||
|
||||
const accentBorder = `border-[${accentColor}]/30`;
|
||||
|
||||
// Tailwind color map for dynamic classes
|
||||
const colorMap: Record<string, { text: string; bg: string; bgHover: string; border: string; ring: string }> = {
|
||||
cyan: { text: 'text-cyan-400', bg: 'bg-cyan-500/10', bgHover: 'hover:bg-cyan-500/15', border: 'border-cyan-500/30', ring: 'ring-cyan-500/50' },
|
||||
orange: { text: 'text-orange-400', bg: 'bg-orange-500/10', bgHover: 'hover:bg-orange-500/15', border: 'border-orange-500/30', ring: 'ring-orange-500/50' },
|
||||
yellow: { text: 'text-yellow-400', bg: 'bg-yellow-500/10', bgHover: 'hover:bg-yellow-500/15', border: 'border-yellow-500/30', ring: 'ring-yellow-500/50' },
|
||||
pink: { text: 'text-pink-400', bg: 'bg-pink-500/10', bgHover: 'hover:bg-pink-500/15', border: 'border-pink-500/30', ring: 'ring-pink-500/50' },
|
||||
blue: { text: 'text-blue-400', bg: 'bg-blue-500/10', bgHover: 'hover:bg-blue-500/15', border: 'border-blue-500/30', ring: 'ring-blue-500/50' },
|
||||
};
|
||||
const c = colorMap[accentColorName] || colorMap.cyan;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] pointer-events-auto" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" />
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: 480,
|
||||
userSelect: isDragging ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.92 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`bg-[#0a0e14]/95 backdrop-blur-xl border ${c.border} rounded-xl shadow-[0_8px_60px_rgba(0,0,0,0.8)] flex flex-col font-mono overflow-hidden`}
|
||||
style={{ maxHeight: '70vh' }}
|
||||
>
|
||||
{/* ── Title Bar (Draggable) ── */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 cursor-grab active:cursor-grabbing border-b border-gray-800/60 select-none flex-shrink-0"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<GripHorizontal size={14} className="text-gray-600" />
|
||||
{icon}
|
||||
<span className={`text-[11px] ${c.text} tracking-[0.25em] font-semibold`}>{title}</span>
|
||||
{totalSelected > 0 && (
|
||||
<span className={`text-[9px] ${c.bg} ${c.text} px-1.5 py-0.5 rounded-sm`}>
|
||||
{totalSelected} SELECTED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-600 hover:text-white transition-colors p-1 rounded hover:bg-gray-800">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Tab Bar (for multi-field categories) ── */}
|
||||
{fields.length > 1 && (
|
||||
<div className="flex border-b border-gray-800/40 px-3 pt-2 gap-1 flex-shrink-0">
|
||||
{fields.map(field => {
|
||||
const isActive = activeTab === field.key;
|
||||
const count = draft[field.key]?.size || 0;
|
||||
return (
|
||||
<button
|
||||
key={field.key}
|
||||
onClick={() => setActiveTab(field.key)}
|
||||
className={`px-3 py-1.5 text-[9px] tracking-widest rounded-t transition-colors relative ${isActive
|
||||
? `${c.bg} ${c.text} border border-b-0 ${c.border}`
|
||||
: 'text-gray-500 hover:text-gray-300 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{field.label}
|
||||
{count > 0 && (
|
||||
<span className={`ml-1.5 text-[8px] ${c.text} bg-black/40 px-1 rounded`}>{count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Selected Chips ── */}
|
||||
{activeField && draft[activeTab]?.size > 0 && (
|
||||
<div className="px-4 pt-3 pb-1 flex flex-wrap gap-1.5 flex-shrink-0 max-h-20 overflow-y-auto styled-scrollbar">
|
||||
{Array.from(draft[activeTab]).map(val => {
|
||||
const displayVal = activeField.optionLabels?.[val] || val;
|
||||
return (
|
||||
<span
|
||||
key={val}
|
||||
className={`inline-flex items-center gap-1 text-[9px] ${c.bg} ${c.text} border ${c.border} rounded-full px-2 py-0.5 group`}
|
||||
>
|
||||
{displayVal.length > 28 ? displayVal.slice(0, 28) + '…' : displayVal}
|
||||
<button
|
||||
onClick={() => removeChip(activeTab, val)}
|
||||
className="opacity-50 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={8} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => clearField(activeTab)}
|
||||
className="text-[8px] text-red-400/70 hover:text-red-300 tracking-widest ml-1"
|
||||
>
|
||||
CLEAR
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Search Bar ── */}
|
||||
<div className="px-4 pt-3 pb-2 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-600" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerms[activeTab] || ''}
|
||||
onChange={(e) => setSearchTerms(prev => ({ ...prev, [activeTab]: e.target.value }))}
|
||||
placeholder={`Search ${activeField?.label.toLowerCase() || ''}...`}
|
||||
className={`w-full bg-black/50 border border-gray-700/70 rounded-lg text-[11px] text-gray-300 pl-8 pr-8 py-2 font-mono tracking-wide focus:outline-none focus:${c.border} focus:ring-1 ${c.ring} placeholder-gray-600 transition-all`}
|
||||
autoFocus
|
||||
/>
|
||||
{searchTerms[activeTab] && (
|
||||
<button
|
||||
onClick={() => setSearchTerms(prev => ({ ...prev, [activeTab]: '' }))}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-300"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-[8px] text-gray-600 tracking-widest">
|
||||
{filteredOptions.length} AVAILABLE
|
||||
</span>
|
||||
<span className="text-[8px] text-gray-600 tracking-widest">
|
||||
{draft[activeTab]?.size || 0} SELECTED
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable Checkbox List ── */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2 styled-scrollbar" style={{ maxHeight: '35vh' }}>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-600 text-[10px] tracking-widest">
|
||||
NO MATCHING RESULTS
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-px">
|
||||
{filteredOptions.map((option) => {
|
||||
const isChecked = draft[activeTab]?.has(option);
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => toggleItem(activeTab, option)}
|
||||
className={`flex items-center gap-2.5 px-3 py-1.5 rounded-md text-left transition-all group ${isChecked
|
||||
? `${c.bg} ${c.text}`
|
||||
: `text-gray-400 hover:bg-gray-800/50 hover:text-gray-200`
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className={`w-3.5 h-3.5 rounded-[3px] border flex items-center justify-center flex-shrink-0 transition-all ${isChecked
|
||||
? `${c.border} ${c.bg}`
|
||||
: 'border-gray-700 group-hover:border-gray-500'
|
||||
}`}>
|
||||
{isChecked && <Check size={9} strokeWidth={3} />}
|
||||
</div>
|
||||
<span className="text-[10px] tracking-wide truncate">
|
||||
{activeField?.optionLabels?.[option] || option}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800/60 flex-shrink-0">
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-[9px] text-red-400/70 hover:text-red-300 tracking-widest transition-colors"
|
||||
>
|
||||
CLEAR ALL
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[9px] text-gray-500 hover:text-gray-300 tracking-widest border border-gray-700 rounded-md px-4 py-1.5 hover:bg-gray-800/50 transition-all"
|
||||
>
|
||||
CANCEL
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className={`text-[9px] ${c.text} tracking-widest border ${c.border} rounded-md px-4 py-1.5 ${c.bg} ${c.bgHover} transition-all font-semibold`}
|
||||
>
|
||||
APPLY{totalSelected > 0 ? ` (${totalSelected})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error(`[ErrorBoundary${this.props.name ? `: ${this.props.name}` : ""}]`, error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4 bg-red-950/40 border border-red-800 rounded-lg m-2">
|
||||
<div className="text-center font-mono">
|
||||
<div className="text-red-400 text-xs tracking-widest mb-1">⚠ SYSTEM ERROR</div>
|
||||
<div className="text-gray-400 text-[10px]">{this.props.name || "Component"} failed to render</div>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="mt-2 px-3 py-1 text-[10px] bg-red-900/60 hover:bg-red-800/60 text-red-300 rounded border border-red-700 transition-colors"
|
||||
>
|
||||
RETRY
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronUp, Filter, Plane, Shield, Star, Ship, SlidersHorizontal } from 'lucide-react';
|
||||
import AdvancedFilterModal from './AdvancedFilterModal';
|
||||
import { airlineNames } from '../lib/airlineCodes';
|
||||
import { trackedCategories, trackedOperators } from '../lib/trackedData';
|
||||
|
||||
interface FilterPanelProps {
|
||||
data: any;
|
||||
activeFilters: Record<string, string[]>;
|
||||
setActiveFilters: (filters: Record<string, string[]>) => void;
|
||||
}
|
||||
|
||||
type ModalConfig = {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
accentColor: string;
|
||||
accentColorName: string;
|
||||
fields: { key: string; label: string; options: string[]; optionLabels?: Record<string, string> }[];
|
||||
};
|
||||
|
||||
export default function FilterPanel({ data, activeFilters, setActiveFilters }: FilterPanelProps) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [openModal, setOpenModal] = useState<string | null>(null);
|
||||
|
||||
// ── Extract unique values from live data ──
|
||||
|
||||
// Commercial: departures, arrivals, airlines
|
||||
const uniqueOrigins = useMemo(() => {
|
||||
const origins = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.origin_name && f.origin_name !== 'UNKNOWN') origins.add(f.origin_name);
|
||||
}
|
||||
return Array.from(origins).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
|
||||
const uniqueDestinations = useMemo(() => {
|
||||
const dests = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.dest_name && f.dest_name !== 'UNKNOWN') dests.add(f.dest_name);
|
||||
}
|
||||
return Array.from(dests).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
|
||||
const uniqueAirlines = useMemo(() => {
|
||||
const airlines = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.airline_code && f.airline_code.trim()) airlines.add(f.airline_code.trim());
|
||||
}
|
||||
return Array.from(airlines).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
|
||||
const airlineLabels = useMemo(() => {
|
||||
const labels: Record<string, string> = {};
|
||||
for (const code of uniqueAirlines) {
|
||||
const name = airlineNames[code];
|
||||
if (name) {
|
||||
labels[code] = `${code} - ${name}`;
|
||||
} else {
|
||||
labels[code] = code;
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}, [uniqueAirlines]);
|
||||
|
||||
// Private: callsigns + aircraft types
|
||||
const uniquePrivateCallsigns = useMemo(() => {
|
||||
const callsigns = new Set<string>();
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
if (f.callsign) callsigns.add(f.callsign);
|
||||
if (f.registration) callsigns.add(f.registration);
|
||||
}
|
||||
return Array.from(callsigns).sort();
|
||||
}, [data?.private_flights, data?.private_jets]);
|
||||
|
||||
const uniquePrivateAircraftTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
if (f.model && f.model !== 'Unknown') types.add(f.model);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.private_flights, data?.private_jets]);
|
||||
|
||||
// Military: country + aircraft type
|
||||
const uniqueMilCountries = useMemo(() => {
|
||||
const countries = new Set<string>();
|
||||
for (const f of data?.military_flights || []) {
|
||||
if (f.country) countries.add(f.country);
|
||||
else if (f.registration) countries.add(f.registration);
|
||||
}
|
||||
return Array.from(countries).sort();
|
||||
}, [data?.military_flights]);
|
||||
|
||||
const uniqueMilAircraftTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const f of data?.military_flights || []) {
|
||||
if (f.military_type && f.military_type !== 'default') types.add(f.military_type);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.military_flights]);
|
||||
|
||||
// Tracked: operators + categories
|
||||
const uniqueTrackedOperators = useMemo(() => {
|
||||
const ops = new Set<string>(trackedOperators);
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
if (f.alert_operator) ops.add(f.alert_operator);
|
||||
if (f.alert_tag1) ops.add(f.alert_tag1);
|
||||
if (f.alert_tag2) ops.add(f.alert_tag2);
|
||||
}
|
||||
return Array.from(ops).sort();
|
||||
}, [data?.tracked_flights]);
|
||||
|
||||
const uniqueTrackedCategories = useMemo(() => {
|
||||
const cats = new Set<string>(trackedCategories);
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
if (f.alert_category) cats.add(f.alert_category);
|
||||
}
|
||||
return Array.from(cats).sort();
|
||||
}, [data?.tracked_flights]);
|
||||
|
||||
// Maritime: vessel names + vessel types (using 'type' field, not 'ship_type')
|
||||
const uniqueShipNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const s of data?.ships || []) {
|
||||
if (s.name && s.name !== 'UNKNOWN') names.add(s.name);
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
}, [data?.ships]);
|
||||
|
||||
const uniqueVesselTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const s of data?.ships || []) {
|
||||
// Use 'type' field from AIS stream (tanker, cargo, passenger, yacht, etc.)
|
||||
if (s.type && s.type !== 'unknown') types.add(s.type);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.ships]);
|
||||
|
||||
// ── Modal configs ──
|
||||
|
||||
const modalConfigs: Record<string, ModalConfig> = {
|
||||
commercial: {
|
||||
title: 'COMMERCIAL FLIGHTS',
|
||||
icon: <Plane size={13} className="text-cyan-400" />,
|
||||
accentColor: '#00bcd4',
|
||||
accentColorName: 'cyan',
|
||||
fields: [
|
||||
{ key: 'commercial_departure', label: 'DEPARTURE', options: uniqueOrigins },
|
||||
{ key: 'commercial_arrival', label: 'ARRIVAL', options: uniqueDestinations },
|
||||
{ key: 'commercial_airline', label: 'AIRLINE', options: uniqueAirlines, optionLabels: airlineLabels },
|
||||
]
|
||||
},
|
||||
private: {
|
||||
title: 'PRIVATE / JETS',
|
||||
icon: <Plane size={13} className="text-orange-400" />,
|
||||
accentColor: '#FF8C00',
|
||||
accentColorName: 'orange',
|
||||
fields: [
|
||||
{ key: 'private_callsign', label: 'CALLSIGN / REG', options: uniquePrivateCallsigns },
|
||||
{ key: 'private_aircraft_type', label: 'AIRCRAFT TYPE', options: uniquePrivateAircraftTypes },
|
||||
]
|
||||
},
|
||||
military: {
|
||||
title: 'MILITARY',
|
||||
icon: <Shield size={13} className="text-yellow-400" />,
|
||||
accentColor: '#EAB308',
|
||||
accentColorName: 'yellow',
|
||||
fields: [
|
||||
{ key: 'military_country', label: 'COUNTRY / REG', options: uniqueMilCountries },
|
||||
{ key: 'military_aircraft_type', label: 'AIRCRAFT TYPE', options: uniqueMilAircraftTypes },
|
||||
]
|
||||
},
|
||||
tracked: {
|
||||
title: 'TRACKED AIRCRAFT',
|
||||
icon: <Star size={13} className="text-pink-400" />,
|
||||
accentColor: '#EC4899',
|
||||
accentColorName: 'pink',
|
||||
fields: [
|
||||
{ key: 'tracked_category', label: 'CATEGORY', options: uniqueTrackedCategories },
|
||||
{ key: 'tracked_owner', label: 'OPERATOR / ENTITY', options: uniqueTrackedOperators },
|
||||
]
|
||||
},
|
||||
ships: {
|
||||
title: 'MARITIME VESSELS',
|
||||
icon: <Ship size={13} className="text-blue-400" />,
|
||||
accentColor: '#3B82F6',
|
||||
accentColorName: 'blue',
|
||||
fields: [
|
||||
{ key: 'ship_name', label: 'VESSEL NAME', options: uniqueShipNames },
|
||||
{ key: 'ship_type', label: 'VESSEL TYPE', options: uniqueVesselTypes },
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const clearAll = () => setActiveFilters({});
|
||||
|
||||
const activeCount = Object.values(activeFilters).reduce((acc, arr) => acc + arr.length, 0);
|
||||
|
||||
const getCountForCategory = (category: string) => {
|
||||
const config = modalConfigs[category];
|
||||
if (!config) return 0;
|
||||
return config.fields.reduce((acc, f) => acc + (activeFilters[f.key]?.length || 0), 0);
|
||||
};
|
||||
|
||||
const handleModalApply = (categoryKey: string, modalFilters: Record<string, string[]>) => {
|
||||
const config = modalConfigs[categoryKey];
|
||||
const next = { ...activeFilters };
|
||||
for (const field of config.fields) {
|
||||
delete next[field.key];
|
||||
}
|
||||
for (const [key, values] of Object.entries(modalFilters)) {
|
||||
if (values.length > 0) next[key] = values;
|
||||
}
|
||||
setActiveFilters(next);
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{ key: 'commercial', title: 'COMMERCIAL FLIGHTS', icon: <Plane size={11} className="text-cyan-400" />, color: 'cyan' },
|
||||
{ key: 'private', title: 'PRIVATE / JETS', icon: <Plane size={11} className="text-orange-400" />, color: 'orange' },
|
||||
{ key: 'military', title: 'MILITARY', icon: <Shield size={11} className="text-yellow-400" />, color: 'yellow' },
|
||||
{ key: 'tracked', title: 'TRACKED AIRCRAFT', icon: <Star size={11} className="text-pink-400" />, color: 'pink' },
|
||||
{ key: 'ships', title: 'MARITIME VESSELS', icon: <Ship size={11} className="text-blue-400" />, color: 'blue' },
|
||||
];
|
||||
|
||||
const borderColors: Record<string, string> = {
|
||||
cyan: 'border-cyan-500/20 hover:border-cyan-500/40',
|
||||
orange: 'border-orange-500/20 hover:border-orange-500/40',
|
||||
yellow: 'border-yellow-500/20 hover:border-yellow-500/40',
|
||||
pink: 'border-pink-500/20 hover:border-pink-500/40',
|
||||
blue: 'border-blue-500/20 hover:border-blue-500/40',
|
||||
};
|
||||
const textColors: Record<string, string> = {
|
||||
cyan: 'text-cyan-400',
|
||||
orange: 'text-orange-400',
|
||||
yellow: 'text-yellow-400',
|
||||
pink: 'text-pink-400',
|
||||
blue: 'text-blue-400',
|
||||
};
|
||||
const bgColors: Record<string, string> = {
|
||||
cyan: 'bg-cyan-500/10',
|
||||
orange: 'bg-orange-500/10',
|
||||
yellow: 'bg-yellow-500/10',
|
||||
pink: 'bg-pink-500/10',
|
||||
blue: 'bg-blue-500/10',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ y: -30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto flex-shrink-0"
|
||||
>
|
||||
{/* Header Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={12} className="text-cyan-500" />
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DATA FILTERS</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="text-[9px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded-sm">
|
||||
{activeCount} ACTIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
{isMinimized ? <SlidersHorizontal 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="overflow-y-auto styled-scrollbar flex flex-col gap-2 p-3 pt-2 max-h-[400px]"
|
||||
>
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-[9px] text-red-400 hover:text-red-300 tracking-widest self-end mb-1"
|
||||
>
|
||||
CLEAR ALL FILTERS
|
||||
</button>
|
||||
)}
|
||||
|
||||
{sections.map(section => {
|
||||
const count = getCountForCategory(section.key);
|
||||
return (
|
||||
<div
|
||||
key={section.key}
|
||||
className={`border rounded-lg transition-all cursor-pointer group ${borderColors[section.color] || 'border-gray-800'} hover:bg-black/30`}
|
||||
onClick={() => setOpenModal(section.key)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-2.5 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{section.icon}
|
||||
<span className="text-[9px] text-gray-400 tracking-widest group-hover:text-gray-200 transition-colors">{section.title}</span>
|
||||
{count > 0 && (
|
||||
<span className={`text-[8px] ${bgColors[section.color]} ${textColors[section.color]} px-1.5 py-0.5 rounded-sm`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SlidersHorizontal size={10} className="text-gray-600 group-hover:text-gray-400 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Render active modal */}
|
||||
<AnimatePresence>
|
||||
{openModal && modalConfigs[openModal] && (
|
||||
<AdvancedFilterModal
|
||||
key={openModal}
|
||||
title={modalConfigs[openModal].title}
|
||||
icon={modalConfigs[openModal].icon}
|
||||
accentColor={modalConfigs[openModal].accentColor}
|
||||
accentColorName={modalConfigs[openModal].accentColorName}
|
||||
fields={modalConfigs[openModal].fields}
|
||||
activeFilters={activeFilters}
|
||||
onApply={(filters) => handleModalApply(openModal, filters)}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { Search, Crosshair, Plane, Shield, Star, Ship, X, Database } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { trackedOperators } from '../lib/trackedData';
|
||||
|
||||
interface FindLocateBarProps {
|
||||
data: any;
|
||||
onLocate: (lat: number, lng: number, entityId: string, entityType: string) => void;
|
||||
onFilter?: (filterType: string, filterValue: string) => void;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
label: string;
|
||||
sublabel: string;
|
||||
category: string;
|
||||
categoryColor: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
entityType: string;
|
||||
}
|
||||
|
||||
export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBarProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
// Build searchable index from all data
|
||||
const allEntities = useMemo(() => {
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// Commercial flights
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
results.push({
|
||||
id: `flight-${uid}`,
|
||||
label: f.callsign || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · ${f.airline_code || 'Commercial'}`,
|
||||
category: "COMMERCIAL",
|
||||
categoryColor: "text-cyan-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: "flight",
|
||||
});
|
||||
}
|
||||
|
||||
// Private flights
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
const type = f.type === 'private_jet' ? 'private_jet' : 'private_flight';
|
||||
results.push({
|
||||
id: `${type === 'private_jet' ? 'private-jet' : 'private-flight'}-${uid}`,
|
||||
label: f.callsign || f.registration || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · Private`,
|
||||
category: "PRIVATE",
|
||||
categoryColor: "text-orange-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: type,
|
||||
});
|
||||
}
|
||||
|
||||
// Military flights
|
||||
for (const f of data?.military_flights || []) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
results.push({
|
||||
id: `mil-flight-${uid}`,
|
||||
label: f.callsign || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · ${f.military_type || 'Military'}`,
|
||||
category: "MILITARY",
|
||||
categoryColor: "text-yellow-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: "military_flight",
|
||||
});
|
||||
}
|
||||
|
||||
// Tracked flights
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
const operator = f.alert_operator || 'Unknown Operator';
|
||||
const category = f.alert_category || 'Tracked';
|
||||
const type = f.alert_type || f.model || 'Unknown';
|
||||
results.push({
|
||||
id: `tracked-${uid}`,
|
||||
label: operator,
|
||||
sublabel: `${category} · ${type} (${f.registration || uid})`,
|
||||
category: "TRACKED",
|
||||
categoryColor: "text-pink-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: "tracked_flight",
|
||||
});
|
||||
}
|
||||
|
||||
// Ships
|
||||
for (const s of data?.ships || []) {
|
||||
results.push({
|
||||
id: `ship-${s.mmsi || s.name || ''}`,
|
||||
label: s.name || "UNKNOWN",
|
||||
sublabel: `${s.type || 'Vessel'} · ${s.destination || 'Unknown dest'}`,
|
||||
category: "MARITIME",
|
||||
categoryColor: "text-blue-400",
|
||||
lat: s.lat,
|
||||
lng: s.lng,
|
||||
entityType: "ship",
|
||||
});
|
||||
}
|
||||
|
||||
// Database Records - Tracked Operators
|
||||
for (const op of trackedOperators) {
|
||||
results.push({
|
||||
id: `tracked-db-${op}`,
|
||||
label: op,
|
||||
sublabel: `Database Record · Operator`,
|
||||
category: "DATABASE",
|
||||
categoryColor: "text-purple-400",
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
entityType: "database_operator",
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [data]);
|
||||
|
||||
// Filter results based on query
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
const q = query.toLowerCase();
|
||||
return allEntities
|
||||
.filter(e => {
|
||||
const searchable = `${e.label} ${e.sublabel} ${e.id}`.toLowerCase();
|
||||
return searchable.includes(q);
|
||||
})
|
||||
.slice(0, 12);
|
||||
}, [query, allEntities]);
|
||||
|
||||
const handleSelect = (result: SearchResult) => {
|
||||
if (result.entityType === "database_operator") {
|
||||
if (onFilter) onFilter("tracked_owner", result.label);
|
||||
} else {
|
||||
onLocate(result.lat, result.lng, result.id, result.entityType);
|
||||
}
|
||||
setQuery("");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, React.ReactNode> = {
|
||||
COMMERCIAL: <Plane size={10} className="text-cyan-400" />,
|
||||
PRIVATE: <Plane size={10} className="text-orange-400" />,
|
||||
MILITARY: <Shield size={10} className="text-yellow-400" />,
|
||||
TRACKED: <Star size={10} className="text-pink-400" />,
|
||||
MARITIME: <Ship size={10} className="text-blue-400" />,
|
||||
DATABASE: <Database size={10} className="text-purple-400" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full pointer-events-auto">
|
||||
<div className="flex items-center gap-2 bg-black/40 backdrop-blur-md border border-gray-800 rounded-lg px-3 py-2 focus-within:border-cyan-500/40 transition-colors">
|
||||
<Search size={12} className="text-gray-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder="Find aircraft or vessel..."
|
||||
className="flex-1 bg-transparent text-[10px] text-gray-300 font-mono tracking-wider outline-none placeholder:text-gray-600"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
/>
|
||||
{query && (
|
||||
<button onClick={() => { setQuery(""); setIsOpen(false); }} className="text-gray-600 hover:text-white transition-colors">
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
<Crosshair size={12} className="text-gray-600 flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && filtered.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-black/90 backdrop-blur-md border border-gray-800 rounded-lg overflow-hidden z-50 shadow-[0_8px_30px_rgba(0,0,0,0.6)]"
|
||||
>
|
||||
<div className="max-h-[300px] overflow-y-auto styled-scrollbar">
|
||||
{filtered.map((r, idx) => (
|
||||
<button
|
||||
key={`${r.id}-${idx}`}
|
||||
onClick={() => handleSelect(r)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-cyan-950/30 transition-colors text-left border-b border-gray-800/50 last:border-0 group"
|
||||
>
|
||||
<div className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded bg-gray-900 border border-gray-800 group-hover:border-cyan-800">
|
||||
{categoryIcons[r.category]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] text-gray-200 font-mono tracking-wide truncate">{r.label}</div>
|
||||
<div className="text-[8px] text-gray-500 font-mono truncate">{r.sublabel}</div>
|
||||
</div>
|
||||
<span className={`text-[7px] font-bold tracking-widest ${r.categoryColor} flex-shrink-0`}>
|
||||
{r.category}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 border-t border-gray-800 bg-black/50 text-[8px] text-gray-600 font-mono tracking-widest">
|
||||
{filtered.length} RESULT{filtered.length !== 1 ? 'S' : ''} — CLICK TO LOCATE
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{isOpen && query.trim() && filtered.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-black/90 backdrop-blur-md border border-gray-800 rounded-lg z-50 p-4 text-center"
|
||||
>
|
||||
<div className="text-[9px] text-gray-600 font-mono tracking-widest">NO MATCHING ASSETS</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
"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,301 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
// ─── Inline SVG legend icons (small, crisp, no external deps) ───
|
||||
const plane = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`;
|
||||
|
||||
const airliner = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M12 2C11.2 2 10.5 2.8 10.5 3.5V8.5L3 13V15L10.5 12.5V18L8 19.5V21L12 19.5L16 21V19.5L13.5 18V12.5L21 15V13L13.5 8.5V3.5C13.5 2.8 12.8 2 12 2Z M5.5 13.5L3.5 14.5 M18.5 13.5L20.5 14.5"/><circle cx="7" cy="12.5" r="1.2" fill="${fill}" stroke="black" stroke-width="0.5"/><circle cx="17" cy="12.5" r="1.2" fill="${fill}" stroke="black" stroke-width="0.5"/></svg>`;
|
||||
|
||||
const turboprop = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L9 19.5V21L12 20L15 21V19.5L13.2 18.5V11.5L21 13.5V12L13.2 9V4C13.2 3.5 12.7 3 12 3Z"/></svg>`;
|
||||
|
||||
const bizjet = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z"/></svg>`;
|
||||
|
||||
const heli = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="${fill}" stroke-dasharray="2 2" stroke-width="1"/></svg>`;
|
||||
|
||||
const ship = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
|
||||
const triangle = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="#000" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||
|
||||
const circle = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
|
||||
const dot = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
|
||||
function IconImg({ svg }: { svg: string }) {
|
||||
return <img src={`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`} alt="" className="w-4 h-4 flex-shrink-0" draggable={false} />;
|
||||
}
|
||||
|
||||
// ─── Legend data ───
|
||||
|
||||
interface LegendItem {
|
||||
svg: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface LegendCategory {
|
||||
name: string;
|
||||
color: string;
|
||||
items: LegendItem[];
|
||||
}
|
||||
|
||||
const sat = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${fill}" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="8" fill="none" stroke="${fill}" stroke-dasharray="3 3" stroke-width="0.8"/></svg>`;
|
||||
|
||||
const square = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="${fill}" stroke="#000" stroke-width="1" opacity="0.6" rx="2"/></svg>`;
|
||||
|
||||
const clusterCircle = (fill: string, stroke: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="${fill}" stroke="${stroke}" stroke-width="2" opacity="0.8"/><text x="12" y="15" text-anchor="middle" fill="white" font-size="8" font-family="monospace" font-weight="bold">5</text></svg>`;
|
||||
|
||||
const LEGEND: LegendCategory[] = [
|
||||
{
|
||||
name: "COMMERCIAL AVIATION",
|
||||
color: "text-cyan-400 border-cyan-500/30",
|
||||
items: [
|
||||
{ svg: airliner("cyan"), label: "Airliner (swept wings)" },
|
||||
{ svg: turboprop("cyan"), label: "Turboprop (straight wings)" },
|
||||
{ svg: heli("cyan"), label: "Helicopter (rotor disc)" },
|
||||
{ svg: airliner("#555"), label: "Grounded / Parked (grey)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "PRIVATE AVIATION",
|
||||
color: "text-orange-400 border-orange-500/30",
|
||||
items: [
|
||||
{ svg: airliner("#FF8C00"), label: "Private Flight — Airliner" },
|
||||
{ svg: turboprop("#FF8C00"), label: "Private Flight — Turboprop" },
|
||||
{ svg: heli("#FF8C00"), label: "Private Flight — Helicopter" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "PRIVATE JETS",
|
||||
color: "text-purple-400 border-purple-500/30",
|
||||
items: [
|
||||
{ svg: bizjet("#9B59B6"), label: "Private Jet — Bizjet" },
|
||||
{ svg: airliner("#9B59B6"), label: "Private Jet — Airliner" },
|
||||
{ svg: turboprop("#9B59B6"), label: "Private Jet — Turboprop" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "MILITARY AVIATION",
|
||||
color: "text-yellow-400 border-yellow-500/30",
|
||||
items: [
|
||||
{ svg: airliner("yellow"), label: "Military — Standard" },
|
||||
{ svg: plane("yellow"), label: "Fighter / Interceptor" },
|
||||
{ svg: heli("yellow"), label: "Military — Helicopter" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="orange" stroke-width="1.5" stroke-dasharray="4 2" opacity="0.6"/><circle cx="12" cy="12" r="2" fill="orange"/></svg>`, label: "UAV Operational Range (dashed circle)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "TRACKED AIRCRAFT (ALERT)",
|
||||
color: "text-pink-400 border-pink-500/30",
|
||||
items: [
|
||||
{ svg: airliner("#FF1493"), label: "Alert — Low Priority (pink)" },
|
||||
{ svg: airliner("#FF2020"), label: "Alert — High Priority (red)" },
|
||||
{ svg: airliner("#1A3A8A"), label: "Alert — Government (navy)" },
|
||||
{ svg: airliner("white"), label: "Alert — General (white)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "SATELLITES",
|
||||
color: "text-sky-400 border-sky-500/30",
|
||||
items: [
|
||||
{ svg: sat("#ff3333"), label: "Military Recon / SAR (red)" },
|
||||
{ svg: sat("#00e5ff"), label: "Synthetic Aperture Radar (cyan)" },
|
||||
{ svg: sat("#ffffff"), label: "Signals Intelligence / ELINT (white)" },
|
||||
{ svg: sat("#4488ff"), label: "Navigation — GPS / GLONASS / BeiDou (blue)" },
|
||||
{ svg: sat("#ff00ff"), label: "Early Warning — Missile Detection (magenta)" },
|
||||
{ svg: sat("#44ff44"), label: "Commercial Imaging (green)" },
|
||||
{ svg: sat("#ffdd00"), label: "Space Station — ISS / Tiangong (gold)" },
|
||||
{ svg: sat("#aaaaaa"), label: "Unclassified / Other (grey)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "MARITIME",
|
||||
color: "text-blue-400 border-blue-500/30",
|
||||
items: [
|
||||
{ svg: ship("gray"), label: "Civilian / Unknown Vessel" },
|
||||
{ svg: ship("yellow"), label: "Tanker" },
|
||||
{ svg: ship("#ff2222"), label: "Military Vessel" },
|
||||
{ svg: ship("#3b82f6"), label: "Cargo Ship" },
|
||||
{ svg: ship("white"), label: "Cruise / Passenger" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><polygon points="3,21 21,21 20,4 16,4 16,3 12,3 12,4 4,4" /><rect x="15" y="6" width="3" height="10" /></svg>`, label: "Aircraft Carrier" },
|
||||
{ svg: clusterCircle("#3b82f6", "#1d4ed8"), label: "Ship Cluster (count inside)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "GEOPHYSICAL",
|
||||
color: "text-orange-400 border-orange-500/30",
|
||||
items: [
|
||||
{ svg: circle("#ff6600"), label: "Earthquake (size = magnitude)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "INCIDENTS & INTELLIGENCE",
|
||||
color: "text-red-400 border-red-500/30",
|
||||
items: [
|
||||
{ svg: triangle("#ffaa00"), label: "GDELT / LiveUA event (yellow)" },
|
||||
{ svg: triangle("#ff0000"), label: "Violent / Kinetic event (red)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>`, label: "Threat Alert (news cluster)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "NEWS & OSINT",
|
||||
color: "text-cyan-400 border-cyan-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12" viewBox="0 0 40 24"><rect x="1" y="1" width="38" height="22" rx="3" fill="#111" stroke="cyan" stroke-width="1"/><text x="6" y="10" fill="red" font-size="6" font-family="monospace">!! ALERT</text><text x="6" y="17" fill="white" font-size="4" font-family="monospace">News Headline</text></svg>`, label: "Geolocated news alert box" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "GPS JAMMING / INTERFERENCE",
|
||||
color: "text-red-400 border-red-500/30",
|
||||
items: [
|
||||
{ svg: square("#ff0040"), label: "High severity (>75% aircraft degraded)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="#ff0040" stroke="#000" stroke-width="1" opacity="0.35" rx="2"/></svg>`, label: "Medium severity (50-75% degraded)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="#ff0040" stroke="#000" stroke-width="1" opacity="0.2" rx="2"/></svg>`, label: "Low severity (25-50% degraded)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "SURVEILLANCE / CCTV",
|
||||
color: "text-green-400 border-green-500/30",
|
||||
items: [
|
||||
{ svg: dot("#22c55e"), label: "Individual CCTV camera (green dot)" },
|
||||
{ svg: clusterCircle("#22c55e", "#16a34a"), label: "Camera cluster (count inside)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="cyan" stroke-width="2"><path d="M16.75 12h3.632a1 1 0 0 1 .894 1.447l-2.034 4.069a1 1 0 0 1-.894.553H5.652a1 1 0 0 1-.894-.553L2.724 13.447A1 1 0 0 1 3.618 12h3.632M14 12V8a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a4 4 0 1 0 8 0Z" /></svg>`, label: "CCTV icon (detail view)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "OVERLAYS",
|
||||
color: "text-gray-400 border-gray-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect width="24" height="24" fill="#0a0e1a" opacity="0.4"/><circle cx="12" cy="12" r="4" fill="#ffd700"/></svg>`, label: "Day / Night terminator" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><line x1="4" y1="4" x2="20" y2="4" stroke="red" stroke-width="2"/><line x1="4" y1="8" x2="20" y2="8" stroke="#ff6600" stroke-width="2"/></svg>`, label: "Ukraine frontline" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = (name: string) => {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Legend Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] max-h-[80vh] bg-gray-950/95 backdrop-blur-xl border border-cyan-900/50 rounded-xl z-[9999] flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.8)]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-800/80 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="cyan" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M12 3v12" />
|
||||
<path d="m8 11 4 4 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">MAP LEGEND</h2>
|
||||
<span className="text-[9px] text-gray-500 font-mono tracking-widest">ICON REFERENCE KEY</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg border border-gray-700 hover:border-red-500/50 flex items-center justify-center text-gray-500 hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend Content */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-2">
|
||||
{LEGEND.map((cat) => {
|
||||
const isCollapsed = collapsed.has(cat.name);
|
||||
return (
|
||||
<div key={cat.name} className="rounded-lg border border-gray-800/60 overflow-hidden">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => toggle(cat.name)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-gray-900/50 hover:bg-gray-900/80 transition-colors"
|
||||
>
|
||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${cat.color}`}>
|
||||
{cat.name}
|
||||
</span>
|
||||
{isCollapsed ? <ChevronDown size={12} className="text-gray-500" /> : <ChevronUp size={12} className="text-gray-500" />}
|
||||
</button>
|
||||
|
||||
{/* Items */}
|
||||
<AnimatePresence>
|
||||
{!isCollapsed && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="border-t border-gray-800/40"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-0">
|
||||
{cat.items.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 px-4 py-1.5 hover:bg-gray-900/30 transition-colors">
|
||||
<IconImg svg={item.svg} />
|
||||
<span className="text-[11px] text-gray-300 font-mono">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-gray-800/80 flex-shrink-0">
|
||||
<div className="text-[9px] text-gray-600 font-mono text-center tracking-wider">
|
||||
{LEGEND.reduce((sum, c) => sum + c.items.length, 0)} ICON DEFINITIONS ACROSS {LEGEND.length} CATEGORIES
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default MapLegend;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, Droplet, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
|
||||
const stocks = data?.stocks || {};
|
||||
const oil = data?.oil || {};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: -50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto flex-shrink-0"
|
||||
>
|
||||
{/* Header Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">GLOBAL MARKETS</span>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
{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="overflow-y-auto styled-scrollbar flex flex-col gap-4 p-4 pt-3 max-h-[400px]"
|
||||
>
|
||||
<div className="border-b border-gray-800 pb-3">
|
||||
<h2 className="text-xs font-bold tracking-widest text-cyan-400 flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="text-cyan-500" size={14} /> DEFENSE SEC TICKERS
|
||||
</h2>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{Object.entries(stocks).map(([ticker, info]: [string, any]) => (
|
||||
<div key={ticker} className="flex items-center justify-between border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm relative group overflow-hidden">
|
||||
<span className="font-bold text-cyan-300 z-10 text-[10px]">[{ticker}]</span>
|
||||
<div className="flex items-center gap-3 text-right z-10">
|
||||
<span className="text-gray-200 font-bold text-xs">${info.price.toFixed(2)}</span>
|
||||
<span className={`flex items-center gap-0.5 w-12 justify-end text-[9px] ${info.up ? 'text-cyan-400' : 'text-red-400'}`}>
|
||||
{info.up ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
|
||||
{Math.abs(info.change_percent).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xs font-bold tracking-widest text-cyan-400 flex items-center gap-2 mb-2">
|
||||
<Droplet className="text-cyan-500" size={14} /> COMMODITY INDEX
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{Object.entries(oil).map(([name, info]: [string, any]) => (
|
||||
<div key={name} className="flex flex-col border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm justify-between">
|
||||
<span className="font-bold text-cyan-500 text-[9px] uppercase mb-0.5">{name}</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-200 font-bold text-[11px]">${info.price.toFixed(2)}</span>
|
||||
<span className={`flex items-center gap-0.5 text-[9px] ${info.up ? 'text-cyan-400' : 'text-red-400'}`}>
|
||||
{info.up ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
|
||||
{Math.abs(info.change_percent).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default MarketsPanel;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { RadioReceiver, Activity, Play, Square, FastForward, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesdropping, eavesdropLocation, cameraCenter }: { data: any, isEavesdropping?: boolean, setIsEavesdropping?: (val: boolean) => void, eavesdropLocation?: { lat: number, lng: number } | null, cameraCenter?: { lat: number, lng: number } | null }) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [feeds, setFeeds] = useState<any[]>([]);
|
||||
const [activeFeed, setActiveFeed] = useState<any | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [volume, setVolume] = useState(0.8);
|
||||
const scanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Fetch the top feeds on mount
|
||||
useEffect(() => {
|
||||
const fetchFeeds = async () => {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/radio/top");
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setFeeds(json);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch radio feeds", e);
|
||||
}
|
||||
};
|
||||
fetchFeeds();
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(fetchFeeds, 300000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Handle Eavesdrop Map Clicks
|
||||
useEffect(() => {
|
||||
if (eavesdropLocation && isEavesdropping) {
|
||||
const fetchNearest = async () => {
|
||||
try {
|
||||
// Show a temporary state
|
||||
setFeeds(prev => [{
|
||||
id: 'scanning-nearest',
|
||||
name: 'TRIANGULATING SIGNAL...',
|
||||
location: `LAT:${eavesdropLocation.lat.toFixed(2)} LNG:${eavesdropLocation.lng.toFixed(2)}`,
|
||||
listeners: 0,
|
||||
category: 'SIGINT'
|
||||
}, ...prev]);
|
||||
|
||||
const res = await fetch(`http://localhost:8000/api/radio/nearest?lat=${eavesdropLocation.lat}&lng=${eavesdropLocation.lng}`);
|
||||
if (res.ok) {
|
||||
const system = await res.json();
|
||||
if (system && system.shortName) {
|
||||
// Valid OpenMHZ system found! Fetch recent calls
|
||||
const callRes = await fetch(`http://localhost:8000/api/radio/openmhz/calls/${system.shortName}`);
|
||||
if (callRes.ok) {
|
||||
const calls = await callRes.json();
|
||||
if (calls && calls.length > 0) {
|
||||
// Found bursts!
|
||||
const latest = calls[0];
|
||||
const openMhzFeed = {
|
||||
id: `openmhz-${system.shortName}-${latest.id}`,
|
||||
name: `${system.name} (TG:${latest.talkgroupNum})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
listeners: system.clientCount || 0,
|
||||
category: 'TRUNKED INTERCEPT',
|
||||
stream_url: latest.url
|
||||
};
|
||||
|
||||
// Remove the triangulating placeholder and add the new intercept
|
||||
setFeeds(prev => {
|
||||
const clean = prev.filter(f => f.id !== 'scanning-nearest');
|
||||
// Avoid duplicates if we clicked the same place twice
|
||||
if (clean.find(f => f.id === openMhzFeed.id)) return clean;
|
||||
return [openMhzFeed, ...clean];
|
||||
});
|
||||
// Auto-play the intercept
|
||||
playFeed(openMhzFeed);
|
||||
} else {
|
||||
// Provide failure feedback
|
||||
setFeeds(prev => {
|
||||
const clean = prev.filter(f => f.id !== 'scanning-nearest');
|
||||
return [{
|
||||
id: `failed-${Date.now()}`,
|
||||
name: `NO RECENT COMMS (${system.shortName})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
category: 'DEAD AIR',
|
||||
listeners: 0
|
||||
}, ...clean];
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Provide failure feedback
|
||||
setFeeds(prev => {
|
||||
const clean = prev.filter(f => f.id !== 'scanning-nearest');
|
||||
return [{
|
||||
id: `failed-${Date.now()}`,
|
||||
name: 'NO LOCAL REPEATERS FOUND',
|
||||
location: 'UNKNOWN',
|
||||
category: 'ENCRYPTED / VOID',
|
||||
listeners: 0
|
||||
}, ...clean];
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Nearest system lookup failed", e);
|
||||
}
|
||||
};
|
||||
fetchNearest();
|
||||
}
|
||||
}, [eavesdropLocation]);
|
||||
|
||||
const playFeed = (feed: any) => {
|
||||
if (isScanning && scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
setIsScanning(false);
|
||||
}
|
||||
setActiveFeed(feed);
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const stopFeed = () => {
|
||||
if (isScanning && scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
setIsScanning(false);
|
||||
}
|
||||
setActiveFeed(null);
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
// Handle Audio Element Play/Stop
|
||||
useEffect(() => {
|
||||
if (activeFeed && isPlaying) {
|
||||
if (!audioRef.current) {
|
||||
const audio = new Audio(activeFeed.stream_url);
|
||||
audioRef.current = audio;
|
||||
} else {
|
||||
audioRef.current.src = activeFeed.stream_url;
|
||||
}
|
||||
audioRef.current.volume = volume;
|
||||
audioRef.current.play().catch(e => console.log("Audio play blocked", e));
|
||||
} else {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = "";
|
||||
}
|
||||
}
|
||||
}, [activeFeed, isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = "";
|
||||
}
|
||||
if (scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleScan = () => {
|
||||
if (isScanning) {
|
||||
setIsScanning(false);
|
||||
if (scanTimeoutRef.current) clearTimeout(scanTimeoutRef.current);
|
||||
stopFeed();
|
||||
} else {
|
||||
setIsScanning(true);
|
||||
scanNextFeed();
|
||||
}
|
||||
};
|
||||
|
||||
const scanNextFeed = async () => {
|
||||
if (!isScanning) return;
|
||||
|
||||
// Try localized scan first if we have a camera center or eavesdrop location
|
||||
const scanLoc = eavesdropLocation || cameraCenter;
|
||||
|
||||
let localFeedFound = false;
|
||||
|
||||
if (scanLoc) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/radio/nearest-list?lat=${scanLoc.lat}&lng=${scanLoc.lng}&limit=3`);
|
||||
if (res.ok) {
|
||||
const systems = await res.json();
|
||||
|
||||
// Try to find a system with an active unplayed burst
|
||||
for (const system of systems) {
|
||||
if (system && system.shortName) {
|
||||
const callRes = await fetch(`http://localhost:8000/api/radio/openmhz/calls/${system.shortName}`);
|
||||
if (callRes.ok) {
|
||||
const calls = await callRes.json();
|
||||
if (calls && calls.length > 0) {
|
||||
// Normally we would track played calls. For now just pick random recent one.
|
||||
const randomCall = calls[Math.floor(Math.random() * Math.min(calls.length, 3))];
|
||||
const openMhzFeed = {
|
||||
id: `openmhz-${system.shortName}-${randomCall.id}`,
|
||||
name: `${system.name} (TG:${randomCall.talkgroupNum})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
listeners: system.clientCount || 0,
|
||||
category: 'TRUNKED INTERCEPT',
|
||||
stream_url: randomCall.url
|
||||
};
|
||||
|
||||
// Replace feeds list visually with this active sector
|
||||
setFeeds(prev => {
|
||||
if (prev.find(f => f.id === openMhzFeed.id)) return prev;
|
||||
return [openMhzFeed, ...prev].slice(0, 10);
|
||||
});
|
||||
setActiveFeed(openMhzFeed);
|
||||
setIsPlaying(true);
|
||||
localFeedFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Auto scan local query failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!localFeedFound && feeds.length > 0) {
|
||||
// Fallback: Pick a random hot feed or cycle them
|
||||
const randomIdx = Math.floor(Math.random() * Math.min(feeds.length, 10)); // Pick from top 10
|
||||
setActiveFeed(feeds[randomIdx]);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
|
||||
// Scan for 15 seconds then switch
|
||||
scanTimeoutRef.current = setTimeout(() => {
|
||||
if (isScanning) scanNextFeed();
|
||||
}, 15000);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="w-full flex flex-col bg-black/40 backdrop-blur-md border border-cyan-900/50 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.5)] relative overflow-hidden max-h-full"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 border-b border-cyan-900/50 cursor-pointer bg-cyan-950/20 hover:bg-cyan-900/30 transition-colors"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-cyan-400">
|
||||
<RadioReceiver size={14} className={isPlaying ? "animate-pulse" : ""} />
|
||||
<span className="text-[10px] font-mono tracking-widest font-semibold">SIGINT INTERCEPT</span>
|
||||
{isPlaying && <Activity size={12} className="text-red-500 animate-pulse ml-2" />}
|
||||
</div>
|
||||
<button className="text-cyan-500 hover:text-cyan-300 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="flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Audio Player Controls */}
|
||||
<div className="p-4 border-b border-cyan-900/40 bg-black/60">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-cyan-300 font-mono tracking-wide">
|
||||
{activeFeed ? activeFeed.name : "NO SIGNAL"}
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-500 font-mono">
|
||||
{activeFeed ? `LOCATION: ${activeFeed.location.toUpperCase()}` : "AWAITING TUNING..."}
|
||||
</span>
|
||||
</div>
|
||||
{activeFeed && (
|
||||
<div className="flex items-center gap-1 bg-red-950/40 border border-red-900/50 px-2 py-0.5 rounded text-[9px] text-red-400 font-mono">
|
||||
<Activity size={10} className="animate-pulse" />
|
||||
LIVE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={activeFeed ? stopFeed : () => feeds.length > 0 && playFeed(feeds[0])}
|
||||
className={`p-2 rounded-full border ${activeFeed ? 'border-red-500/50 text-red-500 hover:bg-red-950/50' : 'border-cyan-700 text-cyan-500 hover:bg-cyan-900/50'} transition-colors`}
|
||||
>
|
||||
{activeFeed ? <Square size={14} /> : <Play size={14} className="ml-0.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleScan}
|
||||
className={`px-3 py-1.5 rounded text-[10px] font-mono border tracking-wider flex items-center gap-2 ${isScanning ? 'bg-cyan-900/60 border-cyan-400 text-cyan-300' : 'border-cyan-800 text-cyan-600 hover:border-cyan-600'} transition-colors`}
|
||||
>
|
||||
<FastForward size={12} />
|
||||
{isScanning ? 'SCANNING...' : 'AUTO SCAN'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsEavesdropping && setIsEavesdropping(!isEavesdropping)}
|
||||
className={`px-3 py-1.5 rounded text-[10px] font-mono border tracking-wider flex items-center gap-2 ${isEavesdropping ? 'bg-red-900/60 border-red-500 text-red-300 animate-pulse' : 'border-cyan-800 text-cyan-600 hover:border-cyan-600'} transition-colors`}
|
||||
title="Click on the globe to intercept local signals"
|
||||
>
|
||||
EAVESDROP
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0" max="1" step="0.05"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-20 accent-cyan-500"
|
||||
title="Volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fake Waveform Visualizer */}
|
||||
<div className="mt-4 flex items-end gap-[2px] h-8 opacity-70">
|
||||
{Array.from({ length: 48 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className={`w-1 rounded-t-sm ${isPlaying ? 'bg-cyan-500' : 'bg-cyan-900/50'}`}
|
||||
animate={{
|
||||
height: isPlaying
|
||||
? ['10%', `${Math.random() * 80 + 20}%`, '10%']
|
||||
: '10%'
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: Math.random() * 0.5 + 0.3,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed List */}
|
||||
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
|
||||
{feeds.length === 0 ? (
|
||||
<div className="text-[10px] text-cyan-700 font-mono text-center p-4">SEARCHING FREQUENCIES...</div>
|
||||
) : (
|
||||
feeds.map((feed: any, idx: number) => (
|
||||
<div
|
||||
key={feed.id}
|
||||
onClick={() => playFeed(feed)}
|
||||
className={`p-2 mb-1 rounded cursor-pointer border-l-2 ${activeFeed?.id === feed.id ? 'bg-cyan-900/30 border-cyan-400' : 'border-transparent hover:bg-white/5'} flex justify-between items-center transition-colors`}
|
||||
>
|
||||
<div className="flex flex-col overflow-hidden pr-2">
|
||||
<span className={`text-[11px] font-mono truncate ${activeFeed?.id === feed.id ? 'text-cyan-300' : 'text-gray-300'}`}>
|
||||
{feed.name}
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-500 font-mono truncate">
|
||||
{feed.location} | {feed.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end flex-shrink-0">
|
||||
<span className="text-[10px] text-cyan-600 font-mono flex items-center gap-1">
|
||||
<Activity size={10} />
|
||||
{feed.listeners.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[8px] text-gray-600 font-mono mt-0.5">LSTN</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { Ruler, X, Trash2 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Dynamic Scale Bar with:
|
||||
* 1. Auto-scaling distance display based on zoom level
|
||||
* 2. Draggable right edge to manually resize the ruler
|
||||
* 3. Measurement mode toggle — lets the user place up to 3 waypoints on the map
|
||||
*/
|
||||
|
||||
const NICE_METRIC = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000];
|
||||
const NICE_IMPERIAL = [0.1, 0.25, 0.5, 1, 2, 5, 10, 25, 50, 100, 200, 500, 1000, 2000, 5000];
|
||||
|
||||
const MILES_PER_METER = 0.000621371;
|
||||
const KM_PER_METER = 0.001;
|
||||
|
||||
/** Metres per pixel at a given zoom & latitude (Web Mercator). */
|
||||
function metersPerPixel(zoom: number, latitude: number) {
|
||||
return (156543.03392 * Math.cos((latitude * Math.PI) / 180)) / Math.pow(2, zoom);
|
||||
}
|
||||
|
||||
/** Format a metric distance nicely. */
|
||||
function fmtMetric(km: number) {
|
||||
return km >= 1 ? `${km.toFixed(km < 10 ? 1 : 0)} km` : `${Math.round(km * 1000)} m`;
|
||||
}
|
||||
/** Format an imperial distance nicely. */
|
||||
function fmtImperial(mi: number) {
|
||||
return mi >= 1 ? `${mi.toFixed(mi < 10 ? 1 : 0)} mi` : `${Math.round(mi * 5280)} ft`;
|
||||
}
|
||||
|
||||
interface MeasurePoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface ScaleBarProps {
|
||||
zoom: number;
|
||||
latitude: number;
|
||||
measureMode?: boolean;
|
||||
measurePoints?: MeasurePoint[];
|
||||
onToggleMeasure?: () => void;
|
||||
onClearMeasure?: () => void;
|
||||
}
|
||||
|
||||
function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure, onClearMeasure }: ScaleBarProps) {
|
||||
const [unit, setUnit] = useState<"mi" | "km">("mi");
|
||||
const [barWidth, setBarWidth] = useState(120); // current bar width in px
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startW = useRef(0);
|
||||
|
||||
const MIN_BAR = 60;
|
||||
const MAX_BAR = 280;
|
||||
|
||||
// ── Draggable right edge ──
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
startW.current = barWidth;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [barWidth]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragging.current) return;
|
||||
const dx = e.clientX - startX.current;
|
||||
setBarWidth(Math.max(MIN_BAR, Math.min(MAX_BAR, startW.current + dx)));
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
dragging.current = false;
|
||||
}, []);
|
||||
|
||||
// ── Distance label for the current bar width ──
|
||||
const scaleLabel = useMemo(() => {
|
||||
const mpp = metersPerPixel(zoom, latitude);
|
||||
const totalMeters = mpp * barWidth;
|
||||
if (unit === "km") {
|
||||
return fmtMetric(totalMeters * KM_PER_METER);
|
||||
} else {
|
||||
return fmtImperial(totalMeters * MILES_PER_METER);
|
||||
}
|
||||
}, [zoom, latitude, barWidth, unit]);
|
||||
|
||||
// ── Measurement distances ──
|
||||
const segmentDistances = useMemo(() => {
|
||||
if (!measurePoints || measurePoints.length < 2) return [];
|
||||
const dists: string[] = [];
|
||||
let total = 0;
|
||||
for (let i = 1; i < measurePoints.length; i++) {
|
||||
const d = haversine(measurePoints[i - 1], measurePoints[i]);
|
||||
total += d;
|
||||
if (unit === "km") dists.push(fmtMetric(d / 1000));
|
||||
else dists.push(fmtImperial(d * MILES_PER_METER));
|
||||
}
|
||||
if (measurePoints.length > 2) {
|
||||
if (unit === "km") dists.push(`Σ ${fmtMetric(total / 1000)}`);
|
||||
else dists.push(`Σ ${fmtImperial(total * MILES_PER_METER)}`);
|
||||
}
|
||||
return dists;
|
||||
}, [measurePoints, unit]);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-3 select-none">
|
||||
{/* Scale ruler */}
|
||||
<div className="flex flex-col items-start">
|
||||
<div
|
||||
className="flex items-end relative"
|
||||
style={{ width: barWidth }}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{/* Left tick */}
|
||||
<div className="w-px h-2.5 bg-cyan-400 flex-shrink-0" />
|
||||
{/* Bar */}
|
||||
<div className="flex-1 h-px bg-cyan-400 relative" style={{ boxShadow: "0 0 6px rgba(0,255,255,0.3)" }}>
|
||||
{/* Graduation marks */}
|
||||
<div className="absolute left-1/4 top-0 w-px h-1.5 bg-cyan-400/50" />
|
||||
<div className="absolute left-1/2 top-0 w-px h-2 bg-cyan-400/70" />
|
||||
<div className="absolute left-3/4 top-0 w-px h-1.5 bg-cyan-400/50" />
|
||||
</div>
|
||||
{/* Draggable right tick */}
|
||||
<div
|
||||
className="w-2 h-3 bg-cyan-400/80 rounded-r cursor-ew-resize flex-shrink-0 hover:bg-cyan-300 transition-colors"
|
||||
onPointerDown={onPointerDown}
|
||||
title="Drag to resize scale"
|
||||
style={{ touchAction: "none" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-cyan-300 tracking-widest mt-0.5">{scaleLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Unit toggle */}
|
||||
<button
|
||||
onClick={() => setUnit(u => u === "mi" ? "km" : "mi")}
|
||||
className="text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-gray-700 hover:border-cyan-500/50 text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20 uppercase"
|
||||
title={`Switch to ${unit === "mi" ? "Metric (km)" : "Imperial (mi)"}`}
|
||||
>
|
||||
{unit === "mi" ? "MI" : "KM"}
|
||||
</button>
|
||||
|
||||
{/* Measure mode toggle */}
|
||||
<button
|
||||
onClick={onToggleMeasure}
|
||||
className={`flex items-center gap-1 text-[8px] font-mono tracking-widest px-2 py-0.5 rounded border transition-all ${measureMode
|
||||
? "border-cyan-500/60 text-cyan-400 bg-cyan-950/30 shadow-[0_0_8px_rgba(0,255,255,0.2)]"
|
||||
: "border-gray-700 text-gray-500 hover:text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-950/20"
|
||||
}`}
|
||||
title={measureMode ? "Exit measurement mode" : "Measure distance (click up to 3 points)"}
|
||||
>
|
||||
<Ruler size={10} />
|
||||
{measureMode ? "MEASURING" : "MEASURE"}
|
||||
</button>
|
||||
|
||||
{/* Clear measurements */}
|
||||
{measureMode && measurePoints && measurePoints.length > 0 && (
|
||||
<button
|
||||
onClick={onClearMeasure}
|
||||
className="flex items-center gap-1 text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-gray-700 text-gray-500 hover:text-red-400 hover:border-red-500/50 hover:bg-red-950/20 transition-all"
|
||||
title="Clear all waypoints"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Segment distances readout */}
|
||||
{segmentDistances.length > 0 && (
|
||||
<div className="flex items-center gap-2 ml-1">
|
||||
{segmentDistances.map((d, i) => (
|
||||
<span key={i} className={`text-[9px] font-mono tracking-wider px-1.5 py-0.5 rounded border ${d.startsWith("Σ")
|
||||
? "border-cyan-500/50 text-cyan-300 bg-cyan-950/30"
|
||||
: "border-gray-700 text-gray-400"
|
||||
}`}>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Haversine distance in meters between two lat/lng points. */
|
||||
function haversine(a: MeasurePoint, b: MeasurePoint): number {
|
||||
const R = 6371000;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||
const sa = Math.sin(dLat / 2);
|
||||
const sb = Math.sin(dLng / 2);
|
||||
const h = sa * sa + Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * sb * sb;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
export default React.memo(ScaleBar);
|
||||
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
interface ApiEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
url: string | null;
|
||||
required: boolean;
|
||||
has_key: boolean;
|
||||
env_key: string | null;
|
||||
value_obfuscated: string | null;
|
||||
value_plain: string | null;
|
||||
}
|
||||
|
||||
// Category colors for the tactical UI
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
|
||||
Maritime: "text-blue-400 border-blue-500/30 bg-blue-950/20",
|
||||
Geophysical: "text-orange-400 border-orange-500/30 bg-orange-950/20",
|
||||
Space: "text-purple-400 border-purple-500/30 bg-purple-950/20",
|
||||
Intelligence: "text-red-400 border-red-500/30 bg-red-950/20",
|
||||
Geolocation: "text-green-400 border-green-500/30 bg-green-950/20",
|
||||
Weather: "text-yellow-400 border-yellow-500/30 bg-yellow-950/20",
|
||||
Markets: "text-emerald-400 border-emerald-500/30 bg-emerald-950/20",
|
||||
SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20",
|
||||
};
|
||||
|
||||
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);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/settings/api-keys");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setApis(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch API keys", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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 || "");
|
||||
};
|
||||
|
||||
const saveKey = async (api: ApiEntry) => {
|
||||
if (!api.env_key) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/settings/api-keys", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEditingId(null);
|
||||
fetchKeys(); // Refresh to get new obfuscated value
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to save API key", e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cat)) next.delete(cat);
|
||||
else next.add(cat);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Group APIs by category
|
||||
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
||||
if (!acc[api.category]) acc[api.category] = [];
|
||||
acc[api.category].push(api);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -300 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -300 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed left-0 top-0 bottom-0 w-[480px] bg-gray-950/95 backdrop-blur-xl border-r border-cyan-900/50 z-[9999] flex flex-col shadow-[4px_0_40px_rgba(0,0,0,0.8)]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-800/80">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
||||
<Settings size={16} className="text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">SYSTEM CONFIG</h2>
|
||||
<span className="text-[9px] text-gray-500 font-mono tracking-widest">API KEY REGISTRY</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg border border-gray-700 hover:border-red-500/50 flex items-center justify-center text-gray-500 hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] text-gray-400 font-mono leading-relaxed">
|
||||
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API List */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
||||
{Object.entries(grouped).map(([category, categoryApis]) => {
|
||||
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
|
||||
return (
|
||||
<div key={category} className="rounded-lg border border-gray-800/60 overflow-hidden">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-gray-900/50 hover:bg-gray-900/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
||||
{category.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 font-mono">
|
||||
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp size={12} className="text-gray-500" /> : <ChevronDown size={12} className="text-gray-500" />}
|
||||
</button>
|
||||
|
||||
{/* APIs in Category */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{categoryApis.map((api) => (
|
||||
<div key={api.id} className="border-t border-gray-800/40 px-4 py-3 hover:bg-gray-900/30 transition-colors">
|
||||
{/* API Name + Status */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{api.required && <Key size={10} className="text-yellow-500" />}
|
||||
<span className="text-xs font-mono text-white font-medium">{api.name}</span>
|
||||
</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>
|
||||
) : (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-gray-700 text-gray-500">
|
||||
PUBLIC
|
||||
</span>
|
||||
)}
|
||||
{api.url && (
|
||||
<a
|
||||
href={api.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-cyan-400 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[10px] text-gray-500 font-mono leading-relaxed mb-2">
|
||||
{api.description}
|
||||
</p>
|
||||
|
||||
{/* Key Field (only for APIs with keys) */}
|
||||
{api.has_key && (
|
||||
<div className="mt-2">
|
||||
{editingId === api.id ? (
|
||||
/* Edit Mode */
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors"
|
||||
placeholder="Enter API key..."
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveKey(api)}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1"
|
||||
>
|
||||
<Save size={10} />
|
||||
{saving ? "..." : "SAVE"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="px-2 py-1.5 rounded border border-gray-700 text-gray-500 hover:text-white hover:border-gray-600 transition-colors text-[10px] font-mono"
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Display Mode */
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="flex-1 bg-black/40 border border-gray-800 rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-gray-700 transition-colors select-none"
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-800/80">
|
||||
<div className="flex items-center justify-between text-[9px] text-gray-600 font-mono">
|
||||
<span>{apis.length} REGISTERED APIs</span>
|
||||
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
// Module-level cache: Wikipedia article title → thumbnail URL
|
||||
const _cache: Record<string, { url: string | null; done: boolean }> = {};
|
||||
|
||||
/**
|
||||
* WikiImage — displays a Wikipedia thumbnail for a given article URL.
|
||||
* Uses the Wikipedia REST API with a module-level cache (only fetches once per article).
|
||||
*
|
||||
* Props:
|
||||
* wikiUrl: Full Wikipedia URL, e.g. "https://en.wikipedia.org/wiki/Boeing_787_Dreamliner"
|
||||
* label: Alt text / label for the image link
|
||||
* maxH: Max height class (default "max-h-32")
|
||||
* accent: Border hover color class (default "hover:border-cyan-500/50")
|
||||
*/
|
||||
export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent = 'hover:border-cyan-500/50' }: {
|
||||
wikiUrl: string;
|
||||
label?: string;
|
||||
maxH?: string;
|
||||
accent?: string;
|
||||
}) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// Extract article title from URL
|
||||
const title = wikiUrl.replace(/^https?:\/\/[^/]+\/wiki\//, '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!title || _cache[title]?.done) return;
|
||||
if (_cache[title]) return; // In-flight
|
||||
_cache[title] = { url: null, done: false };
|
||||
|
||||
fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
_cache[title] = { url: d.thumbnail?.source || d.originalimage?.source || null, done: true };
|
||||
forceUpdate(n => n + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
_cache[title] = { url: null, done: true };
|
||||
forceUpdate(n => n + 1);
|
||||
});
|
||||
}, [title]);
|
||||
|
||||
const cached = _cache[title];
|
||||
const imgUrl = cached?.url;
|
||||
const loading = cached && !cached.done;
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
{loading && (
|
||||
<div className={`w-full h-20 rounded bg-gray-800/60 animate-pulse`} />
|
||||
)}
|
||||
{imgUrl && (
|
||||
<a href={wikiUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={label || title.replace(/_/g, ' ')}
|
||||
className={`w-full h-auto ${maxH} object-cover rounded border border-gray-700/50 ${accent} transition-colors`}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<a href={wikiUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="text-[10px] text-cyan-400 hover:text-cyan-300 underline mt-1 inline-block font-mono">
|
||||
📖 {label || title.replace(/_/g, ' ')} — Wikipedia →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, BookOpen, Radio } from "lucide-react";
|
||||
|
||||
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void }) {
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
|
||||
// Compute ship category counts
|
||||
const importantShipCount = data?.ships?.filter((s: any) => ['carrier', 'military_vessel', 'tanker', 'cargo'].includes(s.type))?.length || 0;
|
||||
const passengerShipCount = data?.ships?.filter((s: any) => s.type === 'passenger')?.length || 0;
|
||||
const civilianShipCount = data?.ships?.filter((s: any) => !['carrier', 'military_vessel', 'tanker', 'cargo', 'passenger'].includes(s.type))?.length || 0;
|
||||
|
||||
const layers = [
|
||||
{ id: "flights", name: "Commercial Flights", source: "adsb.lol", count: data?.commercial_flights?.length || 0, icon: Plane },
|
||||
{ id: "private", name: "Private Flights", source: "adsb.lol", count: data?.private_flights?.length || 0, icon: Plane },
|
||||
{ id: "jets", name: "Private Jets", source: "adsb.lol", count: data?.private_jets?.length || 0, icon: Plane },
|
||||
{ id: "military", name: "Military Flights", source: "adsb.lol", count: data?.military_flights?.length || 0, icon: AlertTriangle },
|
||||
{ id: "tracked", name: "Tracked Aircraft", source: "Plane-Alert DB", count: data?.tracked_flights?.length || 0, icon: Eye },
|
||||
{ id: "earthquakes", name: "Earthquakes (24h)", source: "USGS", count: data?.earthquakes?.length || 0, icon: Activity },
|
||||
{ id: "satellites", name: "Satellites", source: "CelesTrak SGP4", count: data?.satellites?.length || 0, icon: Satellite },
|
||||
{ id: "ships_important", name: "Carriers / Mil / Cargo", source: "AIS Stream", count: importantShipCount, icon: Ship },
|
||||
{ id: "ships_civilian", name: "Civilian Vessels", source: "AIS Stream", count: civilianShipCount, icon: Anchor },
|
||||
{ id: "ships_passenger", name: "Cruise / Passenger", source: "AIS Stream", count: passengerShipCount, icon: Anchor },
|
||||
{ id: "ukraine_frontline", name: "Ukraine Frontline", source: "DeepStateMap", count: data?.frontlines ? 1 : 0, icon: AlertTriangle },
|
||||
{ id: "global_incidents", name: "Global Incidents", source: "GDELT", count: data?.gdelt?.length || 0, icon: Activity },
|
||||
{ id: "cctv", name: "CCTV Mesh", source: "CCTV Mesh + Street View", count: data?.cctv?.length || 0, icon: Cctv },
|
||||
{ id: "gps_jamming", name: "GPS Jamming", source: "ADS-B NACp", count: data?.gps_jamming?.length || 0, icon: Radio },
|
||||
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
|
||||
];
|
||||
|
||||
const shipIcon = <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1 .6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1" /><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-9-4-9 4c0 2.9.94 5.34 2.81 7.76" /><path d="M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6" /></svg>;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="w-full flex-1 min-h-0 flex flex-col pointer-events-none"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-6 pointer-events-auto">
|
||||
<div className="text-[10px] text-gray-400 font-mono tracking-widest mb-1">TOP SECRET // SI-TK // NOFORN</div>
|
||||
<div className="text-[10px] text-gray-500 font-mono tracking-widest mb-4">KH11-4094 OPS-4168</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-[0.2em] text-cyan-50">FLIR</h1>
|
||||
{onSettingsClick && (
|
||||
<button
|
||||
onClick={onSettingsClick}
|
||||
className="w-7 h-7 rounded-lg border border-gray-700 hover:border-cyan-500/50 flex items-center justify-center text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20 group"
|
||||
title="System Settings"
|
||||
>
|
||||
<Settings size={14} className="group-hover:rotate-90 transition-transform duration-300" />
|
||||
</button>
|
||||
)}
|
||||
{onLegendClick && (
|
||||
<button
|
||||
onClick={onLegendClick}
|
||||
className="h-7 px-2 rounded-lg border border-gray-700 hover:border-cyan-500/50 flex items-center justify-center gap-1 text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20"
|
||||
title="Map Legend / Icon Key"
|
||||
>
|
||||
<BookOpen size={12} />
|
||||
<span className="text-[8px] font-mono tracking-widest font-bold">KEY</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Layers Box */}
|
||||
<div className="bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.5)] flex flex-col relative overflow-hidden max-h-full">
|
||||
|
||||
{/* Header / Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DATA LAYERS</span>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
{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="overflow-y-auto styled-scrollbar"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-4 pt-2 pb-6">
|
||||
{layers.map((layer, idx) => {
|
||||
const Icon = layer.icon;
|
||||
const active = activeLayers[layer.id as keyof typeof activeLayers] || false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start justify-between group cursor-pointer"
|
||||
onClick={() => setActiveLayers((prev: any) => ({ ...prev, [layer.id]: !active }))}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className={`mt-1 ${active ? 'text-cyan-400' : 'text-gray-600 group-hover:text-gray-400'} transition-colors`}>
|
||||
{(['ships_important', 'ships_civilian', 'ships_passenger'].includes(layer.id)) ? shipIcon : <Icon size={16} strokeWidth={1.5} />}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-sm font-medium ${active ? 'text-white' : 'text-gray-400'} tracking-wide`}>{layer.name}</span>
|
||||
<span className="text-[9px] text-gray-600 font-mono tracking-wider mt-0.5">{layer.source} · {active ? 'LIVE' : 'OFF'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{active && layer.count > 0 && (
|
||||
<span className="text-[10px] text-gray-300 font-mono">{layer.count.toLocaleString()}</span>
|
||||
)}
|
||||
<div className={`text-[9px] font-mono tracking-wider px-2 py-0.5 rounded-full border ${active
|
||||
? 'border-cyan-500/50 text-cyan-400 bg-cyan-950/30 shadow-[0_0_10px_rgba(34,211,238,0.2)]'
|
||||
: 'border-gray-800 text-gray-600 bg-transparent'
|
||||
}`}>
|
||||
{active ? 'ON' : 'OFF'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorldviewLeftPanel;
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, setEffects, setUiVisible }: { effects: any; setEffects: any; setUiVisible: any }) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState({ date: "XXXX-XX-XX", time: "00:00:00" });
|
||||
|
||||
useEffect(() => {
|
||||
const updateTime = () => {
|
||||
const now = new Date();
|
||||
setCurrentTime({
|
||||
date: now.toISOString().slice(0, 10),
|
||||
time: now.toISOString().slice(11, 19)
|
||||
});
|
||||
};
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className={`w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 flex-shrink-0 ${isMinimized ? 'h-[50px]' : 'h-[320px]'}`}
|
||||
>
|
||||
{/* Record / Orbit Tracker Header */}
|
||||
<div className="flex items-center gap-3 mb-6 border border-gray-800 bg-black/40 backdrop-blur-md px-4 py-2 rounded-sm relative shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto">
|
||||
<div className="absolute -top-1 -left-1 w-2 h-2 border-t border-l border-gray-500/50"></div>
|
||||
<div className="absolute -bottom-1 -right-1 w-2 h-2 border-b border-r border-gray-500/50"></div>
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
||||
<div className="text-[10px] font-mono text-gray-400 tracking-wider">
|
||||
REC {currentTime.date} {currentTime.time}
|
||||
<br />
|
||||
ORB: 47696 PASS: DESC-284
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side controls box */}
|
||||
<div className="bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl pointer-events-auto border-r-2 border-r-cyan-900 flex flex-col relative overflow-hidden h-full">
|
||||
|
||||
{/* Header / Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DISPLAY CONFIG</span>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
{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="overflow-y-auto styled-scrollbar"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-4 pt-4">
|
||||
|
||||
{/* Bloom Toggle */}
|
||||
<div
|
||||
className={`flex items-center justify-between group cursor-pointer border rounded px-4 py-3 transition-colors ${effects.bloom ? 'border-yellow-900/50 bg-yellow-950/10' : 'border-gray-800'}`}
|
||||
onClick={() => setEffects({ ...effects, bloom: !effects.bloom })}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-gray-600'}`}>✧</span>
|
||||
<span className={`text-xs font-mono tracking-widest ${effects.bloom ? 'text-white' : 'text-gray-500'}`}>BLOOM</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono tracking-wider text-gray-500">{effects.bloom ? 'ON' : 'OFF'}</span>
|
||||
</div>
|
||||
|
||||
{/* Sharpen Slider */}
|
||||
<div className="flex flex-col gap-3 group border border-cyan-900/50 bg-cyan-950/10 rounded px-4 py-3 pb-4 relative overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-cyan-500"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full border border-cyan-400 flex items-center justify-center relative">
|
||||
<span className="w-1.5 h-1.5 bg-cyan-400 rounded-full"></span>
|
||||
</span>
|
||||
<span className="text-xs font-mono tracking-widest text-cyan-400 font-bold">SHARPEN</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 mt-1">
|
||||
<div className="h-0.5 bg-gray-800 flex-1 relative rounded-full">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[49%] bg-cyan-500 shadow-[0_0_10px_rgba(34,211,238,0.5)]"></div>
|
||||
<div className="absolute left-[49%] top-1/2 -translate-y-1/2 w-2 h-2 bg-white rounded-full"></div>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-cyan-400">49%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HUD Dropdown */}
|
||||
<div className="flex flex-col gap-2 relative">
|
||||
<div className="flex items-center gap-3 border border-gray-800 rounded px-4 py-3 text-gray-500 cursor-default">
|
||||
<span className="w-3 h-3 border border-gray-500 rounded-full flex items-center justify-center"></span>
|
||||
<span className="text-xs font-mono tracking-widest">HUD</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border border-gray-800 rounded px-4 py-2 mt-1 bg-black/50">
|
||||
<span className="text-[10px] text-gray-500 font-mono">LAYOUT</span>
|
||||
<span className="text-xs text-white tracking-widest border-b border-dashed border-gray-600 pb-0.5 cursor-pointer flex items-center gap-2">
|
||||
Tactical
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full border border-red-900/30 bg-red-950/10 rounded py-3 mt-2 text-[10px] font-mono tracking-widest text-red-500 hover:text-white hover:bg-red-900 hover:border-red-600 transition-all font-bold"
|
||||
onClick={() => setUiVisible(false)}
|
||||
>
|
||||
CLEAR UI (TACTICAL MODE)
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorldviewRightPanel;
|
||||
Reference in New Issue
Block a user