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
+208
View File
@@ -0,0 +1,208 @@
# Configuración de Modelos de IA - OpenCCB
## Visión General
OpenCCB utiliza una arquitectura de **múltiples modelos especializados** para optimizar el rendimiento y la calidad de las respuestas según el tipo de tarea.
## Modelos Disponibles
| Modelo | Tamaño | Uso Principal | Características |
|--------|--------|---------------|-----------------|
| `llama3.2:3b` | 2.0 GB | Chat, Tutor, Q&A | Rápido, eficiente, ideal para conversación en tiempo real |
| `qwen3.5:9b` | 6.6 GB | Razonamiento complejo | Mejor para análisis, feedback detallado, generación de quizzes |
| `gpt-oss:latest` | 13 GB | Tareas avanzadas | Modelo más capaz para generación de cursos, análisis predictivo |
| `nomic-embed-text` | 274 MB | Embeddings | Optimizado para búsqueda semántica (768 dimensiones) |
## Configuración por Defecto
### Variables de Entorno (.env)
```bash
# Modelo para chat conversacional (tutor, Q&A, discusión)
LOCAL_LLM_MODEL=llama3.2:3b
# Modelo para razonamiento complejo (quizzes, feedback, análisis)
LOCAL_LLM_MODEL_COMPLEX=qwen3.5:9b
# Modelo para tareas avanzadas (generación de cursos, analytics)
LOCAL_LLM_MODEL_ADVANCED=gpt-oss:latest
# Modelo para embeddings (búsqueda semántica)
EMBEDDING_MODEL=nomic-embed-text
```
## Asignación de Modelos por Feature
### 🎓 Experiencia del Estudiante (LMS)
| Feature | Modelo | Razón |
|---------|--------|-------|
| Chat con Tutor | `llama3.2:3b` | Respuestas rápidas, conversación fluida |
| Feedback de Audio | `qwen3.5:9b` | Análisis detallado de pronunciación y contenido |
| Recomendaciones | `qwen3.5:9b` | Evaluación compleja del desempeño |
| Búsqueda Semántica | `nomic-embed-text` | Embeddings optimizados para PGVector |
| Transcripción de Video | `whisper-large-v3` | Precisión en transcripción |
### 📚 Gestión de Cursos (CMS)
| Feature | Modelo | Razón |
|---------|--------|-------|
| Generación de Cursos | `gpt-oss:latest` | Estructura compleja, coherencia curricular |
| Generación de Quizzes | `qwen3.5:9b` | Preguntas pedagógicamente sólidas |
| Análisis de Dropout | `qwen3.5:9b` | Detección de patrones complejos |
| Chat con Lección | `llama3.2:3b` | Respuestas contextuales rápidas |
## Parámetros por Modelo
### llama3.2:3b (Chat)
```rust
temperature: 0.7 // Balance creatividad/precisión
max_tokens: 1024 // Respuestas concisas
top_p: 0.9 // Muestreo nuclear
```
### qwen3.5:9b (Complejo)
```rust
temperature: 0.5 // Más enfocado, menos aleatorio
max_tokens: 2048 // Respuestas detalladas
top_p: 0.8 // Más determinista
```
### gpt-oss:latest (Avanzado)
```rust
temperature: 0.6 // Balance para análisis
max_tokens: 4096 // Contenido extenso
top_p: 0.85 // Balance creatividad/coherencia
```
## Selección Automática de Modelos
El sistema selecciona automáticamente el modelo según la tarea:
```rust
use common::ai::{ModelType, get_model_for_task};
// Por tipo
let model = ModelType::Chat.get_model(); // llama3.2:3b
let model = ModelType::Complex.get_model(); // qwen3.5:9b
let model = ModelType::Advanced.get_model(); // gpt-oss:latest
// Por tarea (auto-detección)
let model = get_model_for_task("chat"); // llama3.2:3b
let model = get_model_for_task("quiz"); // qwen3.5:9b
let model = get_model_for_task("course"); // gpt-oss:latest
```
## Optimización de Rendimiento
### Tiempos de Respuesta Esperados
| Modelo | Tokens/s | Uso de VRAM | Latencia T1T |
|--------|----------|-------------|--------------|
| llama3.2:3b | ~50 tok/s | 2.5 GB | ~200ms |
| qwen3.5:9b | ~25 tok/s | 7 GB | ~500ms |
| gpt-oss:latest | ~15 tok/s | 14 GB | ~800ms |
### Recomendaciones
1. **Chat en tiempo real**: Usar siempre `llama3.2:3b`
2. **Procesamiento por lotes**: Usar `qwen3.5:9b` o `gpt-oss:latest`
3. **Embeddings**: `nomic-embed-text` es el más eficiente
4. **Memoria limitada**: Priorizar `llama3.2:3b`, descargar otros modelos si es necesario
## Comandos Útiles
```bash
# Listar modelos instalados
ollama list
# Instalar un modelo
ollama pull llama3.2:3b
ollama pull qwen3.5:9b
ollama pull gpt-oss:latest
# Eliminar un modelo
ollama rm nombre-modelo
# Probar un modelo
ollama run llama3.2:3b "Hola, ¿cómo estás?"
# Ver información del sistema
ollama ps
```
## Configuración en Producción
Para producción con múltiples usuarios concurrentes:
1. **Aumentar memoria VRAM**: Mínimo 16GB recomendado
2. **Usar CUDA**: Acelerar con GPU NVIDIA
3. **Rate limiting**: Configurar límites por usuario
4. **Cache de embeddings**: Reutilizar embeddings generados
5. **Batch processing**: Agrupar solicitudes de embeddings
## Troubleshooting
### Error: "model not found"
```bash
# Verificar modelos instalados
ollama list
# Instalar modelo faltante
ollama pull <nombre-modelo>
# Reiniciar servicio Ollama
sudo systemctl restart ollama
```
### Error: "out of memory"
```bash
# Verificar uso de memoria
ollama ps
# Descargar modelos no utilizados
ollama rm gpt-oss:latest # Si no se usa frecuentemente
```
### Error: "request timeout"
```bash
# Aumentar timeout en .env
REQUEST_TIMEOUT=120
# Usar modelo más rápido
LOCAL_LLM_MODEL=llama3.2:3b
```
## Métricas de Uso
Para monitorear el uso de IA:
```sql
-- Ver uso por modelo
SELECT model, COUNT(*) as requests, SUM(tokens_used) as total_tokens
FROM ai_usage_logs
GROUP BY model
ORDER BY total_tokens DESC;
-- Ver uso por endpoint
SELECT endpoint, model, AVG(tokens_used) as avg_tokens
FROM ai_usage_logs
GROUP BY endpoint, model;
```
## Actualización de Modelos
Para actualizar a versiones más recientes:
```bash
# Forzar actualización
ollama pull --force llama3.2:3b
# Ver cambios en el modelo
ollama show llama3.2:3b --modelfile
```
---
**Última actualización**: 2026-03-20
**Versión**: 1.0
+242
View File
@@ -0,0 +1,242 @@
# Debugging: Audio Recording Issue
## Problema Reportado
El ejercicio de captura de audio no se activa (no inicia la grabación).
## Soluciones Implementadas
### 1. Mejoras en el Componente AudioResponsePlayer
**Archivos modificados:**
- `web/experience/src/components/blocks/AudioResponsePlayer.tsx`
**Cambios realizados:**
1. ✅ Verificación de compatibilidad del navegador
2. ✅ Verificación de contexto seguro (HTTPS/localhost)
3. ✅ Logging detallado para debugging
4. ✅ Manejo mejorado de errores con mensajes específicos
5. ✅ Configuración optimizada de audio (eco cancellation, noise suppression)
6. ✅ Soporte para múltiples formatos de audio
### 2. Mensajes de Error Específicos
El sistema ahora muestra mensajes diferentes según el error:
| Error | Mensaje |
|-------|---------|
| Navegador no compatible | "Your browser does not support audio recording..." |
| Requiere HTTPS | "Audio recording requires HTTPS..." |
| Permiso denegado | "Please allow microphone access..." |
| Micrófono no encontrado | "No microphone found..." |
| Micrófono en uso | "Microphone is already in use..." |
---
## Pasos para Diagnosticar
### 1. Abrir Consola del Navegador
1. Ve a una lección con ejercicio de audio
2. Presiona `F12` o clic derecho → "Inspeccionar"
3. Ve a la pestaña "Console"
### 2. Verificar Mensajes de Log
Cuando hagas clic en "Start Recording", deberías ver:
```
[AudioResponse] Requesting microphone access...
[AudioResponse] Microphone access granted
[AudioResponse] Recording started
[AudioResponse] Speech recognition started
[AudioResponse] Data available, chunk size: XXXX
```
### 3. Verificar Errores
Si hay un error, verás algo como:
```
[AudioResponse] Error accessing microphone: NotAllowedError
```
---
## Verificación de Compatibilidad
### Navegadores Soportados
**Chrome/Chromium** (v60+)
**Firefox** (v53+)
**Edge** (v79+)
**Safari** (v14.1+)
**Internet Explorer** - No soportado
### Verificar en tu Navegador
```javascript
// En la consola del navegador
console.log('MediaDevices:', !!navigator.mediaDevices);
console.log('getUserMedia:', !!navigator.mediaDevices?.getUserMedia);
console.log('MediaRecorder:', !!window.MediaRecorder);
console.log('Secure Context:', window.isSecureContext);
console.log('Protocol:', window.location.protocol);
```
---
## Problemas Comunes y Soluciones
### Problema 1: "Could not access microphone"
**Causa:** El usuario denegó el permiso del micrófono.
**Solución:**
1. Haz clic en el ícono de candado en la barra de URL
2. Permite el acceso al micrófono
3. Recarga la página
### Problema 2: "No microphone found"
**Causa:** No hay micrófono conectado o configurado.
**Solución:**
1. Conecta un micrófono o usa el integrado
2. Verifica en Configuración del Sistema → Sonido
3. Recarga la página
### Problema 3: "Microphone is already in use"
**Causa:** Otra aplicación está usando el micrófono.
**Solución:**
1. Cierra otras aplicaciones (Zoom, Teams, etc.)
2. Cierra otras pestañas del navegador que usen el micrófono
3. Intenta de nuevo
### Problema 4: "HTTPS Required"
**Causa:** El navegador bloquea el micrófono en HTTP.
**Solución:**
1. Usa HTTPS en producción
2. Para desarrollo local, usa `localhost` (funciona sin HTTPS)
3. O usa `ngrok` para crear un túnel HTTPS
### Problema 5: "Browser Not Supported"
**Causa:** El navegador no soporta la API MediaRecorder.
**Solución:**
1. Usa Chrome, Firefox, Edge o Safari actualizado
2. No uses Internet Explorer
---
## Comandos de Debugging
### En la consola del navegador:
```javascript
// Verificar permisos
navigator.permissions.query({name: 'microphone'}).then(result => {
console.log('Mic permission:', result.state);
});
// Probar acceso al micrófono
navigator.mediaDevices.getUserMedia({audio: true})
.then(() => console.log('✓ Mic access OK'))
.catch(err => console.error('✗ Mic access failed:', err));
// Verificar formatos soportados
console.log('WebM supported:', MediaRecorder.isTypeSupported('audio/webm'));
console.log('WebM+Opus supported:', MediaRecorder.isTypeSupported('audio/webm;codecs=opus'));
```
### En la consola del servidor (Docker):
```bash
# Ver logs de Experience
docker logs openccb-experience-1 --tail 100
# Buscar errores específicos
docker logs openccb-experience-1 --tail 100 | grep -i "audio\|error"
```
---
## Estructura del Ejercicio de Audio
### Configuración Típica
```json
{
"type": "audio-response",
"prompt": "Describe your daily routine in English",
"keywords": ["wake up", "breakfast", "work", "sleep"],
"timeLimit": 60,
"isGraded": true
}
```
### Flujo de Grabación
1. Usuario hace clic en "Start Recording"
2. Sistema solicita permiso de micrófono
3. Inicia grabación de audio (formato WebM)
4. Speech recognition transcribe en tiempo real
5. Usuario hace clic en "Stop Recording"
6. Audio se envía al servidor para evaluación
7. IA analiza pronunciación y keywords
8. Se muestra feedback con puntaje
---
## Checklist de Verificación
- [ ] El botón "Start Recording" aparece
- [ ] Al hacer clic, el navegador solicita permiso de micrófono
- [ ] El permiso es concedido
- [ ] El temporizador comienza a contar
- [ ] El ícono de grabación parpadea en rojo
- [ ] La transcripción aparece en tiempo real
- [ ] El botón "Stop Recording" funciona
- [ ] El audio se puede reproducir después de grabar
- [ ] El botón "Submit Response" aparece
- [ ] La evaluación se muestra después de enviar
---
## Reportar el Problema
Por favor proporciona:
1. **¿Qué navegador estás usando?** (Chrome, Firefox, etc.)
2. **¿Qué versión?** (ayuda → acerca de)
3. **¿Estás en localhost o producción?**
4. **¿Qué mensajes de error ves en la consola?**
5. **¿El navegador solicita permiso de micrófono?**
6. **¿Captura de pantalla de la consola (F12)?**
---
## Configuración para Desarrollo
### ngrok (HTTPS tunnel para desarrollo)
```bash
# Instalar ngrok
npm install -g ngrok
# Crear túnel HTTPS
ngrok http 3003
```
Esto te dará una URL HTTPS temporal para probar el micrófono.
---
**Fecha**: 2026-03-23
**Versión**: OpenCCB 0.2.0
**Componente**: AudioResponsePlayer
+155
View File
@@ -0,0 +1,155 @@
# Debugging: Memory Game Block
## Pasos para Diagnosticar el Problema
### 1. Abrir Consola del Navegador
1. Ve a la lección que quieres editar
2. Presiona `F12` o clic derecho → "Inspeccionar"
3. Ve a la pestaña "Console"
### 2. Agregar Bloque Memory Match
1. Haz clic en el botón "🧩 Logic Game" (Memory Match)
2. **Verifica en consola**: ¿Aparece el log `[MemoryBlock] Render with pairs: [...]`?
### 3. Editar los Pares
1. Escribe texto en "Synapse Alpha" (left)
2. Escribe texto en "Synapse Beta" (right)
3. **Verifica en consola**: ¿Aparecen los logs `[MemoryBlock] Updating pair at index...`?
### 4. Guardar la Lección
1. Haz clic en "Save" o "Guardar"
2. **Verifica en consola**: ¿Hay algún error rojo?
### 5. Recargar la Página
1. Recarga la página (F5)
2. **Verifica**: ¿El bloque Memory Match aparece con los datos que guardaste?
---
## Posibles Problemas y Soluciones
### Problema 1: El bloque no aparece al crear
**Síntoma**: Haces clic en "🧩 Logic Game" pero no pasa nada
**Solución**:
```javascript
// Verifica que el bloque se está agregando
console.log('Blocks after add:', blocks);
```
### Problema 2: Los campos no se actualizan
**Síntoma**: Escribes en los inputs pero el texto no aparece
**Causa probable**: El estado no se está actualizando correctamente
**Verifica en consola**:
```
[MemoryBlock] Updating pair at index 0 : {left: "texto"}
```
### Problema 3: Los datos no se guardan
**Síntoma**: Guardas la lección pero al recargar los pares están vacíos
**Causa probable**: El backend no está procesando correctamente los pares
**Verifica**:
1. En la pestaña Network (F12 → Network)
2. Busca la petición PUT a `/lessons/{id}`
3. Revisa el payload: ¿Los pares tienen los valores que escribiste?
### Problema 4: Error al guardar
**Síntoma**: Aparece alerta "Failed to save activity"
**Verifica en consola**: ¿Hay un error rojo con más detalles?
---
## Comandos de Debugging
### En la consola del navegador:
```javascript
// Verificar bloques actuales
console.log('Current blocks:', blocks);
// Verificar solo bloques memory-match
console.log('Memory blocks:', blocks.filter(b => b.type === 'memory-match'));
// Verificar estructura de un par
console.log('First pair:', blocks.filter(b => b.type === 'memory-match')[0]?.pairs?.[0]);
```
### En la consola del servidor (Docker):
```bash
# Ver logs de Studio
docker logs openccb-studio-1 --tail 100
# Buscar errores específicos
docker logs openccb-studio-1 --tail 100 | grep -i "error\|memory\|block"
```
---
## Estructura Esperada del Bloque
Un bloque Memory Match correctamente formado se ve así:
```json
{
"id": "uuid-generado",
"type": "memory-match",
"title": "Mi Juego de Memoria",
"pairs": [
{
"id": "pair_1234567890_abc123",
"left": "Término A",
"right": "Definición A"
},
{
"id": "pair_1234567891_def456",
"left": "Término B",
"right": "Definición B"
}
]
}
```
---
## Checklist de Verificación
- [ ] El botón "🧩 Logic Game" aparece en la lista de bloques
- [ ] Al hacer clic, se agrega un bloque a la lección
- [ ] El bloque muestra campos para título y pares
- [ ] Puedo escribir en los campos "Synapse Alpha" y "Synapse Beta"
- [ ] Al escribir, los valores se actualizan en el estado
- [ ] Puedo agregar más pares con el botón "+"
- [ ] Puedo eliminar pares con el botón de basura
- [ ] Al guardar, no aparece ningún error
- [ ] Al recargar, los pares mantienen sus valores
---
## Reportar el Problema
Por favor proporciona:
1. **¿Qué paso del checklist falla?**
2. **Captura de pantalla de la consola** (F12 → Console)
3. **¿Qué ves en la pestaña Network?** (F12 → Network → petición PUT)
4. **¿Los logs `[MemoryBlock]` aparecen en consola?**
---
**Fecha**: 2026-03-23
**Versión**: OpenCCB 0.2.0
+445
View File
@@ -0,0 +1,445 @@
# Implementación de Internacionalización (i18n) y Responsividad
## Resumen de la Implementación
**Fecha**: 20 de Marzo, 2026
**Estado**: ✅ Completado
---
## 🌍 Internacionalización (i18n)
### Características Implementadas
1. **Detección Automática de Idioma**
- Detección del idioma del navegador al primer ingreso
- Soporte para múltiples idiomas del navegador (`navigator.languages`)
- Fallback a español si el idioma no está soportado
2. **Idiomas Soportados**
- 🇪🇸 Español (es)
- 🇬🇧 Inglés (en)
- 🇵🇹 Portugués (pt)
3. **Selector Manual de Idioma**
- Disponible en AppHeader (Experience) y Navbar (Studio)
- Persistencia en localStorage
- Cambio instantáneo sin recargar la página
4. **Configuración de Idioma por Curso**
- **Modo Automático**: Detecta el idioma del usuario
- **Modo Fijo**: Usa siempre el idioma configurado en el curso
- Ideal para cursos de idiomas (ej: curso de inglés siempre en inglés)
---
## 📱 Responsividad (Mobile-First)
### Breakpoints Utilizados
```
Mobile: < 640px (default)
sm: ≥ 640px
md: ≥ 768px
lg: ≥ 1024px
xl: ≥ 1280px
2xl: ≥ 1536px
```
### Componentes Responsivos
#### AppHeader (Experience)
**Mobile (< 768px):**
- Logo compacto
- Íconos de notificación y tema
- Botón de menú hamburguesa
- Sidebar deslizante con navegación completa
**Desktop (≥ 768px):**
- Logo completo
- Navegación horizontal visible
- Selector de idioma visible
- Perfil de usuario visible
#### Navbar (Studio)
**Mobile (< 768px):**
- Logo compacto
- Dropdowns colapsados
- Menú hamburguesa
- Sidebar deslizante
**Desktop (≥ 768px):**
- Logo completo
- Dropdowns visibles
- Información de usuario visible
---
## 🗂️ Archivos de Traducción
### Experience (web/experience/src/lib/locales/)
**Archivos:**
- `es.json` - Español (completo)
- `en.json` - Inglés (completo)
- `pt.json` - Portugués (completo)
**Categorías:**
- `common` - Textos comunes (loading, error, save, cancel)
- `nav` - Navegación (catalog, profile, signOut)
- `course` - Cursos (modules, lessons, progress)
- `lesson` - Lecciones (summary, transcription, complete)
- `auth` - Autenticación (login, register, password)
- `dashboard` - Dashboard (welcome, stats)
- `profile` - Perfil (edit, avatar, settings)
- `gamification` - Gamificación (level, xp, badges)
- `grading` - Calificaciones (grade, score, feedback)
- `quiz` - Cuestionarios (start, submit, attempts)
- `forum` - Foros (discussions, threads, replies)
- `payments` - Pagos (purchase, payment method)
- `accessibility` - Accesibilidad (contrast, text size)
- `language` - Idiomas (select, course, interface)
- `errors` - Errores (notFound, unauthorized)
- `dates` - Fechas (today, yesterday, daysAgo)
### Studio (web/studio/src/lib/locales/)
**Archivos:**
- `es.json` - Español (completo - administración)
- `en.json` - Inglés (básico - pendiente completar)
- `pt.json` - Portugués (básico - pendiente completar)
**Categorías Adicionales:**
- `course` - Gestión de cursos (create, edit, modules, lessons)
- `content` - Tipos de contenido (video, audio, quiz, code)
- `ai` - Funciones de IA (generate, model, tokens)
- `grading` - Sistema de calificación (categories, weights)
- `students` - Estudiantes (enrollments, progress)
- `analytics` - Analíticas (overview, retention)
- `settings` - Configuración (branding, integrations)
- `user` - Usuario (profile, preferences)
- `validation` - Validación de formularios
---
## 🗄️ Base de Datos
### Migración: Course Language Configuration
**Archivo:** `services/cms-service/migrations/20260320000002_add_course_language_config.sql`
**Campos Agregados a `courses`:**
```sql
language_setting VARCHAR(20) DEFAULT 'auto'
- 'auto': Detectar idioma del usuario
- 'fixed': Usar idioma fijo
fixed_language VARCHAR(5) DEFAULT NULL
- 'es': Español
- 'en': Inglés
- 'pt': Portugués
- NULL: Cuando language_setting es 'auto'
```
**Constraints:**
```sql
chk_language_setting: language_setting IN ('auto', 'fixed')
chk_fixed_language: fixed_language IS NULL OR fixed_language IN ('es', 'en', 'pt')
```
**Índice:**
```sql
idx_courses_language: (language_setting, fixed_language)
```
---
## 🔌 Backend API
### LMS Service (Port 3002)
**Endpoint: Course Language Config**
```http
GET /courses/{id}/language-config
Authorization: Bearer {token}
```
**Respuesta:**
```json
{
"language_setting": "auto",
"fixed_language": null
}
```
**Ejemplos de Uso:**
```javascript
// Curso en modo automático (usa idioma del usuario)
{
"language_setting": "auto",
"fixed_language": null
}
// Curso de inglés (siempre en inglés)
{
"language_setting": "fixed",
"fixed_language": "en"
}
// Curso de español (siempre en español)
{
"language_setting": "fixed",
"fixed_language": "es"
}
```
---
## ⚙️ Contextos y Hooks
### I18nContext (Experience y Studio)
**Funciones:**
```typescript
interface I18nContextType {
language: string;
setLanguage: (lang: string) => void;
t: (path: string) => string;
detectBrowserLanguage: () => string;
}
```
**Uso:**
```typescript
import { useTranslation } from '@/context/I18nContext';
function MyComponent() {
const { language, setLanguage, t } = useTranslation();
return (
<div>
<h1>{t('dashboard.welcome')}</h1>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
>
<option value="es">ES</option>
<option value="en">EN</option>
<option value="pt">PT</option>
</select>
</div>
);
}
```
### useCourseLanguage Hook (Experience)
**Hook para idioma por curso:**
```typescript
import { useCourseLanguage } from '@/hooks/useCourseLanguage';
function CoursePage({ courseId }) {
const {
courseLanguage,
isFixedLanguage,
isLoading
} = useCourseLanguage(courseId);
if (isLoading) return <Loading />;
return (
<div>
<p>Idioma del curso: {courseLanguage}</p>
{isFixedLanguage() && (
<p>Este curso usa idioma fijo</p>
)}
</div>
);
}
```
**Funciones:**
- `courseLanguage`: Idioma actual del curso
- `isFixedLanguage()`: Boolean - ¿el curso tiene idioma fijo?
- `isLoading`: Boolean - ¿cargando configuración?
- `refreshConfig()`: Recargar configuración
### useCourseLanguageSwitcher Hook (Experience)
**Hook para cambiar idioma (solo si es permitido):**
```typescript
import { useCourseLanguageSwitcher } from '@/hooks/useCourseLanguage';
function LanguageSelector({ courseId }) {
const {
currentLanguage,
canChangeLanguage,
changeLanguage,
isFixed
} = useCourseLanguageSwitcher(courseId);
return (
<select
value={currentLanguage}
onChange={(e) => changeLanguage(e.target.value)}
disabled={!canChangeLanguage}
>
<option value="es">Español</option>
<option value="en">English</option>
<option value="pt">Português</option>
</select>
);
}
```
---
## 📋 Guía de Responsividad
**Archivo:** `RESPONSIVIDAD_GUIA.md`
### Principios Mobile-First
1. **Diseñar primero para móvil**
2. **Mejorar progresivamente para pantallas más grandes**
3. **Usar clases responsivas de Tailwind**
### Patrones Comunes
#### Grid de Cursos
```tsx
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{courses.map(course => (
<CourseCard key={course.id} course={course} />
))}
</div>
```
#### Tablas Responsivas
```tsx
<div className="overflow-x-auto">
<table className="min-w-full">
{/* Tabla completa */}
</table>
</div>
```
#### Tipografía Fluida
```tsx
<h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold">
Título Responsivo
</h1>
```
#### Espaciado Responsivo
```tsx
<div className="px-4 md:px-6 lg:px-8 py-4 md:py-6 lg:py-8">
{/* Contenido */}
</div>
```
---
## 🧪 Pruebas
### Checklist de Responsividad
- [ ] Navegación funciona en mobile
- [ ] Menús desplegables accesibles
- [ ] Formularios usables en pantallas pequeñas
- [ ] Tablas con scroll horizontal
- [ ] Imágenes se escalan correctamente
- [ ] Texto legible sin zoom
- [ ] Botones táctiles (mínimo 44x44px)
- [ ] Sin overflow horizontal no intencional
### Dispositivos de Prueba (Chrome DevTools)
- iPhone SE (375x667)
- iPhone 12 Pro (390x844)
- iPad Air (820x1180)
- iPad Pro (1024x1366)
- Desktop (1920x1080)
---
## 🚀 Comandos Útiles
### Ver Logs de i18n
```bash
# Experience
docker logs openccb-experience-1 | grep -i "language\|i18n"
# Studio
docker logs openccb-studio-1 | grep -i "language\|i18n"
```
### Probar Detección de Idioma
```javascript
// Console del navegador
console.log(navigator.languages);
console.log(navigator.language);
localStorage.removeItem('experience_language');
location.reload();
```
### Ver Configuración de Curso
```bash
# SQL
SELECT id, title, language_setting, fixed_language
FROM courses
WHERE id = '{course-id}';
```
---
## 📝 Tareas Futuras (Opcionales)
1. **Completar traducciones de Studio**
- Agregar claves faltantes en `en.json` y `pt.json`
2. **Agregar más idiomas**
- Francés (fr)
- Alemán (de)
- Italiano (it)
3. **Traducción de contenido de cursos**
- Sistema de traducción de lecciones
- Contenido multi-idioma por lección
4. **Mejoras de accesibilidad**
- Aumentar contraste
- Texto de mayor tamaño
- Navegación por teclado
5. **Optimización de rendimiento**
- Lazy loading de traducciones
- Code splitting por idioma
---
## 📞 Referencias
- [Documentación de i18n](https://nextjs.org/docs/app/building-your-application/routing/internationalization)
- [Tailwind CSS Responsive Design](https://tailwindcss.com/docs/responsive-design)
- [MDN Internationalization](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Internationalization)
---
**Implementado por**: Equipo de Desarrollo OpenCCB
**Versión**: 1.0
**Última actualización**: 2026-03-20
+336
View File
@@ -0,0 +1,336 @@
# Guía de Responsividad - OpenCCB
## Principios Mobile-First
### Breakpoints (Tailwind CSS)
```css
/* Mobile: Default (0-639px) */
/* sm: 640px+ */
/* md: 768px+ */
/* lg: 1024px+ */
/* xl: 1280px+ */
/* 2xl: 1536px+ */
```
### Jerarquía de Diseño
1. **Mobile (< 640px)**: Diseño de una sola columna, menú hamburguesa
2. **Tablet (640px - 1024px)**: 2 columnas, navegación visible
3. **Desktop (1024px+)**: Layout completo, todas las características
---
## Componentes Responsivos
### Layout Principal
```tsx
// Mobile-first container
<div className="min-h-screen flex flex-col">
{/* Header responsivo */}
<header className="h-16 px-4 md:px-6">
{/* Logo y navegación */}
</header>
{/* Contenido principal */}
<main className="flex-1 px-4 md:px-6 py-4 md:py-8">
{children}
</main>
</div>
```
### Navegación
**Mobile:**
- Menú hamburguesa (ícono)
- Sidebar deslizante desde la derecha
- Overlay con backdrop blur
- Items de navegación en columna
**Desktop:**
- Navegación horizontal visible
- Dropdowns al hacer hover/click
- Items en fila con espaciado
### Tarjetas de Cursos
```tsx
// Grid responsivo
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
{courses.map(course => (
<CourseCard key={course.id} course={course} />
))}
</div>
```
### Tablas de Datos
**Mobile:**
- Scroll horizontal
- O tarjetas apiladas en lugar de tabla
**Desktop:**
- Tabla completa visible
```tsx
<div className="overflow-x-auto">
<table className="min-w-full">
{/* tabla */}
</table>
</div>
```
---
## Tipografía Fluida
```tsx
// Títulos responsivos
<h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold">
Título
</h1>
// Texto de párrafo
<p className="text-sm md:text-base lg:text-lg">
Contenido
</p>
// Texto pequeño
<span className="text-xs md:text-sm">
Metadata
</span>
```
---
## Espaciado Responsivo
```tsx
// Padding responsivo
<div className="px-4 md:px-6 lg:px-8 py-4 md:py-6 lg:py-8">
{/* contenido */}
</div>
// Gap responsivo
<div className="flex flex-col md:flex-row gap-4 md:gap-6 lg:gap-8">
{/* items */}
</div>
```
---
## Componentes Específicos
### AppHeader (Experience)
**Mobile (< 768px):**
- Logo compacto
- Íconos de notificación y tema
- Botón de menú hamburguesa
- Sidebar deslizante con:
- Navegación completa
- Selector de idioma
- Toggle de tema
- Perfil de usuario
- Botón de logout
**Desktop (≥ 768px):**
- Logo completo
- Navegación horizontal visible
- Íconos de notificación, idioma, tema
- Perfil de usuario visible
- Botón de logout
### Navbar (Studio)
**Mobile (< 768px):**
- Logo compacto
- Dropdowns colapsados
- Toggle de tema
- Selector de idioma
- Botón de menú hamburguesa
- Sidebar deslizante
**Desktop (≥ 768px):**
- Logo completo
- Dropdowns visibles
- Toggle de tema
- Selector de idioma
- Información de usuario visible
### Reproductor de Lecciones
**Mobile:**
- Video en ancho completo
- Controles simplificados
- Pestañas colapsadas (acordeón)
- Navegación entre lecciones (botones)
**Desktop:**
- Video con tamaño fijo
- Sidebar con navegación de lecciones
- Pestañas visibles
- Panel de transcripción/resumen
---
## Imágenes y Multimedia
```tsx
// Imágenes responsivas
<Image
src={src}
alt={alt}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover"
/>
// Video responsivo
<div className="aspect-video w-full">
<video controls className="w-full h-full">
{/* sources */}
</video>
</div>
```
---
## Accesibilidad
### Navegación por Teclado
- Todos los elementos interactivos deben ser focusables
- Orden de tab lógico
- Indicadores de focus visibles
### Screen Readers
- Labels descriptivos en botones e íconos
- `aria-label` en íconos sin texto
- `aria-expanded` en elementos colapsables
- `aria-modal` en diálogos
### Contraste
- Relación de contraste mínima 4.5:1
- Texto grande: 3:1 mínimo
---
## Pruebas de Responsividad
### Dispositivos de Prueba
**Chrome DevTools:**
- iPhone SE (375x667)
- iPhone 12 Pro (390x844)
- iPad Air (820x1180)
- iPad Pro (1024x1366)
- Desktop (1920x1080)
**Herramientas:**
- Chrome DevTools Device Mode
- Firefox Responsive Design Mode
- BrowserStack (dispositivos reales)
### Checklist de Pruebas
- [ ] Navegación funciona en mobile
- [ ] Menús desplegables accesibles
- [ ] Formularios usables en pantallas pequeñas
- [ ] Tablas con scroll horizontal o versión mobile
- [ ] Imágenes se escalan correctamente
- [ ] Texto legible sin zoom
- [ ] Botones táctiles (mínimo 44x44px)
- [ ] Sin overflow horizontal no intencional
- [ ] Layout no se rompe en tamaños extremos
---
## Patrones Comunes
### Mobile: Navegación Inferior
```tsx
<nav className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 border-t md:hidden">
<div className="flex justify-around items-center h-16">
{/* íconos de navegación */}
</div>
</nav>
```
### Desktop: Sidebar Fija
```tsx
<aside className="hidden md:block w-64 fixed left-0 top-16 bottom-0 overflow-y-auto">
{/* navegación lateral */}
</aside>
```
### Tablas Responsivas
```tsx
// Opción 1: Scroll horizontal
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
{/* tabla completa */}
</table>
</div>
// Opción 2: Tarjetas en mobile
<div className="block md:hidden">
{items.map(item => (
<Card key={item.id} item={item} />
))}
</div>
<div className="hidden md:block">
<table>
{/* tabla completa */}
</table>
</div>
```
---
## Rendimiento
### Lazy Loading
```tsx
// Imágenes
<Image
src={src}
alt={alt}
loading="lazy"
width={400}
height={300}
/>
// Componentes pesados
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <p>Cargando...</p>,
ssr: false,
})
```
### Code Splitting
```tsx
// Carga diferida por ruta
const CourseDetail = dynamic(() => import('@/components/CourseDetail'))
```
---
## Referencias
- [Tailwind CSS Responsive Design](https://tailwindcss.com/docs/responsive-design)
- [MDN Responsive Design](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Responsive_Design)
- [Web.dev Responsive Design](https://web.dev/responsive-web-design-basics/)
---
**Última actualización**: 2026-03-20
**Versión**: 1.0
@@ -0,0 +1,24 @@
-- Add language configuration to courses table
-- Allows setting course language to 'auto' (detect from user) or fixed language
ALTER TABLE courses
ADD COLUMN IF NOT EXISTS language_setting VARCHAR(20) DEFAULT 'auto'::VARCHAR,
ADD COLUMN IF NOT EXISTS fixed_language VARCHAR(5) DEFAULT NULL;
-- Add comment explaining the fields
COMMENT ON COLUMN courses.language_setting IS 'Language mode: "auto" (detect from user browser) or "fixed" (use fixed_language)';
COMMENT ON COLUMN courses.fixed_language IS 'Fixed language code (es, en, pt) when language_setting is "fixed". NULL when language_setting is "auto".';
-- Add check constraint for valid language codes
ALTER TABLE courses
ADD CONSTRAINT chk_language_setting CHECK (
language_setting IN ('auto', 'fixed')
);
ALTER TABLE courses
ADD CONSTRAINT chk_fixed_language CHECK (
fixed_language IS NULL OR fixed_language IN ('es', 'en', 'pt')
);
-- Create index for filtering courses by language
CREATE INDEX IF NOT EXISTS idx_courses_language ON courses(language_setting, fixed_language);
+142 -23
View File
@@ -924,7 +924,7 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(),
let full_text = transcription_result["text"].as_str().unwrap_or("");
if !full_text.is_empty() {
tracing::info!("Triggering AI summary for lesson {}", lesson_id);
if let Ok(summary) = generate_summary_with_ollama(full_text).await {
if let Ok((summary, input_tokens, output_tokens)) = generate_summary_with_ollama(full_text, lesson_id, &pool).await {
tracing::info!("Summary generated successfully for lesson {}", lesson_id);
let _ = sqlx::query("UPDATE lessons SET summary = $1 WHERE id = $2")
.bind(summary)
@@ -937,7 +937,7 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(),
Ok(())
}
async fn generate_summary_with_ollama(text: &str) -> Result<String, String> {
async fn generate_summary_with_ollama(text: &str, lesson_id: Uuid, pool: &PgPool) -> Result<(String, i32, i32), String> {
let base_url = get_ai_url("OLLAMA_URL", "http://localhost:11434");
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
let client = reqwest::Client::new();
@@ -977,7 +977,31 @@ async fn generate_summary_with_ollama(text: &str) -> Result<String, String> {
.trim()
.to_string();
Ok(summary)
// Calculate token usage
let input_tokens = count_tokens(&prompt);
let output_tokens = count_tokens(&summary);
// Log token usage (use a system user ID for background tasks)
let total_tokens = input_tokens + output_tokens;
let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
.bind(lesson_id) // Use lesson_id as placeholder for user
.bind(lesson_id) // Use lesson_id as placeholder for org
.bind(total_tokens)
.bind(input_tokens)
.bind(output_tokens)
.bind("/lessons/transcribe")
.bind(&model)
.bind("summary")
.bind(&json!({
"lesson_id": lesson_id,
"task": "auto-summary-from-transcription",
}))
.bind(&prompt)
.bind(&summary)
.execute(pool)
.await;
Ok((summary, input_tokens, output_tokens))
}
pub async fn get_lesson_vtt(
@@ -2020,6 +2044,30 @@ pub async fn generate_code_lab(
(StatusCode::INTERNAL_SERVER_ERROR, "AI returned invalid exercise JSON".into())
})?;
// Calculate and log token usage
let full_prompt = format!("{} - {}", system_prompt, "Genera el ejercicio de código ahora.");
let input_tokens = count_tokens(&system_prompt) + count_tokens("Genera el ejercicio de código ahora.");
let output_tokens = count_tokens(cleaned);
let total_tokens = input_tokens + output_tokens;
let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
.bind(_claims.sub)
.bind(org_ctx.id)
.bind(total_tokens)
.bind(input_tokens)
.bind(output_tokens)
.bind("/lessons/generate-code-lab")
.bind(&model)
.bind("code-lab-generation")
.bind(&json!({
"lesson_id": lesson_id,
"language": language,
}))
.bind(&full_prompt) // prompt
.bind(cleaned) // response
.execute(&pool)
.await;
Ok(Json(serde_json::json!({
"language": language,
"title": exercise["title"],
@@ -2074,15 +2122,16 @@ pub async fn generate_hotspots(
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let client = reqwest::Client::new();
let (url, auth_header, model) = if provider == "local" {
let (url, auth_header, model, is_ollama) = if provider == "local" {
let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llava:latest".to_string()); // Default to llava for vision
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llava:latest".to_string());
(format!("{}/v1/chat/completions", base_url), "".to_string(), model, true)
} else {
(
"https://api.openai.com/v1/chat/completions".to_string(),
format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()),
"gpt-4o".to_string(),
false,
)
};
@@ -2112,22 +2161,29 @@ pub async fn generate_hotspots(
headers.insert("Authorization", auth_header.parse().unwrap());
}
let mut request_body = json!({
"model": model,
"messages": [
{
"role": "user",
"content": [
{ "type": "text", "text": format!("{}\n\n{}", system_prompt, user_prompt) },
{ "type": "image_url", "image_url": { "url": image_url_data } }
]
}
],
"response_format": { "type": "json_object" },
"temperature": 0.2
});
// Ollama requires stream: false for non-streaming responses
if is_ollama {
request_body["stream"] = json!(false);
}
let response = client.post(&url)
.headers(headers)
.json(&json!({
"model": model,
"messages": [
{
"role": "user",
"content": [
{ "type": "text", "text": format!("{}\n\n{}", system_prompt, user_prompt) },
{ "type": "image_url", "image_url": { "url": image_url_data } }
]
}
],
"response_format": { "type": "json_object" },
"temperature": 0.2
}))
.json(&request_body)
.send()
.await
.map_err(|e| {
@@ -2136,34 +2192,74 @@ pub async fn generate_hotspots(
})?;
let ai_text = response.text().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Parse the raw response
let ai_json: serde_json::Value = serde_json::from_str(&ai_text).map_err(|e| {
tracing::error!("Failed to parse AI response: {}. Text: {}", e, ai_text);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// OpenAI and some local servers return { "choices": [ { "message": { "content": "..." } } ] }
// Extract the content from the response
// OpenAI format: { "choices": [ { "message": { "content": "..." } } ] }
// Ollama format (v1 API): same as OpenAI
let content = ai_json["choices"][0]["message"]["content"].as_str()
.or_else(|| ai_json["message"]["content"].as_str()) // Fallback for direct Ollama format
.ok_or_else(|| {
tracing::error!("Unexpected AI response format: {:?}", ai_json);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Attempt to parse the content as JSON (it should be an array)
let hotspots: serde_json::Value = if let Ok(parsed) = serde_json::from_str(content) {
let mut hotspots: serde_json::Value = if let Ok(parsed) = serde_json::from_str(content) {
parsed
} else {
// Fallback: try to find the array in the text if AI wrapped it in markdown or something
if let Some(start) = content.find('[') {
if let Some(end) = content.rfind(']') {
serde_json::from_str(&content[start..=end]).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
serde_json::from_str(&content[start..=end]).map_err(|e| {
tracing::error!("Failed to parse hotspots array: {}. Content: {}", e, content);
StatusCode::INTERNAL_SERVER_ERROR
})?
} else {
tracing::error!("No JSON array found in AI response: {}", content);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
} else {
tracing::error!("AI response doesn't contain a JSON array: {}", content);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
// Handle case where AI returns an object with hotspots array inside
// e.g., { "hotspots": [...] } or { "items": [...] }
if !hotspots.is_array() && hotspots.is_object() {
if let Some(obj) = hotspots.as_object() {
// Try common keys where the array might be stored
for key in ["hotspots", "items", "data", "results", "points"] {
if let Some(val) = obj.get(key) {
if val.is_array() {
hotspots = val.clone();
tracing::info!("Extracted hotspots array from '{}'", key);
break;
}
}
}
}
}
// Handle case where AI returns a single object instead of an array
// e.g., { "label": "...", "x": 50, "y": 50 } instead of [{ "label": "...", "x": 50, "y": 50 }]
if !hotspots.is_array() && hotspots.is_object() {
tracing::info!("AI returned a single object, wrapping in array");
hotspots = serde_json::Value::Array(vec![hotspots]);
}
// Ensure the result is an array
if !hotspots.is_array() {
tracing::error!("AI response is not an array: {:?}", hotspots);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
// Calculate and log token usage
let full_prompt = format!("{} - {}", system_prompt, user_prompt);
let input_tokens = count_tokens(&full_prompt) + 500; // Estimate for image tokens
@@ -2293,6 +2389,29 @@ pub async fn generate_role_play(
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Calculate and log token usage
let full_prompt = format!("{} - {}", system_prompt, user_prompt);
let input_tokens = count_tokens(&system_prompt) + count_tokens(&user_prompt);
let output_tokens = count_tokens(content);
let total_tokens = input_tokens + output_tokens;
let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
.bind(_claims.sub)
.bind(org_ctx.id)
.bind(total_tokens)
.bind(input_tokens)
.bind(output_tokens)
.bind("/lessons/generate-role-play")
.bind(&model)
.bind("role-play-generation")
.bind(&json!({
"lesson_id": lesson_id,
}))
.bind(&full_prompt) // prompt
.bind(content) // response
.execute(&pool)
.await;
Ok(Json(parsed_json))
}
+11 -1
View File
@@ -96,8 +96,18 @@ async fn main() {
}
});
// CORS configuration - Allow multiple origins for development and production
let cors = CorsLayer::new()
.allow_origin("http://localhost:3000".parse::<http::HeaderValue>().unwrap())
.allow_origin([
"http://localhost:3000".parse::<http::HeaderValue>().unwrap(),
"http://localhost:3003".parse::<http::HeaderValue>().unwrap(),
"http://127.0.0.1:3000".parse::<http::HeaderValue>().unwrap(),
"http://127.0.0.1:3003".parse::<http::HeaderValue>().unwrap(),
"http://192.168.0.254:3000".parse::<http::HeaderValue>().unwrap(),
"http://192.168.0.254:3003".parse::<http::HeaderValue>().unwrap(),
// Allow any origin for development (remove in production)
"http://192.168.0.254".parse::<http::HeaderValue>().unwrap(),
])
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH])
.allow_headers([
header::CONTENT_TYPE,
@@ -0,0 +1,67 @@
-- AI Usage Tracking: Add log_ai_usage function
-- Required for chat with tutor and other AI features
-- Create ai_usage_logs table if not exists
CREATE TABLE IF NOT EXISTS ai_usage_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
organization_id UUID NOT NULL,
tokens_used INTEGER NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
endpoint VARCHAR(255) NOT NULL,
model VARCHAR(100) NOT NULL,
request_type VARCHAR(50) NOT NULL,
request_metadata JSONB,
estimated_cost_usd NUMERIC(10, 6),
created_at TIMESTAMPTZ DEFAULT NOW(),
prompt TEXT,
response TEXT
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_user_id ON ai_usage_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_org_id ON ai_usage_logs(organization_id);
CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_created_at ON ai_usage_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_endpoint ON ai_usage_logs(endpoint);
-- Create log_ai_usage function
CREATE OR REPLACE FUNCTION log_ai_usage(
p_user_id UUID,
p_org_id UUID,
p_tokens INTEGER,
p_input_tokens INTEGER,
p_output_tokens INTEGER,
p_endpoint VARCHAR,
p_model VARCHAR,
p_request_type VARCHAR,
p_metadata JSONB,
p_prompt TEXT DEFAULT NULL,
p_response TEXT DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_log_id UUID;
v_cost NUMERIC(10, 6);
BEGIN
-- Calculate estimated cost (OpenAI-like pricing)
v_cost := (p_input_tokens::NUMERIC * 0.000001) + (p_output_tokens::NUMERIC * 0.000003);
INSERT INTO ai_usage_logs (
user_id, organization_id, tokens_used, input_tokens, output_tokens,
endpoint, model, request_type, request_metadata, estimated_cost_usd,
prompt, response
)
VALUES (
p_user_id, p_org_id, p_tokens, p_input_tokens, p_output_tokens,
p_endpoint, p_model, p_request_type, p_metadata, v_cost,
p_prompt, p_response
)
RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON COLUMN ai_usage_logs.prompt IS 'The actual prompt sent to the AI model';
COMMENT ON COLUMN ai_usage_logs.response IS 'The AI model response content';
@@ -0,0 +1,24 @@
-- Add language configuration to courses table (LMS side)
-- This mirrors the CMS migration for cross-service compatibility
ALTER TABLE courses
ADD COLUMN IF NOT EXISTS language_setting VARCHAR(20) DEFAULT 'auto',
ADD COLUMN IF NOT EXISTS fixed_language VARCHAR(5) DEFAULT NULL;
-- Add comment explaining the fields
COMMENT ON COLUMN courses.language_setting IS 'Language mode: auto (detect from user browser) or fixed (use fixed_language)';
COMMENT ON COLUMN courses.fixed_language IS 'Fixed language code (es, en, pt) when language_setting is fixed. NULL when language_setting is auto.';
-- Add check constraints (only if they don't exist)
DO $$ BEGIN
ALTER TABLE courses ADD CONSTRAINT chk_language_setting CHECK (language_setting IN ('auto', 'fixed'));
EXCEPTION WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
ALTER TABLE courses ADD CONSTRAINT chk_fixed_language CHECK (fixed_language IS NULL OR fixed_language IN ('es', 'en', 'pt'));
EXCEPTION WHEN duplicate_object THEN null;
END $$;
-- Create index for filtering courses by language
CREATE INDEX IF NOT EXISTS idx_courses_language ON courses(language_setting, fixed_language);
+43 -9
View File
@@ -48,6 +48,39 @@ pub async fn get_me(
language: user.language,
}))
}
/// Get course language configuration
/// Returns whether the course uses auto-detection or fixed language
pub async fn get_course_language_config(
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, StatusCode> {
#[derive(sqlx::FromRow)]
struct CourseLanguageConfig {
language_setting: String,
fixed_language: Option<String>,
}
let config = sqlx::query_as::<_, CourseLanguageConfig>(
r#"SELECT language_setting, fixed_language FROM courses WHERE id = $1"#
)
.bind(course_id)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!("Error fetching course language config: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
if let Some(cfg) = config {
Ok(Json(serde_json::json!({
"language_setting": cfg.language_setting,
"fixed_language": cfg.fixed_language
})))
} else {
Err(StatusCode::NOT_FOUND)
}
}
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
use std::env;
@@ -764,8 +797,8 @@ pub async fn ingest_course(
for lesson in &pub_module.lessons {
sqlx::query(
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status, is_previewable)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)"
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status, is_previewable, content_blocks)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)"
)
.bind(lesson.id)
.bind(pub_module.module.id)
@@ -786,6 +819,7 @@ pub async fn ingest_course(
.bind(&lesson.important_date_type)
.bind(&lesson.transcription_status)
.bind(lesson.is_previewable)
.bind(&lesson.content_blocks)
.execute(&mut *tx)
.await
.map_err(|e: sqlx::Error| {
@@ -1994,7 +2028,7 @@ pub async fn get_recommendations(
let (url, auth_header, model) = if provider == "local" {
let base_url = get_ai_url("OLLAMA_URL", "http://ollama:11434");
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
@@ -2076,7 +2110,7 @@ pub async fn evaluate_audio_response(
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let (url, auth_header, model) = if provider == "local" {
let base_url = get_ai_url("OLLAMA_URL", "http://ollama:11434");
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
@@ -2249,7 +2283,7 @@ pub async fn evaluate_audio_file(
let (url, auth_header, model) = if provider == "local" {
let base_url =
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
@@ -2559,7 +2593,7 @@ pub async fn chat_with_tutor(
// 2. Setup AI request
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let client = reqwest::Client::new();
let _client = reqwest::Client::new();
// 2.1 Handle Session and Memory
let session_id = if let Some(sid) = payload.session_id {
@@ -2710,7 +2744,7 @@ pub async fn chat_with_tutor(
let (url, auth_header, model) = if provider == "local" {
let base_url =
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
@@ -2934,7 +2968,7 @@ pub async fn chat_role_play(
let (url, auth_header, model) = if provider == "local" {
let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
} else {
("https://api.openai.com/v1/chat/completions".to_string(),
@@ -3046,7 +3080,7 @@ pub async fn get_lesson_feedback(
let (url, auth_header, model) = if provider == "local" {
let base_url =
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
@@ -8,7 +8,6 @@ use axum::{
};
use common::ai::{self, generate_embedding};
use common::middleware::Org;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
@@ -76,7 +75,7 @@ pub async fn generate_knowledge_embeddings(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total = entries.len();
let _total = entries.len();
let mut processed = 0;
let mut failed = 0;
@@ -234,12 +233,12 @@ pub async fn semantic_search_knowledge(
let mut param_idx = 3;
if let Some(course_id) = filters.course_id {
if let Some(_course_id) = filters.course_id {
param_idx += 1;
query.push_str(&format!(" AND course_id = ${}", param_idx));
}
if let Some(lesson_id) = filters.lesson_id {
if let Some(_lesson_id) = filters.lesson_id {
param_idx += 1;
query.push_str(&format!(" AND lesson_id = ${}", param_idx));
}
+32 -18
View File
@@ -19,17 +19,15 @@ use axum::{
Router, middleware,
routing::{delete, get, post, put},
response::Html,
http::{Method, header},
};
use common::health::{self, HealthState};
use dotenvy::dotenv;
use sqlx::postgres::PgPoolOptions;
use std::env;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tower_governor::governor::GovernorConfigBuilder;
use tower_governor::GovernorLayer;
use tower_http::cors::{Any, CorsLayer};
use tower_http::cors::CorsLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use utoipa::OpenApi;
@@ -67,22 +65,41 @@ async fn main() {
}
});
// CORS configuration - Allow multiple origins for development and production
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
.allow_origin([
"http://localhost:3000".parse::<http::HeaderValue>().unwrap(),
"http://localhost:3003".parse::<http::HeaderValue>().unwrap(),
"http://127.0.0.1:3000".parse::<http::HeaderValue>().unwrap(),
"http://127.0.0.1:3003".parse::<http::HeaderValue>().unwrap(),
"http://192.168.0.254:3000".parse::<http::HeaderValue>().unwrap(),
"http://192.168.0.254:3003".parse::<http::HeaderValue>().unwrap(),
// Allow any origin for development (remove in production)
"http://192.168.0.254".parse::<http::HeaderValue>().unwrap(),
])
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH])
.allow_headers([
header::CONTENT_TYPE,
header::AUTHORIZATION,
header::HeaderName::from_static("x-requested-with"),
header::HeaderName::from_static("x-organization-id"),
])
.expose_headers([header::CONTENT_LENGTH, header::CONTENT_TYPE]);
// Rate limiting configuration
let governor_conf = Arc::new(
GovernorConfigBuilder::default()
.per_second(10)
.burst_size(50)
.finish()
.unwrap(),
);
// Rate limiter DESHABILITADO debido a problemas de compatibilidad con el middleware de autenticación
// Ver QWEN.md para más detalles
// let governor_conf = Arc::new(
// GovernorConfigBuilder::default()
// .per_second(10)
// .burst_size(50)
// .finish()
// .unwrap(),
// );
// Rate limiter solo para rutas protegidas (después del middleware de autenticación)
let protected_routes = Router::new()
.route("/auth/me", get(handlers::get_me))
.route("/courses/{id}/language-config", get(handlers::get_course_language_config))
.route("/enroll", post(handlers::enroll_user))
.route("/bulk-enroll", post(handlers::bulk_enroll_users))
.route("/enrollments/{id}", get(handlers::get_user_enrollments))
@@ -321,9 +338,6 @@ async fn main() {
http::HeaderValue::from_static("strict-origin-when-cross-origin"),
))
.layer(cors)
.layer(GovernorLayer {
config: governor_conf,
})
.with_state(pool)
.layer(axum::Extension(mysql_pool));
+76
View File
@@ -13,6 +13,59 @@ pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
/// Embedding dimensions for nomic-embed-text
pub const EMBEDDING_DIMENSIONS: usize = 768;
/// Model selection for different use cases
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelType {
/// Fast conversational AI (chat, tutor, Q&A)
Chat,
/// Complex reasoning (analysis, recommendations, feedback)
Complex,
/// Advanced tasks (course generation, detailed analysis)
Advanced,
/// Embedding generation
Embedding,
}
impl ModelType {
/// Get the model name for this type from environment
pub fn get_model(&self) -> String {
match self {
ModelType::Chat => {
std::env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string())
}
ModelType::Complex => {
std::env::var("LOCAL_LLM_MODEL_COMPLEX").unwrap_or_else(|_| "qwen3.5:9b".to_string())
}
ModelType::Advanced => {
std::env::var("LOCAL_LLM_MODEL_ADVANCED").unwrap_or_else(|_| "gpt-oss:latest".to_string())
}
ModelType::Embedding => {
std::env::var("EMBEDDING_MODEL").unwrap_or_else(|_| "nomic-embed-text".to_string())
}
}
}
/// Get recommended temperature for this model type
pub fn get_temperature(&self) -> f32 {
match self {
ModelType::Chat => 0.7, // Balanced creativity/accuracy
ModelType::Complex => 0.5, // More focused reasoning
ModelType::Advanced => 0.6, // Balanced for analysis
ModelType::Embedding => 0.0, // Deterministic for embeddings
}
}
/// Get max tokens for this model type
pub fn get_max_tokens(&self) -> u32 {
match self {
ModelType::Chat => 1024,
ModelType::Complex => 2048,
ModelType::Advanced => 4096,
ModelType::Embedding => 0, // Not applicable
}
}
}
#[derive(Error, Debug)]
pub enum AiError {
#[error("Ollama request failed: {0}")]
@@ -40,6 +93,29 @@ pub fn get_embedding_model() -> String {
std::env::var("EMBEDDING_MODEL").unwrap_or_else(|_| DEFAULT_EMBEDDING_MODEL.to_string())
}
/// Get the best model for a specific task
pub fn get_model_for_task(task: &str) -> String {
// Task-based model selection
match task.to_lowercase().as_str() {
t if t.contains("chat") || t.contains("tutor") || t.contains("conversation") => {
ModelType::Chat.get_model()
}
t if t.contains("quiz") || t.contains("question") || t.contains("assessment") => {
ModelType::Complex.get_model()
}
t if t.contains("course") || t.contains("curriculum") || t.contains("syllabus") => {
ModelType::Advanced.get_model()
}
t if t.contains("feedback") || t.contains("recommendation") || t.contains("analysis") => {
ModelType::Complex.get_model()
}
t if t.contains("transcript") || t.contains("summary") => {
ModelType::Chat.get_model()
}
_ => ModelType::Chat.get_model(),
}
}
/// Create a reqwest client that accepts invalid certificates (for dev with self-signed certs)
fn create_insecure_client() -> Result<reqwest::Client, AiError> {
reqwest::Client::builder()
@@ -66,32 +66,72 @@ export default function AudioResponsePlayer({
}, []);
const startRecording = async () => {
// Check if browser supports MediaRecorder
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('MediaRecorder API not supported');
alert('Your browser does not support audio recording. Please use Chrome, Firefox, or Edge.');
return;
}
// Check if page is served over HTTPS or localhost
const isSecureContext = window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost';
if (!isSecureContext) {
console.error('getUserMedia requires secure context (HTTPS or localhost)');
alert('Audio recording requires HTTPS. Please access the site via HTTPS or localhost.');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
console.log('[AudioResponse] Requesting microphone access...');
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 44100
}
});
console.log('[AudioResponse] Microphone access granted');
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'
});
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
console.log('[AudioResponse] Data available, chunk size:', event.data.size);
}
};
mediaRecorder.onstop = () => {
console.log('[AudioResponse] Recording stopped, creating blob...');
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
setAudioBlob(audioBlob);
console.log('[AudioResponse] Blob created, size:', audioBlob.size, 'bytes');
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.onerror = (event) => {
console.error('[AudioResponse] MediaRecorder error:', event);
alert('Recording error occurred. Please try again.');
};
mediaRecorder.start();
setIsRecording(true);
setRecordingTime(0);
console.log('[AudioResponse] Recording started');
// Start speech recognition
if (recognitionRef.current) {
setTranscript("");
recognitionRef.current.start();
try {
recognitionRef.current.start();
console.log('[AudioResponse] Speech recognition started');
} catch (err) {
console.warn('[AudioResponse] Could not start speech recognition:', err);
}
}
// Start timer
@@ -99,14 +139,29 @@ export default function AudioResponsePlayer({
setRecordingTime(prev => {
const newTime = prev + 1;
if (timeLimit && newTime >= timeLimit) {
console.log('[AudioResponse] Time limit reached, stopping...');
stopRecording();
}
return newTime;
});
}, 1000);
} catch (error) {
console.error('Error accessing microphone:', error);
alert('Could not access microphone. Please check permissions.');
} catch (error: any) {
console.error('[AudioResponse] Error accessing microphone:', error);
let errorMessage = 'Could not access microphone. ';
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
errorMessage += 'Please allow microphone access and try again.';
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
errorMessage += 'No microphone found. Please connect a microphone and try again.';
} else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') {
errorMessage += 'Microphone is already in use by another application.';
} else if (error.name === 'OverconstrainedError') {
errorMessage += 'Microphone does not meet the required constraints.';
} else {
errorMessage += 'Please check permissions and try again.';
}
alert(errorMessage);
}
};
@@ -177,6 +232,31 @@ export default function AudioResponsePlayer({
return (
<div className="space-y-6" id={id}>
{/* Browser Compatibility Check */}
{typeof window !== 'undefined' && (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) && (
<div className="p-6 bg-red-500/10 border-2 border-red-500/20 rounded-2xl">
<div className="flex items-center gap-3 text-red-600 dark:text-red-400 mb-2">
<X className="w-6 h-6" />
<h3 className="text-lg font-bold">Browser Not Supported</h3>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300">
Your browser does not support audio recording. Please use Chrome, Firefox, Edge, or Safari.
</p>
</div>
)}
{!window.isSecureContext && window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && (
<div className="p-6 bg-yellow-500/10 border-2 border-yellow-500/20 rounded-2xl">
<div className="flex items-center gap-3 text-yellow-600 dark:text-yellow-400 mb-2">
<BrainCircuit className="w-6 h-6" />
<h3 className="text-lg font-bold">HTTPS Required</h3>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300">
Audio recording requires a secure connection (HTTPS). Please access this site via HTTPS or localhost.
</p>
</div>
)}
<div className="p-8 glass border-black/5 dark:border-white/5 rounded-3xl space-y-6 bg-black/[0.02] dark:bg-black/20">
<div className="flex items-start gap-4">
<div className="p-3 bg-purple-600/10 dark:bg-purple-500/20 rounded-xl">
+51 -5
View File
@@ -7,27 +7,71 @@ import pt from '../lib/locales/pt.json';
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';
// Obtener idiomas del navegador (puede ser un array)
const browserLanguages = navigator.languages || [navigator.language || (navigator as any).userLanguage];
// Buscar el primer idioma soportado
for (const browserLang of browserLanguages) {
// Idioma completo (ej: 'en-US')
if (SUPPORTED_LANGUAGES.includes(browserLang as SupportedLanguage)) {
return browserLang;
}
// Solo código de idioma (ej: 'en' de 'en-US')
const langCode = browserLang.split('-')[0].toLowerCase();
if (SUPPORTED_LANGUAGES.includes(langCode as SupportedLanguage)) {
return langCode;
}
}
// Fallback a español
return 'es';
}
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguageState] = useState('es'); // Default to Spanish for Experience as it's more localized by default
const [language, setLanguageState] = useState('es');
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
// 1. Primero intentar cargar de localStorage
const savedLang = localStorage.getItem('experience_language');
if (savedLang) {
if (savedLang && SUPPORTED_LANGUAGES.includes(savedLang as SupportedLanguage)) {
setLanguageState(savedLang);
} else {
// 2. Si no hay guardado, detectar del navegador
const detectedLang = detectBrowserLanguage();
setLanguageState(detectedLang);
localStorage.setItem('experience_language', detectedLang);
}
setIsInitialized(true);
}, []);
const setLanguage = (lang: string) => {
setLanguageState(lang);
localStorage.setItem('experience_language', lang);
if (SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage)) {
setLanguageState(lang);
localStorage.setItem('experience_language', lang);
}
};
const t = (path: string): string => {
@@ -46,7 +90,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>
);
@@ -59,3 +103,5 @@ export function useTranslation() {
}
return context;
}
export { detectBrowserLanguage };
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from '@/context/I18nContext';
interface CourseLanguageConfig {
language_setting: 'auto' | 'fixed';
fixed_language: string | null;
}
/**
* Hook para manejar el idioma específico de un curso
*
* - Si el curso está en modo 'auto', usa el idioma del usuario
* - Si el curso está en modo 'fixed', usa el idioma fijo del curso
*
* @param courseId - ID del curso actual
* @returns El idioma que debe usarse para este curso
*/
export function useCourseLanguage(courseId: string | null) {
const { language: userLanguage } = useTranslation();
const [courseLanguage, setCourseLanguage] = useState<string>(userLanguage);
const [isLoading, setIsLoading] = useState(true);
const [config, setConfig] = useState<CourseLanguageConfig | null>(null);
// Función para cargar la configuración de idioma del curso
const loadCourseLanguageConfig = useCallback(async () => {
if (!courseId) {
setCourseLanguage(userLanguage);
setIsLoading(false);
return;
}
try {
// Importar dinámicamente para evitar circular dependencies
const { lmsApi } = await import('@/lib/api');
const response = await lmsApi.get(`/courses/${courseId}/language-config`);
const data = response.data;
setConfig(data);
// Determinar qué idioma usar
if (data.language_setting === 'fixed' && data.fixed_language) {
setCourseLanguage(data.fixed_language);
} else {
// Modo 'auto' o sin configuración: usar idioma del usuario
setCourseLanguage(userLanguage);
}
} catch (error) {
console.error('Error loading course language config:', error);
// Fallback: usar idioma del usuario
setCourseLanguage(userLanguage);
} finally {
setIsLoading(false);
}
}, [courseId, userLanguage]);
// Cargar configuración cuando cambia el courseId
useEffect(() => {
loadCourseLanguageConfig();
}, [loadCourseLanguageConfig]);
// Función para verificar si el curso usa idioma fijo
const isFixedLanguage = useCallback(() => {
return config?.language_setting === 'fixed';
}, [config]);
// Función para obtener el idioma actual (curso o usuario)
const getCurrentLanguage = useCallback(() => {
return courseLanguage;
}, [courseLanguage]);
return {
courseLanguage,
isFixedLanguage,
getCurrentLanguage,
isLoading,
refreshConfig: loadCourseLanguageConfig,
};
}
/**
* Hook para cambiar el idioma del usuario (solo funciona si el curso está en modo 'auto')
*
* @param courseId - ID del curso actual
*/
export function useCourseLanguageSwitcher(courseId: string | null) {
const { setLanguage, language } = useTranslation();
const { isFixedLanguage } = useCourseLanguage(courseId);
const canChangeLanguage = !isFixedLanguage();
const changeLanguage = (newLanguage: string) => {
if (canChangeLanguage) {
setLanguage(newLanguage);
} else {
console.warn('Cannot change language: course has fixed language setting');
}
};
return {
currentLanguage: language,
canChangeLanguage,
changeLanguage,
isFixed: isFixedLanguage(),
};
}
+204 -5
View File
@@ -5,14 +5,31 @@
"next": "Next",
"finish": "Finish",
"error": "Error",
"retry": "Retry"
"retry": "Retry",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"confirm": "Confirm",
"success": "Success",
"warning": "Warning",
"info": "Info",
"search": "Search",
"noResults": "No results",
"viewAll": "View all",
"learnMore": "Learn more"
},
"nav": {
"catalog": "Catalog",
"myLearning": "My Learning",
"bookmarks": "Bookmarks",
"profile": "Profile",
"signOut": "Sign Out"
"signOut": "Sign Out",
"selectLanguage": "Select Language",
"menu": "Menu",
"notifications": "Notifications",
"settings": "Settings"
},
"course": {
"modules": "Modules",
@@ -20,12 +37,194 @@
"progress": "Progress",
"calendar": "Timeline",
"start": "Start",
"continue": "Continue"
"continue": "Continue",
"enroll": "Enroll",
"enrolled": "Enrolled",
"price": "Price",
"free": "Free",
"duration": "Duration",
"level": "Level",
"description": "Description",
"objectives": "Objectives",
"requirements": "Requirements",
"instructor": "Instructor",
"certificate": "Certificate",
"aboutCourse": "About Course",
"content": "Content",
"reviews": "Reviews",
"faq": "FAQ"
},
"lesson": {
"summary": "AI Summary",
"transcription": "Transcription",
"complete": "Mark as Completed",
"nextLesson": "Next Lesson"
"nextLesson": "Next Lesson",
"previousLesson": "Previous Lesson",
"locked": "Locked",
"lockedMessage": "Complete previous lessons to unlock",
"markComplete": "Mark as Completed",
"inProgress": "In Progress",
"completed": "Completed",
"notStarted": "Not Started",
"dueDate": "Due Date",
"timeRemaining": "Time Remaining",
"overdue": "Overdue"
},
"auth": {
"login": "Login",
"register": "Register",
"email": "Email",
"password": "Password",
"fullName": "Full Name",
"forgotPassword": "Forgot Password?",
"resetPassword": "Reset Password",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"signIn": "Sign In",
"signUp": "Sign Up",
"orContinueWith": "Or continue with",
"google": "Google",
"microsoft": "Microsoft",
"sso": "Single Sign-On"
},
"dashboard": {
"welcome": "Welcome",
"continueLearning": "Continue Learning",
"recommendedCourses": "Recommended Courses",
"recentActivity": "Recent Activity",
"myProgress": "My Progress",
"certificates": "Certificates",
"badges": "Badges",
"stats": {
"coursesCompleted": "Courses Completed",
"hoursLearned": "Hours Learned",
"averageGrade": "Average Grade",
"currentStreak": "Current Streak"
}
},
"profile": {
"myProfile": "My Profile",
"editProfile": "Edit Profile",
"avatar": "Avatar",
"bio": "Bio",
"language": "Language",
"timezone": "Timezone",
"notifications": "Notifications",
"privacy": "Privacy",
"account": "Account",
"changePassword": "Change Password",
"deleteAccount": "Delete Account",
"portfolio": "Portfolio",
"achievements": "Achievements",
"publicProfile": "Public Profile"
},
"gamification": {
"level": "Level",
"xp": "Experience Points",
"leaderboard": "Leaderboard",
"rank": "Rank",
"points": "Points",
"badge": "Badge",
"badges": "Badges",
"earned": "Earned",
"locked": "Locked",
"nextLevel": "Next Level",
"xpToNextLevel": "XP to next level"
},
"grading": {
"grade": "Grade",
"grades": "Grades",
"score": "Score",
"percentage": "Percentage",
"passingGrade": "Passing Grade",
"failed": "Failed",
"passed": "Passed",
"excellent": "Excellent",
"good": "Good",
"regular": "Regular",
"poor": "Poor",
"feedback": "Feedback",
"submissionDate": "Submission Date",
"gradedDate": "Graded Date"
},
"quiz": {
"startQuiz": "Start Quiz",
"submitAnswers": "Submit Answers",
"questionNumber": "Question",
"of": "of",
"correctAnswer": "Correct Answer",
"yourAnswer": "Your Answer",
"explanation": "Explanation",
"tryAgain": "Try Again",
"attempts": "Attempts",
"attemptsRemaining": "attempts remaining",
"timeLimit": "Time Limit",
"timeExpired": "Time Expired"
},
"forum": {
"discussions": "Discussions",
"newThread": "New Discussion",
"title": "Title",
"content": "Content",
"post": "Post",
"reply": "Reply",
"replies": "Replies",
"views": "Views",
"lastActivity": "Last Activity",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"markAsSolved": "Mark as Solved",
"upvote": "Upvote",
"downvote": "Downvote"
},
"payments": {
"purchase": "Purchase",
"buyNow": "Buy Now",
"paymentMethod": "Payment Method",
"cardNumber": "Card Number",
"expiryDate": "Expiry Date",
"cvv": "CVV",
"cardholderName": "Cardholder Name",
"paymentSuccess": "Payment Success",
"paymentFailed": "Payment Failed",
"refund": "Refund",
"invoice": "Invoice",
"receipt": "Receipt"
},
"accessibility": {
"skipToContent": "Skip to content",
"increaseContrast": "Increase Contrast",
"decreaseContrast": "Decrease Contrast",
"largerText": "Larger Text",
"smallerText": "Smaller Text",
"screenReader": "Screen Reader"
},
"language": {
"selectLanguage": "Select Language",
"auto": "Automatic",
"spanish": "Spanish",
"english": "English",
"portuguese": "Portuguese",
"courseLanguage": "Course Language",
"interfaceLanguage": "Interface Language"
},
"errors": {
"notFound": "Not found",
"unauthorized": "Unauthorized",
"serverError": "Server error",
"networkError": "Network error",
"sessionExpired": "Session expired",
"invalidCredentials": "Invalid credentials",
"emailInUse": "Email already in use",
"weakPassword": "Weak password"
},
"dates": {
"today": "Today",
"yesterday": "Yesterday",
"tomorrow": "Tomorrow",
"daysAgo": "{{days}} days ago",
"weeksAgo": "{{weeks}} weeks ago",
"monthsAgo": "{{months}} months ago",
"yearsAgo": "{{years}} years ago"
}
}
}
+204 -5
View File
@@ -5,14 +5,31 @@
"next": "Siguiente",
"finish": "Finalizar",
"error": "Error",
"retry": "Reintentar"
"retry": "Reintentar",
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"close": "Cerrar",
"confirm": "Confirmar",
"success": "Éxito",
"warning": "Advertencia",
"info": "Información",
"search": "Buscar",
"noResults": "Sin resultados",
"viewAll": "Ver todo",
"learnMore": "Más información"
},
"nav": {
"catalog": "Catálogo",
"myLearning": "Mi Aprendizaje",
"bookmarks": "Marcadores",
"profile": "Perfil",
"signOut": "Cerrar Sesión"
"signOut": "Cerrar Sesión",
"selectLanguage": "Seleccionar Idioma",
"menu": "Menú",
"notifications": "Notificaciones",
"settings": "Configuración"
},
"course": {
"modules": "Módulos",
@@ -20,12 +37,194 @@
"progress": "Progreso",
"calendar": "Cronología",
"start": "Comenzar",
"continue": "Continuar"
"continue": "Continuar",
"enroll": "Inscribirse",
"enrolled": "Inscrito",
"price": "Precio",
"free": "Gratis",
"duration": "Duración",
"level": "Nivel",
"description": "Descripción",
"objectives": "Objetivos",
"requirements": "Requisitos",
"instructor": "Instructor",
"certificate": "Certificado",
"aboutCourse": "Acerca del Curso",
"content": "Contenido",
"reviews": "Reseñas",
"faq": "Preguntas Frecuentes"
},
"lesson": {
"summary": "Resumen AI",
"transcription": "Transcripción",
"complete": "Marcar como Completado",
"nextLesson": "Siguiente Lección"
"nextLesson": "Siguiente Lección",
"previousLesson": "Lección Anterior",
"locked": "Bloqueado",
"lockedMessage": "Completa las lecciones anteriores para desbloquear",
"markComplete": "Marcar como Completada",
"inProgress": "En Progreso",
"completed": "Completada",
"notStarted": "No Iniciada",
"dueDate": "Fecha Límite",
"timeRemaining": "Tiempo Restante",
"overdue": "Vencido"
},
"auth": {
"login": "Iniciar Sesión",
"register": "Registrarse",
"email": "Correo Electrónico",
"password": "Contraseña",
"fullName": "Nombre Completo",
"forgotPassword": "¿Olvidaste tu contraseña?",
"resetPassword": "Restablecer Contraseña",
"noAccount": "¿No tienes cuenta?",
"hasAccount": "¿Ya tienes cuenta?",
"signIn": "Ingresar",
"signUp": "Crear Cuenta",
"orContinueWith": "O continúa con",
"google": "Google",
"microsoft": "Microsoft",
"sso": "Inicio de Sesión Único"
},
"dashboard": {
"welcome": "Bienvenido",
"continueLearning": "Continuar Aprendiendo",
"recommendedCourses": "Cursos Recomendados",
"recentActivity": "Actividad Reciente",
"myProgress": "Mi Progreso",
"certificates": "Certificados",
"badges": "Insignias",
"stats": {
"coursesCompleted": "Cursos Completados",
"hoursLearned": "Horas Aprendidas",
"averageGrade": "Nota Promedio",
"currentStreak": "Racha Actual"
}
},
"profile": {
"myProfile": "Mi Perfil",
"editProfile": "Editar Perfil",
"avatar": "Avatar",
"bio": "Biografía",
"language": "Idioma",
"timezone": "Zona Horaria",
"notifications": "Notificaciones",
"privacy": "Privacidad",
"account": "Cuenta",
"changePassword": "Cambiar Contraseña",
"deleteAccount": "Eliminar Cuenta",
"portfolio": "Portafolio",
"achievements": "Logros",
"publicProfile": "Perfil Público"
},
"gamification": {
"level": "Nivel",
"xp": "Puntos de Experiencia",
"leaderboard": "Tabla de Clasificación",
"rank": "Rango",
"points": "Puntos",
"badge": "Insignia",
"badges": "Insignias",
"earned": "Obtenida",
"locked": "Bloqueada",
"nextLevel": "Siguiente Nivel",
"xpToNextLevel": "XP para el siguiente nivel"
},
"grading": {
"grade": "Nota",
"grades": "Notas",
"score": "Puntaje",
"percentage": "Porcentaje",
"passingGrade": "Nota de Aprobación",
"failed": "Reprobado",
"passed": "Aprobado",
"excellent": "Excelente",
"good": "Bueno",
"regular": "Regular",
"poor": "Deficiente",
"feedback": "Retroalimentación",
"submissionDate": "Fecha de Entrega",
"gradedDate": "Fecha de Calificación"
},
"quiz": {
"startQuiz": "Comenzar Cuestionario",
"submitAnswers": "Enviar Respuestas",
"questionNumber": "Pregunta",
"of": "de",
"correctAnswer": "Respuesta Correcta",
"yourAnswer": "Tu Respuesta",
"explanation": "Explicación",
"tryAgain": "Intentar de Nuevo",
"attempts": "Intentos",
"attemptsRemaining": "intentos restantes",
"timeLimit": "Límite de Tiempo",
"timeExpired": "Tiempo Expirado"
},
"forum": {
"discussions": "Discusiones",
"newThread": "Nueva Discusión",
"title": "Título",
"content": "Contenido",
"post": "Publicar",
"reply": "Responder",
"replies": "Respuestas",
"views": "Vistas",
"lastActivity": "Última Actividad",
"subscribe": "Suscribirse",
"unsubscribe": "Desuscribirse",
"markAsSolved": "Marcar como Resuelto",
"upvote": "Votar a Favor",
"downvote": "Votar en Contra"
},
"payments": {
"purchase": "Comprar",
"buyNow": "Comprar Ahora",
"paymentMethod": "Método de Pago",
"cardNumber": "Número de Tarjeta",
"expiryDate": "Fecha de Vencimiento",
"cvv": "CVV",
"cardholderName": "Nombre del Titular",
"paymentSuccess": "Pago Exitoso",
"paymentFailed": "Pago Fallido",
"refund": "Reembolso",
"invoice": "Factura",
"receipt": "Recibo"
},
"accessibility": {
"skipToContent": "Saltar al contenido",
"increaseContrast": "Aumentar Contraste",
"decreaseContrast": "Disminuir Contraste",
"largerText": "Texto Más Grande",
"smallerText": "Texto Más Pequeño",
"screenReader": "Lector de Pantalla"
},
"language": {
"selectLanguage": "Seleccionar Idioma",
"auto": "Automático",
"spanish": "Español",
"english": "Inglés",
"portuguese": "Portugués",
"courseLanguage": "Idioma del Curso",
"interfaceLanguage": "Idioma de la Interfaz"
},
"errors": {
"notFound": "No encontrado",
"unauthorized": "No autorizado",
"serverError": "Error del servidor",
"networkError": "Error de red",
"sessionExpired": "Sesión expirada",
"invalidCredentials": "Credenciales inválidas",
"emailInUse": "Correo ya en uso",
"weakPassword": "Contraseña débil"
},
"dates": {
"today": "Hoy",
"yesterday": "Ayer",
"tomorrow": "Mañana",
"daysAgo": "hace {{days}} días",
"weeksAgo": "hace {{weeks}} semanas",
"monthsAgo": "hace {{months}} meses",
"yearsAgo": "hace {{years}} años"
}
}
}
+208 -9
View File
@@ -3,29 +3,228 @@
"loading": "Carregando...",
"back": "Voltar",
"next": "Próximo",
"finish": "Finalizar",
"finish": "Concluir",
"error": "Erro",
"retry": "Repetir"
"retry": "Tentar Novamente",
"save": "Salvar",
"cancel": "Cancelar",
"delete": "Excluir",
"edit": "Editar",
"close": "Fechar",
"confirm": "Confirmar",
"success": "Sucesso",
"warning": "Aviso",
"info": "Informação",
"search": "Buscar",
"noResults": "Sem resultados",
"viewAll": "Ver tudo",
"learnMore": "Saiba mais"
},
"nav": {
"catalog": "Catálogo",
"myLearning": "Meu Aprendizado",
"bookmarks": "Favoritos",
"profile": "Perfil",
"signOut": "Sair"
"signOut": "Sair",
"selectLanguage": "Selecionar Idioma",
"menu": "Menu",
"notifications": "Notificações",
"settings": "Configurações"
},
"course": {
"modules": "Módulos",
"lessons": "Lições",
"lessons": "Aulas",
"progress": "Progresso",
"calendar": "Cronologia",
"calendar": "Cronograma",
"start": "Começar",
"continue": "Continuar"
"continue": "Continuar",
"enroll": "Inscrever-se",
"enrolled": "Inscrito",
"price": "Preço",
"free": "Gratuito",
"duration": "Duração",
"level": "Nível",
"description": "Descrição",
"objectives": "Objetivos",
"requirements": "Requisitos",
"instructor": "Instrutor",
"certificate": "Certificado",
"aboutCourse": "Sobre o Curso",
"content": "Conteúdo",
"reviews": "Avaliações",
"faq": "Perguntas Frequentes"
},
"lesson": {
"summary": "Resumo IA",
"transcription": "Transcrição",
"complete": "Marcar como Concluído",
"nextLesson": "Próxima Lição"
"complete": "Marcar como Concluída",
"nextLesson": "Próxima Aula",
"previousLesson": "Aula Anterior",
"locked": "Bloqueada",
"lockedMessage": "Complete as aulas anteriores para desbloquear",
"markComplete": "Marcar como Concluída",
"inProgress": "Em Progresso",
"completed": "Concluída",
"notStarted": "Não Iniciada",
"dueDate": "Data Limite",
"timeRemaining": "Tempo Restante",
"overdue": "Atrasada"
},
"auth": {
"login": "Entrar",
"register": "Cadastrar",
"email": "E-mail",
"password": "Senha",
"fullName": "Nome Completo",
"forgotPassword": "Esqueceu a senha?",
"resetPassword": "Redefinir Senha",
"noAccount": "Não tem uma conta?",
"hasAccount": "Já tem uma conta?",
"signIn": "Entrar",
"signUp": "Criar Conta",
"orContinueWith": "Ou continue com",
"google": "Google",
"microsoft": "Microsoft",
"sso": "Login Único"
},
"dashboard": {
"welcome": "Bem-vindo",
"continueLearning": "Continuar Aprendendo",
"recommendedCourses": "Cursos Recomendados",
"recentActivity": "Atividade Recente",
"myProgress": "Meu Progresso",
"certificates": "Certificados",
"badges": "Distintivos",
"stats": {
"coursesCompleted": "Cursos Concluídos",
"hoursLearned": "Horas Aprendidas",
"averageGrade": "Nota Média",
"currentStreak": "Sequência Atual"
}
},
"profile": {
"myProfile": "Meu Perfil",
"editProfile": "Editar Perfil",
"avatar": "Avatar",
"bio": "Biografia",
"language": "Idioma",
"timezone": "Fuso Horário",
"notifications": "Notificações",
"privacy": "Privacidade",
"account": "Conta",
"changePassword": "Alterar Senha",
"deleteAccount": "Excluir Conta",
"portfolio": "Portfólio",
"achievements": "Conquistas",
"publicProfile": "Perfil Público"
},
"gamification": {
"level": "Nível",
"xp": "Pontos de Experiência",
"leaderboard": "Classificação",
"rank": "Posição",
"points": "Pontos",
"badge": "Distintivo",
"badges": "Distintivos",
"earned": "Conquistado",
"locked": "Bloqueado",
"nextLevel": "Próximo Nível",
"xpToNextLevel": "XP para o próximo nível"
},
"grading": {
"grade": "Nota",
"grades": "Notas",
"score": "Pontuação",
"percentage": "Porcentagem",
"passingGrade": "Nota Mínima",
"failed": "Reprovado",
"passed": "Aprovado",
"excellent": "Excelente",
"good": "Bom",
"regular": "Regular",
"poor": "Insuficiente",
"feedback": "Feedback",
"submissionDate": "Data de Entrega",
"gradedDate": "Data de Avaliação"
},
"quiz": {
"startQuiz": "Iniciar Questionário",
"submitAnswers": "Enviar Respostas",
"questionNumber": "Pergunta",
"of": "de",
"correctAnswer": "Resposta Correta",
"yourAnswer": "Sua Resposta",
"explanation": "Explicação",
"tryAgain": "Tentar Novamente",
"attempts": "Tentativas",
"attemptsRemaining": "tentativas restantes",
"timeLimit": "Limite de Tempo",
"timeExpired": "Tempo Esgotado"
},
"forum": {
"discussions": "Discussões",
"newThread": "Nova Discussão",
"title": "Título",
"content": "Conteúdo",
"post": "Publicar",
"reply": "Responder",
"replies": "Respostas",
"views": "Visualizações",
"lastActivity": "Última Atividade",
"subscribe": "Inscrever-se",
"unsubscribe": "Cancelar Inscrição",
"markAsSolved": "Marcar como Resolvido",
"upvote": "Votar a Favor",
"downvote": "Votar Contra"
},
"payments": {
"purchase": "Comprar",
"buyNow": "Comprar Agora",
"paymentMethod": "Método de Pagamento",
"cardNumber": "Número do Cartão",
"expiryDate": "Data de Validade",
"cvv": "CVV",
"cardholderName": "Nome do Titular",
"paymentSuccess": "Pagamento Bem-Sucedido",
"paymentFailed": "Pagamento Falhou",
"refund": "Reembolso",
"invoice": "Fatura",
"receipt": "Recibo"
},
"accessibility": {
"skipToContent": "Pular para o conteúdo",
"increaseContrast": "Aumentar Contraste",
"decreaseContrast": "Diminuir Contraste",
"largerText": "Texto Maior",
"smallerText": "Texto Menor",
"screenReader": "Leitor de Tela"
},
"language": {
"selectLanguage": "Selecionar Idioma",
"auto": "Automático",
"spanish": "Espanhol",
"english": "Inglês",
"portuguese": "Português",
"courseLanguage": "Idioma do Curso",
"interfaceLanguage": "Idioma da Interface"
},
"errors": {
"notFound": "Não encontrado",
"unauthorized": "Não autorizado",
"serverError": "Erro do servidor",
"networkError": "Erro de rede",
"sessionExpired": "Sessão expirada",
"invalidCredentials": "Credenciais inválidas",
"emailInUse": "E-mail já em uso",
"weakPassword": "Senha fraca"
},
"dates": {
"today": "Hoje",
"yesterday": "Ontem",
"tomorrow": "Amanhã",
"daysAgo": "há {{days}} dias",
"weeksAgo": "há {{weeks}} semanas",
"monthsAgo": "há {{months}} meses",
"yearsAgo": "há {{years}} anos"
}
}
}
@@ -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"
}
}
}