mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-26 17:17:51 +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:
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import DesktopBridgeBootstrap from '@/components/DesktopBridgeBootstrap';
|
||||
import { ThemeProvider } from '@/lib/ThemeContext';
|
||||
import { I18nProvider } from '@/i18n';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -27,10 +28,12 @@ export default function RootLayout({
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body className="antialiased bg-[var(--bg-primary)]" suppressHydrationWarning>
|
||||
<ThemeProvider>
|
||||
<DesktopBridgeBootstrap />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<DesktopBridgeBootstrap />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+19
-18
@@ -51,6 +51,7 @@ import {
|
||||
markSentinelInfoSeen,
|
||||
hasSentinelCredentials,
|
||||
} from '@/lib/sentinelHub';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import { LocateBar } from './LocateBar';
|
||||
import { SentinelInfoModal } from './SentinelInfoModal';
|
||||
import SarAoiEditorModal from '@/components/SarAoiEditorModal';
|
||||
@@ -62,6 +63,7 @@ const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ss
|
||||
|
||||
export default function Dashboard() {
|
||||
const viewBoundsRef = useRef<{ south: number; west: number; north: number; east: number } | null>(null);
|
||||
const { t } = useTranslation();
|
||||
// Start the critical map data request before panel/control-plane effects.
|
||||
// Non-map widgets can warm up after this; first paint needs flights, ships, and intel first.
|
||||
useDataPolling();
|
||||
@@ -88,10 +90,10 @@ export default function Dashboard() {
|
||||
useEffect(() => {
|
||||
const l = localStorage.getItem('sb_left_open');
|
||||
const r = localStorage.getItem('sb_right_open');
|
||||
const t = localStorage.getItem('sb_ticker_open');
|
||||
const tk = localStorage.getItem('sb_ticker_open');
|
||||
if (l !== null) setLeftOpen(l === 'true');
|
||||
if (r !== null) setRightOpen(r === 'true');
|
||||
if (t !== null) setTickerOpen(t === 'true');
|
||||
if (tk !== null) setTickerOpen(tk === 'true');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -528,14 +530,14 @@ export default function Dashboard() {
|
||||
S H A D O W <span className="text-cyan-400">B R O K E R</span>
|
||||
</h1>
|
||||
<span className="text-[11px] text-[var(--text-muted)] font-mono tracking-[0.3em] mt-1 ml-1">
|
||||
GLOBAL THREAT INTERCEPT
|
||||
{t('brand.subtitle')}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* SYSTEM METRICS TOP LEFT */}
|
||||
<div className="absolute top-2 left-6 text-[11px] font-mono tracking-widest text-cyan-500/50 z-[200] pointer-events-none hud-zone">
|
||||
OPTIC VIS:113 SRC:180 DENS:1.42 0.8ms
|
||||
{t('brand.systemMetrics')}
|
||||
</div>
|
||||
|
||||
{/* SYSTEM METRICS TOP RIGHT — removed, label moved into TimelineScrubber */}
|
||||
@@ -580,8 +582,8 @@ export default function Dashboard() {
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<div className="bg-[#05090d]/95 border border-cyan-900/50 p-4 font-mono text-cyan-500/70">
|
||||
<div className="text-[11px] tracking-[0.2em] text-cyan-400 font-bold">DATA LAYERS</div>
|
||||
<div className="mt-3 text-[10px] tracking-wider">PRIORITIZING MAP FEEDS</div>
|
||||
<div className="text-[11px] tracking-[0.2em] text-cyan-400 font-bold">{t('nav.dataLayers')}</div>
|
||||
<div className="mt-3 text-[10px] tracking-wider">{t('nav.prioritizingMapFeeds')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -647,7 +649,7 @@ export default function Dashboard() {
|
||||
className="text-[7px] font-mono tracking-[0.2em] font-bold"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||
>
|
||||
LAYERS
|
||||
{t('nav.layers')}
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
@@ -667,7 +669,7 @@ export default function Dashboard() {
|
||||
className="text-[7px] font-mono tracking-[0.2em] font-bold"
|
||||
style={{ writingMode: 'vertical-rl' }}
|
||||
>
|
||||
INTEL
|
||||
{t('nav.intel')}
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
@@ -768,7 +770,7 @@ export default function Dashboard() {
|
||||
{/* Coordinates */}
|
||||
<div className="flex flex-col items-center min-w-[140px]">
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
COORDINATES
|
||||
{t('controls.coordinates')}
|
||||
</div>
|
||||
<div className="text-[14px] text-cyan-400 font-mono font-bold tracking-wide">
|
||||
{mouseCoords
|
||||
@@ -783,10 +785,10 @@ export default function Dashboard() {
|
||||
{/* Location name */}
|
||||
<div className="flex flex-col items-center min-w-[180px] max-w-[320px]">
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
LOCATION
|
||||
{t('controls.location')}
|
||||
</div>
|
||||
<div className="text-[13px] text-[var(--text-secondary)] font-mono truncate max-w-[320px]">
|
||||
{locationLabel || 'Hover over map...'}
|
||||
{locationLabel || t('controls.hoverMap')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -796,7 +798,7 @@ export default function Dashboard() {
|
||||
{/* Style preset (compact) */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
STYLE
|
||||
{t('controls.style')}
|
||||
</div>
|
||||
<div className="text-[14px] text-cyan-400 font-mono font-bold">
|
||||
{activeStyle}
|
||||
@@ -815,7 +817,7 @@ export default function Dashboard() {
|
||||
title={`Kp Index: ${sw?.kp_index ?? 'N/A'}`}
|
||||
>
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
SOLAR
|
||||
{t('controls.solar')}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[14px] font-mono font-bold ${
|
||||
@@ -826,7 +828,7 @@ export default function Dashboard() {
|
||||
: 'text-green-400'
|
||||
}`}
|
||||
>
|
||||
{sw?.kp_text || 'N/A'}
|
||||
{sw?.kp_text || t('controls.na')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -857,7 +859,7 @@ export default function Dashboard() {
|
||||
onClick={() => setUiVisible(true)}
|
||||
className="absolute bottom-9 right-6 z-[200] bg-[var(--bg-primary)]/80 border border-[var(--border-primary)] px-4 py-2 text-[10px] font-mono tracking-widest text-cyan-500 hover:text-cyan-300 hover:border-cyan-800 transition-colors pointer-events-auto"
|
||||
>
|
||||
RESTORE UI
|
||||
{t('nav.restoreUi')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -984,8 +986,7 @@ export default function Dashboard() {
|
||||
{backendStatus === 'disconnected' && (
|
||||
<div className="absolute top-0 left-0 right-0 z-[9000] flex items-center justify-center py-2 bg-red-950/90 border-b border-red-500/40 backdrop-blur-sm">
|
||||
<span className="text-[10px] font-mono tracking-widest text-red-400">
|
||||
BACKEND OFFLINE — Cannot reach backend server. Check that the backend container is
|
||||
running and BACKEND_URL is correct.
|
||||
{t('backend.offline')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -1000,7 +1001,7 @@ export default function Dashboard() {
|
||||
className="flex items-center gap-2 px-3 py-1 bg-cyan-950/40 border border-cyan-800/50 border-b-0 rounded-t text-cyan-700 hover:text-cyan-400 hover:bg-cyan-950/60 hover:border-cyan-500/40 transition-colors"
|
||||
>
|
||||
<div className="text-[7.5px] font-mono tracking-[0.25em] font-bold uppercase">
|
||||
MARKETS
|
||||
{t('nav.markets')}
|
||||
</div>
|
||||
{tickerOpen ? <ChevronDown size={10} /> : <ChevronUp size={10} />}
|
||||
</button>
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import en from './translations/en.json';
|
||||
import zhCN from './translations/zh-CN.json';
|
||||
|
||||
export type Locale = 'en' | 'zh-CN';
|
||||
|
||||
const translations: Record<Locale, Record<string, Record<string, string>>> = { en, 'zh-CN': zhCN };
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue>({
|
||||
locale: 'en',
|
||||
setLocale: () => {},
|
||||
t: (key: string) => key,
|
||||
});
|
||||
|
||||
function resolve(obj: Record<string, unknown>, path: string): string {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === 'object' && part in (current as Record<string, unknown>)) {
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return path; // fallback to key
|
||||
}
|
||||
}
|
||||
return typeof current === 'string' ? current : path;
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocale] = useState<Locale>(() => {
|
||||
if (typeof window === 'undefined') return 'en';
|
||||
const saved = localStorage.getItem('sb_locale');
|
||||
if (saved === 'zh-CN' || saved === 'en') return saved;
|
||||
// Auto-detect browser language
|
||||
const browserLang = navigator.language || '';
|
||||
return browserLang.startsWith('zh') ? 'zh-CN' : 'en';
|
||||
});
|
||||
|
||||
const handleSetLocale = useCallback((newLocale: Locale) => {
|
||||
setLocale(newLocale);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('sb_locale', newLocale);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(key: string): string => {
|
||||
const dict = translations[locale] ?? translations.en;
|
||||
const value = resolve(dict as unknown as Record<string, unknown>, key);
|
||||
return value;
|
||||
},
|
||||
[locale],
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ locale, setLocale: handleSetLocale, t }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
|
||||
export { I18nContext };
|
||||
@@ -0,0 +1,246 @@
|
||||
{
|
||||
"brand": {
|
||||
"title": "S H A D O W B R O K E R",
|
||||
"subtitle": "GLOBAL THREAT INTERCEPT",
|
||||
"systemMetrics": "OPTIC VIS:113 SRC:180 DENS:1.42 0.8ms"
|
||||
},
|
||||
"nav": {
|
||||
"layers": "LAYERS",
|
||||
"intel": "INTEL",
|
||||
"markets": "MARKETS",
|
||||
"dataLayers": "DATA LAYERS",
|
||||
"prioritizingMapFeeds": "PRIORITIZING MAP FEEDS",
|
||||
"restoreUi": "RESTORE UI"
|
||||
},
|
||||
"controls": {
|
||||
"updates": "UPDATES",
|
||||
"checking": "CHECKING...",
|
||||
"upToDate": "UP TO DATE",
|
||||
"checkFailed": "CHECK FAILED",
|
||||
"node": "NODE",
|
||||
"terminal": "TERMINAL",
|
||||
"coordinates": "COORDINATES",
|
||||
"location": "LOCATION",
|
||||
"style": "STYLE",
|
||||
"solar": "SOLAR",
|
||||
"hoverMap": "Hover over map...",
|
||||
"na": "N/A"
|
||||
},
|
||||
"update": {
|
||||
"downloadInstaller": "DOWNLOAD INSTALLER",
|
||||
"installUpdate": "INSTALL UPDATE",
|
||||
"autoUpdate": "AUTO UPDATE",
|
||||
"viewRelease": "VIEW RELEASE",
|
||||
"manualDownload": "MANUAL DOWNLOAD",
|
||||
"cancel": "CANCEL",
|
||||
"tryAgain": "TRY AGAIN",
|
||||
"downloadingUpdate": "DOWNLOADING UPDATE...",
|
||||
"restarting": "RESTARTING...",
|
||||
"updateFailed": "UPDATE FAILED",
|
||||
"dockerUpdate": "DOCKER UPDATE",
|
||||
"dockerUpdateDetail": "Docker containers must be updated by pulling new images.\n Run this on your host machine:"
|
||||
},
|
||||
"node": {
|
||||
"activateNode": "ACTIVATE NODE",
|
||||
"activatingNode": "ACTIVATING NODE",
|
||||
"nodeActivated": "NODE ACTIVATED",
|
||||
"stipulations": "STIPULATIONS",
|
||||
"yes": "YES",
|
||||
"no": "NO",
|
||||
"agree": "AGREE",
|
||||
"disagree": "DISAGREE",
|
||||
"turnOff": "TURN OFF",
|
||||
"keepOn": "KEEP ON",
|
||||
"turningOff": "TURNING OFF...",
|
||||
"activating": "ACTIVATING...",
|
||||
"nodeOnline": "NODE ONLINE",
|
||||
"generatingIdentity": "Generating identity...",
|
||||
"identityReady": "Identity ready",
|
||||
"preparingTransport": "Preparing onion transport...",
|
||||
"findingPeers": "Finding bootstrap peers...",
|
||||
"peersReady": "Bootstrap peers ready",
|
||||
"syncingChain": "Syncing chain...",
|
||||
"soloNodeReady": "Solo node ready",
|
||||
"synced": "Synced",
|
||||
"events": "events",
|
||||
"peers": "peers",
|
||||
"close": "CLOSE",
|
||||
"activatePrompt": "Do you want to activate a node on this install?",
|
||||
"activateDetail": "This turns on your local participant node and syncs Infonet only through available Wormhole onion/RNS peers. Clearnet bootstrap is disabled by default.",
|
||||
"keepSyncing": "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",
|
||||
"termsTitle": "BY CONTINUING YOU AGREE:",
|
||||
"term1": "This install can keep a local copy of the public Infonet chain.",
|
||||
"term2": "Fresh installs do not use a clearnet Infonet seed.",
|
||||
"term3": "Participant-node sync requires an onion/RNS peer through Wormhole.",
|
||||
"term4": "Your backend may sync with configured private bootstrap peers in the background.",
|
||||
"term5": "Wormhole keeps Infonet, gates, Dead Drop, and DM traffic on the obfuscated lane.",
|
||||
"syncTakingLong": "Sync is taking longer than expected. Your node is active and will continue syncing in the background."
|
||||
},
|
||||
"terminal": {
|
||||
"infonetTerminal": "INFONET TERMINAL",
|
||||
"privateLaneReady": "PRIVATE LANE READY",
|
||||
"privateLaneStarting": "PRIVATE LANE STARTING",
|
||||
"privateLaneOffline": "PRIVATE LANE OFFLINE",
|
||||
"enterTerminal": "Enter the Wormhole-facing terminal and sync with the obfuscated Infonet commons?",
|
||||
"terminalDetail": "The terminal runs through Wormhole for obfuscated gates, inbox, and experimental comms.",
|
||||
"identityReady": "Your obfuscated identity is already provisioned. Entering now keeps the obfuscated lane separate from the public node sync path.",
|
||||
"identityNotReady": "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.",
|
||||
"beforeYouEnter": "BEFORE YOU ENTER:",
|
||||
"termTerminal1": "The terminal is for Wormhole gates (transitional private lane) and Dead Drop / DM (stronger private lane).",
|
||||
"termTerminal2": "Your participant node can stay active separately without changing this obfuscated identity lane.",
|
||||
"termTerminal3": "Mesh remains the public perimeter. Wormhole is the obfuscated commons.",
|
||||
"wormholeCleanup": "WORMHOLE CLEANUP:",
|
||||
"cleanupDetail": "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",
|
||||
"cleanupFromRoot": "from the project root to ensure it is fully stopped.",
|
||||
"enterWormhole": "ENTER WORMHOLE",
|
||||
"activateWormhole": "ACTIVATE WORMHOLE",
|
||||
"entering": "ENTERING...",
|
||||
"goToMesh": "GO TO MESH"
|
||||
},
|
||||
"status": {
|
||||
"off": "OFF",
|
||||
"solo": "SOLO",
|
||||
"connected": "CONNECTED",
|
||||
"syncing": "SYNCING",
|
||||
"forkStop": "FORK STOP",
|
||||
"syncIssue": "SYNC ISSUE",
|
||||
"active": "ACTIVE",
|
||||
"participant": "participant",
|
||||
"nodeOff": "node • off",
|
||||
"bootstrapWarning": "node • bootstrap warning"
|
||||
},
|
||||
"backend": {
|
||||
"offline": "BACKEND OFFLINE — Cannot reach backend server. Check that the backend container is running and BACKEND_URL is correct."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"close": "Close",
|
||||
"general": "General",
|
||||
"feeds": "Feeds",
|
||||
"shodan": "Shodan",
|
||||
"sar": "SAR",
|
||||
"infonet": "Infonet",
|
||||
"about": "About"
|
||||
},
|
||||
"legend": {
|
||||
"title": "Legend",
|
||||
"close": "Close"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Welcome to ShadowBroker",
|
||||
"getStarted": "Get Started"
|
||||
},
|
||||
"news": {
|
||||
"title": "News Intel",
|
||||
"noResults": "No results",
|
||||
"searchPlaceholder": "Search news..."
|
||||
},
|
||||
"filters": {
|
||||
"title": "Data Filters",
|
||||
"clear": "Clear",
|
||||
"all": "All"
|
||||
},
|
||||
"map": {
|
||||
"findLocate": "Find / Locate",
|
||||
"searchPlaceholder": "Search coordinates, place, or callsign...",
|
||||
"measure": "Measure",
|
||||
"clearMeasure": "Clear measurement"
|
||||
},
|
||||
"layers": {
|
||||
"aircraft": "Aircraft",
|
||||
"commercialFlights": "Commercial Flights",
|
||||
"privateAircraft": "Private Aircraft",
|
||||
"privateJets": "Private Jets",
|
||||
"militaryFlights": "Military Flights",
|
||||
"trackedAircraft": "Tracked Aircraft",
|
||||
"gpsJamming": "GPS Jamming",
|
||||
"maritime": "Maritime",
|
||||
"militaryVessels": "Military Vessels",
|
||||
"cargoShips": "Cargo Ships",
|
||||
"civilianShips": "Civilian Ships",
|
||||
"passengerShips": "Passenger Ships",
|
||||
"trackedYachts": "Tracked Yachts",
|
||||
"fishingActivity": "Fishing Activity",
|
||||
"space": "Space",
|
||||
"satellites": "Satellites",
|
||||
"gibsImagery": "GIBS Imagery",
|
||||
"highresSatellite": "High-Res Satellite",
|
||||
"sentinelHub": "Sentinel Hub",
|
||||
"viirsNightlights": "VIIRS Nightlights",
|
||||
"hazards": "Hazards",
|
||||
"earthquakes": "Earthquakes",
|
||||
"fires": "Fires",
|
||||
"ukraineAlerts": "Ukraine Alerts",
|
||||
"weatherAlerts": "Weather Alerts",
|
||||
"volcanoes": "Volcanoes",
|
||||
"airQuality": "Air Quality",
|
||||
"infrastructure": "Infrastructure",
|
||||
"cctv": "CCTV",
|
||||
"datacenters": "Datacenters",
|
||||
"internetOutages": "Internet Outages",
|
||||
"powerPlants": "Power Plants",
|
||||
"militaryBases": "Military Bases",
|
||||
"trains": "Trains",
|
||||
"sigint": "SIGINT",
|
||||
"kiwisdr": "KiwiSDR",
|
||||
"pskReporter": "PSK Reporter",
|
||||
"satnogs": "SatNOGS",
|
||||
"tinygs": "TinyGS",
|
||||
"scanners": "Scanners",
|
||||
"meshtastic": "Meshtastic",
|
||||
"aprs": "APRS",
|
||||
"overlays": "Overlays",
|
||||
"ukraineFrontline": "Ukraine Frontline",
|
||||
"globalIncidents": "Global Incidents",
|
||||
"dayNight": "Day/Night",
|
||||
"correlations": "Correlations",
|
||||
"contradictions": "Contradictions",
|
||||
"uapSightings": "UAP Sightings",
|
||||
"biosurveillance": "Biosurveillance",
|
||||
"wastewater": "Wastewater",
|
||||
"crowdThreat": "CrowdThreat",
|
||||
"shodanOverlay": "Shodan Overlay",
|
||||
"aiIntel": "AI Intel",
|
||||
"sar": "SAR"
|
||||
},
|
||||
"shodan": {
|
||||
"title": "Shodan Connector",
|
||||
"searchPlaceholder": "Search devices...",
|
||||
"apiKeyRequired": "API Key Required",
|
||||
"results": "results"
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI Intel Panel",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected"
|
||||
},
|
||||
"meshChat": {
|
||||
"title": "Mesh Chat",
|
||||
"infonet": "Infonet",
|
||||
"meshtastic": "Meshtastic",
|
||||
"deadDrop": "Dead Drop",
|
||||
"sendMessage": "Send message",
|
||||
"placeholder": "Type a message..."
|
||||
},
|
||||
"watchlist": {
|
||||
"title": "Watchlist",
|
||||
"empty": "No items watched",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Event Timeline",
|
||||
"noEvents": "No events"
|
||||
},
|
||||
"sar": {
|
||||
"title": "SAR Ground-Change Detection",
|
||||
"modeA": "Catalog Mode",
|
||||
"modeB": "Anomaly Mode",
|
||||
"aoiEditor": "AOI Editor",
|
||||
"addAoi": "Add AOI",
|
||||
"groundDeformation": "Ground Deformation",
|
||||
"waterChange": "Water Change",
|
||||
"vegetation": "Vegetation Disturbance",
|
||||
"damage": "Damage Assessment",
|
||||
"coherence": "Coherence Change"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
{
|
||||
"brand": {
|
||||
"title": "影子经纪人",
|
||||
"subtitle": "全球威胁拦截系统",
|
||||
"systemMetrics": "光学 可视:113 源:180 密度:1.42 0.8ms"
|
||||
},
|
||||
"nav": {
|
||||
"layers": "图层",
|
||||
"intel": "情报",
|
||||
"markets": "市场",
|
||||
"dataLayers": "数据图层",
|
||||
"prioritizingMapFeeds": "正在加载地图数据源",
|
||||
"restoreUi": "恢复界面"
|
||||
},
|
||||
"controls": {
|
||||
"updates": "更新",
|
||||
"checking": "检查中...",
|
||||
"upToDate": "已是最新",
|
||||
"checkFailed": "检查失败",
|
||||
"node": "节点",
|
||||
"terminal": "终端",
|
||||
"coordinates": "坐标",
|
||||
"location": "位置",
|
||||
"style": "样式",
|
||||
"solar": "太阳",
|
||||
"hoverMap": "悬停地图...",
|
||||
"na": "无数据"
|
||||
},
|
||||
"update": {
|
||||
"downloadInstaller": "下载安装包",
|
||||
"installUpdate": "安装更新",
|
||||
"autoUpdate": "自动更新",
|
||||
"viewRelease": "查看发布",
|
||||
"manualDownload": "手动下载",
|
||||
"cancel": "取消",
|
||||
"tryAgain": "重试",
|
||||
"downloadingUpdate": "正在下载更新...",
|
||||
"restarting": "正在重启...",
|
||||
"updateFailed": "更新失败",
|
||||
"dockerUpdate": "Docker 更新",
|
||||
"dockerUpdateDetail": "Docker 容器需要通过拉取新镜像来更新。请在宿主机上运行:"
|
||||
},
|
||||
"node": {
|
||||
"activateNode": "激活节点",
|
||||
"activatingNode": "正在激活节点",
|
||||
"nodeActivated": "节点已激活",
|
||||
"stipulations": "条款须知",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"agree": "同意",
|
||||
"disagree": "不同意",
|
||||
"turnOff": "关闭",
|
||||
"keepOn": "保持开启",
|
||||
"turningOff": "正在关闭...",
|
||||
"activating": "激活中...",
|
||||
"nodeOnline": "节点已上线",
|
||||
"generatingIdentity": "正在生成身份...",
|
||||
"identityReady": "身份已就绪",
|
||||
"preparingTransport": "正在准备洋葱传输...",
|
||||
"findingPeers": "正在寻找引导节点...",
|
||||
"peersReady": "引导节点已就绪",
|
||||
"syncingChain": "正在同步链...",
|
||||
"soloNodeReady": "独立节点已就绪",
|
||||
"synced": "已同步",
|
||||
"events": "事件",
|
||||
"peers": "节点",
|
||||
"close": "关闭",
|
||||
"activatePrompt": "是否在此安装上激活节点?",
|
||||
"activateDetail": "这将启用本地参与节点,仅通过可用的 Wormhole 洋葱/RNS 节点同步 Infonet。默认禁用明文引导。",
|
||||
"keepSyncing": "只要后端运行,节点就会持续同步 — 你可以关闭此浏览器标签页。要运行无仪表盘的无头节点,请使用",
|
||||
"termsTitle": "继续即表示您同意:",
|
||||
"term1": "此安装将保留公共 Infonet 链的本地副本。",
|
||||
"term2": "全新安装不使用明文 Infonet 种子。",
|
||||
"term3": "参与节点同步需要通过 Wormhole 的洋葱/RNS 节点。",
|
||||
"term4": "您的后端可能会在后台与已配置的私有引导节点同步。",
|
||||
"term5": "Wormhole 将 Infonet、门、死信箱和 DM 流量保留在混淆通道上。",
|
||||
"syncTakingLong": "同步时间超出预期。您的节点已激活,将在后台继续同步。"
|
||||
},
|
||||
"terminal": {
|
||||
"infonetTerminal": "Infonet 终端",
|
||||
"privateLaneReady": "私有通道已就绪",
|
||||
"privateLaneStarting": "私有通道启动中",
|
||||
"privateLaneOffline": "私有通道离线",
|
||||
"enterTerminal": "进入 Wormhole 面向终端并与混淆 Infonet 公共空间同步?",
|
||||
"terminalDetail": "终端通过 Wormhole 运行,用于混淆门、收件箱和实验性通信。",
|
||||
"identityReady": "您的混淆身份已配置。现在进入将保持混淆通道与公共节点同步路径的分离。",
|
||||
"identityNotReady": "这将开启 Wormhole 并打开混淆通道。如果您已有 Wormhole 身份,将复用。如果没有,将一次性引导并持续使用。",
|
||||
"beforeYouEnter": "进入前请注意:",
|
||||
"termTerminal1": "终端用于 Wormhole 门(过渡性私有通道)和死信箱/DM(更强的私有通道)。",
|
||||
"termTerminal2": "您的参与节点可以独立保持活跃,无需更改此混淆身份通道。",
|
||||
"termTerminal3": "Mesh 保持公共边界。Wormhole 是混淆公共空间。",
|
||||
"wormholeCleanup": "WORMHOLE 清理:",
|
||||
"cleanupDetail": "关闭 Infonet 终端将自动关闭 Wormhole。如果您强制关闭浏览器或关闭失败,Wormhole 可能会在后台继续运行。运行",
|
||||
"cleanupFromRoot": "从项目根目录运行以确保完全停止。",
|
||||
"enterWormhole": "进入 WORMHOLE",
|
||||
"activateWormhole": "激活 WORMHOLE",
|
||||
"entering": "正在进入...",
|
||||
"goToMesh": "前往 MESH"
|
||||
},
|
||||
"status": {
|
||||
"off": "关闭",
|
||||
"solo": "独立",
|
||||
"connected": "已连接",
|
||||
"syncing": "同步中",
|
||||
"forkStop": "分叉停止",
|
||||
"syncIssue": "同步异常",
|
||||
"active": "活跃",
|
||||
"participant": "参与者",
|
||||
"nodeOff": "节点已关闭",
|
||||
"bootstrapWarning": "引导警告"
|
||||
},
|
||||
"backend": {
|
||||
"offline": "后端离线 — 无法连接后端服务器。请检查后端容器是否正在运行以及 BACKEND_URL 是否正确。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"close": "关闭",
|
||||
"general": "通用",
|
||||
"feeds": "数据源",
|
||||
"shodan": "Shodan",
|
||||
"sar": "SAR",
|
||||
"infonet": "Infonet",
|
||||
"about": "关于"
|
||||
},
|
||||
"legend": {
|
||||
"title": "图例",
|
||||
"close": "关闭"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "欢迎使用 ShadowBroker",
|
||||
"getStarted": "开始使用"
|
||||
},
|
||||
"news": {
|
||||
"title": "新闻情报",
|
||||
"noResults": "暂无结果",
|
||||
"searchPlaceholder": "搜索新闻..."
|
||||
},
|
||||
"filters": {
|
||||
"title": "数据过滤",
|
||||
"clear": "清除",
|
||||
"all": "全部"
|
||||
},
|
||||
"map": {
|
||||
"findLocate": "查找/定位",
|
||||
"searchPlaceholder": "搜索坐标、地点或呼号...",
|
||||
"measure": "测量",
|
||||
"clearMeasure": "清除测量"
|
||||
},
|
||||
"layers": {
|
||||
"aircraft": "航空器",
|
||||
"commercialFlights": "商业航班",
|
||||
"privateAircraft": "私人飞机",
|
||||
"privateJets": "私人喷气机",
|
||||
"militaryFlights": "军用飞行",
|
||||
"trackedAircraft": "追踪航空器",
|
||||
"gpsJamming": "GPS 干扰",
|
||||
"maritime": "海事",
|
||||
"militaryVessels": "军用船只",
|
||||
"cargoShips": "货船",
|
||||
"civilianShips": "民用船只",
|
||||
"passengerShips": "客轮",
|
||||
"trackedYachts": "追踪游艇",
|
||||
"fishingActivity": "捕鱼活动",
|
||||
"space": "太空",
|
||||
"satellites": "卫星",
|
||||
"gibsImagery": "GIBS 卫星图",
|
||||
"highresSatellite": "高分辨率卫星",
|
||||
"sentinelHub": "Sentinel Hub",
|
||||
"viirsNightlights": "VIIRS 夜间灯光",
|
||||
"hazards": "灾害",
|
||||
"earthquakes": "地震",
|
||||
"fires": "野火",
|
||||
"ukraineAlerts": "乌克兰警报",
|
||||
"weatherAlerts": "天气警报",
|
||||
"volcanoes": "火山",
|
||||
"airQuality": "空气质量",
|
||||
"infrastructure": "基础设施",
|
||||
"cctv": "监控摄像头",
|
||||
"datacenters": "数据中心",
|
||||
"internetOutages": "互联网中断",
|
||||
"powerPlants": "发电厂",
|
||||
"militaryBases": "军事基地",
|
||||
"trains": "列车",
|
||||
"sigint": "信号情报",
|
||||
"kiwisdr": "KiwiSDR",
|
||||
"pskReporter": "PSK 报告",
|
||||
"satnogs": "SatNOGS",
|
||||
"tinygs": "TinyGS",
|
||||
"scanners": "扫描器",
|
||||
"meshtastic": "Meshtastic",
|
||||
"aprs": "APRS",
|
||||
"overlays": "叠加层",
|
||||
"ukraineFrontline": "乌克兰前线",
|
||||
"globalIncidents": "全球事件",
|
||||
"dayNight": "昼夜分界",
|
||||
"correlations": "关联分析",
|
||||
"contradictions": "矛盾检测",
|
||||
"uapSightings": "UAP 目击",
|
||||
"biosurveillance": "生物监测",
|
||||
"wastewater": "废水监测",
|
||||
"crowdThreat": "人群威胁",
|
||||
"shodanOverlay": "Shodan 叠加",
|
||||
"aiIntel": "AI 情报",
|
||||
"sar": "SAR"
|
||||
},
|
||||
"shodan": {
|
||||
"title": "Shodan 连接器",
|
||||
"searchPlaceholder": "搜索设备...",
|
||||
"apiKeyRequired": "需要 API Key",
|
||||
"results": "结果"
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI 情报面板",
|
||||
"connected": "已连接",
|
||||
"disconnected": "未连接"
|
||||
},
|
||||
"meshChat": {
|
||||
"title": "Mesh 聊天",
|
||||
"infonet": "Infonet",
|
||||
"meshtastic": "Meshtastic",
|
||||
"deadDrop": "死信箱",
|
||||
"sendMessage": "发送消息",
|
||||
"placeholder": "输入消息..."
|
||||
},
|
||||
"watchlist": {
|
||||
"title": "监视列表",
|
||||
"empty": "暂无监控项",
|
||||
"clear": "清空"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "事件时间线",
|
||||
"noEvents": "暂无事件"
|
||||
},
|
||||
"sar": {
|
||||
"title": "SAR 地面变化检测",
|
||||
"modeA": "目录模式",
|
||||
"modeB": "异常检测模式",
|
||||
"aoiEditor": "AOI 编辑器",
|
||||
"addAoi": "添加关注区域",
|
||||
"groundDeformation": "地面变形",
|
||||
"waterChange": "水域变化",
|
||||
"vegetation": "植被干扰",
|
||||
"damage": "损毁评估",
|
||||
"coherence": "相干变化"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user