From 9ae0b189ba39e55d5f667b15ddea8cb7aab4a77a Mon Sep 17 00:00:00 2001 From: wsdone <90829454+wsdone@users.noreply.github.com> Date: Tue, 19 May 2026 15:33:07 +0800 Subject: [PATCH] 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 --- frontend/src/app/layout.tsx | 11 +- frontend/src/app/page.tsx | 37 +-- frontend/src/components/AIIntelPanel.tsx | 4 +- frontend/src/components/FilterPanel.tsx | 6 +- frontend/src/components/FindLocateBar.tsx | 4 +- frontend/src/components/MapLegend.tsx | 4 +- frontend/src/components/ScaleBar.tsx | 4 +- frontend/src/components/SettingsPanel.tsx | 14 +- frontend/src/components/ShodanPanel.tsx | 6 +- frontend/src/components/TopRightControls.tsx | 159 ++++++----- .../src/components/WorldviewLeftPanel.tsx | 126 ++++----- frontend/src/i18n/index.ts | 73 ++++++ frontend/src/i18n/translations/en.json | 246 ++++++++++++++++++ frontend/src/i18n/translations/zh-CN.json | 246 ++++++++++++++++++ 14 files changed, 759 insertions(+), 181 deletions(-) create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/translations/en.json create mode 100644 frontend/src/i18n/translations/zh-CN.json 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": "相干变化" + } +}