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;
|
||||
pub mod exporter;
|
||||
mod external_handlers;
|
||||
mod handlers;
|
||||
mod handlers_branding;
|
||||
mod handlers_library;
|
||||
mod webhooks;
|
||||
mod external_handlers;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -139,7 +140,10 @@ async fn main() {
|
||||
get(handlers::get_grading_categories),
|
||||
)
|
||||
.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("/audit-logs", get(handlers::get_audit_logs))
|
||||
.route("/api/ai/review-text", post(handlers::review_text))
|
||||
@@ -177,14 +181,38 @@ async fn main() {
|
||||
"/organizations/{id}/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(
|
||||
common::middleware::org_extractor_middleware,
|
||||
));
|
||||
|
||||
let api_routes = Router::new()
|
||||
.route("/v1/courses", 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));
|
||||
.route(
|
||||
"/v1/courses",
|
||||
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
|
||||
let public_routes = Router::new()
|
||||
|
||||
@@ -178,7 +178,7 @@ pub async fn submit_peer_review(
|
||||
}
|
||||
|
||||
pub async fn get_my_submission_feedback(
|
||||
Org(org_ctx): Org,
|
||||
Org(_org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
|
||||
|
||||
@@ -525,7 +525,52 @@ pub struct SubmitAssignmentPayload {
|
||||
pub struct SubmitPeerReviewPayload {
|
||||
pub submission_id: Uuid,
|
||||
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)]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -298,7 +333,6 @@ export interface StudentGradeReport {
|
||||
email: string;
|
||||
progress: number;
|
||||
average_score: number | null;
|
||||
average_score: number | null;
|
||||
last_active_at: string | null;
|
||||
}
|
||||
|
||||
@@ -516,6 +550,26 @@ export const cmsApi = {
|
||||
getBackgroundTasks: (): Promise<BackgroundTask[]> => apiFetch('/tasks'),
|
||||
retryTask: (id: string): Promise<void> => apiFetch(`/tasks/${id}/retry`, { method: 'POST' }),
|
||||
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 = {
|
||||
@@ -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),
|
||||
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),
|
||||
const query = cohortId ? `?cohort_id=${cohortId}` : '';
|
||||
return apiFetch(`/courses/${id}/grades${query}`, {}, true);
|
||||
},
|
||||
getGrades: (id: string, cohortId?: string): Promise<any> => {
|
||||
const query = cohortId ? `?cohort_id=${cohortId}` : '';
|
||||
return apiFetch(`/courses/${id}/grades${query}`, {}, true);
|
||||
},
|
||||
// Peer Assessment
|
||||
submitAssignment: (courseId: string, lessonId: string, content: string): Promise<CourseSubmission> => apiFetch(`/courses/${courseId}/lessons/${lessonId}/submit`, { method: 'POST', body: JSON.stringify({ content }) }, true),
|
||||
getPeerReviewAssignment: (courseId: string, lessonId: string): Promise<CourseSubmission | null> => 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),
|
||||
|
||||
submitAssignment: (courseId: string, lessonId: string, content: string): Promise<CourseSubmission> =>
|
||||
apiFetch(`/courses/${courseId}/lessons/${lessonId}/submit`, { method: 'POST', body: JSON.stringify({ content }) }, true),
|
||||
getPeerReviewAssignment: (courseId: string, lessonId: string): Promise<CourseSubmission | null> =>
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user