diff --git a/CHANGELOG.md b/CHANGELOG.md index 376d4583a69..8343767bcbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - **feat(api-keys):** track devices/connections per API key — an in-memory, TTL-evicted device fingerprint tracker (SHA-256 of masked IP + truncated user-agent) wired non-blocking into the chat path and surfaced via `GET /api/keys/[id]/devices` with a dashboard device-count chip. (thanks @mugnimaestra) - **feat(proxy):** add Webshare proxy pool import and sync — a `WebshareProvider` (`FreeProxyProvider`) that paginates `proxy.webshare.io/api/v2/proxy/list/` gated on `FREE_PROXY_WEBSHARE_API_KEY`, SSRF-guards imported hosts, and tombstones retired proxy IDs via `pruneStaleFreeProxies()`. (thanks @ricatix) - **feat(dashboard):** suggest HuggingFace Hub media models in the media provider view. (thanks @yicone) +- **feat(i18n):** auto-detect the browser language on first visit. (thanks @ayanmw) ### 🔧 Bug Fixes diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 017d14de00c..d7245845382 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -9,6 +9,7 @@ import { useRouter } from "next/navigation"; import { Card, CardSkeleton, Button, Modal } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { AI_PROVIDERS, NOAUTH_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers"; +import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage"; import { useNotificationStore } from "@/store/notificationStore"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; @@ -685,7 +686,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { if (contentType.includes("application/json")) { const data = await res.json(); if (!res.ok || !data.success) { - notify.error(data.error || "Failed to start update."); + notify.error(extractApiErrorMessage(data, "Failed to start update.")); setUpdating(false); setUpdatePhase("idle"); return; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1e1d6483f8d..83883095709 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import { normalizeComplianceEventTypes } from "@/i18n/request"; import { getSettings } from "@/lib/db/settings"; import type { Viewport } from "next"; import { PwaRegister } from "@/shared/components/PwaRegister"; +import { LocaleAutoDetect } from "@/shared/components/LocaleAutoDetect"; const inter = Inter({ subsets: ["latin"], @@ -109,6 +110,7 @@ export default async function RootLayout({ children }) { + {children} diff --git a/src/i18n/detectBrowserLocale.ts b/src/i18n/detectBrowserLocale.ts new file mode 100644 index 00000000000..00f81287505 --- /dev/null +++ b/src/i18n/detectBrowserLocale.ts @@ -0,0 +1,52 @@ +/** + * Pure browser-language detector used to pick an initial locale on first + * visit, before the user has made an explicit selection (no cookie set). + * + * Matching order: + * 1. Exact match against `navigator.languages` entries (case-insensitive). + * 2. `zh-HK` / `zh-MO` are treated as `zh-TW` (Traditional Chinese) since + * OmniRoute does not ship a dedicated Hong-Kong/Macau locale. + * 3. Language-prefix match — e.g. `en-US` matches a supported `en` locale. + * 4. No match → `null` (caller should keep the existing default). + * + * Kept dependency-free (no DOM/`navigator` access) so it is trivially unit + * testable and reusable from both client components and future server code. + */ +export function detectBrowserLocale( + languages: readonly string[], + locales: readonly string[] +): string | null { + if (!languages || languages.length === 0 || !locales || locales.length === 0) { + return null; + } + + const normalizedLocales = locales.map((locale) => locale.toLowerCase()); + + for (const rawLanguage of languages) { + if (!rawLanguage) continue; + const language = rawLanguage.toLowerCase(); + + // 1. Exact match. + const exactIndex = normalizedLocales.indexOf(language); + if (exactIndex !== -1) { + return locales[exactIndex]; + } + + // 2. zh-HK / zh-MO fold to zh-TW when zh-TW is supported. + if (language === "zh-hk" || language === "zh-mo") { + const zhTwIndex = normalizedLocales.indexOf("zh-tw"); + if (zhTwIndex !== -1) { + return locales[zhTwIndex]; + } + } + + // 3. Language-prefix match (e.g. "en-US" -> "en"). + const prefix = language.split("-")[0]; + const prefixIndex = normalizedLocales.indexOf(prefix); + if (prefixIndex !== -1) { + return locales[prefixIndex]; + } + } + + return null; +} diff --git a/src/shared/components/LanguageSelector.tsx b/src/shared/components/LanguageSelector.tsx index 94d6f70b0ca..c136bf7bd7f 100644 --- a/src/shared/components/LanguageSelector.tsx +++ b/src/shared/components/LanguageSelector.tsx @@ -2,19 +2,10 @@ import { useState, useRef, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { LANGUAGES, LOCALE_COOKIE } from "@/i18n/config"; +import { LANGUAGES } from "@/i18n/config"; import type { Locale } from "@/i18n/config"; import { useLocale } from "next-intl"; - -/** Persist locale preference in cookie + localStorage (outside component scope for ESLint) */ -function persistLocale(code: Locale) { - document.cookie = `${LOCALE_COOKIE}=${code};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`; - try { - localStorage.setItem(LOCALE_COOKIE, code); - } catch { - // Ignore - } -} +import { persistLocale } from "@/shared/lib/persistLocale"; function CountryFlag({ emoji, alt }: { emoji: string; alt: string }) { const [error, setError] = useState(false); diff --git a/src/shared/components/LocaleAutoDetect.tsx b/src/shared/components/LocaleAutoDetect.tsx new file mode 100644 index 00000000000..1531be6c8bb --- /dev/null +++ b/src/shared/components/LocaleAutoDetect.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { LOCALES, LOCALE_COOKIE } from "@/i18n/config"; +import type { Locale } from "@/i18n/config"; +import { detectBrowserLocale } from "@/i18n/detectBrowserLocale"; +import { persistLocale } from "@/shared/lib/persistLocale"; + +function hasLocaleCookie(): boolean { + return document.cookie + .split(";") + .some((entry) => entry.trim().startsWith(`${LOCALE_COOKIE}=`)); +} + +/** + * Auto-detects the browser language on first visit (no locale cookie set + * yet) and persists it via the same writer `LanguageSelector` uses for a + * manual selection, then refreshes the router so the server re-renders with + * the detected locale. Mounted once in the root layout; renders nothing. + */ +export function LocaleAutoDetect() { + const router = useRouter(); + + useEffect(() => { + if (typeof navigator === "undefined" || hasLocaleCookie()) return; + + const detected = detectBrowserLocale(navigator.languages ?? [navigator.language], LOCALES); + if (!detected) return; + + persistLocale(detected as Locale); + router.refresh(); + }, [router]); + + return null; +} diff --git a/src/shared/lib/persistLocale.ts b/src/shared/lib/persistLocale.ts new file mode 100644 index 00000000000..51ce4b245d1 --- /dev/null +++ b/src/shared/lib/persistLocale.ts @@ -0,0 +1,19 @@ +import { LOCALE_COOKIE } from "@/i18n/config"; +import type { Locale } from "@/i18n/config"; + +/** + * Persist the locale preference in the cookie `src/i18n/request.ts` reads on + * the server, plus localStorage as a client-side convenience mirror. + * + * Shared by every client-side locale writer (manual selection in + * `LanguageSelector`, first-visit auto-detection in `LocaleAutoDetect`) so + * there is a single source of truth for the cookie name/format. + */ +export function persistLocale(code: Locale): void { + document.cookie = `${LOCALE_COOKIE}=${code};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`; + try { + localStorage.setItem(LOCALE_COOKIE, code); + } catch { + // Ignore (e.g. storage disabled/full) + } +} diff --git a/tests/unit/i18n-detect-browser-locale.test.ts b/tests/unit/i18n-detect-browser-locale.test.ts new file mode 100644 index 00000000000..d55748fb968 --- /dev/null +++ b/tests/unit/i18n-detect-browser-locale.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { detectBrowserLocale } from "../../src/i18n/detectBrowserLocale"; + +const SUPPORTED_LOCALES = ["en", "pt-BR", "es", "zh-TW", "fr", "de"] as const; + +describe("detectBrowserLocale", () => { + it("returns the exact match when a browser language equals a supported locale", () => { + assert.equal(detectBrowserLocale(["pt-BR"], SUPPORTED_LOCALES), "pt-BR"); + }); + + it("folds zh-HK to zh-TW when zh-TW is supported", () => { + assert.equal(detectBrowserLocale(["zh-HK"], SUPPORTED_LOCALES), "zh-TW"); + }); + + it("folds zh-MO to zh-TW when zh-TW is supported", () => { + assert.equal(detectBrowserLocale(["zh-MO"], SUPPORTED_LOCALES), "zh-TW"); + }); + + it("falls back to a language-prefix match when no exact match exists", () => { + assert.equal(detectBrowserLocale(["en-US"], SUPPORTED_LOCALES), "en"); + }); + + it("returns null when nothing matches", () => { + assert.equal(detectBrowserLocale(["ja-JP"], SUPPORTED_LOCALES), null); + }); + + it("returns null for an empty languages list", () => { + assert.equal(detectBrowserLocale([], SUPPORTED_LOCALES), null); + }); + + it("returns null for an empty locales list", () => { + assert.equal(detectBrowserLocale(["en-US"], []), null); + }); + + it("tries each browser language in order until one matches", () => { + assert.equal(detectBrowserLocale(["ja-JP", "fr-CA"], SUPPORTED_LOCALES), "fr"); + }); + + it("is case-insensitive", () => { + assert.equal(detectBrowserLocale(["PT-br"], SUPPORTED_LOCALES), "pt-BR"); + }); +}); diff --git a/tests/unit/ui/home-update-error-render.test.ts b/tests/unit/ui/home-update-error-render.test.ts new file mode 100644 index 00000000000..931ec4f35e4 --- /dev/null +++ b/tests/unit/ui/home-update-error-render.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync( + resolve(here, "../../../src/app/(dashboard)/dashboard/HomePageClient.tsx"), + "utf8" +); + +describe("HomePageClient update error rendering", () => { + it("imports the safe API error extractor", () => { + assert.match( + source, + /import\s*\{\s*extractApiErrorMessage\s*\}\s*from\s*["']@\/shared\/http\/apiErrorMessage["']/, + "HomePageClient must import extractApiErrorMessage to render API errors safely" + ); + }); + + it("funnels the update error body through extractApiErrorMessage", () => { + assert.match( + source, + /notify\.error\(\s*extractApiErrorMessage\(\s*data\s*,/, + "the update-error notify.error call must use extractApiErrorMessage(data, ...)" + ); + }); + + it("does not pass the raw API error object to notify.error", () => { + assert.doesNotMatch( + source, + /notify\.error\(\s*data\.error\b/, + "notify.error(data.error ...) can render an error envelope object as a React child" + ); + }); +});