Skip to content

Commit 223c4f8

Browse files
committed
feat: add language toggle
1 parent 0a7c3dd commit 223c4f8

File tree

7 files changed

+286
-7
lines changed

7 files changed

+286
-7
lines changed

app/(main)/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MainNav } from "@/components/layout/main-nav";
44
import { UserNav } from "@/components/layout/user-nav";
55
import { ThemeToggle } from "@/components/layout/theme-toggle";
66
import {useTranslations} from 'next-intl';
7+
import { LanguageToggle } from "@/components/layout/lang-toggle";
78

89
function MainLayout({ children }: { children: React.ReactNode }) {
910
const t = useTranslations('home');
@@ -16,6 +17,7 @@ function MainLayout({ children }: { children: React.ReactNode }) {
1617
<div className="ml-auto flex-1 sm:flex-initial">
1718
</div>
1819
<ThemeToggle />
20+
<LanguageToggle />
1921
<UserNav />
2022
</div>
2123
</header>

app/layout.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { SWRProvider } from "@/providers/swr-provider";
66
import { AuthProvider } from "@/providers/auth-provider";
77
import { Toaster } from "@/components/ui/toaster";
88
import { ThemeProvider } from "@/providers/theme-provider";
9-
import {NextIntlClientProvider} from 'next-intl';
9+
// import {NextIntlClientProvider} from 'next-intl';
10+
import { ClientIntlProvider } from "@/providers/i18n-provider";
1011

1112
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
1213

@@ -28,7 +29,7 @@ export default function RootLayout({
2829
inter.variable
2930
)}
3031
>
31-
<NextIntlClientProvider>
32+
<ClientIntlProvider>
3233
<ThemeProvider
3334
attribute="class"
3435
defaultTheme="system"
@@ -39,7 +40,7 @@ export default function RootLayout({
3940
</AuthProvider>
4041
<Toaster />
4142
</ThemeProvider>
42-
</NextIntlClientProvider>
43+
</ClientIntlProvider>
4344
</body>
4445
</html>
4546
);

components/layout/lang-toggle.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// FILE: components/layout/language-switcher.tsx
2+
"use client";
3+
4+
import { useClientLocale } from '@/providers/i18n-provider';
5+
import { Button } from '@/components/ui/button';
6+
import {
7+
DropdownMenu,
8+
DropdownMenuContent,
9+
DropdownMenuItem,
10+
DropdownMenuTrigger,
11+
} from '@/components/ui/dropdown-menu';
12+
import { Languages } from 'lucide-react';
13+
14+
const LOCALE_MAP: Record<string, string> = {
15+
'en': 'English',
16+
'zh': '中文',
17+
};
18+
19+
export function LanguageToggle() {
20+
// 使用导出的 Hook
21+
const { locale, switchLocale } = useClientLocale();
22+
23+
const currentLabel = locale ? LOCALE_MAP[locale] : 'Lang';
24+
25+
return (
26+
<DropdownMenu>
27+
<DropdownMenuTrigger asChild>
28+
<Button variant="outline" size="icon" title="Change Language">
29+
<Languages className="h-[1.2rem] w-[1.2rem]" />
30+
<span className="sr-only">Change Language</span>
31+
</Button>
32+
</DropdownMenuTrigger>
33+
<DropdownMenuContent className="w-24" align="end">
34+
{Object.entries(LOCALE_MAP).map(([code, name]) => (
35+
<DropdownMenuItem
36+
key={code}
37+
onClick={() => switchLocale(code)}
38+
className={locale === code ? "font-bold text-primary" : ""}
39+
>
40+
{name}
41+
</DropdownMenuItem>
42+
))}
43+
</DropdownMenuContent>
44+
</DropdownMenu>
45+
);
46+
}

i18n/request.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import {getRequestConfig} from 'next-intl/server';
22

33
export default getRequestConfig(async () => {
4-
// Static for now, we'll change this later
5-
const locale = 'zh';
6-
4+
const locale = 'en';
75
return {
86
locale,
9-
messages: (await import(`../messages/${locale}.json`)).default
7+
messages: {} as any
108
};
119
});

providers/i18n-provider.tsx

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"use client";
2+
3+
import React, { useState, useEffect, useCallback } from 'react';
4+
import { NextIntlClientProvider } from 'next-intl';
5+
import { Loader2 } from 'lucide-react';
6+
7+
const AVAILABLE_LOCALES = ['en', 'zh'];
8+
const DEFAULT_LOCALE = 'zh';
9+
const LOCALE_STORAGE_KEY = 'csoj_locale';
10+
11+
interface ClientIntlProviderProps {
12+
children: React.ReactNode;
13+
}
14+
15+
interface LocaleContextType {
16+
switchLocale: (newLocale: string) => void;
17+
locale: string | null;
18+
}
19+
20+
const LocaleContext = React.createContext<LocaleContextType>({
21+
switchLocale: () => {
22+
console.warn("switchLocale called outside ClientIntlProvider.");
23+
},
24+
locale: null
25+
});
26+
27+
export const useClientLocale = () => {
28+
const context = React.useContext(LocaleContext);
29+
if (!context) {
30+
throw new Error('useClientLocale must be used within ClientIntlProvider');
31+
}
32+
return context;
33+
};
34+
35+
export const ClientIntlProvider: React.FC<ClientIntlProviderProps> = ({ children }) => {
36+
const [locale, setLocale] = useState<string | null>(null);
37+
const [messages, setMessages] = useState<Record<string, string> | null>(null);
38+
const [isLoading, setIsLoading] = useState(true);
39+
40+
// Use a ref to track if a language load is in progress, preventing jittering from concurrent requests.
41+
const loadingRef = React.useRef(false);
42+
43+
const loadMessages = useCallback(async (targetLocale: string, updateStorage: boolean = true) => {
44+
// Avoid concurrent calls
45+
if (loadingRef.current) return false;
46+
47+
loadingRef.current = true;
48+
setIsLoading(true);
49+
50+
try {
51+
// Check if the target language is available
52+
const finalLocale = AVAILABLE_LOCALES.includes(targetLocale) ? targetLocale : DEFAULT_LOCALE;
53+
54+
const response = await fetch(`/messages/${finalLocale}.json`);
55+
if (!response.ok) {
56+
throw new Error(`Failed to load messages for locale: ${finalLocale}`);
57+
}
58+
const newMessages = await response.json();
59+
60+
// Update state upon success
61+
setMessages(newMessages);
62+
setLocale(finalLocale);
63+
64+
// Update local storage if requested
65+
if (updateStorage && typeof window !== 'undefined') {
66+
localStorage.setItem(LOCALE_STORAGE_KEY, finalLocale);
67+
}
68+
return true;
69+
70+
} catch (error) {
71+
console.error(`Failed to load locale: ${targetLocale}.`, error);
72+
// On failure, only set default locale/messages if no locale has been loaded yet (initial load failure)
73+
if (!locale) {
74+
setMessages({});
75+
setLocale(DEFAULT_LOCALE);
76+
}
77+
return false;
78+
} finally {
79+
setIsLoading(false);
80+
loadingRef.current = false;
81+
}
82+
}, [locale]);
83+
84+
85+
// Load initial locale on mount
86+
useEffect(() => {
87+
let initialLocale = DEFAULT_LOCALE;
88+
if (typeof window !== 'undefined') {
89+
const savedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
90+
if (savedLocale && AVAILABLE_LOCALES.includes(savedLocale)) {
91+
initialLocale = savedLocale;
92+
}
93+
}
94+
95+
// Asynchronously load the initial language
96+
loadMessages(initialLocale);
97+
98+
}, [loadMessages]);
99+
100+
// Handle locale switching
101+
const switchLocale = async (newLocale: string) => {
102+
if (!AVAILABLE_LOCALES.includes(newLocale) || newLocale === locale || loadingRef.current) {
103+
return;
104+
}
105+
106+
// Attempt to load the new language pack
107+
const success = await loadMessages(newLocale, true); // Update storage on successful load
108+
109+
if (!success) {
110+
console.warn(`Could not load ${newLocale}. Sticking to current locale.`);
111+
}
112+
};
113+
114+
// Show full-screen loader if initial load is pending
115+
if (!locale || !messages) {
116+
if (isLoading) {
117+
return (
118+
<div className="flex h-screen flex-col items-center justify-center gap-4">
119+
<Loader2 className="h-12 w-12 animate-spin text-primary" />
120+
</div>
121+
);
122+
}
123+
}
124+
125+
return (
126+
<LocaleContext.Provider value={{ switchLocale, locale }}>
127+
<NextIntlClientProvider locale={locale!} messages={messages!}>
128+
{children}
129+
</NextIntlClientProvider>
130+
</LocaleContext.Provider>
131+
);
132+
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,5 +199,55 @@
199199
"failDefault": "Registration failed"
200200
}
201201
}
202+
},
203+
"submissions": {
204+
"list": {
205+
"title": "My Submissions",
206+
"description": "A list of all your submissions.",
207+
"loadFail": "Failed to load submissions.",
208+
"none": "No submissions yet.",
209+
"table": {
210+
"id": "ID",
211+
"problemId": "Problem ID",
212+
"status": "Status",
213+
"score": "Score",
214+
"submittedAt": "Submitted At"
215+
}
216+
},
217+
"details": {
218+
"loadFail": "Failed to load submission.",
219+
"notFound": "Submission not found.",
220+
"log": {
221+
"title": "Live Log",
222+
"description": "Real-time output from the judge. Select a step to view its log."
223+
},
224+
"info": {
225+
"title": "Submission Info",
226+
"status": "Status",
227+
"score": "Score",
228+
"submitted": "Submitted",
229+
"problem": "Problem",
230+
"user": "User",
231+
"cluster": "Cluster",
232+
"node": "Node",
233+
"stepProgress": "Step {{current}} of {{total}}: {{name}}"
234+
},
235+
"judgeInfo": {
236+
"title": "Judge Info",
237+
"description": "This is the raw JSON output from the final step of the judging process."
238+
},
239+
"queue": {
240+
"position": "Queue Position",
241+
"info": "#{{position}} in {{cluster}} queue"
242+
},
243+
"interrupt": {
244+
"button": "Interrupt",
245+
"confirm": "Are you sure you want to interrupt this submission? This action cannot be undone.",
246+
"successTitle": "Success",
247+
"successDescription": "Submission interruption request sent.",
248+
"failTitle": "Error",
249+
"failDefault": "Failed to interrupt submission."
250+
}
251+
}
202252
}
203253
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,5 +199,55 @@
199199
"failDefault": "注册失败"
200200
}
201201
}
202+
},
203+
"submissions": {
204+
"list": {
205+
"title": "我的提交记录",
206+
"description": "您所有提交的列表。",
207+
"loadFail": "加载提交记录失败。",
208+
"none": "暂无提交记录。",
209+
"table": {
210+
"id": "ID",
211+
"problemId": "题目 ID",
212+
"status": "状态",
213+
"score": "分数",
214+
"submittedAt": "提交时间"
215+
}
216+
},
217+
"details": {
218+
"loadFail": "加载提交详情失败。",
219+
"notFound": "未找到提交记录。",
220+
"log": {
221+
"title": "实时日志",
222+
"description": "来自判题机的实时输出。选择一个步骤以查看其日志。"
223+
},
224+
"info": {
225+
"title": "提交信息",
226+
"status": "状态",
227+
"score": "分数",
228+
"submitted": "提交于",
229+
"problem": "题目",
230+
"user": "用户",
231+
"cluster": "集群",
232+
"node": "节点",
233+
"stepProgress": "第 {{current}} / {{total}} 步: {{name}}"
234+
},
235+
"judgeInfo": {
236+
"title": "判题信息",
237+
"description": "这是判题过程最终步骤的原始 JSON 输出。"
238+
},
239+
"queue": {
240+
"position": "队列位置",
241+
"info": "在 {{cluster}} 队列中排在 #{{position}} 位"
242+
},
243+
"interrupt": {
244+
"button": "中断",
245+
"confirm": "您确定要中断此提交吗?此操作无法撤销。",
246+
"successTitle": "成功",
247+
"successDescription": "已发送中断提交的请求。",
248+
"failTitle": "错误",
249+
"failDefault": "中断提交失败。"
250+
}
251+
}
202252
}
203253
}

0 commit comments

Comments
 (0)