feat: Introduce a VideoMarkerBlock component for interactive video questions, integrating it into the lesson editor and updating API types.

This commit is contained in:
2026-01-19 10:01:05 -03:00
parent 57594ce628
commit 21b2f12485
5 changed files with 267 additions and 17 deletions
+1 -1
View File
@@ -130,7 +130,7 @@
- [x] **Quices de Código**: Desafíos interactivos con reproductor tipo IDE (Completado)
- [x] **Identificación Visual**: Quices de "Puntos Calientes" (Hotspots) en imágenes (Completado)
- [ ] **Tutor de IA Integrado**: Asistente basado en RAG dentro del reproductor de lecciones
- [ ] **Evaluaciones por Audio**: Preguntas con respuesta oral para idiomas
- [x] **Evaluaciones por Audio**: Preguntas con respuesta oral para idiomas (Completado)
- [x] **Marcadores de Video**: Preguntas que pausan el video en timestamps específicos (Completado)
## Fase 12: Generador de Cursos "Mágico" con IA ✅
+1 -14
View File
@@ -593,7 +593,6 @@
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -661,7 +660,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -1135,7 +1133,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2168,7 +2165,6 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -2331,7 +2327,6 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3743,7 +3738,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -5079,7 +5073,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -5285,7 +5278,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -5297,7 +5289,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -5316,7 +5307,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
"integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
@@ -5385,8 +5375,7 @@
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"peer": true
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
@@ -6273,7 +6262,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6451,7 +6439,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11,6 +11,7 @@ import MatchingBlock from "@/components/blocks/MatchingBlock";
import OrderingBlock from "@/components/blocks/OrderingBlock";
import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock";
import DocumentBlock from "@/components/blocks/DocumentBlock";
import VideoMarkerBlock from "@/components/blocks/VideoMarkerBlock";
import {
Save,
X,
@@ -154,7 +155,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
}
};
const addBlock = (type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document') => {
const addBlock = (type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker') => {
const newBlock: Block = {
id: Math.random().toString(36).substr(2, 9),
type,
@@ -166,6 +167,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
...(type === 'ordering' && { items: ["Item A", "Item B"] }),
...(type === 'short-answer' && { prompt: "Question?", correctAnswers: ["Answer"] }),
...(type === 'document' && { url: "", title: "" }),
...(type === 'video_marker' && { url: "", title: "Video Interactivo", markers: [] }),
};
setBlocks([...blocks, newBlock]);
};
@@ -574,6 +576,15 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'video_marker' && (
<VideoMarkerBlock
title={block.title || ""}
videoUrl={block.url || ""}
markers={block.markers || []}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
</div>
</div>
))}
@@ -597,6 +608,13 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<span className="text-2xl group-hover:scale-110 transition-transform">🎬</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Media</span>
</button>
<button
onClick={() => addBlock('video_marker')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-indigo-500/50 transition-all group w-32"
>
<span className="text-2xl group-hover:scale-110 transition-transform"></span>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Video+Q</span>
</button>
<button
onClick={() => addBlock('quiz')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-blue-500/50 transition-all group w-32"
@@ -0,0 +1,239 @@
"use client";
import { useState } from "react";
import { Clock, Plus, Trash2, Play, AlertCircle } from "lucide-react";
interface VideoMarker {
timestamp: number;
question: string;
options: string[];
correctIndex: number;
}
interface VideoMarkerBlockProps {
title: string;
videoUrl: string;
markers: VideoMarker[];
onChange: (updates: { title?: string; markers?: VideoMarker[] }) => void;
editMode: boolean;
}
export default function VideoMarkerBlock({
title,
videoUrl,
markers,
onChange,
editMode
}: VideoMarkerBlockProps) {
const [currentTime, setCurrentTime] = useState(0);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const parseTime = (timeStr: string): number => {
const parts = timeStr.split(':');
if (parts.length === 2) {
return parseInt(parts[0]) * 60 + parseInt(parts[1]);
}
return 0;
};
const addMarker = () => {
const newMarker: VideoMarker = {
timestamp: currentTime,
question: "Nueva pregunta",
options: ["Opción 1", "Opción 2", "Opción 3", "Opción 4"],
correctIndex: 0
};
onChange({ markers: [...markers, newMarker] });
setEditingIndex(markers.length);
};
const updateMarker = (index: number, updates: Partial<VideoMarker>) => {
const updated = markers.map((m, i) => i === index ? { ...m, ...updates } : m);
onChange({ markers: updated });
};
const deleteMarker = (index: number) => {
onChange({ markers: markers.filter((_, i) => i !== index) });
if (editingIndex === index) setEditingIndex(null);
};
const updateOption = (markerIndex: number, optionIndex: number, value: string) => {
const marker = markers[markerIndex];
const newOptions = [...marker.options];
newOptions[optionIndex] = value;
updateMarker(markerIndex, { options: newOptions });
};
if (!editMode) {
return (
<div className="space-y-4">
<h3 className="text-xs font-black uppercase tracking-widest text-gray-400">
{title || "Video con Marcadores"}
</h3>
<div className="glass-card p-6 border-indigo-500/20 bg-indigo-500/5">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
<Clock size={20} />
</div>
<div>
<p className="text-sm font-bold text-white">Video Interactivo</p>
<p className="text-xs text-gray-500">{markers.length} marcadores configurados</p>
</div>
</div>
<div className="space-y-2">
{markers.map((marker, idx) => (
<div key={idx} className="flex items-center gap-2 text-xs text-gray-400">
<span className="font-mono text-indigo-400">{formatTime(marker.timestamp)}</span>
<span></span>
<span className="truncate">{marker.question}</span>
</div>
))}
</div>
</div>
</div>
);
}
return (
<div className="space-y-6 animate-in fade-in duration-300">
{/* Title Editor */}
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Título del Bloque</label>
<input
type="text"
value={title}
onChange={(e) => onChange({ title: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Ej: Video Tutorial - Introducción"
/>
</div>
{/* Video Preview with Timeline */}
<div className="glass-card p-4 space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<Play size={16} className="text-indigo-400" />
Vista Previa del Video
</h4>
<span className="text-xs font-mono text-gray-500">{formatTime(currentTime)}</span>
</div>
<video
src={videoUrl}
controls
className="w-full rounded-lg"
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
/>
<button
onClick={addMarker}
className="w-full py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg font-bold flex items-center justify-center gap-2 transition-all"
>
<Plus size={16} />
Agregar Marcador en {formatTime(currentTime)}
</button>
</div>
{/* Markers List */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<Clock size={16} className="text-amber-400" />
Marcadores ({markers.length})
</h4>
</div>
{markers.length === 0 && (
<div className="glass-card p-8 text-center border-dashed">
<AlertCircle className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-sm text-gray-500">No hay marcadores configurados.</p>
<p className="text-xs text-gray-600 mt-1">Reproduce el video y haz clic en &quot;Agregar Marcador&quot;</p>
</div>
)}
{markers.map((marker, idx) => (
<div key={idx} className="glass-card p-4 space-y-3 border-l-4 border-indigo-500">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold text-indigo-400 bg-indigo-500/10 px-2 py-1 rounded">
{formatTime(marker.timestamp)}
</span>
<button
onClick={() => setEditingIndex(editingIndex === idx ? null : idx)}
className="text-xs text-gray-500 hover:text-white transition-colors"
>
{editingIndex === idx ? "Colapsar" : "Editar"}
</button>
</div>
<button
onClick={() => deleteMarker(idx)}
className="p-2 hover:bg-red-500/10 rounded-lg text-red-500 transition-all"
>
<Trash2 size={14} />
</button>
</div>
{editingIndex === idx ? (
<div className="space-y-3 pt-2 border-t border-white/5">
{/* Timestamp Editor */}
<div className="space-y-1">
<label className="text-[10px] font-bold text-gray-600 uppercase tracking-widest">Timestamp (MM:SS)</label>
<input
type="text"
value={formatTime(marker.timestamp)}
onChange={(e) => updateMarker(idx, { timestamp: parseTime(e.target.value) })}
className="w-full bg-black/20 border border-white/10 rounded px-3 py-1.5 text-sm font-mono text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
{/* Question Editor */}
<div className="space-y-1">
<label className="text-[10px] font-bold text-gray-600 uppercase tracking-widest">Pregunta</label>
<input
type="text"
value={marker.question}
onChange={(e) => updateMarker(idx, { question: e.target.value })}
className="w-full bg-black/20 border border-white/10 rounded px-3 py-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
placeholder="¿Qué concepto se explicó?"
/>
</div>
{/* Options Editor */}
<div className="space-y-2">
<label className="text-[10px] font-bold text-gray-600 uppercase tracking-widest">Opciones de Respuesta</label>
{marker.options.map((option, optIdx) => (
<div key={optIdx} className="flex items-center gap-2">
<input
type="radio"
name={`correct-${idx}`}
checked={marker.correctIndex === optIdx}
onChange={() => updateMarker(idx, { correctIndex: optIdx })}
className="w-4 h-4 text-green-500"
/>
<input
type="text"
value={option}
onChange={(e) => updateOption(idx, optIdx, e.target.value)}
className="flex-1 bg-black/20 border border-white/10 rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-green-500"
placeholder={`Opción ${optIdx + 1}`}
/>
</div>
))}
<p className="text-[10px] text-gray-600 italic">Selecciona el radio button de la respuesta correcta</p>
</div>
</div>
) : (
<p className="text-sm text-gray-400 truncate">{marker.question}</p>
)}
</div>
))}
</div>
</div>
);
}
+7 -1
View File
@@ -44,7 +44,7 @@ export interface QuizQuestion {
export interface Block {
id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document';
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker';
title?: string;
content?: string;
url?: string;
@@ -57,6 +57,12 @@ export interface Block {
items?: string[];
prompt?: string;
correctAnswers?: string[];
markers?: {
timestamp: number;
question: string;
options: string[];
correctIndex: number;
}[];
}
export interface Lesson {