feat: add PluginBlock component for rendering external web components in sandboxed iframes

feat: implement PluginsPage for managing plugins with create, toggle, and delete functionalities

feat: create PedagogicalAnalyticsPage for displaying course analytics including quality metrics, discrimination index, and curricular suggestions

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-27 12:22:05 -04:00
parent 675fa1e299
commit f6a3f6aedf
23 changed files with 2580 additions and 24 deletions
@@ -22,10 +22,12 @@ import RolePlayingPlayer from "@/components/blocks/RolePlayingPlayer";
import PeerReviewPlayer from "@/components/blocks/PeerReviewPlayer";
import MermaidViewer from "@/components/blocks/MermaidViewer";
import ScormPlayer from "@/components/blocks/ScormPlayer";
import PluginBlock from "@/components/blocks/PluginBlock";
import InteractiveTranscript from "@/components/InteractiveTranscript";
import AITutor from "@/components/AITutor";
import LessonLockedView from "@/components/LessonLockedView";
import StudentNotes from "@/components/StudentNotes";
import CollaborativeWhiteboard from "@/components/CollaborativeWhiteboard";
import { ListMusic, StickyNote } from "lucide-react";
import ReactMarkdown from "react-markdown";
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
@@ -522,6 +524,15 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
);
case 'mermaid':
return <MermaidViewer block={block} />;
case 'plugin':
return (
<PluginBlock
pluginId={block.id}
name={block.title || 'Plugin'}
componentUrl={block.component_url || ''}
config={block.config as Record<string, unknown> | undefined}
/>
);
case 'scorm':
return (
<ScormPlayer
@@ -608,6 +619,10 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
)}
</div>
)}
<div className="pt-12 border-t border-black/5 dark:border-white/5 animate-in fade-in slide-in-from-bottom-8 duration-1000">
<CollaborativeWhiteboard lessonId={params.lessonId} />
</div>
</article>
</div>
@@ -0,0 +1,388 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
import { lmsApi, CollaborativeCanvasState } from "@/lib/api";
import { AlertTriangle, CheckCircle, Loader2, RefreshCw, Save, Trash2 } from "lucide-react";
type ConflictInfo = {
localStrokes: Stroke[];
remoteStrokes: Stroke[];
remoteRevision: number;
remoteUpdatedAt: string;
};
type Point = { x: number; y: number };
type Stroke = {
points: Point[];
color: string;
width: number;
};
type Props = {
lessonId: string;
};
const DEFAULT_CANVAS: CollaborativeCanvasState = { strokes: [] };
function toStrokeArray(state: CollaborativeCanvasState): Stroke[] {
if (!Array.isArray(state.strokes)) {
return [];
}
return state.strokes
.map((stroke) => {
const points = Array.isArray(stroke.points)
? stroke.points
.filter((p): p is Point => typeof p?.x === "number" && typeof p?.y === "number")
.map((p) => ({ x: p.x, y: p.y }))
: [];
return {
points,
color: typeof stroke.color === "string" ? stroke.color : "#1f2937",
width: typeof stroke.width === "number" ? stroke.width : 2,
};
})
.filter((stroke) => stroke.points.length > 1);
}
export default function CollaborativeWhiteboard({ lessonId }: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [strokes, setStrokes] = useState<Stroke[]>([]);
const [draftStroke, setDraftStroke] = useState<Stroke | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
const [error, setError] = useState<string | null>(null);
const [conflict, setConflict] = useState<ConflictInfo | null>(null);
const isDrawing = useRef(false);
const allStrokes = useMemo(() => {
return draftStroke ? [...strokes, draftStroke] : strokes;
}, [strokes, draftStroke]);
const resizeCanvas = useCallback(() => {
const canvas = canvasRef.current;
const wrapper = wrapperRef.current;
if (!canvas || !wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(rect.width * scale));
canvas.height = Math.max(1, Math.floor(340 * scale));
canvas.style.width = `${rect.width}px`;
canvas.style.height = "340px";
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.scale(scale, scale);
}, []);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (const stroke of allStrokes) {
if (stroke.points.length < 2) continue;
ctx.beginPath();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i += 1) {
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
ctx.stroke();
}
}, [allStrokes]);
useEffect(() => {
resizeCanvas();
draw();
}, [resizeCanvas, draw]);
useEffect(() => {
const onResize = () => {
resizeCanvas();
draw();
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [draw, resizeCanvas]);
const getPoint = (event: PointerEvent<HTMLCanvasElement>): Point | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return { x, y };
};
const loadCanvas = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await lmsApi.getLessonCollaborativeCanvas(lessonId);
const loadedStrokes = toStrokeArray(data.canvas_state || DEFAULT_CANVAS);
setStrokes(loadedStrokes);
setDraftStroke(null);
setLastSavedAt(data.updated_at || null);
setRevision(data.revision || 0);
setDirty(false);
} catch (e) {
console.error("Error loading collaborative canvas", e);
setError("No se pudo cargar la pizarra colaborativa.");
} finally {
setLoading(false);
}
}, [lessonId]);
const saveCanvas = useCallback(async (force = false) => {
if (saving || loading || isDrawing.current) return;
setSaving(true);
setError(null);
const expectedRev = force ? undefined : revision;
try {
const result = await lmsApi.updateLessonCollaborativeCanvas(lessonId, { strokes }, expectedRev);
setLastSavedAt(result.updated_at);
setRevision(result.revision);
setDirty(false);
setConflict(null);
} catch (e) {
console.error("Error saving collaborative canvas", e);
if (e instanceof Error && e.message.toLowerCase().includes("conflicto")) {
// Fetch remote state to show diff panel
try {
const remote = await lmsApi.getLessonCollaborativeCanvas(lessonId);
setConflict({
localStrokes: strokes,
remoteStrokes: toStrokeArray(remote.canvas_state || DEFAULT_CANVAS),
remoteRevision: remote.revision || 0,
remoteUpdatedAt: remote.updated_at ?? new Date().toISOString(),
});
} catch {
setError("Conflicto detectado y no se pudo obtener el estado remoto. Recarga manualmente.");
}
return;
}
setError("No se pudo guardar la pizarra. Intenta nuevamente.");
} finally {
setSaving(false);
}
}, [lessonId, loading, revision, saving, strokes]);
useEffect(() => {
loadCanvas();
}, [loadCanvas]);
const acceptRemote = useCallback(() => {
if (!conflict) return;
setStrokes(conflict.remoteStrokes);
setRevision(conflict.remoteRevision);
setLastSavedAt(conflict.remoteUpdatedAt);
setDirty(false);
setConflict(null);
setError(null);
}, [conflict]);
const forceLocal = useCallback(() => {
setConflict(null);
void saveCanvas(true);
}, [saveCanvas]);
useEffect(() => {
if (!dirty || loading || saving || isDrawing.current) {
return;
}
const timeoutId = setTimeout(() => {
void saveCanvas();
}, 1500);
return () => clearTimeout(timeoutId);
}, [dirty, loading, saveCanvas, saving, strokes]);
useEffect(() => {
const interval = setInterval(async () => {
if (dirty || isDrawing.current) return;
try {
const data = await lmsApi.getLessonCollaborativeCanvas(lessonId);
const serverStamp = data.updated_at || null;
if (serverStamp !== lastSavedAt) {
setStrokes(toStrokeArray(data.canvas_state || DEFAULT_CANVAS));
setLastSavedAt(serverStamp);
setRevision(data.revision || 0);
}
} catch (e) {
console.error("Polling collaborative canvas failed", e);
}
}, 5000);
return () => clearInterval(interval);
}, [dirty, lastSavedAt, lessonId]);
return (
<section className="space-y-4 rounded-3xl border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">Pizarra colaborativa (MVP)</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">Dibuja ideas rápidas de la lección y compártelas con tu grupo.</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setStrokes([]);
setDraftStroke(null);
setDirty(true);
}}
className="inline-flex items-center gap-1 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100"
>
<Trash2 className="h-3 w-3" /> Limpiar
</button>
<button
type="button"
onClick={() => void saveCanvas()}
disabled={saving || (!dirty && !loading)}
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 hover:bg-blue-100 disabled:opacity-50"
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />} Guardar
</button>
<button
type="button"
onClick={loadCanvas}
disabled={loading}
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-100 disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} /> Recargar
</button>
</div>
</div>
{error && <p className="text-xs font-semibold text-red-600">{error}</p>}
{conflict && (
<div className="rounded-2xl border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
<div>
<p className="text-xs font-black text-amber-800 dark:text-amber-300">
Conflicto de edición detectado
</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
Otro usuario guardó mientras editabas. Elige qué versión conservar:
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-[11px]">
<div className="rounded-xl border border-blue-200 bg-blue-50 dark:bg-blue-900/20 p-3">
<p className="font-black text-blue-800 dark:text-blue-300 mb-1">Tu versión (local)</p>
<p className="text-blue-700 dark:text-blue-400">
{conflict.localStrokes.length} trazos
</p>
<p className="text-blue-500 dark:text-blue-500 mt-1">No guardada</p>
</div>
<div className="rounded-xl border border-green-200 bg-green-50 dark:bg-green-900/20 p-3">
<p className="font-black text-green-800 dark:text-green-300 mb-1">Versión del servidor</p>
<p className="text-green-700 dark:text-green-400">
{conflict.remoteStrokes.length} trazos · rev. {conflict.remoteRevision}
</p>
<p className="text-green-500 dark:text-green-500 mt-1">
{new Date(conflict.remoteUpdatedAt).toLocaleTimeString()}
</p>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={forceLocal}
disabled={saving}
className="inline-flex items-center gap-1 rounded-lg border border-blue-300 bg-blue-100 px-3 py-1.5 text-xs font-semibold text-blue-800 hover:bg-blue-200 disabled:opacity-50"
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
Guardar mi versión
</button>
<button
type="button"
onClick={acceptRemote}
className="inline-flex items-center gap-1 rounded-lg border border-green-300 bg-green-100 px-3 py-1.5 text-xs font-semibold text-green-800 hover:bg-green-200"
>
<CheckCircle className="h-3 w-3" />
Usar versión del servidor
</button>
</div>
</div>
)}
<div ref={wrapperRef} className="w-full overflow-hidden rounded-2xl border border-black/10 bg-white">
<canvas
ref={canvasRef}
className="touch-none"
onPointerDown={(event) => {
const point = getPoint(event);
if (!point) return;
isDrawing.current = true;
setDraftStroke({ points: [point], color: "#1f2937", width: 2 });
}}
onPointerMove={(event) => {
if (!isDrawing.current) return;
const point = getPoint(event);
if (!point) return;
setDraftStroke((prev) => {
if (!prev) return prev;
return { ...prev, points: [...prev.points, point] };
});
}}
onPointerUp={() => {
if (!isDrawing.current) return;
isDrawing.current = false;
setDraftStroke((prev) => {
if (!prev || prev.points.length < 2) return null;
setStrokes((current) => [...current, prev]);
setDirty(true);
return null;
});
}}
onPointerLeave={() => {
if (!isDrawing.current) return;
isDrawing.current = false;
setDraftStroke((prev) => {
if (!prev || prev.points.length < 2) return null;
setStrokes((current) => [...current, prev]);
setDirty(true);
return null;
});
}}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] font-medium text-gray-500 dark:text-gray-400">
<span>Trazos: {strokes.length}</span>
<span>
{saving
? "Guardando..."
: dirty
? "Cambios sin guardar (autosave en 1.5s)"
: "Sin cambios pendientes"}
</span>
<span>Revision: {revision}</span>
<span>Última sincronización: {lastSavedAt ? new Date(lastSavedAt).toLocaleTimeString() : "nunca"}</span>
</div>
</section>
);
}
@@ -0,0 +1,103 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { Puzzle, AlertTriangle, ExternalLink } from "lucide-react";
interface PluginBlockProps {
pluginId: string;
name: string;
componentUrl: string;
config?: Record<string, unknown>;
}
/**
* Renderiza un Web Component externo dentro de un iframe sandboxed.
* El sandbox permite scripts y same-origin pero bloquea navegación superior,
* formularios externos y acceso a cámara/micrófono sin permiso explícito.
*/
export default function PluginBlock({ pluginId, name, componentUrl, config = {} }: PluginBlockProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
// Solo permitir HTTPS
const isSecure = componentUrl.startsWith("https://");
useEffect(() => {
if (!isSecure) {
setError("Este plugin no puede cargarse: la URL debe usar HTTPS.");
return;
}
// Enviar config al iframe cuando cargue vía postMessage
const handleLoad = () => {
setLoaded(true);
iframeRef.current?.contentWindow?.postMessage(
{ type: "OPENCCB_PLUGIN_CONFIG", pluginId, config },
new URL(componentUrl).origin
);
};
const iframe = iframeRef.current;
if (iframe) {
iframe.addEventListener("load", handleLoad);
return () => iframe.removeEventListener("load", handleLoad);
}
}, [componentUrl, config, isSecure, pluginId]);
if (!isSecure) {
return (
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300">
<AlertTriangle className="w-5 h-5 shrink-0" />
<span>El plugin <strong>{name}</strong> no puede cargarse: URL no segura.</span>
</div>
);
}
return (
<div className="rounded-2xl border border-black/10 dark:border-white/10 overflow-hidden bg-white dark:bg-black/20">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/3">
<div className="flex items-center gap-2 text-sm font-medium">
<Puzzle className="w-4 h-4 text-indigo-500" />
{name}
</div>
<a
href={componentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-black/30 dark:text-white/30 hover:text-black/60 dark:hover:text-white/60 flex items-center gap-1 transition-colors"
>
<ExternalLink className="w-3 h-3" />
Abrir
</a>
</div>
{/* Loading state */}
{!loaded && (
<div className="flex items-center justify-center h-48 text-black/30 dark:text-white/30 text-sm animate-pulse">
Cargando plugin
</div>
)}
{/* Iframe sandboxed */}
<iframe
ref={iframeRef}
src={componentUrl}
title={name}
className={`w-full transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0 h-0"}`}
style={{ minHeight: loaded ? "400px" : "0px", border: "none" }}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
loading="lazy"
onError={() => setError("No se pudo cargar el plugin.")}
/>
{error && (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
</div>
);
}
+62 -2
View File
@@ -184,7 +184,7 @@ export interface QuizQuestion {
export interface Block {
id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm';
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm' | 'plugin';
title: string;
content?: string;
url?: string;
@@ -228,6 +228,21 @@ export interface Block {
// SCORM/xAPI fields
launch_url?: string;
metadata?: any;
// Plugin fields
component_url?: string;
}
export interface OrgPlugin {
id: string;
organization_id: string;
name: string;
description: string;
component_url: string;
icon_url: string | null;
config: Record<string, unknown>;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface TrackXapiPayload {
@@ -295,6 +310,28 @@ export interface LessonDependency {
created_at: string;
}
export interface CollaborativeCanvasState {
strokes?: Array<{
points: Array<{ x: number; y: number }>;
color?: string;
width?: number;
}>;
[key: string]: unknown;
}
export interface CollaborativeCanvas {
lesson_id: string;
canvas_state: CollaborativeCanvasState;
revision: number;
updated_at?: string | null;
}
export interface CollaborativeCanvasUpdateResult {
lesson_id: string;
revision: number;
updated_at: string;
}
export interface UserGrade {
id: string;
user_id: string;
@@ -846,6 +883,24 @@ export const lmsApi = {
return apiFetch(`/lessons/${id}`);
},
async getLessonCollaborativeCanvas(lessonId: string): Promise<CollaborativeCanvas> {
return apiFetch(`/lessons/${lessonId}/collaborative-canvas`);
},
async updateLessonCollaborativeCanvas(
lessonId: string,
canvasState: CollaborativeCanvasState,
expectedRevision?: number
): Promise<CollaborativeCanvasUpdateResult> {
return apiFetch(`/lessons/${lessonId}/collaborative-canvas`, {
method: 'PUT',
body: JSON.stringify({
canvas_state: canvasState,
expected_revision: expectedRevision,
})
});
},
async register(payload: AuthPayload): Promise<AuthResponse> {
return apiFetch('/auth/register', {
method: 'POST',
@@ -1278,5 +1333,10 @@ export const lmsApi = {
},
async verifyCertificate(code: string): Promise<any> {
return apiFetch(`/certificates/verify/${code}`);
}
},
// Fase 35: Plugins
getEnabledPlugins(): Promise<OrgPlugin[]> {
return apiFetch('/plugins/enabled', {}, true);
},
};