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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user