feat: Implement AI tutor functionality, add branding fields, and improve API URL handling.

This commit is contained in:
2026-01-23 14:48:41 -03:00
parent 60e2af72f0
commit 470c7f0172
30 changed files with 1352 additions and 274 deletions
+28
View File
@@ -28,6 +28,10 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
- **Course Deletion**: Funcionalidad de eliminación de cursos con verificación de permisos y limpieza en cascada.
- **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots).
- **Auto Transcription**: Integración con Whisper para generación automática de transcripciones y evaluación precisa de voz.
- **Dynamic API Resolution**: Resolución inteligente de endpoints que permite el acceso desde cualquier dispositivo en la red local (WiFi) sin configuración manual.
- **Responsive UI/UX**: Interfaces optimizadas para dispositivos móviles con menús adaptativos y escalado fluido de componentes.
- **AI Teaching Assistant (RAG)**: Tutor inteligente dentro de cada lección que ayuda a los estudiantes utilizando el contexto de la lección actual y el historial del curso.
- **Persistent Grade Locking**: Bloqueo persistente de lecciones calificadas tras agotar los intentos, con retroalimentación personalizada generada por IA.
## Requisitos del Sistema
@@ -71,6 +75,9 @@ Esto iniciará todos los servicios:
- **Studio**: [http://localhost:3000](http://localhost:3000)
- **Experience**: [http://localhost:3003](http://localhost:3003)
> [!TIP]
> **Acceso desde Móviles**: Gracias a la *Dynamic API Resolution*, puedes acceder desde tu celular conectado al mismo WiFi usando la IP de tu computadora (ej: `http://192.168.1.15:3000`). La interfaz se adaptará automáticamente.
### Desarrollo Local
#### Studio & CMS
@@ -307,6 +314,23 @@ Elimina un curso y todos sus contenidos relacionados (módulos, lecciones, asset
- **Procesamiento Asíncrono**: Despacha una tarea en segundo plano que utiliza Whisper para transcripción y Ollama para generar la traducción y el resumen inteligente.
- **Cuerpo de la Petición**: Vacío.
#### GET /lessons/{id}/feedback
Obtiene retroalimentación personalizada de IA basada en el desempeño del estudiante y el contexto de la lección.
- **Uso Crítico**: Se llama automáticamente cuando una lección calificada es bloqueada por intentos agotados.
- **Respuesta**: Un objeto JSON con la respuesta motivacional del tutor.
#### POST /lessons/{id}/chat
Interactúa con el tutor de IA específico para la lección.
- **Contexto Inteligente**: La IA tiene acceso a la transcripción del video, el contenido de los bloques interactivos y el historial de lecciones pasadas del curso.
- **Cuerpo ( ChatPayload ):**
```json
{
"message": "string"
}
```
#### GET /lessons/{id}/vtt?lang=en|es
Devuelve los subtítulos en formato WebVTT para integración nativa.
@@ -410,6 +434,10 @@ Obtiene una lista de todas las organizaciones registradas.
- **PDF Integrated Viewer**: Read academic documents without leaving the platform.
- **Interactive Video Markers**: Pause-and-answer questions embedded in video lessons.
- **White-Label Branding**: Fully custom platform name, logo, favicon, and color themes per organization.
- **Dynamic LAN Connectivity**: Automatic server IP detection for seamless multi-device access.
- **Mobile-First Navigation**: Responsive sliding menus and adaptive layouts for all screen sizes.
- **Context-Aware AI Tutor**: Smart assistant with RAG that remembers past lessons and protects activity answers.
- **Personalized AI Feedback**: Motivational and instructional feedback generated uniquely for each student's results.
## 📄 Licencia
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
+31
View File
@@ -0,0 +1,31 @@
const { Client } = require('pg');
async function checkEnrollments() {
const client = new Client({
connectionString: "postgresql://user:password@localhost:5432/openccb_lms"
});
try {
await client.connect();
console.log("Connected to LMS DB");
console.log("\n--- Users ---");
const users = await client.query("SELECT id, email, organization_id, full_name FROM users");
console.table(users.rows);
console.log("\n--- Enrollments ---");
const enrollments = await client.query("SELECT id, user_id, course_id, organization_id FROM enrollments");
console.table(enrollments.rows);
console.log("\n--- Courses ---");
const courses = await client.query("SELECT id, title, organization_id FROM courses");
console.table(courses.rows);
} catch (err) {
console.error("Error:", err);
} finally {
await client.end();
}
}
checkEnrollments();
+3 -2
View File
@@ -20,7 +20,7 @@ services:
environment:
DATABASE_URL: postgresql://user:password@db:5432/openccb_cms
JWT_SECRET: openccb_secret_key_2025_production
NEXT_PUBLIC_CMS_API_URL: http://localhost:3001
NEXT_PUBLIC_CMS_API_URL: http://192.168.0.254:3001
LMS_INTERNAL_URL: http://experience:3002
volumes:
- uploads_data:/app/uploads
@@ -40,7 +40,8 @@ services:
environment:
DATABASE_URL: postgresql://user:password@db:5432/openccb_lms
JWT_SECRET: openccb_secret_key_2025_production
NEXT_PUBLIC_LMS_API_URL: http://localhost:3002
NEXT_PUBLIC_LMS_API_URL: http://192.168.0.254:3002
NEXT_PUBLIC_CMS_API_URL: http://192.168.0.254:3001
env_file: .env
extra_hosts:
- "host.docker.internal:host-gateway"
+76
View File
@@ -0,0 +1,76 @@
2026-01-23T16:11:08.024085Z  INFO sqlx::postgres::notice: relation "_sqlx_migrations" already exists, skipping
2026-01-23T16:11:08.027458Z  INFO lms_service: LMS Service listening on 0.0.0.0:3002
▲ Next.js 14.2.21
- Local: http://006ab367474a:3003
- Network: http://172.18.0.3:3003
✓ Starting...
✓ Ready in 30ms
TypeError: fetch failed
at node:internal/deps/undici/undici:12637:11
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async fetchExternalImage (/app/node_modules/next/dist/server/image-optimizer.js:589:17)
at async NextNodeServer.imageOptimizer (/app/node_modules/next/dist/server/next-server.js:649:48)
at async cacheEntry.imageResponseCache.get.incrementalCache (/app/node_modules/next/dist/server/next-server.js:182:65)
at async /app/node_modules/next/dist/server/response-cache/index.js:90:36
at async /app/node_modules/next/dist/lib/batcher.js:45:32 {
cause: Error: connect ECONNREFUSED 127.0.0.1:3001
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1555:16) {
errno: -111,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 3001
}
}
2026-01-23T16:12:42.241889Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
2026-01-23T16:12:42.247382Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
2026-01-23T16:12:47.935236Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
2026-01-23T16:12:50.853225Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
2026-01-23T16:12:50.860842Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
2026-01-23T16:12:53.661037Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
2026-01-23T16:12:57.221143Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
2026-01-23T16:12:57.239532Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
2026-01-23T16:12:58.825384Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
2026-01-23T16:12:58.834411Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
2026-01-23T16:13:00.543902Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
2026-01-23T16:13:00.559156Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
2026-01-23T16:13:02.271758Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
2026-01-23T16:22:32.087284Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
TypeError: fetch failed
at node:internal/deps/undici/undici:12637:11
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async fetchExternalImage (/app/node_modules/next/dist/server/image-optimizer.js:589:17)
at async NextNodeServer.imageOptimizer (/app/node_modules/next/dist/server/next-server.js:649:48)
at async cacheEntry.imageResponseCache.get.incrementalCache (/app/node_modules/next/dist/server/next-server.js:182:65)
at async /app/node_modules/next/dist/server/response-cache/index.js:90:36
at async /app/node_modules/next/dist/lib/batcher.js:45:32 {
cause: Error: connect ECONNREFUSED 127.0.0.1:3001
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1555:16)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:128:17) {
errno: -111,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 3001
}
}
2026-01-23T16:22:45.297187Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
TypeError: fetch failed
at node:internal/deps/undici/undici:12637:11
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async fetchExternalImage (/app/node_modules/next/dist/server/image-optimizer.js:589:17)
at async NextNodeServer.imageOptimizer (/app/node_modules/next/dist/server/next-server.js:649:48)
at async cacheEntry.imageResponseCache.get.incrementalCache (/app/node_modules/next/dist/server/next-server.js:182:65)
at async /app/node_modules/next/dist/server/response-cache/index.js:90:36
at async /app/node_modules/next/dist/lib/batcher.js:45:32 {
cause: Error: connect ECONNREFUSED 127.0.0.1:3001
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1555:16)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:128:17) {
errno: -111,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 3001
}
}
+159
View File
@@ -0,0 +1,159 @@
{
"name": "openccb",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"pg": "^8.17.2"
}
},
"node_modules/pg": {
"version": "8.17.2",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz",
"integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.10.1",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz",
"integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"pg": "^8.17.2"
}
}
+21 -6
View File
@@ -39,7 +39,7 @@
- [x] **Portal del Estudiante (Experience)**:
- [x] Catálogo de cursos e inscripciones
- [x] Reproductor interactivo de lecciones
- [x] Diseño responsivo (móviles/tablets)
- [x] Diseño responsivo (móviles/tablets) - **Optimizado y validado en Fase 15**
- [x] **Sistema de Calificación Holístico**:
- [x] Categorías de calificación con pesos (porcentajes)
- [x] Opción de eliminar las N puntuaciones más bajas por categoría
@@ -130,7 +130,7 @@
## Fase 11: Evaluaciones y Quizzes Extendidos (En Progreso)
- [x] **Quices de Código**: Desafíos interactivos con reproductor tipo IDE (Completado)
- [x] **Identificación Visual**: Quices de "Puntos Calientes" (Hotspots) en imágenes (Completado)
- [ ] **Tutor de IA Integrado**: Asistente basado en RAG dentro del reproductor de lecciones
- [x] **Tutor de IA Integrado**: Asistente basado en RAG con acceso a bloques interactivos e historial del curso (Completado)
- [x] **Evaluaciones por Audio**: Preguntas con respuesta oral para idiomas con feedback de IA detallado (Completado)
- [x] **Eliminación de Cursos**: Gestión completa del ciclo de vida del contenido (Completado)
- [x] **Quices con Contexto IA**: Generación de evaluaciones con enfoque y tipo personalizable (Completado)
@@ -155,9 +155,24 @@
---
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado que permite la generación de contenidos con una "Persona" docente experta, evaluaciones de audio automatizadas y quices personalizables con contexto. Se ha completado el ciclo de vida de gestión de cursos con la integración de la funcionalidad de borrado.
## Fase 15: Conectividad y UI Adaptativa ✅
- [x] **Dynamic API Resolution**: Detección automática de IP del servidor para acceso multi-dispositivo y LAN (Completado)
- [x] **Menú Móvil (Experience)**: Implementación de navegación lateral (hamburger) para celulares (Completado)
- [x] **Optimización de Studio**: Interfaz de administración compacta y escalable para pantallas pequeñas (Completado)
- [x] **Tipografía Fluida**: Escalado de fuentes y márgenes adaptativos en todo el portal (Completado)
- [x] **Locked Lesson AI Feedback**: Generación de retroalimentación motivacional para lecciones bloqueadas (Completado)
- [x] **Context Enrichment**: Ingesta de bloques interactivos en el motor de RAG (Completado)
- [x] **Course History Context**: Capacidad del tutor para recordar lecciones previas (Completado)
## Fase 16: Estabilidad y UX Avanzada (En Progreso)
- [/] **QA y Estabilidad**: Verificación del flujo completo de evaluación en entornos de producción.
- [ ] **Rutas de Aprendizaje**: Recomendaciones basadas en el historial personalizadas.
- [ ] **Optimización de Contenedores**: Limpieza automatizada y reducción de huella de infraestructura.
---
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica y una **interfaz 100% responsiva**.
**Próximas Prioridades**:
1. **QA y Estabilidad**: Verificación del flujo completo de evaluación en entornos de producción.
2. **IA Teaching Assistant**: Tutor RAG personalizado por curso.
3. **Rutas de Aprendizaje**: Recomendaciones basadas en el historial.
1. **QA Exhaustivo**: Pruebas de carga en servicios de IA.
2. **Rutas de Aprendizaje**: Motor de recomendaciones dinámicas.
@@ -0,0 +1,4 @@
-- Add missing branding columns to organizations table
ALTER TABLE organizations
ADD COLUMN IF NOT EXISTS platform_name TEXT,
ADD COLUMN IF NOT EXISTS favicon_url TEXT;
+339 -8
View File
@@ -262,6 +262,7 @@ pub async fn get_course_catalog(
State(pool): State<PgPool>,
Query(query): Query<CatalogQuery>,
) -> Result<Json<Vec<Course>>, StatusCode> {
tracing::info!("get_course_catalog: org_id={:?}, user_id={:?}", query.organization_id, query.user_id);
let courses = match (query.organization_id, query.user_id) {
(Some(org_id), Some(user_id)) => {
sqlx::query_as::<_, Course>(
@@ -464,17 +465,22 @@ pub async fn ingest_course(
}
pub async fn get_course_outline(
Org(_org_ctx): Org,
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
tracing::info!("get_course_outline: fetching course {}", id);
tracing::info!("get_course_outline: id={}, caller_org={}", id, org_ctx.id);
// 1. Fetch Course
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
.map_err(|e| {
tracing::error!("get_course_outline: course fetch failed for {}: {}", id, e);
StatusCode::NOT_FOUND
})?;
tracing::info!("get_course_outline: course found, title='{}'", course.title);
// 2. Fetch Modules
let modules =
@@ -482,7 +488,12 @@ pub async fn get_course_outline(
.bind(id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| {
tracing::error!("get_course_outline: modules fetch failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
tracing::info!("get_course_outline: found {} modules", modules.len());
// 3. Fetch Organization
let organization = sqlx::query_as::<_, common::models::Organization>(
@@ -491,7 +502,12 @@ pub async fn get_course_outline(
.bind(course.organization_id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| {
tracing::error!("get_course_outline: organization fetch failed for {}: {}", course.organization_id, e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
tracing::info!("get_course_outline: organization found: {}", organization.name);
// 4. Fetch Grading Categories
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
@@ -500,7 +516,10 @@ pub async fn get_course_outline(
.bind(id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| {
tracing::error!("get_course_outline: grading categories fetch failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// 5. Fetch Lessons
let mut pub_modules = Vec::new();
@@ -511,7 +530,10 @@ pub async fn get_course_outline(
.bind(module.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| {
tracing::error!("get_course_outline: lessons fetch failed for module {}: {}", module.id, e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
pub_modules.push(common::models::PublishedModule { module, lessons });
}
@@ -540,10 +562,11 @@ pub async fn get_lesson_content(
}
pub async fn get_user_enrollments(
Org(_org_ctx): Org,
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
tracing::info!("get_user_enrollments: user_id={}, caller_org_id={}", user_id, org_ctx.id);
let enrollments =
sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1")
.bind(user_id)
@@ -1345,3 +1368,311 @@ pub async fn evaluate_audio_file(
Ok(Json(grading))
}
#[derive(Deserialize)]
pub struct ChatPayload {
pub message: String,
}
#[derive(Serialize)]
pub struct ChatResponse {
pub response: String,
}
pub async fn chat_with_tutor(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Path(lesson_id): Path<Uuid>,
Json(payload): Json<ChatPayload>,
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
// 1. Fetch lesson context (summary and transcription)
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Lesson not found".into()))?;
// 1.5 Fetch previous lessons in the course for context
let module = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE id = $1")
.bind(lesson.module_id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch module context".into()))?;
let previous_lessons = sqlx::query!(
r#"
SELECT l.title, l.summary
FROM lessons l
JOIN modules m ON l.module_id = m.id
WHERE m.course_id = $1
AND (m.position < $2 OR (m.position = $2 AND l.position < $3))
ORDER BY m.position, l.position
"#,
module.course_id,
module.position,
lesson.position
)
.fetch_all(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch previous lessons".into()))?;
let mut history_context = String::new();
if !previous_lessons.is_empty() {
history_context.push_str("\n--- PAST LESSONS HISTORY (FOR CONTEXT) ---\n");
for prev in previous_lessons {
history_context.push_str(&format!(
"Past Lesson: {}\nSummary: {}\n\n",
prev.title,
prev.summary.as_deref().unwrap_or("No summary available.")
));
}
}
let block_content = extract_block_content(&lesson.metadata);
let context = format!(
"CURRENT Lesson Title: {}\nSummary: {}\nTranscription (Partial): {}\n\n--- CURRENT LESSON CONTENT (BLOCKS & ACTIVITIES) ---\n{}\n{}",
lesson.title,
lesson.summary.as_deref().unwrap_or_default(),
lesson.transcription.as_ref().and_then(|t| t.get("text").and_then(|text| text.as_str())).unwrap_or("No transcript available."),
block_content,
history_context
);
// 2. Setup AI request
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 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());
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
} else {
(
"https://api.openai.com/v1/chat/completions".to_string(),
format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()),
"gpt-4-turbo".to_string(),
)
};
let system_prompt = format!(
"You are an expert AI Teaching Assistant for the OpenCCB platform. \
Your purpose is to help the student understand the content of this lesson and how it relates to previous lessons in the course. \
\
STRICT RULES: \
1. You can ONLY answer questions related to the CURRENT lesson or the PAST lessons provided in the context. \
2. If a student asks about topics NOT covered in the current or past lessons (e.g., general knowledge, future topics, or off-topic conversation), \
you MUST politely decline and remind them that you are here only to help with the course content up to this point. \
3. CRITICAL: Do NOT provide direct answers for the CURRENT lesson's activities, quizzes, or code exercises. \
Even if the answer could be inferred from past lessons, you must only provide hints, explain underlying concepts, or guide the student to find the answer themselves. \
4. Maintain a supportive, encouraging, and educational tone. \
5. Answer in the same language as the student's question. \
\
LESSON CONTEXT:\n{}",
context
);
let response = client.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", auth_header)
.json(&serde_json::json!({
"model": model,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": payload.message }
],
"temperature": 0.7
}))
.send()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?;
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("AI API error: {}", err_body)));
}
let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse AI response: {}", e)))?;
let tutor_response = ai_data["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("Lo siento, tuve un problema procesando tu pregunta.")
.to_string();
Ok(Json(ChatResponse { response: tutor_response }))
}
pub async fn get_lesson_feedback(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(lesson_id): Path<Uuid>,
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
let user_id = claims.sub;
// 1. Fetch lesson context
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Lesson not found".into()))?;
// 2. Fetch user's grade for this lesson
let grade = sqlx::query_as::<_, common::models::UserGrade>(
"SELECT * FROM user_grades WHERE user_id = $1 AND lesson_id = $2"
)
.bind(user_id)
.bind(lesson_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::BAD_REQUEST, "No grade found for this lesson".into()))?;
let score_pct = (grade.score * 100.0) as i32;
let block_content = extract_block_content(&lesson.metadata);
let context = format!(
"Lesson Title: {}\nSummary: {}\nStudent Score: {}%\nMax Attempts: {}\nAttempts Used: {}\n\n--- LESSON CONTENT ---\n{}",
lesson.title,
lesson.summary.as_deref().unwrap_or_default(),
score_pct,
lesson.max_attempts.unwrap_or(0),
grade.attempts_count,
block_content
);
// 3. Setup AI request
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 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());
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
} else {
(
"https://api.openai.com/v1/chat/completions".to_string(),
format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()),
"gpt-4-turbo".to_string(),
)
};
let system_prompt = format!(
"You are an expert AI Teaching Assistant. The student has completed a graded assessment and is now seeing their final results. \
Provide a personalized message based on their score ({}%). \
\
STRICT RULES: \
1. Base your feedback ONLY on the lesson content and the student's performance. \
2. If the score is high (>= 80%), congratulate them warmly. \
3. If the score is medium (60-79%), acknowledge their effort and suggest specific areas to improve based on the lesson content. \
4. If the score is low (< 60%), provide encouragement and list specific topics or related blocks they should repeat or review to improve. \
5. Keep the message concise, supportive, and professional. \
6. Answer in Spanish as the platform is mainly used in that language. \
\
LESSON CONTEXT:\n{}",
score_pct,
context
);
let response = client.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", auth_header)
.json(&serde_json::json!({
"model": model,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": "Genera mi retroalimentación personalizada basada en mis resultados." }
],
"temperature": 0.7
}))
.send()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?;
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("AI API error: {}", err_body)));
}
let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse AI response: {}", e)))?;
let tutor_response = ai_data["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("Buen trabajo completando la lección. Revisa tus resultados arriba.")
.to_string();
Ok(Json(ChatResponse { response: tutor_response }))
}
fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
let mut block_content = String::new();
if let Some(meta) = metadata {
if let Some(blocks) = meta.get("blocks").and_then(|b| b.as_array()) {
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
let title = block.get("title").and_then(|t| t.as_str()).unwrap_or("");
block_content.push_str(&format!("\n--- Block: {} ({}) ---\n", title, block_type));
match block_type {
"description" | "fill-in-the-blanks" => {
if let Some(content) = block.get("content").and_then(|c| c.as_str()) {
block_content.push_str(content);
}
}
"quiz" => {
if let Some(questions) = block.get("quiz_data").and_then(|q| q.get("questions")).and_then(|qs| qs.as_array()) {
for (i, q) in questions.iter().enumerate() {
let question_text = q.get("question").and_then(|qt| qt.as_str()).unwrap_or("");
block_content.push_str(&format!("Q{}: {}\n", i + 1, question_text));
}
}
}
"matching" | "memory-match" => {
if let Some(pairs) = block.get("pairs").and_then(|p| p.as_array()) {
for (i, p) in pairs.iter().enumerate() {
let left = p.get("left").and_then(|l| l.as_str()).unwrap_or("");
let right = p.get("right").and_then(|r| r.as_str()).unwrap_or("");
block_content.push_str(&format!("Pair {}: {} <-> {}\n", i + 1, left, right));
}
}
}
"ordering" => {
if let Some(items) = block.get("items").and_then(|i| i.as_array()) {
for (i, item) in items.iter().enumerate() {
let text = item.as_str().unwrap_or("");
block_content.push_str(&format!("Item {}: {}\n", i + 1, text));
}
}
}
"short-answer" | "audio-response" => {
if let Some(prompt) = block.get("prompt").and_then(|p| p.as_str()) {
block_content.push_str(&format!("Prompt: {}\n", prompt));
}
}
"code" => {
if let Some(instructions) = block.get("instructions").and_then(|i| i.as_str()) {
block_content.push_str(&format!("Instructions: {}\n", instructions));
}
}
"hotspot" => {
if let Some(description) = block.get("description").and_then(|d| d.as_str()) {
block_content.push_str(&format!("Description: {}\n", description));
}
}
_ => {}
}
block_content.push_str("\n");
}
}
}
block_content
}
+2
View File
@@ -81,6 +81,8 @@ async fn main() {
)
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
.route("/audio/evaluate-file", post(handlers::evaluate_audio_file))
.route("/lessons/{id}/chat", post(handlers::chat_with_tutor))
.route("/lessons/{id}/feedback", get(handlers::get_lesson_feedback))
.route("/notifications", get(handlers::get_notifications))
.route(
"/notifications/{id}/read",
+31
View File
@@ -0,0 +1,31 @@
const { Client } = require('pg');
async function testQuery() {
const client = new Client({
connectionString: "postgresql://user:password@localhost:5432/openccb_lms"
});
const orgId = '8555931d-b335-4b4e-9f51-4a0434e591b6';
const userId = 'ec2e2a38-a1d1-41b2-8202-c9b90d1d5db5';
try {
await client.connect();
const query = `
SELECT DISTINCT c.* FROM courses c
LEFT JOIN enrollments e ON c.id = e.course_id AND e.user_id = $2
WHERE c.organization_id = $1 OR c.organization_id = '00000000-0000-0000-0000-000000000001' OR e.id IS NOT NULL
`;
const res = await client.query(query, [orgId, userId]);
console.log("Catalog Query Result (Courses found):", res.rowCount);
console.table(res.rows);
} catch (err) {
console.error("Error:", err);
} finally {
await client.end();
}
}
testQuery();
+1 -1
View File
@@ -236,7 +236,7 @@ export default function ExperienceLoginPage() {
<div className="mt-6 pt-6 border-t border-white/10 text-center">
<p className="text-sm text-gray-400">
¿Eres un instructor?{" "}
<a href="http://localhost:3000/auth/login" className="text-indigo-400 hover:text-indigo-300 font-bold">
<a href="http://192.168.0.254:3000/auth/login" className="text-indigo-400 hover:text-indigo-300 font-bold">
Ir al Portal de Instructores
</a>
</p>
@@ -19,6 +19,8 @@ import MemoryPlayer from "@/components/blocks/MemoryPlayer";
import DocumentPlayer from "@/components/blocks/DocumentPlayer";
import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer";
import InteractiveTranscript from "@/components/InteractiveTranscript";
import AITutor from "@/components/AITutor";
import LessonLockedView from "@/components/LessonLockedView";
import { ListMusic } from "lucide-react";
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
@@ -188,179 +190,178 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
</div>
)}
{/* Render Blocks */}
{(lesson.metadata?.blocks || []).length > 0 ? (
<div className="space-y-24">
{lesson.metadata?.blocks?.map((block) => {
const renderBlock = () => {
switch (block.type) {
case 'description':
return <DescriptionPlayer id={block.id} title={block.title} content={block.content || ""} />;
case 'media':
return (
<MediaPlayer
id={block.id}
lessonId={params.lessonId}
title={block.title}
url={block.url || ""}
media_type={block.media_type || 'video'}
config={block.config}
onTimeUpdate={setCurrentTime}
initialPlayCount={
userGrade?.metadata?.play_counts
? (userGrade.metadata.play_counts as Record<string, number>)[block.id] || 0
: 0
}
onPlay={async () => {
if (user && lesson.max_attempts && (!userGrade || userGrade.attempts_count < lesson.max_attempts)) {
const currentPlayCounts = (userGrade?.metadata?.play_counts as Record<string, number>) || {};
const newPlayCounts = {
...currentPlayCounts,
[block.id]: (currentPlayCounts[block.id] || 0) + 1
};
try {
const res = await lmsApi.submitScore(
user.id,
params.id,
params.lessonId,
userGrade?.score || 0,
{ ...userGrade?.metadata, play_counts: newPlayCounts }
);
setUserGrade(res);
} catch (err) {
console.error("Error al guardar el recuento de reproducciones", err);
}
}
}}
isGraded={lesson.is_graded}
hasTranscription={!!lesson.transcription}
/>
);
case 'document':
return <DocumentPlayer id={block.id} title={block.title} url={block.url || ""} />;
case 'quiz':
return (
<QuizPlayer
id={block.id}
title={block.title}
quizData={block.quiz_data || { questions: [] }}
allowRetry={lesson.allow_retry}
maxAttempts={lesson.max_attempts || undefined}
initialAttempts={
userGrade?.metadata?.block_attempts
? (userGrade.metadata.block_attempts as Record<string, number>)[block.id] || 0
: 0
}
onAttempt={async () => {
if (user) {
const currentAttempts = (userGrade?.metadata?.block_attempts as Record<string, number>) || {};
const newAttempts = {
...currentAttempts,
[block.id]: (currentAttempts[block.id] || 0) + 1
};
try {
const res = await lmsApi.submitScore(
user.id,
params.id,
params.lessonId,
userGrade?.score || 0,
{ ...userGrade?.metadata, block_attempts: newAttempts }
);
setUserGrade(res);
} catch (err) {
console.error("Error al guardar los intentos del bloque", err);
}
}
}}
/>
);
case 'fill-in-the-blanks':
return <FillInTheBlanksPlayer id={block.id} title={block.title} content={block.content || ""} allowRetry={lesson.allow_retry} />;
case 'matching':
return <MatchingPlayer id={block.id} title={block.title} pairs={block.pairs || []} allowRetry={lesson.allow_retry} />;
case 'ordering':
return <OrderingPlayer id={block.id} title={block.title} items={block.items || []} allowRetry={lesson.allow_retry} />;
case 'short-answer':
return (
<ShortAnswerPlayer
id={block.id}
title={block.title}
prompt={block.prompt || ""}
correctAnswers={block.correctAnswers || []}
allowRetry={lesson.allow_retry}
/>
);
case 'audio-response':
return (
<AudioResponsePlayer
id={block.id}
prompt={block.prompt || ""}
keywords={block.keywords}
timeLimit={block.timeLimit}
isGraded={lesson.is_graded}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'code':
return (
<CodeExercisePlayer
title={block.title}
instructions={block.instructions || ""}
initialCode={block.initialCode || ""}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'hotspot':
return (
<HotspotPlayer
title={block.title}
description={block.description || ""}
imageUrl={block.imageUrl || ""}
hotspots={block.hotspots || []}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'memory-match':
return (
<MemoryPlayer
title={block.title}
pairs={block.pairs || []}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
default:
return <div className="p-4 bg-white/5 border border-white/10 rounded-xl text-xs font-bold text-gray-500 uppercase tracking-widest">Tipo de Bloque Desconocido: {block.type}</div>;
}
};
return (
<div key={block.id} className="animate-in fade-in slide-in-from-bottom-6 duration-700 delay-100">
{renderBlock()}
</div>
);
})}
</div>
{/* Render Blocks or Locked View */}
{lesson.is_graded && userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? (
<LessonLockedView
lessonId={params.lessonId}
courseId={params.id}
grade={userGrade}
maxAttempts={lesson.max_attempts}
/>
) : (
<div className="py-20 text-center glass-card border-dashed border-white/10">
<p className="text-gray-500 font-bold uppercase tracking-widest">Actualmente, esta lección no tiene contenido.</p>
</div>
(lesson.metadata?.blocks || []).length > 0 ? (
<div className="space-y-24">
{lesson.metadata?.blocks?.map((block) => {
const renderBlock = () => {
switch (block.type) {
case 'description':
return <DescriptionPlayer id={block.id} title={block.title} content={block.content || ""} />;
case 'media':
return (
<MediaPlayer
id={block.id}
lessonId={params.lessonId}
title={block.title}
url={block.url || ""}
media_type={block.media_type || 'video'}
config={block.config}
onTimeUpdate={setCurrentTime}
initialPlayCount={
userGrade?.metadata?.play_counts
? (userGrade.metadata.play_counts as Record<string, number>)[block.id] || 0
: 0
}
onPlay={async () => {
if (user && lesson.max_attempts && (!userGrade || userGrade.attempts_count < lesson.max_attempts)) {
const currentPlayCounts = (userGrade?.metadata?.play_counts as Record<string, number>) || {};
const newPlayCounts = {
...currentPlayCounts,
[block.id]: (currentPlayCounts[block.id] || 0) + 1
};
try {
const res = await lmsApi.submitScore(
user.id,
params.id,
params.lessonId,
userGrade?.score || 0,
{ ...userGrade?.metadata, play_counts: newPlayCounts }
);
setUserGrade(res);
} catch (err) {
console.error("Error al guardar el recuento de reproducciones", err);
}
}
}}
isGraded={lesson.is_graded}
hasTranscription={!!lesson.transcription}
/>
);
case 'document':
return <DocumentPlayer id={block.id} title={block.title} url={block.url || ""} />;
case 'quiz':
return (
<QuizPlayer
id={block.id}
title={block.title}
quizData={block.quiz_data || { questions: [] }}
allowRetry={lesson.allow_retry}
maxAttempts={lesson.max_attempts || undefined}
initialAttempts={
userGrade?.metadata?.block_attempts
? (userGrade.metadata.block_attempts as Record<string, number>)[block.id] || 0
: 0
}
onAttempt={async () => {
if (user) {
const currentAttempts = (userGrade?.metadata?.block_attempts as Record<string, number>) || {};
const newAttempts = {
...currentAttempts,
[block.id]: (currentAttempts[block.id] || 0) + 1
};
try {
const res = await lmsApi.submitScore(
user.id,
params.id,
params.lessonId,
userGrade?.score || 0,
{ ...userGrade?.metadata, block_attempts: newAttempts }
);
setUserGrade(res);
} catch (err) {
console.error("Error al guardar los intentos del bloque", err);
}
}
}}
/>
);
case 'fill-in-the-blanks':
return <FillInTheBlanksPlayer id={block.id} title={block.title} content={block.content || ""} allowRetry={lesson.allow_retry} />;
case 'matching':
return <MatchingPlayer id={block.id} title={block.title} pairs={block.pairs || []} allowRetry={lesson.allow_retry} />;
case 'ordering':
return <OrderingPlayer id={block.id} title={block.title} items={block.items || []} allowRetry={lesson.allow_retry} />;
case 'short-answer':
return (
<ShortAnswerPlayer
id={block.id}
title={block.title}
prompt={block.prompt || ""}
correctAnswers={block.correctAnswers || []}
allowRetry={lesson.allow_retry}
/>
);
case 'audio-response':
return (
<AudioResponsePlayer
id={block.id}
prompt={block.prompt || ""}
keywords={block.keywords}
timeLimit={block.timeLimit}
isGraded={lesson.is_graded}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'code':
return (
<CodeExercisePlayer
title={block.title}
instructions={block.instructions || ""}
initialCode={block.initialCode || ""}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'hotspot':
return (
<HotspotPlayer
title={block.title}
description={block.description || ""}
imageUrl={block.imageUrl || ""}
hotspots={block.hotspots || []}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'memory-match':
return (
<MemoryPlayer
title={block.title}
pairs={block.pairs || []}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
default:
return <div className="p-4 bg-white/5 border border-white/10 rounded-xl text-xs font-bold text-gray-500 uppercase tracking-widest">Tipo de Bloque Desconocido: {block.type}</div>;
}
};
return (
<div key={block.id} className="animate-in fade-in slide-in-from-bottom-6 duration-700 delay-100">
{renderBlock()}
</div>
);
})}
</div>
) : (
<div className="py-20 text-center glass-card border-dashed border-white/10">
<p className="text-gray-500 font-bold uppercase tracking-widest">Actualmente, esta lección no tiene contenido.</p>
</div>
)
)}
{lesson.is_graded && (
<div className="pt-20 border-t border-white/5 animate-in fade-in slide-in-from-bottom-8 duration-1000">
{userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? (
<div className="space-y-4">
<div className="inline-flex items-center gap-2 px-6 py-2 bg-amber-500/10 border border-amber-500/30 text-amber-400 rounded-full text-xs font-black uppercase tracking-widest">
Bloqueado: Se alcanzó el máximo de intentos ({lesson.max_attempts})
</div>
<div className="text-4xl font-black text-white">
Puntuación: <span className="text-blue-500">{userGrade.score * 100}%</span>
</div>
<p className="text-gray-500 text-xs italic">Esta evaluación ya está cerrada para futuras entregas.</p>
</div>
) : (
{userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? null : (
<>
<button
onClick={async () => {
@@ -442,6 +443,9 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
</Link>
)}
</footer>
{/* AI Tutor Bubble/Panel */}
<AITutor lessonId={params.lessonId} />
</main>
</div>
);
+2 -2
View File
@@ -33,7 +33,7 @@ body {
}
.glass-card {
@apply glass rounded-2xl p-6 transition-all duration-300;
@apply glass rounded-2xl p-4 md:p-6 transition-all duration-300;
}
.glass-card:hover {
@@ -88,4 +88,4 @@ body {
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ export default function MyLearningPage() {
enrichedEnrollments.push({
course: { ...course, modules },
progress,
lastAccessed: enrollment.enroled_at
lastAccessed: enrollment.enrolled_at
});
} catch (err) {
console.error(`Error loading course ${enrollment.course_id}`, err);
+7 -7
View File
@@ -85,22 +85,22 @@ export default function CatalogPage() {
}
return (
<div className="max-w-7xl mx-auto px-6 py-20">
<div className="mb-20 flex flex-col md:flex-row md:items-end justify-between gap-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 md:py-20">
<div className="mb-12 md:mb-20 flex flex-col md:flex-row md:items-end justify-between gap-8 text-center md:text-left">
<div className="space-y-4">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-blue-500">
<div className="flex items-center justify-center md:justify-start gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-blue-500">
<Star size={14} className="fill-blue-500" />
<span>Currículo Premier</span>
</div>
<h1 className="text-6xl font-black tracking-tighter leading-none">
Explorar <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-indigo-600">Cursos</span>
<h1 className="text-4xl md:text-6xl font-black tracking-tighter leading-tight md:leading-none">
Explorar <span className="block sm:inline text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-indigo-600">Cursos</span>
</h1>
<p className="text-gray-500 font-medium max-w-xl text-lg">
<p className="text-gray-500 font-medium max-w-xl text-base md:text-lg mx-auto md:mx-0">
Domina las habilidades del futuro con nuestro contenido educativo de alta fidelidad.
</p>
</div>
{!user && (
<Link href="/auth/register" className="btn-premium !bg-white !text-black shadow-none !px-8">
<Link href="/auth/register" className="btn-premium !bg-white !text-black shadow-none !px-8 w-full sm:w-auto">
Comienza Gratis
</Link>
)}
+2 -2
View File
@@ -4,7 +4,7 @@ import Image from "next/image";
import { useState, useEffect, useRef, useCallback } from "react";
import { useAuth } from "@/context/AuthContext";
import { useTranslation } from "@/context/I18nContext";
import { lmsApi, CMS_API_URL } from "@/lib/api";
import { lmsApi, getCmsApiUrl } from "@/lib/api";
import {
Save,
Shield,
@@ -60,7 +60,7 @@ export default function ProfilePage() {
if (path.startsWith('http')) return path;
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
return `${CMS_API_URL}${finalPath}`;
return `${getCmsApiUrl()}${finalPath}`;
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { lmsApi } from "@/lib/api";
import { Send, Bot, User, X, MessageSquare, Loader2 } from "lucide-react";
interface Message {
role: 'tutor' | 'user';
content: string;
}
export default function AITutor({ lessonId }: { lessonId: string }) {
const [messages, setMessages] = useState<Message[]>([
{ role: 'tutor', content: '¡Hola! Soy tu tutor de IA. ¿Tienes alguna duda sobre esta lección?' }
]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleSend = async () => {
if (!input.trim() || isLoading) return;
const userMessage = input.trim();
setInput("");
setMessages(prev => [...prev, { role: 'user', content: userMessage }]);
setIsLoading(true);
try {
const { response } = await lmsApi.chatWithTutor(lessonId, userMessage);
setMessages(prev => [...prev, { role: 'tutor', content: response }]);
} catch (error) {
console.error("Chat error:", error);
setMessages(prev => [...prev, { role: 'tutor', content: "Lo siento, hubo un error conectando con el tutor. Por favor intenta de nuevo." }]);
} finally {
setIsLoading(false);
}
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-24 right-6 w-14 h-14 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-500/40 flex items-center justify-center hover:scale-110 transition-all z-[100] group"
title="Abrir Tutor de IA"
>
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 border-2 border-black rounded-full animate-pulse" />
<MessageSquare className="w-6 h-6 group-hover:rotate-12 transition-transform" />
</button>
);
}
return (
<div className="fixed bottom-24 right-6 w-80 md:w-96 h-[500px] glass bg-black/80 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl flex flex-col z-[200] animate-in slide-in-from-bottom-6 duration-500 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-white/5 bg-blue-600/10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shadow-lg shadow-blue-500/20">
<Bot className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-sm font-black text-white uppercase tracking-widest">Tutor de IA</h3>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-tighter">En Línea</span>
</div>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-white/5 rounded-lg text-gray-400 hover:text-white transition-colors"
>
<X size={20} />
</button>
</div>
{/* Messages */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-hide"
>
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`flex gap-2 max-w-[85%] ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${msg.role === 'user' ? 'bg-white/5' : 'bg-blue-600/20 text-blue-400'}`}>
{msg.role === 'user' ? <User size={16} /> : <Bot size={16} />}
</div>
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed ${msg.role === 'user'
? 'bg-blue-600 text-white rounded-tr-none'
: 'bg-white/5 text-gray-200 border border-white/5 rounded-tl-none'
}`}>
{msg.content}
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start animate-in fade-in duration-300">
<div className="flex gap-2 max-w-[85%]">
<div className="shrink-0 w-8 h-8 rounded-lg bg-blue-600/20 text-blue-400 flex items-center justify-center">
<Bot size={16} />
</div>
<div className="bg-white/5 text-gray-400 border border-white/5 p-3 rounded-2xl rounded-tl-none flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
<span className="text-[10px] font-bold uppercase tracking-widest">El tutor está pensando...</span>
</div>
</div>
</div>
)}
</div>
{/* Input */}
<div className="p-4 border-t border-white/5 bg-black/40">
<div className="relative">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Escribe tu duda aquí..."
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 pr-12 text-xs font-medium focus:outline-none focus:border-blue-500/50 transition-colors placeholder:text-gray-600"
/>
<button
onClick={handleSend}
disabled={isLoading || !input.trim()}
className="absolute right-2 top-1.5 p-1.5 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:bg-gray-600 transition-all hover:bg-blue-500"
>
<Send size={16} />
</button>
</div>
<p className="mt-2 text-[9px] text-gray-600 font-bold uppercase tracking-widest text-center">
IA entrenada con el contenido de esta lección
</p>
</div>
</div>
);
}
+111 -33
View File
@@ -5,8 +5,9 @@ import Image from "next/image";
import { useBranding } from "@/context/BrandingContext";
import { useAuth } from "@/context/AuthContext";
import { useTranslation } from "@/context/I18nContext";
import { LogOut, Globe } from "lucide-react";
import { LogOut, Globe, Menu, X } from "lucide-react";
import NotificationCenter from "./NotificationCenter";
import { useState } from "react";
import { lmsApi, getImageUrl } from "@/lib/api";
@@ -14,14 +15,15 @@ export default function AppHeader() {
const { t, language, setLanguage } = useTranslation();
const { branding } = useBranding();
const { user, logout } = useAuth();
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Use platform_name if available, otherwise name, otherwise default
const platformName = branding?.platform_name || branding?.name || 'OpenCCB';
return (
<header className="h-16 glass sticky top-0 z-50 px-6 flex items-center justify-between backdrop-blur-xl bg-black/40 border-b border-white/5">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-all overflow-hidden relative">
<header className="h-16 glass sticky top-0 z-[100] px-4 md:px-6 flex items-center justify-between backdrop-blur-xl bg-black/40 border-b border-white/5">
<Link href="/" className="flex items-center gap-2 md:gap-3 group">
<div className="w-8 h-8 md:w-10 md:h-10 rounded-lg md:rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-all overflow-hidden relative">
{branding?.logo_url ? (
<Image src={getImageUrl(branding.logo_url)} alt={branding.name} fill className="object-contain" sizes="40px" />
) : (
@@ -31,53 +33,129 @@ export default function AppHeader() {
)}
</div>
<div className="flex flex-col -gap-1">
<span className="font-black text-lg tracking-tighter text-white leading-none">
<span className="font-black text-sm md:text-lg tracking-tighter text-white leading-none">
{platformName.toUpperCase()}
</span>
<span className="text-[10px] font-black tracking-widest text-blue-500 uppercase">EXPERIENCIA</span>
<span className="text-[8px] md:text-[10px] font-black tracking-widest text-blue-500 uppercase">EXPERIENCIA</span>
</div>
</Link>
<nav className="flex items-center gap-2 md:gap-8">
<div className="hidden md:flex items-center gap-8 mr-4">
<div className="flex items-center gap-4">
<nav className="hidden md:flex items-center gap-8 mr-4">
<Link href="/" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">
{t('nav.catalog')}
</Link>
<Link href="/my-learning" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">
{t('nav.myLearning')}
</Link>
</div>
</nav>
<NotificationCenter />
<div className="flex items-center gap-2 md:gap-4">
<NotificationCenter />
<div className="flex items-center gap-2 border-l border-white/10 pl-4">
<Globe size={14} className="text-gray-500" />
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="bg-transparent text-[10px] font-black uppercase tracking-widest text-gray-500 hover:text-white transition-colors focus:outline-none cursor-pointer"
>
<option value="en" className="bg-[#0f1115]">EN</option>
<option value="es" className="bg-[#0f1115]">ES</option>
<option value="pt" className="bg-[#0f1115]">PT</option>
</select>
</div>
<div className="hidden sm:flex items-center gap-2 border-l border-white/10 pl-4">
<Globe size={14} className="text-gray-500" />
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="bg-transparent text-[10px] font-black uppercase tracking-widest text-gray-500 hover:text-white transition-colors focus:outline-none cursor-pointer"
>
<option value="en" className="bg-[#0f1115]">EN</option>
<option value="es" className="bg-[#0f1115]">ES</option>
<option value="pt" className="bg-[#0f1115]">PT</option>
</select>
</div>
<div className="flex items-center gap-2 md:gap-4 pl-4 border-l border-white/10">
<Link href="/profile" className="flex items-center gap-2 group/profile">
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10 flex items-center justify-center font-bold text-xs text-blue-400 group-hover/profile:border-blue-500/50 transition-colors">
{user?.full_name?.charAt(0) || 'U'}
</div>
</Link>
<div className="hidden md:flex items-center gap-4 pl-4 border-l border-white/10">
<Link href="/profile" className="flex items-center gap-2 group/profile">
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10 flex items-center justify-center font-bold text-xs text-blue-400 group-hover/profile:border-blue-500/50 transition-colors">
{user?.full_name?.charAt(0) || 'U'}
</div>
</Link>
<button
onClick={logout}
className="p-2 hover:bg-red-500/10 rounded-full text-gray-400 hover:text-red-400 transition-colors"
title={t('nav.signOut')}
>
<LogOut size={16} />
</button>
</div>
{/* Mobile Menu Button */}
<button
onClick={logout}
className="p-2 hover:bg-red-500/10 rounded-full text-gray-400 hover:text-red-400 transition-colors"
title={t('nav.signOut')}
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="md:hidden p-2 hover:bg-white/5 rounded-lg text-gray-400 transition-colors"
>
<LogOut size={16} />
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</nav>
</div>
{/* Mobile Sidebar Overlay */}
{isMenuOpen && (
<div className="fixed inset-0 z-[150] md:hidden bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
<div className="absolute right-0 top-0 bottom-0 w-64 glass border-l border-white/10 p-6 flex flex-col animate-in slide-in-from-right duration-300">
<div className="flex justify-between items-center mb-8">
<span className="font-black text-xs uppercase tracking-[0.2em] text-gray-500">Menú</span>
<button onClick={() => setIsMenuOpen(false)} className="p-2 hover:bg-white/5 rounded-lg">
<X size={20} />
</button>
</div>
<nav className="flex flex-col gap-6 flex-1">
<Link
href="/"
onClick={() => setIsMenuOpen(false)}
className="text-sm font-black uppercase tracking-widest text-gray-300 hover:text-white border-l-2 border-transparent hover:border-blue-500 pl-4 transition-all"
>
{t('nav.catalog')}
</Link>
<Link
href="/my-learning"
onClick={() => setIsMenuOpen(false)}
className="text-sm font-black uppercase tracking-widest text-gray-300 hover:text-white border-l-2 border-transparent hover:border-blue-500 pl-4 transition-all"
>
{t('nav.myLearning')}
</Link>
<div className="pt-6 mt-6 border-t border-white/5 space-y-4">
<div className="flex items-center gap-3 px-4 py-2 rounded-xl bg-white/5">
<Globe size={16} className="text-gray-500" />
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="bg-transparent text-xs font-bold uppercase tracking-widest text-gray-300 focus:outline-none flex-1"
>
<option value="en" className="bg-[#0f1115]">English</option>
<option value="es" className="bg-[#0f1115]">Español</option>
<option value="pt" className="bg-[#0f1115]">Português</option>
</select>
</div>
</div>
</nav>
<div className="pt-6 border-t border-white/5 space-y-4">
<Link
href="/profile"
onClick={() => setIsMenuOpen(false)}
className="flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-white/5 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center font-bold text-xs text-blue-400">
{user?.full_name?.charAt(0) || 'U'}
</div>
<span className="text-sm font-bold">{user?.full_name || 'Mi Perfil'}</span>
</Link>
<button
onClick={logout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-red-400 hover:bg-red-500/10 transition-colors"
>
<LogOut size={18} />
<span className="text-sm font-bold">{t('nav.signOut')}</span>
</button>
</div>
</div>
</div>
)}
</header>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useState } from "react";
import { lmsApi, UserGrade } from "@/lib/api";
import { Trophy, Award, BookOpen, RotateCcw, Bot, Loader2, Star, Sparkles, CheckCircle2 } from "lucide-react";
interface LessonLockedViewProps {
lessonId: string;
courseId: string;
grade: UserGrade;
maxAttempts?: number;
}
export default function LessonLockedView({ lessonId, courseId, grade, maxAttempts }: LessonLockedViewProps) {
const [feedback, setFeedback] = useState<string>("");
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchFeedback = async () => {
try {
const res = await lmsApi.getLessonFeedback(lessonId);
setFeedback(res.response);
} catch (err) {
console.error("Error fetching AI feedback:", err);
setFeedback("¡Buen trabajo completando esta evaluación! Sigue así para mejorar tus resultados en las próximas lecciones.");
} finally {
setLoading(false);
}
};
fetchFeedback();
}, [lessonId]);
const scorePct = Math.round(grade.score * 100);
const isPassing = scorePct >= 70; // Assuming 70% is passing
return (
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-8 duration-1000">
{/* Header / Score Card */}
<div className="relative group">
<div className="absolute -inset-1 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-[3rem] blur opacity-25 group-hover:opacity-40 transition duration-1000"></div>
<div className="relative glass p-10 md:p-16 rounded-[2.5rem] border border-white/10 bg-black/40 text-center space-y-8 overflow-hidden">
{/* Background visual flair */}
<div className="absolute top-0 right-0 p-8 opacity-10">
<Sparkles size={120} className="text-blue-500" />
</div>
<div className="inline-flex items-center gap-2 px-6 py-2 bg-blue-600/10 border border-blue-500/20 text-blue-400 rounded-full text-[10px] font-black uppercase tracking-[0.2em]">
<CheckCircle2 size={12} /> Evaluación Finalizada
</div>
<div className="space-y-4">
<h2 className="text-5xl md:text-7xl font-black text-white tracking-tighter">
Tu Puntuación: <span className={isPassing ? "text-blue-500" : "text-amber-500"}>{scorePct}%</span>
</h2>
<div className="flex justify-center gap-1">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
size={32}
className={`${s <= Math.ceil(scorePct / 20) ? "text-yellow-500 fill-yellow-500" : "text-white/5"} transition-all`}
/>
))}
</div>
</div>
<div className="flex flex-wrap justify-center gap-6 pt-4">
<div className="flex items-center gap-3 px-6 py-4 rounded-2xl bg-white/5 border border-white/5">
<RotateCcw size={20} className="text-gray-500" />
<div className="text-left">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Intentos Usados</p>
<p className="text-lg font-black text-white">{grade.attempts_count} {maxAttempts ? `de ${maxAttempts}` : ""}</p>
</div>
</div>
<div className="flex items-center gap-3 px-6 py-4 rounded-2xl bg-white/5 border border-white/5">
<Trophy size={20} className="text-amber-500" />
<div className="text-left">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Estado</p>
<p className={`text-lg font-black uppercase ${isPassing ? "text-green-500" : "text-amber-500"}`}>
{isPassing ? "Aprobado" : "No Alcanzado"}
</p>
</div>
</div>
</div>
</div>
</div>
{/* AI Feedback Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="glass p-8 md:p-12 rounded-[2.5rem] border border-white/5 bg-blue-600/5 relative overflow-hidden group">
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-64 h-64 bg-blue-600/10 blur-[100px] rounded-full group-hover:bg-blue-600/20 transition-all duration-1000"></div>
<h3 className="text-xs font-black uppercase tracking-[0.3em] text-blue-400 mb-8 flex items-center gap-3">
<Bot size={20} className="animate-bounce" /> Retroalimentación de tu Tutor de IA
</h3>
{loading ? (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<Loader2 className="w-12 h-12 text-blue-500 animate-spin" />
<p className="text-xs font-black uppercase tracking-widest text-gray-500">Generando análisis personalizado...</p>
</div>
) : (
<div className="space-y-6">
<div className="text-xl md:text-2xl text-gray-200 leading-relaxed font-medium">
{feedback}
</div>
<div className="h-px w-20 bg-blue-500/40 rounded-full"></div>
<p className="text-xs font-bold text-gray-500 italic uppercase tracking-wider">
Este análisis es generado automáticamente basándose en tu desempeño histórico y los contenidos de esta lección.
</p>
</div>
)}
</div>
</div>
<div className="lg:col-span-1 space-y-6">
<div className="glass p-8 rounded-[2rem] border border-white/5 bg-white/[0.02]">
<h3 className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-6 flex items-center gap-2">
<BookOpen size={16} className="text-blue-500" /> Próximos Pasos
</h3>
<ul className="space-y-4">
<li className="flex items-start gap-3 p-4 rounded-xl bg-white/5 hover:bg-white/10 transition-colors border border-transparent hover:border-white/10 group cursor-pointer">
<div className="w-8 h-8 rounded-lg bg-green-500/20 text-green-400 flex items-center justify-center shrink-0">
<Award size={16} />
</div>
<p className="text-xs font-bold text-gray-300 leading-tight group-hover:text-white transition-colors">Continúa con la siguiente lección para seguir sumando XP.</p>
</li>
<li className="flex items-start gap-3 p-4 rounded-xl bg-white/5 hover:bg-white/10 transition-colors border border-transparent hover:border-white/10 group cursor-pointer">
<div className="w-8 h-8 rounded-lg bg-blue-500/20 text-blue-400 flex items-center justify-center shrink-0">
<BookOpen size={16} />
</div>
<p className="text-xs font-bold text-gray-300 leading-tight group-hover:text-white transition-colors">Revisa el glosario de términos de esta sección.</p>
</li>
</ul>
</div>
<div className="p-8 rounded-[2rem] bg-amber-500/5 border border-amber-500/10">
<p className="text-[10px] font-black text-amber-500 uppercase tracking-widest mb-2">Nota Importante</p>
<p className="text-[11px] font-bold text-amber-500/70 leading-relaxed uppercase tracking-tight">
Has alcanzado el máximo de intentos. Esta evaluación está bloqueada, pero puedes seguir repasando los materiales del curso.
</p>
</div>
</div>
</div>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import { FileText, Download, Eye, ExternalLink } from "lucide-react";
import { CMS_API_URL } from "@/lib/api";
import { getCmsApiUrl } from "@/lib/api";
interface DocumentPlayerProps {
id: string;
@@ -18,7 +18,7 @@ export default function DocumentPlayer({ id, title, url }: DocumentPlayerProps)
if (path.startsWith('http')) return path;
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
return `${CMS_API_URL}${finalPath}`;
return `${getCmsApiUrl()}${finalPath}`;
};
const displayUrl = getFullUrl(url);
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { Play, Lock, AlertCircle } from "lucide-react";
import { lmsApi } from "@/lib/api";
import { lmsApi, getCmsApiUrl } from "@/lib/api";
interface MediaPlayerProps {
id: string;
@@ -46,14 +46,14 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
const maxPlays = config?.maxPlays || 0;
const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
const getFullUrl = (path: string) => {
if (path.startsWith('http')) return path;
// Map /uploads to /assets for the backend
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
return `${CMS_API_URL}${finalPath}`;
return `${getCmsApiUrl()}${finalPath}`;
};
const isLocalFile = url.startsWith('/uploads') || url.startsWith('http://localhost:3001/assets') || url.includes('/assets/');
@@ -129,8 +129,8 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
// Since browser <track> doesn't support custom headers easily,
// we might need to handle this via a proxy or temporary signed URLs.
// For now, we'll assume the backend allows VTT access if requested with the correct lesson ID.
const vttEn = lessonId ? `${CMS_API_URL}/lessons/${lessonId}/vtt?lang=en` : null;
const vttEs = lessonId ? `${CMS_API_URL}/lessons/${lessonId}/vtt?lang=es` : null;
const vttEn = lessonId ? `${getCmsApiUrl()}/lessons/${lessonId}/vtt?lang=en` : null;
const vttEs = lessonId ? `${getCmsApiUrl()}/lessons/${lessonId}/vtt?lang=es` : null;
return (
<div className="space-y-6" id={id}>
@@ -1,7 +1,7 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { lmsApi, Organization } from '@/lib/api';
import { lmsApi, Organization, getImageUrl } from '@/lib/api';
interface BrandingContextType {
branding: Organization | null;
@@ -45,14 +45,6 @@ export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ chil
// Import getImageUrl logic locally or assume it needs import
// Since I can't easily add import at top with replace_file, I will assume getImageUrl handles the path or do logic here.
// Actually I need to import getImageUrl at the top. Instead of complicating, I'll update imports too.
const getImageUrl = (path?: string) => {
if (!path) return '';
if (path.startsWith('http')) return path;
const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
return `${CMS_API_URL}${finalPath}`;
};
const faviconUrl = getImageUrl(data.favicon_url);
const link: HTMLLinkElement | null = document.querySelector("link[rel*='icon']");
+26 -8
View File
@@ -1,12 +1,21 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_LMS_API_URL || "http://localhost:3002";
export const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
return `${protocol}//${hostname}:${defaultPort}`;
}
return envVar || `http://localhost:${defaultPort}`;
};
export const getLmsApiUrl = () => getApiBaseUrl("3002", process.env.NEXT_PUBLIC_LMS_API_URL);
export const getCmsApiUrl = () => getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
export const getImageUrl = (path?: string) => {
if (!path) return '';
if (path.startsWith('http')) return path;
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
return `${CMS_API_URL}${finalPath}`;
return `${getCmsApiUrl()}${finalPath}`;
};
export interface Organization {
@@ -171,7 +180,7 @@ export interface Enrollment {
id: string;
user_id: string;
course_id: string;
enroled_at: string;
enrolled_at: string;
}
export interface Module {
@@ -186,7 +195,7 @@ const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('exp
const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
const token = getToken();
const baseUrl = isCMS ? CMS_API_URL : API_BASE_URL;
const baseUrl = isCMS ? getCmsApiUrl() : getLmsApiUrl();
const headers = {
'Content-Type': 'application/json',
...options.headers,
@@ -238,7 +247,7 @@ export const lmsApi = {
},
initSSOLogin(orgId: string): void {
window.location.href = `${CMS_API_URL}/auth/sso/login/${orgId}`;
window.location.href = `${getCmsApiUrl()}/auth/sso/login/${orgId}`;
},
async enroll(courseId: string, userId: string): Promise<void> {
@@ -286,7 +295,7 @@ export const lmsApi = {
const formData = new FormData();
formData.append('file', file);
const token = getToken();
return fetch(`${CMS_API_URL}/assets/upload`, {
return fetch(`${getCmsApiUrl()}/assets/upload`, {
method: 'POST',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
@@ -331,7 +340,7 @@ export const lmsApi = {
formData.append('keywords', JSON.stringify(keywords));
const token = getToken();
return fetch(`${API_BASE_URL}/audio/evaluate-file`, {
return fetch(`${getLmsApiUrl()}/audio/evaluate-file`, {
method: 'POST',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
@@ -344,5 +353,14 @@ export const lmsApi = {
}
return res.json();
});
},
async chatWithTutor(lessonId: string, message: string): Promise<{ response: string }> {
return apiFetch(`/lessons/${lessonId}/chat`, {
method: 'POST',
body: JSON.stringify({ message })
});
},
async getLessonFeedback(lessonId: string): Promise<{ response: string }> {
return apiFetch(`/lessons/${lessonId}/feedback`);
}
};
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { cmsApi, Organization, getImageUrl } from '@/lib/api';
import { cmsApi, Organization, getImageUrl, API_BASE_URL } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
import Image from 'next/image';
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X, Fingerprint, Key, Settings2 } from 'lucide-react';
@@ -176,18 +176,18 @@ export default function OrganizationsPage() {
}
return (
<div className="space-y-8 animate-in fade-in duration-500">
<div className="flex justify-between items-center">
<div className="space-y-6 md:space-y-8 animate-in fade-in duration-500 p-4 md:p-0">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Organizations</h1>
<p className="text-gray-400 mt-1">Manage tenants and isolated environments.</p>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Organizations</h1>
<p className="text-gray-400 mt-1 text-sm">Manage tenants and isolated environments.</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 shadow-glow"
className="w-full sm:w-auto flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 shadow-glow"
>
<Plus className="w-4 h-4" />
New Organization
<span className="inline">New Organization</span>
</button>
</div>
@@ -570,7 +570,7 @@ export default function OrganizationsPage() {
</div>
<p className="text-[10px] text-blue-300 leading-relaxed">
1. Register OpenCCB as an application in your Identity Provider (Okta, Google, Azure AD).<br />
2. Set the Redirect URI to: <span className="font-mono bg-blue-500/20 px-1">http://localhost:3001/auth/sso/callback</span><br />
2. Set the Redirect URI to: <span className="font-mono bg-blue-500/20 px-1">{API_BASE_URL}/auth/sso/callback</span><br />
3. Copy the Issuer URL, Client ID, and Client Secret here.
</p>
</div>
+1 -1
View File
@@ -237,7 +237,7 @@ export default function StudioLoginPage() {
<div className="mt-6 pt-6 border-t border-white/10 text-center">
<p className="text-sm text-gray-400">
Are you a student?{" "}
<a href="http://localhost:3003/auth/login" className="text-blue-400 hover:text-blue-300 font-bold">
<a href="http://192.168.0.254:3003/auth/login" className="text-blue-400 hover:text-blue-300 font-bold">
Go to Student Portal
</a>
</p>
+1 -1
View File
@@ -31,7 +31,7 @@ body {
}
.glass-card {
@apply glass rounded-2xl p-6;
@apply glass rounded-2xl p-4 md:p-6;
}
.btn-premium {
+1 -1
View File
@@ -29,7 +29,7 @@ export default function RootLayout({
<I18nProvider>
<AuthGuard>
<BrandingManager />
<header className="h-20 glass sticky top-0 z-50 px-8 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
<header className="h-16 md:h-20 glass sticky top-0 z-50 px-4 md:px-8 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform">
<BookOpen size={20} />
+8 -8
View File
@@ -10,17 +10,17 @@ export default function AuthHeader() {
<div className="flex items-center gap-4">
{user?.role === 'admin' && (
<>
<Link href="/admin/organizations" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
<Building2 size={16} /> Org
<Link href="/admin/organizations" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2" title="Organizations">
<Building2 size={16} /> <span className="hidden md:inline">Org</span>
</Link>
<Link href="/admin/audit" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
<ShieldAlert size={16} /> Audit
<Link href="/admin/audit" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2" title="Audit Logs">
<ShieldAlert size={16} /> <span className="hidden md:inline">Audit</span>
</Link>
<Link href="/admin/tasks" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
<Activity size={16} /> Tasks
<Link href="/admin/tasks" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2" title="Tasks">
<Activity size={16} /> <span className="hidden md:inline">Tasks</span>
</Link>
<Link href="/settings" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
<Settings size={16} /> Settings
<Link href="/settings" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2" title="Settings">
<Settings size={16} /> <span className="hidden md:inline">Settings</span>
</Link>
</>
)}
+11 -1
View File
@@ -1,4 +1,14 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
// Detect if we are on a custom domain or IP
const protocol = window.location.protocol;
return `${protocol}//${hostname}:${defaultPort}`;
}
return envVar || `http://localhost:${defaultPort}`;
};
export const API_BASE_URL = getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
export const getImageUrl = (path?: string) => {
if (!path) return '';