feat: se aplican varios fix a las pruebas

This commit is contained in:
2026-01-22 13:24:48 -03:00
parent 360cf520e8
commit 957539d201
15 changed files with 899 additions and 26 deletions
@@ -13,6 +13,9 @@ import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock";
import DocumentBlock from "@/components/blocks/DocumentBlock";
import VideoMarkerBlock from "@/components/blocks/VideoMarkerBlock";
import AudioResponseBlock from "@/components/blocks/AudioResponseBlock";
import HotspotBlock from "@/components/blocks/HotspotBlock";
import MemoryBlock from "@/components/blocks/MemoryBlock";
import Modal from "@/components/Modal";
import {
Save,
X,
@@ -42,6 +45,10 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [dueDate, setDueDate] = useState<string>("");
const [importantDateType, setImportantDateType] = useState<string>("");
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
const [aiQuizContext, setAiQuizContext] = useState("");
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
const [editValue, setEditValue] = useState("");
@@ -156,7 +163,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' | 'video_marker' | 'audio-response') => {
const addBlock = (type: Block['type']) => {
const newBlock: Block = {
id: Math.random().toString(36).substr(2, 9),
type,
@@ -170,6 +177,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
...(type === 'document' && { url: "", title: "" }),
...(type === 'video_marker' && { url: "", title: "Video Interactivo", markers: [] }),
...(type === 'audio-response' && { prompt: "Ask a question for the student to record their answer...", keywords: [], timeLimit: 60 }),
...(type === 'hotspot' && { imageUrl: "", description: "Find the following items...", hotspots: [] }),
...(type === 'memory-match' && { pairs: [{ id: "1", left: "Term A", right: "Match A" }] }),
};
setBlocks([...blocks, newBlock]);
};
@@ -206,11 +215,21 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
};
const handleGenerateQuiz = async () => {
setIsAIQuizModalOpen(true);
};
const handleConfirmGenerateQuiz = async (e: React.FormEvent) => {
e.preventDefault();
if (!lesson) return;
setIsAIQuizModalOpen(false);
setIsGeneratingQuiz(true);
try {
const newBlocks = await cmsApi.generateQuiz(lesson.id);
const newBlocks = await cmsApi.generateQuiz(lesson.id, {
context: aiQuizContext,
quiz_type: aiQuizType
});
setBlocks([...blocks, ...newBlocks]);
setAiQuizContext("");
} catch {
alert("Failed to generate quiz.");
} finally {
@@ -598,6 +617,27 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'hotspot' && (
<HotspotBlock
id={block.id}
title={block.title}
description={block.description}
imageUrl={block.imageUrl}
hotspots={block.hotspots || []}
editMode={editMode}
courseId={params.id}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'memory-match' && (
<MemoryBlock
id={block.id}
title={block.title}
pairs={block.pairs || []}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
</div>
</div>
))}
@@ -677,6 +717,20 @@ 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">Audio</span>
</button>
<button
onClick={() => addBlock('hotspot')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-amber-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">Hotspot</span>
</button>
<button
onClick={() => addBlock('memory-match')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-purple-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">Memory</span>
</button>
<div className="w-px h-12 bg-white/5"></div>
@@ -693,6 +747,76 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
</div>
)}
</div>
<Modal
isOpen={isAIQuizModalOpen}
onClose={() => !isGeneratingQuiz && setIsAIQuizModalOpen(false)}
title="AI Quiz Customization"
>
<form onSubmit={handleConfirmGenerateQuiz} className="space-y-6">
<div className="p-4 rounded-xl bg-purple-500/5 border border-purple-500/10">
<p className="text-xs text-purple-300 leading-relaxed font-medium">
Tell the AI what to focus on and what type of questions you prefer.
</p>
</div>
<div>
<label className="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">
Focus / Context
</label>
<textarea
autoFocus
value={aiQuizContext}
onChange={(e) => setAiQuizContext(e.target.value)}
placeholder="e.g. Focus on past tense verbs, or use vocabulary related to travel..."
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-purple-500 transition-all text-white h-24 resize-none"
disabled={isGeneratingQuiz}
/>
</div>
<div>
<label className="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">
Question Type
</label>
<select
value={aiQuizType}
onChange={(e) => setAiQuizType(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-purple-500 transition-all text-white appearance-none font-bold"
disabled={isGeneratingQuiz}
>
<option value="multiple-choice">Multiple Choice</option>
<option value="true-false">True / False</option>
<option value="vocabulary">Vocabulary Focus</option>
<option value="grammar">Grammar Focus</option>
</select>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setIsAIQuizModalOpen(false)}
disabled={isGeneratingQuiz}
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all text-sm font-medium"
>
Cancel
</button>
<button
type="submit"
disabled={isGeneratingQuiz}
className="flex-[2] px-4 py-2.5 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white rounded-lg transition-all shadow-lg shadow-purple-500/20 font-bold text-sm flex items-center justify-center gap-2"
>
{isGeneratingQuiz ? (
<>
<div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
Generating...
</>
) : (
"Generate Quiz"
)}
</button>
</div>
</form>
</Modal>
</div >
);
}
+26 -2
View File
@@ -5,7 +5,7 @@ import { cmsApi, Course, Organization } from "@/lib/api";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
import { useTranslation } from "@/context/I18nContext";
import { Plus, BookOpen, Download, Upload, Sparkles, Wand2 } from "lucide-react";
import { Plus, BookOpen, Download, Upload, Sparkles, Wand2, Trash2 } from "lucide-react";
import OrganizationSelector from "@/components/OrganizationSelector";
import Modal from "@/components/Modal";
@@ -140,6 +140,23 @@ export default function StudioDashboard() {
reader.readAsText(file);
};
const handleDeleteCourse = async (e: React.MouseEvent, courseId: string, title: string) => {
e.preventDefault();
e.stopPropagation();
if (!confirm(t('confirm_delete_course') || `Are you sure you want to delete "${title}"? This action cannot be undone.`)) {
return;
}
try {
await cmsApi.deleteCourse(courseId);
setCourses(prev => prev.filter(c => c.id !== courseId));
} catch (err) {
console.error("Delete failed", err);
alert("Failed to delete course");
}
};
return (
<div className="min-h-screen bg-[#0f1115] text-white p-8">
<div className="max-w-7xl mx-auto">
@@ -201,6 +218,13 @@ export default function StudioDashboard() {
>
<Download size={18} />
</button>
<button
onClick={(e) => handleDeleteCourse(e, course.id, course.title)}
className="p-2 hover:bg-red-500/10 rounded-lg text-gray-500 hover:text-red-400 transition-all"
title="Delete Course"
>
<Trash2 size={18} />
</button>
</div>
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-400 transition-colors">{course.title}</h3>
<p className="text-sm text-gray-400 line-clamp-2">{course.description || "No description provided."}</p>
@@ -321,6 +345,6 @@ export default function StudioDashboard() {
actionLabel="Create Course"
onConfirm={(orgId) => createCourse(orgId)}
/>
</div>
</div >
);
}
@@ -2,9 +2,9 @@
import { useState, useRef } from "react";
import ReactMarkdown from "react-markdown";
import { Bold, Italic, Link as LinkIcon, Image as ImageIcon, FileText, Eye, PenLine } from "lucide-react";
import { Bold, Italic, Link as LinkIcon, Image as ImageIcon, FileText, Eye, PenLine, Sparkles, Wand2, Check, X as CloseIcon } from "lucide-react";
import AssetPickerModal from "../AssetPickerModal";
import { Asset, getImageUrl } from "@/lib/api";
import { Asset, getImageUrl, cmsApi } from "@/lib/api";
interface DescriptionBlockProps {
id: string;
@@ -19,8 +19,30 @@ export default function DescriptionBlock({ id, title, content, editMode, courseI
const [showPreview, setShowPreview] = useState(false);
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const [pickerType, setPickerType] = useState<"image" | "file">("image");
const [isReviewing, setIsReviewing] = useState(false);
const [suggestion, setSuggestion] = useState<{ suggestion: string, comments: string } | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleReviewText = async () => {
if (!content.trim() || isReviewing) return;
setIsReviewing(true);
try {
const data = await cmsApi.reviewText(content);
setSuggestion(data);
} catch (err) {
console.error("Content review failed", err);
alert("Failed to review content");
} finally {
setIsReviewing(false);
}
};
const applySuggestion = () => {
if (!suggestion) return;
onChange({ content: suggestion.suggestion });
setSuggestion(null);
};
const insertMarkdown = (prefix: string, suffix: string = "") => {
const textarea = textareaRef.current;
if (!textarea) return;
@@ -99,6 +121,16 @@ export default function DescriptionBlock({ id, title, content, editMode, courseI
>
<FileText size={14} />
</button>
<div className="w-px h-3 bg-white/10 mx-1" />
<button
onClick={handleReviewText}
disabled={isReviewing}
className={`p-1.5 rounded-md transition-all flex items-center gap-1.5 text-xs font-bold ${isReviewing ? 'bg-indigo-500/20 text-indigo-300 animate-pulse' : 'hover:bg-indigo-500/20 text-indigo-400 hover:text-indigo-300'}`}
title="AI Suggest Improvements"
>
<Sparkles size={14} className={isReviewing ? 'animate-spin' : ''} />
{isReviewing ? 'Analyzing...' : 'AI Suggest'}
</button>
</div>
)}
</div>
@@ -138,6 +170,54 @@ export default function DescriptionBlock({ id, title, content, editMode, courseI
</div>
</div>
)}
{suggestion && (
<div className="bg-indigo-500/10 border border-indigo-500/30 rounded-2xl p-6 space-y-4 animate-in fade-in slide-in-from-top-4 duration-500 shadow-xl shadow-indigo-500/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Wand2 size={16} className="text-indigo-400" />
<span className="text-xs font-black uppercase tracking-widest text-indigo-300">AI Teacher Suggestions</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setSuggestion(null)}
className="p-1.5 hover:bg-white/10 rounded-lg text-gray-400 transition-colors"
>
<CloseIcon size={14} />
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-widest pl-1">Improved Version</span>
<div className="p-4 bg-black/40 rounded-xl border border-white/5 text-sm text-gray-300 leading-relaxed italic">
{suggestion.suggestion}
</div>
</div>
<div className="space-y-3">
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-widest pl-1">Key Changes</span>
<div className="text-xs text-gray-400 leading-relaxed pl-1 whitespace-pre-line">
{suggestion.comments}
</div>
<div className="pt-4 flex gap-3">
<button
onClick={applySuggestion}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold rounded-lg transition-all shadow-lg shadow-indigo-500/20 flex items-center gap-2 group active:scale-95"
>
<Check size={14} className="group-hover:scale-110 transition-transform" />
Apply Changes
</button>
<button
onClick={() => setSuggestion(null)}
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white text-xs font-bold rounded-lg border border-white/10 transition-all active:scale-95"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
)}
</div>
) : (
<div className="prose prose-invert max-w-none prose-p:text-gray-300 prose-p:leading-relaxed prose-p:text-lg prose-headings:text-white prose-a:text-blue-400 prose-img:rounded-xl">
@@ -0,0 +1,251 @@
"use client";
import { useState, useRef } from "react";
import Image from "next/image";
import { Search, MapPin, Plus, Trash2, Image as ImageIcon, Crosshair } from "lucide-react";
import AssetPickerModal from "../AssetPickerModal";
import { Asset, getImageUrl } from "@/lib/api";
interface Hotspot {
id: string;
x: number;
y: number;
radius: number;
label: string;
}
interface HotspotBlockProps {
id: string;
title?: string;
description?: string;
imageUrl?: string;
hotspots?: Hotspot[];
editMode: boolean;
courseId: string;
onChange: (updates: { title?: string; description?: string; imageUrl?: string; hotspots?: Hotspot[] }) => void;
}
export default function HotspotBlock({
id,
title,
description,
imageUrl,
hotspots = [],
editMode,
courseId,
onChange
}: HotspotBlockProps) {
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleImageSelect = (asset: Asset) => {
const url = asset.storage_path.replace('uploads/', '/assets/');
onChange({ imageUrl: url });
setIsAssetPickerOpen(false);
};
const handleImageClick = (e: React.MouseEvent) => {
if (!editMode || !containerRef.current || !imageUrl) return;
const rect = containerRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
const newHotspot: Hotspot = {
id: Math.random().toString(36).substr(2, 9),
x,
y,
radius: 5,
label: "New Hotspot"
};
onChange({ hotspots: [...hotspots, newHotspot] });
};
const updateHotspot = (index: number, updates: Partial<Hotspot>) => {
const newHotspots = [...hotspots];
newHotspots[index] = { ...newHotspots[index], ...updates };
onChange({ hotspots: newHotspots });
};
const removeHotspot = (index: number) => {
const newHotspots = hotspots.filter((_, i) => i !== index);
onChange({ hotspots: newHotspots });
};
if (!editMode) {
return (
<div className="space-y-4" id={id}>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500/20 text-amber-500">
<Search size={20} />
</div>
<div>
<h3 className="text-lg font-bold text-white transition-colors">{title || "Image Hunt"}</h3>
<p className="text-xs text-gray-500 uppercase tracking-widest font-black">{description || "Find the hidden spots!"}</p>
</div>
</div>
<div className="relative aspect-video rounded-2xl overflow-hidden border border-white/5 bg-black/40">
{imageUrl ? (
<Image src={getImageUrl(imageUrl)} alt={title || ""} fill className="object-cover opacity-50" />
) : (
<div className="absolute inset-0 flex items-center justify-center text-gray-600 italic text-sm">No image provided.</div>
)}
<div className="absolute inset-0 flex items-center justify-center backdrop-blur-[2px]">
<div className="text-center space-y-2">
<Crosshair className="w-12 h-12 text-white/20 mx-auto" />
<p className="text-xs font-bold text-white/40 uppercase tracking-widest">Interactive Game Preview (Switch to Student View to Play)</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6" id={id}>
<div className="p-6 glass border-white/5 bg-white/5 space-y-6 rounded-3xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Game Title</label>
<input
type="text"
value={title || ""}
onChange={(e) => onChange({ title: e.target.value })}
placeholder="e.g. Parts of the Body..."
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2 text-sm font-bold focus:border-amber-500/50 focus:outline-none"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Student Instructions</label>
<input
type="text"
value={description || ""}
onChange={(e) => onChange({ description: e.target.value })}
placeholder="e.g. Find and click on the following items..."
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2 text-sm focus:border-amber-500/50 focus:outline-none"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Game Image</label>
{!imageUrl ? (
<button
onClick={() => setIsAssetPickerOpen(true)}
className="w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 hover:border-amber-500/50 hover:bg-amber-500/5 transition-all flex flex-col items-center justify-center gap-2 group"
>
<ImageIcon className="text-gray-600 group-hover:text-amber-500 transition-colors" size={32} />
<span className="text-xs font-bold text-gray-500 uppercase tracking-widest group-hover:text-amber-300">Choose Image</span>
</button>
) : (
<div className="relative aspect-video rounded-2xl overflow-hidden group">
<Image src={getImageUrl(imageUrl)} alt="Hotspot base" fill className="object-cover" />
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
<button onClick={() => setIsAssetPickerOpen(true)} className="p-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-all"><ImageIcon size={18} /></button>
<button onClick={() => onChange({ imageUrl: undefined })} className="p-2 bg-red-500/20 hover:bg-red-500/40 rounded-lg text-red-400 transition-all"><Trash2 size={18} /></button>
</div>
</div>
)}
</div>
</div>
{imageUrl && (
<div className="space-y-4 pt-4 border-t border-white/5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Define Hotspots (Click on the image below)</label>
<span className="text-[10px] font-bold text-amber-500 bg-amber-500/10 px-2 py-1 rounded uppercase tracking-widest">{hotspots.length} Defined</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div
ref={containerRef}
onClick={handleImageClick}
className="relative aspect-video rounded-2xl overflow-hidden border-2 border-white/10 cursor-crosshair shadow-2xl"
>
<Image src={getImageUrl(imageUrl)} alt="Define Hotspots" fill className="object-cover select-none" />
{hotspots.map((h, idx) => (
<div
key={h.id}
className="absolute group/pin"
style={{
left: `${h.x}%`,
top: `${h.y}%`,
transform: 'translate(-50%, -50%)',
}}
>
<div
className="bg-amber-500/30 border-2 border-amber-400 rounded-full flex items-center justify-center relative transition-transform hover:scale-110"
style={{
width: `${h.radius * 2}vw`,
height: `${h.radius * 2}vw`,
maxWidth: '100px',
maxHeight: '100px'
}}
>
<div className="bg-amber-500 rounded-full p-1 text-black shadow-lg">
<MapPin size={12} strokeWidth={3} />
</div>
<div className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-[10px] font-bold text-white whitespace-nowrap opacity-0 group-hover/pin:opacity-100 transition-opacity">
{h.label}
</div>
</div>
</div>
))}
</div>
</div>
<div className="space-y-3 max-h-[400px] overflow-y-auto custom-scrollbar pr-2">
{hotspots.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center p-6 border-2 border-dashed border-white/5 rounded-2xl">
<Plus className="text-gray-700 mb-2" size={24} />
<p className="text-xs text-gray-600 font-bold uppercase tracking-widest">Click on the image to add hotspots</p>
</div>
) : (
hotspots.map((h, idx) => (
<div key={h.id} className="p-4 bg-white/5 border border-white/10 rounded-xl space-y-3 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="flex items-center justify-between">
<span className="text-[10px] font-black text-amber-500 uppercase tracking-widest">Hotspot #{idx + 1}</span>
<button onClick={() => removeHotspot(idx)} className="p-1 hover:bg-red-500/20 text-red-500 rounded transition-colors"><Trash2 size={12} /></button>
</div>
<div className="space-y-2">
<input
type="text"
value={h.label}
onChange={(e) => updateHotspot(idx, { label: e.target.value })}
placeholder="Item name..."
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-xs font-bold focus:border-amber-500/50 focus:outline-none"
/>
<div className="flex items-center gap-3">
<label className="text-[9px] font-black uppercase tracking-widest text-gray-600">Radius</label>
<input
type="range"
min="2"
max="15"
value={h.radius}
onChange={(e) => updateHotspot(idx, { radius: parseInt(e.target.value) })}
className="flex-1 accent-amber-500 h-1 bg-white/10 rounded-lg appearance-none cursor-pointer"
/>
<span className="text-[10px] font-bold text-gray-400 w-6">{h.radius}%</span>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
)}
</div>
<AssetPickerModal
isOpen={isAssetPickerOpen}
onClose={() => setIsAssetPickerOpen(false)}
courseId={courseId}
filterType="image"
onSelect={handleImageSelect}
/>
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { Brain, Plus, Trash2, HelpCircle } from "lucide-react";
interface MatchingPair {
left: string;
right: string;
id?: string;
}
interface MemoryBlockProps {
id: string;
title?: string;
pairs?: MatchingPair[];
editMode: boolean;
onChange: (updates: { title?: string; pairs?: MatchingPair[] }) => void;
}
export default function MemoryBlock({ id, title, pairs = [], editMode, onChange }: MemoryBlockProps) {
const addPair = () => {
const newPair: MatchingPair = {
id: Math.random().toString(36).substr(2, 9),
left: "",
right: ""
};
onChange({ pairs: [...pairs, newPair] });
};
const updatePair = (index: number, updates: Partial<MatchingPair>) => {
const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], ...updates };
onChange({ pairs: newPairs });
};
const removePair = (index: number) => {
const newPairs = pairs.filter((_, i) => i !== index);
onChange({ pairs: newPairs });
};
if (!editMode) {
return (
<div className="space-y-4" id={id}>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white shadow-lg shadow-indigo-500/20">
<Brain size={24} />
</div>
<div>
<h3 className="text-xl font-black tracking-tight text-white uppercase tracking-[0.1em]">{title || "Memory Match"}</h3>
<p className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Brain Training Exercise</p>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{Array.from({ length: Math.min(pairs.length * 2, 8) }).map((_, i) => (
<div key={i} className="h-24 rounded-2xl bg-white/5 border-2 border-dashed border-white/5 flex items-center justify-center">
<HelpCircle className="text-white/5" size={32} />
</div>
))}
</div>
<div className="text-center py-4 bg-indigo-500/5 rounded-2xl border border-indigo-500/10">
<p className="text-[10px] font-bold text-indigo-300 uppercase tracking-widest">Memory Game with {pairs.length} pairs defined</p>
</div>
</div>
);
}
return (
<div className="space-y-6" id={id}>
<div className="p-8 glass border-white/5 bg-white/5 space-y-8 rounded-3xl">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 pl-1">Game Description / Title</label>
<input
type="text"
value={title || ""}
onChange={(e) => onChange({ title: e.target.value })}
placeholder="e.g. Vocabulary Memory Match..."
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm font-bold focus:border-indigo-500/50 focus:outline-none transition-all"
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between pl-1">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Pairs to Match</label>
<span className="text-[10px] font-bold text-indigo-400 bg-indigo-500/10 px-2 py-1 rounded-md uppercase tracking-widest">{pairs.length} Pairs</span>
</div>
<div className="space-y-3">
{pairs.map((pair, idx) => (
<div key={pair.id || idx} className="grid grid-cols-1 sm:grid-cols-9 gap-4 p-4 bg-black/20 rounded-2xl border border-white/5 group animate-in slide-in-from-left-4 duration-300">
<div className="sm:col-span-4 space-y-1">
<span className="text-[9px] font-black uppercase tracking-tight text-gray-600 pl-1">Card A</span>
<input
value={pair.left}
onChange={(e) => updatePair(idx, { left: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm font-bold focus:border-indigo-500/50 focus:outline-none transition-all"
placeholder="Term, Image URL, or Word..."
/>
</div>
<div className="sm:col-span-1 flex items-center justify-center pt-4 sm:pt-0">
<div className="w-8 h-8 rounded-full bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center">
<div className="w-1 h-1 rounded-full bg-indigo-500" />
</div>
</div>
<div className="sm:col-span-3 space-y-1">
<span className="text-[9px] font-black uppercase tracking-tight text-gray-600 pl-1">Card B</span>
<input
value={pair.right}
onChange={(e) => updatePair(idx, { right: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm font-bold focus:border-indigo-500/50 focus:outline-none transition-all"
placeholder="Matching Item..."
/>
</div>
<div className="sm:col-span-1 flex items-end justify-end pb-1 pr-1">
<button
onClick={() => removePair(idx)}
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
<button
onClick={addPair}
className="w-full py-6 border-dashed border-2 border-white/5 text-gray-500 hover:text-indigo-400 hover:border-indigo-500/30 hover:bg-indigo-500/5 transition-all font-black text-[10px] uppercase tracking-widest rounded-3xl flex flex-col items-center gap-2 group"
>
<div className="p-2 rounded-xl bg-white/5 group-hover:bg-indigo-500/20 transition-all">
<Plus size={20} />
</div>
<span>Add New Memory Pair</span>
</button>
</div>
</div>
</div>
</div>
);
}
+15 -3
View File
@@ -44,16 +44,17 @@ export interface QuizQuestion {
export interface Block {
id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response' | 'memory-match';
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response' | 'memory-match' | 'hotspot';
title?: string;
content?: string;
url?: string;
description?: string; // For hotspot or general info
media_type?: 'video' | 'audio';
config?: Record<string, unknown>;
quiz_data?: {
questions: QuizQuestion[];
};
pairs?: { left: string; right: string }[];
pairs?: { left: string; right: string; id?: string }[];
items?: string[];
prompt?: string;
correctAnswers?: string[];
@@ -65,6 +66,14 @@ export interface Block {
options: string[];
correctIndex: number;
}[];
hotspots?: {
id: string;
x: number;
y: number;
radius: number;
label: string;
}[];
imageUrl?: string;
}
export interface Lesson {
@@ -279,7 +288,8 @@ export const cmsApi = {
getLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}`),
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
generateQuiz: (id: string): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST' }),
generateQuiz: (id: string, payload: { context?: string, quiz_type?: string }): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST', body: JSON.stringify(payload) }),
reviewText: (text: string): Promise<{ suggestion: string, comments: string }> => apiFetch('/api/ai/review-text', { method: 'POST', body: JSON.stringify({ text }) }),
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),
reorderModules: (payload: { items: { id: string, position: number }[] }): Promise<void> => apiFetch('/modules/reorder', { method: 'POST', body: JSON.stringify(payload) }),
@@ -301,6 +311,8 @@ export const cmsApi = {
body: JSON.stringify(data)
}),
deleteCourse: (id: string): Promise<void> => apiFetch(`/courses/${id}`, { method: 'DELETE' }),
async generateCourse(prompt: string, targetOrgId?: string): Promise<Course> {
return apiFetch(`/courses/generate`, {
method: 'POST',