Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion app/api/generate/scene-content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export async function POST(req: NextRequest) {
// Ensure outline has language from stageInfo (fallback for older outlines)
const outline: SceneOutline = {
...rawOutline,
language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN',
language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US' | 'es-MX') || 'zh-CN',
};

// ── Model resolution from request headers ──
Expand Down
11 changes: 8 additions & 3 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen';
interface FormState {
pdfFile: File | null;
requirement: string;
language: 'zh-CN' | 'en-US';
language: 'zh-CN' | 'en-US' | 'es-MX';
webSearch: boolean;
}

Expand Down Expand Up @@ -99,10 +99,15 @@ function HomePage() {
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
const updates: Partial<FormState> = {};
if (savedWebSearch === 'true') updates.webSearch = true;
if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') {
if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US' || savedLanguage === 'es-MX') {
updates.language = savedLanguage;
} else {
const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US';
const browserLang = navigator.language;
const detected = browserLang?.startsWith('zh')
? 'zh-CN'
: browserLang?.startsWith('es')
? 'es-MX'
: 'en-US';
updates.language = detected;
}
if (Object.keys(updates).length > 0) {
Expand Down
12 changes: 8 additions & 4 deletions components/generation/generation-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024;

// ─── Types ───────────────────────────────────────────────────
export interface GenerationToolbarProps {
language: 'zh-CN' | 'en-US';
onLanguageChange: (lang: 'zh-CN' | 'en-US') => void;
language: 'zh-CN' | 'en-US' | 'es-MX';
onLanguageChange: (lang: 'zh-CN' | 'en-US' | 'es-MX') => void;
webSearch: boolean;
onWebSearchChange: (v: boolean) => void;
onSettingsOpen: (section?: SettingsSection) => void;
Expand Down Expand Up @@ -361,11 +361,15 @@ export function GenerationToolbar({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onLanguageChange(language === 'zh-CN' ? 'en-US' : 'zh-CN')}
onClick={() => {
const langs: Array<'zh-CN' | 'en-US' | 'es-MX'> = ['zh-CN', 'en-US', 'es-MX'];
const idx = langs.indexOf(language);
onLanguageChange(langs[(idx + 1) % langs.length]);
}}
className={pillMuted}
>
<Globe className="size-3.5" />
<span>{language === 'zh-CN' ? '中文' : 'EN'}</span>
<span>{language === 'zh-CN' ? '中文' : language === 'es-MX' ? 'ES' : 'EN'}</span>
</button>
</TooltipTrigger>
<TooltipContent>{t('toolbar.languageHint')}</TooltipContent>
Expand Down
15 changes: 14 additions & 1 deletion components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export function Header({ currentSceneTitle }: HeaderProps) {
}}
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'}
{locale === 'zh-CN' ? 'CN' : locale === 'es-MX' ? 'ES' : '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]">
Expand Down Expand Up @@ -138,6 +138,19 @@ export function Header({ currentSceneTitle }: HeaderProps) {
>
English
</button>
<button
onClick={() => {
setLocale('es-MX');
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 === 'es-MX' &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
Español
</button>
</div>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion components/settings/image-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export function ImageSettings({ selectedProviderId }: ImageSettingsProps) {
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
<span>{t('settings.testConnection')}</span>
</>
)}
</Button>
Expand Down
2 changes: 1 addition & 1 deletion components/settings/model-edit-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ export function ModelEditDialog({
{testStatus === 'testing' && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testStatus === 'success' && <CheckCircle className="mr-2 h-4 w-4" />}
{testStatus === 'error' && <XCircle className="mr-2 h-4 w-4" />}
{testStatus === 'testing' ? t('settings.testing') : t('settings.testConnection')}
<span>{testStatus === 'testing' ? t('settings.testing') : t('settings.testConnection')}</span>
</Button>
</div>
{testMessage && (
Expand Down
2 changes: 1 addition & 1 deletion components/settings/pdf-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
<span>{t('settings.testConnection')}</span>
</>
)}
</Button>
Expand Down
2 changes: 1 addition & 1 deletion components/settings/provider-config-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export function ProviderConfigPanel({
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
<span>{t('settings.testConnection')}</span>
</>
)}
</Button>
Expand Down
2 changes: 1 addition & 1 deletion components/settings/tts-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
) : (
<Volume2 className="h-4 w-4" />
)}
{t('settings.testTTS')}
<span>{t('settings.testTTS')}</span>
</Button>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion components/settings/video-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export function VideoSettings({ selectedProviderId }: VideoSettingsProps) {
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
<span>{t('settings.testConnection')}</span>
</>
)}
</Button>
Expand Down
2 changes: 1 addition & 1 deletion lib/generation/scene-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,7 @@ function normalizeQuizAnswer(question: Record<string, unknown>): string[] | unde
async function generateInteractiveContent(
outline: SceneOutline,
aiCall: AICallFn,
language: 'zh-CN' | 'en-US' = 'zh-CN',
language: 'zh-CN' | 'en-US' | 'es-MX' = 'zh-CN',
): Promise<GeneratedInteractiveContent | null> {
const config = outline.interactiveConfig!;

Expand Down
23 changes: 21 additions & 2 deletions lib/hooks/use-i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ type I18nContextType = {
};

const LOCALE_STORAGE_KEY = 'locale';
const VALID_LOCALES: Locale[] = ['zh-CN', 'en-US'];
const VALID_LOCALES: Locale[] = ['zh-CN', 'en-US', 'es-MX'];

const I18nContext = createContext<I18nContextType | undefined>(undefined);

export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(defaultLocale);
const [hydrated, setHydrated] = useState(false);

// Hydrate from localStorage after mount (avoids SSR mismatch)
/* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */
Expand All @@ -24,14 +25,21 @@ export function I18nProvider({ children }: { children: ReactNode }) {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && VALID_LOCALES.includes(stored as Locale)) {
setLocaleState(stored as Locale);
setHydrated(true);
return;
}
const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US';
const browserLang = navigator.language;
const detected = browserLang?.startsWith('zh')
? 'zh-CN'
: browserLang?.startsWith('es')
? 'es-MX'
: 'en-US';
localStorage.setItem(LOCALE_STORAGE_KEY, detected);
setLocaleState(detected);
} catch {
// localStorage unavailable, keep default
}
setHydrated(true);
}, []);
/* eslint-enable react-hooks/set-state-in-effect */

Expand All @@ -42,6 +50,17 @@ export function I18nProvider({ children }: { children: ReactNode }) {

const t = (key: string): string => translate(locale, key);

// Prevent hydration mismatch: server renders with defaultLocale (zh-CN),
// but client may detect a different locale from localStorage/browser.
// Hide content briefly until the correct locale is resolved.
if (!hydrated) {
return (
<I18nContext.Provider value={{ locale, setLocale, t }}>
<div style={{ visibility: 'hidden' }}>{children}</div>
</I18nContext.Provider>
);
}

return <I18nContext.Provider value={{ locale, setLocale, t }}>{children}</I18nContext.Provider>;
}

Expand Down
74 changes: 74 additions & 0 deletions lib/i18n/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,77 @@ export const chatEnUS = {
stopListening: 'Stop recording',
},
} as const;

export const chatEsMX = {
chat: {
lecture: 'Clase',
noConversations: 'Sin conversaciones',
startConversation: 'Escribe un mensaje para iniciar la conversación',
noMessages: 'Aún no hay mensajes',
ended: 'finalizado',
unknown: 'Desconocido',
stopDiscussion: 'Detener Discusión',
endQA: 'Finalizar Preguntas',
tabs: {
lecture: 'Notas',
chat: 'Chat',
},
lectureNotes: {
empty: 'Las notas aparecerán aquí después de reproducir la clase',
emptyHint: 'Presiona reproducir para iniciar la clase',
pageLabel: 'Página {n}',
currentPage: 'Actual',
},
badge: {
qa: 'P&R',
discussion: 'DISC',
lecture: 'CLASE',
},
},
actions: {
names: {
spotlight: 'Foco',
laser: 'Láser',
wb_open: 'Abrir Pizarra',
wb_draw_text: 'Texto en Pizarra',
wb_draw_shape: 'Forma en Pizarra',
wb_draw_chart: 'Gráfica en Pizarra',
wb_draw_latex: 'Fórmula en Pizarra',
wb_draw_table: 'Tabla en Pizarra',
wb_draw_line: 'Línea en Pizarra',
wb_clear: 'Limpiar Pizarra',
wb_delete: 'Eliminar Elemento',
wb_close: 'Cerrar Pizarra',
discussion: 'Discusión',
},
status: {
inputStreaming: 'Esperando',
inputAvailable: 'Ejecutando',
outputAvailable: 'Completado',
outputError: 'Error',
outputDenied: 'Rechazado',
running: 'Ejecutando',
result: 'Completado',
error: 'Error',
},
},
agentBar: {
readyToLearn: '¿Listos para aprender juntos?',
expandedTitle: 'Configuración de Roles del Aula',
configTooltip: 'Clic para configurar los roles del aula',
voiceLabel: 'Voz',
voiceLoading: 'Cargando...',
voiceAutoAssign: 'Las voces se asignarán automáticamente',
},
proactiveCard: {
discussion: 'Discusión',
join: 'Unirse',
skip: 'Omitir',
pause: 'Pausar',
resume: 'Reanudar',
},
voice: {
startListening: 'Entrada de voz',
stopListening: 'Detener grabación',
},
} as const;
41 changes: 41 additions & 0 deletions lib/i18n/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,44 @@ export const commonEnUS = {
exportFailed: 'Export failed',
},
} as const;

export const commonEsMX = {
common: {
you: 'Tú',
confirm: 'Confirmar',
cancel: 'Cancelar',
loading: 'Cargando...',
},
home: {
slogan: 'Aprendizaje Generativo en Aulas Interactivas Multi-Agente',
greeting: 'Hola, ',
},
toolbar: {
languageHint: 'El curso se generará en este idioma',
pdfParser: 'Analizador',
pdfUpload: 'Subir PDF',
removePdf: 'Eliminar archivo',
webSearchOn: 'Activado',
webSearchOff: 'Clic para activar',
webSearchDesc: 'Buscar en la web información actualizada antes de generar',
webSearchProvider: 'Motor de búsqueda',
webSearchNoProvider: 'Configura la API Key del motor de búsqueda en Ajustes',
selectProvider: 'Seleccionar proveedor',
configureProvider: 'Configurar modelo',
configureProviderHint: 'Configura al menos un proveedor de modelo para generar cursos',
enterClassroom: 'Entrar al Aula',
advancedSettings: 'Ajustes Avanzados',
ttsTitle: 'Síntesis de Voz',
ttsHint: 'Elige una voz para el profesor IA',
ttsPreview: 'Vista previa',
ttsPreviewing: 'Reproduciendo...',
},
export: {
pptx: 'Exportar PPTX',
resourcePack: 'Exportar Paquete de Recursos',
resourcePackDesc: 'PPTX + páginas interactivas',
exporting: 'Exportando...',
exportSuccess: 'Exportación exitosa',
exportFailed: 'Error al exportar',
},
} as const;
Loading
Loading