diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index f0698e6b..f51fa4a1 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -29,12 +29,12 @@ function LiveBadge({ wsManager, compact = false }: { wsManager: WsManager; compa if (status !== "connecting") return; function update() { const staleSec = Math.floor((Date.now() - wsManager.getLastEventTimestamp()) / 1000); - setStaleStr(staleSec > 60 ? `${Math.floor(staleSec / 60)}m` : `${staleSec}s`); + setStaleStr(staleSec > 60 ? t("timestamp.unit.m", { count: Math.floor(staleSec / 60) }) : t("timestamp.unit.s", { count: staleSec })); } update(); const id = setInterval(update, 1000); return () => clearInterval(id); - }, [status, wsManager]); + }, [status, wsManager, t]); if (status === "connected") { if (compact) return ( @@ -364,12 +364,13 @@ function ThemeOptions({ onPick }: { onPick?: () => void }) { } function GitHubLink() { + const { t } = useTranslation(); return (
{collapsible && setCollapsed((v) => !v)} />} - +
@@ -71,12 +74,12 @@ export function DetailPanel({ title, onClose, closeLabel = "Close detail panel",
{isLoading ? (
- Loading... + {t("common.loadingDots")}
) : notFound ? (
{notFoundIcon} - {notFoundLabel} + {notFoundLabel ?? t("common.notFound")}
) : ( children diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index bebdabcf..04dd1157 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; interface Props { children: ReactNode; @@ -22,19 +23,24 @@ export class ErrorBoundary extends Component { // React.lazy caches a rejected import, so remounting can't recover a failed chunk load // (e.g. a deploy swapped the hashed assets out from under us) — only a real reload can const chunkFailure = /dynamically imported module|Loading chunk|error loading/i.test(this.state.error.message); - return ( -
-

Something went wrong rendering this view.

- -
- ); + return this.setState({ error: null })} />; } return this.props.children; } } + +function ErrorFallback({ chunkFailure, onRetry }: { chunkFailure: boolean; onRetry: () => void }) { + const { t } = useTranslation(); + return ( +
+

{t("errorBoundary.message")}

+ +
+ ); +} diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index fb5b172f..ee6efc82 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { MultiSelectDropdown } from "./MultiSelectDropdown"; import { SearchBar, type SearchFieldOption } from "./SearchBar"; import { FilterSheet, FiltersButton } from "./FilterSheet"; @@ -10,11 +11,11 @@ interface FilterOption { label: string; } -const PACKET_SEARCH_FIELDS: SearchFieldOption[] = [ - { value: "hash", label: "Hash" }, - { value: "path", label: "Latest path" }, - { value: "payload", label: "Payload", disabled: true }, -]; +const PACKET_SEARCH_FIELDS = [ + { value: "hash", labelKey: "filters.field.hash" }, + { value: "path", labelKey: "filters.field.path" }, + { value: "payload", labelKey: "filters.field.payload", disabled: true }, +] as const; interface FilterBarProps { typeOptions: FilterOption[]; @@ -57,7 +58,9 @@ export function FilterBar({ onSearchFieldChange, onClear, }: FilterBarProps) { + const { t } = useTranslation(); const isMobile = useIsMobile(); + const searchFields: SearchFieldOption[] = PACKET_SEARCH_FIELDS.map(({ labelKey, ...f }) => ({ ...f, label: t(labelKey) })); const [sheetOpen, setSheetOpen] = useState(false); // close the sheet when leaving mobile — derive during render, not in an effect if (sheetOpen && !isMobile) setSheetOpen(false); @@ -75,19 +78,19 @@ export function FilterBar({ // shared by the desktop inline bar and the mobile filter sheet const controls = (fullWidth: boolean) => ( <> - - - + + + {scopeOptions.length > 0 && ( - + )} ); if (isMobile) { return ( -
- onSearchFieldChange(f as SearchField)} /> +
+ onSearchFieldChange(f as SearchField)} /> setSheetOpen(true)} /> {sheetOpen && ( setSheetOpen(false)} onClear={hasFilters ? onClear : undefined}> @@ -102,12 +105,12 @@ export function FilterBar({
onSearchFieldChange(f as SearchField)} /> @@ -122,7 +125,7 @@ export function FilterBar({ className="text-[11px] font-mono text-text-dim hover:text-danger px-1.5 py-0.5 cursor-pointer transition-colors" onClick={onClear} > - Clear + {t("common.clear")} )}
diff --git a/src/components/FilterSheet.tsx b/src/components/FilterSheet.tsx index 17ff5a9a..30dfa9fa 100644 --- a/src/components/FilterSheet.tsx +++ b/src/components/FilterSheet.tsx @@ -1,9 +1,11 @@ import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { BottomSheet } from "./BottomSheet"; import { CloseButton } from "./CloseButton"; // Mobile filter trigger: funnel + active count, styled like a dropdown trigger. export function FiltersButton({ activeCount, onClick }: { activeCount: number; onClick: () => void }) { + const { t } = useTranslation(); const active = activeCount > 0; return ( ) : ( @@ -59,7 +62,7 @@ export function FilterSheet({ onClose, onClear, children }: { onClick={onClose} className="text-xs font-mono font-medium text-primary border border-primary-dim bg-primary/6 rounded px-4 py-1.5 cursor-pointer transition-colors hover:bg-primary/10" > - Done + {t("common.done")}
diff --git a/src/components/LoadingPill.tsx b/src/components/LoadingPill.tsx index 258d5b99..acc672b7 100644 --- a/src/components/LoadingPill.tsx +++ b/src/components/LoadingPill.tsx @@ -1,8 +1,12 @@ +import { useTranslation } from "react-i18next"; + +type LoadingNoun = "nodes" | "routes" | "packets" | "messages" | "observers"; + interface LoadingPillProps { loading: boolean; error?: boolean; count: number; // rows loaded so far - noun: string; // plural entity name, e.g. "nodes" / "observers" + noun: LoadingNoun; label?: string; // optional complete localized phrase position?: string; // corner placement (the parent must be `relative`) } @@ -10,6 +14,7 @@ interface LoadingPillProps { // Small floating status pill shared by the map and the entity tables: a muted "Loading … (N)" while // pages stream in, a danger-toned message if a fetch fails. Renders nothing when idle. export function LoadingPill({ loading, error, count, noun, label, position = "bottom-3 left-3" }: LoadingPillProps) { + const { t } = useTranslation(); if (!loading && !error) return null; const tone = loading ? "text-text-muted" : "text-danger"; const dot = loading ? "bg-primary animate-pulse" : "bg-danger"; @@ -20,10 +25,10 @@ export function LoadingPill({ loading, error, count, noun, label, position = "bo > {label ?? (loading - ? `Loading ${noun}… (${count})` + ? t(`loadingPill.${noun}.loading`, { count }) : count > 0 - ? `Some ${noun} failed to load (${count} shown)` - : `Failed to load ${noun}`)} + ? t(`loadingPill.${noun}.partial`, { count }) + : t(`loadingPill.${noun}.failed`))}
); } diff --git a/src/components/MultiSelectDropdown.tsx b/src/components/MultiSelectDropdown.tsx index 80c427bc..856bd400 100644 --- a/src/components/MultiSelectDropdown.tsx +++ b/src/components/MultiSelectDropdown.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useMemo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { useClickOutside } from "../hooks/useClickOutside"; interface Option { @@ -19,6 +20,7 @@ interface MultiSelectDropdownProps { // checkbox dropdown with optional search filter export function MultiSelectDropdown({ label, options, selected, onChange, searchable, align = "left", fullWidth = false }: MultiSelectDropdownProps) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const ref = useRef(null); @@ -92,7 +94,7 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search type="text" value={filter} onChange={(e) => setFilter(e.target.value)} - placeholder="Filter..." + placeholder={t("common.filterPlaceholder")} className="w-full text-[11px] font-mono bg-bg-surface border border-border rounded px-2 py-1 text-text-bright placeholder:text-text-dim" />
@@ -106,7 +108,7 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search }`} onClick={() => onChange(options.map((o) => o.value))} > - All + {t("common.all")} · @@ -158,7 +160,7 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search ); })} {filtered.length === 0 && ( -
No matches
+
{t("common.noMatches")}
)} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 8148ee4c..139a9cfc 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { Dropdown } from "./Dropdown"; export interface SearchFieldOption { @@ -20,6 +21,7 @@ interface SearchBarProps { // debounced search input with field dropdown export function SearchBar({ value, onChange, fields, field, onFieldChange, hideField = false, inputLabel }: SearchBarProps) { + const { t } = useTranslation(); const [localValue, setLocalValue] = useState(value); const [prevValue, setPrevValue] = useState(value); const debounceRef = useRef | null>(null); @@ -112,7 +114,7 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange, hideF value={localValue} aria-label={inputLabel} onChange={(e) => handleChange(e.target.value)} - placeholder={inputLabel ?? `Search by ${(currentField?.label ?? "").toLowerCase()}...`} + placeholder={inputLabel ?? t("search.placeholder", { field: (currentField?.label ?? "").toLowerCase() })} className={`w-full font-mono bg-bg-surface border border-border pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim transition-colors ${hideField ? "rounded-sm text-[11px]" : "rounded-r-sm text-[11px]"}`} /> {localValue && ( diff --git a/src/components/SelectDropdown.tsx b/src/components/SelectDropdown.tsx index d77fe9ba..0ccdcc26 100644 --- a/src/components/SelectDropdown.tsx +++ b/src/components/SelectDropdown.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Dropdown } from "./Dropdown"; interface SelectOption { @@ -18,7 +19,9 @@ interface SelectDropdownProps { // single-select dropdown styled to match the packets MultiSelectDropdown trigger -export function SelectDropdown({ label, options, value, onChange, align = "right", allLabel = "All", hideAll = false, fullWidth = false }: SelectDropdownProps) { +export function SelectDropdown({ label, options, value, onChange, align = "right", allLabel: allLabelProp, hideAll = false, fullWidth = false }: SelectDropdownProps) { + const { t } = useTranslation(); + const allLabel = allLabelProp ?? t("common.all"); const active = value !== ""; const selectedLabel = options.find((o) => o.value === value)?.label ?? value; @@ -79,7 +82,7 @@ export function SelectDropdown({ label, options, value, onChange, align = "right ); })} {hideAll && options.length === 0 && ( -
No options
+
{t("common.noOptions")}
)} )} diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index 20a1be1d..026555af 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -6,6 +6,7 @@ // line from src/App.tsx. Nothing else references it. import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { BeaconLogo } from "./BeaconLogo"; import { SKIP_SPLASH } from "../lib/constants"; @@ -14,6 +15,7 @@ const VISIBLE_MS = 2000; const FADE_MS = 400; export function SplashScreen() { + const { t } = useTranslation(); // Synchronous gate: decided before first paint so StrictMode's double-mount // (and any same-session reload) never re-shows it. const [render, setRender] = useState(() => { @@ -64,7 +66,7 @@ export function SplashScreen() { className="text-text-muted text-xs tracking-[0.12em]" style={{ fontFamily: "'Chakra Petch', sans-serif" }} > - MeshCore Network Analyzer + {t("splash.tagline")} diff --git a/src/features/channels/ChannelFilterBar.tsx b/src/features/channels/ChannelFilterBar.tsx index d091267f..dbcf9cb2 100644 --- a/src/features/channels/ChannelFilterBar.tsx +++ b/src/features/channels/ChannelFilterBar.tsx @@ -1,25 +1,11 @@ import { useState } from "react"; -import { SearchBar, type SearchFieldOption } from "../../components/SearchBar"; +import { useTranslation } from "react-i18next"; +import { SearchBar } from "../../components/SearchBar"; import { SelectDropdown } from "../../components/SelectDropdown"; import { FilterSheet, FiltersButton } from "../../components/FilterSheet"; import { useIsMobile } from "../../hooks/useMediaQuery"; import type { ChannelKeyFilter, ChannelHashtagFilter } from "./channel-filters"; -const SEARCH_FIELDS: SearchFieldOption[] = [ - { value: "name", label: "Name" }, - { value: "hash", label: "Hash" }, -]; - -const KEY_OPTIONS = [ - { value: "known", label: "Known" }, - { value: "unknown", label: "Unknown" }, -]; - -const HASHTAG_OPTIONS = [ - { value: "true", label: "Yes" }, - { value: "false", label: "No" }, -]; - interface ChannelFilterBarProps { search: string; onSearchChange: (v: string) => void; @@ -41,7 +27,20 @@ export function ChannelFilterBar({ hashtagFilter, onHashtagChange, }: ChannelFilterBarProps) { + const { t } = useTranslation(); const isMobile = useIsMobile(); + const searchFields = [ + { value: "name", label: t("channels.fieldName") }, + { value: "hash", label: t("channels.fieldHash") }, + ]; + const keyOptions = [ + { value: "known", label: t("channels.keyKnownOption") }, + { value: "unknown", label: t("channels.keyUnknownOption") }, + ]; + const hashtagOptions = [ + { value: "true", label: t("channels.yes") }, + { value: "false", label: t("channels.no") }, + ]; const [sheetOpen, setSheetOpen] = useState(false); // close the sheet when leaving mobile — derive during render, not in an effect if (sheetOpen && !isMobile) setSheetOpen(false); @@ -55,15 +54,15 @@ export function ChannelFilterBar({ // shared by the desktop inline bar and the mobile filter sheet const controls = (fullWidth: boolean) => ( <> - onKeyChange(v as ChannelKeyFilter)} fullWidth={fullWidth} /> - onHashtagChange(v as ChannelHashtagFilter)} fullWidth={fullWidth} /> + onKeyChange(v as ChannelKeyFilter)} fullWidth={fullWidth} /> + onHashtagChange(v as ChannelHashtagFilter)} fullWidth={fullWidth} /> ); if (isMobile) { return ( -
- +
+ setSheetOpen(true)} /> {sheetOpen && ( setSheetOpen(false)} onClear={activeCount > 0 ? clearAll : undefined}> @@ -78,12 +77,12 @@ export function ChannelFilterBar({
diff --git a/src/features/channels/ChannelList.tsx b/src/features/channels/ChannelList.tsx index f8fa6f83..26787a98 100644 --- a/src/features/channels/ChannelList.tsx +++ b/src/features/channels/ChannelList.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useMemo, useEffect, useRef, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { useInfiniteQuery, useIsFetching, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { getChannels } from "../../api/client"; import { isRateLimited, subscribeRateLimit } from "../../api/rate-limit"; @@ -33,6 +34,7 @@ function appendMessages(old: InfiniteData> | undefine } export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { + const { t } = useTranslation(); const { iatas, regionKey } = useRegion(); const isMobile = useIsMobile(); // Keep the open channel available when a directory page is evicted or refreshed. @@ -251,18 +253,18 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { /> )} {!keyed.isLoading && filteredChannels.length === 0 && ( -

No matching channels loaded.

+

{t("channels.noMatches")}

)} - {(keyed.isError || others.isError) &&

Could not load channels.

} + {(keyed.isError || others.isError) &&

{t("channels.loadError")}

} {keyed.isError ? ( void keyed.refetch()}> - {keyed.isFetching ? "Loading channels..." : "Retry loading channels"} + {keyed.isFetching ? t("channels.loadingChannels") : t("channels.retryChannels")} ) : !others.isEnabled ? ( - !keyed.isLoading && setOthersRegion(regionKey)}>Load other channels + !keyed.isLoading && setOthersRegion(regionKey)}>{t("channels.loadOthers")} ) : (others.hasNextPage || others.isError) && ( void (others.hasNextPage ? others.fetchNextPage() : others.refetch())}> - {others.isFetching ? "Loading channels..." : others.isError ? "Retry loading channels" : "Load more channels"} + {others.isFetching ? t("channels.loadingChannels") : others.isError ? t("channels.retryChannels") : t("channels.loadMore")} )}
diff --git a/src/features/channels/ChannelSidebar.tsx b/src/features/channels/ChannelSidebar.tsx index 9511ecde..2ec184c8 100644 --- a/src/features/channels/ChannelSidebar.tsx +++ b/src/features/channels/ChannelSidebar.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Badge } from "../../components/Badge"; import { Timestamp } from "../../components/Timestamp"; import { channelDisplayName } from "./types"; @@ -10,6 +11,7 @@ interface ChannelSidebarProps { } export function ChannelSidebar({ channels, selectedId, onSelect }: ChannelSidebarProps) { + const { t } = useTranslation(); return (
@@ -33,11 +35,11 @@ export function ChannelSidebar({ channels, selectedId, onSelect }: ChannelSideba
{ch.keyKnown ? ( - key + {t("channels.key")} ) : ( - no key + {t("channelMessages.noKey")} )} - {ch.isHashtag && hashtag} + {ch.isHashtag && {t("channelMessages.hashtag")}}
); diff --git a/src/features/channels/MessagePanel.tsx b/src/features/channels/MessagePanel.tsx index e738460f..c3cd34c5 100644 --- a/src/features/channels/MessagePanel.tsx +++ b/src/features/channels/MessagePanel.tsx @@ -41,7 +41,9 @@ function MessageRow({ msg, heardCount, onAnalyze }: { msg: ChannelMessage; heard {msg.senderName} - {reach > 0 && ×{reach}} + {reach > 0 && + ×{reach}{t("channelMessages.heardTimes", { count: reach })} + } {/* Unscoped is the default, so only a named or unresolved scope earns a chip. */} {msg.scope ? {msg.scope} : msg.scopeStatus === "unknown" && {t("channelMessages.unknownScope")}} diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index e6c87589..432267b0 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { MapStyleSwitcher } from "./MapStyleSwitcher"; import { SegmentedControl } from "./SegmentedControl"; import { NODE_TYPE_FILTER_OPTIONS, type NeighborLinesMode } from "./types"; @@ -9,30 +10,16 @@ import { useIsMobile } from "../../hooks/useMediaQuery"; // Open/closed state persists across sessions; no click-outside dismiss, so it stays open while you pan. const OPEN_STORAGE_KEY = "beacon-map-settings-open"; -const TYPE_OPTIONS = [{ value: "", label: "All" }, ...NODE_TYPE_FILTER_OPTIONS]; -const CLUSTER_OPTIONS = [ - { value: "on", label: "On" }, - { value: "off", label: "Off" }, -]; -const NEIGHBOR_OPTIONS = [ - { value: "on", label: "On" }, - { value: "selected", label: "Selected" }, - { value: "off", label: "Off" }, -]; -const BORDER_OPTIONS = [ - { value: "on", label: "On" }, - { value: "off", label: "Off" }, -]; - // Swatch matching the border layer paint (secondary line over a faint fill), so the legend tracks the theme. function BorderLegend() { + const { t } = useTranslation(); return (
- Area outline + {t("map.areaOutline")}
); } @@ -40,9 +27,10 @@ function BorderLegend() { // Legend for a selected node's coloured edges. Gradient stops mirror the map paint's log anchors // (red ~1, yellow ~20 at 60%, green ~150+); palette vars keep it in step with the active theme. function NeighborLegend() { + const { t } = useTranslation(); return (
-
Observations
+
{t("map.observations")}
20 150+
-
fainter = heard longer ago
+
{t("map.fainter")}
); } @@ -85,7 +73,15 @@ export function MapSettingsPanel({ onBordersChange, buildShareParams, }: MapSettingsPanelProps) { + const { t } = useTranslation(); const isMobile = useIsMobile(); + const on = { value: "on", label: t("map.on") }; + const off = { value: "off", label: t("map.off") }; + const typeOptions = [ + { value: "", label: t("map.all") }, + ...NODE_TYPE_FILTER_OPTIONS.map((o) => ({ value: o.value, label: t(`nodeTypes.${o.value}`, { defaultValue: o.label }) })), + ]; + const neighborOptions = [on, { value: "selected", label: t("map.selected") }, off]; // collapsed by default on mobile (the card would cover the map); a saved preference still wins const [open, setOpen] = useState(() => { const stored = localStorage.getItem(OPEN_STORAGE_KEY); @@ -117,48 +113,48 @@ export function MapSettingsPanel({ - Map Settings + {t("map.settings")} {open ? "▾" : "▸"} {open && (
-
+
-
+
-
+
onClusteredChange(v === "on")} className="w-full" />
-
+
onNeighborLinesChange(v as NeighborLinesMode)} className="w-full" /> {neighborLines === "selected" && }
-
+
onBordersChange(v === "on")} className="w-full" @@ -168,8 +164,8 @@ export function MapSettingsPanel({
diff --git a/src/features/map/MapStyleSwitcher.tsx b/src/features/map/MapStyleSwitcher.tsx index 0b8fee3e..e80973c0 100644 --- a/src/features/map/MapStyleSwitcher.tsx +++ b/src/features/map/MapStyleSwitcher.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { MAP_STYLES } from "./types"; import { SegmentedControl } from "./SegmentedControl"; @@ -8,10 +9,11 @@ interface MapStyleSwitcherProps { } export function MapStyleSwitcher({ styleId, onChange, className }: MapStyleSwitcherProps) { + const { t } = useTranslation(); return ( ({ value: s.id, label: s.name }))} + ariaLabel={t("map.style")} + options={MAP_STYLES.map((s) => ({ value: s.id, label: t(`map.styles.${s.id}`, { defaultValue: s.name }) }))} value={styleId} onChange={onChange} className={className} diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx index d89ff2a7..674e08b0 100644 --- a/src/features/map/MapView.tsx +++ b/src/features/map/MapView.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import "maplibre-gl/dist/maplibre-gl.css"; import { useMapLibre } from "./useMapLibre"; @@ -37,6 +38,7 @@ interface MapViewProps { } export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProps) { + const { t } = useTranslation(); // Deep-link params, read once at mount (like the region's ?iata seed). Each setting below is seeded // URL -> localStorage -> default; the URL wins for this session but is never written back to // localStorage, so a shared link can't clobber the visitor's saved prefs. @@ -235,7 +237,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp {error && ( // z-20 so the failure overlay covers the settings card (z-10) instead of it floating on top
- +
)}
diff --git a/src/features/map/PacketFlowButton.tsx b/src/features/map/PacketFlowButton.tsx index d1fe3e30..ea6f4ce4 100644 --- a/src/features/map/PacketFlowButton.tsx +++ b/src/features/map/PacketFlowButton.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from "react-i18next"; + interface PacketFlowButtonProps { active: boolean; onToggle: () => void; @@ -7,13 +9,15 @@ interface PacketFlowButtonProps { // border either way so it reads as a control, not decoration. Bottom-center, clear of the corner // map controls. Labelled "Live Map" to distinguish it from the header's LIVE websocket badge. export function PacketFlowButton({ active, onToggle }: PacketFlowButtonProps) { + const { t } = useTranslation(); + const label = active ? t("map.stopFlow") : t("map.playFlow"); return ( ); } diff --git a/src/features/map/PacketPathMap.tsx b/src/features/map/PacketPathMap.tsx index 2c02b685..ebf35070 100644 --- a/src/features/map/PacketPathMap.tsx +++ b/src/features/map/PacketPathMap.tsx @@ -13,6 +13,7 @@ import type { Point } from "geojson"; import type { PacketPath } from "./packet-path"; import { packetPathsToFeatures } from "./packet-path"; import { resolveMapStyle, DEFAULT_CENTER, DEFAULT_ZOOM, IATA_ZOOM } from "./types"; +import { mapLocale } from "./useMapLibre"; // Private ids — this map instance is dedicated to the popup, so they can't collide with the main map. const LINE_SOURCE = "pp-lines"; @@ -45,6 +46,7 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, attributionControl: false, + locale: mapLocale(), }); mapRef.current = map; map.addControl(new NavigationControl({ showCompass: false }), "top-right"); diff --git a/src/features/map/useMapLibre.ts b/src/features/map/useMapLibre.ts index cca1a697..183ffa5b 100644 --- a/src/features/map/useMapLibre.ts +++ b/src/features/map/useMapLibre.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Map as MapLibreMap, NavigationControl, ScaleControl, AttributionControl, LngLatBounds } from "maplibre-gl"; import type { RasterDEMSourceSpecification } from "maplibre-gl"; import "./maplibre-worker"; +import i18n from "../../i18n"; import { DEM_TILES, DEM_ATTRIBUTION, @@ -16,6 +17,19 @@ import { resolveMapStyle, } from "./types"; +// MapLibre reads its control tooltips once at construction, so a language switch applies on next mount. +export function mapLocale(): Record { + return { + "AttributionControl.ToggleAttribution": i18n.t("map.controls.toggleAttribution"), + "AttributionControl.MapFeedback": i18n.t("map.controls.mapFeedback"), + "Map.Title": i18n.t("map.controls.map"), + "NavigationControl.ResetBearing": i18n.t("map.controls.resetBearing"), + "NavigationControl.ZoomIn": i18n.t("map.controls.zoomIn"), + "NavigationControl.ZoomOut": i18n.t("map.controls.zoomOut"), + "Popup.Close": i18n.t("map.controls.closePopup"), + }; +} + // serialized fit target, so the fit effect can skip redundant re-fits const fitKey = (points: [number, number][] | null) => points && points.length ? points.map((p) => `${p[0]},${p[1]}`).join(";") : null; @@ -114,6 +128,7 @@ export function useMapLibre( bearing: DEFAULT_BEARING, maxPitch: MAX_PITCH, attributionControl: false, // replaced below with a compact (always-collapsed) control + locale: mapLocale(), }); mapRef.current = map; lastStyleIdRef.current = styleIdRef.current; diff --git a/src/features/nodes/ForeignNodeBadge.tsx b/src/features/nodes/ForeignNodeBadge.tsx index cad22d16..e4ae87c0 100644 --- a/src/features/nodes/ForeignNodeBadge.tsx +++ b/src/features/nodes/ForeignNodeBadge.tsx @@ -1,11 +1,13 @@ +import { useTranslation } from "react-i18next"; import { Badge } from "../../components/Badge"; import { Tooltip } from "../../components/Tooltip"; export function ForeignNodeBadge({ possiblyForeign }: { possiblyForeign?: boolean }) { + const { t } = useTranslation(); if (possiblyForeign !== true) return null; return ( - - Possibly foreign + + {t("nodes.possiblyForeign")} ); } diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 4ab8d18d..dedc1838 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -13,6 +13,7 @@ import type { NodeObservation, NodeNeighbor } from "./types"; import { ForeignNodeBadge } from "./ForeignNodeBadge"; function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) { + const { t } = useTranslation(); return (
{neighbor.publicKey} · - {neighbor.observationCount.toLocaleString()} obs + {t("nodeDetail.observations", { count: neighbor.observationCount, formatted: neighbor.observationCount.toLocaleString() })}
); } function NodeObservationRow({ obs, onClick }: { obs: NodeObservation; onClick?: () => void }) { + const { t } = useTranslation(); const level = snrLevel(obs.snr); return (
- Hops + {t("nodeDetail.hops")} {obs.hopCount ?? "—"}
@@ -102,13 +104,13 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o return ( } + headerAction={} isLoading={isLoading} notFound={!node} - notFoundLabel="Node not found" + notFoundLabel={t("nodeDetail.notFound")} notFoundIcon={ @@ -117,7 +119,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o > {node && ( <> -
+
{node.name ?? formatHex(node.id)} @@ -129,7 +131,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
{node.publicKey}
- +
{node.observerId && ( )} {(hasLocation || node.locationSource) && ( -
+
- {hasLocation && } - {hasLocation && } - {node.locationSource && } + {hasLocation && } + {hasLocation && } + {node.locationSource && }
{hasMapLocation(node) && onViewOnMap && (