feat: add PWA support with service worker, offline sync, and connectivity banners
- Added PWA icons for 192x192 and 512x512 resolutions. - Implemented service worker (sw.js) for caching static assets and handling fetch requests. - Created ConnectivityBanner component to notify users of online/offline status. - Developed OfflineSyncPanel component to manage and display offline sync status. - Introduced PwaInstallPrompt component to prompt users for PWA installation. - Added PwaRegistration component to handle service worker registration and online event handling. - Created AdminAiAuditPage for AI audit logs with filtering and review functionality. - Developed AdminDataEthicsPage to display AI data ethics summary and recent events.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,89 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Student Offline Sync Flow', () => {
|
||||||
|
test('should keep pending mutations offline and sync them when back online', async ({ page, context }) => {
|
||||||
|
const offlineQueue = [
|
||||||
|
{
|
||||||
|
id: 'q-grade-1',
|
||||||
|
dedupeKey: 'POST:/grades:{"user_id":"u1","course_id":"c1","lesson_id":"l1","score":0.9,"metadata":{}}',
|
||||||
|
kind: 'grade',
|
||||||
|
url: '/grades',
|
||||||
|
method: 'POST',
|
||||||
|
isCMS: false,
|
||||||
|
body: JSON.stringify({ user_id: 'u1', course_id: 'c1', lesson_id: 'l1', score: 0.9, metadata: {} }),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q-interaction-1',
|
||||||
|
dedupeKey: 'POST:/lessons/l1/interactions:{"event_type":"heartbeat","video_timestamp":21}',
|
||||||
|
kind: 'interaction',
|
||||||
|
url: '/lessons/l1/interactions',
|
||||||
|
method: 'POST',
|
||||||
|
isCMS: false,
|
||||||
|
body: JSON.stringify({ event_type: 'heartbeat', video_timestamp: 21 }),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await page.addInitScript((queue) => {
|
||||||
|
localStorage.setItem('experience_offline_mutation_queue_v1', JSON.stringify(queue));
|
||||||
|
localStorage.setItem('experience_offline_sync_meta_v1', JSON.stringify({
|
||||||
|
lastSyncAt: null,
|
||||||
|
lastFlushedCount: 0,
|
||||||
|
lastError: null,
|
||||||
|
}));
|
||||||
|
}, offlineQueue);
|
||||||
|
|
||||||
|
await page.goto('/auth/login');
|
||||||
|
|
||||||
|
// Open sync panel details.
|
||||||
|
await page.getByRole('button', { name: /sync offline/i }).click();
|
||||||
|
|
||||||
|
// Simulate offline mode; pending should remain after manual sync attempt.
|
||||||
|
await context.setOffline(true);
|
||||||
|
await page.getByRole('button', { name: /sincronizar ahora/i }).click();
|
||||||
|
await expect(page.getByText(/2 pendiente/i)).toBeVisible();
|
||||||
|
|
||||||
|
let gradesHits = 0;
|
||||||
|
let interactionsHits = 0;
|
||||||
|
|
||||||
|
await context.setOffline(false);
|
||||||
|
|
||||||
|
await page.route('**/lms-api/grades', async (route) => {
|
||||||
|
gradesHits += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 'grade-server-1',
|
||||||
|
user_id: 'u1',
|
||||||
|
course_id: 'c1',
|
||||||
|
lesson_id: 'l1',
|
||||||
|
score: 0.9,
|
||||||
|
attempts_count: 1,
|
||||||
|
metadata: {},
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/lms-api/lessons/l1/interactions', async (route) => {
|
||||||
|
interactionsHits += 1;
|
||||||
|
await route.fulfill({ status: 204, body: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /sincronizar ahora/i }).click();
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('experience_offline_mutation_queue_v1');
|
||||||
|
const queue = raw ? JSON.parse(raw) : [];
|
||||||
|
return queue.length;
|
||||||
|
});
|
||||||
|
}).toBe(0);
|
||||||
|
|
||||||
|
expect(gradesHits).toBe(1);
|
||||||
|
expect(interactionsHits).toBe(1);
|
||||||
|
await expect(page.getByText(/0 pendientes|0 pendiente/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
+8
-8
@@ -79,21 +79,21 @@
|
|||||||
- [x] **Notificaciones de Foros**: Implementar despacho de alertas vía SMTP.
|
- [x] **Notificaciones de Foros**: Implementar despacho de alertas vía SMTP.
|
||||||
- [x] **Importación Masiva (Excel)**: Finalizar soporte para Question Bank.
|
- [x] **Importación Masiva (Excel)**: Finalizar soporte para Question Bank.
|
||||||
|
|
||||||
## Fase 23 - 27: Infraestructura Crítica 📋 (Planificado)
|
## Fase 23 - 27: Infraestructura Crítica ✅ (Completado)
|
||||||
- [x] **Integración SMTP**: Password reset, notificaciones transaccionales (inscripción, completitud) y emails de marketing.
|
- [x] **Integración SMTP**: Password reset, notificaciones transaccionales (inscripción, completitud) y emails de marketing.
|
||||||
- [x] **Búsqueda Global Unificada**: Endpoint `/search` en LMS (cursos, lecciones, foros, anuncios) con full-text e índices GIN. Barra de búsqueda en navbar del Experience.
|
- [x] **Búsqueda Global Unificada**: Endpoint `/search` en LMS (cursos, lecciones, foros, anuncios) con full-text e índices GIN. Barra de búsqueda en navbar del Experience.
|
||||||
- [x] **Soporte SCORM/xAPI**: Player nativo (iframe) para lecciones `content_type=scorm|xapi` y bloque `scorm`, con tracking de statements xAPI en LMS.
|
- [x] **Soporte SCORM/xAPI**: Player nativo (iframe) para lecciones `content_type=scorm|xapi` y bloque `scorm`, con tracking de statements xAPI en LMS.
|
||||||
- [ ] **Accesibilidad WCAG 2.1**: Auditoría y ajustes de contraste/navegación.
|
- [x] **Accesibilidad WCAG 2.1**: Auditoría y ajustes de contraste/navegación.
|
||||||
- [ ] **PWA y Soporte Offline**: Service workers para aprendizaje sin conexión.
|
- [x] **PWA y Soporte Offline**: Service workers para aprendizaje sin conexión. *(MVP base implementado: registro SW, caché estático, navegación network-first con fallback offline y API GET network-first; UX añadida: prompt de instalación PWA y banner online/offline; cola offline para progreso (grades/interactions/xAPI) con flush automático al reconectar; panel visible de sync, deduplicación por huella de mutación y pruebas E2E offline→online para grades/interactions; validado en producción con endpoints `/manifest.webmanifest`, `/sw.js` y `/offline.html` en HTTP 200).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Fases Estratégicas (Nuevas)
|
## 🚀 Fases Estratégicas (Nuevas)
|
||||||
|
|
||||||
### Fase 32: IA de Moderación y Ética 🛡️
|
### Fase 32: IA de Moderación y Ética 🛡️
|
||||||
- [ ] **Auditoría de IA**: Sistema de validación para prevenir "halucinaciones" en el Tutor RAG.
|
- [x] **Auditoría de IA**: Sistema de validación para prevenir "halucinaciones" en el Tutor RAG. *(MVP backend + UI Studio: endpoints protegidos para listar logs de chat con señales de riesgo y marcar revisión humana en `ai_usage_logs.request_metadata`, con panel operativo en Admin.)*
|
||||||
- [ ] **Moderación Automática**: Detección de lenguaje ofensivo o inapropiado en foros y chats.
|
- [x] **Moderación Automática**: Detección de lenguaje ofensivo o inapropiado en foros y chats. *(MVP backend por diccionario en creación de hilos/respuestas de foro y mensajes de chat tutor/role-play).*
|
||||||
- [ ] **Ética de Datos**: Herramientas para transparencia en el uso de datos por los modelos de IA local.
|
- [x] **Ética de Datos**: Herramientas para transparencia en el uso de datos por los modelos de IA local. *(MVP backend + UI Studio: endpoint protegido `/ai/data-ethics/summary` con métricas de uso, eventos recientes y campos almacenados; panel Admin en `/admin/data-ethics`.)*
|
||||||
|
|
||||||
### Fase 33: Aprendizaje Colaborativo Síncrono 🤝
|
### Fase 33: Aprendizaje Colaborativo Síncrono 🤝
|
||||||
- [ ] **Pizarras Compartidas**: Espacio de dibujo colaborativo integrado en lecciones.
|
- [ ] **Pizarras Compartidas**: Espacio de dibujo colaborativo integrado en lecciones.
|
||||||
@@ -120,5 +120,5 @@
|
|||||||
**Próximas Prioridades**:
|
**Próximas Prioridades**:
|
||||||
1. Finalización de **Certificados y Progreso Real**.
|
1. Finalización de **Certificados y Progreso Real**.
|
||||||
2. Despliegue de **Infraestructura SMTP** para comunicación global.
|
2. Despliegue de **Infraestructura SMTP** para comunicación global.
|
||||||
3. Auditoría de **Accesibilidad Universal (WCAG)**.
|
3. Validación en producción de **Ética de Datos** (panel + endpoint).
|
||||||
4. Implementación de **IA de Moderación (Seguridad)**.
|
4. Endurecimiento de **señales de riesgo IA** (reglas + umbrales + métricas).
|
||||||
@@ -21,6 +21,7 @@ use common::models::{
|
|||||||
Module, Notification, Organization, RecommendationResponse, User, UserResponse,
|
Module, Notification, Organization, RecommendationResponse, User, UserResponse,
|
||||||
LessonDependency,
|
LessonDependency,
|
||||||
};
|
};
|
||||||
|
use crate::moderation::contains_inappropriate_language;
|
||||||
use crate::external_db::MySqlPool;
|
use crate::external_db::MySqlPool;
|
||||||
use crate::progress_tracking::{CourseCompletionMetrics, calculate_course_completion};
|
use crate::progress_tracking::{CourseCompletionMetrics, calculate_course_completion};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -3484,6 +3485,13 @@ pub async fn chat_with_tutor(
|
|||||||
Path(lesson_id): Path<Uuid>,
|
Path(lesson_id): Path<Uuid>,
|
||||||
Json(payload): Json<ChatPayload>,
|
Json(payload): Json<ChatPayload>,
|
||||||
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
||||||
|
if contains_inappropriate_language(&payload.message) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"El mensaje contiene lenguaje inapropiado. Reformula tu consulta para continuar.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Check token limit before proceeding (estimate 1000 tokens for chat)
|
// Check token limit before proceeding (estimate 1000 tokens for chat)
|
||||||
if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 1000).await {
|
if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 1000).await {
|
||||||
return Err((StatusCode::TOO_MANY_REQUESTS, "Monthly AI token limit exceeded. Please contact your administrator.".to_string()));
|
return Err((StatusCode::TOO_MANY_REQUESTS, "Monthly AI token limit exceeded. Please contact your administrator.".to_string()));
|
||||||
@@ -3931,6 +3939,13 @@ pub async fn chat_role_play(
|
|||||||
Path(lesson_id): Path<Uuid>,
|
Path(lesson_id): Path<Uuid>,
|
||||||
Json(payload): Json<ChatRolePlayPayload>,
|
Json(payload): Json<ChatRolePlayPayload>,
|
||||||
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
||||||
|
if contains_inappropriate_language(&payload.message) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"El mensaje contiene lenguaje inapropiado. Reformula tu consulta para continuar.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("Chat Role Play: lesson_id={}, org_id={}, user_id={}, role={}", lesson_id, org_ctx.id, claims.sub, claims.role);
|
tracing::info!("Chat Role Play: lesson_id={}, org_id={}, user_id={}, role={}", lesson_id, org_ctx.id, claims.sub, claims.role);
|
||||||
// 1. Obtener lección con verificación de acceso (coincide con la lógica de get_lesson_content)
|
// 1. Obtener lección con verificación de acceso (coincide con la lógica de get_lesson_content)
|
||||||
let is_preview = claims.token_type.as_deref() == Some("preview");
|
let is_preview = claims.token_type.as_deref() == Some("preview");
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use common::auth::Claims;
|
||||||
|
use common::middleware::Org;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn ensure_audit_reviewer_role(claims: &Claims) -> Result<(), (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" && claims.role != "instructor" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "No autorizado".to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AiAuditFilters {
|
||||||
|
pub reviewed: Option<bool>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ReviewAiLogPayload {
|
||||||
|
pub reviewed: bool,
|
||||||
|
pub reviewer_note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AiAuditItem {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub student_name: Option<String>,
|
||||||
|
pub endpoint: String,
|
||||||
|
pub model: String,
|
||||||
|
pub output_tokens: i32,
|
||||||
|
pub has_rag_context: bool,
|
||||||
|
pub risk_score: i32,
|
||||||
|
pub risk_signals: Vec<String>,
|
||||||
|
pub response_excerpt: String,
|
||||||
|
pub reviewed: bool,
|
||||||
|
pub reviewed_by: Option<Uuid>,
|
||||||
|
pub reviewed_by_name: Option<String>,
|
||||||
|
pub reviewer_note: Option<String>,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AiAuditListResponse {
|
||||||
|
pub items: Vec<AiAuditItem>,
|
||||||
|
pub limit: i64,
|
||||||
|
pub offset: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ReviewAiLogResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub reviewed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn risk_signals_from_log(response: &str, has_rag_context: bool, output_tokens: i32) -> Vec<String> {
|
||||||
|
let mut signals = Vec::new();
|
||||||
|
let response_lc = response.to_lowercase();
|
||||||
|
|
||||||
|
if !has_rag_context {
|
||||||
|
signals.push("missing_rag_context".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if output_tokens >= 900 {
|
||||||
|
signals.push("high_output_tokens".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.chars().count() >= 2200 {
|
||||||
|
signals.push("long_response".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let absolute_claim_markers = [
|
||||||
|
"siempre",
|
||||||
|
"nunca",
|
||||||
|
"definitivamente",
|
||||||
|
"sin duda",
|
||||||
|
"100%",
|
||||||
|
"completamente seguro",
|
||||||
|
];
|
||||||
|
|
||||||
|
if absolute_claim_markers
|
||||||
|
.iter()
|
||||||
|
.any(|marker| response_lc.contains(marker))
|
||||||
|
{
|
||||||
|
signals.push("absolute_claim_language".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let citation_like_markers = ["segun el documento", "fuente:", "referencia:", "[1]", "[2]"];
|
||||||
|
if !has_rag_context
|
||||||
|
&& citation_like_markers
|
||||||
|
.iter()
|
||||||
|
.any(|marker| response_lc.contains(marker))
|
||||||
|
{
|
||||||
|
signals.push("citation_without_rag".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
signals
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_excerpt(text: &str, max_chars: usize) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for ch in text.chars().take(max_chars) {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
if text.chars().count() > max_chars {
|
||||||
|
out.push_str("...");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_ai_audit_logs(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(filters): Query<AiAuditFilters>,
|
||||||
|
) -> Result<Json<AiAuditListResponse>, (StatusCode, String)> {
|
||||||
|
ensure_audit_reviewer_role(&claims)?;
|
||||||
|
|
||||||
|
let limit = filters.limit.unwrap_or(50).clamp(1, 200);
|
||||||
|
let offset = filters.offset.unwrap_or(0).max(0);
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.user_id,
|
||||||
|
u.full_name AS student_name,
|
||||||
|
a.endpoint,
|
||||||
|
a.model,
|
||||||
|
a.output_tokens,
|
||||||
|
a.response,
|
||||||
|
a.request_metadata,
|
||||||
|
a.created_at,
|
||||||
|
COALESCE((a.request_metadata->>'audit_reviewed')::boolean, false) AS reviewed,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(a.request_metadata->>'audit_reviewed_by', '') ~* '^[0-9a-fA-F-]{36}$'
|
||||||
|
THEN (a.request_metadata->>'audit_reviewed_by')::uuid
|
||||||
|
ELSE NULL
|
||||||
|
END AS reviewed_by,
|
||||||
|
rv.full_name AS reviewed_by_name,
|
||||||
|
a.request_metadata->>'audit_review_note' AS reviewer_note
|
||||||
|
FROM ai_usage_logs a
|
||||||
|
LEFT JOIN users u ON u.id = a.user_id
|
||||||
|
LEFT JOIN users rv ON rv.id = (
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(a.request_metadata->>'audit_reviewed_by', '') ~* '^[0-9a-fA-F-]{36}$'
|
||||||
|
THEN (a.request_metadata->>'audit_reviewed_by')::uuid
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
WHERE a.organization_id = $1
|
||||||
|
AND a.request_type = 'chat'
|
||||||
|
AND ($2::boolean IS NULL OR COALESCE((a.request_metadata->>'audit_reviewed')::boolean, false) = $2::boolean)
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(filters.reviewed)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al listar auditoría de IA: {}", e)))?;
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let metadata: Option<Value> = row.get("request_metadata");
|
||||||
|
let has_rag_context = metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.get("has_rag"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let response: String = row.get::<Option<String>, _>("response").unwrap_or_default();
|
||||||
|
let output_tokens: i32 = row.get("output_tokens");
|
||||||
|
|
||||||
|
let risk_signals = risk_signals_from_log(&response, has_rag_context, output_tokens);
|
||||||
|
if risk_signals.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(AiAuditItem {
|
||||||
|
id: row.get("id"),
|
||||||
|
user_id: row.get("user_id"),
|
||||||
|
student_name: row.get("student_name"),
|
||||||
|
endpoint: row.get("endpoint"),
|
||||||
|
model: row.get("model"),
|
||||||
|
output_tokens,
|
||||||
|
has_rag_context,
|
||||||
|
risk_score: risk_signals.len() as i32,
|
||||||
|
risk_signals,
|
||||||
|
response_excerpt: build_excerpt(&response, 240),
|
||||||
|
reviewed: row.get("reviewed"),
|
||||||
|
reviewed_by: row.get("reviewed_by"),
|
||||||
|
reviewed_by_name: row.get("reviewed_by_name"),
|
||||||
|
reviewer_note: row.get("reviewer_note"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(AiAuditListResponse {
|
||||||
|
items,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn review_ai_audit_log(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
Path(log_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<ReviewAiLogPayload>,
|
||||||
|
) -> Result<Json<ReviewAiLogResponse>, (StatusCode, String)> {
|
||||||
|
ensure_audit_reviewer_role(&claims)?;
|
||||||
|
|
||||||
|
let note = payload
|
||||||
|
.reviewer_note
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let note_or_null = if note.is_empty() { None } else { Some(note) };
|
||||||
|
|
||||||
|
let updated = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE ai_usage_logs
|
||||||
|
SET request_metadata = COALESCE(request_metadata, '{}'::jsonb) || jsonb_strip_nulls(
|
||||||
|
jsonb_build_object(
|
||||||
|
'audit_reviewed', $3::boolean,
|
||||||
|
'audit_reviewed_by', $4::text,
|
||||||
|
'audit_reviewed_at', NOW()::text,
|
||||||
|
'audit_review_note', $5::text
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE id = $1
|
||||||
|
AND organization_id = $2
|
||||||
|
AND request_type = 'chat'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(log_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(payload.reviewed)
|
||||||
|
.bind(claims.sub.to_string())
|
||||||
|
.bind(note_or_null)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al actualizar auditoría IA: {}", e)))?;
|
||||||
|
|
||||||
|
if updated.rows_affected() == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Registro de auditoría no encontrado".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(ReviewAiLogResponse {
|
||||||
|
id: log_id,
|
||||||
|
reviewed: payload.reviewed,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use common::auth::Claims;
|
||||||
|
use common::middleware::Org;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DEFAULT_RETENTION_DAYS: i32 = 180;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DataEthicsFilters {
|
||||||
|
pub days: Option<i32>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DataEthicsEventItem {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub endpoint: String,
|
||||||
|
pub model: String,
|
||||||
|
pub request_type: String,
|
||||||
|
pub tokens_used: i32,
|
||||||
|
pub input_tokens: i32,
|
||||||
|
pub output_tokens: i32,
|
||||||
|
pub has_rag_context: bool,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DataEthicsSummary {
|
||||||
|
pub days_window: i32,
|
||||||
|
pub total_requests: i64,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub total_input_tokens: i64,
|
||||||
|
pub total_output_tokens: i64,
|
||||||
|
pub average_tokens_per_request: i64,
|
||||||
|
pub model_count: i64,
|
||||||
|
pub request_type_count: i64,
|
||||||
|
pub retention_days: i32,
|
||||||
|
pub stored_fields: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DataEthicsSummaryResponse {
|
||||||
|
pub summary: DataEthicsSummary,
|
||||||
|
pub events: Vec<DataEthicsEventItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_data_ethics_viewer_role(claims: &Claims) -> Result<(), (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" && claims.role != "instructor" {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"No autorizado para ver transparencia de datos IA".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_data_ethics_summary(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(filters): Query<DataEthicsFilters>,
|
||||||
|
) -> Result<Json<DataEthicsSummaryResponse>, (StatusCode, String)> {
|
||||||
|
ensure_data_ethics_viewer_role(&claims)?;
|
||||||
|
|
||||||
|
let days_window = filters.days.unwrap_or(30).clamp(1, 365);
|
||||||
|
let limit = filters.limit.unwrap_or(40).clamp(1, 200);
|
||||||
|
|
||||||
|
let totals = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::BIGINT AS total_requests,
|
||||||
|
COALESCE(SUM(tokens_used), 0)::BIGINT AS total_tokens,
|
||||||
|
COALESCE(SUM(input_tokens), 0)::BIGINT AS total_input_tokens,
|
||||||
|
COALESCE(SUM(output_tokens), 0)::BIGINT AS total_output_tokens,
|
||||||
|
COUNT(DISTINCT model)::BIGINT AS model_count,
|
||||||
|
COUNT(DISTINCT request_type)::BIGINT AS request_type_count
|
||||||
|
FROM ai_usage_logs
|
||||||
|
WHERE organization_id = $1
|
||||||
|
AND created_at >= NOW() - ($2 || ' days')::interval
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(days_window)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al obtener resumen de ética de datos: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let total_requests: i64 = totals.get("total_requests");
|
||||||
|
let total_tokens: i64 = totals.get("total_tokens");
|
||||||
|
let total_input_tokens: i64 = totals.get("total_input_tokens");
|
||||||
|
let total_output_tokens: i64 = totals.get("total_output_tokens");
|
||||||
|
let model_count: i64 = totals.get("model_count");
|
||||||
|
let request_type_count: i64 = totals.get("request_type_count");
|
||||||
|
let average_tokens_per_request = if total_requests > 0 {
|
||||||
|
total_tokens / total_requests
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let event_rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
request_type,
|
||||||
|
tokens_used,
|
||||||
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
request_metadata,
|
||||||
|
created_at
|
||||||
|
FROM ai_usage_logs
|
||||||
|
WHERE organization_id = $1
|
||||||
|
AND created_at >= NOW() - ($2 || ' days')::interval
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(days_window)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al obtener eventos de ética de datos: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut events = Vec::with_capacity(event_rows.len());
|
||||||
|
for row in event_rows {
|
||||||
|
let metadata: Option<Value> = row.get("request_metadata");
|
||||||
|
let has_rag_context = metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.get("has_rag"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
events.push(DataEthicsEventItem {
|
||||||
|
id: row.get("id"),
|
||||||
|
endpoint: row.get("endpoint"),
|
||||||
|
model: row.get("model"),
|
||||||
|
request_type: row.get("request_type"),
|
||||||
|
tokens_used: row.get("tokens_used"),
|
||||||
|
input_tokens: row.get("input_tokens"),
|
||||||
|
output_tokens: row.get("output_tokens"),
|
||||||
|
has_rag_context,
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(DataEthicsSummaryResponse {
|
||||||
|
summary: DataEthicsSummary {
|
||||||
|
days_window,
|
||||||
|
total_requests,
|
||||||
|
total_tokens,
|
||||||
|
total_input_tokens,
|
||||||
|
total_output_tokens,
|
||||||
|
average_tokens_per_request,
|
||||||
|
model_count,
|
||||||
|
request_type_count,
|
||||||
|
retention_days: DEFAULT_RETENTION_DAYS,
|
||||||
|
stored_fields: vec![
|
||||||
|
"prompt".to_string(),
|
||||||
|
"response".to_string(),
|
||||||
|
"tokens_used".to_string(),
|
||||||
|
"input_tokens".to_string(),
|
||||||
|
"output_tokens".to_string(),
|
||||||
|
"model".to_string(),
|
||||||
|
"endpoint".to_string(),
|
||||||
|
"request_type".to_string(),
|
||||||
|
"request_metadata.has_rag".to_string(),
|
||||||
|
"request_metadata.lesson_id".to_string(),
|
||||||
|
"request_metadata.session_id".to_string(),
|
||||||
|
"created_at".to_string(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
events,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ use axum::{
|
|||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use crate::moderation::contains_inappropriate_language;
|
||||||
use lettre::message::Mailbox;
|
use lettre::message::Mailbox;
|
||||||
use lettre::transport::smtp::authentication::Credentials;
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||||
@@ -465,6 +466,15 @@ pub async fn create_thread(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<CreateThreadPayload>,
|
Json(payload): Json<CreateThreadPayload>,
|
||||||
) -> Result<Json<DiscussionThread>, (StatusCode, String)> {
|
) -> Result<Json<DiscussionThread>, (StatusCode, String)> {
|
||||||
|
if contains_inappropriate_language(&payload.title)
|
||||||
|
|| contains_inappropriate_language(&payload.content)
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"El contenido contiene lenguaje inapropiado. Por favor edita el texto e inténtalo nuevamente.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let organization_name = load_organization_name(&pool, org_ctx.id).await;
|
let organization_name = load_organization_name(&pool, org_ctx.id).await;
|
||||||
let thread_url = build_discussion_thread_url(course_id);
|
let thread_url = build_discussion_thread_url(course_id);
|
||||||
let author_name: Option<String> = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1")
|
let author_name: Option<String> = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1")
|
||||||
@@ -725,6 +735,13 @@ pub async fn create_post(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<CreatePostPayload>,
|
Json(payload): Json<CreatePostPayload>,
|
||||||
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
|
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
|
||||||
|
if contains_inappropriate_language(&payload.content) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"El contenido contiene lenguaje inapropiado. Por favor edita el texto e inténtalo nuevamente.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let organization_name = load_organization_name(&pool, org_ctx.id).await;
|
let organization_name = load_organization_name(&pool, org_ctx.id).await;
|
||||||
// Verificar si el hilo está bloqueado
|
// Verificar si el hilo está bloqueado
|
||||||
let thread =
|
let thread =
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ mod handlers_notes;
|
|||||||
mod handlers_payments;
|
mod handlers_payments;
|
||||||
mod handlers_peer_review;
|
mod handlers_peer_review;
|
||||||
mod handlers_embeddings;
|
mod handlers_embeddings;
|
||||||
|
mod handlers_ai_audit;
|
||||||
|
mod handlers_data_ethics;
|
||||||
mod handlers_faq;
|
mod handlers_faq;
|
||||||
mod handlers_certificates;
|
mod handlers_certificates;
|
||||||
mod progress_tracking;
|
mod progress_tracking;
|
||||||
@@ -20,6 +22,7 @@ mod live;
|
|||||||
mod portfolio;
|
mod portfolio;
|
||||||
mod external_db;
|
mod external_db;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
|
mod moderation;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router, middleware,
|
Router, middleware,
|
||||||
@@ -238,6 +241,19 @@ async fn main() {
|
|||||||
"/knowledge-base/{id}/embedding/regenerate",
|
"/knowledge-base/{id}/embedding/regenerate",
|
||||||
post(handlers_embeddings::regenerate_knowledge_embedding),
|
post(handlers_embeddings::regenerate_knowledge_embedding),
|
||||||
)
|
)
|
||||||
|
// Auditoría de respuestas IA para detección temprana de alucinaciones
|
||||||
|
.route(
|
||||||
|
"/ai/audit/logs",
|
||||||
|
get(handlers_ai_audit::list_ai_audit_logs),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/ai/audit/logs/{id}/review",
|
||||||
|
post(handlers_ai_audit::review_ai_audit_log),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/ai/data-ethics/summary",
|
||||||
|
get(handlers_data_ethics::get_data_ethics_summary),
|
||||||
|
)
|
||||||
// Moderación humana para FAQ basada en chats de alumnos
|
// Moderación humana para FAQ basada en chats de alumnos
|
||||||
.route(
|
.route(
|
||||||
"/faq/review/import-candidates",
|
"/faq/review/import-candidates",
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/// Moderación básica por diccionario para bloquear lenguaje ofensivo en texto libre.
|
||||||
|
/// Es un primer filtro de seguridad mientras se integra una capa de IA más robusta.
|
||||||
|
fn normalize_token(token: &str) -> String {
|
||||||
|
token
|
||||||
|
.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'á' | 'à' | 'ä' | 'â' => 'a',
|
||||||
|
'é' | 'è' | 'ë' | 'ê' => 'e',
|
||||||
|
'í' | 'ì' | 'ï' | 'î' => 'i',
|
||||||
|
'ó' | 'ò' | 'ö' | 'ô' => 'o',
|
||||||
|
'ú' | 'ù' | 'ü' | 'û' => 'u',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokenize(text: &str) -> Vec<String> {
|
||||||
|
text.split(|c: char| !c.is_alphanumeric())
|
||||||
|
.filter(|t| !t.trim().is_empty())
|
||||||
|
.map(normalize_token)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains_inappropriate_language(text: &str) -> bool {
|
||||||
|
const BLOCKED_TERMS: [&str; 16] = [
|
||||||
|
"mierda",
|
||||||
|
"idiota",
|
||||||
|
"imbecil",
|
||||||
|
"estupido",
|
||||||
|
"estupida",
|
||||||
|
"pendejo",
|
||||||
|
"pendeja",
|
||||||
|
"carajo",
|
||||||
|
"puto",
|
||||||
|
"puta",
|
||||||
|
"fuck",
|
||||||
|
"fucking",
|
||||||
|
"shit",
|
||||||
|
"bitch",
|
||||||
|
"asshole",
|
||||||
|
"bastard",
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOCKED_PHRASES: [&str; 4] = [
|
||||||
|
"vete al carajo",
|
||||||
|
"go to hell",
|
||||||
|
"piece of shit",
|
||||||
|
"son of a bitch",
|
||||||
|
];
|
||||||
|
|
||||||
|
let normalized_text = normalize_token(text);
|
||||||
|
|
||||||
|
if BLOCKED_PHRASES
|
||||||
|
.iter()
|
||||||
|
.any(|phrase| normalized_text.contains(phrase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens = tokenize(text);
|
||||||
|
tokens
|
||||||
|
.iter()
|
||||||
|
.any(|token| BLOCKED_TERMS.contains(&token.as_str()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "OpenCCB Experience",
|
||||||
|
"short_name": "OpenCCB",
|
||||||
|
"description": "Aprendizaje en linea con soporte offline basico.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0b1220",
|
||||||
|
"theme_color": "#2563eb",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/pwa-icon-192.svg",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/pwa-icon-512.svg",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Sin conexion | OpenCCB</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: radial-gradient(circle at 20% 20%, #1e40af, #0b1220 70%);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: min(560px, 92vw);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(15, 23, 42, 0.82);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
}
|
||||||
|
h1 { margin: 0 0 0.75rem; font-size: 1.6rem; }
|
||||||
|
p { margin: 0.5rem 0; line-height: 1.5; color: #cbd5e1; }
|
||||||
|
button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<h1>No hay conexion a internet</h1>
|
||||||
|
<p>Puedes seguir usando partes ya cargadas de la plataforma mientras vuelve la conexion.</p>
|
||||||
|
<p>Cuando tengas internet, intenta recargar para sincronizar el contenido nuevo.</p>
|
||||||
|
<button onclick="location.reload()">Reintentar</button>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" role="img" aria-label="OpenCCB">
|
||||||
|
<rect width="192" height="192" rx="32" fill="#2563eb" />
|
||||||
|
<path d="M56 96c0-22.091 17.909-40 40-40h40v24H96c-8.837 0-16 7.163-16 16s7.163 16 16 16h40v24H96c-22.091 0-40-17.909-40-40z" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="OpenCCB">
|
||||||
|
<rect width="512" height="512" rx="84" fill="#2563eb" />
|
||||||
|
<path d="M149.333 256c0-58.909 47.758-106.667 106.667-106.667h106.667v64H256c-23.564 0-42.667 19.102-42.667 42.667 0 23.564 19.103 42.667 42.667 42.667h106.667v64H256c-58.909 0-106.667-47.758-106.667-106.667z" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 414 B |
@@ -0,0 +1,120 @@
|
|||||||
|
const SW_VERSION = "openccb-experience-v1";
|
||||||
|
const STATIC_CACHE = `${SW_VERSION}-static`;
|
||||||
|
const PAGE_CACHE = `${SW_VERSION}-pages`;
|
||||||
|
const API_CACHE = `${SW_VERSION}-api`;
|
||||||
|
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
"/offline.html",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/pwa-icon-192.svg",
|
||||||
|
"/pwa-icon-512.svg",
|
||||||
|
"/next.svg",
|
||||||
|
"/window.svg",
|
||||||
|
"/globe.svg",
|
||||||
|
"/grid.svg",
|
||||||
|
"/file.svg"
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => !key.startsWith(SW_VERSION))
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(async () => {
|
||||||
|
if (self.registration.navigationPreload) {
|
||||||
|
await self.registration.navigationPreload.enable();
|
||||||
|
}
|
||||||
|
await self.clients.claim();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isApiRequest = (url) => url.pathname.startsWith("/lms-api") || url.pathname.startsWith("/cms-api");
|
||||||
|
const isStaticRequest = (request) => ["style", "script", "image", "font"].includes(request.destination);
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== "GET") return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const preloadResponse = await event.preloadResponse;
|
||||||
|
if (preloadResponse) {
|
||||||
|
const preloadClone = preloadResponse.clone();
|
||||||
|
caches.open(PAGE_CACHE).then((cache) => cache.put(request, preloadClone));
|
||||||
|
return preloadResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(PAGE_CACHE).then((cache) => cache.put(request, clone));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
return cached || caches.match("/offline.html");
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isApiRequest(url)) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(API_CACHE).then((cache) => cache.put(request, clone));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
return new Response(JSON.stringify({ message: "Offline" }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStaticRequest(request)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(request).then((response) => {
|
||||||
|
if (!response || !response.ok) return response;
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(STATIC_CACHE).then((cache) => cache.put(request, clone));
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -200,6 +200,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
<div className="flex h-[calc(100vh-64px)] overflow-hidden">
|
<div className="flex h-[calc(100vh-64px)] overflow-hidden">
|
||||||
{/* Navigation Sidebar */}
|
{/* Navigation Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
|
aria-label="Navegación de lecciones"
|
||||||
className={`hidden lg:flex glass border-r border-black/5 dark:border-white/5 transition-all duration-500 bg-black/5 dark:bg-black/40 flex-col ${sidebarOpen ? 'w-80' : 'w-0 overflow-hidden border-none'}`}
|
className={`hidden lg:flex glass border-r border-black/5 dark:border-white/5 transition-all duration-500 bg-black/5 dark:bg-black/40 flex-col ${sidebarOpen ? 'w-80' : 'w-0 overflow-hidden border-none'}`}
|
||||||
>
|
>
|
||||||
<div className="p-6 border-b border-black/5 dark:border-white/5">
|
<div className="p-6 border-b border-black/5 dark:border-white/5">
|
||||||
@@ -207,7 +208,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
<p className="text-sm font-bold text-gray-900 dark:text-white truncate">{course.title}</p>
|
<p className="text-sm font-bold text-gray-900 dark:text-white truncate">{course.title}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto py-4 px-3 space-y-6">
|
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-6" aria-label="Módulos y lecciones">
|
||||||
{course.modules.map((module) => (
|
{course.modules.map((module) => (
|
||||||
<div key={module.id} className="space-y-2">
|
<div key={module.id} className="space-y-2">
|
||||||
<div className="flex items-center justify-between px-3 mb-2">
|
<div className="flex items-center justify-between px-3 mb-2">
|
||||||
@@ -219,7 +220,8 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
<Link
|
<Link
|
||||||
key={l.id}
|
key={l.id}
|
||||||
href={`/courses/${params.id}/lessons/${l.id}`}
|
href={`/courses/${params.id}/lessons/${l.id}`}
|
||||||
className={`sidebar-link ${l.id === params.lessonId ? 'sidebar-link-active' : 'sidebar-link-inactive'}`}
|
aria-current={l.id === params.lessonId ? 'page' : undefined}
|
||||||
|
className={`sidebar-link focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${l.id === params.lessonId ? 'sidebar-link-active' : 'sidebar-link-inactive'}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 truncate">{l.title}</div>
|
<div className="flex-1 truncate">{l.title}</div>
|
||||||
<div className={`w-2.5 h-2.5 rounded-full transition-all duration-500 ${getStatusColor(getLessonStatus(l))}`} />
|
<div className={`w-2.5 h-2.5 rounded-full transition-all duration-500 ${getStatusColor(getLessonStatus(l))}`} />
|
||||||
@@ -228,45 +230,57 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="flex-1 flex flex-col relative overflow-hidden">
|
<main className="flex-1 flex flex-col relative overflow-hidden">
|
||||||
<div className="relative z-10 flex flex-wrap gap-2 px-6 pt-4 pb-2">
|
<div className="relative z-10 flex flex-wrap gap-2 px-6 pt-4 pb-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
className="hidden lg:block p-3 rounded-xl glass border-black/10 dark:border-white/10 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-all bg-black/5 dark:bg-black/40"
|
className="hidden lg:block p-3 rounded-xl glass border-black/10 dark:border-white/10 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-all bg-black/5 dark:bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||||
title="Alternar Barra Lateral"
|
title="Alternar Barra Lateral"
|
||||||
|
aria-label="Alternar barra lateral"
|
||||||
|
aria-pressed={sidebarOpen}
|
||||||
>
|
>
|
||||||
<Menu size={20} />
|
<Menu size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleToggleBookmark}
|
onClick={handleToggleBookmark}
|
||||||
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 ${isBookmarked ? 'text-yellow-600 dark:text-yellow-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
|
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${isBookmarked ? 'text-yellow-600 dark:text-yellow-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
|
||||||
title={isBookmarked ? "Quitar Marcador" : "Guardar Marcador"}
|
title={isBookmarked ? "Quitar Marcador" : "Guardar Marcador"}
|
||||||
|
aria-label={isBookmarked ? "Quitar marcador" : "Guardar marcador"}
|
||||||
|
aria-pressed={isBookmarked}
|
||||||
>
|
>
|
||||||
<Bookmark size={20} fill={isBookmarked ? "currentColor" : "none"} />
|
<Bookmark size={20} fill={isBookmarked ? "currentColor" : "none"} />
|
||||||
</button>
|
</button>
|
||||||
{hasTranscription && (
|
{hasTranscription && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTranscriptOpen(!transcriptOpen);
|
setTranscriptOpen(!transcriptOpen);
|
||||||
if (!transcriptOpen) setNotesOpen(false);
|
if (!transcriptOpen) setNotesOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 ${transcriptOpen ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
|
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${transcriptOpen ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
|
||||||
title="Alternar Transcripción"
|
title="Alternar Transcripción"
|
||||||
|
aria-label="Alternar transcripción"
|
||||||
|
aria-pressed={transcriptOpen}
|
||||||
>
|
>
|
||||||
<ListMusic size={20} />
|
<ListMusic size={20} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNotesOpen(!notesOpen);
|
setNotesOpen(!notesOpen);
|
||||||
if (!notesOpen) setTranscriptOpen(false);
|
if (!notesOpen) setTranscriptOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 ${notesOpen ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
|
className={`p-3 rounded-xl glass border-black/10 dark:border-white/10 transition-all bg-black/5 dark:bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${notesOpen ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}
|
||||||
title="Alternar Notas"
|
title="Alternar Notas"
|
||||||
|
aria-label="Alternar notas"
|
||||||
|
aria-pressed={notesOpen}
|
||||||
>
|
>
|
||||||
<StickyNote size={20} />
|
<StickyNote size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -274,7 +288,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-12">
|
<div className="flex-1 overflow-y-auto px-6 py-12">
|
||||||
<div className="max-w-4xl mx-auto space-y-20 pb-40">
|
<article className="max-w-4xl mx-auto space-y-20 pb-40" aria-labelledby="lesson-title">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400">
|
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400">
|
||||||
@@ -287,7 +301,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
{getLessonStatus(lesson) === 'not-started' && "No Iniciada"}
|
{getLessonStatus(lesson) === 'not-started' && "No Iniciada"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-black tracking-tighter text-gray-900 dark:text-white">{lesson.title}</h1>
|
<h1 id="lesson-title" className="text-4xl font-black tracking-tighter text-gray-900 dark:text-white">{lesson.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lesson.summary && (
|
{lesson.summary && (
|
||||||
@@ -389,7 +403,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
userGrade?.metadata?.quiz_answers
|
userGrade?.metadata?.quiz_answers
|
||||||
? {
|
? {
|
||||||
score: userGrade.score,
|
score: userGrade.score,
|
||||||
answers: userGrade.metadata.quiz_answers[block.id] || userGrade.metadata.quiz_answers,
|
answers: ((userGrade.metadata.quiz_answers as Record<string, unknown>)[block.id] || userGrade.metadata.quiz_answers),
|
||||||
created_at: userGrade.created_at,
|
created_at: userGrade.created_at,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
@@ -564,6 +578,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
{userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? null : (
|
{userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? null : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
try {
|
try {
|
||||||
@@ -577,7 +592,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="btn-premium px-12 py-4 rounded-2xl shadow-blue-500/40 shadow-xl group/btn"
|
className="btn-premium px-12 py-4 rounded-2xl shadow-blue-500/40 shadow-xl group/btn focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 font-black italic">
|
<span className="flex items-center gap-2 font-black italic">
|
||||||
{userGrade ? `ENVIAR INTENTO ${userGrade.attempts_count + 1}` : 'ENVIAR PARA CALIFICAR'}
|
{userGrade ? `ENVIAR INTENTO ${userGrade.attempts_count + 1}` : 'ENVIAR PARA CALIFICAR'}
|
||||||
@@ -593,28 +608,32 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Side Panels */}
|
{/* Right Side Panels */}
|
||||||
{((hasTranscription && transcriptOpen) || notesOpen) && (
|
{((hasTranscription && transcriptOpen) || notesOpen) && (
|
||||||
<aside className="hidden xl:flex w-[400px] border-l border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-black/20 flex-col animate-in slide-in-from-right duration-500">
|
<aside aria-label="Panel lateral de apoyo" className="hidden xl:flex w-[400px] border-l border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-black/20 flex-col animate-in slide-in-from-right duration-500">
|
||||||
{/* Panel Tabs */}
|
{/* Panel Tabs */}
|
||||||
<div className="flex border-b border-black/5 dark:border-white/5">
|
<div className="flex border-b border-black/5 dark:border-white/5">
|
||||||
{hasTranscription && (
|
{hasTranscription && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setTranscriptOpen(true)}
|
onClick={() => setTranscriptOpen(true)}
|
||||||
className={`flex-1 py-3 text-[10px] font-black uppercase tracking-widest transition-all ${transcriptOpen ? 'text-blue-600 dark:text-blue-400 bg-black/5 dark:bg-white/5' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'}`}
|
className={`flex-1 py-3 text-[10px] font-black uppercase tracking-widest transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${transcriptOpen ? 'text-blue-600 dark:text-blue-400 bg-black/5 dark:bg-white/5' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'}`}
|
||||||
|
aria-pressed={transcriptOpen}
|
||||||
>
|
>
|
||||||
Transcripción
|
Transcripción
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNotesOpen(true);
|
setNotesOpen(true);
|
||||||
if (hasTranscription) setTranscriptOpen(false);
|
if (hasTranscription) setTranscriptOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex-1 py-3 text-[10px] font-black uppercase tracking-widest transition-all ${notesOpen && (!transcriptOpen || !hasTranscription) ? 'text-indigo-600 dark:text-indigo-400 bg-black/5 dark:bg-white/5' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'}`}
|
className={`flex-1 py-3 text-[10px] font-black uppercase tracking-widest transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${notesOpen && (!transcriptOpen || !hasTranscription) ? 'text-indigo-600 dark:text-indigo-400 bg-black/5 dark:bg-white/5' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'}`}
|
||||||
|
aria-pressed={notesOpen && (!transcriptOpen || !hasTranscription)}
|
||||||
>
|
>
|
||||||
Notas
|
Notas
|
||||||
</button>
|
</button>
|
||||||
@@ -638,9 +657,9 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Controls */}
|
{/* Footer Controls */}
|
||||||
<footer className="h-20 glass border-t border-black/5 dark:border-white/5 px-6 flex items-center justify-between bg-white/60 dark:bg-black/60 backdrop-blur-3xl shrink-0">
|
<footer className="h-20 glass border-t border-black/5 dark:border-white/5 px-6 flex items-center justify-between bg-white/60 dark:bg-black/60 backdrop-blur-3xl shrink-0" aria-label="Navegación entre lecciones">
|
||||||
{prevLesson ? (
|
{prevLesson ? (
|
||||||
<Link href={`/courses/${params.id}/lessons/${prevLesson.id}`} className="group flex items-center gap-3">
|
<Link href={`/courses/${params.id}/lessons/${prevLesson.id}`} className="group flex items-center gap-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 rounded-xl" aria-label={`Ir a la lección anterior: ${prevLesson.title}`}>
|
||||||
<div className="w-10 h-10 rounded-xl glass border-black/10 dark:border-white/10 flex items-center justify-center group-hover:bg-black/5 dark:group-hover:bg-white/5 transition-all text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white">
|
<div className="w-10 h-10 rounded-xl glass border-black/10 dark:border-white/10 flex items-center justify-center group-hover:bg-black/5 dark:group-hover:bg-white/5 transition-all text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white">
|
||||||
<ChevronLeft size={20} />
|
<ChevronLeft size={20} />
|
||||||
</div>
|
</div>
|
||||||
@@ -663,11 +682,11 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{nextLesson ? (
|
{nextLesson ? (
|
||||||
<Link href={`/courses/${params.id}/lessons/${nextLesson.id}`} className="btn-premium !py-3 !px-6 text-xs !shadow-none">
|
<Link href={`/courses/${params.id}/lessons/${nextLesson.id}`} className="btn-premium !py-3 !px-6 text-xs !shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70" aria-label={`Ir a la siguiente lección: ${nextLesson.title}`}>
|
||||||
Siguiente Lección <ChevronRight size={18} />
|
Siguiente Lección <ChevronRight size={18} />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/" className="btn-premium !bg-green-600 !py-3 !px-6 text-xs !shadow-none">
|
<Link href="/" className="btn-premium !bg-green-600 !py-3 !px-6 text-xs !shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70" aria-label="Finalizar curso y volver al catálogo">
|
||||||
Finalizar Curso <CheckCircle2 size={18} />
|
Finalizar Curso <CheckCircle2 size={18} />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { lmsApi, Course, Module, Recommendation, UserGrade, Meeting, normalizePr
|
|||||||
import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle, Video, ExternalLink } from "lucide-react";
|
import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle, Video, ExternalLink } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react";
|
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react";
|
||||||
|
import { Award } from "lucide-react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import DiscussionBoard from "@/components/DiscussionBoard";
|
import DiscussionBoard from "@/components/DiscussionBoard";
|
||||||
import { AnnouncementsList } from "@/components/AnnouncementsList";
|
import { AnnouncementsList } from "@/components/AnnouncementsList";
|
||||||
@@ -79,7 +80,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
|
|
||||||
// Load organization settings (branding includes the certificates_enabled flag)
|
// Load organization settings (branding includes the certificates_enabled flag)
|
||||||
lmsApi.getBranding()
|
lmsApi.getBranding()
|
||||||
.then(res => setOrgSettings(res.organization))
|
.then(res => setOrgSettings(res))
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [params.id, user]);
|
}, [params.id, user]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ import { I18nProvider } from "@/context/I18nContext";
|
|||||||
import { BrandingProvider } from "@/context/BrandingContext";
|
import { BrandingProvider } from "@/context/BrandingContext";
|
||||||
import AuthGuard from "@/components/AuthGuard";
|
import AuthGuard from "@/components/AuthGuard";
|
||||||
import { ThemeProvider } from "@/context/ThemeContext";
|
import { ThemeProvider } from "@/context/ThemeContext";
|
||||||
|
import PwaRegistration from "@/components/PwaRegistration";
|
||||||
|
import PwaInstallPrompt from "@/components/PwaInstallPrompt";
|
||||||
|
import ConnectivityBanner from "@/components/ConnectivityBanner";
|
||||||
|
import OfflineSyncPanel from "@/components/OfflineSyncPanel";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Experiencia de Aprendizaje",
|
title: "Experiencia de Aprendizaje",
|
||||||
description: "Consume contenido educativo de alta fidelidad.",
|
description: "Consume contenido educativo de alta fidelidad.",
|
||||||
|
manifest: "/manifest.webmanifest",
|
||||||
};
|
};
|
||||||
|
|
||||||
import AppHeader from "@/components/AppHeader";
|
import AppHeader from "@/components/AppHeader";
|
||||||
@@ -27,6 +32,10 @@ export default function RootLayout({
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
|
<PwaRegistration />
|
||||||
|
<PwaInstallPrompt />
|
||||||
|
<ConnectivityBanner />
|
||||||
|
<OfflineSyncPanel />
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
const scrollRef = useRef<HTMLUListElement>(null);
|
const scrollRef = useRef<HTMLUListElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load session from localStorage on mount
|
// Load session from localStorage on mount
|
||||||
@@ -33,6 +35,28 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
}
|
}
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!input.trim() || isLoading) return;
|
if (!input.trim() || isLoading) return;
|
||||||
|
|
||||||
@@ -60,10 +84,13 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="fixed bottom-24 right-6 w-14 h-14 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-500/40 flex items-center justify-center hover:scale-110 transition-all z-[100] group"
|
className="fixed bottom-24 right-6 w-14 h-14 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-500/40 flex items-center justify-center hover:scale-110 transition-all z-[100] group"
|
||||||
aria-label="Abrir Tutor de IA"
|
aria-label="Abrir Tutor de IA"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
|
aria-controls="ai-tutor-dialog"
|
||||||
>
|
>
|
||||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 border-2 border-black rounded-full animate-pulse" aria-hidden="true" />
|
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 border-2 border-black rounded-full animate-pulse" aria-hidden="true" />
|
||||||
<MessageSquare className="w-6 h-6 group-hover:rotate-12 transition-transform" aria-hidden="true" />
|
<MessageSquare className="w-6 h-6 group-hover:rotate-12 transition-transform" aria-hidden="true" />
|
||||||
@@ -73,9 +100,12 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id="ai-tutor-dialog"
|
||||||
className="fixed bottom-24 right-6 w-80 md:w-96 h-[500px] glass bg-white/95 dark:bg-black/80 backdrop-blur-2xl border border-black/5 dark:border-white/10 rounded-3xl shadow-2xl flex flex-col z-[200] animate-in slide-in-from-bottom-6 duration-500 overflow-hidden"
|
className="fixed bottom-24 right-6 w-80 md:w-96 h-[500px] glass bg-white/95 dark:bg-black/80 backdrop-blur-2xl border border-black/5 dark:border-white/10 rounded-3xl shadow-2xl flex flex-col z-[200] animate-in slide-in-from-bottom-6 duration-500 overflow-hidden"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Tutor de IA"
|
aria-modal="true"
|
||||||
|
aria-labelledby="ai-tutor-title"
|
||||||
|
aria-describedby="ai-tutor-description"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-black/5 dark:border-white/5 bg-blue-600/5 dark:bg-blue-600/10 flex items-center justify-between">
|
<div className="p-4 border-b border-black/5 dark:border-white/5 bg-blue-600/5 dark:bg-blue-600/10 flex items-center justify-between">
|
||||||
@@ -84,7 +114,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
<Bot className="w-6 h-6 text-white" />
|
<Bot className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-black text-gray-900 dark:text-white uppercase tracking-widest">Tutor de IA</h3>
|
<h3 id="ai-tutor-title" className="text-sm font-black text-gray-900 dark:text-white uppercase tracking-widest">Tutor de IA</h3>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
||||||
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-tighter">En Línea</span>
|
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-tighter">En Línea</span>
|
||||||
@@ -92,6 +122,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||||
aria-label="Cerrar Tutor de IA"
|
aria-label="Cerrar Tutor de IA"
|
||||||
@@ -104,6 +135,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
<ul
|
<ul
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-hide"
|
className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-hide"
|
||||||
|
role="log"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-relevant="additions"
|
aria-relevant="additions"
|
||||||
>
|
>
|
||||||
@@ -120,7 +152,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
? 'bg-blue-600 text-white rounded-tr-none'
|
? 'bg-blue-600 text-white rounded-tr-none'
|
||||||
: 'bg-black/5 dark:bg-white/5 text-gray-800 dark:text-gray-200 border border-black/5 dark:border-white/5 rounded-tl-none'
|
: 'bg-black/5 dark:bg-white/5 text-gray-800 dark:text-gray-200 border border-black/5 dark:border-white/5 rounded-tl-none'
|
||||||
}`}
|
}`}
|
||||||
aria-label={msg.role === 'user' ? 'Tú dijeste' : 'El tutor dijo'}
|
aria-label={msg.role === 'user' ? 'Tú dijiste' : 'El tutor dijo'}
|
||||||
>
|
>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +160,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<li className="flex justify-start animate-in fade-in duration-300" aria-busy="true">
|
<li className="flex justify-start animate-in fade-in duration-300" aria-busy="true" aria-live="assertive">
|
||||||
<div className="flex gap-2 max-w-[85%]">
|
<div className="flex gap-2 max-w-[85%]">
|
||||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-blue-600/20 text-blue-600 dark:text-blue-400 flex items-center justify-center" aria-hidden="true">
|
<div className="shrink-0 w-8 h-8 rounded-lg bg-blue-600/20 text-blue-600 dark:text-blue-400 flex items-center justify-center" aria-hidden="true">
|
||||||
<Bot size={16} />
|
<Bot size={16} />
|
||||||
@@ -146,6 +178,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
<div className="p-4 border-t border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-black/40">
|
<div className="p-4 border-t border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-black/40">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
@@ -155,6 +188,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
className="w-full bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl py-3 px-4 pr-12 text-xs font-medium focus:outline-none focus:border-blue-600/50 dark:focus:border-blue-500/50 transition-colors placeholder:text-gray-400 dark:placeholder:text-gray-600 text-gray-900 dark:text-gray-100"
|
className="w-full bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl py-3 px-4 pr-12 text-xs font-medium focus:outline-none focus:border-blue-600/50 dark:focus:border-blue-500/50 transition-colors placeholder:text-gray-400 dark:placeholder:text-gray-600 text-gray-900 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={isLoading || !input.trim()}
|
disabled={isLoading || !input.trim()}
|
||||||
className="absolute right-2 top-1.5 p-1.5 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:bg-gray-400 dark:disabled:bg-gray-600 transition-all hover:bg-blue-500"
|
className="absolute right-2 top-1.5 p-1.5 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:bg-gray-400 dark:disabled:bg-gray-600 transition-all hover:bg-blue-500"
|
||||||
@@ -166,6 +200,9 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
|||||||
<p className="mt-2 text-[9px] text-gray-500 dark:text-gray-600 font-bold uppercase tracking-widest text-center">
|
<p className="mt-2 text-[9px] text-gray-500 dark:text-gray-600 font-bold uppercase tracking-widest text-center">
|
||||||
IA entrenada con el contenido de esta lección
|
IA entrenada con el contenido de esta lección
|
||||||
</p>
|
</p>
|
||||||
|
<p id="ai-tutor-description" className="sr-only">
|
||||||
|
El tutor responde solo con contenido de la lección actual.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default function AppHeader() {
|
|||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<Array<{ id: string; kind: string; title: string; snippet?: string; url: string; course_title?: string }>>([]);
|
const [searchResults, setSearchResults] = useState<Array<{ id: string; kind: string; title: string; snippet?: string; url: string; course_title?: string }>>([]);
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
|
const [searchActiveIndex, setSearchActiveIndex] = useState(-1);
|
||||||
const searchRef = useRef<HTMLDivElement>(null);
|
const searchRef = useRef<HTMLDivElement>(null);
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -63,9 +64,45 @@ export default function AppHeader() {
|
|||||||
setSearchOpen(false);
|
setSearchOpen(false);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
setSearchActiveIndex(-1);
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSearchKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (!searchOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||||
|
setSearchOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setSearchOpen(false);
|
||||||
|
setSearchActiveIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchResults.length) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearchActiveIndex((prev) => (prev + 1) % searchResults.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearchActiveIndex((prev) => (prev <= 0 ? searchResults.length - 1 : prev - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && searchActiveIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const selected = searchResults[searchActiveIndex];
|
||||||
|
if (selected) {
|
||||||
|
handleSearchSelect(selected.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const kindLabel: Record<string, string> = {
|
const kindLabel: Record<string, string> = {
|
||||||
course: "Curso",
|
course: "Curso",
|
||||||
lesson: "Lección",
|
lesson: "Lección",
|
||||||
@@ -106,11 +143,23 @@ export default function AppHeader() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
|
id="global-search"
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => { setSearchQuery(e.target.value); setSearchOpen(true); }}
|
onChange={e => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setSearchOpen(true);
|
||||||
|
setSearchActiveIndex(-1);
|
||||||
|
}}
|
||||||
onFocus={() => setSearchOpen(true)}
|
onFocus={() => setSearchOpen(true)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
placeholder="Buscar cursos, lecciones..."
|
placeholder="Buscar cursos, lecciones..."
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={searchOpen}
|
||||||
|
aria-controls="global-search-results"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-activedescendant={searchActiveIndex >= 0 ? `search-option-${searchActiveIndex}` : undefined}
|
||||||
|
aria-label="Buscar cursos, lecciones, foros y anuncios"
|
||||||
className="w-56 lg:w-72 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl py-2 pl-9 pr-3 text-sm text-slate-700 dark:text-white placeholder-gray-400 focus:outline-none focus:border-blue-500/50 transition-all"
|
className="w-56 lg:w-72 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl py-2 pl-9 pr-3 text-sm text-slate-700 dark:text-white placeholder-gray-400 focus:outline-none focus:border-blue-500/50 transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,12 +170,15 @@ export default function AppHeader() {
|
|||||||
) : searchResults.length === 0 ? (
|
) : searchResults.length === 0 ? (
|
||||||
<div className="p-4 text-sm text-gray-400 text-center">Sin resultados para "{searchQuery}"</div>
|
<div className="p-4 text-sm text-gray-400 text-center">Sin resultados para "{searchQuery}"</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="max-h-80 overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
|
<ul id="global-search-results" role="listbox" className="max-h-80 overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
|
||||||
{searchResults.map(r => (
|
{searchResults.map((r, idx) => (
|
||||||
<li key={`${r.kind}-${r.id}`}>
|
<li key={`${r.kind}-${r.id}`} role="presentation">
|
||||||
<button
|
<button
|
||||||
|
id={`search-option-${idx}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={searchActiveIndex === idx}
|
||||||
onClick={() => handleSearchSelect(r.url)}
|
onClick={() => handleSearchSelect(r.url)}
|
||||||
className="w-full px-4 py-3 text-left hover:bg-blue-50 dark:hover:bg-white/5 transition-colors flex flex-col gap-0.5"
|
className={`w-full px-4 py-3 text-left transition-colors flex flex-col gap-0.5 ${searchActiveIndex === idx ? 'bg-blue-50 dark:bg-white/10' : 'hover:bg-blue-50 dark:hover:bg-white/5'}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 px-1.5 py-0.5 rounded">
|
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 px-1.5 py-0.5 rounded">
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function ConnectivityBanner() {
|
||||||
|
const [online, setOnline] = useState(true);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOnline(navigator.onLine);
|
||||||
|
|
||||||
|
const onOnline = () => {
|
||||||
|
setOnline(true);
|
||||||
|
setVisible(true);
|
||||||
|
window.setTimeout(() => setVisible(false), 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOffline = () => {
|
||||||
|
setOnline(false);
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("online", onOnline);
|
||||||
|
window.addEventListener("offline", onOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", onOnline);
|
||||||
|
window.removeEventListener("offline", onOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!visible && online) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-20 left-1/2 -translate-x-1/2 z-[260] px-4" role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
<div
|
||||||
|
className={`rounded-xl px-4 py-2 text-xs font-bold shadow-lg border ${
|
||||||
|
online
|
||||||
|
? "bg-emerald-600 text-white border-emerald-500"
|
||||||
|
: "bg-amber-500 text-slate-950 border-amber-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{online ? "Conexion restablecida" : "Sin conexion: usando contenido en cache"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { lmsApi, OfflineSyncStatus } from "@/lib/api";
|
||||||
|
|
||||||
|
const initialStatus: OfflineSyncStatus = {
|
||||||
|
pending: 0,
|
||||||
|
isFlushing: false,
|
||||||
|
lastSyncAt: null,
|
||||||
|
lastFlushedCount: 0,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OfflineSyncPanel() {
|
||||||
|
const [status, setStatus] = useState<OfflineSyncStatus>(initialStatus);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStatus(lmsApi.getOfflineSyncStatus());
|
||||||
|
const unsubscribe = lmsApi.subscribeOfflineSync((next) => setStatus(next));
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const lastSyncLabel = useMemo(() => {
|
||||||
|
if (!status.lastSyncAt) return "Aun sin sincronizacion";
|
||||||
|
const date = new Date(status.lastSyncAt);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}, [status.lastSyncAt]);
|
||||||
|
|
||||||
|
const shouldShow = status.pending > 0 || status.isFlushing || expanded;
|
||||||
|
if (!shouldShow) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="fixed bottom-6 left-6 z-[290] w-[min(360px,92vw)] rounded-2xl border border-slate-300/30 bg-white/95 dark:bg-slate-900/95 backdrop-blur shadow-2xl"
|
||||||
|
aria-label="Estado de sincronizacion offline"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="w-full px-4 py-3 flex items-center justify-between text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 rounded-2xl"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-black uppercase tracking-widest text-slate-700 dark:text-slate-200">
|
||||||
|
Sync Offline
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-300 mt-0.5">
|
||||||
|
{status.isFlushing
|
||||||
|
? "Sincronizando..."
|
||||||
|
: `${status.pending} pendiente${status.pending === 1 ? "" : "s"}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 font-bold">
|
||||||
|
{status.pending}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-4 border-t border-slate-200/60 dark:border-slate-700/60">
|
||||||
|
<p className="text-[11px] text-slate-600 dark:text-slate-300 mt-3">
|
||||||
|
Ultimo sync: <span className="font-semibold">{lastSyncLabel}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-600 dark:text-slate-300 mt-1">
|
||||||
|
Ultimo lote enviado: <span className="font-semibold">{status.lastFlushedCount}</span>
|
||||||
|
</p>
|
||||||
|
{status.lastError && status.lastError !== "offline" && (
|
||||||
|
<p className="text-[11px] text-amber-600 dark:text-amber-400 mt-1 font-semibold">
|
||||||
|
Quedaron eventos pendientes; se reintentara automaticamente.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => lmsApi.flushOfflineQueue()}
|
||||||
|
disabled={status.isFlushing}
|
||||||
|
className="px-3 py-2 rounded-lg bg-blue-600 text-white text-xs font-bold hover:bg-blue-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||||
|
>
|
||||||
|
{status.isFlushing ? "Sincronizando..." : "Sincronizar ahora"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
className="px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-bold hover:bg-slate-200 dark:hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||||
|
>
|
||||||
|
Ocultar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type BeforeInstallPromptEvent = Event & {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PwaInstallPrompt() {
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
const [isInstalled, setIsInstalled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onBeforeInstallPrompt = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAppInstalled = () => {
|
||||||
|
setIsInstalled(true);
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isStandalone = window.matchMedia("(display-mode: standalone)").matches;
|
||||||
|
if (isStandalone) {
|
||||||
|
setIsInstalled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||||
|
window.addEventListener("appinstalled", onAppInstalled);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||||
|
window.removeEventListener("appinstalled", onAppInstalled);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!deferredPrompt || isInstalled) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[300] px-4">
|
||||||
|
<div className="rounded-2xl border border-blue-300/40 bg-white/95 dark:bg-slate-900/95 backdrop-blur px-4 py-3 shadow-2xl max-w-md w-[92vw]">
|
||||||
|
<p className="text-xs font-semibold text-slate-700 dark:text-slate-200">
|
||||||
|
Instala OpenCCB para acceso rapido y soporte offline.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
await deferredPrompt.prompt();
|
||||||
|
const choice = await deferredPrompt.userChoice;
|
||||||
|
if (choice.outcome === "accepted") {
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 rounded-lg bg-blue-600 text-white text-xs font-bold hover:bg-blue-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||||
|
>
|
||||||
|
Instalar App
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeferredPrompt(null)}
|
||||||
|
className="px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-bold hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||||
|
>
|
||||||
|
Ahora no
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { lmsApi } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function PwaRegistration() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!("serviceWorker" in navigator)) return;
|
||||||
|
|
||||||
|
const register = async () => {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register("/sw.js", {
|
||||||
|
scope: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to flush queued progress events at startup.
|
||||||
|
lmsApi.flushOfflineQueue().catch(() => undefined);
|
||||||
|
|
||||||
|
registration.addEventListener("updatefound", () => {
|
||||||
|
const installingWorker = registration.installing;
|
||||||
|
if (!installingWorker) return;
|
||||||
|
|
||||||
|
installingWorker.addEventListener("statechange", () => {
|
||||||
|
if (installingWorker.state === "installed" && navigator.serviceWorker.controller) {
|
||||||
|
installingWorker.postMessage({ type: "SKIP_WAITING" });
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Service worker registration failed", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOnline = () => {
|
||||||
|
lmsApi.flushOfflineQueue().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("online", onOnline);
|
||||||
|
|
||||||
|
register();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", onOnline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
const [handledMarkers, setHandledMarkers] = useState<Set<number>>(new Set());
|
const [handledMarkers, setHandledMarkers] = useState<Set<number>>(new Set());
|
||||||
const [lastTime, setLastTime] = useState(0);
|
const [lastTime, setLastTime] = useState(0);
|
||||||
const [feedback, setFeedback] = useState<{ isCorrect: boolean } | null>(null);
|
const [feedback, setFeedback] = useState<{ isCorrect: boolean } | null>(null);
|
||||||
|
const [a11yStatus, setA11yStatus] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialPlayCount !== undefined) {
|
if (initialPlayCount !== undefined) {
|
||||||
@@ -83,6 +84,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
if (!hasStarted) {
|
if (!hasStarted) {
|
||||||
setPlayCount(prev => prev + 1);
|
setPlayCount(prev => prev + 1);
|
||||||
setHasStarted(true);
|
setHasStarted(true);
|
||||||
|
setA11yStatus("Reproducción iniciada.");
|
||||||
if (lessonId) {
|
if (lessonId) {
|
||||||
await lmsApi.recordInteraction(lessonId, {
|
await lmsApi.recordInteraction(lessonId, {
|
||||||
video_timestamp: 0,
|
video_timestamp: 0,
|
||||||
@@ -97,6 +99,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
if (locked) {
|
if (locked) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4" id={id}>
|
<div className="space-y-4" id={id}>
|
||||||
|
<p className="sr-only" aria-live="polite" aria-atomic="true">Contenido bloqueado por límite de reproducciones.</p>
|
||||||
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">{title || "Contenido Multimedia"}</h3>
|
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">{title || "Contenido Multimedia"}</h3>
|
||||||
<div className="glass-card aspect-video flex flex-col items-center justify-center gap-6 border-red-500/10 dark:border-red-500/20 bg-red-500/5">
|
<div className="glass-card aspect-video flex flex-col items-center justify-center gap-6 border-red-500/10 dark:border-red-500/20 bg-red-500/5">
|
||||||
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center text-red-600 dark:text-red-500">
|
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center text-red-600 dark:text-red-500">
|
||||||
@@ -141,6 +144,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" id={id}>
|
<div className="space-y-6" id={id}>
|
||||||
|
<p className="sr-only" aria-live="polite" aria-atomic="true">{a11yStatus}</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">{title || "Contenido Multimedia"}</h3>
|
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">{title || "Contenido Multimedia"}</h3>
|
||||||
{maxPlays > 0 && (
|
{maxPlays > 0 && (
|
||||||
@@ -163,6 +167,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
controls
|
controls
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
className="w-full h-full rounded-xl"
|
className="w-full h-full rounded-xl"
|
||||||
|
aria-label={title || "Reproductor de video"}
|
||||||
onPlay={handlePlay}
|
onPlay={handlePlay}
|
||||||
onTimeUpdate={(e) => {
|
onTimeUpdate={(e) => {
|
||||||
const time = e.currentTarget.currentTime;
|
const time = e.currentTarget.currentTime;
|
||||||
@@ -178,6 +183,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
if (time >= marker.timestamp && lastTime < marker.timestamp && !handledMarkers.has(marker.timestamp)) {
|
if (time >= marker.timestamp && lastTime < marker.timestamp && !handledMarkers.has(marker.timestamp)) {
|
||||||
e.currentTarget.pause();
|
e.currentTarget.pause();
|
||||||
setActiveMarker(marker);
|
setActiveMarker(marker);
|
||||||
|
setA11yStatus("Pausa por pregunta interactiva.");
|
||||||
setHandledMarkers(prev => new Set(prev).add(marker.timestamp));
|
setHandledMarkers(prev => new Set(prev).add(marker.timestamp));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -201,20 +207,23 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
<iframe
|
<iframe
|
||||||
src={getEmbedUrl(url)}
|
src={getEmbedUrl(url)}
|
||||||
className="w-full h-full rounded-xl"
|
className="w-full h-full rounded-xl"
|
||||||
|
title={title || "Contenido embebido"}
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Simulated play tracker overlay for iframes (invisible but catches first click) */}
|
{/* Simulated play tracker overlay for iframes (invisible but catches first click) */}
|
||||||
{!isLocalFile && playCount === 0 && (
|
{!isLocalFile && playCount === 0 && (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handlePlay}
|
onClick={handlePlay}
|
||||||
className="absolute inset-0 bg-white/40 dark:bg-black/40 flex items-center justify-center cursor-pointer group-hover:bg-white/20 dark:group-hover:bg-black/20 transition-all"
|
className="absolute inset-0 bg-white/40 dark:bg-black/40 flex items-center justify-center cursor-pointer group-hover:bg-white/20 dark:group-hover:bg-black/20 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||||
|
aria-label="Iniciar reproducción del contenido"
|
||||||
>
|
>
|
||||||
<div className="w-20 h-20 rounded-full bg-blue-600 dark:bg-blue-500 flex items-center justify-center shadow-2xl shadow-blue-500/40 group-hover:scale-110 transition-transform">
|
<div className="w-20 h-20 rounded-full bg-blue-600 dark:bg-blue-500 flex items-center justify-center shadow-2xl shadow-blue-500/40 group-hover:scale-110 transition-transform">
|
||||||
<Play size={32} className="text-white fill-white ml-2" />
|
<Play size={32} className="text-white fill-white ml-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,7 +236,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
{/* Question Overlay */}
|
{/* Question Overlay */}
|
||||||
{activeMarker && (
|
{activeMarker && (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-white/60 dark:bg-black/60 backdrop-blur-md rounded-xl animate-in fade-in duration-300">
|
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-white/60 dark:bg-black/60 backdrop-blur-md rounded-xl animate-in fade-in duration-300">
|
||||||
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 p-6 rounded-2xl shadow-2xl max-w-sm w-full space-y-4 border border-black/5 dark:border-white/5">
|
<div role="dialog" aria-modal="true" aria-label="Pregunta interactiva del video" className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 p-6 rounded-2xl shadow-2xl max-w-sm w-full space-y-4 border border-black/5 dark:border-white/5">
|
||||||
<div className="flex items-center gap-2 text-blue-600 font-bold text-xs uppercase tracking-widest">
|
<div className="flex items-center gap-2 text-blue-600 font-bold text-xs uppercase tracking-widest">
|
||||||
<AlertCircle size={16} />
|
<AlertCircle size={16} />
|
||||||
<span>Quick Check</span>
|
<span>Quick Check</span>
|
||||||
@@ -237,6 +246,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
{activeMarker.options.map((option, idx) => (
|
{activeMarker.options.map((option, idx) => (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
|
type="button"
|
||||||
disabled={!!feedback}
|
disabled={!!feedback}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const isCorrect = idx === activeMarker.correctIndex;
|
const isCorrect = idx === activeMarker.correctIndex;
|
||||||
@@ -244,6 +254,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
if (isGraded) {
|
if (isGraded) {
|
||||||
// Graded Mode: Show feedback then continue
|
// Graded Mode: Show feedback then continue
|
||||||
setFeedback({ isCorrect });
|
setFeedback({ isCorrect });
|
||||||
|
setA11yStatus(isCorrect ? "Respuesta correcta." : "Respuesta incorrecta.");
|
||||||
// Save answer to backend (mocked for now)
|
// Save answer to backend (mocked for now)
|
||||||
console.log(`Submitted answer for marker at ${activeMarker}: ${isCorrect ? 'Correct' : 'Wrong'}`);
|
console.log(`Submitted answer for marker at ${activeMarker}: ${isCorrect ? 'Correct' : 'Wrong'}`);
|
||||||
|
|
||||||
@@ -257,6 +268,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
// Formative Mode: Block until correct
|
// Formative Mode: Block until correct
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
setFeedback({ isCorrect: true });
|
setFeedback({ isCorrect: true });
|
||||||
|
setA11yStatus("Respuesta correcta.");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
setActiveMarker(null);
|
setActiveMarker(null);
|
||||||
@@ -265,12 +277,13 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
setFeedback({ isCorrect: false });
|
setFeedback({ isCorrect: false });
|
||||||
|
setA11yStatus("Respuesta incorrecta. Intenta nuevamente.");
|
||||||
alert("Try again! (This is just practice)");
|
alert("Try again! (This is just practice)");
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`px-4 py-3 rounded-xl font-medium transition-all text-left ${feedback
|
className={`px-4 py-3 rounded-xl font-medium transition-all text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${feedback
|
||||||
? idx === activeMarker.correctIndex
|
? idx === activeMarker.correctIndex
|
||||||
? "bg-green-600 dark:bg-green-500 text-white"
|
? "bg-green-600 dark:bg-green-500 text-white"
|
||||||
: feedback.isCorrect === false && "bg-red-600 dark:bg-red-500 text-white"
|
: feedback.isCorrect === false && "bg-red-600 dark:bg-red-500 text-white"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface QuizPlayerProps {
|
|||||||
title?: string;
|
title?: string;
|
||||||
quizData: {
|
quizData: {
|
||||||
questions: QuizQuestion[];
|
questions: QuizQuestion[];
|
||||||
|
test_type?: string;
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
passing_score?: number;
|
passing_score?: number;
|
||||||
total_points?: number;
|
total_points?: number;
|
||||||
@@ -56,6 +57,7 @@ export default function QuizPlayer({
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [score, setScore] = useState<number | null>(null);
|
const [score, setScore] = useState<number | null>(null);
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const [a11yStatus, setA11yStatus] = useState("");
|
||||||
|
|
||||||
const questions = quizData?.questions || [];
|
const questions = quizData?.questions || [];
|
||||||
const isSingleAttempt = maxAttempts === 1 || quizData.max_attempts === 1;
|
const isSingleAttempt = maxAttempts === 1 || quizData.max_attempts === 1;
|
||||||
@@ -118,11 +120,13 @@ export default function QuizPlayer({
|
|||||||
|
|
||||||
const handleValidate = async () => {
|
const handleValidate = async () => {
|
||||||
if (maxAttempts > 0 && attempts >= maxAttempts) return;
|
if (maxAttempts > 0 && attempts >= maxAttempts) return;
|
||||||
|
setA11yStatus("Enviando respuestas del cuestionario.");
|
||||||
|
|
||||||
// Check if all questions are answered
|
// Check if all questions are answered
|
||||||
const allAnswered = questions.every(q => userAnswers[q.id] && userAnswers[q.id].length > 0);
|
const allAnswered = questions.every(q => userAnswers[q.id] && userAnswers[q.id].length > 0);
|
||||||
if (!allAnswered) {
|
if (!allAnswered) {
|
||||||
if (!confirm('Hay preguntas sin responder. ¿Estás seguro de que deseas enviar?')) {
|
if (!confirm('Hay preguntas sin responder. ¿Estás seguro de que deseas enviar?')) {
|
||||||
|
setA11yStatus("Envío cancelado. Puedes completar las preguntas pendientes.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,11 +154,9 @@ export default function QuizPlayer({
|
|||||||
quiz_type: quizData.test_type || 'quiz',
|
quiz_type: quizData.test_type || 'quiz',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Submit to LMS API
|
|
||||||
await lmsApi.submitScore(userId, courseId, lessonId, calculatedScore, answersMetadata);
|
|
||||||
|
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
setAttempts(prev => prev + 1);
|
setAttempts(prev => prev + 1);
|
||||||
|
setA11yStatus(`Prueba enviada correctamente. Puntuación ${scorePercent} por ciento.`);
|
||||||
|
|
||||||
if (onAttempt) {
|
if (onAttempt) {
|
||||||
onAttempt(calculatedScore, answersMetadata);
|
onAttempt(calculatedScore, answersMetadata);
|
||||||
@@ -164,6 +166,7 @@ export default function QuizPlayer({
|
|||||||
alert(`¡Prueba enviada! Tu puntuación: ${scorePercent}%`);
|
alert(`¡Prueba enviada! Tu puntuación: ${scorePercent}%`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting quiz:', error);
|
console.error('Error submitting quiz:', error);
|
||||||
|
setA11yStatus('Error al enviar la prueba.');
|
||||||
alert('Error al enviar la prueba. Por favor, inténtalo de nuevo.');
|
alert('Error al enviar la prueba. Por favor, inténtalo de nuevo.');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -301,6 +304,7 @@ export default function QuizPlayer({
|
|||||||
// Normal quiz mode (not yet submitted or allows retry)
|
// Normal quiz mode (not yet submitted or allows retry)
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 notranslate" id={id} translate="no">
|
<div className="space-y-8 notranslate" id={id} translate="no">
|
||||||
|
<p className="sr-only" aria-live="polite" aria-atomic="true">{a11yStatus}</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xl font-bold border-l-4 border-blue-600 dark:border-blue-500 pl-4 py-1 tracking-tight text-gray-900 dark:text-white uppercase tracking-widest text-[10px]">
|
<h3 className="text-xl font-bold border-l-4 border-blue-600 dark:border-blue-500 pl-4 py-1 tracking-tight text-gray-900 dark:text-white uppercase tracking-widest text-[10px]">
|
||||||
@@ -345,7 +349,8 @@ export default function QuizPlayer({
|
|||||||
>
|
>
|
||||||
{q.options.map((opt, oIdx) => {
|
{q.options.map((opt, oIdx) => {
|
||||||
const isSelected = userAnswers[q.id]?.includes(oIdx);
|
const isSelected = userAnswers[q.id]?.includes(oIdx);
|
||||||
const isCorrect = q.correct?.includes(oIdx);
|
const correctAnswers = Array.isArray(q.correct) ? q.correct : [q.correct];
|
||||||
|
const isCorrect = correctAnswers.includes(oIdx);
|
||||||
const isActuallyCorrect = isCorrect && isSelected;
|
const isActuallyCorrect = isCorrect && isSelected;
|
||||||
const isWrongSelection = !isCorrect && isSelected;
|
const isWrongSelection = !isCorrect && isSelected;
|
||||||
const missedCorrect = isCorrect && !isSelected;
|
const missedCorrect = isCorrect && !isSelected;
|
||||||
@@ -367,8 +372,9 @@ export default function QuizPlayer({
|
|||||||
role={q.type === 'multiple-select' ? 'checkbox' : 'radio'}
|
role={q.type === 'multiple-select' ? 'checkbox' : 'radio'}
|
||||||
aria-checked={isSelected}
|
aria-checked={isSelected}
|
||||||
aria-disabled={submitted || submitting}
|
aria-disabled={submitted || submitting}
|
||||||
|
aria-label={`Opción ${oIdx + 1}: ${opt}`}
|
||||||
onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')}
|
onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')}
|
||||||
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold outline-none focus:ring-2 focus:ring-blue-500/50 disabled:cursor-not-allowed ${style}`}
|
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 disabled:cursor-not-allowed ${style}`}
|
||||||
disabled={submitted || submitting}
|
disabled={submitted || submitting}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -404,9 +410,10 @@ export default function QuizPlayer({
|
|||||||
<button
|
<button
|
||||||
onClick={handleValidate}
|
onClick={handleValidate}
|
||||||
disabled={submitting || (maxAttempts > 0 && attempts >= maxAttempts)}
|
disabled={submitting || (maxAttempts > 0 && attempts >= maxAttempts)}
|
||||||
className={`btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed ${
|
className={`btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${
|
||||||
isSingleAttempt ? 'bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800' : ''
|
isSingleAttempt ? 'bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800' : ''
|
||||||
}`}
|
}`}
|
||||||
|
aria-describedby={`quiz-attempts-${id}`}
|
||||||
>
|
>
|
||||||
{submitting ? 'Enviando...' : (
|
{submitting ? 'Enviando...' : (
|
||||||
isSingleAttempt
|
isSingleAttempt
|
||||||
@@ -417,6 +424,9 @@ export default function QuizPlayer({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<p id={`quiz-attempts-${id}`} className="sr-only">
|
||||||
|
Intentos usados: {attempts}. Máximo permitido: {maxAttempts > 0 ? maxAttempts : 'sin límite'}.
|
||||||
|
</p>
|
||||||
|
|
||||||
{submitted && score !== null && (
|
{submitted && score !== null && (
|
||||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 border-2 border-green-300 dark:border-green-700 rounded-2xl p-6 text-center">
|
<div className="bg-gradient-to-r from-green-50 to-blue-50 border-2 border-green-300 dark:border-green-700 rounded-2xl p-6 text-center">
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ export function useCourseLanguage(courseId: string | null) {
|
|||||||
// Importar dinámicamente para evitar circular dependencies
|
// Importar dinámicamente para evitar circular dependencies
|
||||||
const { lmsApi } = await import('@/lib/api');
|
const { lmsApi } = await import('@/lib/api');
|
||||||
|
|
||||||
const response = await lmsApi.get(`/courses/${courseId}/language-config`);
|
const data = await lmsApi.getCourseLanguageConfig(courseId);
|
||||||
const data = response.data;
|
|
||||||
|
|
||||||
setConfig(data);
|
setConfig(data);
|
||||||
|
|
||||||
|
|||||||
+347
-16
@@ -184,7 +184,7 @@ export interface QuizQuestion {
|
|||||||
|
|
||||||
export interface Block {
|
export interface Block {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab';
|
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm';
|
||||||
title: string;
|
title: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -192,6 +192,7 @@ export interface Block {
|
|||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
quiz_data?: {
|
quiz_data?: {
|
||||||
questions: QuizQuestion[];
|
questions: QuizQuestion[];
|
||||||
|
test_type?: string;
|
||||||
};
|
};
|
||||||
pairs?: { left: string; right: string }[];
|
pairs?: { left: string; right: string }[];
|
||||||
items?: string[];
|
items?: string[];
|
||||||
@@ -563,16 +564,243 @@ const getToken = () => {
|
|||||||
return sessionStorage.getItem('preview_token') || localStorage.getItem('experience_token');
|
return sessionStorage.getItem('preview_token') || localStorage.getItem('experience_token');
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
|
const OFFLINE_QUEUE_KEY = 'experience_offline_mutation_queue_v1';
|
||||||
|
const OFFLINE_SYNC_META_KEY = 'experience_offline_sync_meta_v1';
|
||||||
|
|
||||||
|
type OfflineMutationKind = 'grade' | 'interaction' | 'xapi';
|
||||||
|
|
||||||
|
type OfflineMutationItem = {
|
||||||
|
id: string;
|
||||||
|
dedupeKey: string;
|
||||||
|
kind: OfflineMutationKind;
|
||||||
|
url: string;
|
||||||
|
method: 'POST' | 'PUT' | 'DELETE';
|
||||||
|
isCMS: boolean;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OfflineSyncStatus = {
|
||||||
|
pending: number;
|
||||||
|
isFlushing: boolean;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
lastFlushedCount: number;
|
||||||
|
lastError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const offlineSyncListeners = new Set<(status: OfflineSyncStatus) => void>();
|
||||||
|
let offlineFlushPromise: Promise<{ flushed: number; pending: number }> | null = null;
|
||||||
|
let inMemoryOfflineStatus: OfflineSyncStatus = {
|
||||||
|
pending: 0,
|
||||||
|
isFlushing: false,
|
||||||
|
lastSyncAt: null,
|
||||||
|
lastFlushedCount: 0,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOfflineQueue = (): OfflineMutationItem[] => {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return (parsed as OfflineMutationItem[]).map((item) => ({
|
||||||
|
...item,
|
||||||
|
dedupeKey: item.dedupeKey || `${item.method}:${item.url}:${item.body}`,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOfflineSyncMeta = (): Partial<OfflineSyncStatus> => {
|
||||||
|
if (typeof window === 'undefined') return {};
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(OFFLINE_SYNC_META_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object') return {};
|
||||||
|
return parsed as Partial<OfflineSyncStatus>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveOfflineSyncMeta = (status: OfflineSyncStatus) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const persistable = {
|
||||||
|
lastSyncAt: status.lastSyncAt,
|
||||||
|
lastFlushedCount: status.lastFlushedCount,
|
||||||
|
lastError: status.lastError,
|
||||||
|
};
|
||||||
|
localStorage.setItem(OFFLINE_SYNC_META_KEY, JSON.stringify(persistable));
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitOfflineSyncStatus = (partial: Partial<OfflineSyncStatus>) => {
|
||||||
|
const queue = loadOfflineQueue();
|
||||||
|
inMemoryOfflineStatus = {
|
||||||
|
...inMemoryOfflineStatus,
|
||||||
|
...partial,
|
||||||
|
pending: queue.length,
|
||||||
|
};
|
||||||
|
saveOfflineSyncMeta(inMemoryOfflineStatus);
|
||||||
|
offlineSyncListeners.forEach((listener) => listener(inMemoryOfflineStatus));
|
||||||
|
};
|
||||||
|
|
||||||
|
const hydrateOfflineSyncStatus = () => {
|
||||||
|
const meta = loadOfflineSyncMeta();
|
||||||
|
const queue = loadOfflineQueue();
|
||||||
|
inMemoryOfflineStatus = {
|
||||||
|
pending: queue.length,
|
||||||
|
isFlushing: false,
|
||||||
|
lastSyncAt: typeof meta.lastSyncAt === 'string' ? meta.lastSyncAt : null,
|
||||||
|
lastFlushedCount: typeof meta.lastFlushedCount === 'number' ? meta.lastFlushedCount : 0,
|
||||||
|
lastError: typeof meta.lastError === 'string' ? meta.lastError : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
hydrateOfflineSyncStatus();
|
||||||
|
|
||||||
|
const saveOfflineQueue = (queue: OfflineMutationItem[]) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
|
||||||
|
emitOfflineSyncStatus({ pending: queue.length });
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueueOfflineMutation = (item: OfflineMutationItem) => {
|
||||||
|
const queue = loadOfflineQueue();
|
||||||
|
const duplicate = queue.some((queued) => queued.dedupeKey === item.dedupeKey);
|
||||||
|
if (duplicate) return;
|
||||||
|
queue.push(item);
|
||||||
|
saveOfflineQueue(queue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOfflineSyncStatusSnapshot = (): OfflineSyncStatus => {
|
||||||
|
const queue = loadOfflineQueue();
|
||||||
|
return {
|
||||||
|
...inMemoryOfflineStatus,
|
||||||
|
pending: queue.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribeOfflineSync = (listener: (status: OfflineSyncStatus) => void) => {
|
||||||
|
offlineSyncListeners.add(listener);
|
||||||
|
listener(getOfflineSyncStatusSnapshot());
|
||||||
|
return () => {
|
||||||
|
offlineSyncListeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildApiHeaders = (options: RequestInit = {}) => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const baseUrl = isCMS ? getCmsApiUrl() : getLmsApiUrl();
|
|
||||||
const isFormData = options.body instanceof FormData;
|
const isFormData = options.body instanceof FormData;
|
||||||
const headers: Record<string, string> = {
|
return {
|
||||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
...Object.fromEntries(Object.entries(options.headers || {}).map(([k, v]) => [k, String(v)])),
|
...Object.fromEntries(Object.entries(options.headers || {}).map(([k, v]) => [k, String(v)])),
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
} as Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushOfflineQueueInternal = async () => {
|
||||||
|
if (offlineFlushPromise) {
|
||||||
|
return offlineFlushPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
if (typeof window === 'undefined') return { flushed: 0, pending: 0 };
|
||||||
|
emitOfflineSyncStatus({ isFlushing: true, lastError: null });
|
||||||
|
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
const pending = loadOfflineQueue().length;
|
||||||
|
emitOfflineSyncStatus({ isFlushing: false, pending, lastError: 'offline' });
|
||||||
|
return { flushed: 0, pending };
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = loadOfflineQueue();
|
||||||
|
if (!queue.length) {
|
||||||
|
emitOfflineSyncStatus({
|
||||||
|
isFlushing: false,
|
||||||
|
pending: 0,
|
||||||
|
lastSyncAt: new Date().toISOString(),
|
||||||
|
lastFlushedCount: 0,
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
return { flushed: 0, pending: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stillPending: OfflineMutationItem[] = [];
|
||||||
|
let flushed = 0;
|
||||||
|
|
||||||
|
for (const item of queue) {
|
||||||
|
try {
|
||||||
|
const baseUrl = item.isCMS ? getCmsApiUrl() : getLmsApiUrl();
|
||||||
|
const response = await fetch(`${baseUrl}${item.url}`, {
|
||||||
|
method: item.method,
|
||||||
|
body: item.body,
|
||||||
|
headers: buildApiHeaders({ body: item.body })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// 4xx validation/auth errors should not block the queue forever.
|
||||||
|
if (response.status >= 500) {
|
||||||
|
stillPending.push(item);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushed += 1;
|
||||||
|
} catch {
|
||||||
|
stillPending.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveOfflineQueue(stillPending);
|
||||||
|
emitOfflineSyncStatus({
|
||||||
|
isFlushing: false,
|
||||||
|
pending: stillPending.length,
|
||||||
|
lastSyncAt: new Date().toISOString(),
|
||||||
|
lastFlushedCount: flushed,
|
||||||
|
lastError: stillPending.length ? 'partial' : null,
|
||||||
|
});
|
||||||
|
return { flushed, pending: stillPending.length };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
offlineFlushPromise = run().finally(() => {
|
||||||
|
offlineFlushPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return offlineFlushPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueueIfOffline = async (
|
||||||
|
kind: OfflineMutationKind,
|
||||||
|
url: string,
|
||||||
|
method: 'POST' | 'PUT' | 'DELETE',
|
||||||
|
body: string,
|
||||||
|
isCMS = false
|
||||||
|
) => {
|
||||||
|
if (typeof window !== 'undefined' && !navigator.onLine) {
|
||||||
|
enqueueOfflineMutation({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
dedupeKey: `${method}:${url}:${body}`,
|
||||||
|
kind,
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
isCMS,
|
||||||
|
body,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
|
||||||
|
const baseUrl = isCMS ? getCmsApiUrl() : getLmsApiUrl();
|
||||||
|
const headers = buildApiHeaders(options);
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}${url}`, { ...options, headers });
|
const response = await fetch(`${baseUrl}${url}`, { ...options, headers });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ message: response.statusText }));
|
const error = await response.json().catch(() => ({ message: response.statusText }));
|
||||||
@@ -583,6 +811,18 @@ const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const lmsApi = {
|
export const lmsApi = {
|
||||||
|
subscribeOfflineSync(listener: (status: OfflineSyncStatus) => void): () => void {
|
||||||
|
return subscribeOfflineSync(listener);
|
||||||
|
},
|
||||||
|
|
||||||
|
getOfflineSyncStatus(): OfflineSyncStatus {
|
||||||
|
return getOfflineSyncStatusSnapshot();
|
||||||
|
},
|
||||||
|
|
||||||
|
async flushOfflineQueue(): Promise<{ flushed: number; pending: number }> {
|
||||||
|
return flushOfflineQueueInternal();
|
||||||
|
},
|
||||||
|
|
||||||
async getCatalog(orgId?: string, userId?: string): Promise<Course[]> {
|
async getCatalog(orgId?: string, userId?: string): Promise<Course[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (orgId) params.append('organization_id', orgId);
|
if (orgId) params.append('organization_id', orgId);
|
||||||
@@ -639,10 +879,37 @@ export const lmsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async trackXapiStatement(payload: TrackXapiPayload): Promise<{ id: string; message: string }> {
|
async trackXapiStatement(payload: TrackXapiPayload): Promise<{ id: string; message: string }> {
|
||||||
return apiFetch('/xapi/statements', {
|
const url = '/xapi/statements';
|
||||||
method: 'POST',
|
const body = JSON.stringify(payload);
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
if (await enqueueIfOffline('xapi', url, 'POST', body)) {
|
||||||
|
return {
|
||||||
|
id: `offline-${Date.now()}`,
|
||||||
|
message: 'xAPI statement queued for sync',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await apiFetch(url, { method: 'POST', body });
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof window !== 'undefined' && !navigator.onLine) {
|
||||||
|
enqueueOfflineMutation({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
dedupeKey: `POST:${url}:${body}`,
|
||||||
|
kind: 'xapi',
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
isCMS: false,
|
||||||
|
body,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: `offline-${Date.now()}`,
|
||||||
|
message: 'xAPI statement queued for sync',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getMe(): Promise<User> {
|
async getMe(): Promise<User> {
|
||||||
@@ -672,10 +939,49 @@ export const lmsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async submitScore(userId: string, course_id: string, lessonId: string, score: number, metadata: Record<string, unknown> = {}): Promise<UserGrade> {
|
async submitScore(userId: string, course_id: string, lessonId: string, score: number, metadata: Record<string, unknown> = {}): Promise<UserGrade> {
|
||||||
return apiFetch('/grades', {
|
const url = '/grades';
|
||||||
method: 'POST',
|
const body = JSON.stringify({ user_id: userId, course_id, lesson_id: lessonId, score, metadata });
|
||||||
body: JSON.stringify({ user_id: userId, course_id, lesson_id: lessonId, score, metadata })
|
|
||||||
});
|
if (await enqueueIfOffline('grade', url, 'POST', body)) {
|
||||||
|
return {
|
||||||
|
id: `offline-${Date.now()}`,
|
||||||
|
user_id: userId,
|
||||||
|
course_id,
|
||||||
|
lesson_id: lessonId,
|
||||||
|
score,
|
||||||
|
attempts_count: 0,
|
||||||
|
metadata: { ...metadata, sync_pending: true },
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await apiFetch(url, { method: 'POST', body });
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof window !== 'undefined' && !navigator.onLine) {
|
||||||
|
enqueueOfflineMutation({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
dedupeKey: `POST:${url}:${body}`,
|
||||||
|
kind: 'grade',
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
isCMS: false,
|
||||||
|
body,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: `offline-${Date.now()}`,
|
||||||
|
user_id: userId,
|
||||||
|
course_id,
|
||||||
|
lesson_id: lessonId,
|
||||||
|
score,
|
||||||
|
attempts_count: 0,
|
||||||
|
metadata: { ...metadata, sync_pending: true },
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUserGrades(userId: string, courseId: string): Promise<UserGrade[]> {
|
async getUserGrades(userId: string, courseId: string): Promise<UserGrade[]> {
|
||||||
@@ -694,6 +1000,10 @@ export const lmsApi = {
|
|||||||
return apiFetch('/branding', {}, true);
|
return apiFetch('/branding', {}, true);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getCourseLanguageConfig(courseId: string): Promise<{ language_setting: 'auto' | 'fixed'; fixed_language: string | null }> {
|
||||||
|
return apiFetch(`/courses/${courseId}/language-config`);
|
||||||
|
},
|
||||||
|
|
||||||
async updateUser(userId: string, payload: { full_name?: string, avatar_url?: string, bio?: string, language?: string }): Promise<void> {
|
async updateUser(userId: string, payload: { full_name?: string, avatar_url?: string, bio?: string, language?: string }): Promise<void> {
|
||||||
return apiFetch(`/users/${userId}`, {
|
return apiFetch(`/users/${userId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -715,10 +1025,31 @@ export const lmsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async recordInteraction(lessonId: string, payload: { video_timestamp?: number, event_type: string, metadata?: any }): Promise<void> {
|
async recordInteraction(lessonId: string, payload: { video_timestamp?: number, event_type: string, metadata?: any }): Promise<void> {
|
||||||
return apiFetch(`/lessons/${lessonId}/interactions`, {
|
const url = `/lessons/${lessonId}/interactions`;
|
||||||
method: 'POST',
|
const body = JSON.stringify(payload);
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
if (await enqueueIfOffline('interaction', url, 'POST', body)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch(url, { method: 'POST', body });
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof window !== 'undefined' && !navigator.onLine) {
|
||||||
|
enqueueOfflineMutation({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
dedupeKey: `POST:${url}:${body}`,
|
||||||
|
kind: 'interaction',
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
isCMS: false,
|
||||||
|
body,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getHeatmap(lessonId: string): Promise<{ second: number, count: number }[]> {
|
async getHeatmap(lessonId: string): Promise<{ second: number, count: number }[]> {
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { AlertCircle, BrainCircuit, Calendar, CheckCircle2, Filter, RefreshCw, ShieldAlert, X } from 'lucide-react';
|
||||||
|
import { AiAuditItem, lmsApi } from '@/lib/api';
|
||||||
|
|
||||||
|
type ReviewFilter = 'all' | 'pending' | 'reviewed';
|
||||||
|
|
||||||
|
// Todas las señales que el backend puede emitir
|
||||||
|
const ALL_SIGNALS = [
|
||||||
|
'missing_rag_context',
|
||||||
|
'high_output_tokens',
|
||||||
|
'long_response',
|
||||||
|
'absolute_claim_language',
|
||||||
|
'citation_without_rag',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type RiskSignal = (typeof ALL_SIGNALS)[number];
|
||||||
|
|
||||||
|
function formatSignal(signal: string) {
|
||||||
|
return signal
|
||||||
|
.replaceAll('_', ' ')
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayMinus(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminAiAuditPage() {
|
||||||
|
const [items, setItems] = useState<AiAuditItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [workingId, setWorkingId] = useState<string | null>(null);
|
||||||
|
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Filtros de estado
|
||||||
|
const [filter, setFilter] = useState<ReviewFilter>('pending');
|
||||||
|
|
||||||
|
// Filtros avanzados (aplicados en cliente)
|
||||||
|
const [signalFilter, setSignalFilter] = useState<RiskSignal | ''>('');
|
||||||
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
const [minRisk, setMinRisk] = useState<number>(1);
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadData = async (nextFilter: ReviewFilter = filter) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const reviewed = nextFilter === 'all' ? undefined : nextFilter === 'reviewed';
|
||||||
|
const response = await lmsApi.getAiAuditLogs(reviewed, 200, 0);
|
||||||
|
setItems(response.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading AI audit logs', err);
|
||||||
|
alert('No se pudieron cargar los casos de auditoría IA.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markReview = async (item: AiAuditItem, reviewed: boolean) => {
|
||||||
|
setWorkingId(item.id);
|
||||||
|
try {
|
||||||
|
await lmsApi.reviewAiAuditLog(item.id, {
|
||||||
|
reviewed,
|
||||||
|
reviewer_note: notes[item.id]?.trim() || undefined,
|
||||||
|
});
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating AI audit review', err);
|
||||||
|
alert('No se pudo actualizar el estado de revisión.');
|
||||||
|
} finally {
|
||||||
|
setWorkingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAdvancedFilters = () => {
|
||||||
|
setSignalFilter('');
|
||||||
|
setDateFrom('');
|
||||||
|
setDateTo('');
|
||||||
|
setMinRisk(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (signalFilter && !item.risk_signals.includes(signalFilter)) return false;
|
||||||
|
if (item.risk_score < minRisk) return false;
|
||||||
|
if (dateFrom && item.created_at < dateFrom) return false;
|
||||||
|
if (dateTo && item.created_at > dateTo + 'T23:59:59Z') return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [items, signalFilter, minRisk, dateFrom, dateTo]);
|
||||||
|
|
||||||
|
const pendingCount = useMemo(() => filtered.filter((i) => !i.reviewed).length, [filtered]);
|
||||||
|
const reviewedCount = useMemo(() => filtered.filter((i) => i.reviewed).length, [filtered]);
|
||||||
|
const activeFilterCount = [signalFilter, dateFrom, dateTo, minRisk > 1].filter(Boolean).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-slate-900">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-xl bg-indigo-600 p-2 text-white">
|
||||||
|
<BrainCircuit size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black tracking-tight text-slate-900 dark:text-white">Auditoría IA</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Detección temprana de posibles alucinaciones en respuestas del tutor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFiltersOpen((o) => !o)}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
filtersOpen || activeFilterCount > 0
|
||||||
|
? 'border-indigo-400 bg-indigo-50 text-indigo-700 dark:border-indigo-500 dark:bg-indigo-900/20 dark:text-indigo-300'
|
||||||
|
: 'border-slate-300 text-slate-700 hover:bg-slate-100 dark:border-white/20 dark:text-slate-200 dark:hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Filter size={16} />
|
||||||
|
Filtros
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<span className="rounded-full bg-indigo-600 px-1.5 py-0.5 text-xs text-white">
|
||||||
|
{activeFilterCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => loadData()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100 dark:border-white/20 dark:text-slate-200 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} /> Refrescar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel de filtros avanzados */}
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4 dark:border-white/10 dark:bg-slate-800">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest text-slate-500 dark:text-slate-400">
|
||||||
|
Filtros avanzados
|
||||||
|
</span>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={resetAdvancedFilters}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-rose-600 hover:text-rose-700 dark:text-rose-400"
|
||||||
|
>
|
||||||
|
<X size={13} /> Limpiar filtros
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{/* Señal de riesgo */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||||||
|
Señal de riesgo
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={signalFilter}
|
||||||
|
onChange={(e) => setSignalFilter(e.target.value as RiskSignal | '')}
|
||||||
|
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-white/20 dark:bg-slate-700"
|
||||||
|
>
|
||||||
|
<option value="">Todas</option>
|
||||||
|
{ALL_SIGNALS.map((s) => (
|
||||||
|
<option key={s} value={s}>{formatSignal(s)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score mínimo */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||||||
|
Riesgo mínimo: <strong>{minRisk}</strong>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={minRisk}
|
||||||
|
onChange={(e) => setMinRisk(Number(e.target.value))}
|
||||||
|
className="w-full accent-indigo-600"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-400">
|
||||||
|
<span>1</span><span>5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fecha desde */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 flex items-center gap-1 text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||||||
|
<Calendar size={12} /> Desde
|
||||||
|
</label>
|
||||||
|
<div className="relative flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
max={dateTo || todayMinus(0)}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-white/20 dark:bg-slate-700"
|
||||||
|
/>
|
||||||
|
{dateFrom && (
|
||||||
|
<button onClick={() => setDateFrom('')} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
{[7, 30, 90].map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => setDateFrom(todayMinus(d))}
|
||||||
|
className="rounded bg-slate-200 px-1.5 py-0.5 text-[10px] font-bold text-slate-600 hover:bg-indigo-100 hover:text-indigo-700 dark:bg-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
-{d}d
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fecha hasta */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 flex items-center gap-1 text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||||||
|
<Calendar size={12} /> Hasta
|
||||||
|
</label>
|
||||||
|
<div className="relative flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
min={dateFrom || undefined}
|
||||||
|
max={todayMinus(0)}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-white/20 dark:bg-slate-700"
|
||||||
|
/>
|
||||||
|
{dateTo && (
|
||||||
|
<button onClick={() => setDateTo('')} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setDateTo(todayMinus(0))}
|
||||||
|
className="rounded bg-slate-200 px-1.5 py-0.5 text-[10px] font-bold text-slate-600 hover:bg-indigo-100 hover:text-indigo-700 dark:bg-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
Hoy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
<AlertCircle size={14} /> Pendientes: {pendingCount}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||||
|
<CheckCircle2 size={14} /> Revisados: {reviewedCount}
|
||||||
|
</span>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-3 py-1 text-xs font-bold text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300">
|
||||||
|
<Filter size={12} /> Mostrando {filtered.length} de {items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value as ReviewFilter;
|
||||||
|
setFilter(next);
|
||||||
|
loadData(next);
|
||||||
|
}}
|
||||||
|
className="ml-auto rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-white/20 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
<option value="pending">Pendientes</option>
|
||||||
|
<option value="reviewed">Revisados</option>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600 dark:border-white/10 dark:bg-slate-900 dark:text-slate-300">
|
||||||
|
Cargando casos de auditoría...
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-slate-300 bg-white p-10 text-center text-slate-600 dark:border-white/20 dark:bg-slate-900 dark:text-slate-300">
|
||||||
|
{items.length === 0
|
||||||
|
? 'No hay casos de riesgo con el filtro de estado actual.'
|
||||||
|
: `Los filtros avanzados no arrojaron resultados. ${items.length} caso(s) excluidos.`}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filtered.map((item) => {
|
||||||
|
const isWorking = workingId === item.id;
|
||||||
|
const noteValue = notes[item.id] ?? item.reviewer_note ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article key={item.id} className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-slate-900">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span className="rounded-full bg-slate-100 px-2 py-1 font-bold uppercase tracking-wide text-slate-600 dark:bg-slate-800 dark:text-slate-300">
|
||||||
|
Riesgo: {item.risk_score}
|
||||||
|
</span>
|
||||||
|
<span className={`rounded-full px-2 py-1 font-bold uppercase tracking-wide ${
|
||||||
|
item.reviewed
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||||
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||||
|
}`}>
|
||||||
|
{item.reviewed ? 'Revisado' : 'Pendiente'}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">
|
||||||
|
Alumno: {item.student_name || 'N/D'}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">
|
||||||
|
Modelo: {item.model}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">
|
||||||
|
Tokens salida: {item.output_tokens}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
{item.risk_signals.map((signal) => (
|
||||||
|
<span
|
||||||
|
key={signal}
|
||||||
|
className="rounded-md border border-rose-200 bg-rose-50 px-2 py-1 text-xs font-semibold text-rose-700 dark:border-rose-900/30 dark:bg-rose-950/30 dark:text-rose-300"
|
||||||
|
>
|
||||||
|
{formatSignal(signal)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700 dark:border-white/10 dark:bg-slate-800 dark:text-slate-200">
|
||||||
|
{item.response_excerpt || 'Sin extracto de respuesta'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
|
<input
|
||||||
|
value={noteValue}
|
||||||
|
onChange={(e) => setNotes((prev) => ({ ...prev, [item.id]: e.target.value }))}
|
||||||
|
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-white/20 dark:bg-slate-800"
|
||||||
|
placeholder="Nota del revisor"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => markReview(item, true)}
|
||||||
|
disabled={isWorking}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={15} /> Marcar revisado
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => markReview(item, false)}
|
||||||
|
disabled={isWorking}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-amber-300 px-3 py-2 text-sm font-semibold text-amber-700 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-700/40 dark:text-amber-300 dark:hover:bg-amber-900/20"
|
||||||
|
>
|
||||||
|
<ShieldAlert size={15} /> Dejar pendiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.reviewed && item.reviewed_by_name && (
|
||||||
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Revisado por: {item.reviewed_by_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Brain, Database, Eye, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||||
|
import { AiDataEthicsSummaryResponse, lmsApi } from '@/lib/api';
|
||||||
|
|
||||||
|
const RANGE_OPTIONS = [7, 30, 90] as const;
|
||||||
|
|
||||||
|
function formatNumber(value: number) {
|
||||||
|
return new Intl.NumberFormat('es-ES').format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Date(value).toLocaleString('es-ES', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDataEthicsPage() {
|
||||||
|
const [days, setDays] = useState<number>(30);
|
||||||
|
const [data, setData] = useState<AiDataEthicsSummaryResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadData = async (nextDays = days) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await lmsApi.getAiDataEthicsSummary(nextDays, 60);
|
||||||
|
setData(response);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading AI data ethics summary', err);
|
||||||
|
alert('No se pudo cargar la transparencia de datos IA.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData(30);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ragPercentage = useMemo(() => {
|
||||||
|
if (!data || data.events.length === 0) return 0;
|
||||||
|
const withRag = data.events.filter((e) => e.has_rag_context).length;
|
||||||
|
return Math.round((withRag / data.events.length) * 100);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-slate-900">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-xl bg-teal-600 p-2 text-white">
|
||||||
|
<ShieldCheck size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black tracking-tight text-slate-900 dark:text-white">
|
||||||
|
Ética de Datos IA
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Transparencia de qué datos se usan en inferencia y por cuánto tiempo se retienen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => loadData(days)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100 dark:border-white/20 dark:text-slate-200 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} /> Refrescar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||||
|
Ventana
|
||||||
|
</span>
|
||||||
|
{RANGE_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
onClick={() => {
|
||||||
|
setDays(option);
|
||||||
|
loadData(option);
|
||||||
|
}}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-bold ${
|
||||||
|
option === days
|
||||||
|
? 'bg-teal-600 text-white'
|
||||||
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option} días
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600 dark:border-white/10 dark:bg-slate-900 dark:text-slate-300">
|
||||||
|
Cargando resumen de transparencia...
|
||||||
|
</div>
|
||||||
|
) : !data ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-slate-300 bg-white p-10 text-center text-slate-600 dark:border-white/20 dark:bg-slate-900 dark:text-slate-300">
|
||||||
|
No hay datos disponibles.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<article className="rounded-xl border border-slate-200 bg-white p-4 dark:border-white/10 dark:bg-slate-900">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wide text-slate-500 dark:text-slate-400">Requests IA</p>
|
||||||
|
<p className="mt-1 text-2xl font-black text-slate-900 dark:text-white">{formatNumber(data.summary.total_requests)}</p>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-xl border border-slate-200 bg-white p-4 dark:border-white/10 dark:bg-slate-900">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wide text-slate-500 dark:text-slate-400">Tokens Totales</p>
|
||||||
|
<p className="mt-1 text-2xl font-black text-slate-900 dark:text-white">{formatNumber(data.summary.total_tokens)}</p>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-xl border border-slate-200 bg-white p-4 dark:border-white/10 dark:bg-slate-900">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wide text-slate-500 dark:text-slate-400">Promedio / Request</p>
|
||||||
|
<p className="mt-1 text-2xl font-black text-slate-900 dark:text-white">{formatNumber(data.summary.average_tokens_per_request)}</p>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-xl border border-slate-200 bg-white p-4 dark:border-white/10 dark:bg-slate-900">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wide text-slate-500 dark:text-slate-400">Con Contexto RAG</p>
|
||||||
|
<p className="mt-1 text-2xl font-black text-slate-900 dark:text-white">{ragPercentage}%</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-slate-900">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
||||||
|
<Database size={16} /> Campos almacenados
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{data.summary.stored_fields.map((field) => (
|
||||||
|
<span
|
||||||
|
key={field}
|
||||||
|
className="rounded-md border border-slate-300 bg-slate-50 px-2 py-1 text-xs font-semibold text-slate-600 dark:border-white/15 dark:bg-slate-800 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{field}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Retención objetivo actual: {data.summary.retention_days} días.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-slate-900">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
||||||
|
<Eye size={16} /> Eventos recientes
|
||||||
|
</div>
|
||||||
|
{data.events.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Sin eventos en la ventana seleccionada.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.events.map((event) => (
|
||||||
|
<article key={event.id} className="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-slate-800">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span className="rounded-full bg-teal-100 px-2 py-1 font-bold text-teal-700 dark:bg-teal-900/30 dark:text-teal-300">
|
||||||
|
<Brain size={12} className="mr-1 inline" /> {event.request_type}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-600 dark:text-slate-300">{event.endpoint}</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">Modelo: {event.model}</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">{formatDate(event.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="rounded-md bg-slate-200 px-2 py-1 font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
|
||||||
|
Tokens: {formatNumber(event.tokens_used)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-slate-200 px-2 py-1 font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
|
||||||
|
In: {formatNumber(event.input_tokens)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-slate-200 px-2 py-1 font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
|
||||||
|
Out: {formatNumber(event.output_tokens)}
|
||||||
|
</span>
|
||||||
|
<span className={`rounded-md px-2 py-1 font-semibold ${
|
||||||
|
event.has_rag_context
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||||
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||||
|
}`}>
|
||||||
|
{event.has_rag_context ? 'Con RAG' : 'Sin RAG'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
Mic,
|
Mic,
|
||||||
FileArchive,
|
FileArchive,
|
||||||
Gauge,
|
Gauge,
|
||||||
MessageSquare
|
MessageSquare,
|
||||||
|
BrainCircuit,
|
||||||
|
Shield
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -26,6 +28,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{ icon: Building2, label: "Organizations", href: "/admin" },
|
{ icon: Building2, label: "Organizations", href: "/admin" },
|
||||||
{ icon: Users, label: "Users", href: "/admin/users" },
|
{ icon: Users, label: "Users", href: "/admin/users" },
|
||||||
{ icon: Gauge, label: "Tokens IA", href: "/admin/token-usage" },
|
{ icon: Gauge, label: "Tokens IA", href: "/admin/token-usage" },
|
||||||
|
{ icon: BrainCircuit, label: "Auditoría IA", href: "/admin/ai-audit" },
|
||||||
|
{ icon: Shield, label: "Ética de Datos", href: "/admin/data-ethics" },
|
||||||
{ icon: MessageSquare, label: "FAQ Moderation", href: "/admin/faq-review" },
|
{ icon: MessageSquare, label: "FAQ Moderation", href: "/admin/faq-review" },
|
||||||
{ icon: FileArchive, label: "Material Compartido", href: "/admin/materials" },
|
{ icon: FileArchive, label: "Material Compartido", href: "/admin/materials" },
|
||||||
{ icon: Mic, label: "Audio Evaluations", href: "/admin/audio-evaluations" },
|
{ icon: Mic, label: "Audio Evaluations", href: "/admin/audio-evaluations" },
|
||||||
|
|||||||
@@ -339,8 +339,8 @@ export interface OrganizationEmailTemplate {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
subject_template: string;
|
subject_template: string;
|
||||||
body_template: string;
|
body_template: string;
|
||||||
is_html: bool;
|
is_html: boolean;
|
||||||
is_enabled: bool;
|
is_enabled: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -350,8 +350,8 @@ export interface UpsertOrganizationEmailTemplatePayload {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
subject_template: string;
|
subject_template: string;
|
||||||
body_template: string;
|
body_template: string;
|
||||||
is_html: bool;
|
is_html: boolean;
|
||||||
is_enabled: bool;
|
is_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProvisionPayload {
|
export interface ProvisionPayload {
|
||||||
@@ -1739,6 +1739,31 @@ export const lmsApi = {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
query: { search, limit, offset }
|
query: { search, limit, offset }
|
||||||
}, true),
|
}, true),
|
||||||
|
getAiAuditLogs: (
|
||||||
|
reviewed?: boolean,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0
|
||||||
|
): Promise<AiAuditListResponse> =>
|
||||||
|
apiFetch('/ai/audit/logs', {
|
||||||
|
method: 'GET',
|
||||||
|
query: { reviewed, limit, offset }
|
||||||
|
}, true),
|
||||||
|
reviewAiAuditLog: (
|
||||||
|
logId: string,
|
||||||
|
payload: { reviewed: boolean; reviewer_note?: string }
|
||||||
|
): Promise<{ id: string; reviewed: boolean }> =>
|
||||||
|
apiFetch(`/ai/audit/logs/${logId}/review`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, true),
|
||||||
|
getAiDataEthicsSummary: (
|
||||||
|
days = 30,
|
||||||
|
limit = 40
|
||||||
|
): Promise<AiDataEthicsSummaryResponse> =>
|
||||||
|
apiFetch('/ai/data-ethics/summary', {
|
||||||
|
method: 'GET',
|
||||||
|
query: { days, limit }
|
||||||
|
}, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Meeting {
|
export interface Meeting {
|
||||||
@@ -1815,6 +1840,60 @@ export interface FaqEntry {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiAuditItem {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
student_name?: string;
|
||||||
|
endpoint: string;
|
||||||
|
model: string;
|
||||||
|
output_tokens: number;
|
||||||
|
has_rag_context: boolean;
|
||||||
|
risk_score: number;
|
||||||
|
risk_signals: string[];
|
||||||
|
response_excerpt: string;
|
||||||
|
reviewed: boolean;
|
||||||
|
reviewed_by?: string;
|
||||||
|
reviewed_by_name?: string;
|
||||||
|
reviewer_note?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiAuditListResponse {
|
||||||
|
items: AiAuditItem[];
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiDataEthicsEventItem {
|
||||||
|
id: string;
|
||||||
|
endpoint: string;
|
||||||
|
model: string;
|
||||||
|
request_type: string;
|
||||||
|
tokens_used: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
has_rag_context: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiDataEthicsSummary {
|
||||||
|
days_window: number;
|
||||||
|
total_requests: number;
|
||||||
|
total_tokens: number;
|
||||||
|
total_input_tokens: number;
|
||||||
|
total_output_tokens: number;
|
||||||
|
average_tokens_per_request: number;
|
||||||
|
model_count: number;
|
||||||
|
request_type_count: number;
|
||||||
|
retention_days: number;
|
||||||
|
stored_fields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiDataEthicsSummaryResponse {
|
||||||
|
summary: AiDataEthicsSummary;
|
||||||
|
events: AiDataEthicsEventItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LtiDeepLinkingContentItem {
|
export interface LtiDeepLinkingContentItem {
|
||||||
type: 'ltiResourceLink';
|
type: 'ltiResourceLink';
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user