feat: Implement AI tutor functionality, add branding fields, and improve API URL handling.
This commit is contained in:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
[2m2026-01-23T16:11:08.024085Z[0m [32m INFO[0m [2msqlx::postgres::notice[0m[2m:[0m relation "_sqlx_migrations" already exists, skipping
|
||||
[2m2026-01-23T16:11:08.027458Z[0m [32m INFO[0m [2mlms_service[0m[2m:[0m 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
|
||||
}
|
||||
}
|
||||
[2m2026-01-23T16:12:42.241889Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
|
||||
[2m2026-01-23T16:12:42.247382Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
|
||||
[2m2026-01-23T16:12:47.935236Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
|
||||
[2m2026-01-23T16:12:50.853225Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
|
||||
[2m2026-01-23T16:12:50.860842Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
|
||||
[2m2026-01-23T16:12:53.661037Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
|
||||
[2m2026-01-23T16:12:57.221143Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
|
||||
[2m2026-01-23T16:12:57.239532Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
|
||||
[2m2026-01-23T16:12:58.825384Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
|
||||
[2m2026-01-23T16:12:58.834411Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
|
||||
[2m2026-01-23T16:13:00.543902Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
|
||||
[2m2026-01-23T16:13:00.559156Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb
|
||||
[2m2026-01-23T16:13:02.271758Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4
|
||||
[2m2026-01-23T16:22:32.087284Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m 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
|
||||
}
|
||||
}
|
||||
[2m2026-01-23T16:22:45.297187Z[0m [32m INFO[0m [2mlms_service::handlers[0m[2m:[0m 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
|
||||
}
|
||||
}
|
||||
Generated
+159
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"pg": "^8.17.2"
|
||||
}
|
||||
}
|
||||
+21
-6
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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']");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -31,7 +31,7 @@ body {
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
@apply glass rounded-2xl p-6;
|
||||
@apply glass rounded-2xl p-4 md:p-6;
|
||||
}
|
||||
|
||||
.btn-premium {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
Reference in New Issue
Block a user