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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { lmsApi, OrgPlugin, CreatePluginPayload } from "@/lib/api";
|
||||
import {
|
||||
Puzzle,
|
||||
Plus,
|
||||
Trash2,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Modal: Crear plugin
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function CreatePluginModal({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreated: (p: OrgPlugin) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<CreatePluginPayload>({
|
||||
name: "",
|
||||
description: "",
|
||||
component_url: "",
|
||||
icon_url: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.component_url.startsWith("https://")) {
|
||||
setError("La URL del componente debe comenzar con https://");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const plugin = await lmsApi.createPlugin({
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
component_url: form.component_url,
|
||||
icon_url: form.icon_url || undefined,
|
||||
});
|
||||
onCreated(plugin);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Error al crear plugin");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-lg border border-black/10 dark:border-white/10">
|
||||
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Puzzle className="w-5 h-5 text-indigo-500" />
|
||||
Registrar Plugin
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-black/40 dark:text-white/40 hover:text-black dark:hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={(e) => void handleSubmit(e)} className="p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Nombre *</label>
|
||||
<input
|
||||
required
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Mi Plugin Interactivo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Descripción</label>
|
||||
<input
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Descripción breve del plugin"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">URL del Web Component *</label>
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 font-mono"
|
||||
value={form.component_url}
|
||||
onChange={e => setForm(f => ({ ...f, component_url: e.target.value }))}
|
||||
placeholder="https://mi-plugin.ejemplo.com/component"
|
||||
/>
|
||||
<p className="text-xs text-black/40 dark:text-white/40">Solo se permiten URLs HTTPS. El componente se cargará en un iframe sandboxed.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">URL del Icono</label>
|
||||
<input
|
||||
type="url"
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 font-mono"
|
||||
value={form.icon_url}
|
||||
onChange={e => setForm(f => ({ ...f, icon_url: e.target.value }))}
|
||||
placeholder="https://…/icon.svg (opcional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-sm text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg text-sm text-black/60 dark:text-white/60 hover:bg-black/5 dark:hover:bg-white/5 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{saving && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
Registrar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tarjeta de Plugin
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PluginCard({
|
||||
plugin,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
plugin: OrgPlugin;
|
||||
onToggle: (id: string, enabled: boolean) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
const handleToggle = async () => {
|
||||
setToggling(true);
|
||||
await onToggle(plugin.id, !plugin.enabled);
|
||||
setToggling(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`¿Eliminar el plugin "${plugin.name}"? Esta acción no se puede deshacer.`)) return;
|
||||
setDeleting(true);
|
||||
await onDelete(plugin.id);
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-start gap-4 p-4 rounded-xl border transition-all ${plugin.enabled ? "border-black/10 dark:border-white/10 bg-white/60 dark:bg-white/5" : "border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/2 opacity-60"}`}>
|
||||
{/* Icono */}
|
||||
<div className="w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 flex items-center justify-center shrink-0">
|
||||
{plugin.icon_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={plugin.icon_url} alt="" className="w-6 h-6 object-contain" />
|
||||
) : (
|
||||
<Puzzle className="w-5 h-5 text-indigo-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{plugin.name}</span>
|
||||
{plugin.enabled ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">Activo</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-black/5 dark:bg-white/5 text-black/40 dark:text-white/40">Inactivo</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-black/50 dark:text-white/50 mt-0.5">{plugin.description}</p>
|
||||
)}
|
||||
<a
|
||||
href={plugin.component_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-indigo-500 hover:text-indigo-700 dark:hover:text-indigo-300 mt-1 transition-colors font-mono truncate max-w-xs"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||
{plugin.component_url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Acciones */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => void handleToggle()}
|
||||
disabled={toggling}
|
||||
title={plugin.enabled ? "Deshabilitar" : "Habilitar"}
|
||||
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{plugin.enabled
|
||||
? <ToggleRight className="w-5 h-5 text-green-500" />
|
||||
: <ToggleLeft className="w-5 h-5 text-black/30 dark:text-white/30" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleting}
|
||||
title="Eliminar"
|
||||
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-black/30 dark:text-white/30 hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Página principal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PluginsPage() {
|
||||
const [plugins, setPlugins] = useState<OrgPlugin[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await lmsApi.listPlugins();
|
||||
setPlugins(data);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Error al cargar plugins");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const handleToggle = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
const updated = await lmsApi.updatePlugin(id, { enabled });
|
||||
setPlugins(prev => prev.map(p => p.id === id ? updated : p));
|
||||
} catch {
|
||||
setError("Error al actualizar el plugin");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await lmsApi.deletePlugin(id);
|
||||
setPlugins(prev => prev.filter(p => p.id !== id));
|
||||
} catch {
|
||||
setError("Error al eliminar el plugin");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreated = (plugin: OrgPlugin) => {
|
||||
setPlugins(prev => [...prev, plugin]);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const active = plugins.filter(p => p.enabled);
|
||||
const inactive = plugins.filter(p => !p.enabled);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-10 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Puzzle className="w-6 h-6 text-indigo-500" />
|
||||
Ecosistema de Plugins
|
||||
</h1>
|
||||
<p className="text-sm text-black/40 dark:text-white/40 mt-1">
|
||||
Registra y gestiona Web Components externos que se muestran como bloques en las lecciones.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="p-2 rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Registrar Plugin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 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-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vacío */}
|
||||
{!loading && plugins.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-black/30 dark:text-white/30">
|
||||
<Puzzle className="w-12 h-12" />
|
||||
<p className="text-sm text-center">Aún no hay plugins registrados.<br />Haz clic en <strong>Registrar Plugin</strong> para añadir el primero.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugins activos */}
|
||||
{active.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
|
||||
Activos ({active.length})
|
||||
</h2>
|
||||
{active.map(p => (
|
||||
<PluginCard
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
onToggle={(id, en) => void handleToggle(id, en)}
|
||||
onDelete={(id) => void handleDelete(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugins inactivos */}
|
||||
{inactive.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40">
|
||||
Inactivos ({inactive.length})
|
||||
</h2>
|
||||
{inactive.map(p => (
|
||||
<PluginCard
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
onToggle={(id, en) => void handleToggle(id, en)}
|
||||
onDelete={(id) => void handleDelete(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="rounded-xl border border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/2 p-4 text-xs text-black/40 dark:text-white/40 space-y-1">
|
||||
<p className="font-semibold text-black/50 dark:text-white/50">¿Cómo funciona?</p>
|
||||
<p>1. Registra la URL HTTPS del Web Component externo.</p>
|
||||
<p>2. En el Editor de Lecciones añade un bloque de tipo <code className="font-mono bg-black/5 dark:bg-white/5 px-1 rounded">plugin</code> y selecciona el plugin.</p>
|
||||
<p>3. El componente se carga en un <code className="font-mono bg-black/5 dark:bg-white/5 px-1 rounded">iframe sandbox</code> — los scripts externos nunca acceden al DOM de OpenCCB.</p>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<CreatePluginModal onClose={() => setShowModal(false)} onCreated={handleCreated} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,11 @@ export default function BackgroundTasksPage() {
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
<div className="text-[10px] text-gray-400 mt-1 font-medium">{progress}% completo</div>
|
||||
{(task.processed_items > 0 || task.failed_items > 0) && (
|
||||
<div className="text-[10px] text-gray-400 font-medium">
|
||||
✓ {task.processed_items} / ✗ {task.failed_items}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -79,7 +84,21 @@ export default function BackgroundTasksPage() {
|
||||
return <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Queued</span>;
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return <span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Failed</span>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Failed</span>
|
||||
{task.error_message && (
|
||||
<div className="text-[10px] text-red-500 max-w-[180px] leading-tight" title={task.error_message}>
|
||||
{task.error_message.length > 60 ? task.error_message.slice(0, 60) + '…' : task.error_message}
|
||||
</div>
|
||||
)}
|
||||
{task.task_type !== 'lesson_transcription' && (task.processed_items > 0 || task.failed_items > 0) && (
|
||||
<div className="text-[10px] text-gray-400 font-medium">
|
||||
✓ {task.processed_items} ok / ✗ {task.failed_items} error
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'completed':
|
||||
return <span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Completed</span>;
|
||||
case 'idle':
|
||||
@@ -179,7 +198,17 @@ export default function BackgroundTasksPage() {
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{task.task_type === 'lesson_transcription' && (
|
||||
{(task.task_type === 'lesson_transcription' && (task.status === 'queued' || task.status === 'processing')) && (
|
||||
<button
|
||||
onClick={() => handleCancel(task.id)}
|
||||
disabled={actionLoading === task.id}
|
||||
className="inline-flex items-center px-3 py-1.5 border border-red-200 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === task.id ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <XCircle className="w-3 h-3 mr-1" />}
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{(task.task_type === 'zip_rag_import' && (task.status === 'queued' || task.status === 'processing')) && (
|
||||
<button
|
||||
onClick={() => handleCancel(task.id)}
|
||||
disabled={actionLoading === task.id}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
lmsApi,
|
||||
CourseQualityMetrics,
|
||||
CourseDiscriminationReport,
|
||||
CurricularSuggestionsReport,
|
||||
CurricularSuggestion,
|
||||
LessonQualityMetric,
|
||||
QuizDiscriminationItem,
|
||||
} from "@/lib/api";
|
||||
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Star,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers de formato
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function pct(v: number) {
|
||||
return `${(v * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function discriminationLabel(d: number): { label: string; color: string } {
|
||||
if (d >= 0.4) return { label: "Excelente", color: "text-green-600 dark:text-green-400" };
|
||||
if (d >= 0.3) return { label: "Buena", color: "text-blue-600 dark:text-blue-400" };
|
||||
if (d >= 0.2) return { label: "Aceptable", color: "text-yellow-600 dark:text-yellow-400" };
|
||||
return { label: "Revisar", color: "text-red-600 dark:text-red-400" };
|
||||
}
|
||||
|
||||
function severityIcon(severity: string) {
|
||||
switch (severity) {
|
||||
case "high": return <AlertTriangle className="w-4 h-4 text-red-500 shrink-0" />;
|
||||
case "medium": return <Info className="w-4 h-4 text-yellow-500 shrink-0" />;
|
||||
case "positive": return <Star className="w-4 h-4 text-green-500 shrink-0" />;
|
||||
default: return <Info className="w-4 h-4 text-blue-500 shrink-0" />;
|
||||
}
|
||||
}
|
||||
|
||||
function severityBadge(severity: string) {
|
||||
const map: Record<string, string> = {
|
||||
high: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
|
||||
info: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
positive: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
high: "Alta prioridad",
|
||||
medium: "Atención",
|
||||
info: "Información",
|
||||
positive: "Destacado",
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${map[severity] ?? map.info}`}>
|
||||
{labels[severity] ?? severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Barra horizontal proporcional
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Bar({ value, max = 1, colorClass = "bg-indigo-500" }: { value: number; max?: number; colorClass?: string }) {
|
||||
const pctVal = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
return (
|
||||
<div className="w-full h-2 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${colorClass}`} style={{ width: `${pctVal}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tabs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = "quality" | "discrimination" | "suggestions";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "quality", label: "Métricas de Calidad", icon: <BarChart3 className="w-4 h-4" /> },
|
||||
{ id: "discrimination", label: "Índice de Discriminación", icon: <TrendingDown className="w-4 h-4" /> },
|
||||
{ id: "suggestions", label: "Sugerencias Curriculares", icon: <Lightbulb className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sección: Métricas de Calidad
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function QualityPanel({ data }: { data: CourseQualityMetrics }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-black/50 dark:text-white/50">
|
||||
{data.enrolled} alumno{data.enrolled !== 1 ? "s" : ""} inscrito{data.enrolled !== 1 ? "s" : ""} · {data.lessons.length} lección{data.lessons.length !== 1 ? "es" : ""}
|
||||
</p>
|
||||
|
||||
{data.lessons.length === 0 && (
|
||||
<p className="text-sm text-black/40 dark:text-white/40 italic">Sin datos de entregas todavía.</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-separate border-spacing-y-1">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-black/40 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">#</th>
|
||||
<th className="pb-2 pr-4">Lección</th>
|
||||
<th className="pb-2 pr-4">Completitud</th>
|
||||
<th className="pb-2 pr-4">Puntaje Medio</th>
|
||||
<th className="pb-2 pr-4">Fallo</th>
|
||||
<th className="pb-2 pr-4">Intentos Prom.</th>
|
||||
<th className="pb-2">Sin entregar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.lessons.map((l: LessonQualityMetric) => (
|
||||
<tr key={l.lesson_id} className="bg-white/50 dark:bg-white/5 rounded-lg">
|
||||
<td className="py-3 px-3 rounded-l-lg text-black/30 dark:text-white/30 w-8">{l.position}</td>
|
||||
<td className="py-3 pr-4 font-medium max-w-[200px] truncate" title={l.lesson_title}>{l.lesson_title}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<span className="font-mono">{pct(l.completion_rate)}</span>
|
||||
<Bar value={l.completion_rate} colorClass={l.completion_rate < 0.4 ? "bg-red-400" : "bg-indigo-500"} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<span className="font-mono">{pct(l.avg_score)}</span>
|
||||
<Bar value={l.avg_score} colorClass={l.avg_score < 0.5 ? "bg-orange-400" : l.avg_score > 0.9 ? "bg-green-400" : "bg-sky-500"} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono">
|
||||
<span className={l.failure_rate > 0.4 ? "text-red-500 font-semibold" : ""}>{pct(l.failure_rate)}</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono">{l.avg_attempts.toFixed(1)}</td>
|
||||
<td className="py-3 px-3 rounded-r-lg font-mono">
|
||||
<span className={l.abandonment_count > 0 ? "text-yellow-600 dark:text-yellow-400" : "text-black/30 dark:text-white/30"}>{l.abandonment_count}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sección: Índice de Discriminación
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function DiscriminationPanel({ data }: { data: CourseDiscriminationReport }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-black/50 dark:text-white/50">
|
||||
El índice mide si los alumnos con mejor rendimiento global aciertan más esta pregunta. Un índice ≥ 0.4 es excelente.
|
||||
</p>
|
||||
|
||||
{data.items.length === 0 && (
|
||||
<p className="text-sm text-black/40 dark:text-white/40 italic">
|
||||
No hay datos de <code>block_scores</code> en los metadatos de entregas todavía.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-separate border-spacing-y-1">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-black/40 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">Lección</th>
|
||||
<th className="pb-2 pr-4">Bloque</th>
|
||||
<th className="pb-2 pr-4">Índice Discriminación</th>
|
||||
<th className="pb-2 pr-4">Facilidad</th>
|
||||
<th className="pb-2">Muestra</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((item: QuizDiscriminationItem) => {
|
||||
const { label, color } = discriminationLabel(item.discrimination_index);
|
||||
return (
|
||||
<tr key={`${item.lesson_id}-${item.block_id}`} className="bg-white/50 dark:bg-white/5 rounded-lg">
|
||||
<td className="py-3 px-3 rounded-l-lg font-medium max-w-[180px] truncate" title={item.lesson_title}>{item.lesson_title}</td>
|
||||
<td className="py-3 pr-4 font-mono text-xs text-black/50 dark:text-white/50 max-w-[120px] truncate">{item.block_id}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono">{item.discrimination_index.toFixed(2)}</span>
|
||||
<span className={`text-xs font-semibold ${color}`}>{label}</span>
|
||||
</div>
|
||||
<Bar value={item.discrimination_index} max={1} colorClass={item.discrimination_index >= 0.3 ? "bg-green-500" : item.discrimination_index >= 0.2 ? "bg-yellow-400" : "bg-red-400"} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<span className="font-mono">{pct(item.facility_index)}</span>
|
||||
<Bar value={item.facility_index} colorClass="bg-sky-500" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 rounded-r-lg font-mono text-black/40 dark:text-white/40">{item.sample_size}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sección: Sugerencias Curriculares
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SuggestionsPanel({ data }: { data: CurricularSuggestionsReport }) {
|
||||
if (data.suggestions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-black/30 dark:text-white/30">
|
||||
<CheckCircle2 className="w-10 h-10" />
|
||||
<p className="text-sm">No hay sugerencias en este momento. El curso tiene métricas saludables o aún pocos datos.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const high = data.suggestions.filter(s => s.severity === "high");
|
||||
const medium = data.suggestions.filter(s => s.severity === "medium");
|
||||
const others = data.suggestions.filter(s => s.severity !== "high" && s.severity !== "medium");
|
||||
|
||||
const groups = [
|
||||
{ label: "Alta prioridad", items: high },
|
||||
{ label: "Atención", items: medium },
|
||||
{ label: "Otras observaciones", items: others },
|
||||
].filter(g => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{groups.map(group => (
|
||||
<div key={group.label} className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40">{group.label}</h3>
|
||||
{group.items.map((s: CurricularSuggestion, i: number) => (
|
||||
<div key={i} className="flex gap-3 p-4 rounded-xl bg-white/50 dark:bg-white/5 border border-black/5 dark:border-white/5">
|
||||
{severityIcon(s.severity)}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium truncate">{s.lesson_title}</span>
|
||||
{severityBadge(s.severity)}
|
||||
</div>
|
||||
<p className="text-sm text-black/60 dark:text-white/60">{s.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Página principal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PedagogicalAnalyticsPage() {
|
||||
const { id } = useParams() as { id: string };
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("quality");
|
||||
const [quality, setQuality] = useState<CourseQualityMetrics | null>(null);
|
||||
const [discrimination, setDiscrimination] = useState<CourseDiscriminationReport | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<CurricularSuggestionsReport | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [q, d, s] = await Promise.all([
|
||||
lmsApi.getCourseQualityMetrics(id),
|
||||
lmsApi.getCourseDiscriminationIndex(id),
|
||||
lmsApi.getCourseSuggestions(id),
|
||||
]);
|
||||
setQuality(q);
|
||||
setDiscrimination(d);
|
||||
setSuggestions(s);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Error al cargar análisis pedagógico");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
return (
|
||||
<CourseEditorLayout activeTab="pedagogical">
|
||||
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<TrendingUp className="w-6 h-6 text-indigo-500" />
|
||||
Análisis Pedagógico Profundo
|
||||
</h1>
|
||||
<p className="text-sm text-black/40 dark:text-white/40 mt-1">
|
||||
Métricas de calidad, índice de discriminación y sugerencias de mejora curricular.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 p-1 bg-black/5 dark:bg-white/5 rounded-xl w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === tab.id
|
||||
? "bg-white dark:bg-white/10 shadow-sm text-indigo-600 dark:text-indigo-400"
|
||||
: "text-black/50 dark:text-white/50 hover:text-black/80 dark:hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="rounded-2xl border border-black/5 dark:border-white/5 bg-white/30 dark:bg-white/5 p-6">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20 text-black/30 dark:text-white/30">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mr-2" />
|
||||
Cargando análisis...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "quality" && quality && (
|
||||
<QualityPanel data={quality} />
|
||||
)}
|
||||
{!loading && activeTab === "discrimination" && discrimination && (
|
||||
<DiscriminationPanel data={discrimination} />
|
||||
)}
|
||||
{!loading && activeTab === "suggestions" && suggestions && (
|
||||
<SuggestionsPanel data={suggestions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CourseEditorLayout>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,8 @@ type TabKey =
|
||||
| "team"
|
||||
| "peer-reviews"
|
||||
| "students"
|
||||
| "sessions";
|
||||
| "sessions"
|
||||
| "pedagogical";
|
||||
|
||||
interface CourseEditorLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -114,6 +115,7 @@ export default function CourseEditorLayout({
|
||||
icon: TrendingUp,
|
||||
tabs: [
|
||||
{ key: "analytics", label: "Analíticas", icon: BarChart2, href: `/courses/${id}/analytics` },
|
||||
{ key: "pedagogical", label: "Análisis Pedagógico", icon: TrendingUp, href: `/courses/${id}/analytics/pedagogical` },
|
||||
{ key: "settings", label: "Configuración", icon: Settings, href: `/courses/${id}/settings` },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -672,6 +672,47 @@ export interface CourseAnalytics {
|
||||
}[];
|
||||
}
|
||||
|
||||
// Análisis Pedagógico Profundo (Fase 34)
|
||||
export interface LessonQualityMetric {
|
||||
lesson_id: string;
|
||||
lesson_title: string;
|
||||
position: number;
|
||||
completion_rate: number;
|
||||
avg_attempts: number;
|
||||
avg_score: number;
|
||||
failure_rate: number;
|
||||
abandonment_count: number;
|
||||
}
|
||||
export interface CourseQualityMetrics {
|
||||
course_id: string;
|
||||
enrolled: number;
|
||||
lessons: LessonQualityMetric[];
|
||||
}
|
||||
export interface QuizDiscriminationItem {
|
||||
lesson_id: string;
|
||||
lesson_title: string;
|
||||
block_id: string;
|
||||
discrimination_index: number;
|
||||
facility_index: number;
|
||||
sample_size: number;
|
||||
}
|
||||
export interface CourseDiscriminationReport {
|
||||
course_id: string;
|
||||
items: QuizDiscriminationItem[];
|
||||
}
|
||||
export interface CurricularSuggestion {
|
||||
lesson_id: string;
|
||||
lesson_title: string;
|
||||
kind: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
}
|
||||
export interface CurricularSuggestionsReport {
|
||||
course_id: string;
|
||||
suggestions: CurricularSuggestion[];
|
||||
}
|
||||
|
||||
|
||||
export interface CohortData {
|
||||
period: string;
|
||||
count: number;
|
||||
@@ -1769,6 +1810,24 @@ export const lmsApi = {
|
||||
method: 'GET',
|
||||
query: { days, limit }
|
||||
}, true),
|
||||
|
||||
// Análisis Pedagógico Profundo (Fase 34)
|
||||
getCourseQualityMetrics: (courseId: string): Promise<CourseQualityMetrics> =>
|
||||
apiFetch(`/courses/${courseId}/pedagogical/quality-metrics`, {}, true),
|
||||
getCourseDiscriminationIndex: (courseId: string): Promise<CourseDiscriminationReport> =>
|
||||
apiFetch(`/courses/${courseId}/pedagogical/discrimination-index`, {}, true),
|
||||
getCourseSuggestions: (courseId: string): Promise<CurricularSuggestionsReport> =>
|
||||
apiFetch(`/courses/${courseId}/pedagogical/suggestions`, {}, true),
|
||||
|
||||
// Fase 35: Ecosistema de Plugins
|
||||
listPlugins: (): Promise<OrgPlugin[]> =>
|
||||
apiFetch('/plugins', {}, true),
|
||||
createPlugin: (payload: CreatePluginPayload): Promise<OrgPlugin> =>
|
||||
apiFetch('/plugins', { method: 'POST', body: JSON.stringify(payload) }, true),
|
||||
updatePlugin: (id: string, payload: UpdatePluginPayload): Promise<OrgPlugin> =>
|
||||
apiFetch(`/plugins/${id}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
|
||||
deletePlugin: (id: string): Promise<void> =>
|
||||
apiFetch(`/plugins/${id}`, { method: 'DELETE' }, true),
|
||||
};
|
||||
|
||||
export interface Meeting {
|
||||
@@ -1916,6 +1975,35 @@ export interface AiDataEthicsSummaryResponse {
|
||||
events: AiDataEthicsEventItem[];
|
||||
}
|
||||
|
||||
// Fase 35: Ecosistema de Plugins
|
||||
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 CreatePluginPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
component_url: string;
|
||||
icon_url?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
export interface UpdatePluginPayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
component_url?: string;
|
||||
icon_url?: string;
|
||||
config?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LtiDeepLinkingContentItem {
|
||||
type: 'ltiResourceLink';
|
||||
title?: string;
|
||||
@@ -1942,6 +2030,9 @@ export interface BackgroundTask {
|
||||
task_type: 'lesson_transcription' | 'lesson_image' | 'course_image' | 'zip_rag_import';
|
||||
status: 'idle' | 'queued' | 'processing' | 'failed' | 'completed' | 'error';
|
||||
progress: number;
|
||||
processed_items: number;
|
||||
failed_items: number;
|
||||
error_message?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user