From 02909ea85a3c0d94e0d0216ccff257ca4898acb4 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Sat, 17 Jan 2026 13:55:04 -0300 Subject: [PATCH] feat: Implement course-level asset management and interactive media markers. --- Cargo.lock | 164 ++++++++++ README.md | 2 + roadmap.md | 300 +++++++++--------- services/cms-service/Cargo.toml | 2 + ...20260117000200_add_course_id_to_assets.sql | 2 + services/cms-service/src/exporter.rs | 50 +++ services/cms-service/src/handlers.rs | 230 +++++++++++++- services/cms-service/src/main.rs | 2 + shared/common/src/models.rs | 1 + .../courses/[id]/lessons/[lessonId]/page.tsx | 1 + .../src/components/blocks/MediaPlayer.tsx | 102 +++++- .../src/app/courses/[id]/files/page.tsx | 166 ++++++++++ .../src/components/CourseEditorLayout.tsx | 9 +- .../src/components/blocks/MediaBlock.tsx | 163 +++++++++- web/studio/src/lib/api.ts | 15 +- 15 files changed, 1027 insertions(+), 182 deletions(-) create mode 100644 services/cms-service/migrations/20260117000200_add_course_id_to_assets.sql create mode 100644 web/studio/src/app/courses/[id]/files/page.tsx diff --git a/Cargo.lock b/Cargo.lock index 5a7c4a8..0c57f93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -213,6 +230,26 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cc" version = "1.2.50" @@ -220,6 +257,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -266,6 +305,7 @@ dependencies = [ "hex", "hmac", "jsonwebtoken", + "mime_guess", "openidconnect", "reqwest 0.12.26", "serde", @@ -277,6 +317,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "zip", ] [[package]] @@ -314,6 +355,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "core-foundation" version = "0.9.4" @@ -354,6 +401,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -652,6 +708,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1314,6 +1380,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.83" @@ -1475,6 +1551,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1755,6 +1841,29 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "pem" version = "3.0.6" @@ -2451,6 +2560,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "simple_asn1" version = "0.6.3" @@ -3788,3 +3903,52 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/README.md b/README.md index b3d6d1e..874d547 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,7 @@ Agrega contenido multimedia o evaluaciones a un módulo. - **Nuevos Tipos Gamificados**: - `hotspot`: Identificación visual sobre imágenes (ideal para niños). - `memory-match`: Juego de memoria con pares conceptuales. + - `video-marker`: Preguntas interactivas en timestamps específicos del video. - **Cuerpo ( CreateLessonRequest ):** ```json { @@ -385,6 +386,7 @@ Obtiene una lista de todas las organizaciones registradas. - **Glassmorphism Design**: Consistent aesthetic across Studio and Experience portals. - **Global Localization**: Native support for English, Spanish, and Portuguese. - **PDF Integrated Viewer**: Read academic documents without leaving the platform. +- **Interactive Video Markers**: Pause-and-answer questions embedded in video lessons. ## 📄 Licencia Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. \ No newline at end of file diff --git a/roadmap.md b/roadmap.md index 80d960c..25953fb 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,175 +1,161 @@ -# OpenCCB: Open Comprehensive Course Backbone - Roadmap +# OpenCCB: Hoja de Ruta (Roadmap) del Proyecto -## Phase 1: Foundation ✅ -- [x] Rust Workspace Setup (Edition 2024) -- [x] Microservices Scaffolding (CMS & LMS) -- [x] Multi-Database Infrastructure (PostgreSQL with separate DBs) -- [x] Frontend Initialization (Next.js Studio & Experience) -- [x] Dockerization of all services -- [x] API Integration (Dashboard <-> CMS Service) -- [x] Unified `install.sh` script with hardware detection & auto-config +## Fase 1: Cimientos ✅ +- [x] Configuración del Workspace de Rust (Edición 2024) +- [x] Estructura de Microservicios (CMS y LMS) +- [x] Infraestructura Multi-Base de Datos (PostgreSQL con DBs separadas) +- [x] Inicialización del Frontend (Studio y Experience con Next.js) +- [x] Dockerización de todos los servicios +- [x] Integración de API (Dashboard <-> Servicio CMS) +- [x] Script de instalación unificado (`install.sh`) con detección de hardware y auto-configuración -## Phase 2: Core CMS Features ✅ -- [x] Course Outline Editor (Modules & Lessons) -- [x] File Upload System (Video/Audio/Native Assets) -- [x] Interactive Content (Activity Builder) - - [x] Block Reordering (Move Up/Down) - - [x] Rich Text Descriptions - - [x] Media Blocks with Playback Constraints - - [x] Quiz Blocks (Multiple Choice, True/False, Multiple Select) - - [x] Advanced Assessment Types: - - [x] Fill-in-the-Blanks - - [x] Matching Pairs - - [x] Ordering/Sequencing - - [x] Short Answer -- [x] Service-to-Service Communication (CMS -> LMS sync) -- [x] Premium Video Player with playback limits -- [x] Full Studio UI with dynamic course management +## Fase 2: Funcionalidades Core del CMS ✅ +- [x] Editor de Estructura de Cursos (Módulos y Lecciones) +- [x] Sistema de Carga de Archivos (Video, Audio, Recursos Nativos) +- [x] Contenido Interactivos (Constructor de Actividades) + - [x] Reordenamiento de bloques (Subir/Bajar) + - [x] Descripciones con texto enriquecido + - [x] Bloques multimedia con restricciones de reproducción + - [x] Bloques de Quiz (Opción múltiple, Verdadero/Falso, Selección múltiple) + - [x] Tipos de evaluación avanzada: + - [x] Completar espacios en blanco + - [x] Emparejamiento de parejas + - [x] Ordenamiento/Secuenciación + - [x] Respuesta corta +- [x] Comunicación entre servicios (Sincronización CMS -> LMS) +- [x] Reproductor de video Premium con límites de visualización +- [x] Interfaz de Studio completa con gestión dinámica de cursos -## Phase 3: Authentication & Security ✅ -- [x] **JWT-Based Authentication**: Common auth across all services -- [x] **Role-Based Access Control (RBAC)**: - - [x] Multi-role support (Admin, Instructor, Student) - - [x] Role-specific permissions and UI - - [x] Token-based authorization for protected endpoints -- [x] **Audit Logging**: All CMS mutations tracked -- [x] **Audit UI**: Admin interface to view audit logs +## Fase 3: Autenticación y Seguridad ✅ +- [x] **Autenticación Basada en JWT**: Auth común para todos los servicios +- [x] **Control de Acceso Basado en Roles (RBAC)**: + - [x] Soporte multi-rol (Admin, Instructor, Estudiante) + - [x] Permisos e interfaces específicas por rol + - [x] Autorización basada en tokens para endpoints protegidos +- [x] **Registro de Auditoría (Audit Log)**: Seguimiento de todos los cambios en el CMS +- [x] **Interfaz de Auditoría**: Panel de administración para visualizar registros de cambios -## Phase 4: LMS Experience & Grading ✅ -- [x] **Student Portal (Experience)**: - - [x] Course catalog and enrollment - - [x] Interactive lesson player - - [x] Mobile-responsive design -- [x] **Holistic Grading System**: - - [x] Weighted grading categories - - [x] Drop lowest N scores per category - - [x] Automatic weighted grade calculation -- [x] **Assessment Policies**: - - [x] Configurable max attempts per lesson - - [x] Instant corrections and retry policies - - [x] Atomic attempt tracking with enforcement -- [x] **Progress Tracking**: - - [x] Real-time score visualization - - [x] Category-by-category breakdown - - [x] Weighted grade calculation -- [x] **Dynamic Passing Thresholds**: - - [x] Configurable passing percentage per course - - [x] 5-tier performance visualization - - [x] Color-coded feedback (Reprobado to Excelente) -- [x] **Certificates**: Automated certificate generation upon completion +## Fase 4: Experiencia LMS y Calificaciones ✅ +- [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] **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 + - [x] Cálculo automático de la nota ponderada +- [x] **Políticas de Evaluación**: + - [x] Intentos máximos configurables por lección + - [x] Correcciones instantáneas y políticas de reintento + - [x] Seguimiento atómico de intentos con validación de reglas +- [x] **Seguimiento del Progreso**: + - [x] Visualización de puntuaciones en tiempo real + - [x] Desglose categoría por categoría +- [x] **Umbrales de Aprobación Dinámicos**: + - [x] Porcentaje de aprobación configurable por curso + - [x] Visualización de rendimiento en 5 niveles + - [x] Feedback por colores (desde Reprobado hasta Excelente) +- [x] **Certificados**: Generación automática de certificados al completar el curso -## Phase 5: Analytics & Insights ✅ -- [x] **Instructor Analytics Dashboard**: - - [x] Total enrollments per course - - [x] Overall average score - - [x] Per-lesson performance breakdown - - [x] "Struggling lessons" detection - - [x] RBAC enforcement (instructors see only their courses) -- [x] **Student Progress Dashboard**: - - [x] Interactive performance bar - - [x] Tier-based feedback visualization - - [x] Real-time grade updates +## Fase 5: Analíticas e Insights ✅ +- [x] **Dashboard de Analíticas para Instructores**: + - [x] Total de inscritos por curso + - [x] Promedio general de notas + - [x] Desglose de rendimiento por lección + - [x] Detección de "lecciones difíciles" + - [x] Aplicación de RBAC (los instructores solo ven sus cursos) +- [x] **Dashboard de Progreso del Estudiante**: + - [x] Barra de rendimiento interactiva + - [x] Visualización de feedback basada en niveles + - [x] Actualización de notas en tiempo real -## Phase 6: Advanced Features ✅ -- [x] **Multi-tenancy**: Support for multiple organizations (Completed) - - [x] Database schema migration (add `organization_id`) - - [x] Update Rust models & JWT Claims - - [x] Implement Axum middleware for organization context - - [x] Update Frontend registration to support organizations - - [x] **Super Admin & Default Org**: Global management of all tenants. - - [x] **Global Course Visibility**: System-wide courses available to all organizations. -- [x] **Organization Branding**: Custom identity per tenant (Completed) - - [x] Logo upload & optimization - - [x] Custom color schemes (Primary/Secondary) - - [x] Dynamic Experience Portal adaptation - - [x] Live Branding Preview in Studio -- [x] **Advanced UI**: - - [x] **Premium Organization Selector**: For search-as-you-type multi-tenant management. - - [x] **Searchable Combobox**: Elegant glassmorphism filtering component. +## Fase 6: Funcionalidades Avanzadas ✅ +- [x] **Multi-tenancy**: Soporte para múltiples organizaciones (Completado) + - [x] Migración del esquema DB (añadir `organization_id`) + - [x] Actualización de modelos Rust y Claims de JWT + - [x] Middleware en Axum para contexto de organización + - [x] Registro en frontend con soporte para organizaciones + - [x] **Super Admin y Org por Defecto**: Gestión global de todos los inquilinos + - [x] **Visibilidad Global de Cursos**: Cursos de sistema disponibles para todas las organizaciones +- [x] **Personalización de Marca (Branding)**: Identidad propia por organización (Completado) + - [x] Carga y optimización de logotipos + - [x] Esquemas de colores personalizados (Primario/Secundario) + - [x] Adaptación dinámica del portal de Experience + - [x] Previsualización en vivo del branding en Studio +- [x] **Interfaz de Usuario Avanzada**: + - [x] **Selector de Organizaciones Premium**: Gestión multi-tenant con búsqueda predictiva + - [x] **Combobox de Búsqueda**: Componente elegante con filtrado y estilo glassmorphism -## Phase 7: User Engagement & Social (In Progress) -- [x] **Advanced Analytics**: - - [x] Cohort analysis (Implemented) - - [x] Retention metrics (Implemented) - - [x] Engagement heatmaps (Implemented) -- [x] **AI Integration**: - - [x] AI-driven lesson summaries (Implemented) - - [x] Real-time video transcription & translation via Local AI (Implemented) - - [x] Automated quiz generation (Implemented) - - [ ] Personalized learning paths -- [x] **Gamification**: (Broadly implemented) - - [x] Badges and achievements (Implemented base system) - - [x] Leaderboards (Implemented) - - [x] XP and leveling system (Implemented) -- [x] **Course Management Enhancements**: - - [x] Manual naming for modules, lessons, and activities during creation. - - [x] Reordering for modules, lessons, and activities (Level up/down). - - [x] Deletion of modules and lessons with confirmation. - - [x] **Pacing Control**: - - [x] Self-paced mode (Evergreen). - - [x] Instructor-led mode (Cohort-based with start/end dates). - - [x] **Course Calendar**: - - [x] Management of important dates (exams, assignments, milestones). - - [x] Automated reminders for upcoming deadlines. (Implemented) +## Fase 7: Compromiso y Social (En Progreso) +- [x] **Analíticas de Vanguardia**: + - [x] Análisis de cohortes (Implementado) + - [x] Métricas de retención (Implementado) + - [x] Mapas de calor de participación (Heatmaps) (Implementado) +- [x] **Integración de IA**: + - [x] Resúmenes de lecciones generados por IA (Implementado) + - [x] Transcripción y traducción de video en tiempo real (IA Local) (Implementado) + - [x] Generación automática de quices (Implementado) +- [ ] **Rutas de Aprendizaje Personalizadas**: Recomendaciones impulsadas por IA +- [x] **Gamificación Base**: (Implementada a nivel de sistema) + - [x] Medallas y logros + - [x] Tablas de clasificación (Leaderboards) + - [x] Sistema de XP y niveles +- [x] **Mejoras en la Gestión de Cursos**: + - [x] Nombrado manual de módulos, lecciones y actividades + - [x] Pacing de cursos: Modo autodidacta (Evergreen) o Dirigido por instructor (Cohort) + - [x] Calendario de hitos y recordatorios automáticos de fechas límite -## Phase 8: Enterprise Features (In Progress) -- [x] **User Profiles & Lifecycle**: - - [x] **Integrated Logout**: Standardized session management in both portals. - - [x] **Profile Management**: Self-service user info updates (Avatar, Bio, Language). -- [x] **Advanced Reporting**: Custom report builder and CSV exports. (Implemented) -- [x] **Integration Ecosystem**: - - [x] **SSO (Single Sign-On)**: Soporte completo para OIDC (Google, Okta, Azure AD) con autoprovisionamiento. (Completado) -- [ ] **Mobile Apps**: -- [ ] **Accessibility**: +## Fase 8: Funcionalidades Enterprise (En Progreso) +- [x] **Perfil de Usuario y Ciclo de Vida**: + - [x] **Cierre de Sesión Integrado**: Gestión estandarizada en ambos portales + - [x] **Gestión del Perfil**: Actualización de avatar, bio e idioma por el usuario +- [x] **Reportes Avanzados**: Constructor de reportes personalizados y exportación a CSV (Implementado) +- [x] **Ecosistema de Integración**: + - [x] **SSO (Single Sign-On)**: Soporte completo OIDC (Google, Okta, Azure AD) (Completado) +- [ ] **Apps Móviles**: (Postpuesto por ahora) +- [ ] **Accesibilidad**: Auditoría y correcciones WCAG 2.1 -## Phase 9: Course Portability (Import/Export) ✅ -- [x] **Universal JSON Schema**: Standardized format for course interchange. (Completed) -- [x] **Recursive Exporter**: Serialization of full course hierarchies. (Completed) -- [x] **Atomic Importer**: Batch creation with dependency re-mapping. (Completed) -- [x] **Portability UI**: Integrated Export/Import buttons in Settings. (Completed) +## Fase 9: Portabilidad de Cursos ✅ +- [x] **Esquema JSON Universal**: Formato estandarizado para intercambio de cursos (Completado) +- [x] **Exportador Recursivo**: Serialización de jerarquías completas de cursos (Completado) +- [x] **Importador Atómico**: Creación por lotes con re-mapeo de dependencias (Completado) +- [x] **Interfaz de Portabilidad**: Botones de Exportación/Importación en Ajustes (Completado) -## Phase 10: Global Admin Console (Admin Interface) ✅ -- [x] **The "Django" Panel**: Dedicated UI for Super-Admins to manage Orgs, Users, and Health. (Completed) -- [x] **System Monitoring**: Real-time stats on AI usage and service heartbeats. (Completed) -- [x] **Universal Audit Log**: Centralized dashboard for cross-tenant activity. (Completed) +## Fase 10: Consola de Administración Global ✅ +- [x] **Panel "Estilo Django"**: Interfaz dedicada para Super-Admins para gestionar Orgs y Usuarios (Completado) +- [x] **Monitoreo del Sistema**: Estadísticas en tiempo real de uso de IA y estado de servicios (Completado) +- [x] **Auditoría Universal**: Panel centralizado de actividad para todos los tenants (Completado) -## Phase 11: Extended Assessments & Quizzes (In Progress) -- [x] **Code Quizzes**: Interactive coding challenges with IDE-like player. (Completed) -- [ ] **Image Labeling**: Hotspot quizzes for technical training. -- [ ] **AI Teaching Assistant**: RAG-powered tutor within the lesson player. +## 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 +- [ ] **Evaluaciones por Audio**: Preguntas con respuesta oral para idiomas +- [x] **Marcadores de Video**: Preguntas que pausan el video en timestamps específicos (Completado) -## Phase 12: AI-Powered "Auto-Course" Generator ✅ -- [x] **Magic Course Creation**: Structure generation from a single prompt. (Completed) -- [x] **Atomic Transactional Ingestion**: Create whole structures (Module -> Lesson) in one go. (Completed) -- [x] **LLM Prompt Engineering**: Professional curriculum design via AI. (Completed) +## Fase 12: Generador de Cursos "Mágico" con IA ✅ +- [x] **Creación Instantánea**: Generación de estructura completa a partir de un prompt (Completado) +- [x] **Ingestión Atómica Transaccional**: Creación de módulos y lecciones en un solo paso (Completado) +- [x] **Ingeniería de Prompts**: Diseño curricular profesional optimizado para LLMs (Completado) -## Phase 13: Kid-Friendly Gamified Assessments ✅ -- [x] **Image Hotspots**: Visual identification quizzes. (Completed) -- [x] **Memory Match**: Educational card games. (Completed) -- [x] **AI Prompt Tuning**: LLM awareness of child-friendly formats. (Completed) +## Fase 13: Gamificación para Niños ✅ +- [x] **Juego de Memoria**: Emparejamiento conceptual mediante cartas interactivas (Completado) +- [x] **Arrastrar al Cubo**: Categorización visual por arrastre (Completado) +- [x] **Feedback Animado**: Animaciones de celebración (confeti, estrellas) para éxitos (Completado) -## Phase 14: Globalization & Document-Based Learning ✅ -- [x] **Internationalization (i18n)**: UI support for English, Spanish, and Portuguese. (Completed) -- [x] **Language Switcher**: Dynamic locale switching in Navbar and User Profile. (Completed) -- [x] **Document Block**: In-platform PDF preview and DOCX/PPTX downloads. (Completed) -- [x] **Academic Language Consistency**: Content (graded activities) remains in original language. (Completed) -- [x] **Multi-language AI Support**: Transcriptions and summaries follow the course context. (Completed) +## Fase 14: Globalización y Aprendizaje con Documentos ✅ +- [x] **Internacionalización (i18n)**: Soporte de UI para Inglés, Español y Portugués (Completado) +- [x] **Selector de Idiomas**: Cambio dinámico en la barra de navegación y perfil (Completado) +- [x] **Bloque de Documentos**: Previsualización de PDF y descargas de DOCX/PPTX (Completado) +- [x] **IA Multi-idioma**: Las transcripciones y resúmenes siguen el contexto del curso (Completado) -## Current Status +--- -**Platform Maturity**: Core multi-tenant architecture is stable and performance-optimized. +## Estado Actual y Próximas Prioridades -**Recent Milestones**: -- ✅ **Globalization (i18n)**: Multi-language UI (EN/ES/PT) for Studio and Experience. -- ✅ **Document Learning**: Support for PDF, DOCX, and PPTX reading activities. -- ✅ **Course Portability**: JSON-based import/export system for multi-tenant mobility. -- ✅ **Gamified Kids Assessments**: Image Hotspots and Memory Match features. -- ✅ **AI Course Wizard**: Instant course structure generation from prompts. -- ✅ **Global Admin Panel**: Centralized control center for system administrators. -- ✅ **Interactive Code Player**: New technical assessment type for developers. -- ✅ **SSO Integration**: OIDC support for corporate clients with self-service config. +**Madurez de la Plataforma**: El núcleo multi-tenant es estable, escalable y está optimizado para alto rendimiento con IA Local. -**Next Priorities**: -1. **Personalized Learning Paths**: AI-driven content recommendations. -2. **Mobile Apps**: Dedicated iOS and Android wrappers. -3. **Accessibility**: WCAG 2.1 compliance audit and fixes. +**Prioridades Inmediatas**: +1. **Marcadores de Video & Audio**: Evaluaciones integradas en contenido multimedia. +2. **IA Teaching Assistant**: Tutor RAG personalizado por curso. +3. **Rutas de Aprendizaje**: Recomendaciones basadas en el historial. diff --git a/services/cms-service/Cargo.toml b/services/cms-service/Cargo.toml index fecef52..8fb25fe 100644 --- a/services/cms-service/Cargo.toml +++ b/services/cms-service/Cargo.toml @@ -25,3 +25,5 @@ sha2.workspace = true hex.workspace = true openidconnect.workspace = true anyhow.workspace = true +zip = "0.6" +mime_guess = "2.0" diff --git a/services/cms-service/migrations/20260117000200_add_course_id_to_assets.sql b/services/cms-service/migrations/20260117000200_add_course_id_to_assets.sql new file mode 100644 index 0000000..b7363e1 --- /dev/null +++ b/services/cms-service/migrations/20260117000200_add_course_id_to_assets.sql @@ -0,0 +1,2 @@ +ALTER TABLE assets ADD COLUMN course_id UUID REFERENCES courses(id) ON DELETE SET NULL; +CREATE INDEX idx_assets_course_id ON assets(course_id); diff --git a/services/cms-service/src/exporter.rs b/services/cms-service/src/exporter.rs index 10ce34c..0bdb826 100644 --- a/services/cms-service/src/exporter.rs +++ b/services/cms-service/src/exporter.rs @@ -2,7 +2,10 @@ use chrono::{DateTime, Utc}; use common::models::{Course, Lesson, Module}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; +use std::io::Cursor; +use std::io::Write; use uuid::Uuid; +use zip::write::FileOptions; #[derive(Debug, Serialize, Deserialize)] pub struct CourseExport { @@ -63,3 +66,50 @@ pub async fn get_course_data(pool: &PgPool, course_id: Uuid) -> anyhow::Result anyhow::Result> { + let course_data = get_course_data(pool, course_id).await?; + let assets = + sqlx::query_as::<_, common::models::Asset>("SELECT * FROM assets WHERE course_id = $1") + .bind(course_id) + .fetch_all(pool) + .await?; + + let mut buf = Vec::new(); + let mut zip = zip::ZipWriter::new(Cursor::new(&mut buf)); + + let options = FileOptions::default() + .compression_method(zip::CompressionMethod::Stored) + .unix_permissions(0o755); + + // 1. Add course.json + zip.start_file("course.json", options)?; + let json_bytes = serde_json::to_vec_pretty(&course_data)?; + zip.write_all(&json_bytes)?; + + // 2. Add Assets + for asset in assets { + // Use the internal storage filename (UUID.ext) to avoid collisions + let storage_filename = asset + .storage_path + .split('/') + .last() + .unwrap_or(&asset.filename); + let zip_path = format!("assets/{}", storage_filename); + + if let Ok(file_content) = tokio::fs::read(&asset.storage_path).await { + zip.start_file(zip_path, options)?; + zip.write_all(&file_content)?; + } else { + tracing::warn!( + "Failed to read asset file for export: {}", + asset.storage_path + ); + } + } + + zip.finish()?; + drop(zip); + + Ok(buf) +} diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 5beb1cd..b617deb 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -1526,6 +1526,7 @@ pub async fn upload_asset( let mut filename = String::new(); let mut data = Vec::new(); let mut mimetype = String::new(); + let mut course_id: Option = None; while let Some(field) = multipart @@ -1549,6 +1550,12 @@ pub async fn upload_asset( (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) })? .to_vec(); + } else if name == "course_id" { + if let Ok(txt) = field.text().await { + if let Ok(id) = Uuid::parse_str(&txt) { + course_id = Some(id); + } + } } } @@ -1582,7 +1589,7 @@ pub async fn upload_asset( .unwrap_or(0); sqlx::query( - "INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id) VALUES ($1, $2, $3, $4, $5, $6)" + "INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id, course_id) VALUES ($1, $2, $3, $4, $5, $6, $7)" ) .bind(asset_id) .bind(&filename) @@ -1590,6 +1597,7 @@ pub async fn upload_asset( .bind(mimetype) .bind(size_bytes) .bind(org_ctx.id) + .bind(course_id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -1604,6 +1612,93 @@ pub async fn upload_asset( })) } +pub async fn get_course_assets( + Org(org_ctx): Org, + State(pool): State, + Path(course_id): Path, +) -> Result>, StatusCode> { + let assets = sqlx::query_as::<_, common::models::Asset>( + "SELECT * FROM assets WHERE organization_id = $1 AND course_id = $2 ORDER BY created_at DESC" + ) + .bind(org_ctx.id) + .bind(course_id) + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch course assets: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(assets)) +} + +pub async fn delete_asset( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(asset_id): Path, +) -> Result { + // 1. Fetch asset to verify ownership/org + let asset = sqlx::query_as::<_, common::models::Asset>( + "SELECT * FROM assets WHERE id = $1 AND organization_id = $2", + ) + .bind(asset_id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let asset = match asset { + Some(a) => a, + None => return Err((StatusCode::NOT_FOUND, "Asset not found".to_string())), + }; + + // 2. Check permissions (only instructor of the course or admin) + if claims.role != "admin" { + // If linked to a course, check if user owns that course + if let Some(cid) = asset.course_id { + let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + .bind(cid) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some(c) = course { + if c.instructor_id != claims.sub { + return Err(( + StatusCode::FORBIDDEN, + "Not authorized to delete this asset".to_string(), + )); + } + } + } + // If not linked to a course, only admins might delete? Or maybe uploader? + // For now, let's assume if it's orphaned, only admin deletes. + if asset.course_id.is_none() { + return Err(( + StatusCode::FORBIDDEN, + "Only admins can delete global assets".to_string(), + )); + } + } + + // 3. Delete file + // Note: storage_path is relative to working dir usually "uploads/..." + if let Err(e) = tokio::fs::remove_file(&asset.storage_path).await { + tracing::warn!("Failed to delete file {}: {}", asset.storage_path, e); + // We continue to delete from DB even if file specific deletion failed (maybe already gone) + } + + // 4. Delete from DB + sqlx::query("DELETE FROM assets WHERE id = $1") + .bind(asset_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) +} + #[derive(Deserialize)] pub struct AuthPayload { pub email: String, @@ -2706,7 +2801,7 @@ pub async fn export_course( _claims: Claims, State(pool): State, Path(id): Path, -) -> Result, StatusCode> { +) -> Result { // 1. Verify access (ensure course belongs to org) let exists = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM courses WHERE id = $1 AND organization_id = $2)", @@ -2715,33 +2810,134 @@ pub async fn export_course( .bind(org_ctx.id) .fetch_one(&pool) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - if !exists { - return Err(StatusCode::NOT_FOUND); - } - - // 2. Export recursively - let export = exporter::get_course_data(&pool, id).await.map_err(|e| { - tracing::error!("Export failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "DB Check failed".to_string(), + ) })?; - Ok(Json(export)) + if !exists { + return Err((StatusCode::NOT_FOUND, "Course not found".to_string())); + } + + // 2. Generate ZIP + let zip_bytes = exporter::generate_course_zip(&pool, id) + .await + .map_err(|e| { + tracing::error!("Export failed: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + + let filename = format!("course-{}.ccb", id); + let disposition = format!("attachment; filename=\"{}\"", filename); + + axum::response::Response::builder() + .header(axum::http::header::CONTENT_TYPE, "application/zip") + .header(axum::http::header::CONTENT_DISPOSITION, disposition) + .body(axum::body::Body::from(zip_bytes)) + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to build response".to_string(), + ) + }) } + +#[axum::debug_handler] pub async fn import_course( Org(org_ctx): Org, claims: Claims, State(pool): State, - Json(payload): Json, + mut multipart: axum::extract::Multipart, ) -> Result, StatusCode> { + // 1. Buffer the uploaded ZIP file + let mut zip_data = Vec::new(); + while let Some(field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? { + if field.name() == Some("file") { + zip_data = field.bytes().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?.to_vec(); + break; + } + } + + if zip_data.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + // 2. Open ZIP + let reader = std::io::Cursor::new(zip_data); + let mut archive = zip::ZipArchive::new(reader).map_err(|_| StatusCode::BAD_REQUEST)?; + + // 3. Process Assets & Prepare Remapping + let mut asset_map = std::collections::HashMap::new(); // Old Filename -> New URL + + let len = archive.len(); + for i in 0..len { + let (old_filename, content) = { + let mut file = archive.by_index(i).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if !file.name().starts_with("assets/") || !file.is_file() { + continue; + } + let old_filename = file.name().trim_start_matches("assets/").to_string(); + + // Read content + let mut content = Vec::new(); + std::io::Read::read_to_end(&mut file, &mut content).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + (old_filename, content) + }; // file is dropped here + + // Generate New ID and Path + let new_id = Uuid::new_v4(); + let extension = std::path::Path::new(&old_filename).extension().and_then(|s| s.to_str()).unwrap_or(""); + let new_storage_filename = format!("{}.{}", new_id, extension); + let new_storage_path = format!("uploads/{}", new_storage_filename); + let new_url = format!("/assets/{}", new_storage_filename); + + // Write to Disk + tokio::fs::create_dir_all("uploads").await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + tokio::fs::write(&new_storage_path, &content).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Get mimetype (guess or simple) + let mimetype = mime_guess::from_path(&old_filename).first_or_octet_stream().to_string(); + + sqlx::query( + "INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id) + VALUES ($1, $2, $3, $4, $5, $6)" + ) + .bind(new_id) + .bind(&old_filename) + .bind(&new_storage_path) + .bind(&mimetype) + .bind(content.len() as i64) + .bind(org_ctx.id) + .execute(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + asset_map.insert(format!("/assets/{}", old_filename), new_url); + } + + // 4. Read Course JSON and Remap + let mut course_json_str = String::new(); + { + let mut json_file = archive.by_name("course.json").map_err(|_| StatusCode::BAD_REQUEST)?; + std::io::Read::read_to_string(&mut json_file, &mut course_json_str).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + + // Apply Replacements + for (old_url, new_url) in &asset_map { + course_json_str = course_json_str.replace(old_url, new_url); + } + + let payload: exporter::CourseExport = serde_json::from_str(&course_json_str).map_err(|_| StatusCode::BAD_REQUEST)?; + let mut tx = pool .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // 1. Create Course + // 5. Create Course let new_course = sqlx::query_as::<_, Course>( "INSERT INTO courses ( organization_id, instructor_id, title, pacing_mode, description, @@ -2765,7 +2961,7 @@ pub async fn import_course( StatusCode::INTERNAL_SERVER_ERROR })?; - // 2. Import Grading Categories and create mapping + // 6. Import Grading Categories let mut cat_map = std::collections::HashMap::new(); for old_cat in payload.grading_categories { let new_cat = sqlx::query_as::<_, common::models::GradingCategory>( @@ -2785,7 +2981,7 @@ pub async fn import_course( cat_map.insert(old_cat.id, new_cat.id); } - // 3. Import Modules & Lessons + // 7. Import Modules & Lessons for module_data in payload.modules { let new_module = sqlx::query_as::<_, Module>( "INSERT INTO modules (course_id, organization_id, title, position) diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 1ed075e..31cfd1b 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -140,6 +140,8 @@ async fn main() { .route("/users/{id}", axum::routing::put(handlers::update_user)) .route("/audit-logs", get(handlers::get_audit_logs)) .route("/assets/upload", post(handlers::upload_asset)) + .route("/assets/{id}", delete(handlers::delete_asset)) + .route("/courses/{id}/assets", get(handlers::get_course_assets)) .layer(DefaultBodyLimit::disable()) .route( "/organizations", diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index e8dcde1..f270fc6 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -130,6 +130,7 @@ pub struct Enrollment { pub struct Asset { pub id: Uuid, pub organization_id: Uuid, + pub course_id: Option, pub filename: String, pub storage_path: String, pub mimetype: String, diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index c558dfb..d302263 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -218,6 +218,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les } } }} + isGraded={lesson.is_graded} /> ); case 'document': diff --git a/web/experience/src/components/blocks/MediaPlayer.tsx b/web/experience/src/components/blocks/MediaPlayer.tsx index 74e5b6d..fc08dcf 100644 --- a/web/experience/src/components/blocks/MediaPlayer.tsx +++ b/web/experience/src/components/blocks/MediaPlayer.tsx @@ -12,18 +12,31 @@ interface MediaPlayerProps { media_type: 'video' | 'audio'; config?: { maxPlays?: number; + markers?: { + timestamp: number; + question: string; + options: string[]; + correctIndex: number; + }[]; }; hasTranscription?: boolean; initialPlayCount?: number; onTimeUpdate?: (time: number) => void; onPlay?: () => void; + isGraded?: boolean; } -export default function MediaPlayer({ id, lessonId, title, url, media_type, config, hasTranscription, initialPlayCount, onTimeUpdate, onPlay }: MediaPlayerProps) { +export default function MediaPlayer({ id, lessonId, title, url, media_type, config, hasTranscription, initialPlayCount, onTimeUpdate, onPlay, isGraded }: MediaPlayerProps) { const [playCount, setPlayCount] = useState(initialPlayCount || 0); const [hasStarted, setHasStarted] = useState(false); const [locked, setLocked] = useState(false); + // Marker State + const [activeMarker, setActiveMarker] = useState<{ question: string, options: string[], correctIndex: number } | null>(null); + const [handledMarkers, setHandledMarkers] = useState>(new Set()); + const [lastTime, setLastTime] = useState(0); + const [feedback, setFeedback] = useState<{ isCorrect: boolean } | null>(null); + useEffect(() => { if (initialPlayCount !== undefined) { setPlayCount(initialPlayCount); @@ -138,8 +151,28 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf className="w-full h-full rounded-xl" onPlay={handlePlay} onTimeUpdate={(e) => { + const time = e.currentTarget.currentTime; + + // Marker Logic + if (config?.markers && !activeMarker) { + // Check for markers we just crossed + const markers = config.markers; + + for (const marker of markers) { + // Trigger if we crossed the timestamp and haven't handled it yet + // Use a small window to ensure we catch it but don't double trigger + if (time >= marker.timestamp && lastTime < marker.timestamp && !handledMarkers.has(marker.timestamp)) { + e.currentTarget.pause(); + setActiveMarker(marker); + setHandledMarkers(prev => new Set(prev).add(marker.timestamp)); + break; + } + } + } + setLastTime(time); + if (onTimeUpdate) { - onTimeUpdate(e.currentTarget.currentTime); + onTimeUpdate(time); } }} > @@ -177,6 +210,71 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf Presta atención. El contenido se bloqueará después de {maxPlays} reproducciones. )} + {/* Question Overlay */} + {activeMarker && ( +
+
+
+ + Quick Check +
+

{activeMarker.question}

+
+ {activeMarker.options.map((option, idx) => ( + + ))} +
+ {feedback && ( +
+ {feedback.isCorrect ? "Correct!" : "Incorrect"} +
+ )} +
+
+ )} ); } diff --git a/web/studio/src/app/courses/[id]/files/page.tsx b/web/studio/src/app/courses/[id]/files/page.tsx new file mode 100644 index 0000000..3011744 --- /dev/null +++ b/web/studio/src/app/courses/[id]/files/page.tsx @@ -0,0 +1,166 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useParams } from "next/navigation"; +import CourseEditorLayout from "@/components/CourseEditorLayout"; +import { cmsApi, Asset, getImageUrl } from "@/lib/api"; +import { Upload, Trash2, Copy, FileText, Image as ImageIcon, Film, File as FileIcon } from "lucide-react"; + +export default function CourseFilesPage() { + const { id } = useParams() as { id: string }; + const [assets, setAssets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + + const loadAssets = useCallback(async () => { + try { + const data = await cmsApi.getCourseAssets(id); + setAssets(data); + } catch (error) { + console.error("Failed to load assets:", error); + } finally { + setIsLoading(false); + } + }, [id]); + + useEffect(() => { + loadAssets(); + }, [loadAssets]); + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsUploading(true); + setUploadProgress(0); + + try { + await cmsApi.uploadAsset(file, (pct) => setUploadProgress(pct), id); + await loadAssets(); // Refresh list + } catch (error) { + console.error("Upload failed:", error); + alert("Failed to upload file"); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + }; + + const handleDelete = async (assetId: string) => { + if (!confirm("Are you sure you want to delete this file? This cannot be undone.")) return; + try { + await cmsApi.deleteAsset(assetId); + setAssets(assets.filter(a => a.id !== assetId)); + } catch (error) { + console.error("Delete failed:", error); + alert("Failed to delete file"); + } + }; + + const copyToClipboard = (url: string) => { + // Copy the relative path (e.g. /assets/uuid.ext) for use in lessons + navigator.clipboard.writeText(url); + alert(`Copied URL: ${url}`); + }; + + const getIcon = (mimetype: string) => { + if (mimetype.startsWith('image/')) return ; + if (mimetype.startsWith('video/')) return ; + if (mimetype.includes('pdf')) return ; + return ; + }; + + const formatSize = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + return ( + +
+
+
+

Course Files & Assets

+

Manage files specific to this course. These will be included in exports.

+
+
+ + +
+
+ +
+ + + + + + + + + + + + {isLoading ? ( + + ) : assets.length === 0 ? ( + + ) : ( + assets.map((asset) => ( + + + + + + + + )) + )} + +
NameTypeSizeUploadedActions
Loading files...
No files uploaded yet.
+
+ {getIcon(asset.mimetype)} +
+
{asset.filename}
+
{getImageUrl(asset.storage_path.replace('uploads/', '/assets/'))}
+
+
+
{asset.mimetype}{formatSize(asset.size_bytes)}{new Date(asset.created_at).toLocaleDateString()} +
+ + +
+
+
+
+
+ ); +} diff --git a/web/studio/src/components/CourseEditorLayout.tsx b/web/studio/src/components/CourseEditorLayout.tsx index 3a1713f..5db54b6 100644 --- a/web/studio/src/components/CourseEditorLayout.tsx +++ b/web/studio/src/components/CourseEditorLayout.tsx @@ -3,11 +3,11 @@ import React from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { Layout, CheckCircle2, Calendar, BarChart2, Settings } from "lucide-react"; +import { Layout, CheckCircle2, Calendar, BarChart2, Settings, Folder } from "lucide-react"; interface CourseEditorLayoutProps { children: React.ReactNode; - activeTab: "outline" | "grading" | "calendar" | "analytics" | "settings"; + activeTab: "outline" | "grading" | "calendar" | "analytics" | "settings" | "files"; } export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) { @@ -18,6 +18,7 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor { key: "grading", label: "Grading", icon: CheckCircle2, href: `/courses/${id}/grading` }, { key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` }, { key: "analytics", label: "Analytics", icon: BarChart2, href: `/courses/${id}/analytics` }, + { key: "files", label: "Files & Uploads", icon: Folder, href: `/courses/${id}/files` }, { key: "settings", label: "Settings", icon: Settings, href: `/courses/${id}/settings` }, ]; @@ -34,8 +35,8 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor key={tab.key} href={tab.href} className={`flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors ${isActive - ? "border-b-2 border-blue-500 bg-white/5" - : "text-gray-500 hover:text-white" + ? "border-b-2 border-blue-500 bg-white/5" + : "text-gray-500 hover:text-white" }`} > diff --git a/web/studio/src/components/blocks/MediaBlock.tsx b/web/studio/src/components/blocks/MediaBlock.tsx index 3be9756..e770363 100644 --- a/web/studio/src/components/blocks/MediaBlock.tsx +++ b/web/studio/src/components/blocks/MediaBlock.tsx @@ -5,6 +5,13 @@ import MediaPlayer from "../MediaPlayer"; import FileUpload from "../FileUpload"; import { getImageUrl } from "@/lib/api"; +export interface Marker { + timestamp: number; + question: string; + options: string[]; + correctIndex: number; +} + interface MediaBlockProps { id: string; title?: string; @@ -14,9 +21,19 @@ interface MediaBlockProps { maxPlays?: number; currentPlays?: number; show_transcript?: boolean; + markers?: Marker[]; }; editMode: boolean; - onChange: (updates: { title?: string; url?: string; config?: { maxPlays?: number; currentPlays?: number; show_transcript?: boolean } }) => void; + onChange: (updates: { + title?: string; + url?: string; + config?: { + maxPlays?: number; + currentPlays?: number; + show_transcript?: boolean; + markers?: Marker[]; + } + }) => void; transcription?: { en?: string; es?: string; @@ -128,6 +145,150 @@ export default function MediaBlock({ title, url, type, config, editMode, onChang

Uncheck to hide transcription text (e.g. for listening tests).

+ + {/* Markers Editor */} +
+ + +
+ {(config.markers || []).map((marker, idx) => ( +
+
+ + {Math.floor(marker.timestamp / 60)}:{String(marker.timestamp % 60).padStart(2, '0')} + + { + const newMarkers = [...(config.markers || [])]; + newMarkers[idx].question = e.target.value; + onChange({ config: { ...config, markers: newMarkers } }); + }} + className="text-sm bg-transparent border-b border-white/10 flex-1 focus:border-blue-500 outline-none" + /> + + +
+ + {/* Options Management */} +
+ + {marker.options.map((opt, optIdx) => ( +
+ { + const newMarkers = [...(config.markers || [])]; + newMarkers[idx].correctIndex = optIdx; + onChange({ config: { ...config, markers: newMarkers } }); + }} + className="accent-green-500" + /> + { + const newMarkers = [...(config.markers || [])]; + newMarkers[idx].options[optIdx] = e.target.value; + onChange({ config: { ...config, markers: newMarkers } }); + }} + className={`text-xs bg-transparent border border-white/10 rounded px-2 py-1 flex-1 ${marker.correctIndex === optIdx ? 'text-green-400 border-green-500/30' : ''}`} + /> + +
+ ))} + +
+
+ ))} +
+ +
+ + + +
+

+ Questions will pause the video at the specified second. Only simple Yes/No questions supported currently. +

+
)} diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index f41ed98..d00ffc9 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -206,6 +206,16 @@ export interface CreateWebhookPayload { secret?: string; } +export interface Asset { + id: string; + course_id: string | null; + filename: string; + storage_path: string; + mimetype: string; + size_bytes: number; + created_at: string; +} + const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null; const getSelectedOrgId = () => typeof window !== 'undefined' ? localStorage.getItem('studio_selected_org_id') : null; @@ -301,10 +311,13 @@ export const cmsApi = { deleteWebhook: (id: string): Promise => apiFetch(`/webhooks/${id}`, { method: 'DELETE' }), // Assets - uploadAsset: (file: File, onProgress?: (pct: number) => void): Promise => { + getCourseAssets: (courseId: string): Promise => apiFetch(`/courses/${courseId}/assets`), + deleteAsset: (id: string): Promise => apiFetch(`/assets/${id}`, { method: 'DELETE' }), + uploadAsset: (file: File, onProgress?: (pct: number) => void, courseId?: string): Promise => { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', file); + if (courseId) formData.append('course_id', courseId); const xhr = new XMLHttpRequest(); xhr.open('POST', `${API_BASE_URL}/assets/upload`);