feat: Implement course-level asset management and interactive media markers.

This commit is contained in:
2026-01-17 13:55:04 -03:00
parent 0772a88fbe
commit 02909ea85a
15 changed files with 1027 additions and 182 deletions
Generated
+164
View File
@@ -2,6 +2,23 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -213,6 +230,26 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" 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]] [[package]]
name = "cc" name = "cc"
version = "1.2.50" version = "1.2.50"
@@ -220,6 +257,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
@@ -266,6 +305,7 @@ dependencies = [
"hex", "hex",
"hmac", "hmac",
"jsonwebtoken", "jsonwebtoken",
"mime_guess",
"openidconnect", "openidconnect",
"reqwest 0.12.26", "reqwest 0.12.26",
"serde", "serde",
@@ -277,6 +317,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"zip",
] ]
[[package]] [[package]]
@@ -314,6 +355,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "constant_time_eq"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -354,6 +401,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" 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]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.12" version = "0.3.12"
@@ -652,6 +708,16 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" 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]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -1314,6 +1380,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 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]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.83" version = "0.3.83"
@@ -1475,6 +1551,16 @@ dependencies = [
"unicase", "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]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.1.1"
@@ -1755,6 +1841,29 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "pem" name = "pem"
version = "3.0.6" version = "3.0.6"
@@ -2451,6 +2560,12 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]] [[package]]
name = "simple_asn1" name = "simple_asn1"
version = "0.6.3" version = "0.6.3"
@@ -3788,3 +3903,52 @@ dependencies = [
"quote", "quote",
"syn", "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",
]
+2
View File
@@ -192,6 +192,7 @@ Agrega contenido multimedia o evaluaciones a un módulo.
- **Nuevos Tipos Gamificados**: - **Nuevos Tipos Gamificados**:
- `hotspot`: Identificación visual sobre imágenes (ideal para niños). - `hotspot`: Identificación visual sobre imágenes (ideal para niños).
- `memory-match`: Juego de memoria con pares conceptuales. - `memory-match`: Juego de memoria con pares conceptuales.
- `video-marker`: Preguntas interactivas en timestamps específicos del video.
- **Cuerpo ( CreateLessonRequest ):** - **Cuerpo ( CreateLessonRequest ):**
```json ```json
{ {
@@ -385,6 +386,7 @@ Obtiene una lista de todas las organizaciones registradas.
- **Glassmorphism Design**: Consistent aesthetic across Studio and Experience portals. - **Glassmorphism Design**: Consistent aesthetic across Studio and Experience portals.
- **Global Localization**: Native support for English, Spanish, and Portuguese. - **Global Localization**: Native support for English, Spanish, and Portuguese.
- **PDF Integrated Viewer**: Read academic documents without leaving the platform. - **PDF Integrated Viewer**: Read academic documents without leaving the platform.
- **Interactive Video Markers**: Pause-and-answer questions embedded in video lessons.
## 📄 Licencia ## 📄 Licencia
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
+143 -157
View File
@@ -1,175 +1,161 @@
# OpenCCB: Open Comprehensive Course Backbone - Roadmap # OpenCCB: Hoja de Ruta (Roadmap) del Proyecto
## Phase 1: Foundation ## Fase 1: Cimientos
- [x] Rust Workspace Setup (Edition 2024) - [x] Configuración del Workspace de Rust (Edición 2024)
- [x] Microservices Scaffolding (CMS & LMS) - [x] Estructura de Microservicios (CMS y LMS)
- [x] Multi-Database Infrastructure (PostgreSQL with separate DBs) - [x] Infraestructura Multi-Base de Datos (PostgreSQL con DBs separadas)
- [x] Frontend Initialization (Next.js Studio & Experience) - [x] Inicialización del Frontend (Studio y Experience con Next.js)
- [x] Dockerization of all services - [x] Dockerización de todos los servicios
- [x] API Integration (Dashboard <-> CMS Service) - [x] Integración de API (Dashboard <-> Servicio CMS)
- [x] Unified `install.sh` script with hardware detection & auto-config - [x] Script de instalación unificado (`install.sh`) con detección de hardware y auto-configuración
## Phase 2: Core CMS Features ## Fase 2: Funcionalidades Core del CMS
- [x] Course Outline Editor (Modules & Lessons) - [x] Editor de Estructura de Cursos (Módulos y Lecciones)
- [x] File Upload System (Video/Audio/Native Assets) - [x] Sistema de Carga de Archivos (Video, Audio, Recursos Nativos)
- [x] Interactive Content (Activity Builder) - [x] Contenido Interactivos (Constructor de Actividades)
- [x] Block Reordering (Move Up/Down) - [x] Reordenamiento de bloques (Subir/Bajar)
- [x] Rich Text Descriptions - [x] Descripciones con texto enriquecido
- [x] Media Blocks with Playback Constraints - [x] Bloques multimedia con restricciones de reproducción
- [x] Quiz Blocks (Multiple Choice, True/False, Multiple Select) - [x] Bloques de Quiz (Opción múltiple, Verdadero/Falso, Selección múltiple)
- [x] Advanced Assessment Types: - [x] Tipos de evaluación avanzada:
- [x] Fill-in-the-Blanks - [x] Completar espacios en blanco
- [x] Matching Pairs - [x] Emparejamiento de parejas
- [x] Ordering/Sequencing - [x] Ordenamiento/Secuenciación
- [x] Short Answer - [x] Respuesta corta
- [x] Service-to-Service Communication (CMS -> LMS sync) - [x] Comunicación entre servicios (Sincronización CMS -> LMS)
- [x] Premium Video Player with playback limits - [x] Reproductor de video Premium con límites de visualización
- [x] Full Studio UI with dynamic course management - [x] Interfaz de Studio completa con gestión dinámica de cursos
## Phase 3: Authentication & Security ## Fase 3: Autenticación y Seguridad
- [x] **JWT-Based Authentication**: Common auth across all services - [x] **Autenticación Basada en JWT**: Auth común para todos los servicios
- [x] **Role-Based Access Control (RBAC)**: - [x] **Control de Acceso Basado en Roles (RBAC)**:
- [x] Multi-role support (Admin, Instructor, Student) - [x] Soporte multi-rol (Admin, Instructor, Estudiante)
- [x] Role-specific permissions and UI - [x] Permisos e interfaces específicas por rol
- [x] Token-based authorization for protected endpoints - [x] Autorización basada en tokens para endpoints protegidos
- [x] **Audit Logging**: All CMS mutations tracked - [x] **Registro de Auditoría (Audit Log)**: Seguimiento de todos los cambios en el CMS
- [x] **Audit UI**: Admin interface to view audit logs - [x] **Interfaz de Auditoría**: Panel de administración para visualizar registros de cambios
## Phase 4: LMS Experience & Grading ## Fase 4: Experiencia LMS y Calificaciones
- [x] **Student Portal (Experience)**: - [x] **Portal del Estudiante (Experience)**:
- [x] Course catalog and enrollment - [x] Catálogo de cursos e inscripciones
- [x] Interactive lesson player - [x] Reproductor interactivo de lecciones
- [x] Mobile-responsive design - [x] Diseño responsivo (móviles/tablets)
- [x] **Holistic Grading System**: - [x] **Sistema de Calificación Holístico**:
- [x] Weighted grading categories - [x] Categorías de calificación con pesos (porcentajes)
- [x] Drop lowest N scores per category - [x] Opción de eliminar las N puntuaciones más bajas por categoría
- [x] Automatic weighted grade calculation - [x] Cálculo automático de la nota ponderada
- [x] **Assessment Policies**: - [x] **Políticas de Evaluación**:
- [x] Configurable max attempts per lesson - [x] Intentos máximos configurables por lección
- [x] Instant corrections and retry policies - [x] Correcciones instantáneas y políticas de reintento
- [x] Atomic attempt tracking with enforcement - [x] Seguimiento atómico de intentos con validación de reglas
- [x] **Progress Tracking**: - [x] **Seguimiento del Progreso**:
- [x] Real-time score visualization - [x] Visualización de puntuaciones en tiempo real
- [x] Category-by-category breakdown - [x] Desglose categoría por categoría
- [x] Weighted grade calculation - [x] **Umbrales de Aprobación Dinámicos**:
- [x] **Dynamic Passing Thresholds**: - [x] Porcentaje de aprobación configurable por curso
- [x] Configurable passing percentage per course - [x] Visualización de rendimiento en 5 niveles
- [x] 5-tier performance visualization - [x] Feedback por colores (desde Reprobado hasta Excelente)
- [x] Color-coded feedback (Reprobado to Excelente) - [x] **Certificados**: Generación automática de certificados al completar el curso
- [x] **Certificates**: Automated certificate generation upon completion
## Phase 5: Analytics & Insights ✅ ## Fase 5: Analíticas e Insights ✅
- [x] **Instructor Analytics Dashboard**: - [x] **Dashboard de Analíticas para Instructores**:
- [x] Total enrollments per course - [x] Total de inscritos por curso
- [x] Overall average score - [x] Promedio general de notas
- [x] Per-lesson performance breakdown - [x] Desglose de rendimiento por lección
- [x] "Struggling lessons" detection - [x] Detección de "lecciones difíciles"
- [x] RBAC enforcement (instructors see only their courses) - [x] Aplicación de RBAC (los instructores solo ven sus cursos)
- [x] **Student Progress Dashboard**: - [x] **Dashboard de Progreso del Estudiante**:
- [x] Interactive performance bar - [x] Barra de rendimiento interactiva
- [x] Tier-based feedback visualization - [x] Visualización de feedback basada en niveles
- [x] Real-time grade updates - [x] Actualización de notas en tiempo real
## Phase 6: Advanced Features ✅ ## Fase 6: Funcionalidades Avanzadas ✅
- [x] **Multi-tenancy**: Support for multiple organizations (Completed) - [x] **Multi-tenancy**: Soporte para múltiples organizaciones (Completado)
- [x] Database schema migration (add `organization_id`) - [x] Migración del esquema DB (añadir `organization_id`)
- [x] Update Rust models & JWT Claims - [x] Actualización de modelos Rust y Claims de JWT
- [x] Implement Axum middleware for organization context - [x] Middleware en Axum para contexto de organización
- [x] Update Frontend registration to support organizations - [x] Registro en frontend con soporte para organizaciones
- [x] **Super Admin & Default Org**: Global management of all tenants. - [x] **Super Admin y Org por Defecto**: Gestión global de todos los inquilinos
- [x] **Global Course Visibility**: System-wide courses available to all organizations. - [x] **Visibilidad Global de Cursos**: Cursos de sistema disponibles para todas las organizaciones
- [x] **Organization Branding**: Custom identity per tenant (Completed) - [x] **Personalización de Marca (Branding)**: Identidad propia por organización (Completado)
- [x] Logo upload & optimization - [x] Carga y optimización de logotipos
- [x] Custom color schemes (Primary/Secondary) - [x] Esquemas de colores personalizados (Primario/Secundario)
- [x] Dynamic Experience Portal adaptation - [x] Adaptación dinámica del portal de Experience
- [x] Live Branding Preview in Studio - [x] Previsualización en vivo del branding en Studio
- [x] **Advanced UI**: - [x] **Interfaz de Usuario Avanzada**:
- [x] **Premium Organization Selector**: For search-as-you-type multi-tenant management. - [x] **Selector de Organizaciones Premium**: Gestión multi-tenant con búsqueda predictiva
- [x] **Searchable Combobox**: Elegant glassmorphism filtering component. - [x] **Combobox de Búsqueda**: Componente elegante con filtrado y estilo glassmorphism
## Phase 7: User Engagement & Social (In Progress) ## Fase 7: Compromiso y Social (En Progreso)
- [x] **Advanced Analytics**: - [x] **Analíticas de Vanguardia**:
- [x] Cohort analysis (Implemented) - [x] Análisis de cohortes (Implementado)
- [x] Retention metrics (Implemented) - [x] Métricas de retención (Implementado)
- [x] Engagement heatmaps (Implemented) - [x] Mapas de calor de participación (Heatmaps) (Implementado)
- [x] **AI Integration**: - [x] **Integración de IA**:
- [x] AI-driven lesson summaries (Implemented) - [x] Resúmenes de lecciones generados por IA (Implementado)
- [x] Real-time video transcription & translation via Local AI (Implemented) - [x] Transcripción y traducción de video en tiempo real (IA Local) (Implementado)
- [x] Automated quiz generation (Implemented) - [x] Generación automática de quices (Implementado)
- [ ] Personalized learning paths - [ ] **Rutas de Aprendizaje Personalizadas**: Recomendaciones impulsadas por IA
- [x] **Gamification**: (Broadly implemented) - [x] **Gamificación Base**: (Implementada a nivel de sistema)
- [x] Badges and achievements (Implemented base system) - [x] Medallas y logros
- [x] Leaderboards (Implemented) - [x] Tablas de clasificación (Leaderboards)
- [x] XP and leveling system (Implemented) - [x] Sistema de XP y niveles
- [x] **Course Management Enhancements**: - [x] **Mejoras en la Gestión de Cursos**:
- [x] Manual naming for modules, lessons, and activities during creation. - [x] Nombrado manual de módulos, lecciones y actividades
- [x] Reordering for modules, lessons, and activities (Level up/down). - [x] Pacing de cursos: Modo autodidacta (Evergreen) o Dirigido por instructor (Cohort)
- [x] Deletion of modules and lessons with confirmation. - [x] Calendario de hitos y recordatorios automáticos de fechas límite
- [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)
## Phase 8: Enterprise Features (In Progress) ## Fase 8: Funcionalidades Enterprise (En Progreso)
- [x] **User Profiles & Lifecycle**: - [x] **Perfil de Usuario y Ciclo de Vida**:
- [x] **Integrated Logout**: Standardized session management in both portals. - [x] **Cierre de Sesión Integrado**: Gestión estandarizada en ambos portales
- [x] **Profile Management**: Self-service user info updates (Avatar, Bio, Language). - [x] **Gestión del Perfil**: Actualización de avatar, bio e idioma por el usuario
- [x] **Advanced Reporting**: Custom report builder and CSV exports. (Implemented) - [x] **Reportes Avanzados**: Constructor de reportes personalizados y exportación a CSV (Implementado)
- [x] **Integration Ecosystem**: - [x] **Ecosistema de Integración**:
- [x] **SSO (Single Sign-On)**: Soporte completo para OIDC (Google, Okta, Azure AD) con autoprovisionamiento. (Completado) - [x] **SSO (Single Sign-On)**: Soporte completo OIDC (Google, Okta, Azure AD) (Completado)
- [ ] **Mobile Apps**: - [ ] **Apps Móviles**: (Postpuesto por ahora)
- [ ] **Accessibility**: - [ ] **Accesibilidad**: Auditoría y correcciones WCAG 2.1
## Phase 9: Course Portability (Import/Export) ## Fase 9: Portabilidad de Cursos
- [x] **Universal JSON Schema**: Standardized format for course interchange. (Completed) - [x] **Esquema JSON Universal**: Formato estandarizado para intercambio de cursos (Completado)
- [x] **Recursive Exporter**: Serialization of full course hierarchies. (Completed) - [x] **Exportador Recursivo**: Serialización de jerarquías completas de cursos (Completado)
- [x] **Atomic Importer**: Batch creation with dependency re-mapping. (Completed) - [x] **Importador Atómico**: Creación por lotes con re-mapeo de dependencias (Completado)
- [x] **Portability UI**: Integrated Export/Import buttons in Settings. (Completed) - [x] **Interfaz de Portabilidad**: Botones de Exportación/Importación en Ajustes (Completado)
## Phase 10: Global Admin Console (Admin Interface) ## Fase 10: Consola de Administración Global
- [x] **The "Django" Panel**: Dedicated UI for Super-Admins to manage Orgs, Users, and Health. (Completed) - [x] **Panel "Estilo Django"**: Interfaz dedicada para Super-Admins para gestionar Orgs y Usuarios (Completado)
- [x] **System Monitoring**: Real-time stats on AI usage and service heartbeats. (Completed) - [x] **Monitoreo del Sistema**: Estadísticas en tiempo real de uso de IA y estado de servicios (Completado)
- [x] **Universal Audit Log**: Centralized dashboard for cross-tenant activity. (Completed) - [x] **Auditoría Universal**: Panel centralizado de actividad para todos los tenants (Completado)
## Phase 11: Extended Assessments & Quizzes (In Progress) ## Fase 11: Evaluaciones y Quizzes Extendidos (En Progreso)
- [x] **Code Quizzes**: Interactive coding challenges with IDE-like player. (Completed) - [x] **Quices de Código**: Desafíos interactivos con reproductor tipo IDE (Completado)
- [ ] **Image Labeling**: Hotspot quizzes for technical training. - [x] **Identificación Visual**: Quices de "Puntos Calientes" (Hotspots) en imágenes (Completado)
- [ ] **AI Teaching Assistant**: RAG-powered tutor within the lesson player. - [ ] **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 ## Fase 12: Generador de Cursos "Mágico" con IA
- [x] **Magic Course Creation**: Structure generation from a single prompt. (Completed) - [x] **Creación Instantánea**: Generación de estructura completa a partir de un prompt (Completado)
- [x] **Atomic Transactional Ingestion**: Create whole structures (Module -> Lesson) in one go. (Completed) - [x] **Ingestión Atómica Transaccional**: Creación de módulos y lecciones en un solo paso (Completado)
- [x] **LLM Prompt Engineering**: Professional curriculum design via AI. (Completed) - [x] **Ingeniería de Prompts**: Diseño curricular profesional optimizado para LLMs (Completado)
## Phase 13: Kid-Friendly Gamified Assessments ✅ ## Fase 13: Gamificación para Niños ✅
- [x] **Image Hotspots**: Visual identification quizzes. (Completed) - [x] **Juego de Memoria**: Emparejamiento conceptual mediante cartas interactivas (Completado)
- [x] **Memory Match**: Educational card games. (Completed) - [x] **Arrastrar al Cubo**: Categorización visual por arrastre (Completado)
- [x] **AI Prompt Tuning**: LLM awareness of child-friendly formats. (Completed) - [x] **Feedback Animado**: Animaciones de celebración (confeti, estrellas) para éxitos (Completado)
## Phase 14: Globalization & Document-Based Learning ## Fase 14: Globalización y Aprendizaje con Documentos
- [x] **Internationalization (i18n)**: UI support for English, Spanish, and Portuguese. (Completed) - [x] **Internacionalización (i18n)**: Soporte de UI para Inglés, Español y Portugués (Completado)
- [x] **Language Switcher**: Dynamic locale switching in Navbar and User Profile. (Completed) - [x] **Selector de Idiomas**: Cambio dinámico en la barra de navegación y perfil (Completado)
- [x] **Document Block**: In-platform PDF preview and DOCX/PPTX downloads. (Completed) - [x] **Bloque de Documentos**: Previsualización de PDF y descargas de DOCX/PPTX (Completado)
- [x] **Academic Language Consistency**: Content (graded activities) remains in original language. (Completed) - [x] **IA Multi-idioma**: Las transcripciones y resúmenes siguen el contexto del curso (Completado)
- [x] **Multi-language AI Support**: Transcriptions and summaries follow the course context. (Completed)
## Current Status ---
**Platform Maturity**: Core multi-tenant architecture is stable and performance-optimized. ## Estado Actual y Próximas Prioridades
**Recent Milestones**: **Madurez de la Plataforma**: El núcleo multi-tenant es estable, escalable y está optimizado para alto rendimiento con IA Local.
-**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.
**Next Priorities**: **Prioridades Inmediatas**:
1. **Personalized Learning Paths**: AI-driven content recommendations. 1. **Marcadores de Video & Audio**: Evaluaciones integradas en contenido multimedia.
2. **Mobile Apps**: Dedicated iOS and Android wrappers. 2. **IA Teaching Assistant**: Tutor RAG personalizado por curso.
3. **Accessibility**: WCAG 2.1 compliance audit and fixes. 3. **Rutas de Aprendizaje**: Recomendaciones basadas en el historial.
+2
View File
@@ -25,3 +25,5 @@ sha2.workspace = true
hex.workspace = true hex.workspace = true
openidconnect.workspace = true openidconnect.workspace = true
anyhow.workspace = true anyhow.workspace = true
zip = "0.6"
mime_guess = "2.0"
@@ -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);
+50
View File
@@ -2,7 +2,10 @@ use chrono::{DateTime, Utc};
use common::models::{Course, Lesson, Module}; use common::models::{Course, Lesson, Module};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use std::io::Cursor;
use std::io::Write;
use uuid::Uuid; use uuid::Uuid;
use zip::write::FileOptions;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct CourseExport { pub struct CourseExport {
@@ -63,3 +66,50 @@ pub async fn get_course_data(pool: &PgPool, course_id: Uuid) -> anyhow::Result<C
exported_at: Utc::now(), exported_at: Utc::now(),
}) })
} }
pub async fn generate_course_zip(pool: &PgPool, course_id: Uuid) -> anyhow::Result<Vec<u8>> {
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)
}
+213 -17
View File
@@ -1526,6 +1526,7 @@ pub async fn upload_asset(
let mut filename = String::new(); let mut filename = String::new();
let mut data = Vec::new(); let mut data = Vec::new();
let mut mimetype = String::new(); let mut mimetype = String::new();
let mut course_id: Option<Uuid> = None;
while let Some(field) = while let Some(field) =
multipart multipart
@@ -1549,6 +1550,12 @@ pub async fn upload_asset(
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})? })?
.to_vec(); .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); .unwrap_or(0);
sqlx::query( 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(asset_id)
.bind(&filename) .bind(&filename)
@@ -1590,6 +1597,7 @@ pub async fn upload_asset(
.bind(mimetype) .bind(mimetype)
.bind(size_bytes) .bind(size_bytes)
.bind(org_ctx.id) .bind(org_ctx.id)
.bind(course_id)
.execute(&pool) .execute(&pool)
.await .await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .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<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<Vec<common::models::Asset>>, 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<PgPool>,
Path(asset_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
// 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)] #[derive(Deserialize)]
pub struct AuthPayload { pub struct AuthPayload {
pub email: String, pub email: String,
@@ -2706,7 +2801,7 @@ pub async fn export_course(
_claims: Claims, _claims: Claims,
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<exporter::CourseExport>, StatusCode> { ) -> Result<impl axum::response::IntoResponse, (StatusCode, String)> {
// 1. Verify access (ensure course belongs to org) // 1. Verify access (ensure course belongs to org)
let exists = sqlx::query_scalar::<_, bool>( let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM courses WHERE id = $1 AND organization_id = $2)", "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) .bind(org_ctx.id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| {
(
if !exists { StatusCode::INTERNAL_SERVER_ERROR,
return Err(StatusCode::NOT_FOUND); "DB Check failed".to_string(),
} )
// 2. Export recursively
let export = exporter::get_course_data(&pool, id).await.map_err(|e| {
tracing::error!("Export failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
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( pub async fn import_course(
Org(org_ctx): Org, Org(org_ctx): Org,
claims: Claims, claims: Claims,
State(pool): State<PgPool>, State(pool): State<PgPool>,
Json(payload): Json<exporter::CourseExport>, mut multipart: axum::extract::Multipart,
) -> Result<Json<Course>, StatusCode> { ) -> Result<Json<Course>, 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 let mut tx = pool
.begin() .begin()
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 1. Create Course // 5. Create Course
let new_course = sqlx::query_as::<_, Course>( let new_course = sqlx::query_as::<_, Course>(
"INSERT INTO courses ( "INSERT INTO courses (
organization_id, instructor_id, title, pacing_mode, description, organization_id, instructor_id, title, pacing_mode, description,
@@ -2765,7 +2961,7 @@ pub async fn import_course(
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
// 2. Import Grading Categories and create mapping // 6. Import Grading Categories
let mut cat_map = std::collections::HashMap::new(); let mut cat_map = std::collections::HashMap::new();
for old_cat in payload.grading_categories { for old_cat in payload.grading_categories {
let new_cat = sqlx::query_as::<_, common::models::GradingCategory>( 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); cat_map.insert(old_cat.id, new_cat.id);
} }
// 3. Import Modules & Lessons // 7. Import Modules & Lessons
for module_data in payload.modules { for module_data in payload.modules {
let new_module = sqlx::query_as::<_, Module>( let new_module = sqlx::query_as::<_, Module>(
"INSERT INTO modules (course_id, organization_id, title, position) "INSERT INTO modules (course_id, organization_id, title, position)
+2
View File
@@ -140,6 +140,8 @@ async fn main() {
.route("/users/{id}", axum::routing::put(handlers::update_user)) .route("/users/{id}", axum::routing::put(handlers::update_user))
.route("/audit-logs", get(handlers::get_audit_logs)) .route("/audit-logs", get(handlers::get_audit_logs))
.route("/assets/upload", post(handlers::upload_asset)) .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()) .layer(DefaultBodyLimit::disable())
.route( .route(
"/organizations", "/organizations",
+1
View File
@@ -130,6 +130,7 @@ pub struct Enrollment {
pub struct Asset { pub struct Asset {
pub id: Uuid, pub id: Uuid,
pub organization_id: Uuid, pub organization_id: Uuid,
pub course_id: Option<Uuid>,
pub filename: String, pub filename: String,
pub storage_path: String, pub storage_path: String,
pub mimetype: String, pub mimetype: String,
@@ -218,6 +218,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
} }
} }
}} }}
isGraded={lesson.is_graded}
/> />
); );
case 'document': case 'document':
@@ -12,18 +12,31 @@ interface MediaPlayerProps {
media_type: 'video' | 'audio'; media_type: 'video' | 'audio';
config?: { config?: {
maxPlays?: number; maxPlays?: number;
markers?: {
timestamp: number;
question: string;
options: string[];
correctIndex: number;
}[];
}; };
hasTranscription?: boolean; hasTranscription?: boolean;
initialPlayCount?: number; initialPlayCount?: number;
onTimeUpdate?: (time: number) => void; onTimeUpdate?: (time: number) => void;
onPlay?: () => 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 [playCount, setPlayCount] = useState(initialPlayCount || 0);
const [hasStarted, setHasStarted] = useState(false); const [hasStarted, setHasStarted] = useState(false);
const [locked, setLocked] = 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<Set<number>>(new Set());
const [lastTime, setLastTime] = useState(0);
const [feedback, setFeedback] = useState<{ isCorrect: boolean } | null>(null);
useEffect(() => { useEffect(() => {
if (initialPlayCount !== undefined) { if (initialPlayCount !== undefined) {
setPlayCount(initialPlayCount); setPlayCount(initialPlayCount);
@@ -138,8 +151,28 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
className="w-full h-full rounded-xl" className="w-full h-full rounded-xl"
onPlay={handlePlay} onPlay={handlePlay}
onTimeUpdate={(e) => { 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) { if (onTimeUpdate) {
onTimeUpdate(e.currentTarget.currentTime); onTimeUpdate(time);
} }
}} }}
> >
@@ -177,6 +210,71 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
<span>Presta atención. El contenido se bloqueará después de {maxPlays} reproducciones.</span> <span>Presta atención. El contenido se bloqueará después de {maxPlays} reproducciones.</span>
</div> </div>
)} )}
{/* Question Overlay */}
{activeMarker && (
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md rounded-xl animate-in fade-in duration-300">
<div className="bg-white text-black p-6 rounded-2xl shadow-2xl max-w-sm w-full space-y-4">
<div className="flex items-center gap-2 text-blue-600 font-bold text-xs uppercase tracking-widest">
<AlertCircle size={16} />
<span>Quick Check</span>
</div>
<h4 className="text-xl font-bold">{activeMarker.question}</h4>
<div className="grid grid-cols-2 gap-3">
{activeMarker.options.map((option, idx) => (
<button
key={idx}
disabled={!!feedback}
onClick={() => {
const isCorrect = idx === activeMarker.correctIndex;
if (isGraded) {
// Graded Mode: Show feedback then continue
setFeedback({ isCorrect });
// Save answer to backend (mocked for now)
console.log(`Submitted answer for marker at ${activeMarker}: ${isCorrect ? 'Correct' : 'Wrong'}`);
setTimeout(() => {
setFeedback(null);
setActiveMarker(null);
const video = document.querySelector(`div[id="${id}"] video`) as HTMLVideoElement;
if (video) video.play();
}, 1500);
} else {
// Formative Mode: Block until correct
if (isCorrect) {
setFeedback({ isCorrect: true });
setTimeout(() => {
setFeedback(null);
setActiveMarker(null);
const video = document.querySelector(`div[id="${id}"] video`) as HTMLVideoElement;
if (video) video.play();
}, 1000);
} else {
setFeedback({ isCorrect: false });
alert("Try again! (This is just practice)");
setFeedback(null);
}
}
}}
className={`px-4 py-3 rounded-xl font-medium transition-all text-left ${feedback
? idx === activeMarker.correctIndex
? "bg-green-500 text-white"
: feedback.isCorrect === false && "bg-red-500 text-white"
: "bg-gray-100 hover:bg-blue-500 hover:text-white"
}`}
>
{option}
</button>
))}
</div>
{feedback && (
<div className={`mt-2 text-center text-sm font-bold uppercase tracking-widest ${feedback.isCorrect ? 'text-green-600' : 'text-red-500'}`}>
{feedback.isCorrect ? "Correct!" : "Incorrect"}
</div>
)}
</div>
</div>
)}
</div> </div>
); );
} }
@@ -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<Asset[]>([]);
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<HTMLInputElement>) => {
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 <ImageIcon className="w-8 h-8 text-blue-400" />;
if (mimetype.startsWith('video/')) return <Film className="w-8 h-8 text-purple-400" />;
if (mimetype.includes('pdf')) return <FileText className="w-8 h-8 text-red-400" />;
return <FileIcon className="w-8 h-8 text-gray-400" />;
};
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 (
<CourseEditorLayout activeTab="files">
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Course Files & Assets</h1>
<p className="text-gray-400 mt-1">Manage files specific to this course. These will be included in exports.</p>
</div>
<div className="relative">
<input
type="file"
onChange={handleUpload}
className="hidden"
id="file-upload"
disabled={isUploading}
/>
<label
htmlFor="file-upload"
className={`btn btn-primary gap-2 cursor-pointer ${isUploading ? 'loading' : ''}`}
>
{!isUploading && <Upload className="w-4 h-4" />}
{isUploading ? `Uploading ${uploadProgress}%` : 'Upload File'}
</label>
</div>
</div>
<div className="glass rounded-xl overflow-hidden">
<table className="w-full text-left">
<thead className="bg-white/5 border-b border-white/10 text-gray-400 font-medium">
<tr>
<th className="p-4">Name</th>
<th className="p-4">Type</th>
<th className="p-4">Size</th>
<th className="p-4">Uploaded</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{isLoading ? (
<tr><td colSpan={5} className="p-8 text-center text-gray-500">Loading files...</td></tr>
) : assets.length === 0 ? (
<tr><td colSpan={5} className="p-12 text-center text-gray-500">No files uploaded yet.</td></tr>
) : (
assets.map((asset) => (
<tr key={asset.id} className="hover:bg-white/5 transition-colors">
<td className="p-4">
<div className="flex items-center gap-3">
{getIcon(asset.mimetype)}
<div>
<div className="font-medium text-white">{asset.filename}</div>
<div className="text-xs text-blue-400">{getImageUrl(asset.storage_path.replace('uploads/', '/assets/'))}</div>
</div>
</div>
</td>
<td className="p-4 text-gray-400 font-mono text-sm">{asset.mimetype}</td>
<td className="p-4 text-gray-400 text-sm">{formatSize(asset.size_bytes)}</td>
<td className="p-4 text-gray-400 text-sm">{new Date(asset.created_at).toLocaleDateString()}</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => copyToClipboard(asset.storage_path.replace('uploads/', '/assets/'))}
title="Copy Internal URL"
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-blue-400"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(asset.id)}
title="Delete File"
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</CourseEditorLayout>
);
}
@@ -3,11 +3,11 @@
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; 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 { interface CourseEditorLayoutProps {
children: React.ReactNode; children: React.ReactNode;
activeTab: "outline" | "grading" | "calendar" | "analytics" | "settings"; activeTab: "outline" | "grading" | "calendar" | "analytics" | "settings" | "files";
} }
export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) { 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: "grading", label: "Grading", icon: CheckCircle2, href: `/courses/${id}/grading` },
{ key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` }, { key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` },
{ key: "analytics", label: "Analytics", icon: BarChart2, href: `/courses/${id}/analytics` }, { 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` }, { key: "settings", label: "Settings", icon: Settings, href: `/courses/${id}/settings` },
]; ];
@@ -34,8 +35,8 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
key={tab.key} key={tab.key}
href={tab.href} href={tab.href}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors ${isActive 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" ? "border-b-2 border-blue-500 bg-white/5"
: "text-gray-500 hover:text-white" : "text-gray-500 hover:text-white"
}`} }`}
> >
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
+162 -1
View File
@@ -5,6 +5,13 @@ import MediaPlayer from "../MediaPlayer";
import FileUpload from "../FileUpload"; import FileUpload from "../FileUpload";
import { getImageUrl } from "@/lib/api"; import { getImageUrl } from "@/lib/api";
export interface Marker {
timestamp: number;
question: string;
options: string[];
correctIndex: number;
}
interface MediaBlockProps { interface MediaBlockProps {
id: string; id: string;
title?: string; title?: string;
@@ -14,9 +21,19 @@ interface MediaBlockProps {
maxPlays?: number; maxPlays?: number;
currentPlays?: number; currentPlays?: number;
show_transcript?: boolean; show_transcript?: boolean;
markers?: Marker[];
}; };
editMode: boolean; 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?: { transcription?: {
en?: string; en?: string;
es?: string; es?: string;
@@ -128,6 +145,150 @@ export default function MediaBlock({ title, url, type, config, editMode, onChang
<p className="text-[10px] text-gray-500 uppercase leading-relaxed mt-2">Uncheck to hide transcription text (e.g. for listening tests).</p> <p className="text-[10px] text-gray-500 uppercase leading-relaxed mt-2">Uncheck to hide transcription text (e.g. for listening tests).</p>
</div> </div>
</div> </div>
{/* Markers Editor */}
<div className="space-y-4 pt-6 border-t border-white/10">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest block">Interactive Questions (Timestamps)</label>
<div className="space-y-2">
{(config.markers || []).map((marker, idx) => (
<div key={idx} className="bg-white/5 p-4 rounded-lg border border-white/5 space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-mono bg-blue-500/20 text-blue-400 px-2 py-1 rounded">
{Math.floor(marker.timestamp / 60)}:{String(marker.timestamp % 60).padStart(2, '0')}
</span>
<input
value={marker.question}
onChange={(e) => {
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"
/>
<button
onClick={() => {
const newMarkers = [...(config.markers || [])];
// Change order safely
if (idx > 0) {
[newMarkers[idx], newMarkers[idx - 1]] = [newMarkers[idx - 1], newMarkers[idx]];
newMarkers[idx].timestamp = newMarkers[idx - 1].timestamp; // Keep timestamp (?) No, we sort by timestamp usually.
// Simpler delete logic only for now. Reordering happens automatically by sort on Add.
}
}}
className="text-gray-500 hover:text-white hidden"
>
</button>
<button
onClick={() => {
const newMarkers = [...(config.markers || [])];
newMarkers.splice(idx, 1);
onChange({ config: { ...config, markers: newMarkers } });
}}
className="text-red-400 hover:text-red-300 p-1"
>
×
</button>
</div>
{/* Options Management */}
<div className="pl-14 space-y-2">
<label className="text-[10px] font-bold text-gray-500 uppercase">Options</label>
{marker.options.map((opt, optIdx) => (
<div key={optIdx} className="flex items-center gap-2">
<input
type="radio"
name={`correct-${idx}`}
checked={marker.correctIndex === optIdx}
onChange={() => {
const newMarkers = [...(config.markers || [])];
newMarkers[idx].correctIndex = optIdx;
onChange({ config: { ...config, markers: newMarkers } });
}}
className="accent-green-500"
/>
<input
value={opt}
onChange={(e) => {
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' : ''}`}
/>
<button
onClick={() => {
const newMarkers = [...(config.markers || [])];
newMarkers[idx].options.splice(optIdx, 1);
if (newMarkers[idx].correctIndex >= optIdx) {
newMarkers[idx].correctIndex = Math.max(0, newMarkers[idx].correctIndex - 1);
}
onChange({ config: { ...config, markers: newMarkers } });
}}
className="text-gray-600 hover:text-red-400 px-2"
>
×
</button>
</div>
))}
<button
onClick={() => {
const newMarkers = [...(config.markers || [])];
newMarkers[idx].options.push(`Option ${newMarkers[idx].options.length + 1}`);
onChange({ config: { ...config, markers: newMarkers } });
}}
className="text-[10px] text-blue-400 hover:text-blue-300 uppercase font-bold tracking-widest mt-1"
>
+ Add Option
</button>
</div>
</div>
))}
</div>
<div className="grid grid-cols-4 gap-2">
<input
code-type="number"
placeholder="Sec"
id="new-marker-time"
className="col-span-1 bg-white/5 border border-white/10 rounded px-3 py-2 text-sm"
/>
<input
type="text"
placeholder="Question?"
id="new-marker-question"
className="col-span-2 bg-white/5 border border-white/10 rounded px-3 py-2 text-sm"
/>
<button
onClick={() => {
const timeInput = document.getElementById('new-marker-time') as HTMLInputElement;
const questionInput = document.getElementById('new-marker-question') as HTMLInputElement;
const time = parseInt(timeInput.value);
const question = questionInput.value;
if (time >= 0 && question) {
const newMarker: Marker = {
timestamp: time,
question,
options: ["Yes", "No"], // Default options
correctIndex: 0
};
const newMarkers = [...(config.markers || []), newMarker].sort((a, b) => a.timestamp - b.timestamp);
onChange({ config: { ...config, markers: newMarkers } });
timeInput.value = "";
questionInput.value = "";
}
}}
className="col-span-1 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs font-bold uppercase"
>
Add
</button>
</div>
<p className="text-[10px] text-gray-500 uppercase leading-relaxed">
Questions will pause the video at the specified second. Only simple Yes/No questions supported currently.
</p>
</div>
</div> </div>
)} )}
+14 -1
View File
@@ -206,6 +206,16 @@ export interface CreateWebhookPayload {
secret?: string; 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 getToken = () => typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null;
const getSelectedOrgId = () => typeof window !== 'undefined' ? localStorage.getItem('studio_selected_org_id') : null; const getSelectedOrgId = () => typeof window !== 'undefined' ? localStorage.getItem('studio_selected_org_id') : null;
@@ -301,10 +311,13 @@ export const cmsApi = {
deleteWebhook: (id: string): Promise<void> => apiFetch(`/webhooks/${id}`, { method: 'DELETE' }), deleteWebhook: (id: string): Promise<void> => apiFetch(`/webhooks/${id}`, { method: 'DELETE' }),
// Assets // Assets
uploadAsset: (file: File, onProgress?: (pct: number) => void): Promise<UploadResponse> => { getCourseAssets: (courseId: string): Promise<Asset[]> => apiFetch(`/courses/${courseId}/assets`),
deleteAsset: (id: string): Promise<void> => apiFetch(`/assets/${id}`, { method: 'DELETE' }),
uploadAsset: (file: File, onProgress?: (pct: number) => void, courseId?: string): Promise<UploadResponse> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
if (courseId) formData.append('course_id', courseId);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE_URL}/assets/upload`); xhr.open('POST', `${API_BASE_URL}/assets/upload`);