feat(webui): i18n chrome — header, LocaleSwitcher, login page
This commit is contained in:
@@ -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
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: "切换到深色模式",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
export default {
|
||||
login: {},
|
||||
login: {
|
||||
signInToContinue: "登录以继续",
|
||||
username: "用户名",
|
||||
password: "密码",
|
||||
rememberMe: "记住我 30 天",
|
||||
verification: "验证:{question}",
|
||||
newQuestion: "换一题",
|
||||
signIn: "登录",
|
||||
signingIn: "登录中...",
|
||||
unableToSignIn: "无法登录。",
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user