From 19f1253ed5f7ddc74b37118616b30161964453c9 Mon Sep 17 00:00:00 2001 From: yanhenrique-dev Date: Sun, 6 Sep 2026 12:45:27 -0300 Subject: [PATCH 1/3] feat(i18n): Portuguese localization for app chrome, cards, dialogs and pages Adds a locale preference (en/pt, persisted, browser-detected on first run) with a shared dictionary and English fallback, a globe dropdown in the sidebar plus a Language card in Settings, and EN|PT toggles on the standalone pages. Covers sidebar, settings, tabs, overview, pending bar, toasts, device cards, Logitech/Keychron editors, support/capture/share dialogs, landing, FAQ, device check, supported list, donate, admin console and controller status/error messages. Device-provided values, hardware-written option values and server-bound fields stay untouched. --- src/admin.tsx | 131 ++- src/app/App.tsx | 62 +- src/app/CaptureDialog.tsx | 24 +- src/app/Diagnostics.tsx | 35 +- src/app/InterfaceSettings.tsx | 124 +- src/app/KeychronNapeLayers.tsx | 46 +- src/app/PendingBar.tsx | 14 +- src/app/Profiles.tsx | 80 +- src/app/ShareProfileDialog.tsx | 11 +- src/app/Sidebar.tsx | 88 +- src/app/Superstrike.tsx | 37 +- src/app/SupportRequestsDialog.tsx | 64 +- src/app/Toasts.tsx | 8 +- src/app/cards/AdvancedCards.tsx | 329 +++--- src/app/cards/DpiCard.tsx | 65 +- src/app/cards/LightingCard.tsx | 46 +- src/app/cards/MxMasterCards.tsx | 63 +- src/app/cards/PerformanceCards.tsx | 83 +- src/app/page-locale.tsx | 87 ++ src/app/site-chrome.tsx | 18 +- src/app/ui.tsx | 6 +- src/check-scan.ts | 33 +- src/check.tsx | 66 +- src/device/controller.ts | 446 +++---- src/device/options.ts | 20 +- src/donate.tsx | 109 +- src/faq.tsx | 114 +- src/i18n.test.ts | 29 + src/i18n.ts | 1743 ++++++++++++++++++++++++++++ src/interface-preferences.test.ts | 28 +- src/interface-preferences.ts | 40 +- src/landing.tsx | 53 +- src/offline-banner.ts | 15 +- src/supported.ts | 267 +++-- 34 files changed, 3287 insertions(+), 1097 deletions(-) create mode 100644 src/app/page-locale.tsx create mode 100644 src/i18n.test.ts create mode 100644 src/i18n.ts diff --git a/src/admin.tsx b/src/admin.tsx index acb1a047..d1dcf535 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -1,6 +1,9 @@ import { useEffect, useMemo, useState } from "react"; import { createRoot } from "react-dom/client"; import "./admin.css"; +import { t, tp } from "./i18n"; +import type { InterfaceLocale } from "./interface-preferences"; +import { PageLocaleToggle, usePageLocale } from "./app/page-locale"; type DailyPoint = { day: string; view_count: number; peak_concurrent: number }; type CountRow = { country?: string; mouse_model?: string; view_count?: number; uses?: number }; @@ -15,13 +18,23 @@ type Stats = { const LIVE_POLL_MS = 5000; const RANGE_OPTIONS = [7, 30, 90] as const; -const WEEKDAY_LABELS = ["S", "M", "T", "W", "T", "F", "S"]; -const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; -function formatDay(day: string) { +/** 2024-01-07 was a Sunday; used as the anchor for locale weekday names. */ +function weekdayName(tag: string, index: number): string { + return new Date(2024, 0, 7 + index).toLocaleDateString(tag, { weekday: "long" }); +} + +function weekdayNarrow(tag: string): string[] { + return Array.from( + { length: 7 }, + (_, index) => new Date(2024, 0, 7 + index).toLocaleDateString(tag, { weekday: "narrow" }), + ); +} + +function formatDay(day: string, tag: string) { const d = new Date(day); if (Number.isNaN(d.getTime())) return day; - return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return d.toLocaleDateString(tag, { month: "short", day: "numeric" }); } function pctChange(current: number, previous: number): number | null { if (previous <= 0) return null; @@ -37,7 +50,7 @@ const IconTrend = () => ( const IconLogout = () => (); /* ---------- login ---------- */ -function LoginForm({ onLoggedIn }: { onLoggedIn: () => void }) { +function LoginForm({ onLoggedIn, locale }: { onLoggedIn: () => void; locale: InterfaceLocale }) { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -54,12 +67,12 @@ function LoginForm({ onLoggedIn }: { onLoggedIn: () => void }) { }); if (!response.ok) { const body = await response.json().catch(() => ({})); - setError(body.message ?? "Login failed."); + setError(body.message ?? t(locale, "adm.loginFailed")); return; } onLoggedIn(); } catch { - setError("Network error."); + setError(t(locale, "adm.networkError")); } finally { setBusy(false); } @@ -67,17 +80,17 @@ function LoginForm({ onLoggedIn }: { onLoggedIn: () => void }) { return (
-

OpenMouse Admin

+

{t(locale, "adm.title")}

setPassword((e.target as HTMLInputElement).value)} className="adm-login-input" /> {error &&

{error}

} @@ -85,12 +98,12 @@ function LoginForm({ onLoggedIn }: { onLoggedIn: () => void }) { } /* ---------- chart ---------- */ -function DailyChart({ points }: { points: DailyPoint[] }) { +function DailyChart({ points, locale, tag }: { points: DailyPoint[]; locale: InterfaceLocale; tag: string }) { const [hover, setHover] = useState(null); const width = 640; const height = 168; const max = Math.max(1, ...points.map((p) => p.view_count)); - if (!points.length) return

No history yet.

; + if (!points.length) return

{t(locale, "adm.noHistory")}

; const activeIndex = hover ?? points.length - 1; const active = points[activeIndex]; @@ -103,7 +116,7 @@ function DailyChart({ points }: { points: DailyPoint[] }) {
{active.view_count.toLocaleString()} - views · {formatDay(active.day)} · peak concurrent {active.peak_concurrent} + {t(locale, "adm.viewsWord")} · {formatDay(active.day, tag)} · {tp(locale, "adm.peakConcurrent", { n: active.peak_concurrent })}
setHover(null)}> @@ -114,15 +127,15 @@ function DailyChart({ points }: { points: DailyPoint[] }) { {coords.map(([x, y], i) => (i === activeIndex ? : null))}
- {formatDay(points[0].day).toUpperCase()} - {formatDay(points[points.length - 1].day).toUpperCase()} + {formatDay(points[0].day, tag).toUpperCase()} + {formatDay(points[points.length - 1].day, tag).toUpperCase()}
); } /** GitHub-style intensity heatmap of views by weekday, one row per week. */ -function WeekdayHeatmap({ points }: { points: DailyPoint[] }) { +function WeekdayHeatmap({ points, locale, tag }: { points: DailyPoint[]; locale: InterfaceLocale; tag: string }) { const weeks = useMemo(() => { const chunks: (DailyPoint | null)[][] = []; for (let i = 0; i < points.length; i += 7) { @@ -136,7 +149,7 @@ function WeekdayHeatmap({ points }: { points: DailyPoint[] }) { } return chunks; }, [points]); - if (!points.length) return

No history yet.

; + if (!points.length) return

{t(locale, "adm.noHistory")}

; const max = Math.max(1, ...points.map((p) => p.view_count)); return ( @@ -147,7 +160,7 @@ function WeekdayHeatmap({ points }: { points: DailyPoint[] }) { W{wi + 1} {week.map((cell, di) => cell ? ( -
+
) : (
), @@ -157,17 +170,18 @@ function WeekdayHeatmap({ points }: { points: DailyPoint[] }) {
- {WEEKDAY_LABELS.map((l, i) => {l})} + {weekdayNarrow(tag).map((l, i) => {l})}
); } -function DataTable({ rows, labelKey, valueKey, nameHeader, valueHeader }: { +function DataTable({ rows, labelKey, valueKey, nameHeader, valueHeader, locale }: { rows: CountRow[]; labelKey: "country" | "mouse_model"; valueKey: "view_count" | "uses"; nameHeader: string; valueHeader: string; + locale: InterfaceLocale; }) { const max = Math.max(1, ...rows.map((r) => Number(r[valueKey]) || 0)); - if (!rows.length) return

No data yet.

; + if (!rows.length) return

{t(locale, "adm.noData")}

; return ( @@ -180,7 +194,7 @@ function DataTable({ rows, labelKey, valueKey, nameHeader, valueHeader }: { @@ -194,6 +208,8 @@ function DataTable({ rows, labelKey, valueKey, nameHeader, valueHeader }: { /* ---------- dashboard ---------- */ function Dashboard() { + const [locale, setLocale] = usePageLocale(); + const tag = locale === "pt" ? "pt-BR" : "en-US"; const [stats, setStats] = useState(null); const [error, setError] = useState(null); const [range, setRange] = useState(30); @@ -202,7 +218,7 @@ function Dashboard() { const load = async (days: number) => { const response = await fetch(`/api/admin/stats?days=${days}`); if (response.status === 401) { setError("session-expired"); return; } - if (!response.ok) { setError("Could not load stats."); return; } + if (!response.ok) { setError(t(locale, "adm.loadFail")); return; } setStats(await response.json()); setLastUpdated(new Date()); setError(null); @@ -227,7 +243,7 @@ function Dashboard() { const sums = new Array(7).fill(0); daily.forEach((d) => { const dt = new Date(d.day); if (!Number.isNaN(dt.getTime())) sums[dt.getUTCDay()] += d.view_count; }); const bestIndex = sums.reduce((best, v, i) => (v > sums[best] ? i : best), 0); - return WEEKDAY_NAMES[bestIndex]; + return weekdayName(tag, bestIndex); }, [daily]); const weekTrend = useMemo(() => { if (daily.length < 14) return null; @@ -241,9 +257,9 @@ function Dashboard() { const liveVal = stats?.live ?? 0; const peakVal = stats?.allTimePeak ?? 0; const concurrencyRatio = peakVal > 0 ? Math.min(100, (liveVal / peakVal) * 100) : 0; - const trackedSince = daily.length ? formatDay(daily[0].day) : "—"; + const trackedSince = daily.length ? formatDay(daily[0].day, tag) : "—"; - if (error === "session-expired") return load(range)} />; + if (error === "session-expired") return load(range)} locale={locale} />; return (
@@ -251,17 +267,18 @@ function Dashboard() {
-
OpenMouse Admin
-
{lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : "Loading…"}
+
{t(locale, "adm.title")}
+
{lastUpdated ? tp(locale, "adm.updated", { time: lastUpdated.toLocaleTimeString(tag) }) : t(locale, "adm.loading")}
+
{RANGE_OPTIONS.map((opt) => ( ))}
-
@@ -272,70 +289,70 @@ function Dashboard() {
-
Live right now
+
{t(locale, "adm.live")}
{stats?.live ?? "—"}
-
All-time peak {stats?.allTimePeak ?? "—"}{stats?.allTimePeakAt ? ` · ${new Date(stats.allTimePeakAt).toLocaleDateString()}` : ""}
+
{t(locale, "adm.peak")} {stats?.allTimePeak ?? "—"}{stats?.allTimePeakAt ? ` · ${new Date(stats.allTimePeakAt).toLocaleDateString(tag)}` : ""}
-
{concurrencyRatio.toFixed(0)}% of peak
+
{tp(locale, "adm.ofPeak", { n: concurrencyRatio.toFixed(0) })}
-

Daily views

- LAST {daily.length}D · AVG {avgDaily.toFixed(1)} +

{t(locale, "adm.dailyViews")}

+ {tp(locale, "adm.lastAvg", { n: daily.length, avg: avgDaily.toFixed(1) })}
- +
-
Views, {daily.length}d
+
{tp(locale, "adm.viewsDays", { n: daily.length })}
{totalViews.toLocaleString()}
{weekTrend != null && ( = 0 ? "is-up" : "is-down"}`}>{weekTrend >= 0 ? "▲" : "▼"} {Math.abs(weekTrend).toFixed(1)}% )}
-
Avg / day
+
{t(locale, "adm.avgDay")}
{avgDaily.toFixed(1)}
-
median-adjacent
+
{t(locale, "adm.medianAdj")}
-
Best day
+
{t(locale, "adm.bestDay")}
{peakDay?.view_count ?? "—"}
-
{peakDay ? formatDay(peakDay.day) : "—"}
+
{peakDay ? formatDay(peakDay.day, tag) : "—"}
-
Best weekday
+
{t(locale, "adm.bestWeekday")}
{bestWeekday ?? "—"}
-
by total views
+
{t(locale, "adm.byViews")}
-

Regions

- {regionTotal.toLocaleString()} views · {regions.length} regions - +

{t(locale, "adm.regions")}

+ {tp(locale, "adm.regionsSub", { views: regionTotal.toLocaleString(), n: regions.length })} +
-

Most used mice

- {mouseTotal.toLocaleString()} connects · {mice.length} models - +

{t(locale, "adm.mice")}

+ {tp(locale, "adm.miceSub", { n: mouseTotal.toLocaleString(), m: mice.length })} +
-

Traffic by weekday

- Views per day, by week - +

{t(locale, "adm.traffic")}

+ {t(locale, "adm.trafficSub")} +
-

Session detail

- Derived from the current range +

{t(locale, "adm.session")}

+ {t(locale, "adm.sessionSub")}
-
Tracked since
{trackedSince}
-
Days tracked
{daily.length}
-
Quietest day
{quietDay ? `${quietDay.view_count} · ${formatDay(quietDay.day)}` : "—"}
-
7d trend
{weekTrend != null ? `${weekTrend >= 0 ? "+" : ""}${weekTrend.toFixed(1)}%` : "—"}
+
{t(locale, "adm.trackedSince")}
{trackedSince}
+
{t(locale, "adm.daysTracked")}
{daily.length}
+
{t(locale, "adm.quietest")}
{quietDay ? `${quietDay.view_count} · ${formatDay(quietDay.day, tag)}` : "—"}
+
{t(locale, "adm.trend7d")}
{weekTrend != null ? `${weekTrend >= 0 ? "+" : ""}${weekTrend.toFixed(1)}%` : "—"}
diff --git a/src/app/App.tsx b/src/app/App.tsx index 06182739..dc48bdfb 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import * as control from "../device/controller"; import { WORKSPACE_TAB_ORDER, type ControlSnapshot, type WorkspaceTab } from "../device/types"; +import { t, connectLabelText, connectionText, type I18nKey } from "../i18n"; import { interfaceThemeSlug } from "../interface-preferences"; import { CaptureDialog } from "./CaptureDialog"; import { Diagnostics, LogitechDetails } from "./Diagnostics"; @@ -50,6 +51,7 @@ function on(tab: WorkspaceTab, tabs: readonly WorkspaceTab[]): boolean { function DeviceOverview({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const status = snapshot.status; if (!status) return null; + const locale = snapshot.preferences.locale; const isWired = status.connectionType === "Wired"; const showBattery = !snapshot.traits.eggControls && (status.ui?.forceShowBattery || !isWired || status.batteryPercent !== null); @@ -69,37 +71,39 @@ function DeviceOverview({ snapshot }: { snapshot: ControlSnapshot }): ReactNode > {showBattery ? (
- BATTERY + {t(locale, "ov.battery")} {status.batteryPercent === null ? "—" : `${status.batteryPercent}%`} - {control.batteryDetail(status)} + {control.batteryDetail(status, locale)}
) : null}
- FIRMWARE + {t(locale, "ov.firmware")} {status.firmware[0] ?? "—"} {status.firmware.length > 1 ? status.firmware.slice(1).join(" · ") : status.firmware.length === 1 - ? "Firmware reported by mouse" - : "Not reported"} + ? t(locale, "ov.fwSingle") + : t(locale, "ov.fwNone")}
- CONNECTION - {status.connectionType ?? "Wireless"} + {t(locale, "ov.connection")} + {connectionText(locale, status.connectionType)} {status.connectionDetail - ?? (status.activeProfile ? `2.4 GHz · Profile ${status.activeProfile}` : "2.4 GHz receiver")} + ?? (status.activeProfile + ? `${t(locale, "ov.conn24")} · ${t(locale, "ov.profile")} ${status.activeProfile}` + : t(locale, "ov.connReceiver"))} {supportsDongleLed ? ( ) : null}
@@ -117,6 +121,7 @@ function Workspace({ const status = snapshot.status; const tab = snapshot.workspaceTab; if (!status) return null; + const locale = snapshot.preferences.locale; const has = cardAvailability(snapshot); const show = (available: boolean, tabs: readonly WorkspaceTab[]): boolean => available && on(tab, tabs); @@ -256,11 +261,10 @@ function Workspace({ ) : null} {on(tab, ["advanced"]) ? ( -
{nameHeader}{valueHeader}
- {row[labelKey] || "Unknown"} + {row[labelKey] || t(locale, "adm.unknown")}
{value.toLocaleString()}