feat: Introduce new interactive content blocks including Fill-in-the-Blanks, Short Answer, Ordering, and Matching, with corresponding API, database, and UI integration.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
interface FillInTheBlanksBlockProps {
|
||||
id: string;
|
||||
title?: string;
|
||||
content: string;
|
||||
editMode: boolean;
|
||||
onChange: (updates: { title?: string; content?: string }) => void;
|
||||
}
|
||||
|
||||
export default function FillInTheBlanksBlock({ id, title, content, editMode, onChange }: FillInTheBlanksBlockProps) {
|
||||
const [userAnswers, setUserAnswers] = useState<string[]>([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
// Parse content to find blanks
|
||||
const parsed = useMemo(() => {
|
||||
const parts: { type: 'text' | 'blank'; value?: string; index?: number; answer?: string }[] = [];
|
||||
const answers: string[] = [];
|
||||
let lastIndex = 0;
|
||||
const regex = /\[\[(.*?)\]\]/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
// Push text before the blank
|
||||
parts.push({ type: 'text', value: content.substring(lastIndex, match.index) });
|
||||
// Push the blank
|
||||
const answer = match[1];
|
||||
parts.push({ type: 'blank', index: answers.length, answer });
|
||||
answers.push(answer);
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
// Push remaining text
|
||||
parts.push({ type: 'text', value: content.substring(lastIndex) });
|
||||
|
||||
return { parts, answers };
|
||||
}, [content]);
|
||||
|
||||
const handleReset = () => {
|
||||
setSubmitted(false);
|
||||
setUserAnswers([]);
|
||||
};
|
||||
|
||||
const isCorrect = (index: number) => {
|
||||
return userAnswers[index]?.trim().toLowerCase() === parsed.answers[index]?.trim().toLowerCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8" id={id}>
|
||||
<div className="space-y-2">
|
||||
{editMode ? (
|
||||
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title || ""}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
placeholder="e.g. Fill the gaps, Quote..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">
|
||||
{title || "Fill in the Blanks"}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-6 glass border-white/5 space-y-4">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Text with blanks (use [[answer]])</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => onChange({ content: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl p-4 min-h-[150px] text-lg font-medium focus:outline-none focus:border-blue-500/50 transition-all"
|
||||
placeholder="Example: The [[capital]] of France is [[Paris]]."
|
||||
/>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider">Tip: Surround any word with double brackets to create a blank.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 glass border-white/5 rounded-3xl space-y-8">
|
||||
<div className="text-lg leading-loose text-gray-100">
|
||||
{parsed.parts.map((part, i) => (
|
||||
part.type === 'text' ? (
|
||||
<span key={i}>{part.value}</span>
|
||||
) : (
|
||||
<input
|
||||
key={i}
|
||||
type="text"
|
||||
value={userAnswers[part.index!] || ""}
|
||||
onChange={(e) => {
|
||||
const newAnswers = [...userAnswers];
|
||||
newAnswers[part.index!] = e.target.value;
|
||||
setUserAnswers(newAnswers);
|
||||
}}
|
||||
disabled={submitted}
|
||||
className={`mx-1 px-2 py-0 border-b-2 bg-transparent transition-all focus:outline-none text-center rounded-t-sm ${submitted
|
||||
? (isCorrect(part.index!) ? "border-green-500 text-green-400 bg-green-500/10" : "border-red-500 text-red-100 bg-red-500/10")
|
||||
: "border-blue-500/30 focus:border-blue-500 text-blue-400 focus:bg-blue-500/5"
|
||||
}`}
|
||||
style={{ width: `${Math.max((part.answer?.length || 5) * 12, 60)}px` }}
|
||||
placeholder="..."
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!submitted && parsed.answers.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSubmitted(true)}
|
||||
className="btn-premium w-full py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
|
||||
>
|
||||
Validate Answers
|
||||
</button>
|
||||
)}
|
||||
|
||||
{submitted && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="w-full py-4 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-xl border-white/5"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
interface MatchingPair {
|
||||
left: string;
|
||||
right: string;
|
||||
}
|
||||
|
||||
interface MatchingBlockProps {
|
||||
id: string;
|
||||
title?: string;
|
||||
pairs: MatchingPair[];
|
||||
editMode: boolean;
|
||||
onChange: (updates: { title?: string; pairs?: MatchingPair[] }) => void;
|
||||
}
|
||||
|
||||
export default function MatchingBlock({ id, title, pairs, editMode, onChange }: MatchingBlockProps) {
|
||||
const [selectedLeft, setSelectedLeft] = useState<number | null>(null);
|
||||
const [matches, setMatches] = useState<Record<number, number>>({}); // leftIdx -> rightIdx
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
// Shuffled right items for the game
|
||||
const shuffledRight = useMemo(() => {
|
||||
return pairs
|
||||
.map((p, i) => ({ value: p.right, originalIdx: i }))
|
||||
.sort(() => Math.random() - 0.5);
|
||||
}, [pairs]);
|
||||
|
||||
const handleMatch = (leftIdx: number, rightIdx: number) => {
|
||||
if (submitted) return;
|
||||
setMatches(prev => ({ ...prev, [leftIdx]: rightIdx }));
|
||||
setSelectedLeft(null);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSubmitted(false);
|
||||
setMatches({});
|
||||
setSelectedLeft(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8" id={id}>
|
||||
<div className="space-y-2">
|
||||
{editMode ? (
|
||||
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title || ""}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
placeholder="e.g. Match the concepts..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">
|
||||
{title || "Concept Matching"}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode ? (
|
||||
<div className="space-y-4">
|
||||
{pairs.map((pair, idx) => (
|
||||
<div key={idx} className="flex gap-4 items-center animate-in slide-in-from-left-4 duration-300">
|
||||
<input
|
||||
value={pair.left}
|
||||
onChange={(e) => {
|
||||
const newPairs = [...pairs];
|
||||
newPairs[idx].left = e.target.value;
|
||||
onChange({ pairs: newPairs });
|
||||
}}
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:border-blue-500/50 focus:outline-none"
|
||||
placeholder="Term A"
|
||||
/>
|
||||
<span className="text-gray-500 font-bold">↔</span>
|
||||
<input
|
||||
value={pair.right}
|
||||
onChange={(e) => {
|
||||
const newPairs = [...pairs];
|
||||
newPairs[idx].right = e.target.value;
|
||||
onChange({ pairs: newPairs });
|
||||
}}
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:border-blue-500/50 focus:outline-none"
|
||||
placeholder="Definition B"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newPairs = pairs.filter((_, i) => i !== idx);
|
||||
onChange({ pairs: newPairs });
|
||||
}}
|
||||
className="p-2 text-gray-500 hover:text-red-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onChange({ pairs: [...pairs, { left: "", right: "" }] })}
|
||||
className="w-full py-4 border-dashed border-2 border-white/10 text-gray-400 hover:text-white hover:border-blue-500/30 transition-all font-bold text-xs uppercase tracking-widest rounded-xl"
|
||||
>
|
||||
+ Add Pair
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 p-8 glass border-white/5 rounded-3xl relative">
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Term</label>
|
||||
{pairs.map((pair, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => !submitted && setSelectedLeft(i)}
|
||||
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft === i ? "border-blue-500 bg-blue-500/10 text-white shadow-lg" :
|
||||
matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" :
|
||||
"border-white/5 bg-white/5 text-gray-200 hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
{pair.left}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Definition</label>
|
||||
{shuffledRight.map((item, i) => {
|
||||
const matchedLeftIdx = Object.keys(matches).find(k => matches[parseInt(k)] === item.originalIdx);
|
||||
const isCorrect = submitted && matchedLeftIdx !== undefined && parseInt(matchedLeftIdx) === item.originalIdx;
|
||||
const isWrong = submitted && matchedLeftIdx !== undefined && parseInt(matchedLeftIdx) !== item.originalIdx;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
disabled={selectedLeft === null || submitted}
|
||||
onClick={() => handleMatch(selectedLeft!, item.originalIdx)}
|
||||
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft !== null && matchedLeftIdx === undefined ? "hover:border-blue-500/50 hover:bg-white/5" : ""
|
||||
} ${isCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
|
||||
isWrong ? "border-red-500 bg-red-500/20 text-red-100" :
|
||||
matchedLeftIdx !== undefined ? "border-blue-500/30 bg-blue-500/5 text-blue-400" :
|
||||
"border-white/5 bg-white/5 text-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{item.value}</span>
|
||||
{isCorrect && <span>✅</span>}
|
||||
{isWrong && <span>❌</span>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 pt-8 border-t border-white/5">
|
||||
{!submitted && Object.keys(matches).length === pairs.length && (
|
||||
<button
|
||||
onClick={() => setSubmitted(true)}
|
||||
className="btn-premium w-full py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
|
||||
>
|
||||
Validate Matching
|
||||
</button>
|
||||
)}
|
||||
{submitted && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="w-full py-4 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-xl border-white/5"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
interface OrderingBlockProps {
|
||||
id: string;
|
||||
title?: string;
|
||||
items: string[];
|
||||
editMode: boolean;
|
||||
onChange: (updates: { title?: string; items?: string[] }) => void;
|
||||
}
|
||||
|
||||
export default function OrderingBlock({ id, title, items, editMode, onChange }: OrderingBlockProps) {
|
||||
const [userOrder, setUserOrder] = useState<number[]>([]); // Array of original indices in user-selected order
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
// Shuffled items for the start
|
||||
const shuffledItems = useMemo(() => {
|
||||
return items
|
||||
.map((item, i) => ({ value: item, originalIdx: i }))
|
||||
.sort(() => Math.random() - 0.5);
|
||||
}, [items]);
|
||||
|
||||
const handlePick = (originalIdx: number) => {
|
||||
if (submitted) return;
|
||||
if (userOrder.includes(originalIdx)) {
|
||||
setUserOrder(userOrder.filter(i => i !== originalIdx));
|
||||
} else {
|
||||
setUserOrder([...userOrder, originalIdx]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSubmitted(false);
|
||||
setUserOrder([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8" id={id}>
|
||||
<div className="space-y-2">
|
||||
{editMode ? (
|
||||
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title || ""}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
placeholder="e.g. Sequence of Events..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">
|
||||
{title || "Sequence Ordering"}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest mb-2">Define items in their CORRECT order:</p>
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="flex gap-4 items-center animate-in slide-in-from-left-4 duration-300">
|
||||
<span className="text-blue-500 font-black w-6">{idx + 1}.</span>
|
||||
<input
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const newItems = [...items];
|
||||
newItems[idx] = e.target.value;
|
||||
onChange({ items: newItems });
|
||||
}}
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:border-blue-500/50 focus:outline-none"
|
||||
placeholder={`Step ${idx + 1}`}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={idx === 0}
|
||||
onClick={() => {
|
||||
const newItems = [...items];
|
||||
[newItems[idx], newItems[idx - 1]] = [newItems[idx - 1], newItems[idx]];
|
||||
onChange({ items: newItems });
|
||||
}}
|
||||
className="p-2 text-gray-500 hover:text-white disabled:opacity-20"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
disabled={idx === items.length - 1}
|
||||
onClick={() => {
|
||||
const newItems = [...items];
|
||||
[newItems[idx], newItems[idx + 1]] = [newItems[idx + 1], newItems[idx]];
|
||||
onChange({ items: newItems });
|
||||
}}
|
||||
className="p-2 text-gray-500 hover:text-white disabled:opacity-20"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newItems = items.filter((_, i) => i !== idx);
|
||||
onChange({ items: newItems });
|
||||
}}
|
||||
className="p-2 text-gray-500 hover:text-red-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onChange({ items: [...items, ""] })}
|
||||
className="w-full py-4 border-dashed border-2 border-white/10 text-gray-400 hover:text-white hover:border-blue-500/30 transition-all font-bold text-xs uppercase tracking-widest rounded-xl"
|
||||
>
|
||||
+ Add Step
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8 p-8 glass border-white/5 rounded-3xl">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Available Items</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{shuffledItems.map((item, i) => {
|
||||
const isPicked = userOrder.includes(item.originalIdx);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
disabled={isPicked || submitted}
|
||||
onClick={() => handlePick(item.originalIdx)}
|
||||
className={`px-6 py-3 rounded-full border text-sm font-bold transition-all ${isPicked ? "opacity-20 grayscale border-white/5 bg-white/5" :
|
||||
"border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5"
|
||||
}`}
|
||||
>
|
||||
{item.value}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Your Sequence</label>
|
||||
<div className="space-y-3">
|
||||
{userOrder.length === 0 && <p className="text-xs text-gray-600 italic py-4">Click items to build the sequence...</p>}
|
||||
{userOrder.map((idx, i) => {
|
||||
const isItemCorrect = submitted && idx === i;
|
||||
const isItemWrong = submitted && idx !== i;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => !submitted && handlePick(idx)}
|
||||
className={`flex items-center gap-4 p-4 rounded-xl border text-sm font-bold transition-all cursor-pointer ${isItemCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
|
||||
isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" :
|
||||
"border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10"
|
||||
}`}
|
||||
>
|
||||
<span className="opacity-50 text-xs">{i + 1}.</span>
|
||||
<span className="flex-1">{items[idx]}</span>
|
||||
{!submitted && <span className="text-xs opacity-50">×</span>}
|
||||
{isItemCorrect && <span>✅</span>}
|
||||
{isItemWrong && <span>❌</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 border-t border-white/5">
|
||||
{!submitted && userOrder.length === items.length && (
|
||||
<button
|
||||
onClick={() => setSubmitted(true)}
|
||||
className="btn-premium w-full py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
|
||||
>
|
||||
Validate Sequence
|
||||
</button>
|
||||
)}
|
||||
{submitted && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="w-full py-4 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-xl border-white/5"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ interface QuizQuestion {
|
||||
id: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
correct: number;
|
||||
type?: 'multiple-choice' | 'true-false';
|
||||
correct: number[];
|
||||
type?: 'multiple-choice' | 'true-false' | 'multiple-select';
|
||||
}
|
||||
|
||||
interface QuizBlockProps {
|
||||
@@ -17,11 +17,11 @@ interface QuizBlockProps {
|
||||
questions: QuizQuestion[];
|
||||
};
|
||||
editMode: boolean;
|
||||
onChange: (data: { title?: string; questions?: QuizQuestion[] }) => void;
|
||||
onChange: (data: { title?: string; quiz_data?: { questions: QuizQuestion[] } }) => void;
|
||||
}
|
||||
|
||||
export default function QuizBlock({ id, title, quizData, editMode, onChange }: QuizBlockProps) {
|
||||
const [userAnswers, setUserAnswers] = useState<Record<string, number>>({});
|
||||
const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const questions = quizData.questions || [];
|
||||
@@ -31,21 +31,43 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
question: "New Question?",
|
||||
options: ["Option 1", "Option 2"],
|
||||
correct: 0,
|
||||
correct: [0],
|
||||
type: 'multiple-choice'
|
||||
};
|
||||
onChange({ questions: [...questions, newQuestion] });
|
||||
onChange({ quiz_data: { questions: [...questions, newQuestion] } });
|
||||
};
|
||||
|
||||
const updateQuestion = (index: number, updates: Partial<QuizQuestion>) => {
|
||||
const newQuestions = [...questions];
|
||||
newQuestions[index] = { ...newQuestions[index], ...updates };
|
||||
onChange({ questions: newQuestions });
|
||||
onChange({ quiz_data: { questions: newQuestions } });
|
||||
};
|
||||
|
||||
const handleAnswer = (qId: string, optionIndex: number) => {
|
||||
const toggleCorrectOption = (qIdx: number, optIdx: number, isMulti: boolean) => {
|
||||
const current = questions[qIdx].correct || [];
|
||||
if (isMulti) {
|
||||
const next = current.includes(optIdx)
|
||||
? current.filter(i => i !== optIdx)
|
||||
: [...current, optIdx].sort((a, b) => a - b);
|
||||
updateQuestion(qIdx, { correct: next.length ? next : [0] });
|
||||
} else {
|
||||
updateQuestion(qIdx, { correct: [optIdx] });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnswer = (qId: string, optionIndex: number, isMulti: boolean) => {
|
||||
if (submitted) return;
|
||||
setUserAnswers(prev => ({ ...prev, [qId]: optionIndex }));
|
||||
setUserAnswers(prev => {
|
||||
const current = prev[qId] || [];
|
||||
if (isMulti) {
|
||||
const next = current.includes(optionIndex)
|
||||
? current.filter(i => i !== optionIndex)
|
||||
: [...current, optionIndex].sort((a, b) => a - b);
|
||||
return { ...prev, [qId]: next };
|
||||
} else {
|
||||
return { ...prev, [qId]: [optionIndex] };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -77,13 +99,19 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex bg-white/5 rounded-lg p-1 border border-white/5">
|
||||
<button
|
||||
onClick={() => updateQuestion(idx, { type: 'multiple-choice' })}
|
||||
className={`px-3 py-1.5 text-[9px] uppercase font-black tracking-widest rounded-md transition-all ${q.type !== 'true-false' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||
onClick={() => updateQuestion(idx, { type: 'multiple-choice', correct: [q.correct?.[0] || 0] })}
|
||||
className={`px-3 py-1.5 text-[9px] uppercase font-black tracking-widest rounded-md transition-all ${q.type === 'multiple-choice' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||
>
|
||||
MCQ
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateQuestion(idx, { type: 'true-false', options: ["True", "False"], correct: 0 })}
|
||||
onClick={() => updateQuestion(idx, { type: 'multiple-select', correct: [q.correct?.[0] || 0] })}
|
||||
className={`px-3 py-1.5 text-[9px] uppercase font-black tracking-widest rounded-md transition-all ${q.type === 'multiple-select' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||
>
|
||||
MSQ
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateQuestion(idx, { type: 'true-false', options: ["True", "False"], correct: [0] })}
|
||||
className={`px-3 py-1.5 text-[9px] uppercase font-black tracking-widest rounded-md transition-all ${q.type === 'true-false' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||
>
|
||||
T / F
|
||||
@@ -92,7 +120,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
<button
|
||||
onClick={() => {
|
||||
const newQuestions = questions.filter((_, i) => i !== idx);
|
||||
onChange({ questions: newQuestions });
|
||||
onChange({ quiz_data: { questions: newQuestions } });
|
||||
}}
|
||||
className="p-2 text-gray-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
@@ -113,8 +141,8 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
{["True", "False"].map((opt, oIdx) => (
|
||||
<button
|
||||
key={oIdx}
|
||||
onClick={() => updateQuestion(idx, { correct: oIdx })}
|
||||
className={`flex-1 py-4 rounded-xl border-2 transition-all font-black text-xs uppercase tracking-widest ${q.correct === oIdx ? "border-blue-500 bg-blue-500/10 text-white" : "border-white/5 bg-white/5 text-gray-500"}`}
|
||||
onClick={() => updateQuestion(idx, { correct: [oIdx] })}
|
||||
className={`flex-1 py-4 rounded-xl border-2 transition-all font-black text-xs uppercase tracking-widest ${q.correct?.includes(oIdx) ? "border-blue-500 bg-blue-500/10 text-white" : "border-white/5 bg-white/5 text-gray-500"}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
@@ -124,9 +152,9 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
q.options.map((opt, oIdx) => (
|
||||
<div key={oIdx} className="flex gap-3 items-center group/opt">
|
||||
<input
|
||||
type="radio"
|
||||
checked={q.correct === oIdx}
|
||||
onChange={() => updateQuestion(idx, { correct: oIdx })}
|
||||
type={q.type === 'multiple-select' ? "checkbox" : "radio"}
|
||||
checked={q.correct?.includes(oIdx)}
|
||||
onChange={() => toggleCorrectOption(idx, oIdx, q.type === 'multiple-select')}
|
||||
className="w-5 h-5 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
@@ -143,7 +171,8 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
<button
|
||||
onClick={() => {
|
||||
const newOpts = q.options.filter((_, i) => i !== oIdx);
|
||||
updateQuestion(idx, { options: newOpts, correct: q.correct >= newOpts.length ? 0 : q.correct });
|
||||
const newCorrect = q.correct?.filter(i => i !== oIdx).map(i => i > oIdx ? i - 1 : i);
|
||||
updateQuestion(idx, { options: newOpts, correct: newCorrect?.length ? newCorrect : [0] });
|
||||
}}
|
||||
className="opacity-0 group-hover/opt:opacity-100 p-2 text-gray-500 hover:text-red-400 transition-all"
|
||||
>
|
||||
@@ -179,12 +208,17 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
<h4 className="font-bold text-xl text-gray-100 leading-tight">{q.question}</h4>
|
||||
<div className="grid gap-3">
|
||||
{q.options.map((opt, oIdx) => {
|
||||
const isSelected = userAnswers[q.id] === oIdx;
|
||||
const isCorrect = q.correct === oIdx;
|
||||
const isSelected = userAnswers[q.id]?.includes(oIdx);
|
||||
const isCorrect = q.correct?.includes(oIdx);
|
||||
const isActuallyCorrect = isCorrect && isSelected;
|
||||
const isWrongSelection = !isCorrect && isSelected;
|
||||
const missedCorrect = isCorrect && !isSelected;
|
||||
|
||||
let style = "glass border-white/10 hover:bg-white/5";
|
||||
if (submitted) {
|
||||
if (isCorrect) style = "bg-green-500/20 border-green-500 text-green-400";
|
||||
else if (isSelected && !isCorrect) style = "bg-red-500/20 border-red-500 text-red-100";
|
||||
if (isActuallyCorrect) style = "bg-green-500/20 border-green-500 text-green-400";
|
||||
else if (isWrongSelection) style = "bg-red-500/20 border-red-500 text-red-100";
|
||||
else if (missedCorrect) style = "border-orange-500/50 text-orange-400 animate-pulse";
|
||||
else style = "opacity-50 grayscale border-white/5";
|
||||
} else if (isSelected) {
|
||||
style = "bg-blue-500/20 border-blue-500 text-white shadow-[0_0_20px_rgba(59,130,246,0.2)]";
|
||||
@@ -193,10 +227,15 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
return (
|
||||
<button
|
||||
key={oIdx}
|
||||
onClick={() => handleAnswer(q.id, oIdx)}
|
||||
onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')}
|
||||
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold ${style}`}
|
||||
>
|
||||
{opt}
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{opt}</span>
|
||||
{submitted && isActuallyCorrect && <span>✅</span>}
|
||||
{submitted && isWrongSelection && <span>❌</span>}
|
||||
{submitted && missedCorrect && <span className="text-[10px] uppercase font-black tracking-tighter">Correct Answer</span>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface ShortAnswerBlockProps {
|
||||
id: string;
|
||||
title?: string;
|
||||
prompt: string;
|
||||
correctAnswers: string[];
|
||||
editMode: boolean;
|
||||
onChange: (updates: { title?: string; prompt?: string; correctAnswers?: string[] }) => void;
|
||||
}
|
||||
|
||||
export default function ShortAnswerBlock({ id, title, prompt, correctAnswers, editMode, onChange }: ShortAnswerBlockProps) {
|
||||
const [userAnswer, setUserAnswer] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const handleReset = () => {
|
||||
setSubmitted(false);
|
||||
setUserAnswer("");
|
||||
};
|
||||
|
||||
const isCorrect = correctAnswers.some(ans => ans.trim().toLowerCase() === userAnswer.trim().toLowerCase());
|
||||
|
||||
return (
|
||||
<div className="space-y-8" id={id}>
|
||||
<div className="space-y-2">
|
||||
{editMode ? (
|
||||
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title || ""}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
placeholder="e.g. Critical Thinking, Quick Response..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">
|
||||
{title || "Short Answer"}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode ? (
|
||||
<div className="space-y-6">
|
||||
<div className="p-6 glass border-white/5 space-y-4">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Question Prompt</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => onChange({ prompt: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl p-4 min-h-[100px] text-lg font-medium focus:outline-none focus:border-blue-500/50 transition-all"
|
||||
placeholder="Type the question for the student..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-6 glass border-white/5 space-y-4">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Correct Answers (One per line)</label>
|
||||
<textarea
|
||||
value={correctAnswers ? correctAnswers.join("\n") : ""}
|
||||
onChange={(e) => onChange({ correctAnswers: e.target.value.split("\n").filter(a => a.trim() !== "") })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl p-4 min-h-[100px] text-sm font-medium focus:outline-none focus:border-blue-500/50 transition-all"
|
||||
placeholder="Answer 1 Answer 2 (Alternative)"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider">Validation is case-insensitive. Provide multiple alternatives if necessary.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 glass border-white/5 rounded-3xl space-y-8">
|
||||
<p className="text-xl font-bold text-gray-100">{prompt || "Please enter your answer below:"}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={userAnswer}
|
||||
onChange={(e) => setUserAnswer(e.target.value)}
|
||||
disabled={submitted}
|
||||
className={`w-full bg-white/5 border-2 rounded-2xl px-6 py-4 text-lg transition-all focus:outline-none ${submitted
|
||||
? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100")
|
||||
: "border-white/10 focus:border-blue-500 text-white"
|
||||
}`}
|
||||
placeholder="Type your answer..."
|
||||
/>
|
||||
|
||||
{submitted && !isCorrect && (
|
||||
<div className="p-4 bg-orange-500/10 border border-orange-500/20 rounded-xl">
|
||||
<p className="text-[10px] text-orange-400 uppercase font-black tracking-widest">Suggested Answer(s):</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{correctAnswers && correctAnswers[0]}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!submitted && (
|
||||
<button
|
||||
onClick={() => setSubmitted(true)}
|
||||
disabled={!userAnswer.trim()}
|
||||
className="btn-premium w-full py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 disabled:grayscale"
|
||||
>
|
||||
Submit Answer
|
||||
</button>
|
||||
)}
|
||||
|
||||
{submitted && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="w-full py-4 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-xl border-white/5"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user