feat: introduce content library management for reusable content blocks with dedicated API endpoints and database schema.

This commit is contained in:
2026-02-16 20:45:48 -03:00
parent 1d7e5a39ce
commit 84bbeb12c6
8 changed files with 852 additions and 16 deletions
@@ -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)
}
+33 -5
View File
@@ -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)>,
+46 -1
View File
@@ -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)]
+235
View File
@@ -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>
);
}
+67 -9
View File
@@ -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 {