diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index e2a81d2..351642d 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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({ - - - {children} - + + + + {children} + + ); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index dd6ab71..b8e388c 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 B R O K E R - GLOBAL THREAT INTERCEPT + {t('brand.subtitle')} {/* SYSTEM METRICS TOP LEFT */}
- OPTIC VIS:113 SRC:180 DENS:1.42 0.8ms + {t('brand.systemMetrics')}
{/* SYSTEM METRICS TOP RIGHT — removed, label moved into TimelineScrubber */} @@ -580,8 +582,8 @@ export default function Dashboard() { ) : (
-
DATA LAYERS
-
PRIORITIZING MAP FEEDS
+
{t('nav.dataLayers')}
+
{t('nav.prioritizingMapFeeds')}
)} @@ -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')} @@ -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')} @@ -768,7 +770,7 @@ export default function Dashboard() { {/* Coordinates */}
- COORDINATES + {t('controls.coordinates')}
{mouseCoords @@ -783,10 +785,10 @@ export default function Dashboard() { {/* Location name */}
- LOCATION + {t('controls.location')}
- {locationLabel || 'Hover over map...'} + {locationLabel || t('controls.hoverMap')}
@@ -796,7 +798,7 @@ export default function Dashboard() { {/* Style preset (compact) */}
- STYLE + {t('controls.style')}
{activeStyle} @@ -815,7 +817,7 @@ export default function Dashboard() { title={`Kp Index: ${sw?.kp_index ?? 'N/A'}`} >
- SOLAR + {t('controls.solar')}
- {sw?.kp_text || 'N/A'} + {sw?.kp_text || t('controls.na')}
); @@ -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')} )} @@ -984,8 +986,7 @@ export default function Dashboard() { {backendStatus === 'disconnected' && (
- BACKEND OFFLINE — Cannot reach backend server. Check that the backend container is - running and BACKEND_URL is correct. + {t('backend.offline')}
)} @@ -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" >
- MARKETS + {t('nav.markets')}
{tickerOpen ? : } diff --git a/frontend/src/components/AIIntelPanel.tsx b/frontend/src/components/AIIntelPanel.tsx index cee5c24..5f62398 100644 --- a/frontend/src/components/AIIntelPanel.tsx +++ b/frontend/src/components/AIIntelPanel.tsx @@ -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({
- AI INTEL + {t('ai.title').toUpperCase()} {totalPins > 0 && ( diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx index 2f6acc3..a855936 100644 --- a/frontend/src/components/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel.tsx @@ -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(null); @@ -310,7 +312,7 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi
- DATA FILTERS + {t('filters.title').toUpperCase()} {activeCount > 0 && ( @@ -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()} )} diff --git a/frontend/src/components/FindLocateBar.tsx b/frontend/src/components/FindLocateBar.tsx index 06e446a..04374e6 100644 --- a/frontend/src/components/FindLocateBar.tsx +++ b/frontend/src/components/FindLocateBar.tsx @@ -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); diff --git a/frontend/src/components/MapLegend.tsx b/frontend/src/components/MapLegend.tsx index 7372d70..febb7a3 100644 --- a/frontend/src/components/MapLegend.tsx +++ b/frontend/src/components/MapLegend.tsx @@ -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>(new Set()); const toggle = (name: string) => { @@ -362,7 +364,7 @@ const MapLegend = React.memo(function MapLegend({

- MAP LEGEND + {t('legend.title').toUpperCase()}

ICON REFERENCE KEY diff --git a/frontend/src/components/ScaleBar.tsx b/frontend/src/components/ScaleBar.tsx index ea9cd23..8c49417 100644 --- a/frontend/src/components/ScaleBar.tsx +++ b/frontend/src/components/ScaleBar.tsx @@ -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)'} > - {measureMode ? 'MEASURING' : 'MEASURE'} + {measureMode ? 'MEASURING' : t('map.measure')} {/* Clear measurements */} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 34c6e08..b7078aa 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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({

- SYSTEM CONFIG + {t('settings.title').toUpperCase()}

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)]'}`} > - API KEYS + {t('settings.general').toUpperCase()}
diff --git a/frontend/src/components/ShodanPanel.tsx b/frontend/src/components/ShodanPanel.tsx index 0d1d18c..3e9f1d0 100644 --- a/frontend/src/components/ShodanPanel.tsx +++ b/frontend/src/components/ShodanPanel.tsx @@ -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({
- SHODAN + {t('shodan.title').toUpperCase()} {currentResults.length > 0 && ( @@ -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" />
diff --git a/frontend/src/components/TopRightControls.tsx b/frontend/src/components/TopRightControls.tsx index 6aa0e94..f639500 100644 --- a/frontend/src/components/TopRightControls.tsx +++ b/frontend/src/components/TopRightControls.tsx @@ -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('idle'); const [latestVersion, setLatestVersion] = useState(''); const [errorMessage, setErrorMessage] = useState(''); @@ -556,7 +558,7 @@ export default function TopRightControls({ {/* Header */}
- UPDATE v{currentVersion} → v{latestVersion} + {t('update.autoUpdate').toUpperCase()} v{currentVersion} → v{latestVersion} - {updateAction === 'manual_download' ? 'VIEW RELEASE' : 'MANUAL DOWNLOAD'} + {updateAction === 'manual_download' ? t('update.viewRelease') : t('update.manualDownload')}
@@ -609,7 +611,7 @@ export default function TopRightControls({
- UPDATE FAILED + {t('update.updateFailed')}

@@ -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" > - TRY AGAIN + {t('update.tryAgain')} - {updateAction === 'manual_download' ? 'VIEW RELEASE' : 'MANUAL DOWNLOAD'} + {updateAction === 'manual_download' ? t('update.viewRelease') : t('update.manualDownload')}

@@ -642,7 +644,7 @@ export default function TopRightControls({
- DOCKER UPDATE — v{latestVersion} + {t('update.dockerUpdate')} — v{latestVersion}

- Docker containers must be updated by pulling new images. - Run this on your host machine: + {t('update.dockerUpdateDetail')}

{dockerCommands} @@ -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" > - VIEW RELEASE + {t('update.viewRelease')}
@@ -743,12 +744,12 @@ export default function TopRightControls({
{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')}
{nodeMode} • {syncOutcome} • participant-node sync does not require Wormhole @@ -767,7 +768,7 @@ export default function TopRightControls({ {nodeStep === 'disable' ? ( <>
- Node activated. + {t('node.nodeActivated')}. {(() => { const id = getNodeIdentity(); return id?.nodeId ? (
{id.nodeId} @@ -775,11 +776,11 @@ export default function TopRightControls({ ) : null; })()}
{syncOutcome.toLowerCase()} - {(nodeStatus?.total_events ?? 0) > 0 && {nodeStatus?.total_events} events} - {(nodeStatus?.bootstrap?.sync_peer_count ?? 0) > 0 && {nodeStatus?.bootstrap?.sync_peer_count} peers} + {(nodeStatus?.total_events ?? 0) > 0 && {nodeStatus?.total_events} {t('node.events')}} + {(nodeStatus?.bootstrap?.sync_peer_count ?? 0) > 0 && {nodeStatus?.bootstrap?.sync_peer_count} {t('node.peers')}}
- 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 meshnode.bat (Windows) or meshnode.sh (macOS/Linux). + {t('node.keepSyncing')}
{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')}
@@ -817,7 +818,7 @@ export default function TopRightControls({ )} - {activatingPhase === 'keys' ? 'Generating identity...' : 'Identity ready'} + {activatingPhase === 'keys' ? t('node.generatingIdentity') : t('node.identityReady')} {activatingPhase !== 'keys' && (() => { const id = getNodeIdentity(); return id?.nodeId ? ( {id.nodeId} @@ -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')}
{/* 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')}
{/* Done banner */} {activatingPhase === 'done' && ( <>
- NODE ONLINE + {t('node.nodeOnline')}
- 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 meshnode.bat (Windows) or meshnode.sh (macOS/Linux). + {t('node.keepSyncing')}
)}
{activatingTimedOut && activatingPhase !== 'done' && (
- Sync is taking longer than expected. Your node is active and will continue syncing in the background. + {t('node.syncTakingLong')}
)} {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')} )} ) : nodeStep === 'prompt' ? ( <>
- Do you want to activate a node on this install? -
- This turns on your local participant node and syncs Infonet only through available Wormhole onion/RNS peers. Clearnet bootstrap is disabled by default. -
+ {t('node.activatePrompt')}
{(bootstrapFailed || nodeStatusError || nodeToggleError) && (
@@ -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')}
) : ( <>
-
BY CONTINUING YOU AGREE:
+
{t('node.termsTitle')}
    -
  • This install can keep a local copy of the public Infonet chain.
  • -
  • Fresh installs do not use a clearnet Infonet seed.
  • -
  • Participant-node sync requires an onion/RNS peer through Wormhole.
  • -
  • Your backend may sync with configured private bootstrap peers in the background.
  • -
  • Wormhole keeps Infonet, gates, Dead Drop, and DM traffic on the obfuscated lane.
  • +
  • {t('node.term1')}
  • +
  • {t('node.term2')}
  • +
  • {t('node.term3')}
  • +
  • {t('node.term4')}
  • +
  • {t('node.term5')}
@@ -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')}
@@ -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({
- INFONET TERMINAL + {t('terminal.infonetTerminal')}
{terminalStatusLabel} • {terminalTransportTier} @@ -1012,12 +1009,12 @@ export default function TopRightControls({
{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')}
{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')}
{terminalLaunchError && ( @@ -1026,21 +1023,17 @@ export default function TopRightControls({
)}
-
BEFORE YOU ENTER:
+
{t('terminal.beforeYouEnter')}
    -
  • The terminal is for Wormhole gates (transitional private lane) and Dead Drop / DM (stronger private lane).
  • -
  • Your participant node can stay active separately without changing this obfuscated identity lane.
  • -
  • Mesh remains the public perimeter. Wormhole is the obfuscated commons.
  • +
  • {t('terminal.term1')}
  • +
  • {t('terminal.term2')}
  • +
  • {t('terminal.term3')}
-
WORMHOLE CLEANUP:
+
{t('terminal.wormholeCleanup')}
- 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 killwormhole.bat (Windows) or{' '} - killwormhole.sh (macOS/Linux) - from the project root to ensure it is fully stopped. + {t('terminal.wormholeCleanupDetail')}
@@ -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')}
@@ -1100,7 +1093,7 @@ export default function TopRightControls({ title={nodeTitle} > - NODE + {t('controls.node')} @@ -1112,7 +1105,7 @@ export default function TopRightControls({ title="Open Mesh Terminal" > - TERMINAL + {t('controls.terminal')} {(dmCount ?? 0) > 0 && ( {(dmCount ?? 0) > 9 ? '9+' : dmCount} @@ -1146,7 +1139,7 @@ export default function TopRightControls({ {updateStatus === 'updating' && (
- DOWNLOADING UPDATE... + {t('update.downloadingUpdate')}
)} @@ -1154,7 +1147,7 @@ export default function TopRightControls({ {updateStatus === 'restarting' && (
- RESTARTING... + {t('update.restarting')}
)} @@ -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" > - UPDATE FAILED + {t('update.updateFailed')} {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)]" > - DOCKER UPDATE + {t('update.dockerUpdate')} {renderDockerDialog()} @@ -1204,12 +1197,12 @@ export default function TopRightControls({ {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')} )} diff --git a/frontend/src/components/WorldviewLeftPanel.tsx b/frontend/src/components/WorldviewLeftPanel.tsx index 6aade28..0a9d050 100644 --- a/frontend/src/components/WorldviewLeftPanel.tsx +++ b/frontend/src/components/WorldviewLeftPanel.tsx @@ -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({ {section.label} @@ -1571,7 +1573,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ {anyOn && totalCount > 0 && ( {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)', diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..be22c69 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -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>> = { en, 'zh-CN': zhCN }; + +interface I18nContextValue { + locale: Locale; + setLocale: (locale: Locale) => void; + t: (key: string) => string; +} + +const I18nContext = createContext({ + locale: 'en', + setLocale: () => {}, + t: (key: string) => key, +}); + +function resolve(obj: Record, 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)) { + current = (current as Record)[part]; + } else { + return path; // fallback to key + } + } + return typeof current === 'string' ? current : path; +} + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocale] = useState(() => { + 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, key); + return value; + }, + [locale], + ); + + return ( + + {children} + + ); +} + +export function useTranslation() { + return useContext(I18nContext); +} + +export { I18nContext }; diff --git a/frontend/src/i18n/translations/en.json b/frontend/src/i18n/translations/en.json new file mode 100644 index 0000000..f2bce99 --- /dev/null +++ b/frontend/src/i18n/translations/en.json @@ -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" + } +} diff --git a/frontend/src/i18n/translations/zh-CN.json b/frontend/src/i18n/translations/zh-CN.json new file mode 100644 index 0000000..e3a4eb2 --- /dev/null +++ b/frontend/src/i18n/translations/zh-CN.json @@ -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": "相干变化" + } +}