mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-11 13:10:24 +02:00
feat: add Chinese (zh-CN) localization with i18n infrastructure (#226)
Introduce a lightweight i18n system with auto-detection of browser language and localStorage persistence. Add complete Chinese translations for all major UI sections: navigation, controls, update dialogs, node activation, terminal launcher, data layers, settings, filters, and more. Technical terms (Wormhole, Infonet, Mesh, Shodan, SAR, etc.) are intentionally kept in English. Falls back to English when Chinese translation is not found. Co-authored-by: wangsudong <wangsudong@kylinos.cn>
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import type { AIIntelPin, AIIntelLayer, SatelliteScene } from '@/types/aiIntel';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import {
|
||||
createLayer as apiCreateLayer,
|
||||
@@ -1039,6 +1040,7 @@ export default function AIIntelPanel({
|
||||
pinPlacementMode,
|
||||
onPinPlacementModeChange,
|
||||
}: AIIntelPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [internalMinimized, setInternalMinimized] = useState(true);
|
||||
const isMinimized = isMinimizedProp !== undefined ? isMinimizedProp : internalMinimized;
|
||||
const setIsMinimized = (val: boolean | ((prev: boolean) => boolean)) => {
|
||||
@@ -1293,7 +1295,7 @@ export default function AIIntelPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain size={16} className="text-violet-400" />
|
||||
<span className="text-[12px] text-violet-400 font-mono tracking-widest font-bold">
|
||||
AI INTEL
|
||||
{t('ai.title').toUpperCase()}
|
||||
</span>
|
||||
{totalPins > 0 && (
|
||||
<span className="text-[11px] font-mono px-1.5 py-0.5 bg-violet-500/20 border border-violet-500/40 text-violet-300">
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import AdvancedFilterModal from './AdvancedFilterModal';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import { airlineNames } from '../lib/airlineCodes';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import { trackedCategories, trackedOperators } from '../lib/trackedData';
|
||||
|
||||
interface FilterPanelProps {
|
||||
@@ -36,6 +37,7 @@ type ModalConfig = {
|
||||
};
|
||||
|
||||
const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFilters }: FilterPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = useDataKeys(['commercial_flights', 'private_flights', 'private_jets', 'military_flights', 'tracked_flights', 'ships'] as const);
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [openModal, setOpenModal] = useState<string | null>(null);
|
||||
@@ -310,7 +312,7 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-cyan-400" />
|
||||
<span className="text-[12px] text-cyan-400 font-mono tracking-widest font-bold">
|
||||
DATA FILTERS
|
||||
{t('filters.title').toUpperCase()}
|
||||
</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="text-[11px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 font-mono">
|
||||
@@ -338,7 +340,7 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi
|
||||
onClick={clearAll}
|
||||
className="text-[10px] text-red-400 hover:text-red-300 font-mono tracking-widest self-end mb-1"
|
||||
>
|
||||
CLEAR ALL FILTERS
|
||||
{t('filters.clear').toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Search, Crosshair, Plane, Shield, Star, Ship, X, Database } from 'lucid
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { trackedOperators } from '../lib/trackedData';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import { useTranslation } from '@/i18n';
|
||||
|
||||
interface FindLocateBarProps {
|
||||
onLocate: (lat: number, lng: number, entityId: string, entityType: string) => void;
|
||||
@@ -24,6 +25,7 @@ interface SearchResult {
|
||||
}
|
||||
|
||||
const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: FindLocateBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = useDataKeys(['commercial_flights', 'private_flights', 'private_jets', 'military_flights', 'tracked_flights', 'ships'] as const);
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -184,7 +186,7 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }:
|
||||
name="sb-locate-search"
|
||||
autoComplete="off"
|
||||
data-search-input
|
||||
placeholder="Search aircraft, person or vessel..."
|
||||
placeholder={t('map.searchPlaceholder')}
|
||||
className="flex-1 bg-transparent text-[12px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-slate-500"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import ExternalImage from '@/components/ExternalImage';
|
||||
import { useTranslation } from '@/i18n';
|
||||
|
||||
// ─── Inline SVG legend icons (small, crisp, no external deps) ───
|
||||
const plane = (fill: string, size = 16) =>
|
||||
@@ -309,6 +310,7 @@ const MapLegend = React.memo(function MapLegend({
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = (name: string) => {
|
||||
@@ -362,7 +364,7 @@ const MapLegend = React.memo(function MapLegend({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">
|
||||
MAP LEGEND
|
||||
{t('legend.title').toUpperCase()}
|
||||
</h2>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||
ICON REFERENCE KEY
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Ruler, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/i18n';
|
||||
|
||||
/**
|
||||
* Dynamic Scale Bar with:
|
||||
@@ -49,6 +50,7 @@ function ScaleBar({
|
||||
onToggleMeasure,
|
||||
onClearMeasure,
|
||||
}: ScaleBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [unit, setUnit] = useState<'mi' | 'km'>('mi');
|
||||
const [barWidth, setBarWidth] = useState(120); // current bar width in px
|
||||
const dragging = useRef(false);
|
||||
@@ -165,7 +167,7 @@ function ScaleBar({
|
||||
title={measureMode ? 'Exit measurement mode' : 'Measure distance (click up to 3 points)'}
|
||||
>
|
||||
<Ruler size={10} />
|
||||
{measureMode ? 'MEASURING' : 'MEASURE'}
|
||||
{measureMode ? 'MEASURING' : t('map.measure')}
|
||||
</button>
|
||||
|
||||
{/* Clear measurements */}
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
setPrivacyStrictPreference,
|
||||
setSessionModePreference,
|
||||
} from '@/lib/privacyBrowserStorage';
|
||||
import { useTranslation } from '@/i18n';
|
||||
|
||||
interface ApiEntry {
|
||||
id: string;
|
||||
@@ -245,6 +246,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
// settings are authenticated through Rust-side admin-key ownership. The
|
||||
// browser admin-session flow is unnecessary and unavailable in packaged mode.
|
||||
const nativeProtected = isNativeProtectedSettingsReady();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// --- Admin Key (for protected endpoints) ---
|
||||
const [adminKey, setAdminKey] = useState('');
|
||||
@@ -1127,7 +1129,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">
|
||||
SYSTEM CONFIG
|
||||
{t('settings.title').toUpperCase()}
|
||||
</h2>
|
||||
<span className="text-[13px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||
SETTINGS & DATA SOURCES
|
||||
@@ -1273,14 +1275,14 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === 'api-keys' ? 'text-cyan-400 border-b-2 border-cyan-500 bg-cyan-950/10' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
||||
>
|
||||
<Key size={10} />
|
||||
API KEYS
|
||||
{t('settings.general').toUpperCase()}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('news-feeds')}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === 'news-feeds' ? 'text-orange-400 border-b-2 border-orange-500 bg-orange-950/10' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
||||
>
|
||||
<Rss size={10} />
|
||||
NEWS FEEDS
|
||||
{t('settings.feeds').toUpperCase()}
|
||||
{feedsDirty && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse" />
|
||||
)}
|
||||
@@ -1290,21 +1292,21 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === 'sentinel' ? 'text-purple-400 border-b-2 border-purple-500 bg-purple-950/10' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
||||
>
|
||||
<Satellite size={10} />
|
||||
SENTINEL
|
||||
{t('settings.shodan').toUpperCase()}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sar')}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === 'sar' ? 'text-amber-400 border-b-2 border-amber-500 bg-amber-950/10' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
||||
>
|
||||
<Radar size={10} />
|
||||
SAR
|
||||
{t('settings.sar').toUpperCase()}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('protocol')}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === 'protocol' ? 'text-green-400 border-b-2 border-green-500 bg-green-950/10' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
||||
>
|
||||
<Shield size={10} />
|
||||
MESH
|
||||
{t('settings.infonet').toUpperCase()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
ShodanMarkerSize,
|
||||
} from '@/types/shodan';
|
||||
import { countShodan, fetchShodanStatus, lookupShodanHost, searchShodan } from '@/lib/shodanClient';
|
||||
import { useTranslation } from '@/i18n';
|
||||
|
||||
type Mode = 'search' | 'count' | 'host';
|
||||
type ShodanPreset = {
|
||||
@@ -177,6 +178,7 @@ export default function ShodanPanel({
|
||||
onMinimizedChange,
|
||||
settingsOpen,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [internalMinimized, setInternalMinimized] = useState(true);
|
||||
const isMinimized = isMinimizedProp !== undefined ? isMinimizedProp : internalMinimized;
|
||||
const setIsMinimized = (val: boolean | ((prev: boolean) => boolean)) => {
|
||||
@@ -506,7 +508,7 @@ export default function ShodanPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
<Radar size={16} className="text-green-400" />
|
||||
<span className="text-[12px] font-mono font-bold tracking-widest text-green-400">
|
||||
SHODAN
|
||||
{t('shodan.title').toUpperCase()}
|
||||
</span>
|
||||
{currentResults.length > 0 && (
|
||||
<span className="text-[11px] font-mono px-1.5 py-0.5 bg-green-900/30 border border-green-700/30 text-green-300">
|
||||
@@ -619,7 +621,7 @@ export default function ShodanPanel({
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (mode === 'search' ? void handleSearch() : void handleCount())}
|
||||
placeholder='port:443 org:"Amazon"'
|
||||
placeholder={t('shodan.searchPlaceholder')}
|
||||
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1 text-green-300 outline-none transition-colors focus:border-green-500/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import { controlPlaneFetch } from '@/lib/controlPlane';
|
||||
import {
|
||||
checkDesktopUpdaterUpdate,
|
||||
@@ -83,6 +84,7 @@ export default function TopRightControls({
|
||||
dmCount,
|
||||
onMeshChatNavigate,
|
||||
}: TopRightControlsProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
const [latestVersion, setLatestVersion] = useState<string>('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
@@ -556,7 +558,7 @@ export default function TopRightControls({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--border-primary)]">
|
||||
<span className="text-[10px] font-mono tracking-widest text-cyan-400">
|
||||
UPDATE v{currentVersion} → v{latestVersion}
|
||||
{t('update.autoUpdate').toUpperCase()} v{currentVersion} → v{latestVersion}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setUpdateStatus('available')}
|
||||
@@ -577,10 +579,10 @@ export default function TopRightControls({
|
||||
>
|
||||
<Download size={12} />
|
||||
{updateAction === 'manual_download'
|
||||
? 'DOWNLOAD INSTALLER'
|
||||
? t('update.downloadInstaller')
|
||||
: updateAction === 'desktop_updater'
|
||||
? 'INSTALL UPDATE'
|
||||
: 'AUTO UPDATE'}
|
||||
? t('update.installUpdate')
|
||||
: t('update.autoUpdate')}
|
||||
</button>
|
||||
|
||||
<a
|
||||
@@ -590,14 +592,14 @@ export default function TopRightControls({
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] hover:border-[var(--text-muted)] transition-all text-[10px] text-[var(--text-muted)] font-mono tracking-widest"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
{updateAction === 'manual_download' ? 'VIEW RELEASE' : 'MANUAL DOWNLOAD'}
|
||||
{updateAction === 'manual_download' ? t('update.viewRelease') : t('update.manualDownload')}
|
||||
</a>
|
||||
|
||||
<button
|
||||
onClick={() => setUpdateStatus('available')}
|
||||
className="w-full flex items-center justify-center px-3 py-1.5 text-[9px] text-[var(--text-muted)] font-mono tracking-widest hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
CANCEL
|
||||
{t('update.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -609,7 +611,7 @@ export default function TopRightControls({
|
||||
<div className="absolute top-full right-0 mt-2 w-72 z-[9999]">
|
||||
<div className="bg-[var(--bg-primary)]/95 backdrop-blur-sm border border-red-800/60 shadow-[0_4px_30px_rgba(255,0,0,0.1)] overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-red-900/40">
|
||||
<span className="text-[10px] font-mono tracking-widest text-red-400">UPDATE FAILED</span>
|
||||
<span className="text-[10px] font-mono tracking-widest text-red-400">{t('update.updateFailed')}</span>
|
||||
</div>
|
||||
<div className="p-3 flex flex-col gap-2">
|
||||
<p className="text-[9px] font-mono text-[var(--text-muted)] leading-relaxed break-words">
|
||||
@@ -620,7 +622,7 @@ export default function TopRightControls({
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-cyan-500/10 border border-cyan-500/40 hover:bg-cyan-500/20 transition-all text-[10px] text-cyan-400 font-mono tracking-widest"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
TRY AGAIN
|
||||
{t('update.tryAgain')}
|
||||
</button>
|
||||
<a
|
||||
href={updateAction === 'manual_download' ? releasePageUrl : manualUpdateUrl}
|
||||
@@ -629,7 +631,7 @@ export default function TopRightControls({
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] hover:border-[var(--text-muted)] transition-all text-[10px] text-[var(--text-muted)] font-mono tracking-widest"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
{updateAction === 'manual_download' ? 'VIEW RELEASE' : 'MANUAL DOWNLOAD'}
|
||||
{updateAction === 'manual_download' ? t('update.viewRelease') : t('update.manualDownload')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -642,7 +644,7 @@ export default function TopRightControls({
|
||||
<div className="bg-[var(--bg-primary)]/95 backdrop-blur-sm border border-cyan-800/60 shadow-[0_4px_30px_rgba(0,255,255,0.15)] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--border-primary)]">
|
||||
<span className="text-[10px] font-mono tracking-widest text-cyan-400">
|
||||
DOCKER UPDATE — v{latestVersion}
|
||||
{t('update.dockerUpdate')} — v{latestVersion}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setUpdateStatus('idle')}
|
||||
@@ -653,8 +655,7 @@ export default function TopRightControls({
|
||||
</div>
|
||||
<div className="p-3 flex flex-col gap-2">
|
||||
<p className="text-[9px] font-mono text-[var(--text-muted)] leading-relaxed">
|
||||
Docker containers must be updated by pulling new images.
|
||||
Run this on your host machine:
|
||||
{t('update.dockerUpdateDetail')}
|
||||
</p>
|
||||
<div className="relative bg-black/40 border border-[var(--border-primary)] p-2 group">
|
||||
<code className="text-[9px] font-mono text-green-400 break-all">{dockerCommands}</code>
|
||||
@@ -673,7 +674,7 @@ export default function TopRightControls({
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] hover:border-[var(--text-muted)] transition-all text-[10px] text-[var(--text-muted)] font-mono tracking-widest"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
VIEW RELEASE
|
||||
{t('update.viewRelease')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -743,12 +744,12 @@ export default function TopRightControls({
|
||||
<div>
|
||||
<div className="text-[10px] font-mono tracking-[0.24em] text-cyan-300">
|
||||
{nodeStep === 'disable'
|
||||
? 'NODE ACTIVATED'
|
||||
? t('node.nodeActivated')
|
||||
: nodeStep === 'activating'
|
||||
? 'ACTIVATING NODE'
|
||||
? t('node.activatingNode')
|
||||
: nodeStep === 'prompt'
|
||||
? 'ACTIVATE NODE'
|
||||
: 'STIPULATIONS'}
|
||||
? t('node.activateNode')
|
||||
: t('node.stipulations')}
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] font-mono text-[var(--text-muted)]">
|
||||
{nodeMode} • {syncOutcome} • participant-node sync does not require Wormhole
|
||||
@@ -767,7 +768,7 @@ export default function TopRightControls({
|
||||
{nodeStep === 'disable' ? (
|
||||
<>
|
||||
<div className="border border-cyan-500/20 bg-cyan-950/10 px-4 py-4 text-[10px] font-mono text-cyan-100 leading-[1.8]">
|
||||
Node activated.
|
||||
{t('node.nodeActivated')}.
|
||||
{(() => { const id = getNodeIdentity(); return id?.nodeId ? (
|
||||
<div className="mt-2 text-[9px] text-cyan-400 font-mono tracking-wide">
|
||||
{id.nodeId}
|
||||
@@ -775,11 +776,11 @@ export default function TopRightControls({
|
||||
) : null; })()}
|
||||
<div className="mt-2 text-[9px] text-cyan-200/70 normal-case tracking-normal flex flex-wrap gap-x-3">
|
||||
<span>{syncOutcome.toLowerCase()}</span>
|
||||
{(nodeStatus?.total_events ?? 0) > 0 && <span>{nodeStatus?.total_events} events</span>}
|
||||
{(nodeStatus?.bootstrap?.sync_peer_count ?? 0) > 0 && <span>{nodeStatus?.bootstrap?.sync_peer_count} peers</span>}
|
||||
{(nodeStatus?.total_events ?? 0) > 0 && <span>{nodeStatus?.total_events} {t('node.events')}</span>}
|
||||
{(nodeStatus?.bootstrap?.sync_peer_count ?? 0) > 0 && <span>{nodeStatus?.bootstrap?.sync_peer_count} {t('node.peers')}</span>}
|
||||
</div>
|
||||
<div className="mt-3 text-[11px] text-[var(--text-muted)] normal-case tracking-normal leading-[1.8]">
|
||||
Your node keeps syncing as long as the backend is running — you can close this browser tab. To run a headless node without the dashboard, use <span className="text-cyan-400">meshnode.bat</span> (Windows) or <span className="text-cyan-400">meshnode.sh</span> (macOS/Linux).
|
||||
{t('node.keepSyncing')}
|
||||
</div>
|
||||
</div>
|
||||
{nodeToggleError && (
|
||||
@@ -794,7 +795,7 @@ export default function TopRightControls({
|
||||
disabled={nodeToggleBusy}
|
||||
className="px-4 py-3 border border-rose-500/40 bg-rose-950/20 hover:bg-rose-950/35 disabled:opacity-50 text-[11px] font-mono text-rose-300 tracking-[0.18em]"
|
||||
>
|
||||
{nodeToggleBusy ? 'TURNING OFF...' : 'TURN OFF'}
|
||||
{nodeToggleBusy ? t('node.turningOff') : t('node.turnOff')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -802,7 +803,7 @@ export default function TopRightControls({
|
||||
disabled={nodeToggleBusy}
|
||||
className="px-4 py-3 border border-[var(--border-primary)] hover:border-cyan-500/40 disabled:opacity-50 text-[11px] font-mono text-[var(--text-muted)] tracking-[0.18em]"
|
||||
>
|
||||
KEEP ON
|
||||
{t('node.keepOn')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -817,7 +818,7 @@ export default function TopRightControls({
|
||||
<CheckCircle2 size={11} className="text-green-400 shrink-0" />
|
||||
)}
|
||||
<span className={activatingPhase === 'keys' ? 'text-cyan-300' : 'text-green-300'}>
|
||||
{activatingPhase === 'keys' ? 'Generating identity...' : 'Identity ready'}
|
||||
{activatingPhase === 'keys' ? t('node.generatingIdentity') : t('node.identityReady')}
|
||||
</span>
|
||||
{activatingPhase !== 'keys' && (() => { const id = getNodeIdentity(); return id?.nodeId ? (
|
||||
<span className="text-[11px] text-cyan-400/70 ml-auto">{id.nodeId}</span>
|
||||
@@ -837,9 +838,9 @@ export default function TopRightControls({
|
||||
: activatingPhase === 'peers' ? 'text-cyan-300'
|
||||
: 'text-green-300'
|
||||
}>
|
||||
{activatingPhase === 'keys' ? 'Preparing onion transport...'
|
||||
: activatingPhase === 'peers' ? 'Finding bootstrap peers...'
|
||||
: 'Bootstrap peers ready'}
|
||||
{activatingPhase === 'keys' ? t('node.preparingTransport')
|
||||
: activatingPhase === 'peers' ? t('node.findingPeers')
|
||||
: t('node.peersReady')}
|
||||
</span>
|
||||
</div>
|
||||
{/* Step: Sync chain */}
|
||||
@@ -858,29 +859,28 @@ export default function TopRightControls({
|
||||
}>
|
||||
{activatingPhase === 'done'
|
||||
? (syncOutcomeRaw === 'solo'
|
||||
? `Solo node ready — ${nodeStatus?.total_events ?? 0} events`
|
||||
: `Synced — ${nodeStatus?.total_events ?? 0} events`)
|
||||
? `${t('node.soloReady')} — ${nodeStatus?.total_events ?? 0} ${t('node.events')}`
|
||||
: `${t('node.synced')} — ${nodeStatus?.total_events ?? 0} ${t('node.events')}`)
|
||||
: activatingPhase === 'sync'
|
||||
? `Syncing chain...${(nodeStatus?.total_events ?? 0) > 0 ? ` ${nodeStatus?.total_events} events` : ''}`
|
||||
: 'Syncing chain...'}
|
||||
? `${t('node.syncingChain')}${(nodeStatus?.total_events ?? 0) > 0 ? ` ${nodeStatus?.total_events} ${t('node.events')}` : ''}`
|
||||
: t('node.syncingChain')}
|
||||
</span>
|
||||
</div>
|
||||
{/* Done banner */}
|
||||
{activatingPhase === 'done' && (
|
||||
<>
|
||||
<div className="mt-2 border border-green-500/30 bg-green-950/20 px-3 py-2 text-[10px] font-mono text-green-300 tracking-[0.15em] text-center">
|
||||
NODE ONLINE
|
||||
{t('node.nodeOnline')}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-mono text-[var(--text-muted)] leading-[1.8] normal-case tracking-normal">
|
||||
Your node keeps syncing as long as the backend is running — you can close this browser tab.
|
||||
To run a headless node without the dashboard, use <span className="text-cyan-400">meshnode.bat</span> (Windows) or <span className="text-cyan-400">meshnode.sh</span> (macOS/Linux).
|
||||
{t('node.keepSyncing')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{activatingTimedOut && activatingPhase !== 'done' && (
|
||||
<div className="border border-amber-500/40 bg-amber-950/20 px-4 py-3 text-[9px] font-mono text-amber-200 leading-[1.7]">
|
||||
Sync is taking longer than expected. Your node is active and will continue syncing in the background.
|
||||
{t('node.syncTakingLong')}
|
||||
</div>
|
||||
)}
|
||||
{nodeToggleError && (
|
||||
@@ -894,17 +894,14 @@ export default function TopRightControls({
|
||||
onClick={closeLauncher}
|
||||
className="w-full px-4 py-3 border border-cyan-500/40 bg-cyan-950/20 hover:bg-cyan-950/35 text-[11px] font-mono text-cyan-300 tracking-[0.18em]"
|
||||
>
|
||||
CLOSE
|
||||
{t('node.close')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : nodeStep === 'prompt' ? (
|
||||
<>
|
||||
<div className="border border-cyan-500/20 bg-cyan-950/10 px-4 py-4 text-[10px] font-mono text-cyan-100 leading-[1.8]">
|
||||
Do you want to activate a node on this install?
|
||||
<div className="mt-2 text-[9px] text-cyan-200/70 normal-case tracking-normal">
|
||||
This turns on your local participant node and syncs Infonet only through available Wormhole onion/RNS peers. Clearnet bootstrap is disabled by default.
|
||||
</div>
|
||||
{t('node.activatePrompt')}
|
||||
</div>
|
||||
{(bootstrapFailed || nodeStatusError || nodeToggleError) && (
|
||||
<div className="border border-amber-500/40 bg-amber-950/20 px-4 py-3 text-[9px] font-mono text-amber-200 leading-[1.7]">
|
||||
@@ -917,27 +914,27 @@ export default function TopRightControls({
|
||||
onClick={() => setNodeStep('terms')}
|
||||
className="px-4 py-3 border border-cyan-500/40 bg-cyan-950/20 hover:bg-cyan-950/35 text-[11px] font-mono text-cyan-300 tracking-[0.18em]"
|
||||
>
|
||||
YES
|
||||
{t('node.yes')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeLauncher}
|
||||
className="px-4 py-3 border border-[var(--border-primary)] hover:border-cyan-500/40 text-[11px] font-mono text-[var(--text-muted)] tracking-[0.18em]"
|
||||
>
|
||||
NO
|
||||
{t('node.no')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="border border-cyan-500/20 bg-black/30 px-4 py-4 text-[9px] font-mono text-slate-200 leading-[1.85]">
|
||||
<div className="text-cyan-300 tracking-[0.18em]">BY CONTINUING YOU AGREE:</div>
|
||||
<div className="text-cyan-300 tracking-[0.18em]">{t('node.termsTitle')}</div>
|
||||
<ul className="mt-3 space-y-2 list-disc pl-5">
|
||||
<li>This install can keep a local copy of the public Infonet chain.</li>
|
||||
<li>Fresh installs do not use a clearnet Infonet seed.</li>
|
||||
<li>Participant-node sync requires an onion/RNS peer through Wormhole.</li>
|
||||
<li>Your backend may sync with configured private bootstrap peers in the background.</li>
|
||||
<li>Wormhole keeps Infonet, gates, Dead Drop, and DM traffic on the obfuscated lane.</li>
|
||||
<li>{t('node.term1')}</li>
|
||||
<li>{t('node.term2')}</li>
|
||||
<li>{t('node.term3')}</li>
|
||||
<li>{t('node.term4')}</li>
|
||||
<li>{t('node.term5')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-cyan-300/80">
|
||||
@@ -950,7 +947,7 @@ export default function TopRightControls({
|
||||
disabled={nodeToggleBusy}
|
||||
className="px-4 py-3 border border-cyan-500/40 bg-cyan-950/20 hover:bg-cyan-950/35 disabled:opacity-50 text-[11px] font-mono text-cyan-300 tracking-[0.18em]"
|
||||
>
|
||||
{nodeToggleBusy ? 'ACTIVATING...' : 'AGREE'}
|
||||
{nodeToggleBusy ? t('node.activating') : t('node.agree')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -958,7 +955,7 @@ export default function TopRightControls({
|
||||
disabled={nodeToggleBusy}
|
||||
className="px-4 py-3 border border-[var(--border-primary)] hover:border-cyan-500/40 disabled:opacity-50 text-[11px] font-mono text-[var(--text-muted)] tracking-[0.18em]"
|
||||
>
|
||||
DISAGREE
|
||||
{t('node.disagree')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -971,10 +968,10 @@ export default function TopRightControls({
|
||||
: null;
|
||||
|
||||
const terminalStatusLabel = terminalPrivateReady
|
||||
? 'PRIVATE LANE READY'
|
||||
? t('terminal.privateLaneReady')
|
||||
: terminalPrivateEnabled
|
||||
? 'PRIVATE LANE STARTING'
|
||||
: 'PRIVATE LANE OFFLINE';
|
||||
? t('terminal.privateLaneStarting')
|
||||
: t('terminal.privateLaneOffline');
|
||||
const terminalStatusTone = terminalPrivateReady
|
||||
? 'text-emerald-300'
|
||||
: terminalPrivateEnabled
|
||||
@@ -994,7 +991,7 @@ export default function TopRightControls({
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-cyan-900/30">
|
||||
<div>
|
||||
<div className="text-[13px] font-mono tracking-[0.24em] text-cyan-300">
|
||||
INFONET TERMINAL
|
||||
{t('terminal.infonetTerminal')}
|
||||
</div>
|
||||
<div className={`mt-1 text-[11px] font-mono ${terminalStatusTone}`}>
|
||||
{terminalStatusLabel} • {terminalTransportTier}
|
||||
@@ -1012,12 +1009,12 @@ export default function TopRightControls({
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
<div className="border border-cyan-500/20 bg-cyan-950/10 px-4 py-4 text-[13px] font-mono text-cyan-100 leading-[1.8]">
|
||||
{terminalPrivateReady
|
||||
? 'Enter the Wormhole-facing terminal and sync with the obfuscated Infonet commons?'
|
||||
: 'The terminal runs through Wormhole for obfuscated gates, inbox, and experimental comms.'}
|
||||
? t('terminal.enterTerminal')
|
||||
: t('terminal.terminalDetail')}
|
||||
<div className="mt-2 text-[12px] text-cyan-200/70 normal-case tracking-normal">
|
||||
{terminalPrivateReady
|
||||
? 'Your obfuscated identity is already provisioned. Entering now keeps the obfuscated lane separate from the public node sync path.'
|
||||
: 'This turns Wormhole on and opens the obfuscated lane. If you already have a Wormhole identity, it reuses it. If you do not, it bootstraps one once and then keeps using it.'}
|
||||
? t('terminal.enterTerminalDetail')
|
||||
: t('terminal.terminalDetailMore')}
|
||||
</div>
|
||||
</div>
|
||||
{terminalLaunchError && (
|
||||
@@ -1026,21 +1023,17 @@ export default function TopRightControls({
|
||||
</div>
|
||||
)}
|
||||
<div className="border border-cyan-500/20 bg-black/30 px-4 py-4 text-[12px] font-mono text-slate-200 leading-[1.85]">
|
||||
<div className="text-cyan-300 tracking-[0.18em]">BEFORE YOU ENTER:</div>
|
||||
<div className="text-cyan-300 tracking-[0.18em]">{t('terminal.beforeYouEnter')}</div>
|
||||
<ul className="mt-3 space-y-2 list-disc pl-5">
|
||||
<li>The terminal is for Wormhole gates (transitional private lane) and Dead Drop / DM (stronger private lane).</li>
|
||||
<li>Your participant node can stay active separately without changing this obfuscated identity lane.</li>
|
||||
<li>Mesh remains the public perimeter. Wormhole is the obfuscated commons.</li>
|
||||
<li>{t('terminal.term1')}</li>
|
||||
<li>{t('terminal.term2')}</li>
|
||||
<li>{t('terminal.term3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="border border-amber-500/20 bg-amber-950/10 px-4 py-3 text-[12px] font-mono text-amber-200/80 leading-[1.85]">
|
||||
<div className="text-amber-300 tracking-[0.18em]">WORMHOLE CLEANUP:</div>
|
||||
<div className="text-amber-300 tracking-[0.18em]">{t('terminal.wormholeCleanup')}</div>
|
||||
<div className="mt-2">
|
||||
Closing the Infonet terminal will shut down Wormhole automatically. If you force-close
|
||||
the browser or the shutdown fails, Wormhole may keep running in the background.
|
||||
Run <span className="text-amber-100 font-bold">killwormhole.bat</span> (Windows) or{' '}
|
||||
<span className="text-amber-100 font-bold">killwormhole.sh</span> (macOS/Linux)
|
||||
from the project root to ensure it is fully stopped.
|
||||
{t('terminal.wormholeCleanupDetail')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
@@ -1051,10 +1044,10 @@ export default function TopRightControls({
|
||||
className="px-4 py-3 border border-cyan-500/40 bg-cyan-950/20 hover:bg-cyan-950/35 disabled:opacity-50 text-[13px] font-mono text-cyan-300 tracking-[0.16em]"
|
||||
>
|
||||
{terminalLaunchBusy
|
||||
? 'ENTERING...'
|
||||
? t('terminal.entering')
|
||||
: terminalPrivateReady
|
||||
? 'ENTER WORMHOLE'
|
||||
: 'ACTIVATE WORMHOLE'}
|
||||
? t('terminal.enterWormhole')
|
||||
: t('terminal.activateWormhole')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1065,7 +1058,7 @@ export default function TopRightControls({
|
||||
disabled={terminalLaunchBusy}
|
||||
className="px-4 py-3 border border-[var(--border-primary)] hover:border-cyan-500/40 disabled:opacity-50 text-[13px] font-mono text-[var(--text-muted)] tracking-[0.16em]"
|
||||
>
|
||||
GO TO MESH
|
||||
{t('terminal.goToMesh')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1073,7 +1066,7 @@ export default function TopRightControls({
|
||||
disabled={terminalLaunchBusy}
|
||||
className="px-4 py-3 border border-[var(--border-primary)] hover:border-cyan-500/40 disabled:opacity-50 text-[13px] font-mono text-[var(--text-muted)] tracking-[0.16em]"
|
||||
>
|
||||
CANCEL
|
||||
{t('update.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1100,7 +1093,7 @@ export default function TopRightControls({
|
||||
title={nodeTitle}
|
||||
>
|
||||
<Server size={11} className="text-cyan-400" />
|
||||
<span className="tracking-wider">NODE</span>
|
||||
<span className="tracking-wider">{t('controls.node')}</span>
|
||||
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${nodeIndicatorClass}`} />
|
||||
</button>
|
||||
|
||||
@@ -1112,7 +1105,7 @@ export default function TopRightControls({
|
||||
title="Open Mesh Terminal"
|
||||
>
|
||||
<Terminal size={11} className="text-cyan-400" />
|
||||
<span className="tracking-wider">TERMINAL</span>
|
||||
<span className="tracking-wider">{t('controls.terminal')}</span>
|
||||
{(dmCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] font-bold rounded-full min-w-[14px] h-[14px] flex items-center justify-center px-0.5 shadow-[0_0_6px_rgba(239,68,68,0.5)]">
|
||||
{(dmCount ?? 0) > 9 ? '9+' : dmCount}
|
||||
@@ -1146,7 +1139,7 @@ export default function TopRightControls({
|
||||
{updateStatus === 'updating' && (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-cyan-500/10 backdrop-blur-sm border border-cyan-500/50 text-[10px] text-cyan-400 font-mono">
|
||||
<RefreshCw size={12} className="w-3 h-3 animate-spin" />
|
||||
<span className="tracking-widest">DOWNLOADING UPDATE...</span>
|
||||
<span className="tracking-widest">{t('update.downloadingUpdate')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1154,7 +1147,7 @@ export default function TopRightControls({
|
||||
{updateStatus === 'restarting' && (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-cyan-500/10 backdrop-blur-sm border border-cyan-500/50 text-[10px] text-cyan-400 font-mono shadow-[0_0_15px_rgba(0,255,255,0.2)]">
|
||||
<RefreshCw size={12} className="w-3 h-3 animate-spin" />
|
||||
<span className="tracking-widest">RESTARTING...</span>
|
||||
<span className="tracking-widest">{t('update.restarting')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1166,7 +1159,7 @@ export default function TopRightControls({
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-red-500/10 backdrop-blur-sm border border-red-500/50 hover:bg-red-500/20 transition-all text-[10px] text-red-400 font-mono"
|
||||
>
|
||||
<AlertCircle size={12} className="w-3 h-3" />
|
||||
<span className="tracking-widest">UPDATE FAILED</span>
|
||||
<span className="tracking-widest">{t('update.updateFailed')}</span>
|
||||
</button>
|
||||
{renderErrorDialog()}
|
||||
</>
|
||||
@@ -1180,7 +1173,7 @@ export default function TopRightControls({
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-cyan-500/10 backdrop-blur-sm border border-cyan-500/50 text-[10px] text-cyan-400 font-mono shadow-[0_0_15px_rgba(0,255,255,0.2)]"
|
||||
>
|
||||
<Terminal size={12} className="w-3 h-3" />
|
||||
<span className="tracking-widest">DOCKER UPDATE</span>
|
||||
<span className="tracking-widest">{t('update.dockerUpdate')}</span>
|
||||
</button>
|
||||
{renderDockerDialog()}
|
||||
</>
|
||||
@@ -1204,12 +1197,12 @@ export default function TopRightControls({
|
||||
|
||||
<span className="tracking-wider">
|
||||
{updateStatus === 'checking'
|
||||
? 'CHECKING...'
|
||||
? t('controls.checking')
|
||||
: updateStatus === 'uptodate'
|
||||
? 'UP TO DATE'
|
||||
? t('controls.upToDate')
|
||||
: updateStatus === 'error'
|
||||
? 'CHECK FAILED'
|
||||
: 'UPDATES'}
|
||||
? t('controls.checkFailed')
|
||||
: t('controls.updates')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { API_BASE } from '@/lib/api';
|
||||
import { onTileLoadingChange, resetTileLoading } from '@/lib/sentinelHub';
|
||||
import packageJson from '../../package.json';
|
||||
import { useTheme } from '@/lib/ThemeContext';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import SarModeChooserModal from './SarModeChooserModal';
|
||||
import KiwiSdrConsentDialog from './ui/KiwiSdrConsentDialog';
|
||||
|
||||
@@ -674,6 +675,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
onOpenSarAoiEditor?: () => void;
|
||||
}) {
|
||||
const data = useDataSnapshot() as import('@/types/dashboard').DashboardData;
|
||||
const { t } = useTranslation();
|
||||
const [internalMinimized, setInternalMinimized] = useState(true);
|
||||
const isMinimized = isMinimizedProp !== undefined ? isMinimizedProp : internalMinimized;
|
||||
const setIsMinimized = (val: boolean | ((prev: boolean) => boolean)) => {
|
||||
@@ -872,47 +874,47 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
|
||||
const sections = [
|
||||
{
|
||||
label: 'AIRCRAFT',
|
||||
label: t('layers.aircraft').toUpperCase(),
|
||||
icon: Plane,
|
||||
layers: [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Commercial Flights',
|
||||
name: t('layers.commercialFlights'),
|
||||
source: 'adsb.lol',
|
||||
count: data?.commercial_flights?.length || 0,
|
||||
icon: Plane,
|
||||
},
|
||||
{
|
||||
id: 'private',
|
||||
name: 'Private Flights',
|
||||
name: t('layers.privateAircraft'),
|
||||
source: 'adsb.lol',
|
||||
count: data?.private_flights?.length || 0,
|
||||
icon: Plane,
|
||||
},
|
||||
{
|
||||
id: 'jets',
|
||||
name: 'Private Jets',
|
||||
name: t('layers.privateJets'),
|
||||
source: 'adsb.lol',
|
||||
count: data?.private_jets?.length || 0,
|
||||
icon: Plane,
|
||||
},
|
||||
{
|
||||
id: 'military',
|
||||
name: 'Military Flights',
|
||||
name: t('layers.militaryFlights'),
|
||||
source: 'adsb.lol',
|
||||
count: data?.military_flights?.length || 0,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
id: 'tracked',
|
||||
name: 'Tracked Aircraft',
|
||||
name: t('layers.trackedAircraft'),
|
||||
source: 'Plane-Alert DB',
|
||||
count: data?.tracked_flights?.length || 0,
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
id: 'gps_jamming',
|
||||
name: 'GPS Jamming',
|
||||
name: t('layers.gpsJamming'),
|
||||
source: 'ADS-B NACp',
|
||||
count: data?.gps_jamming?.length || 0,
|
||||
icon: Radio,
|
||||
@@ -920,47 +922,47 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MARITIME',
|
||||
label: t('layers.maritime').toUpperCase(),
|
||||
icon: Ship,
|
||||
layers: [
|
||||
{
|
||||
id: 'ships_military',
|
||||
name: 'Military / Carriers',
|
||||
name: t('layers.militaryVessels'),
|
||||
source: 'AIS Stream',
|
||||
count: militaryShipCount,
|
||||
icon: Ship,
|
||||
},
|
||||
{
|
||||
id: 'ships_cargo',
|
||||
name: 'Cargo / Tankers',
|
||||
name: t('layers.cargoShips'),
|
||||
source: 'AIS Stream',
|
||||
count: cargoShipCount,
|
||||
icon: Ship,
|
||||
},
|
||||
{
|
||||
id: 'ships_civilian',
|
||||
name: 'Civilian Vessels',
|
||||
name: t('layers.civilianShips'),
|
||||
source: 'AIS Stream',
|
||||
count: civilianShipCount,
|
||||
icon: Anchor,
|
||||
},
|
||||
{
|
||||
id: 'ships_passenger',
|
||||
name: 'Cruise / Passenger',
|
||||
name: t('layers.passengerShips'),
|
||||
source: 'AIS Stream',
|
||||
count: passengerShipCount,
|
||||
icon: Anchor,
|
||||
},
|
||||
{
|
||||
id: 'ships_tracked_yachts',
|
||||
name: 'Tracked Yachts',
|
||||
name: t('layers.trackedYachts'),
|
||||
source: 'Yacht-Alert DB',
|
||||
count: trackedYachtCount,
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
id: 'fishing_activity',
|
||||
name: 'Fishing Activity',
|
||||
name: t('layers.fishingActivity'),
|
||||
source: 'Global Fishing Watch',
|
||||
count: data?.fishing_activity?.length || 0,
|
||||
icon: Fish,
|
||||
@@ -968,12 +970,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SPACE',
|
||||
label: t('layers.space').toUpperCase(),
|
||||
icon: Satellite,
|
||||
layers: [
|
||||
{
|
||||
id: 'satellites',
|
||||
name: 'Satellites',
|
||||
name: t('layers.satellites'),
|
||||
source:
|
||||
(data?.satellite_source === 'celestrak'
|
||||
? 'CelesTrak SGP4'
|
||||
@@ -993,28 +995,28 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
},
|
||||
{
|
||||
id: 'gibs_imagery',
|
||||
name: 'MODIS Terra (Daily)',
|
||||
name: t('layers.gibsImagery'),
|
||||
source: 'NASA GIBS',
|
||||
count: null,
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
id: 'highres_satellite',
|
||||
name: 'High-Res Satellite',
|
||||
name: t('layers.highresSatellite'),
|
||||
source: 'Esri World Imagery',
|
||||
count: null,
|
||||
icon: Satellite,
|
||||
},
|
||||
{
|
||||
id: 'sentinel_hub',
|
||||
name: 'Sentinel Hub',
|
||||
name: t('layers.sentinelHub'),
|
||||
source: 'Copernicus CDSE',
|
||||
count: null,
|
||||
icon: Satellite,
|
||||
},
|
||||
{
|
||||
id: 'viirs_nightlights',
|
||||
name: 'VIIRS Night Lights',
|
||||
name: t('layers.viirsNightlights'),
|
||||
source: 'NASA GIBS',
|
||||
count: null,
|
||||
icon: Moon,
|
||||
@@ -1022,54 +1024,54 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'HAZARDS',
|
||||
label: t('layers.hazards').toUpperCase(),
|
||||
icon: AlertTriangle,
|
||||
layers: [
|
||||
{
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes (24h)',
|
||||
name: t('layers.earthquakes'),
|
||||
source: 'USGS',
|
||||
count: data?.earthquakes?.length || 0,
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
id: 'firms',
|
||||
name: 'Fire Hotspots (24h)',
|
||||
name: t('layers.fires'),
|
||||
source: 'NASA FIRMS VIIRS',
|
||||
count: data?.firms_fires?.length || 0,
|
||||
icon: Flame,
|
||||
},
|
||||
{
|
||||
id: 'ukraine_alerts',
|
||||
name: 'Ukraine Air Raids',
|
||||
name: t('layers.ukraineAlerts'),
|
||||
source: 'alerts.in.ua',
|
||||
count: data?.ukraine_alerts?.length || 0,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
id: 'weather_alerts',
|
||||
name: 'Severe Weather',
|
||||
name: t('layers.weatherAlerts'),
|
||||
source: 'NOAA/NWS',
|
||||
count: data?.weather_alerts?.length || 0,
|
||||
icon: CloudLightning,
|
||||
},
|
||||
{
|
||||
id: 'volcanoes',
|
||||
name: 'Volcanoes',
|
||||
name: t('layers.volcanoes'),
|
||||
source: 'Smithsonian GVP',
|
||||
count: data?.volcanoes?.length || 0,
|
||||
icon: Mountain,
|
||||
},
|
||||
{
|
||||
id: 'air_quality',
|
||||
name: 'Air Quality',
|
||||
name: t('layers.airQuality'),
|
||||
source: 'OpenAQ',
|
||||
count: data?.air_quality?.length || 0,
|
||||
icon: Wind,
|
||||
},
|
||||
{
|
||||
id: 'sar',
|
||||
name: 'SAR Ground-Change',
|
||||
name: t('layers.sar'),
|
||||
source:
|
||||
(data?.sar_anomalies?.length
|
||||
? `OPERA/EGMS · ${data.sar_anomalies.length} alerts · ${data.sar_scenes?.length || 0} passes`
|
||||
@@ -1082,12 +1084,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'UAP SIGHTINGS',
|
||||
label: t('layers.uapSightings').toUpperCase(),
|
||||
icon: Eye,
|
||||
layers: [
|
||||
{
|
||||
id: 'uap_sightings',
|
||||
name: 'UAP Reports',
|
||||
name: t('layers.uapSightings'),
|
||||
source: 'NUFORC',
|
||||
count: data?.uap_sightings?.length || 0,
|
||||
icon: Eye,
|
||||
@@ -1095,12 +1097,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'BIOSURVEILLANCE',
|
||||
label: t('layers.biosurveillance').toUpperCase(),
|
||||
icon: Droplets,
|
||||
layers: [
|
||||
{
|
||||
id: 'wastewater',
|
||||
name: 'Wastewater Pathogens',
|
||||
name: t('layers.wastewater'),
|
||||
source: 'WastewaterSCAN',
|
||||
count: data?.wastewater?.length || 0,
|
||||
icon: Droplets,
|
||||
@@ -1108,47 +1110,47 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'INFRASTRUCTURE',
|
||||
label: t('layers.infrastructure').toUpperCase(),
|
||||
icon: Server,
|
||||
layers: [
|
||||
{
|
||||
id: 'cctv',
|
||||
name: 'CCTV Mesh',
|
||||
name: t('layers.cctv'),
|
||||
source: 'CCTV Mesh + Street View',
|
||||
count: cctvCount,
|
||||
icon: Cctv,
|
||||
},
|
||||
{
|
||||
id: 'datacenters',
|
||||
name: 'Data Centers',
|
||||
name: t('layers.datacenters'),
|
||||
source: 'DC Map (GitHub)',
|
||||
count: data?.datacenters?.length || 0,
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
id: 'internet_outages',
|
||||
name: 'Internet Outages',
|
||||
name: t('layers.internetOutages'),
|
||||
source: 'IODA + RIPE Atlas',
|
||||
count: data?.internet_outages?.length || 0,
|
||||
icon: Wifi,
|
||||
},
|
||||
{
|
||||
id: 'power_plants',
|
||||
name: 'Power Plants',
|
||||
name: t('layers.powerPlants'),
|
||||
source: 'WRI (Static)',
|
||||
count: data?.power_plants?.length || 0,
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
id: 'military_bases',
|
||||
name: 'Military Bases',
|
||||
name: t('layers.militaryBases'),
|
||||
source: 'OSINT (Static)',
|
||||
count: data?.military_bases?.length || 0,
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
id: 'trains',
|
||||
name: 'Live Trains',
|
||||
name: t('layers.trains'),
|
||||
source: 'Amtraker + DigiTraffic',
|
||||
count: data?.trains?.length || 0,
|
||||
icon: TrainFront,
|
||||
@@ -1156,12 +1158,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SHODAN',
|
||||
label: t('layers.shodanOverlay').toUpperCase(),
|
||||
icon: Search,
|
||||
layers: [
|
||||
{
|
||||
id: 'shodan_overlay',
|
||||
name: 'Shodan Overlay',
|
||||
name: t('layers.shodanOverlay'),
|
||||
source: 'Operator Search',
|
||||
count: shodanResultCount,
|
||||
icon: Search,
|
||||
@@ -1169,54 +1171,54 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SIGINT',
|
||||
label: t('layers.sigint').toUpperCase(),
|
||||
icon: Radio,
|
||||
layers: [
|
||||
{
|
||||
id: 'kiwisdr',
|
||||
name: 'SDR Receivers',
|
||||
name: t('layers.kiwisdr'),
|
||||
source: 'KiwiSDR.com',
|
||||
count: data?.kiwisdr?.length || 0,
|
||||
icon: Radio,
|
||||
},
|
||||
{
|
||||
id: 'psk_reporter',
|
||||
name: 'HF Digital Spots',
|
||||
name: t('layers.pskReporter'),
|
||||
source: 'PSK Reporter',
|
||||
count: data?.psk_reporter?.length || 0,
|
||||
icon: Radio,
|
||||
},
|
||||
{
|
||||
id: 'satnogs',
|
||||
name: 'Sat Ground Stations',
|
||||
name: t('layers.satnogs'),
|
||||
source: 'SatNOGS',
|
||||
count: satnogsCount,
|
||||
icon: Satellite,
|
||||
},
|
||||
{
|
||||
id: 'tinygs',
|
||||
name: 'LoRa Satellites',
|
||||
name: t('layers.tinygs'),
|
||||
source: 'TinyGS',
|
||||
count: tinygsCount,
|
||||
icon: Satellite,
|
||||
},
|
||||
{
|
||||
id: 'scanners',
|
||||
name: 'Police Scanners',
|
||||
name: t('layers.scanners'),
|
||||
source: 'OpenMHZ',
|
||||
count: data?.scanners?.length || 0,
|
||||
icon: Radio,
|
||||
},
|
||||
{
|
||||
id: 'sigint_meshtastic',
|
||||
name: 'Meshtastic',
|
||||
name: t('layers.meshtastic'),
|
||||
source: 'LoRa MQTT',
|
||||
count: meshtasticCount,
|
||||
icon: Radio,
|
||||
},
|
||||
{
|
||||
id: 'sigint_aprs',
|
||||
name: 'APRS / JS8Call',
|
||||
name: t('layers.aprs'),
|
||||
source: 'APRS-IS / JS8',
|
||||
count: aprsCount,
|
||||
icon: Radio,
|
||||
@@ -1224,54 +1226,54 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'OVERLAYS',
|
||||
label: t('layers.overlays').toUpperCase(),
|
||||
icon: Globe,
|
||||
layers: [
|
||||
{
|
||||
id: 'ukraine_frontline',
|
||||
name: 'Ukraine Frontline',
|
||||
name: t('layers.ukraineFrontline'),
|
||||
source: 'DeepStateMap',
|
||||
count: data?.frontlines ? 1 : 0,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
id: 'global_incidents',
|
||||
name: 'Global Incidents',
|
||||
name: t('layers.globalIncidents'),
|
||||
source: 'GDELT',
|
||||
count: data?.gdelt?.length || 0,
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
id: 'crowdthreat',
|
||||
name: 'CrowdThreat',
|
||||
name: t('layers.crowdThreat'),
|
||||
source: 'CrowdThreat',
|
||||
count: data?.crowdthreat?.length || 0,
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
id: 'correlations',
|
||||
name: 'Correlations',
|
||||
name: t('layers.correlations'),
|
||||
source: 'Cross-Layer Analysis',
|
||||
count: data?.correlations?.length || 0,
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
id: 'contradictions',
|
||||
name: 'Possible Contradictions',
|
||||
name: t('layers.contradictions'),
|
||||
source: 'Narrative Intelligence',
|
||||
count: data?.correlations?.filter((c: { type: string }) => c.type === 'contradiction').length || 0,
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
id: 'day_night',
|
||||
name: 'Day / Night Cycle',
|
||||
name: t('layers.dayNight'),
|
||||
source: 'Solar Calc',
|
||||
count: null,
|
||||
icon: Sun,
|
||||
},
|
||||
{
|
||||
id: 'ai_intel',
|
||||
name: 'AI Intel',
|
||||
name: t('layers.aiIntel'),
|
||||
source: 'OpenClaw AI',
|
||||
count: null,
|
||||
icon: Zap,
|
||||
@@ -1552,7 +1554,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
<SectionIcon
|
||||
size={12}
|
||||
className={`${
|
||||
section.label === 'SHODAN'
|
||||
section.layers[0]?.id === 'shodan_overlay'
|
||||
? anyOn
|
||||
? 'text-green-400'
|
||||
: 'text-green-700/70'
|
||||
@@ -1563,7 +1565,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
/>
|
||||
<span
|
||||
className={`text-[13px] font-mono tracking-[0.2em] font-bold ${
|
||||
section.label === 'SHODAN' ? 'text-green-400' : 'text-[var(--text-muted)]'
|
||||
section.layers[0]?.id === 'shodan_overlay' ? 'text-green-400' : 'text-[var(--text-muted)]'
|
||||
}`}
|
||||
>
|
||||
{section.label}
|
||||
@@ -1571,7 +1573,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
{anyOn && totalCount > 0 && (
|
||||
<span
|
||||
className={`text-[12px] font-mono ${
|
||||
section.label === 'SHODAN' ? 'text-green-500/70' : 'text-cyan-500/50'
|
||||
section.layers[0]?.id === 'shodan_overlay' ? 'text-green-500/70' : 'text-cyan-500/50'
|
||||
}`}
|
||||
>
|
||||
{totalCount.toLocaleString()}
|
||||
@@ -1587,7 +1589,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
className="relative w-8 h-4 rounded-full transition-colors shrink-0"
|
||||
style={{
|
||||
backgroundColor: allOn
|
||||
? section.label === 'SHODAN' ? 'rgb(34 197 94 / 0.5)' : 'rgb(6 182 212 / 0.5)'
|
||||
? section.layers[0]?.id === 'shodan_overlay' ? 'rgb(34 197 94 / 0.5)' : 'rgb(6 182 212 / 0.5)'
|
||||
: anyOn
|
||||
? 'rgb(6 182 212 / 0.25)'
|
||||
: 'rgb(100 116 139 / 0.3)',
|
||||
@@ -1610,7 +1612,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
|
||||
style={{
|
||||
left: allOn ? '18px' : anyOn ? '10px' : '2px',
|
||||
backgroundColor: allOn
|
||||
? section.label === 'SHODAN' ? 'rgb(74 222 128)' : 'rgb(34 211 238)'
|
||||
? section.layers[0]?.id === 'shodan_overlay' ? 'rgb(74 222 128)' : 'rgb(34 211 238)'
|
||||
: anyOn
|
||||
? 'rgb(34 211 238 / 0.6)'
|
||||
: 'rgb(148 163 184 / 0.5)',
|
||||
|
||||
Reference in New Issue
Block a user