feat: i18n full support, responsive UI, multi-model AI config, and bug fixes

Major Features:
- Internationalization (i18n) with auto-detection for ES/EN/PT
- Mobile-first responsive design for Studio and Experience
- Multi-model AI configuration (llama3.2:3b, qwen3.5:9b, gpt-oss:latest)
- Course language configuration (auto-detect or fixed per course)

Backend Changes:
- shared/common: ModelType enum for intelligent model selection
- LMS: log_ai_usage function migration (fix chat tutor 500 error)
- LMS/CMS: course language config fields (language_setting, fixed_language)
- LMS: /courses/{id}/language-config endpoint for language detection

Frontend Changes:
- Experience: Enhanced i18n with browser language detection
- Experience: Audio recording with HTTPS check and error handling
- Studio: Memory game with unique pair IDs and debug logging
- Studio: Expanded translations (250+ keys for ES, EN, PT)
- Both: Language selector in headers (mobile responsive)

Documentation:
- AI_MODELS_CONFIG.md: Multi-model configuration guide
- RESPONSIVIDAD_GUIA.md: Mobile-first design patterns
- I18N_RESPONSIVIDAD_IMPLEMENTACION.md: Implementation details
- DEBUG_AUDIO_RECORDING.md: Audio troubleshooting guide
- DEBUG_MEMORY_GAME.md: Memory game debugging steps

