Refactor code structure for improved readability and maintainability
@@ -0,0 +1,66 @@
|
|||||||
|
# Brochure Comercial - Plataforma Studio
|
||||||
|
|
||||||
|
## 1) Qué es Studio
|
||||||
|
Studio es la plataforma de autoría académica de OpenCCB para diseñar, publicar y administrar cursos de alta fidelidad. Está orientada a instituciones educativas, equipos académicos y docentes que necesitan construir experiencias de aprendizaje interactivas sin fricción técnica.
|
||||||
|
|
||||||
|
## 2) Propuesta de valor
|
||||||
|
- Diseño instruccional en un entorno moderno y visual.
|
||||||
|
- Creación de lecciones modulares con bloques interactivos.
|
||||||
|
- Banco de preguntas reutilizable para evaluaciones.
|
||||||
|
- Gestión de usuarios, cursos y configuraciones institucionales.
|
||||||
|
- Integración con LMS Experience para consumo de contenidos por estudiantes.
|
||||||
|
|
||||||
|
## 3) Capacidades principales
|
||||||
|
- Gestión de cursos: creación, edición, estructura por módulos y lecciones.
|
||||||
|
- Constructor de lecciones: bloques de texto, multimedia, quiz, respuestas cortas, ordenamiento, matching, hotspot, audio-response, peer review, role-playing y mermaid.
|
||||||
|
- Plantillas: plantillas de curso y de pruebas para estandarizar procesos.
|
||||||
|
- Biblioteca de assets: administración y reutilización de recursos multimedia.
|
||||||
|
- Banco de preguntas: catálogo de preguntas filtrable por tipo y dificultad.
|
||||||
|
- Analítica y seguimiento: soporte para revisión de progreso y evaluaciones.
|
||||||
|
- Branding institucional: logo, favicon y colores de marca.
|
||||||
|
|
||||||
|
## 4) Beneficios para la institución
|
||||||
|
- Reduce tiempos de producción académica.
|
||||||
|
- Estandariza calidad de contenidos.
|
||||||
|
- Facilita colaboración entre docentes y equipos de diseño instruccional.
|
||||||
|
- Mejora la trazabilidad de evaluaciones y resultados.
|
||||||
|
- Acelera despliegues de nuevos programas y cursos.
|
||||||
|
|
||||||
|
## 5) Flujo recomendado de uso
|
||||||
|
1. Configurar branding institucional.
|
||||||
|
2. Crear curso y definir módulos.
|
||||||
|
3. Diseñar lecciones con bloques interactivos.
|
||||||
|
4. Configurar evaluaciones y rúbricas.
|
||||||
|
5. Publicar y monitorear desempeño.
|
||||||
|
|
||||||
|
## 6) Capturas sugeridas (fotos)
|
||||||
|
Pantalla de acceso
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Vista principal de cursos
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Editor de lección con bloques
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Banco de preguntas y filtros
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Librería de recursos
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Editor de rúbricas
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Administración de usuarios
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 7) Mensaje comercial breve
|
||||||
|
Studio convierte el diseño académico en un proceso más rápido, controlado y escalable. Permite a la institución pasar de la idea al curso publicado con calidad pedagógica y consistencia operacional.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Manual de Usuario - Plataforma Studio
|
||||||
|
|
||||||
|
## 1) Objetivo
|
||||||
|
Este manual explica cómo usar Studio para crear y administrar cursos, lecciones y evaluaciones.
|
||||||
|
|
||||||
|
## 2) Perfil de usuario
|
||||||
|
- Docentes
|
||||||
|
- Diseñadores instruccionales
|
||||||
|
- Administradores académicos
|
||||||
|
|
||||||
|
## 3) Acceso a la plataforma
|
||||||
|
1. Ir a Studio en el navegador.
|
||||||
|
2. Iniciar sesión con credenciales institucionales.
|
||||||
|
3. Verificar que el menú principal muestre los módulos habilitados para el rol.
|
||||||
|
|
||||||
|
Captura recomendada:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 4) Navegación principal
|
||||||
|
- Cursos: listado y gestión de cursos.
|
||||||
|
- Librería: administración de archivos y recursos.
|
||||||
|
- Banco de preguntas: creación y reutilización de preguntas.
|
||||||
|
- Plantillas: estandarización de contenidos.
|
||||||
|
- Configuración: opciones de perfil y parámetros generales.
|
||||||
|
|
||||||
|
Captura recomendada:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 5) Crear un curso
|
||||||
|
1. Entrar a Cursos.
|
||||||
|
2. Seleccionar Crear curso.
|
||||||
|
3. Definir título y descripción.
|
||||||
|
4. Guardar para habilitar el editor completo.
|
||||||
|
|
||||||
|
Buenas prácticas:
|
||||||
|
- Usar nombres claros por nivel/tema.
|
||||||
|
- Definir objetivos de aprendizaje desde el inicio.
|
||||||
|
|
||||||
|
## 6) Editar estructura (módulos y lecciones)
|
||||||
|
1. Abrir un curso.
|
||||||
|
2. Crear módulos.
|
||||||
|
3. Agregar lecciones por módulo.
|
||||||
|
4. Ordenar secuencia de aprendizaje.
|
||||||
|
|
||||||
|
Captura recomendada:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 7) Constructor de lecciones por bloques
|
||||||
|
Tipos recomendados de bloque:
|
||||||
|
- Description
|
||||||
|
- Media
|
||||||
|
- Quiz
|
||||||
|
- Fill in the blanks
|
||||||
|
- Matching
|
||||||
|
- Ordering
|
||||||
|
- Short answer
|
||||||
|
- Hotspot
|
||||||
|
- Audio response
|
||||||
|
- Peer review
|
||||||
|
- Role playing
|
||||||
|
- Mermaid
|
||||||
|
|
||||||
|
Flujo:
|
||||||
|
1. Agregar bloque.
|
||||||
|
2. Configurar contenido e instrucciones.
|
||||||
|
3. Guardar cambios.
|
||||||
|
4. Previsualizar.
|
||||||
|
|
||||||
|
## 8) Banco de preguntas
|
||||||
|
1. Entrar a Banco de preguntas.
|
||||||
|
2. Crear nueva pregunta.
|
||||||
|
3. Definir tipo, dificultad y enunciado.
|
||||||
|
4. Guardar y reutilizar en evaluaciones.
|
||||||
|
|
||||||
|
Captura recomendada:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 9) Libreria de assets
|
||||||
|
1. Subir recursos (imágenes, audio, documentos).
|
||||||
|
2. Etiquetar y organizar.
|
||||||
|
3. Insertar recursos desde lecciones.
|
||||||
|
|
||||||
|
Captura recomendada:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 10) Rúbricas de evaluación
|
||||||
|
1. Crear rúbrica por curso o actividad.
|
||||||
|
2. Definir criterios y niveles.
|
||||||
|
3. Asignar puntajes por nivel.
|
||||||
|
4. Guardar al finalizar la edición.
|
||||||
|
|
||||||
|
Captura recomendada:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 11) Administración de usuarios (rol admin)
|
||||||
|
1. Entrar al panel de administración.
|
||||||
|
2. Buscar usuario.
|
||||||
|
3. Editar rol o estado.
|
||||||
|
4. Guardar cambios.
|
||||||
|
|
||||||
|
Captura recomendada:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 12) Solución de problemas comunes
|
||||||
|
- No veo menús esperados:
|
||||||
|
Verificar sesión activa y rol del usuario.
|
||||||
|
- No se guardan cambios:
|
||||||
|
Confirmar conexión y permisos del curso.
|
||||||
|
- Recursos no cargan:
|
||||||
|
Revisar formatos permitidos y tamaño del archivo.
|
||||||
|
|
||||||
|
## 13) Recomendaciones operativas
|
||||||
|
- Definir convención de nombres para cursos y módulos.
|
||||||
|
- Usar plantillas para mantener estándar pedagógico.
|
||||||
|
- Revisar cada lección en previsualización antes de publicar.
|
||||||
|
- Mantener el banco de preguntas limpio y etiquetado.
|
||||||
|
|
||||||
|
## 14) Checklist de publicacion
|
||||||
|
- Objetivos definidos.
|
||||||
|
- Lecciones ordenadas.
|
||||||
|
- Bloques revisados.
|
||||||
|
- Evaluaciones probadas.
|
||||||
|
- Rúbricas listas.
|
||||||
|
- Recursos multimedia verificados.
|
||||||
|
|
||||||
|
## 15) Anexo: inventario de fotos para documentación
|
||||||
|
- docs/assets/studio/01-login-studio.png
|
||||||
|
- docs/assets/studio/02-dashboard-cursos.png
|
||||||
|
- docs/assets/studio/03-editor-leccion.png
|
||||||
|
- docs/assets/studio/04-banco-preguntas.png
|
||||||
|
- docs/assets/studio/05-library-assets.png
|
||||||
|
- docs/assets/studio/06-rubricas.png
|
||||||
|
- docs/assets/studio/07-admin-usuarios.png
|
||||||
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -13,6 +13,29 @@ location /lms-api/ {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /cms-api/ {
|
location /cms-api/ {
|
||||||
|
# CORS safety net at proxy level for CMS API.
|
||||||
|
set $cors_origin "";
|
||||||
|
if ($http_origin ~* "^https?://([a-z0-9-]+\\.)?norteamericano\\.(com|cl)$") {
|
||||||
|
set $cors_origin $http_origin;
|
||||||
|
}
|
||||||
|
if ($http_origin ~* "^http://localhost(:[0-9]+)?$") {
|
||||||
|
set $cors_origin $http_origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||||
|
add_header Vary "Origin, Access-Control-Request-Method, Access-Control-Request-Headers" always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET,POST,PUT,DELETE,OPTIONS,PATCH,HEAD" always;
|
||||||
|
add_header Access-Control-Allow-Headers "content-type,authorization,x-requested-with,x-organization-id,range" always;
|
||||||
|
add_header Access-Control-Max-Age 86400 always;
|
||||||
|
add_header Content-Length 0 always;
|
||||||
|
add_header Content-Type "text/plain; charset=utf-8" always;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||||
|
add_header Vary "Origin" always;
|
||||||
|
|
||||||
rewrite ^/cms-api/(.*)$ /$1 break;
|
rewrite ^/cms-api/(.*)$ /$1 break;
|
||||||
proxy_pass http://openccb-studio:3001;
|
proxy_pass http://openccb-studio:3001;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -9,6 +9,29 @@ client_body_timeout 1800s;
|
|||||||
# Prefer the explicit `/cms-api/*` prefix for frontend fetches. This avoids collisions
|
# Prefer the explicit `/cms-api/*` prefix for frontend fetches. This avoids collisions
|
||||||
# with Next.js pages like `/courses` and `/admin` that share the same host.
|
# with Next.js pages like `/courses` and `/admin` that share the same host.
|
||||||
location /cms-api/ {
|
location /cms-api/ {
|
||||||
|
# CORS safety net at proxy level for CMS API.
|
||||||
|
set $cors_origin "";
|
||||||
|
if ($http_origin ~* "^https?://([a-z0-9-]+\\.)?norteamericano\\.(com|cl)$") {
|
||||||
|
set $cors_origin $http_origin;
|
||||||
|
}
|
||||||
|
if ($http_origin ~* "^http://localhost(:[0-9]+)?$") {
|
||||||
|
set $cors_origin $http_origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||||
|
add_header Vary "Origin, Access-Control-Request-Method, Access-Control-Request-Headers" always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET,POST,PUT,DELETE,OPTIONS,PATCH,HEAD" always;
|
||||||
|
add_header Access-Control-Allow-Headers "content-type,authorization,x-requested-with,x-organization-id,range" always;
|
||||||
|
add_header Access-Control-Max-Age 86400 always;
|
||||||
|
add_header Content-Length 0 always;
|
||||||
|
add_header Content-Type "text/plain; charset=utf-8" always;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||||
|
add_header Vary "Origin" always;
|
||||||
|
|
||||||
rewrite ^/cms-api/(.*)$ /$1 break;
|
rewrite ^/cms-api/(.*)$ /$1 break;
|
||||||
proxy_pass http://openccb-studio:3001;
|
proxy_pass http://openccb-studio:3001;
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
|
|||||||
@@ -47,6 +47,47 @@ source_asset_id,
|
|||||||
unit_number
|
unit_number
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
fn normalize_answer_keywords_value(value: serde_json::Value) -> serde_json::Value {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Array(items) => serde_json::Value::Array(
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.map(normalize_answer_keywords_value)
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
let answer_text = map
|
||||||
|
.get("answer")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
if map.contains_key("keywords") {
|
||||||
|
if let Some(answer) = answer_text {
|
||||||
|
return serde_json::Value::String(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized_map = map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k, normalize_answer_keywords_value(v)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
serde_json::Value::Object(normalized_map)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_question_bank_payload_values(
|
||||||
|
options: Option<serde_json::Value>,
|
||||||
|
correct_answer: Option<serde_json::Value>,
|
||||||
|
) -> (Option<serde_json::Value>, Option<serde_json::Value>) {
|
||||||
|
(
|
||||||
|
options.map(normalize_answer_keywords_value),
|
||||||
|
correct_answer.map(normalize_answer_keywords_value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn connect_mysql_pool(env_var: &str) -> Result<sqlx::MySqlPool, (StatusCode, String)> {
|
async fn connect_mysql_pool(env_var: &str) -> Result<sqlx::MySqlPool, (StatusCode, String)> {
|
||||||
use sqlx::mysql::MySqlPoolOptions;
|
use sqlx::mysql::MySqlPoolOptions;
|
||||||
|
|
||||||
@@ -288,6 +329,23 @@ pub async fn create_question(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<CreateQuestionBankPayload>,
|
Json(payload): Json<CreateQuestionBankPayload>,
|
||||||
) -> Result<Json<QuestionBank>, (StatusCode, String)> {
|
) -> Result<Json<QuestionBank>, (StatusCode, String)> {
|
||||||
|
let CreateQuestionBankPayload {
|
||||||
|
question_text,
|
||||||
|
question_type,
|
||||||
|
options,
|
||||||
|
correct_answer,
|
||||||
|
explanation,
|
||||||
|
points,
|
||||||
|
difficulty,
|
||||||
|
tags,
|
||||||
|
media_url,
|
||||||
|
media_type,
|
||||||
|
skill_assessed,
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
let (normalized_options, normalized_correct_answer) =
|
||||||
|
normalize_question_bank_payload_values(options, correct_answer);
|
||||||
|
|
||||||
let create_question_sql = format!(
|
let create_question_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO question_bank (
|
INSERT INTO question_bank (
|
||||||
@@ -306,17 +364,17 @@ pub async fn create_question(
|
|||||||
)
|
)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.bind(claims.sub)
|
.bind(claims.sub)
|
||||||
.bind(&payload.question_text)
|
.bind(&question_text)
|
||||||
.bind(&payload.question_type)
|
.bind(&question_type)
|
||||||
.bind(&payload.options)
|
.bind(&normalized_options)
|
||||||
.bind(&payload.correct_answer)
|
.bind(&normalized_correct_answer)
|
||||||
.bind(&payload.explanation)
|
.bind(&explanation)
|
||||||
.bind(payload.points.unwrap_or(1))
|
.bind(points.unwrap_or(1))
|
||||||
.bind(payload.difficulty.as_deref().unwrap_or("medium"))
|
.bind(difficulty.as_deref().unwrap_or("medium"))
|
||||||
.bind(payload.tags.as_deref())
|
.bind(tags.as_deref())
|
||||||
.bind(payload.skill_assessed.as_deref())
|
.bind(skill_assessed.as_deref())
|
||||||
.bind(payload.media_url.as_deref())
|
.bind(media_url.as_deref())
|
||||||
.bind(payload.media_type.as_deref())
|
.bind(media_type.as_deref())
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
@@ -486,6 +544,22 @@ pub async fn update_question(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<UpdateQuestionBankPayload>,
|
Json(payload): Json<UpdateQuestionBankPayload>,
|
||||||
) -> Result<Json<QuestionBank>, (StatusCode, String)> {
|
) -> Result<Json<QuestionBank>, (StatusCode, String)> {
|
||||||
|
let UpdateQuestionBankPayload {
|
||||||
|
question_text,
|
||||||
|
question_type,
|
||||||
|
options,
|
||||||
|
correct_answer,
|
||||||
|
explanation,
|
||||||
|
points,
|
||||||
|
difficulty,
|
||||||
|
tags,
|
||||||
|
is_active,
|
||||||
|
is_archived,
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
let (normalized_options, normalized_correct_answer) =
|
||||||
|
normalize_question_bank_payload_values(options, correct_answer);
|
||||||
|
|
||||||
let update_question_sql = format!(
|
let update_question_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
UPDATE question_bank
|
UPDATE question_bank
|
||||||
@@ -512,16 +586,16 @@ pub async fn update_question(
|
|||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.bind(payload.question_text)
|
.bind(question_text)
|
||||||
.bind(payload.question_type.map(|t| t.to_string()))
|
.bind(question_type.map(|t| t.to_string()))
|
||||||
.bind(&payload.options)
|
.bind(&normalized_options)
|
||||||
.bind(&payload.correct_answer)
|
.bind(&normalized_correct_answer)
|
||||||
.bind(&payload.explanation)
|
.bind(&explanation)
|
||||||
.bind(payload.points)
|
.bind(points)
|
||||||
.bind(payload.difficulty)
|
.bind(difficulty)
|
||||||
.bind(payload.tags.as_deref())
|
.bind(tags.as_deref())
|
||||||
.bind(payload.is_active)
|
.bind(is_active)
|
||||||
.bind(payload.is_archived)
|
.bind(is_archived)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
|
|||||||
@@ -13,6 +13,47 @@ use sqlx::PgPool;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn normalize_answer_keywords_value(value: serde_json::Value) -> serde_json::Value {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Array(items) => serde_json::Value::Array(
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.map(normalize_answer_keywords_value)
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
let answer_text = map
|
||||||
|
.get("answer")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
if map.contains_key("keywords") {
|
||||||
|
if let Some(answer) = answer_text {
|
||||||
|
return serde_json::Value::String(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized_map = map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k, normalize_answer_keywords_value(v)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
serde_json::Value::Object(normalized_map)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_question_bank_payload_values(
|
||||||
|
options: Option<serde_json::Value>,
|
||||||
|
correct_answer: Option<serde_json::Value>,
|
||||||
|
) -> (Option<serde_json::Value>, Option<serde_json::Value>) {
|
||||||
|
(
|
||||||
|
options.map(normalize_answer_keywords_value),
|
||||||
|
correct_answer.map(normalize_answer_keywords_value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Query Parameters ====================
|
// ==================== Query Parameters ====================
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -1736,6 +1777,12 @@ pub async fn generate_questions_with_rag(
|
|||||||
_ => common::models::QuestionBankType::MultipleChoice,
|
_ => common::models::QuestionBankType::MultipleChoice,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (normalized_options, normalized_correct_answer) =
|
||||||
|
normalize_question_bank_payload_values(
|
||||||
|
question.options.clone(),
|
||||||
|
question.correct_answer.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO question_bank (
|
INSERT INTO question_bank (
|
||||||
@@ -1750,8 +1797,8 @@ pub async fn generate_questions_with_rag(
|
|||||||
.bind(claims.sub)
|
.bind(claims.sub)
|
||||||
.bind(&question.question_text)
|
.bind(&question.question_text)
|
||||||
.bind(&question_type)
|
.bind(&question_type)
|
||||||
.bind(&question.options)
|
.bind(&normalized_options)
|
||||||
.bind(&question.correct_answer)
|
.bind(&normalized_correct_answer)
|
||||||
.bind(&question.explanation)
|
.bind(&question.explanation)
|
||||||
.bind(question.points)
|
.bind(question.points)
|
||||||
.bind("medium")
|
.bind("medium")
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use sqlx::postgres::PgPoolOptions;
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::CorsLayer;
|
||||||
use tower_http::set_header::SetResponseHeaderLayer;
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
@@ -114,8 +114,14 @@ async fn main() {
|
|||||||
"http://192.168.0.254:3000",
|
"http://192.168.0.254:3000",
|
||||||
"http://192.168.0.254:3003",
|
"http://192.168.0.254:3003",
|
||||||
"http://192.168.0.254",
|
"http://192.168.0.254",
|
||||||
// Production - Norteamericano domains (HTTPS)
|
// Production - Norteamericano domains (.cl and .com)
|
||||||
|
"http://studio.norteamericano.com",
|
||||||
|
"https://studio.norteamericano.com",
|
||||||
|
"http://learning.norteamericano.com",
|
||||||
|
"https://learning.norteamericano.com",
|
||||||
|
"http://studio.norteamericano.cl",
|
||||||
"https://studio.norteamericano.cl",
|
"https://studio.norteamericano.cl",
|
||||||
|
"http://learning.norteamericano.cl",
|
||||||
"https://learning.norteamericano.cl",
|
"https://learning.norteamericano.cl",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -124,17 +130,21 @@ async fn main() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check wildcard for subdomains: https://*.norteamericano.cl
|
// Check wildcard for subdomains in norteamericano.cl/.com over HTTP(S)
|
||||||
if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") {
|
for scheme in ["http://", "https://"] {
|
||||||
let subdomain = origin_str
|
for domain in [".norteamericano.cl", ".norteamericano.com"] {
|
||||||
.strip_prefix("https://")
|
if origin_str.starts_with(scheme) && origin_str.ends_with(domain) {
|
||||||
.unwrap_or("")
|
let subdomain = origin_str
|
||||||
.strip_suffix(".norteamericano.cl")
|
.strip_prefix(scheme)
|
||||||
.unwrap_or("");
|
.unwrap_or("")
|
||||||
|
.strip_suffix(domain)
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
// Allow any subdomain (e.g., api., cdn., admin., etc.)
|
// Allow any subdomain (e.g., api., cdn., admin., etc.)
|
||||||
if !subdomain.is_empty() && !subdomain.contains('/') {
|
if !subdomain.is_empty() && !subdomain.contains('/') {
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { questionBankApi, QuestionBank, QuestionBankFilters, QuestionBankType } from '@/lib/api';
|
import { questionBankApi, QuestionBank, QuestionBankFilters } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
Plus, Search, Filter, Edit2, Trash2, Download,
|
Plus, Search, Filter,
|
||||||
Upload, Sparkles, ChevronDown, ChevronUp, X, Check, AlertCircle,
|
Upload, BookOpen
|
||||||
Headphones, BookOpen, Tag, Hash, Globe
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import QuestionBankEditor from '@/components/QuestionBank/QuestionBankEditor';
|
import QuestionBankEditor from '@/components/QuestionBank/QuestionBankEditor';
|
||||||
import QuestionBankCard from '@/components/QuestionBank/QuestionBankCard';
|
import QuestionBankCard from '@/components/QuestionBank/QuestionBankCard';
|
||||||
@@ -15,6 +14,68 @@ const isMySqlOrigin = (source?: string) => source === 'imported-mysql' || source
|
|||||||
const isMaterialsOrigin = (source?: string) => source === 'imported-material';
|
const isMaterialsOrigin = (source?: string) => source === 'imported-material';
|
||||||
const isAiOrigin = (source?: string) => source === 'ai-generated';
|
const isAiOrigin = (source?: string) => source === 'ai-generated';
|
||||||
|
|
||||||
|
const toSafeText = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => toSafeText(v)).filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
if (typeof obj.answer === 'string') return obj.answer;
|
||||||
|
if (typeof obj.text === 'string') return obj.text;
|
||||||
|
if (typeof obj.label === 'string') return obj.label;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(obj);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeQuestion = (q: QuestionBank): QuestionBank => {
|
||||||
|
const safeTags = Array.isArray(q.tags)
|
||||||
|
? q.tags.map((t) => toSafeText(t)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const safeOptions = Array.isArray(q.options)
|
||||||
|
? q.options.map((opt) => {
|
||||||
|
if (typeof opt === 'object' && opt !== null) {
|
||||||
|
const item = opt as Record<string, unknown>;
|
||||||
|
if ('answer' in item || 'keywords' in item) {
|
||||||
|
return toSafeText(item.answer ?? item.text ?? item.label ?? item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opt;
|
||||||
|
})
|
||||||
|
: q.options;
|
||||||
|
|
||||||
|
const safeCorrectAnswer = (() => {
|
||||||
|
const raw = q.correct_answer;
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map((item) => toSafeText(item));
|
||||||
|
}
|
||||||
|
if (raw && typeof raw === 'object') {
|
||||||
|
const item = raw as Record<string, unknown>;
|
||||||
|
if ('answer' in item || 'keywords' in item || 'text' in item || 'label' in item) {
|
||||||
|
return toSafeText(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...q,
|
||||||
|
question_text: toSafeText(q.question_text),
|
||||||
|
tags: safeTags,
|
||||||
|
options: safeOptions,
|
||||||
|
correct_answer: safeCorrectAnswer,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function QuestionBankPage() {
|
export default function QuestionBankPage() {
|
||||||
const [questions, setQuestions] = useState<QuestionBank[]>([]);
|
const [questions, setQuestions] = useState<QuestionBank[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -24,22 +85,25 @@ export default function QuestionBankPage() {
|
|||||||
const [showEditor, setShowEditor] = useState(false);
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
const [editingQuestion, setEditingQuestion] = useState<QuestionBank | null>(null);
|
const [editingQuestion, setEditingQuestion] = useState<QuestionBank | null>(null);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
|
||||||
const loadQuestions = async () => {
|
const loadQuestions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await questionBankApi.list(filters);
|
const data = await questionBankApi.list(filters);
|
||||||
setQuestions(data);
|
setQuestions(data.map(sanitizeQuestion));
|
||||||
|
setCurrentPage(1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load questions:', error);
|
console.error('Failed to load questions:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [filters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadQuestions();
|
loadQuestions();
|
||||||
}, [filters]);
|
}, [loadQuestions]);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditingQuestion(null);
|
setEditingQuestion(null);
|
||||||
@@ -68,31 +132,6 @@ export default function QuestionBankPage() {
|
|||||||
await loadQuestions();
|
await loadQuestions();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getQuestionTypeLabel = (type: QuestionBankType) => {
|
|
||||||
const labels: Record<QuestionBankType, string> = {
|
|
||||||
'multiple-choice': 'Opción Múltiple',
|
|
||||||
'true-false': 'Verdadero/Falso',
|
|
||||||
'short-answer': 'Respuesta Corta',
|
|
||||||
'essay': 'Ensayo',
|
|
||||||
'matching': 'Emparejamiento',
|
|
||||||
'ordering': 'Ordenar',
|
|
||||||
'fill-in-the-blanks': 'Completar',
|
|
||||||
'audio-response': 'Respuesta Audio',
|
|
||||||
'hotspot': 'Hotspot',
|
|
||||||
'code-lab': 'Código',
|
|
||||||
};
|
|
||||||
return labels[type] || type;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDifficultyColor = (difficulty?: string) => {
|
|
||||||
switch (difficulty) {
|
|
||||||
case 'easy': return 'bg-green-100 text-green-800';
|
|
||||||
case 'medium': return 'bg-yellow-100 text-yellow-800';
|
|
||||||
case 'hard': return 'bg-red-100 text-red-800';
|
|
||||||
default: return 'bg-gray-100 text-gray-800';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -187,7 +226,7 @@ export default function QuestionBankPage() {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.question_type || ''}
|
value={filters.question_type || ''}
|
||||||
onChange={(e) => setFilters({ ...filters, question_type: e.target.value as QuestionBankType || undefined })}
|
onChange={(e) => setFilters({ ...filters, question_type: (e.target.value || undefined) as QuestionBankFilters['question_type'] })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
>
|
>
|
||||||
<option value="">Todos los tipos</option>
|
<option value="">Todos los tipos</option>
|
||||||
@@ -280,18 +319,107 @@ export default function QuestionBankPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (() => {
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
const totalPages = Math.ceil(questions.length / pageSize);
|
||||||
{questions.map((question) => (
|
const paginated = questions.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
<QuestionBankCard
|
return (
|
||||||
key={question.id}
|
<>
|
||||||
question={question}
|
{/* Page size + info */}
|
||||||
onEdit={() => handleEdit(question)}
|
<div className="flex items-center justify-between mb-4">
|
||||||
onDelete={() => handleDelete(question.id)}
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
/>
|
Mostrando {(currentPage - 1) * pageSize + 1}–{Math.min(currentPage * pageSize, questions.length)} de {questions.length} preguntas
|
||||||
))}
|
</p>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
)}
|
<span className="text-sm text-gray-600 dark:text-gray-400">Por página:</span>
|
||||||
|
{[20, 50, 100].map((size) => (
|
||||||
|
<button
|
||||||
|
key={size}
|
||||||
|
onClick={() => { setPageSize(size); setCurrentPage(1); }}
|
||||||
|
className={`px-3 py-1 text-sm rounded-md border transition-colors ${
|
||||||
|
pageSize === size
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{size}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{paginated.map((question) => (
|
||||||
|
<QuestionBankCard
|
||||||
|
key={question.id}
|
||||||
|
question={question}
|
||||||
|
onEdit={() => handleEdit(question)}
|
||||||
|
onDelete={() => handleDelete(question.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination controls */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="px-3 py-1 text-sm rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="px-3 py-1 text-sm rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||||
|
.reduce<(number | '...')[]>((acc, p, idx, arr) => {
|
||||||
|
if (idx > 0 && (p as number) - (arr[idx - 1] as number) > 1) acc.push('...');
|
||||||
|
acc.push(p);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.map((p, idx) =>
|
||||||
|
p === '...' ? (
|
||||||
|
<span key={`ellipsis-${idx}`} className="px-2 text-gray-400">…</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setCurrentPage(p as number)}
|
||||||
|
className={`px-3 py-1 text-sm rounded-md border transition-colors ${
|
||||||
|
currentPage === p
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="px-3 py-1 text-sm rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="px-3 py-1 text-sm rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor Modal */}
|
{/* Editor Modal */}
|
||||||
|
|||||||
@@ -12,8 +12,34 @@ interface QuestionBankCardProps {
|
|||||||
export default function QuestionBankCard({ question, onEdit, onDelete }: QuestionBankCardProps): React.JSX.Element {
|
export default function QuestionBankCard({ question, onEdit, onDelete }: QuestionBankCardProps): React.JSX.Element {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
|
const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
|
||||||
const safeQuestionText: React.ReactNode =
|
|
||||||
typeof question.question_text === 'string' ? question.question_text : '';
|
const toDisplayText = (value: any): string => {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => toDisplayText(v)).filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (typeof value.answer === 'string') return value.answer;
|
||||||
|
if (typeof value.text === 'string') return value.text;
|
||||||
|
if (typeof value.label === 'string') return value.label;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeSourceText = toDisplayText(question.source);
|
||||||
|
const safeDifficultyText = toDisplayText(question.difficulty);
|
||||||
|
const safeSkillAssessedText = toDisplayText(question.skill_assessed);
|
||||||
|
const safePointsText = toDisplayText(question.points);
|
||||||
|
const safeQuestionTypeText = toDisplayText(question.question_type);
|
||||||
|
const safeQuestionText = toDisplayText(question.question_text);
|
||||||
|
|
||||||
const getQuestionTypeLabel = (type: string) => {
|
const getQuestionTypeLabel = (type: string) => {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
@@ -41,7 +67,7 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sourceBadge = (() => {
|
const sourceBadge = (() => {
|
||||||
switch (question.source) {
|
switch (safeSourceText) {
|
||||||
case 'imported-mysql':
|
case 'imported-mysql':
|
||||||
case 'sam-diagnostico':
|
case 'sam-diagnostico':
|
||||||
return (
|
return (
|
||||||
@@ -64,8 +90,8 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
|
|||||||
case 'manual':
|
case 'manual':
|
||||||
return <span className="text-xs text-gray-500 dark:text-gray-400">Manual</span>;
|
return <span className="text-xs text-gray-500 dark:text-gray-400">Manual</span>;
|
||||||
default:
|
default:
|
||||||
return question.source ? (
|
return safeSourceText ? (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">{question.source}</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400">{safeSourceText}</span>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -104,14 +130,14 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-xs font-medium rounded">
|
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-xs font-medium rounded">
|
||||||
{getQuestionTypeLabel(question.question_type)}
|
{getQuestionTypeLabel(safeQuestionTypeText)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getDifficultyColor(question.difficulty)}`}>
|
<span className={`px-2 py-1 text-xs font-medium rounded ${getDifficultyColor(safeDifficultyText || undefined)}`}>
|
||||||
{question.difficulty || 'Medium'}
|
{safeDifficultyText || 'Medium'}
|
||||||
</span>
|
</span>
|
||||||
{question.skill_assessed && (
|
{safeSkillAssessedText && (
|
||||||
<span className="px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 text-xs font-medium rounded capitalize">
|
<span className="px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 text-xs font-medium rounded capitalize">
|
||||||
📊 {question.skill_assessed}
|
📊 {safeSkillAssessedText}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -145,7 +171,7 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
|
|||||||
|
|
||||||
{/* Question Text */}
|
{/* Question Text */}
|
||||||
<p className="text-gray-900 dark:text-white font-medium mb-3 line-clamp-3">
|
<p className="text-gray-900 dark:text-white font-medium mb-3 line-clamp-3">
|
||||||
{safeQuestionText as any}
|
{safeQuestionText}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Matching Pairs Preview */}
|
{/* Matching Pairs Preview */}
|
||||||
@@ -163,9 +189,9 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
|
|||||||
|
|
||||||
return pairs.slice(0, 3).map((pair: any, idx: number) => (
|
return pairs.slice(0, 3).map((pair: any, idx: number) => (
|
||||||
<div key={idx} className="text-xs bg-gray-50 dark:bg-gray-700/50 p-2 rounded flex items-center gap-2">
|
<div key={idx} className="text-xs bg-gray-50 dark:bg-gray-700/50 p-2 rounded flex items-center gap-2">
|
||||||
<span className="flex-1 text-gray-700 dark:text-gray-300">{pair.left || pair[0]}</span>
|
<span className="flex-1 text-gray-700 dark:text-gray-300">{toDisplayText(pair.left ?? pair[0])}</span>
|
||||||
<span className="text-gray-400">→</span>
|
<span className="text-gray-400">→</span>
|
||||||
<span className="flex-1 text-gray-600 dark:text-gray-400 line-clamp-1">{pair.right || pair[1]}</span>
|
<span className="flex-1 text-gray-600 dark:text-gray-400 line-clamp-1">{toDisplayText(pair.right ?? pair[1])}</span>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
})()}
|
})()}
|
||||||
@@ -194,12 +220,12 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
|
|||||||
{/* Options Preview */}
|
{/* Options Preview */}
|
||||||
{question.options && (
|
{question.options && (
|
||||||
<div className="mb-3 space-y-1">
|
<div className="mb-3 space-y-1">
|
||||||
{Array.isArray(question.options) && question.options.slice(0, 3).map((opt: string, idx: number) => (
|
{Array.isArray(question.options) && question.options.slice(0, 3).map((opt: any, idx: number) => (
|
||||||
<div key={idx} className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
<div key={idx} className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||||
<span className="w-4 h-4 rounded border border-gray-300 dark:border-gray-600 flex items-center justify-center text-[10px]">
|
<span className="w-4 h-4 rounded border border-gray-300 dark:border-gray-600 flex items-center justify-center text-[10px]">
|
||||||
{String.fromCharCode(65 + idx)}
|
{String.fromCharCode(65 + idx)}
|
||||||
</span>
|
</span>
|
||||||
<span className="line-clamp-1">{opt}</span>
|
<span className="line-clamp-1">{toDisplayText(opt)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{Array.isArray(question.options) && question.options.length > 3 && (
|
{Array.isArray(question.options) && question.options.length > 3 && (
|
||||||
@@ -212,7 +238,7 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
|
|||||||
<div className="pt-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
<div className="pt-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
{question.points} pts
|
{safePointsText || '0'} pts
|
||||||
</span>
|
</span>
|
||||||
{sourceBadge && <div className="flex items-center gap-1">{sourceBadge}</div>}
|
{sourceBadge && <div className="flex items-center gap-1">{sourceBadge}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ interface QuestionBankEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function QuestionBankEditor({ question, onSuccess, onCancel }: QuestionBankEditorProps) {
|
export default function QuestionBankEditor({ question, onSuccess, onCancel }: QuestionBankEditorProps) {
|
||||||
|
const toDisplayText = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
if (Array.isArray(value)) return value.map((v) => toDisplayText(v)).filter(Boolean).join(', ');
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
if (typeof obj.answer === 'string') return obj.answer;
|
||||||
|
if (typeof obj.text === 'string') return obj.text;
|
||||||
|
if (typeof obj.label === 'string') return obj.label;
|
||||||
|
try { return JSON.stringify(obj); } catch { return ''; }
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
const [formData, setFormData] = useState<CreateQuestionBankPayload>({
|
const [formData, setFormData] = useState<CreateQuestionBankPayload>({
|
||||||
question_text: question?.question_text || '',
|
question_text: question?.question_text || '',
|
||||||
question_type: question?.question_type || 'multiple-choice',
|
question_type: question?.question_type || 'multiple-choice',
|
||||||
@@ -34,7 +48,7 @@ export default function QuestionBankEditor({ question, onSuccess, onCancel }: Qu
|
|||||||
const [generatingAI, setGeneratingAI] = useState(false);
|
const [generatingAI, setGeneratingAI] = useState(false);
|
||||||
|
|
||||||
const normalizedOptions = Array.isArray(formData.options)
|
const normalizedOptions = Array.isArray(formData.options)
|
||||||
? (formData.options as string[])
|
? (formData.options as unknown[]).map((opt) => toDisplayText(opt))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -476,10 +490,10 @@ export default function QuestionBankEditor({ question, onSuccess, onCancel }: Qu
|
|||||||
key={idx}
|
key={idx}
|
||||||
className="px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-full text-sm flex items-center gap-1"
|
className="px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-full text-sm flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{tag}
|
{toDisplayText(tag)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveTag(tag)}
|
onClick={() => handleRemoveTag(toDisplayText(tag))}
|
||||||
className="hover:text-blue-600"
|
className="hover:text-blue-600"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
|
|||||||
@@ -25,6 +25,28 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
|||||||
const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({});
|
const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({});
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const toDisplayText = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => toDisplayText(v)).filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
if (typeof obj.answer === 'string') return obj.answer;
|
||||||
|
if (typeof obj.text === 'string') return obj.text;
|
||||||
|
if (typeof obj.label === 'string') return obj.label;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(obj);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
const questions = quizData.questions || [];
|
const questions = quizData.questions || [];
|
||||||
|
|
||||||
const addQuestion = () => {
|
const addQuestion = () => {
|
||||||
@@ -133,7 +155,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[10px] font-black text-slate-400 dark:text-gray-500 uppercase tracking-widest pl-1">Inquiry Description</label>
|
<label className="text-[10px] font-black text-slate-400 dark:text-gray-500 uppercase tracking-widest pl-1">Inquiry Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={q.question}
|
value={toDisplayText(q.question)}
|
||||||
onChange={(e) => updateQuestion(idx, { question: e.target.value })}
|
onChange={(e) => updateQuestion(idx, { question: e.target.value })}
|
||||||
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-2xl p-6 text-lg font-black tracking-tight focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 transition-all placeholder:text-slate-300 resize-none shadow-inner h-24"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-2xl p-6 text-lg font-black tracking-tight focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 transition-all placeholder:text-slate-300 resize-none shadow-inner h-24"
|
||||||
placeholder="What is the core problem being evaluated?"
|
placeholder="What is the core problem being evaluated?"
|
||||||
@@ -172,7 +194,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
value={opt}
|
value={toDisplayText(opt)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newOpts = [...q.options];
|
const newOpts = [...q.options];
|
||||||
newOpts[oIdx] = e.target.value;
|
newOpts[oIdx] = e.target.value;
|
||||||
@@ -221,7 +243,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
|||||||
{questions.map((q) => (
|
{questions.map((q) => (
|
||||||
<div key={q.id} className="space-y-6 p-10 bg-white dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-[2.5rem] shadow-sm hover:shadow-xl transition-all duration-700 group/qitem relative overflow-hidden">
|
<div key={q.id} className="space-y-6 p-10 bg-white dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-[2.5rem] shadow-sm hover:shadow-xl transition-all duration-700 group/qitem relative overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 group-hover/qitem:bg-indigo-500/10 transition-colors"></div>
|
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 group-hover/qitem:bg-indigo-500/10 transition-colors"></div>
|
||||||
<h4 className="font-black text-2xl text-slate-900 dark:text-white leading-tight uppercase tracking-tight relative z-10">{q.question}</h4>
|
<h4 className="font-black text-2xl text-slate-900 dark:text-white leading-tight uppercase tracking-tight relative z-10">{toDisplayText(q.question)}</h4>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{q.options && q.options.length > 0 ? (
|
{q.options && q.options.length > 0 ? (
|
||||||
q.options.map((opt, oIdx) => {
|
q.options.map((opt, oIdx) => {
|
||||||
@@ -246,7 +268,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
|||||||
className={`p-6 rounded-2xl border transition-all text-left text-sm font-black uppercase tracking-tight relative overflow-hidden group/optans ${style}`}
|
className={`p-6 rounded-2xl border transition-all text-left text-sm font-black uppercase tracking-tight relative overflow-hidden group/optans ${style}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between relative z-10">
|
<div className="flex items-center justify-between relative z-10">
|
||||||
<span>{opt}</span>
|
<span>{toDisplayText(opt)}</span>
|
||||||
{submitted ? (
|
{submitted ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isActuallyCorrect && <span className="text-green-600 dark:text-green-400"><CheckCircle2 size={18} /></span>}
|
{isActuallyCorrect && <span className="text-green-600 dark:text-green-400"><CheckCircle2 size={18} /></span>}
|
||||||
|
|||||||
@@ -1324,19 +1324,98 @@ export interface UpdateQuestionBankPayload {
|
|||||||
audio_text?: string;
|
audio_text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toSafeQuestionBankText = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => toSafeQuestionBankText(v)).filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
if (typeof obj.answer === 'string') return obj.answer;
|
||||||
|
if (typeof obj.text === 'string') return obj.text;
|
||||||
|
if (typeof obj.label === 'string') return obj.label;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(obj);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeQuestionBank = (q: QuestionBank): QuestionBank => {
|
||||||
|
const safeTags = Array.isArray(q.tags)
|
||||||
|
? q.tags.map((tag) => toSafeQuestionBankText(tag)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const safeOptions = Array.isArray(q.options)
|
||||||
|
? q.options.map((opt) => toSafeQuestionBankText(opt))
|
||||||
|
: q.options;
|
||||||
|
|
||||||
|
const safeCorrectAnswer = (() => {
|
||||||
|
const raw = q.correct_answer;
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map((item) => toSafeQuestionBankText(item));
|
||||||
|
}
|
||||||
|
if (raw && typeof raw === 'object') {
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
if (Array.isArray(obj.pairs)) {
|
||||||
|
return obj.pairs.map((pair) => {
|
||||||
|
if (pair && typeof pair === 'object') {
|
||||||
|
const pairObj = pair as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
left: toSafeQuestionBankText(pairObj.left ?? pairObj[0]),
|
||||||
|
right: toSafeQuestionBankText(pairObj.right ?? pairObj[1]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
left: toSafeQuestionBankText(pair),
|
||||||
|
right: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ('answer' in obj || 'keywords' in obj || 'text' in obj || 'label' in obj) {
|
||||||
|
return toSafeQuestionBankText(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...q,
|
||||||
|
question_text: toSafeQuestionBankText(q.question_text),
|
||||||
|
tags: safeTags,
|
||||||
|
options: safeOptions,
|
||||||
|
correct_answer: safeCorrectAnswer,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const questionBankApi = {
|
export const questionBankApi = {
|
||||||
list: (filters?: QuestionBankFilters): Promise<QuestionBank[]> =>
|
list: async (filters?: QuestionBankFilters): Promise<QuestionBank[]> => {
|
||||||
apiFetch('/question-bank', { method: 'GET', query: filters as Record<string, string | number | boolean | undefined | null> }, false),
|
const questions = await apiFetch('/question-bank', { method: 'GET', query: filters as Record<string, string | number | boolean | undefined | null> }, false);
|
||||||
get: (id: string): Promise<QuestionBank> =>
|
return (questions as QuestionBank[]).map(normalizeQuestionBank);
|
||||||
apiFetch(`/question-bank/${id}`, {}, false),
|
},
|
||||||
create: (payload: CreateQuestionBankPayload): Promise<QuestionBank> =>
|
get: async (id: string): Promise<QuestionBank> => {
|
||||||
apiFetch('/question-bank', { method: 'POST', body: JSON.stringify(payload) }, false),
|
const question = await apiFetch(`/question-bank/${id}`, {}, false);
|
||||||
update: (id: string, payload: UpdateQuestionBankPayload): Promise<QuestionBank> =>
|
return normalizeQuestionBank(question as QuestionBank);
|
||||||
apiFetch(`/question-bank/${id}`, { method: 'PUT', body: JSON.stringify(payload) }, false),
|
},
|
||||||
|
create: async (payload: CreateQuestionBankPayload): Promise<QuestionBank> => {
|
||||||
|
const question = await apiFetch('/question-bank', { method: 'POST', body: JSON.stringify(payload) }, false);
|
||||||
|
return normalizeQuestionBank(question as QuestionBank);
|
||||||
|
},
|
||||||
|
update: async (id: string, payload: UpdateQuestionBankPayload): Promise<QuestionBank> => {
|
||||||
|
const question = await apiFetch(`/question-bank/${id}`, { method: 'PUT', body: JSON.stringify(payload) }, false);
|
||||||
|
return normalizeQuestionBank(question as QuestionBank);
|
||||||
|
},
|
||||||
delete: (id: string): Promise<void> =>
|
delete: (id: string): Promise<void> =>
|
||||||
apiFetch(`/question-bank/${id}`, { method: 'DELETE' }, false),
|
apiFetch(`/question-bank/${id}`, { method: 'DELETE' }, false),
|
||||||
importFromMySQL: (courseId?: number, questionIds?: number[], importAll?: boolean): Promise<QuestionBank[]> =>
|
importFromMySQL: async (courseId?: number, questionIds?: number[], importAll?: boolean): Promise<QuestionBank[]> => {
|
||||||
apiFetch('/question-bank/import-mysql', { method: 'POST', body: JSON.stringify({ mysql_course_id: courseId, question_ids: questionIds, import_all: importAll }) }, false),
|
const questions = await apiFetch('/question-bank/import-mysql', { method: 'POST', body: JSON.stringify({ mysql_course_id: courseId, question_ids: questionIds, import_all: importAll }) }, false);
|
||||||
|
return (questions as QuestionBank[]).map(normalizeQuestionBank);
|
||||||
|
},
|
||||||
getMySQLPlans: async (): Promise<MySqlPlan[]> => {
|
getMySQLPlans: async (): Promise<MySqlPlan[]> => {
|
||||||
const plans = (await apiFetch('/question-bank/mysql-plans', {}, false)) as MySqlPlanRaw[];
|
const plans = (await apiFetch('/question-bank/mysql-plans', {}, false)) as MySqlPlanRaw[];
|
||||||
return plans.reduce((acc: MySqlPlan[], p: MySqlPlanRaw) => {
|
return plans.reduce((acc: MySqlPlan[], p: MySqlPlanRaw) => {
|
||||||
|
|||||||