feat(webui): i18n chrome — header, LocaleSwitcher, login page

This commit is contained in:
m4
2026-08-11 16:03:26 +08:00
parent 63f3310fa0
commit ddbc6c808e
8 changed files with 184 additions and 37 deletions
+15 -9
View File
@@ -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() {
<h1 className="text-lg font-semibold">
{publicConfig?.wordmark ?? DEFAULT_WORDMARK}
</h1>
<p className="text-sm text-muted-foreground">Sign in to continue</p>
<p className="text-sm text-muted-foreground">
{t("signInToContinue")}
</p>
</div>
</div>
{publicConfig?.loginTerms.enabled && publicConfig.loginTerms.markdown && (
@@ -107,7 +113,7 @@ export default function LoginPage() {
)}
<div className="space-y-4">
<label className="block space-y-1.5 text-sm font-medium">
<span>Username</span>
<span>{t("username")}</span>
<Input
name="username"
autoComplete="username"
@@ -119,7 +125,7 @@ export default function LoginPage() {
/>
</label>
<label className="block space-y-1.5 text-sm font-medium">
<span>Password</span>
<span>{t("password")}</span>
<Input
name="password"
type="password"
@@ -139,18 +145,18 @@ export default function LoginPage() {
disabled={submitting}
className="h-4 w-4"
/>
<span>Remember me for 30 days</span>
<span>{t("rememberMe")}</span>
</label>
{captchaRequired && (
<label className="block space-y-1.5 text-sm font-medium">
<span>
Verification: {captchaQuestion}{" "}
{t("verification", { question: captchaQuestion })}{" "}
<button
type="button"
onClick={() => void refreshCaptcha()}
className="text-muted-foreground underline underline-offset-2"
>
New question
{t("newQuestion")}
</button>
</span>
<Input
@@ -171,7 +177,7 @@ export default function LoginPage() {
</p>
)}
<Button className="mt-6 w-full" type="submit" disabled={submitting}>
{submitting ? "Signing in..." : "Sign in"}
{submitting ? t("signingIn") : t("signIn")}
</Button>
</form>
</main>
+27 -22
View File
@@ -2,11 +2,13 @@
import React, { useState, useEffect, useCallback, Suspense } from "react";
import { useQueryState } from "nuqs";
import { useTranslations } from "next-intl";
import { AccountDialog } from "@/app/components/AccountDialog";
import { ChangePasswordDialog } from "@/app/components/ChangePasswordDialog";
import { ConfigDialog } from "@/app/components/ConfigDialog";
import { ErrorLogBell } from "@/app/components/ErrorLogBell";
import { ImageModelsDialog } from "@/app/components/ImageModelsDialog";
import { LocaleSwitcher } from "@/app/components/LocaleSwitcher";
import { SystemConfigDialog } from "@/app/components/system-config/SystemConfigDialog";
import { Button } from "@/components/ui/button";
import {
@@ -66,6 +68,7 @@ interface HomePageInnerProps {
}
function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
const t = useTranslations("header");
const publicConfig = usePublicSystemConfig();
const [threadId, setThreadId] = useQueryState("threadId");
const [sidebar, setSidebar] = useQueryState("sidebar");
@@ -200,11 +203,11 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
}, [isDesktopLayout, setInspector, setSidebar, setInspectorTab]);
const sidebarToggleLabel = view
? sidebar
? "Hide navigation"
: "Show navigation"
? t("hideNavigation")
: t("showNavigation")
: sidebar
? "Hide research"
: "Show research";
? t("hideResearch")
: t("showResearch");
const startNewChat = useCallback(() => {
setThreadAutoApprove(null, false);
setThreadId(null);
@@ -361,7 +364,7 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
variant="ghost"
size="icon"
onClick={startNewChat}
aria-label="New chat"
aria-label={t("newChat")}
className="size-8"
>
<SquarePen
@@ -375,14 +378,15 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
<ErrorLogBell />
<HealthIndicator />
<LocaleSwitcher />
<ThemeToggle />
{authEnabled && (
<Button
variant="ghost"
size="icon"
onClick={signOut}
aria-label="Sign out"
title="Sign out"
aria-label={t("signOut")}
title={t("signOut")}
className="size-8"
>
<LogOut
@@ -395,8 +399,8 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
variant="ghost"
size="icon"
onClick={toggleInspector}
aria-label={inspector ? "Hide inspector" : "Show workspace"}
title={inspector ? "Hide inspector" : "Show workspace"}
aria-label={inspector ? t("hideInspector") : t("showWorkspace")}
title={inspector ? t("hideInspector") : t("showWorkspace")}
className="size-8"
>
{inspector ? (
@@ -416,8 +420,8 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
<Button
variant="ghost"
size="icon"
aria-label="Configuration"
title="Configuration"
aria-label={t("configuration")}
title={t("configuration")}
className="size-8"
>
<Settings
@@ -430,7 +434,7 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
align="end"
className="w-52"
>
<DropdownMenuLabel>Configuration</DropdownMenuLabel>
<DropdownMenuLabel>{t("configuration")}</DropdownMenuLabel>
<DropdownMenuGroup>
{authEnabled && (
<>
@@ -438,29 +442,29 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
onSelect={() => setAccountDialogOpen(true)}
>
<UserRound aria-hidden="true" />
User information
{t("userInformation")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setChangePasswordOpen(true)}
>
<KeyRound aria-hidden="true" />
Change password
{t("changePassword")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={() => setModelConfigOpen(true)}>
<SlidersHorizontal aria-hidden="true" />
Model configuration
{t("modelConfiguration")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setImageModelsOpen(true)}>
<ImageIcon aria-hidden="true" />
Image models
{t("imageModels")}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onSelect={() => setSystemConfigOpen(true)}>
<ServerCog aria-hidden="true" />
System config
{t("systemConfig")}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
@@ -474,12 +478,12 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
<div className="absolute inset-0 z-40 flex md:hidden">
<button
type="button"
aria-label="Close research"
aria-label={t("closeResearch")}
className="absolute inset-0 bg-black/40"
onClick={closeSidebar}
/>
<aside
aria-label={view ? "Navigation" : "Research navigation"}
aria-label={view ? t("navigation") : t("researchNavigation")}
className="relative z-10 h-full w-[min(19rem,calc(100vw-2.25rem))] bg-background shadow-xl"
>
<ThreadList
@@ -499,12 +503,12 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
<div className="absolute inset-0 z-40 flex justify-end md:hidden">
<button
type="button"
aria-label="Close inspector"
aria-label={t("closeInspector")}
className="absolute inset-0 bg-black/40"
onClick={closeInspector}
/>
<aside
aria-label="Inspector"
aria-label={t("inspector")}
className="relative z-10 h-full w-[min(22rem,calc(100vw-2.25rem))] bg-background shadow-xl"
>
<InspectorPanel
@@ -656,11 +660,12 @@ function HomePageContent() {
}
export default function HomePage() {
const tCommon = useTranslations("common");
return (
<Suspense
fallback={
<div className="flex h-screen items-center justify-center">
<p className="text-muted-foreground">Loading…</p>
<p className="text-muted-foreground">{tCommon("loading")}</p>
</div>
}
>
+67
View File
@@ -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<string, string> = { 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 sm:size-9"
aria-label={t("language")}
title={t("language")}
>
<Languages
className="size-5"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{routing.locales.map((option) => (
<DropdownMenuItem
key={option}
onSelect={() =>
router.replace(search ? `${pathname}?${search}` : pathname, {
locale: option,
})
}
>
<span className="flex-1">{LOCALE_LABELS[option]}</span>
{option === locale && (
<Check
className="size-4"
aria-hidden="true"
/>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+5 -2
View File
@@ -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 (
<Button
@@ -25,8 +28,8 @@ export function ThemeToggle() {
size="icon"
className="size-8 sm:size-9"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
aria-label={label}
title={label}
>
{isDark ? (
<Moon
+24 -1
View File
@@ -1,3 +1,26 @@
export default {
header: {},
header: {
signOut: "Sign out",
configuration: "Configuration",
userInformation: "User information",
changePassword: "Change password",
modelConfiguration: "Model configuration",
imageModels: "Image models",
systemConfig: "System config",
language: "Language",
newChat: "New chat",
showNavigation: "Show navigation",
hideNavigation: "Hide navigation",
showResearch: "Show research",
hideResearch: "Hide research",
navigation: "Navigation",
researchNavigation: "Research navigation",
closeResearch: "Close research",
showWorkspace: "Show workspace",
hideInspector: "Hide inspector",
closeInspector: "Close inspector",
inspector: "Inspector",
switchToLightMode: "Switch to light mode",
switchToDarkMode: "Switch to dark mode",
},
};
+11 -1
View File
@@ -1,3 +1,13 @@
export default {
login: {},
login: {
signInToContinue: "Sign in to continue",
username: "Username",
password: "Password",
rememberMe: "Remember me for 30 days",
verification: "Verification: {question}",
newQuestion: "New question",
signIn: "Sign in",
signingIn: "Signing in...",
unableToSignIn: "Unable to sign in.",
},
};
+24 -1
View File
@@ -1,3 +1,26 @@
export default {
header: {},
header: {
signOut: "退出登录",
configuration: "配置",
userInformation: "用户信息",
changePassword: "修改密码",
modelConfiguration: "模型配置",
imageModels: "图像模型",
systemConfig: "系统配置",
language: "语言",
newChat: "新对话",
showNavigation: "显示导航",
hideNavigation: "隐藏导航",
showResearch: "显示研究",
hideResearch: "隐藏研究",
navigation: "导航",
researchNavigation: "研究导航",
closeResearch: "关闭研究",
showWorkspace: "显示工作区",
hideInspector: "隐藏检查器",
closeInspector: "关闭检查器",
inspector: "检查器",
switchToLightMode: "切换到浅色模式",
switchToDarkMode: "切换到深色模式",
},
};
+11 -1
View File
@@ -1,3 +1,13 @@
export default {
login: {},
login: {
signInToContinue: "登录以继续",
username: "用户名",
password: "密码",
rememberMe: "记住我 30 天",
verification: "验证:{question}",
newQuestion: "换一题",
signIn: "登录",
signingIn: "登录中...",
unableToSignIn: "无法登录。",
},
};