feat: introduce content library management for reusable content blocks with dedicated API endpoints and database schema.
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
-- Content Libraries: Repositorio reutilizable de bloques y lecciones
|
||||||
|
-- Permite a instructores guardar y reutilizar componentes de contenido
|
||||||
|
|
||||||
|
-- Bloques reutilizables guardados en la biblioteca
|
||||||
|
CREATE TABLE library_blocks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL,
|
||||||
|
created_by UUID NOT NULL, -- El instructor que lo guardó
|
||||||
|
name TEXT NOT NULL, -- Nombre descriptivo dado por el instructor
|
||||||
|
description TEXT, -- Descripción opcional
|
||||||
|
block_type TEXT NOT NULL, -- 'quiz', 'peer-review', 'hotspot', etc.
|
||||||
|
block_data JSONB NOT NULL, -- El bloque completo (mismo formato que metadata.blocks)
|
||||||
|
tags TEXT[], -- Array de etiquetas para búsqueda
|
||||||
|
usage_count INTEGER DEFAULT 0, -- Contador de cuántas veces se ha usado
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Índices para búsqueda eficiente
|
||||||
|
CREATE INDEX idx_library_blocks_org ON library_blocks(organization_id);
|
||||||
|
CREATE INDEX idx_library_blocks_type ON library_blocks(block_type);
|
||||||
|
CREATE INDEX idx_library_blocks_tags ON library_blocks USING GIN(tags);
|
||||||
|
CREATE INDEX idx_library_blocks_created_by ON library_blocks(created_by);
|
||||||
|
|
||||||
|
-- Trigger para actualizar updated_at
|
||||||
|
CREATE TRIGGER update_library_blocks_updated_at
|
||||||
|
BEFORE UPDATE ON library_blocks
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE update_updated_at_column();
|
||||||
|
|
||||||
|
-- Plantillas de lecciones completas (para futuras expansiones)
|
||||||
|
CREATE TABLE library_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL,
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
lesson_data JSONB NOT NULL, -- Incluye metadata.blocks y configuración
|
||||||
|
tags TEXT[],
|
||||||
|
usage_count INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_library_templates_org ON library_templates(organization_id);
|
||||||
|
|
||||||
|
-- Trigger para actualizar updated_at
|
||||||
|
CREATE TRIGGER update_library_templates_updated_at
|
||||||
|
BEFORE UPDATE ON library_templates
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE update_updated_at_column();
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use common::models::{CreateLibraryBlockPayload, LibraryBlock, UpdateLibraryBlockPayload};
|
||||||
|
use common::{auth::Claims, middleware::Org};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LibraryBlockFilters {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub block_type: Option<String>,
|
||||||
|
pub tags: Option<String>, // Comma-separated list
|
||||||
|
pub search: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/library/blocks - Guardar un bloque en la biblioteca
|
||||||
|
pub async fn create_library_block(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<CreateLibraryBlockPayload>,
|
||||||
|
) -> Result<Json<LibraryBlock>, (StatusCode, String)> {
|
||||||
|
let block = sqlx::query_as!(
|
||||||
|
LibraryBlock,
|
||||||
|
r#"
|
||||||
|
INSERT INTO library_blocks (organization_id, created_by, name, description, block_type, block_data, tags)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as "usage_count!", created_at, updated_at
|
||||||
|
"#,
|
||||||
|
org_ctx.id,
|
||||||
|
claims.sub,
|
||||||
|
payload.name,
|
||||||
|
payload.description,
|
||||||
|
payload.block_type,
|
||||||
|
payload.block_data,
|
||||||
|
payload.tags.as_deref()
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(block))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/library/blocks - Listar bloques de la biblioteca
|
||||||
|
pub async fn list_library_blocks(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(filters): Query<LibraryBlockFilters>,
|
||||||
|
) -> Result<Json<Vec<LibraryBlock>>, (StatusCode, String)> {
|
||||||
|
// Base query
|
||||||
|
let mut query = String::from("SELECT * FROM library_blocks WHERE organization_id = $1");
|
||||||
|
let mut param_count = 1;
|
||||||
|
|
||||||
|
// Filtro por tipo
|
||||||
|
if filters.block_type.is_some() {
|
||||||
|
param_count += 1;
|
||||||
|
query.push_str(&format!(" AND block_type = ${}", param_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro por tags (busca si algún tag coincide)
|
||||||
|
if filters.tags.is_some() {
|
||||||
|
param_count += 1;
|
||||||
|
query.push_str(&format!(" AND tags && ${}", param_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Búsqueda en nombre y descripción
|
||||||
|
if filters.search.is_some() {
|
||||||
|
param_count += 1;
|
||||||
|
query.push_str(&format!(
|
||||||
|
" AND (name ILIKE ${0} OR description ILIKE ${0})",
|
||||||
|
param_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
query.push_str(" ORDER BY created_at DESC");
|
||||||
|
|
||||||
|
// Build query con bind dinámico
|
||||||
|
let mut sql_query = sqlx::query_as::<_, LibraryBlock>(&query).bind(org_ctx.id);
|
||||||
|
|
||||||
|
if let Some(block_type) = &filters.block_type {
|
||||||
|
sql_query = sql_query.bind(block_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tags_str) = &filters.tags {
|
||||||
|
let tags: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
|
||||||
|
sql_query = sql_query.bind(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
let search_pattern = filters.search.as_ref().map(|s| format!("%{}%", s));
|
||||||
|
if let Some(ref pattern) = search_pattern {
|
||||||
|
sql_query = sql_query.bind(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
let blocks = sql_query
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(blocks))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/library/blocks/:id - Obtener un bloque específico
|
||||||
|
pub async fn get_library_block(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(block_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<LibraryBlock>, (StatusCode, String)> {
|
||||||
|
let block = sqlx::query_as!(
|
||||||
|
LibraryBlock,
|
||||||
|
r#"SELECT id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as "usage_count!", created_at, updated_at FROM library_blocks WHERE id = $1 AND organization_id = $2"#,
|
||||||
|
block_id,
|
||||||
|
org_ctx.id
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
match block {
|
||||||
|
Some(b) => Ok(Json(b)),
|
||||||
|
None => Err((StatusCode::NOT_FOUND, "Block not found".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/library/blocks/:id - Actualizar bloque (nombre, descripción, tags)
|
||||||
|
pub async fn update_library_block(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(block_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateLibraryBlockPayload>,
|
||||||
|
) -> Result<Json<LibraryBlock>, (StatusCode, String)> {
|
||||||
|
// Verificar que el bloque existe y pertenece a la org
|
||||||
|
let existing = sqlx::query!(
|
||||||
|
"SELECT id FROM library_blocks WHERE id = $1 AND organization_id = $2",
|
||||||
|
block_id,
|
||||||
|
org_ctx.id
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if existing.is_none() {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Block not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update dinámico basado en campos provistos
|
||||||
|
let updated = if let Some(name) = &payload.name {
|
||||||
|
sqlx::query_as!(
|
||||||
|
LibraryBlock,
|
||||||
|
r#"
|
||||||
|
UPDATE library_blocks
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
tags = COALESCE($3, tags),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $4 AND organization_id = $5
|
||||||
|
RETURNING id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as "usage_count!", created_at, updated_at
|
||||||
|
"#,
|
||||||
|
Some(name),
|
||||||
|
payload.description,
|
||||||
|
payload.tags.as_deref(),
|
||||||
|
block_id,
|
||||||
|
org_ctx.id
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as!(
|
||||||
|
LibraryBlock,
|
||||||
|
r#"
|
||||||
|
UPDATE library_blocks
|
||||||
|
SET description = COALESCE($1, description),
|
||||||
|
tags = COALESCE($2, tags),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $3 AND organization_id = $4
|
||||||
|
RETURNING id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as "usage_count!", created_at, updated_at
|
||||||
|
"#,
|
||||||
|
payload.description,
|
||||||
|
payload.tags.as_deref(),
|
||||||
|
block_id,
|
||||||
|
org_ctx.id
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/library/blocks/:id - Eliminar bloque
|
||||||
|
pub async fn delete_library_block(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(block_id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"DELETE FROM library_blocks WHERE id = $1 AND organization_id = $2",
|
||||||
|
block_id,
|
||||||
|
org_ctx.id
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Block not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/library/blocks/:id/increment-usage - Incrementar contador de uso
|
||||||
|
pub async fn increment_block_usage(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(block_id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"UPDATE library_blocks SET usage_count = usage_count + 1 WHERE id = $1 AND organization_id = $2",
|
||||||
|
block_id,
|
||||||
|
org_ctx.id
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Block not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
mod db_util;
|
mod db_util;
|
||||||
pub mod exporter;
|
pub mod exporter;
|
||||||
|
mod external_handlers;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod handlers_branding;
|
mod handlers_branding;
|
||||||
|
mod handlers_library;
|
||||||
mod webhooks;
|
mod webhooks;
|
||||||
mod external_handlers;
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
@@ -139,7 +140,10 @@ async fn main() {
|
|||||||
get(handlers::get_grading_categories),
|
get(handlers::get_grading_categories),
|
||||||
)
|
)
|
||||||
.route("/auth/me", get(handlers::get_me))
|
.route("/auth/me", get(handlers::get_me))
|
||||||
.route("/users", get(handlers::get_all_users).post(handlers::admin_create_user))
|
.route(
|
||||||
|
"/users",
|
||||||
|
get(handlers::get_all_users).post(handlers::admin_create_user),
|
||||||
|
)
|
||||||
.route("/users/{id}", axum::routing::put(handlers::update_user))
|
.route("/users/{id}", axum::routing::put(handlers::update_user))
|
||||||
.route("/audit-logs", get(handlers::get_audit_logs))
|
.route("/audit-logs", get(handlers::get_audit_logs))
|
||||||
.route("/api/ai/review-text", post(handlers::review_text))
|
.route("/api/ai/review-text", post(handlers::review_text))
|
||||||
@@ -177,14 +181,38 @@ async fn main() {
|
|||||||
"/organizations/{id}/branding",
|
"/organizations/{id}/branding",
|
||||||
axum::routing::put(handlers_branding::update_organization_branding),
|
axum::routing::put(handlers_branding::update_organization_branding),
|
||||||
)
|
)
|
||||||
|
// Content Libraries routes
|
||||||
|
.route(
|
||||||
|
"/library/blocks",
|
||||||
|
get(handlers_library::list_library_blocks).post(handlers_library::create_library_block),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/library/blocks/{id}",
|
||||||
|
get(handlers_library::get_library_block)
|
||||||
|
.put(handlers_library::update_library_block)
|
||||||
|
.delete(handlers_library::delete_library_block),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/library/blocks/{id}/increment-usage",
|
||||||
|
post(handlers_library::increment_block_usage),
|
||||||
|
)
|
||||||
.route_layer(middleware::from_fn(
|
.route_layer(middleware::from_fn(
|
||||||
common::middleware::org_extractor_middleware,
|
common::middleware::org_extractor_middleware,
|
||||||
));
|
));
|
||||||
|
|
||||||
let api_routes = Router::new()
|
let api_routes = Router::new()
|
||||||
.route("/v1/courses", post(external_handlers::create_course_external))
|
.route(
|
||||||
.route("/v1/courses/{id}", get(external_handlers::get_course_external))
|
"/v1/courses",
|
||||||
.route("/v1/lessons/{id}/transcribe", post(external_handlers::trigger_transcription_external));
|
post(external_handlers::create_course_external),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/courses/{id}",
|
||||||
|
get(external_handlers::get_course_external),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/lessons/{id}/transcribe",
|
||||||
|
post(external_handlers::trigger_transcription_external),
|
||||||
|
);
|
||||||
|
|
||||||
// Rutas públicas que no requieren autenticación
|
// Rutas públicas que no requieren autenticación
|
||||||
let public_routes = Router::new()
|
let public_routes = Router::new()
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ pub async fn submit_peer_review(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_my_submission_feedback(
|
pub async fn get_my_submission_feedback(
|
||||||
Org(org_ctx): Org,
|
Org(_org_ctx): Org,
|
||||||
claims: Claims,
|
claims: Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
|
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
|
||||||
|
|||||||
@@ -525,7 +525,52 @@ pub struct SubmitAssignmentPayload {
|
|||||||
pub struct SubmitPeerReviewPayload {
|
pub struct SubmitPeerReviewPayload {
|
||||||
pub submission_id: Uuid,
|
pub submission_id: Uuid,
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
pub feedback: String,
|
}
|
||||||
|
|
||||||
|
// Content Libraries
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct LibraryBlock {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub block_type: String,
|
||||||
|
pub block_data: serde_json::Value,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub usage_count: i32,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CreateLibraryBlockPayload {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub block_type: String,
|
||||||
|
pub block_data: serde_json::Value,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateLibraryBlockPayload {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct LibraryTemplate {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub lesson_data: serde_json::Value,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub usage_count: i32,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { LibraryBlock, api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface LibraryPanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectBlock: (block: LibraryBlock) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LibraryPanel({ isOpen, onClose, onSelectBlock }: LibraryPanelProps) {
|
||||||
|
const [blocks, setBlocks] = useState<LibraryBlock[]>([]);
|
||||||
|
const [filteredBlocks, setFilteredBlocks] = useState<LibraryBlock[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedType, setSelectedType] = useState<string>('');
|
||||||
|
const [selectedTag, setSelectedTag] = useState<string>('');
|
||||||
|
|
||||||
|
// Get unique types and tags from blocks
|
||||||
|
const blockTypes = Array.from(new Set(blocks.map(b => b.block_type)));
|
||||||
|
const allTags = Array.from(new Set(blocks.flatMap(b => b.tags || [])));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadBlocks();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
filterBlocks();
|
||||||
|
}, [blocks, searchTerm, selectedType, selectedTag]);
|
||||||
|
|
||||||
|
const loadBlocks = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.listLibraryBlocks();
|
||||||
|
setBlocks(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading library blocks:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterBlocks = () => {
|
||||||
|
let filtered = [...blocks];
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
filtered = filtered.filter(
|
||||||
|
b => b.name.toLowerCase().includes(term) ||
|
||||||
|
b.description?.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedType) {
|
||||||
|
filtered = filtered.filter(b => b.block_type === selectedType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTag) {
|
||||||
|
filtered = filtered.filter(b => b.tags?.includes(selectedTag));
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredBlocks(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseBlock = async (block: LibraryBlock) => {
|
||||||
|
try {
|
||||||
|
// Increment usage counter
|
||||||
|
await api.incrementBlockUsage(block.id);
|
||||||
|
onSelectBlock(block);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error using block:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteBlock = async (blockId: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm('¿Estás seguro de eliminar este bloque de la biblioteca?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteLibraryBlock(blockId);
|
||||||
|
setBlocks(blocks.filter(b => b.id !== blockId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting block:', error);
|
||||||
|
alert('Error al eliminar el bloque');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-2xl font-bold">Biblioteca de Bloques</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
{/* Search */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Type Filter */}
|
||||||
|
<select
|
||||||
|
value={selectedType}
|
||||||
|
onChange={(e) => setSelectedType(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Todos los tipos</option>
|
||||||
|
{blockTypes.map(type => (
|
||||||
|
<option key={type} value={type}>{type}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Tag Filter */}
|
||||||
|
<select
|
||||||
|
value={selectedTag}
|
||||||
|
onChange={(e) => setSelectedTag(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Todas las etiquetas</option>
|
||||||
|
{allTags.map(tag => (
|
||||||
|
<option key={tag} value={tag}>{tag}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="text-gray-500">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
) : filteredBlocks.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-32 text-gray-500">
|
||||||
|
<svg className="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||||
|
</svg>
|
||||||
|
<p>No se encontraron bloques</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{filteredBlocks.map((block) => (
|
||||||
|
<div
|
||||||
|
key={block.id}
|
||||||
|
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-lg">{block.name}</h3>
|
||||||
|
<span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded mt-1">
|
||||||
|
{block.block_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDeleteBlock(block.id, e)}
|
||||||
|
className="text-red-500 hover:text-red-700 ml-2"
|
||||||
|
title="Eliminar"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{block.description && (
|
||||||
|
<p className="text-sm text-gray-600 mb-3">{block.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{block.tags && block.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
|
{block.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-block bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mt-3 pt-3 border-t">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Usado {block.usage_count} {block.usage_count === 1 ? 'vez' : 'veces'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUseBlock(block)}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Usar Bloque
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t bg-gray-50">
|
||||||
|
<div className="flex justify-between items-center text-sm text-gray-600">
|
||||||
|
<span>{filteredBlocks.length} bloque(s) encontrado(s)</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-gray-700 hover:bg-gray-200 rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Block } from '@/lib/api';
|
||||||
|
|
||||||
|
interface SaveToLibraryModalProps {
|
||||||
|
block: Block;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (name: string, description: string, tags: string[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SaveToLibraryModal({
|
||||||
|
block,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: SaveToLibraryModalProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [tagInput, setTagInput] = useState('');
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleAddTag = () => {
|
||||||
|
const trimmed = tagInput.trim();
|
||||||
|
if (trimmed && !tags.includes(trimmed)) {
|
||||||
|
setTags([...tags, trimmed]);
|
||||||
|
setTagInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTag = (tagToRemove: string) => {
|
||||||
|
setTags(tags.filter(t => t !== tagToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(name, description, tags);
|
||||||
|
// Reset form
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setTags([]);
|
||||||
|
setTagInput('');
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving to library:', error);
|
||||||
|
alert('Error al guardar en la biblioteca. Por favor intenta de nuevo.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && tagInput.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddTag();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Guardar en Biblioteca</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Block Type Badge */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">
|
||||||
|
{block.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name Input */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="block-name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nombre <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="block-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="ej: Quiz de Matemáticas - Álgebra"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description Input */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="block-description" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Descripción (opcional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="block-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Describe cuándo usar este bloque..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags Input */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="block-tags" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Etiquetas (opcional)
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
id="block-tags"
|
||||||
|
type="text"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="ej: matemáticas, álgebra"
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddTag}
|
||||||
|
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-md text-sm font-medium"
|
||||||
|
>
|
||||||
|
Agregar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags Display */}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center gap-1 bg-indigo-100 text-indigo-800 text-sm px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveTag(tag)}
|
||||||
|
className="text-indigo-600 hover:text-indigo-800"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving || !name.trim()}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Guardando...' : 'Guardar en Biblioteca'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -200,6 +200,41 @@ export interface GradingCategory {
|
|||||||
drop_count: number;
|
drop_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Content Libraries
|
||||||
|
export interface LibraryBlock {
|
||||||
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
|
created_by: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
block_type: string;
|
||||||
|
block_data: Block;
|
||||||
|
tags?: string[];
|
||||||
|
usage_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLibraryBlockPayload {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
block_type: string;
|
||||||
|
block_data: Block;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLibraryBlockPayload {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryBlockFilters {
|
||||||
|
type?: string;
|
||||||
|
tags?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -298,7 +333,6 @@ export interface StudentGradeReport {
|
|||||||
email: string;
|
email: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
average_score: number | null;
|
average_score: number | null;
|
||||||
average_score: number | null;
|
|
||||||
last_active_at: string | null;
|
last_active_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +550,26 @@ export const cmsApi = {
|
|||||||
getBackgroundTasks: (): Promise<BackgroundTask[]> => apiFetch('/tasks'),
|
getBackgroundTasks: (): Promise<BackgroundTask[]> => apiFetch('/tasks'),
|
||||||
retryTask: (id: string): Promise<void> => apiFetch(`/tasks/${id}/retry`, { method: 'POST' }),
|
retryTask: (id: string): Promise<void> => apiFetch(`/tasks/${id}/retry`, { method: 'POST' }),
|
||||||
cancelTask: (id: string): Promise<void> => apiFetch(`/tasks/${id}`, { method: 'DELETE' }),
|
cancelTask: (id: string): Promise<void> => apiFetch(`/tasks/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Content Libraries
|
||||||
|
createLibraryBlock: (payload: CreateLibraryBlockPayload): Promise<LibraryBlock> =>
|
||||||
|
apiFetch('/library/blocks', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
listLibraryBlocks: (filters?: LibraryBlockFilters): Promise<LibraryBlock[]> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.type) params.append('type', filters.type);
|
||||||
|
if (filters?.tags) params.append('tags', filters.tags);
|
||||||
|
if (filters?.search) params.append('search', filters.search);
|
||||||
|
const query = params.toString() ? `?${params.toString()}` : '';
|
||||||
|
return apiFetch(`/library/blocks${query}`);
|
||||||
|
},
|
||||||
|
getLibraryBlock: (id: string): Promise<LibraryBlock> =>
|
||||||
|
apiFetch(`/library/blocks/${id}`),
|
||||||
|
updateLibraryBlock: (id: string, payload: UpdateLibraryBlockPayload): Promise<LibraryBlock> =>
|
||||||
|
apiFetch(`/library/blocks/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
deleteLibraryBlock: (id: string): Promise<void> =>
|
||||||
|
apiFetch(`/library/blocks/${id}`, { method: 'DELETE' }),
|
||||||
|
incrementBlockUsage: (id: string): Promise<void> =>
|
||||||
|
apiFetch(`/library/blocks/${id}/increment-usage`, { method: 'POST' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const lmsApi = {
|
export const lmsApi = {
|
||||||
@@ -524,15 +578,19 @@ export const lmsApi = {
|
|||||||
addMember: (cohortId: string, userId: string): Promise<UserCohort> => apiFetch(`/cohorts/${cohortId}/members`, { method: 'POST', body: JSON.stringify({ user_id: userId }) }, true),
|
addMember: (cohortId: string, userId: string): Promise<UserCohort> => apiFetch(`/cohorts/${cohortId}/members`, { method: 'POST', body: JSON.stringify({ user_id: userId }) }, true),
|
||||||
removeMember: (cohortId: string, userId: string): Promise<void> => apiFetch(`/cohorts/${cohortId}/members/${userId}`, { method: 'DELETE' }, true),
|
removeMember: (cohortId: string, userId: string): Promise<void> => apiFetch(`/cohorts/${cohortId}/members/${userId}`, { method: 'DELETE' }, true),
|
||||||
getMembers: (id: string): Promise<string[]> => apiFetch(`/cohorts/${id}/members`, {}, true),
|
getMembers: (id: string): Promise<string[]> => apiFetch(`/cohorts/${id}/members`, {}, true),
|
||||||
const query = cohortId ? `?cohort_id=${cohortId}` : '';
|
getGrades: (id: string, cohortId?: string): Promise<any> => {
|
||||||
return apiFetch(`/courses/${id}/grades${query}`, {}, true);
|
const query = cohortId ? `?cohort_id=${cohortId}` : '';
|
||||||
},
|
return apiFetch(`/courses/${id}/grades${query}`, {}, true);
|
||||||
|
},
|
||||||
// Peer Assessment
|
// Peer Assessment
|
||||||
submitAssignment: (courseId: string, lessonId: string, content: string): Promise<CourseSubmission> => apiFetch(`/courses/${courseId}/lessons/${lessonId}/submit`, { method: 'POST', body: JSON.stringify({ content }) }, true),
|
submitAssignment: (courseId: string, lessonId: string, content: string): Promise<CourseSubmission> =>
|
||||||
getPeerReviewAssignment: (courseId: string, lessonId: string): Promise<CourseSubmission | null> => apiFetch(`/courses/${courseId}/lessons/${lessonId}/peer-review`, {}, true),
|
apiFetch(`/courses/${courseId}/lessons/${lessonId}/submit`, { method: 'POST', body: JSON.stringify({ content }) }, true),
|
||||||
submitPeerReview: (courseId: string, lessonId: string, submissionId: string, score: number, feedback: string): Promise<PeerReview> => apiFetch(`/courses/${courseId}/lessons/${lessonId}/peer-review`, { method: 'POST', body: JSON.stringify({ submission_id: submissionId, score, feedback }) }, true),
|
getPeerReviewAssignment: (courseId: string, lessonId: string): Promise<CourseSubmission | null> =>
|
||||||
getMySubmissionFeedback: (courseId: string, lessonId: string): Promise<PeerReview[]> => apiFetch(`/courses/${courseId}/lessons/${lessonId}/feedback`, {}, true),
|
apiFetch(`/courses/${courseId}/lessons/${lessonId}/peer-review`, {}, true),
|
||||||
|
submitPeerReview: (courseId: string, lessonId: string, submissionId: string, score: number, feedback: string): Promise<PeerReview> =>
|
||||||
|
apiFetch(`/courses/${courseId}/lessons/${lessonId}/peer-review`, { method: 'POST', body: JSON.stringify({ submission_id: submissionId, score, feedback }) }, true),
|
||||||
|
getMySubmissionFeedback: (courseId: string, lessonId: string): Promise<PeerReview[]> =>
|
||||||
|
apiFetch(`/courses/${courseId}/lessons/${lessonId}/feedback`, {}, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BackgroundTask {
|
export interface BackgroundTask {
|
||||||
|
|||||||
Reference in New Issue
Block a user