diff --git a/src/app/[locale]/login/page.tsx b/src/app/[locale]/login/page.tsx index de1693a..fff77b7 100644 --- a/src/app/[locale]/login/page.tsx +++ b/src/app/[locale]/login/page.tsx @@ -1,6 +1,7 @@ "use client"; import { FormEvent, useState } from "react"; +import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { MarkdownContent } from "@/app/components/MarkdownContent"; @@ -19,6 +20,7 @@ function returnPath(): string { } export default function LoginPage() { + const t = useTranslations("login"); const publicConfig = usePublicSystemConfig(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -69,13 +71,15 @@ export default function LoginPage() { setCaptchaRequired(true); await refreshCaptcha(); } - throw new Error(body.error || "Unable to sign in."); + throw new Error(body.error || t("unableToSignIn")); } window.location.replace( body.redirectTo || localizedPath("/", window.location.pathname) ); } catch (reason) { - setError(reason instanceof Error ? reason.message : "Unable to sign in."); + setError( + reason instanceof Error ? reason.message : t("unableToSignIn") + ); setSubmitting(false); } }; @@ -97,7 +101,9 @@ export default function LoginPage() {

{publicConfig?.wordmark ?? DEFAULT_WORDMARK}

-

Sign in to continue

+

+ {t("signInToContinue")} +

{publicConfig?.loginTerms.enabled && publicConfig.loginTerms.markdown && ( @@ -107,7 +113,7 @@ export default function LoginPage() { )}
{captchaRequired && (
} > diff --git a/src/app/components/LocaleSwitcher.tsx b/src/app/components/LocaleSwitcher.tsx new file mode 100644 index 0000000..96cefb2 --- /dev/null +++ b/src/app/components/LocaleSwitcher.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Check, Languages } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { useLocale, useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { usePathname, useRouter } from "@/i18n/navigation"; +import { routing } from "@/i18n/routing"; + +const LOCALE_LABELS: Record = { en: "English", zh: "中文" }; + +// Header dropdown to switch locales. router.replace with the locale option +// swaps the URL prefix; the search string (threadId, view, ...) is forwarded +// explicitly because usePathname() strips it. The NEXT_LOCALE cookie is set +// by the middleware on the resulting request, remembering the choice. +export function LocaleSwitcher() { + const locale = useLocale(); + const t = useTranslations("header"); + const pathname = usePathname(); + const router = useRouter(); + const search = useSearchParams().toString(); + + return ( + + + + + + {routing.locales.map((option) => ( + + router.replace(search ? `${pathname}?${search}` : pathname, { + locale: option, + }) + } + > + {LOCALE_LABELS[option]} + {option === locale && ( + + ))} + + + ); +} diff --git a/src/app/components/ThemeToggle.tsx b/src/app/components/ThemeToggle.tsx index 0302f7a..ff26089 100644 --- a/src/app/components/ThemeToggle.tsx +++ b/src/app/components/ThemeToggle.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Moon, Sun } from "lucide-react"; +import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { useTheme } from "@/providers/ThemeProvider"; @@ -13,11 +14,13 @@ import { useTheme } from "@/providers/ThemeProvider"; * match (no hydration warning); the resolved icon appears right after mount. */ export function ThemeToggle() { + const t = useTranslations("header"); const { resolvedTheme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); const isDark = mounted && resolvedTheme === "dark"; + const label = isDark ? t("switchToLightMode") : t("switchToDarkMode"); return (