Bug Fixes:
- Fix chat tutor 500 error (missing log_ai_usage function)
- Fix audio recording (HTTPS check, browser compatibility)
- Fix memory game pair IDs (unique ID generation)
- Fix HotspotBlock TypeScript errors

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-23 12:24:22 -03:00
parent 0598fc4865
commit 2ff06ee7ae
26 changed files with 2993 additions and 124 deletions
@@ -3,7 +3,7 @@
import { useEffect, useState, useCallback } from "react";
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, getImageUrl } from '@/lib/api';
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, getImageUrl, generateUUID } from '@/lib/api';
import {
Layout,
CheckCircle2,
@@ -103,7 +103,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
setImportantDateType(lessonData.important_date_type || "");
setIsPreviewable(lessonData.is_previewable || false);
if (lessonData.metadata?.blocks) {
if (lessonData.content_blocks && Array.isArray(lessonData.content_blocks)) {
setBlocks(lessonData.content_blocks);
} else if (lessonData.metadata?.blocks) {
setBlocks(lessonData.metadata.blocks);
} else {
setBlocks([
@@ -195,7 +197,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
}
const updated = await cmsApi.updateLesson(lesson.id, {
metadata: { ...lesson.metadata, blocks },
metadata: { ...lesson.metadata },
content_blocks: blocks,
content_url,
content_type, // Sync type to ensure backend triggers transcription
summary,
@@ -218,7 +221,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const addBlock = (type: Block['type']) => {
const newBlock: Block = {
id: crypto.randomUUID(),
id: generateUUID(),
type,
...(type === 'description' && { content: "" }),
...(type === 'media' && { url: "", media_type: 'video' as const, config: { maxPlays: 0 } }),
@@ -4,7 +4,7 @@ import { useState, useRef } from "react";
import Image from "next/image";
import { Search, MapPin, Plus, Trash2, Image as ImageIcon, Crosshair, Wand2, Loader2 } from "lucide-react";
import AssetPickerModal from "../AssetPickerModal";
import { Asset, getImageUrl, cmsApi } from "@/lib/api";
import { Asset, getImageUrl, cmsApi, generateUUID } from "@/lib/api";
interface Hotspot {
id: string;
@@ -46,12 +46,19 @@ export default function HotspotBlock({
setIsGenerating(true);
try {
const data = await cmsApi.generateHotspots(lessonId, { image_url: imageUrl });
const newHotspots = data.map(h => ({
id: crypto.randomUUID(),
x: h.x,
y: h.y,
// Handle different response formats from AI
let hotspotsArray = Array.isArray(data) ? data : (data.hotspots || data.items || []);
if (!Array.isArray(hotspotsArray)) {
throw new Error("La respuesta de la IA no es un array válido");
}
const newHotspots = hotspotsArray.map((h: any) => ({
id: generateUUID(),
x: typeof h.x === 'number' ? h.x : 50,
y: typeof h.y === 'number' ? h.y : 50,
radius: 5,
label: h.label
label: h.label || 'Punto de interés'
}));
onChange({ hotspots: [...hotspots, ...newHotspots] });
} catch (error) {
@@ -76,7 +83,7 @@ export default function HotspotBlock({
const y = ((e.clientY - rect.top) / rect.height) * 100;
const newHotspot: Hotspot = {
id: crypto.randomUUID(),
id: generateUUID(),
x,
y,
radius: 5,
@@ -16,27 +16,38 @@ interface MemoryBlockProps {
onChange: (updates: { title?: string; pairs?: MatchingPair[] }) => void;
}
// Generate unique ID for pairs
const generatePairId = () => {
return `pair_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
export default function MemoryBlock({ id, title, pairs = [], editMode, onChange }: MemoryBlockProps) {
const addPair = () => {
const newPair: MatchingPair = {
id: Math.random().toString(36).substr(2, 9),
id: generatePairId(),
left: "",
right: ""
};
console.log('[MemoryBlock] Adding new pair:', newPair);
onChange({ pairs: [...pairs, newPair] });
};
const updatePair = (index: number, updates: Partial<MatchingPair>) => {
const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], ...updates };
console.log('[MemoryBlock] Updating pair at index', index, ':', updates);
onChange({ pairs: newPairs });
};
const removePair = (index: number) => {
console.log('[MemoryBlock] Removing pair at index', index);
const newPairs = pairs.filter((_, i) => i !== index);
onChange({ pairs: newPairs });
};
// Debug: Log pairs on render
console.log('[MemoryBlock] Render with pairs:', pairs);
if (!editMode) {
return (
<div className="space-y-8" id={id}>
+43 -6
View File
@@ -8,29 +8,64 @@ import pt from '../lib/locales/pt.json';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const translations: Record<string, any> = { en, es, pt };
// Idiomas soportados
export const SUPPORTED_LANGUAGES = ['es', 'en', 'pt'] as const;
export type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number];
interface I18nContextType {
language: string;
setLanguage: (lang: string) => void;
t: (path: string) => string;
detectBrowserLanguage: () => string;
}
const I18nContext = createContext<I18nContextType | undefined>(undefined);
/**
* Detecta el idioma del navegador del usuario
*/
function detectBrowserLanguage(): string {
if (typeof navigator === 'undefined') return 'es';
const browserLanguages = navigator.languages || [navigator.language || (navigator as any).userLanguage];
for (const browserLang of browserLanguages) {
if (SUPPORTED_LANGUAGES.includes(browserLang as SupportedLanguage)) {
return browserLang;
}
const langCode = browserLang.split('-')[0].toLowerCase();
if (SUPPORTED_LANGUAGES.includes(langCode as SupportedLanguage)) {
return langCode;
}
}
return 'es';
}
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguageState] = useState('es');
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
const savedLang = localStorage.getItem('studio_language');
if (savedLang) {
if (savedLang && SUPPORTED_LANGUAGES.includes(savedLang as SupportedLanguage)) {
setLanguageState(savedLang);
} else {
// Try to detect from user profile if available, but for now default or localstorage
const detectedLang = detectBrowserLanguage();
setLanguageState(detectedLang);
localStorage.setItem('studio_language', detectedLang);
}
setIsInitialized(true);
}, []);
const setLanguage = (lang: string) => {
setLanguageState(lang);
localStorage.setItem('studio_language', lang);
if (SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage)) {
setLanguageState(lang);
localStorage.setItem('studio_language', lang);
}
};
const t = (path: string): string => {
@@ -41,7 +76,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
if (result[key]) {
result = result[key];
} else {
return path; // Fallback to path if key missing
return path;
}
}
@@ -49,7 +84,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
};
return (
<I18nContext.Provider value={{ language, setLanguage, t }}>
<I18nContext.Provider value={{ language, setLanguage, t, detectBrowserLanguage }}>
{children}
</I18nContext.Provider>
);
@@ -62,3 +97,5 @@ export function useTranslation() {
}
return context;
}
export { detectBrowserLanguage };
+13
View File
@@ -11,6 +11,19 @@ const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
export const API_BASE_URL = getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
export const LMS_API_BASE_URL = getApiBaseUrl("3002", process.env.NEXT_PUBLIC_LMS_API_URL);
// Polyfill for crypto.randomUUID() for non-HTTPS contexts
export function generateUUID(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for non-secure contexts
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
export const getImageUrl = (path?: string) => {
if (!path) return '';
if (path.startsWith('http')) return path;
+235 -21
View File
@@ -1,39 +1,253 @@
{
"common": {
"loading": "Cargando...",
"back": "Volver",
"next": "Siguiente",
"finish": "Finalizar",
"error": "Error",
"retry": "Reintentar",
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"close": "Cerrar",
"confirm": "Confirmar",
"success": "Éxito",
"warning": "Advertencia",
"create": "Crear",
"loading": "Cargando...",
"add": "Agregar",
"remove": "Eliminar",
"update": "Actualizar",
"search": "Buscar",
"back": "Volver",
"next": "Siguiente",
"filter": "Filtrar",
"export": "Exportar",
"import": "Importar",
"download": "Descargar",
"upload": "Subir",
"preview": "Vista Previa",
"publish": "Publicar",
"preview": "Previsualizar"
"draft": "Borrador",
"archive": "Archivar",
"duplicate": "Duplicar",
"settings": "Configuración"
},
"nav": {
"dashboard": "Dashboard",
"courses": "Cursos",
"globalControl": "Control Global",
"webhooks": "Webhooks",
"library": "Librería",
"questionBank": "Banco de Preguntas",
"testTemplates": "Plantillas de Pruebas",
"analytics": "Analíticas",
"students": "Estudiantes",
"instructors": "Instructores",
"settings": "Configuración",
"profile": "Perfil",
"signOut": "Cerrar Sesión"
"signOut": "Cerrar Sesión",
"selectLanguage": "Seleccionar Idioma",
"controlPanel": "Panel de Control",
"webhooks": "Webhooks",
"globalControl": "Control Global"
},
"dashboard": {
"title": "Panel de Control",
"newCourse": "Nuevo Curso",
"aiBuilder": "Constructor AI",
"noCourses": "No hay cursos disponibles."
"course": {
"createCourse": "Crear Curso",
"editCourse": "Editar Curso",
"deleteCourse": "Eliminar Curso",
"courseTitle": "Título del Curso",
"courseDescription": "Descripción del Curso",
"courseLanguage": "Idioma del Curso",
"languageAuto": "Automático (detectar del usuario)",
"languageFixed": "Fijo (siempre este idioma)",
"languageEnglish": "Inglés",
"languageSpanish": "Español",
"languagePortuguese": "Portugués",
"modules": "Módulos",
"addModule": "Agregar Módulo",
"editModule": "Editar Módulo",
"deleteModule": "Eliminar Módulo",
"moduleName": "Nombre del Módulo",
"moduleDescription": "Descripción del Módulo",
"lessons": "Lecciones",
"addLesson": "Agregar Lección",
"editLesson": "Editar Lección",
"deleteLesson": "Eliminar Lección",
"lessonTitle": "Título de la Lección",
"lessonType": "Tipo de Lección",
"blocks": "Bloques",
"addBlock": "Agregar Bloque",
"blockType": "Tipo de Bloque",
"reorderBlocks": "Reordenar Bloques",
"moveUp": "Mover Arriba",
"moveDown": "Mover Abajo",
"pricing": "Precios",
"currency": "Moneda",
"enrollment": "Inscripciones",
"published": "Publicado",
"unpublished": "No Publicado",
"visibility": "Visibilidad",
"public": "Público",
"private": "Privado"
},
"editor": {
"outline": "Estructura",
"settings": "Configuración",
"newModule": "Nuevo Módulo",
"newLesson": "Nueva Lección",
"addBlock": "Añadir Bloque de Contenido",
"publishToOrg": "Publicar en Organización",
"reading": "Lectura"
"content": {
"video": "Video",
"audio": "Audio",
"text": "Texto",
"image": "Imagen",
"document": "Documento",
"quiz": "Cuestionario",
"codeExercise": "Ejercicio de Código",
"memoryGame": "Juego de Memoria",
"dragToCube": "Arrastrar al Cubo",
"hotspots": "Puntos Calientes",
"videoMarkers": "Marcadores de Video",
"audioResponse": "Respuesta de Audio",
"fillBlanks": "Completar Espacios",
"matching": "Emparejamiento",
"ordering": "Ordenamiento",
"shortAnswer": "Respuesta Corta",
"multipleChoice": "Opción Múltiple",
"trueFalse": "Verdadero/Falso",
"essay": "Ensayo",
"uploadMedia": "Subir Archivo",
"dropFiles": "Soltar archivos aquí",
"browseFiles": "Examinar archivos",
"uploading": "Subiendo...",
"uploadComplete": "Subida Completa",
"uploadFailed": "Subida Fallida"
},
"ai": {
"generateWithAI": "Generar con IA",
"generateCourse": "Generar Curso con IA",
"generateQuiz": "Generar Cuestionario con IA",
"generateSummary": "Generar Resumen con IA",
"generateTranscription": "Transcribir con IA",
"generateQuestions": "Generar Preguntas con IA",
"topicPrompt": "Ingresa el tema del curso",
"generating": "Generando...",
"generationComplete": "Generación Completa",
"generationFailed": "Generación Fallida",
"aiPowered": "Potenciado por IA",
"model": "Modelo de IA",
"tokens": "Tokens",
"cost": "Costo"
},
"grading": {
"gradingSystem": "Sistema de Calificación",
"categories": "Categorías",
"weights": "Pesos",
"passingGrade": "Nota de Aprobación",
"percentage": "Porcentaje",
"dropLowest": "Eliminar Notas Más Bajas",
"maxAttempts": "Intentos Máximos",
"autoGrade": "Calificación Automática",
"manualGrade": "Calificación Manual",
"rubric": "Rúbrica",
"feedback": "Retroalimentación",
"grades": "Notas",
"exportGrades": "Exportar Notas",
"importGrades": "Importar Notas"
},
"students": {
"enrollments": "Inscripciones",
"enrollStudent": "Inscribir Estudiante",
"bulkEnroll": "Inscripción Masiva",
"studentProgress": "Progreso del Estudiante",
"studentGrades": "Notas del Estudiante",
"studentActivity": "Actividad del Estudiante",
"cohort": "Cohorte",
"groups": "Grupos",
"retention": "Retención",
"dropoutRisk": "Riesgo de Abandono",
"engagement": "Compromiso"
},
"analytics": {
"overview": "Resumen",
"enrollments": "Inscripciones",
"completions": "Completaciones",
"averageGrade": "Nota Promedio",
"engagement": "Compromiso",
"retention": "Retención",
"revenue": "Ingresos",
"aiUsage": "Uso de IA",
"exportReport": "Exportar Reporte",
"dateRange": "Rango de Fechas",
"compareCourses": "Comparar Cursos",
"studentPerformance": "Rendimiento Estudiantil"
},
"settings": {
"general": "Configuración General",
"branding": "Marca",
"logo": "Logotipo",
"favicon": "Favicon",
"colors": "Colores",
"platformName": "Nombre de la Plataforma",
"notifications": "Notificaciones",
"emailTemplates": "Plantillas de Email",
"integrations": "Integraciones",
"lti": "LTI",
"sso": "SSO",
"webhooks": "Webhooks",
"apiKeys": "Claves API",
"security": "Seguridad",
"backup": "Respaldo",
"export": "Exportar Datos",
"import": "Importar Datos"
},
"user": {
"profile": "Perfil",
"editProfile": "Editar Perfil",
"changePassword": "Cambiar Contraseña",
"preferences": "Preferencias",
"language": "Idioma",
"timezone": "Zona Horaria",
"notifications": "Notificaciones",
"avatar": "Avatar",
"bio": "Biografía",
"role": "Rol",
"admin": "Administrador",
"instructor": "Instructor",
"student": "Estudiante"
},
"errors": {
"required": "Campo requerido",
"invalidEmail": "Email inválido",
"minLength": "Longitud mínima: {{length}}",
"maxLength": "Longitud máxima: {{length}}",
"invalidFormat": "Formato inválido",
"alreadyExists": "Ya existe",
"notFound": "No encontrado",
"unauthorized": "No autorizado",
"serverError": "Error del servidor",
"networkError": "Error de red",
"uploadFailed": "Subida fallida",
"generationFailed": "Generación fallida"
},
"validation": {
"required": "Este campo es requerido",
"email": "Debe ser un email válido",
"url": "Debe ser una URL válida",
"number": "Debe ser un número",
"min": "Debe ser mayor o igual a {{min}}",
"max": "Debe ser menor o igual a {{max}}",
"pattern": "Formato inválido"
},
"dates": {
"today": "Hoy",
"yesterday": "Ayer",
"tomorrow": "Mañana",
"lastWeek": "Semana Pasada",
"lastMonth": "Mes Pasado",
"lastYear": "Año Pasado",
"custom": "Personalizado",
"startDate": "Fecha de Inicio",
"endDate": "Fecha de Fin",
"dueDate": "Fecha Límite",
"publishedDate": "Fecha de Publicación",
"createdDate": "Fecha de Creación",
"updatedDate": "Fecha de Actualización"
},
"accessibility": {
"skipToContent": "Saltar al contenido",
"increaseContrast": "Aumentar Contraste",
"screenReader": "Lector de Pantalla"
}
}
}