feat: Implement course-level asset management and interactive media markers.
This commit is contained in:
Generated
+164
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
+143
-157
@@ -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.
|
||||
|
||||
@@ -25,3 +25,5 @@ sha2.workspace = true
|
||||
hex.workspace = true
|
||||
openidconnect.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);
|
||||
@@ -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<C
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<Uuid> = 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<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)]
|
||||
pub struct AuthPayload {
|
||||
pub email: String,
|
||||
@@ -2706,7 +2801,7 @@ pub async fn export_course(
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<exporter::CourseExport>, StatusCode> {
|
||||
) -> Result<impl axum::response::IntoResponse, (StatusCode, String)> {
|
||||
// 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<PgPool>,
|
||||
Json(payload): Json<exporter::CourseExport>,
|
||||
mut multipart: axum::extract::Multipart,
|
||||
) -> 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
|
||||
.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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -130,6 +130,7 @@ pub struct Enrollment {
|
||||
pub struct Asset {
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub course_id: Option<Uuid>,
|
||||
pub filename: String,
|
||||
pub storage_path: String,
|
||||
pub mimetype: String,
|
||||
|
||||
@@ -218,6 +218,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
}
|
||||
}
|
||||
}}
|
||||
isGraded={lesson.is_graded}
|
||||
/>
|
||||
);
|
||||
case 'document':
|
||||
|
||||
@@ -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<Set<number>>(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
|
||||
<span>Presta atención. El contenido se bloqueará después de {maxPlays} reproducciones.</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 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"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
|
||||
@@ -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
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<void> => apiFetch(`/webhooks/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// 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) => {
|
||||
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`);
|
||||
|
||||
Reference in New Issue
Block a user