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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user