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
+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 {