Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
30bd2e3
refactor(i18n): migrate to i18next framework
cosarah Mar 30, 2026
d813fb4
fix(i18n): use interpolation for greeting to support natural phrasing
cosarah Mar 30, 2026
2c4861f
refactor(i18n): auto-discover locale files via dynamic import
cosarah Mar 30, 2026
220c5de
fix(i18n): resolve hydration mismatch by deferring language detection
cosarah Mar 30, 2026
a7cf676
refactor(i18n): remove hardcoded locale list from language detection
cosarah Mar 30, 2026
f7bd6bd
feat(i18n): add course language config with 11 languages and UI linkage
cosarah Mar 30, 2026
5bb5133
fix(quiz): use course language instead of UI locale for grading
cosarah Mar 30, 2026
d8f98f5
revert: remove course language config and quiz fix from i18next branch
cosarah Mar 30, 2026
16a93b2
Merge remote-tracking branch 'origin/main' into feat/i18next
cosarah Mar 30, 2026
f4fe45f
chore(i18n): regenerate locale JSON after merging latest main
cosarah Mar 30, 2026
af20e34
chore(i18n): remove obsolete TS translation files
cosarah Mar 30, 2026
5319e07
style(i18n): fix prettier formatting in locale JSON files
cosarah Mar 30, 2026
9a07750
refactor(i18n): replace hardcoded language options with central local…
cosarah Apr 3, 2026
fa729ee
Merge remote-tracking branch 'origin/main' into feat/i18next
cosarah Apr 3, 2026
eb363f1
fix(i18n): add missing rename keys to JSON locale files
cosarah Apr 3, 2026
c0f8c5b
refactor(i18n): extract LanguageSwitcher, restore type safety, fix lo…
cosarah Apr 3, 2026
93f5e42
fix(i18n): use i18next double-brace interpolation instead of manual .…
cosarah Apr 3, 2026
f365c75
feat(i18n): unify greeting with default nickname, add translation guide
cosarah Apr 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions app/generation-preview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,15 +278,11 @@ function GenerationPreviewContent() {
// Truncation warnings
const warnings: string[] = [];
if ((parseResult.data.text as string).length > MAX_PDF_CONTENT_CHARS) {
warnings.push(
t('generation.textTruncated').replace('{n}', String(MAX_PDF_CONTENT_CHARS)),
);
warnings.push(t('generation.textTruncated', { n: MAX_PDF_CONTENT_CHARS }));
}
if (images.length > MAX_VISION_IMAGES) {
warnings.push(
t('generation.imageTruncated')
.replace('{total}', String(images.length))
.replace('{max}', String(MAX_VISION_IMAGES)),
t('generation.imageTruncated', { total: images.length, max: MAX_VISION_IMAGES }),
);
}
if (warnings.length > 0) {
Expand Down
61 changes: 7 additions & 54 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ChevronUp,
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { LanguageSwitcher } from '@/components/language-switcher';
import { createLogger } from '@/lib/logger';
import { Button } from '@/components/ui/button';
import { Textarea as UITextarea } from '@/components/ui/textarea';
Expand Down Expand Up @@ -69,7 +70,7 @@ const initialFormState: FormState = {
};

function HomePage() {
const { t, locale, setLocale } = useI18n();
const { t } = useI18n();
const { theme, setTheme } = useTheme();
const router = useRouter();
const [form, setForm] = useState<FormState>(initialFormState);
Expand Down Expand Up @@ -124,7 +125,6 @@ function HomePage() {
}
}

const [languageOpen, setLanguageOpen] = useState(false);
const [themeOpen, setThemeOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [classrooms, setClassrooms] = useState<StageListItem[]>([]);
Expand All @@ -135,16 +135,15 @@ function HomePage() {

// Close dropdowns when clicking outside
useEffect(() => {
if (!languageOpen && !themeOpen) return;
if (!themeOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) {
setLanguageOpen(false);
setThemeOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [languageOpen, themeOpen]);
}, [themeOpen]);

const loadClassrooms = async () => {
try {
Expand Down Expand Up @@ -338,47 +337,7 @@ function HomePage() {
className="fixed top-4 right-4 z-50 flex items-center gap-1 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md px-2 py-1.5 rounded-full border border-gray-100/50 dark:border-gray-700/50 shadow-sm"
>
{/* Language Selector */}
<div className="relative">
<button
onClick={() => {
setLanguageOpen(!languageOpen);
setThemeOpen(false);
}}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all"
>
{locale === 'zh-CN' ? 'CN' : 'EN'}
</button>
{languageOpen && (
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[120px]">
<button
onClick={() => {
setLocale('zh-CN');
setLanguageOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
locale === 'zh-CN' &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
简体中文
</button>
<button
onClick={() => {
setLocale('en-US');
setLanguageOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
locale === 'en-US' &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
English
</button>
</div>
)}
</div>
<LanguageSwitcher onOpen={() => setThemeOpen(false)} />

<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />

Expand All @@ -387,7 +346,6 @@ function HomePage() {
<button
onClick={() => {
setThemeOpen(!themeOpen);
setLanguageOpen(false);
}}
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all"
>
Expand Down Expand Up @@ -797,13 +755,8 @@ function GreetingBar() {
<Tooltip>
<TooltipTrigger asChild>
<span className="leading-none select-none flex items-center gap-1">
<span>
<span className="text-xs text-muted-foreground/60 group-hover:text-muted-foreground transition-colors">
{t('home.greeting')}
</span>
<span className="text-[13px] font-semibold text-foreground/85 group-hover:text-foreground transition-colors">
{displayName}
</span>
<span className="text-[13px] font-semibold text-foreground/85 group-hover:text-foreground transition-colors">
{t('home.greetingWithName', { name: displayName })}
</span>
<ChevronDown className="size-3 text-muted-foreground/30 group-hover:text-muted-foreground/60 transition-colors shrink-0" />
</span>
Expand Down
2 changes: 1 addition & 1 deletion components/chat/lecture-notes-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function LectureNotesView({ notes, currentSceneId }: LectureNotesViewProp
{notes.map((note, index) => {
const isCurrent = note.sceneId === currentSceneId;
const pageNum = index + 1;
const pageLabel = t('chat.lectureNotes.pageLabel').replace('{n}', String(pageNum));
const pageLabel = t('chat.lectureNotes.pageLabel', { n: pageNum });

return (
<div
Expand Down
57 changes: 6 additions & 51 deletions components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useTheme } from '@/lib/hooks/use-theme';
import { LanguageSwitcher } from './language-switcher';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { SettingsDialog } from './settings';
Expand All @@ -26,11 +27,10 @@ interface HeaderProps {
}

export function Header({ currentSceneTitle }: HeaderProps) {
const { t, locale, setLocale } = useI18n();
const { t } = useI18n();
const { theme, setTheme } = useTheme();
const router = useRouter();
const [settingsOpen, setSettingsOpen] = useState(false);
const [languageOpen, setLanguageOpen] = useState(false);
const [themeOpen, setThemeOpen] = useState(false);

// Export
Expand All @@ -48,31 +48,27 @@ export function Header({ currentSceneTitle }: HeaderProps) {
failedOutlines.length === 0 &&
Object.values(mediaTasks).every((task) => task.status === 'done' || task.status === 'failed');

const languageRef = useRef<HTMLDivElement>(null);
const themeRef = useRef<HTMLDivElement>(null);

// Close dropdown when clicking outside
const handleClickOutside = useCallback(
(e: MouseEvent) => {
if (languageOpen && languageRef.current && !languageRef.current.contains(e.target as Node)) {
setLanguageOpen(false);
}
if (themeOpen && themeRef.current && !themeRef.current.contains(e.target as Node)) {
setThemeOpen(false);
}
if (exportMenuOpen && exportRef.current && !exportRef.current.contains(e.target as Node)) {
setExportMenuOpen(false);
}
},
[languageOpen, themeOpen, exportMenuOpen],
[themeOpen, exportMenuOpen],
);

useEffect(() => {
if (languageOpen || themeOpen || exportMenuOpen) {
if (themeOpen || exportMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [languageOpen, themeOpen, exportMenuOpen, handleClickOutside]);
}, [themeOpen, exportMenuOpen, handleClickOutside]);

return (
<>
Expand Down Expand Up @@ -100,47 +96,7 @@ export function Header({ currentSceneTitle }: HeaderProps) {

<div className="flex items-center gap-4 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md px-2 py-1.5 rounded-full border border-gray-100/50 dark:border-gray-700/50 shadow-sm shrink-0">
{/* Language Selector */}
<div className="relative" ref={languageRef}>
<button
onClick={() => {
setLanguageOpen(!languageOpen);
setThemeOpen(false);
}}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all"
>
{locale === 'zh-CN' ? 'CN' : 'EN'}
</button>
{languageOpen && (
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[120px]">
<button
onClick={() => {
setLocale('zh-CN');
setLanguageOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
locale === 'zh-CN' &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
简体中文
</button>
<button
onClick={() => {
setLocale('en-US');
setLanguageOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
locale === 'en-US' &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
English
</button>
</div>
)}
</div>
<LanguageSwitcher onOpen={() => setThemeOpen(false)} />

<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />

Expand All @@ -149,7 +105,6 @@ export function Header({ currentSceneTitle }: HeaderProps) {
<button
onClick={() => {
setThemeOpen(!themeOpen);
setLanguageOpen(false);
}}
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group"
>
Expand Down
64 changes: 64 additions & 0 deletions components/language-switcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use client';

import { useState, useRef, useEffect } from 'react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { supportedLocales } from '@/lib/i18n';
import { cn } from '@/lib/utils';

interface LanguageSwitcherProps {
/** Called when the dropdown opens, so parent can close sibling dropdowns */
onOpen?: () => void;
}

export function LanguageSwitcher({ onOpen }: LanguageSwitcherProps) {
const { locale, setLocale } = useI18n();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);

// Close on click outside
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);

return (
<div className="relative" ref={ref}>
<button
onClick={() => {
const next = !open;
setOpen(next);
if (next) onOpen?.();
}}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all"
>
{supportedLocales.find((l) => l.code === locale)?.shortLabel ?? locale}
</button>
{open && (
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[120px]">
{supportedLocales.map((l) => (
<button
key={l.code}
onClick={() => {
setLocale(l.code);
setOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
locale === l.code &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
{l.label}
</button>
))}
</div>
)}
</div>
);
}
7 changes: 4 additions & 3 deletions components/scene-renderers/pbl-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ export function PBLRenderer({ content, mode: _mode, sceneId }: PBLRendererProps)
// Add Question Agent welcome message if chat is empty and active issue has questions
const activeIssue = newConfig.issueboard.issues.find((i) => i.is_active);
if (activeIssue?.generated_questions && newConfig.chat.messages.length === 0) {
const welcomeMsg = t('pbl.chat.welcomeMessage')
.replace('{title}', activeIssue.title)
.replace('{questions}', activeIssue.generated_questions);
const welcomeMsg = t('pbl.chat.welcomeMessage', {
title: activeIssue.title,
questions: activeIssue.generated_questions,
});
newConfig.chat = {
messages: [
{
Expand Down
23 changes: 13 additions & 10 deletions components/scene-renderers/pbl/use-pbl-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ async function handleIssueComplete(
config: PBLProjectConfig,
completedIssue: PBLIssue,
headers: Record<string, string>,
t: (key: string) => string,
t: (key: string, options?: Record<string, unknown>) => string,
) {
// Mark current issue as done
const issue = config.issueboard.issues.find((i) => i.id === completedIssue.id);
Expand Down Expand Up @@ -226,9 +226,10 @@ async function handleIssueComplete(
config.chat.messages.push({
id: `msg_${Date.now()}_welcome`,
agent_name: nextIssue.question_agent_name,
message: t('pbl.chat.welcomeMessage')
.replace('{title}', nextIssue.title)
.replace('{questions}', data.message),
message: t('pbl.chat.welcomeMessage', {
title: nextIssue.title,
questions: data.message,
}),
timestamp: Date.now(),
read_by: [],
});
Expand All @@ -241,9 +242,10 @@ async function handleIssueComplete(
config.chat.messages.push({
id: `msg_${Date.now()}_welcome`,
agent_name: nextIssue.question_agent_name,
message: t('pbl.chat.welcomeMessage')
.replace('{title}', nextIssue.title)
.replace('{questions}', nextIssue.generated_questions),
message: t('pbl.chat.welcomeMessage', {
title: nextIssue.title,
questions: nextIssue.generated_questions,
}),
timestamp: Date.now(),
read_by: [],
});
Expand All @@ -253,9 +255,10 @@ async function handleIssueComplete(
config.chat.messages.push({
id: `msg_${Date.now()}_system`,
agent_name: 'System',
message: t('pbl.chat.issueCompleteMessage')
.replace('{completed}', completedIssue.title)
.replace('{next}', nextIssue.title),
message: t('pbl.chat.issueCompleteMessage', {
completed: completedIssue.title,
next: nextIssue.title,
}),
timestamp: Date.now(),
read_by: [],
});
Expand Down
Loading
Loading