354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { ArrowLeft, Save, Plus, Play, AlertTriangle, Shuffle } from 'lucide-react';
|
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
|
|
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
import { Quiz, Question } from '../types';
|
|
import { SortableQuestionCard } from './SortableQuestionCard';
|
|
import { QuestionEditModal } from './QuestionEditModal';
|
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
interface QuizEditorProps {
|
|
quiz: Quiz;
|
|
onSave: (quiz: Quiz) => void;
|
|
onStartGame: (quiz: Quiz) => void;
|
|
onBack: () => void;
|
|
showSaveButton?: boolean;
|
|
isSaving?: boolean;
|
|
}
|
|
|
|
export const QuizEditor: React.FC<QuizEditorProps> = ({
|
|
quiz: initialQuiz,
|
|
onSave,
|
|
onStartGame,
|
|
onBack,
|
|
showSaveButton = true,
|
|
isSaving
|
|
}) => {
|
|
const [quiz, setQuiz] = useState<Quiz>(initialQuiz);
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
|
const [titleEditing, setTitleEditing] = useState(false);
|
|
const [shuffleQuestions, setShuffleQuestions] = useState(false);
|
|
const [shuffleAnswers, setShuffleAnswers] = useState(false);
|
|
|
|
useBodyScrollLock(!!showDeleteConfirm);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 8 }
|
|
}),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates
|
|
})
|
|
);
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
|
|
if (over && active.id !== over.id) {
|
|
setQuiz(prev => {
|
|
const oldIndex = prev.questions.findIndex(q => q.id === active.id);
|
|
const newIndex = prev.questions.findIndex(q => q.id === over.id);
|
|
return {
|
|
...prev,
|
|
questions: arrayMove(prev.questions, oldIndex, newIndex)
|
|
};
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleTitleChange = (newTitle: string) => {
|
|
setQuiz(prev => ({ ...prev, title: newTitle }));
|
|
};
|
|
|
|
const handleQuestionUpdate = (updated: Question) => {
|
|
setQuiz(prev => ({
|
|
...prev,
|
|
questions: prev.questions.map(q => q.id === updated.id ? updated : q)
|
|
}));
|
|
setEditingQuestion(null);
|
|
};
|
|
|
|
const handleQuestionDelete = (id: string) => {
|
|
if (quiz.questions.length <= 1) {
|
|
return;
|
|
}
|
|
setQuiz(prev => ({
|
|
...prev,
|
|
questions: prev.questions.filter(q => q.id !== id)
|
|
}));
|
|
setShowDeleteConfirm(null);
|
|
};
|
|
|
|
const handleAddQuestion = () => {
|
|
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
|
|
const colors = ['red', 'blue', 'yellow', 'green'] as const;
|
|
|
|
const newQuestion: Question = {
|
|
id: uuidv4(),
|
|
text: '',
|
|
options: shapes.map((shape, idx) => ({
|
|
text: '',
|
|
isCorrect: idx === 0,
|
|
shape,
|
|
color: colors[idx],
|
|
reason: ''
|
|
})),
|
|
timeLimit: 20
|
|
};
|
|
|
|
setQuiz(prev => ({
|
|
...prev,
|
|
questions: [...prev.questions, newQuestion]
|
|
}));
|
|
setEditingQuestion(newQuestion);
|
|
};
|
|
|
|
const canStartGame = quiz.title.trim() && quiz.questions.length > 0 &&
|
|
quiz.questions.every(q =>
|
|
q.text.trim() &&
|
|
q.options.every(o => o.text.trim()) &&
|
|
q.options.some(o => o.isCorrect)
|
|
);
|
|
|
|
const handleStartGame = () => {
|
|
let questions = [...quiz.questions];
|
|
|
|
if (shuffleQuestions) {
|
|
questions = questions.sort(() => Math.random() - 0.5);
|
|
}
|
|
|
|
if (shuffleAnswers) {
|
|
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
|
|
const colors = ['red', 'blue', 'yellow', 'green'] as const;
|
|
|
|
questions = questions.map(q => {
|
|
const shuffledOptions = [...q.options].sort(() => Math.random() - 0.5);
|
|
return {
|
|
...q,
|
|
options: shuffledOptions.map((opt, idx) => ({
|
|
...opt,
|
|
shape: shapes[idx % 4],
|
|
color: colors[idx % 4]
|
|
}))
|
|
};
|
|
});
|
|
}
|
|
|
|
onStartGame({ ...quiz, questions });
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center">
|
|
<div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white">
|
|
<div className="bg-theme-primary p-6 text-white relative overflow-hidden">
|
|
<div className="relative z-10 flex items-center justify-between gap-4">
|
|
<button
|
|
onClick={onBack}
|
|
className="bg-white/20 p-2 rounded-full hover:bg-white/30 transition"
|
|
>
|
|
<ArrowLeft size={24} />
|
|
</button>
|
|
|
|
<div className="flex-1 text-center">
|
|
{titleEditing ? (
|
|
<input
|
|
type="text"
|
|
value={quiz.title}
|
|
onChange={(e) => handleTitleChange(e.target.value)}
|
|
onBlur={() => setTitleEditing(false)}
|
|
onKeyDown={(e) => e.key === 'Enter' && setTitleEditing(false)}
|
|
autoFocus
|
|
className="bg-white/20 text-white text-2xl md:text-3xl font-black text-center w-full max-w-md px-4 py-2 rounded-xl outline-none placeholder:text-white/50"
|
|
placeholder="Quiz Title"
|
|
/>
|
|
) : (
|
|
<h1
|
|
onClick={() => setTitleEditing(true)}
|
|
className="text-2xl md:text-3xl font-black cursor-pointer hover:bg-white/10 px-4 py-2 rounded-xl transition inline-block"
|
|
>
|
|
{quiz.title || 'Untitled Quiz'}
|
|
</h1>
|
|
)}
|
|
<p className="opacity-80 font-bold text-sm mt-1">
|
|
{quiz.questions.length} question{quiz.questions.length !== 1 ? 's' : ''}
|
|
</p>
|
|
</div>
|
|
|
|
{showSaveButton && (
|
|
<button
|
|
onClick={() => onSave(quiz)}
|
|
disabled={isSaving}
|
|
className="bg-white/20 p-2 rounded-full hover:bg-white/30 transition disabled:opacity-50"
|
|
title="Save to library"
|
|
>
|
|
<Save size={24} />
|
|
</button>
|
|
)}
|
|
{!showSaveButton && <div className="w-10" />}
|
|
</div>
|
|
|
|
<div className="absolute -right-10 -top-10 w-40 h-40 bg-white/10 rounded-full"></div>
|
|
<div className="absolute right-20 bottom-[-50px] w-24 h-24 bg-white/10 rounded-full"></div>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-bold text-gray-700">Questions</h2>
|
|
<button
|
|
onClick={handleAddQuestion}
|
|
className="flex items-center gap-2 px-4 py-2 bg-theme-primary text-white rounded-xl font-bold hover:bg-theme-primary/90 transition shadow-md"
|
|
>
|
|
<Plus size={18} /> Add Question
|
|
</button>
|
|
</div>
|
|
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={quiz.questions.map(q => q.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="space-y-3">
|
|
<AnimatePresence>
|
|
{quiz.questions.map((question, index) => (
|
|
<SortableQuestionCard
|
|
key={question.id}
|
|
question={question}
|
|
index={index}
|
|
isExpanded={expandedId === question.id}
|
|
onToggleExpand={() => setExpandedId(expandedId === question.id ? null : question.id)}
|
|
onEdit={() => setEditingQuestion(question)}
|
|
onDelete={() => quiz.questions.length > 1 ? setShowDeleteConfirm(question.id) : null}
|
|
/>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
|
|
{quiz.questions.length === 0 && (
|
|
<div className="text-center py-12 text-gray-400">
|
|
<p className="font-bold text-lg">No questions yet</p>
|
|
<p className="text-sm">Click "Add Question" to get started</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 space-y-4">
|
|
<div className="space-y-2">
|
|
<label className="flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 cursor-pointer hover:border-theme-primary transition group">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-lg transition ${shuffleQuestions ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
|
|
<Shuffle size={20} />
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-gray-900">Shuffle Questions</p>
|
|
<p className="text-sm text-gray-500">Randomize question order when starting</p>
|
|
</div>
|
|
</div>
|
|
<div className="relative">
|
|
<input
|
|
type="checkbox"
|
|
checked={shuffleQuestions}
|
|
onChange={(e) => setShuffleQuestions(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-theme-primary transition-colors"></div>
|
|
<div className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5"></div>
|
|
</div>
|
|
</label>
|
|
|
|
<label className="flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 cursor-pointer hover:border-theme-primary transition group">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-lg transition ${shuffleAnswers ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
|
|
<Shuffle size={20} />
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-gray-900">Shuffle Answers</p>
|
|
<p className="text-sm text-gray-500">Randomize answer positions for each question</p>
|
|
</div>
|
|
</div>
|
|
<div className="relative">
|
|
<input
|
|
type="checkbox"
|
|
checked={shuffleAnswers}
|
|
onChange={(e) => setShuffleAnswers(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-theme-primary transition-colors"></div>
|
|
<div className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5"></div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleStartGame}
|
|
disabled={!canStartGame}
|
|
className="w-full bg-green-500 text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#16a34a] active:shadow-none active:translate-y-[6px] transition-all hover:bg-green-600 flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:translate-y-0 disabled:active:shadow-[0_6px_0_#16a34a]"
|
|
>
|
|
<Play size={24} fill="currentColor" /> Start Game with {quiz.questions.length} Questions
|
|
</button>
|
|
|
|
{!canStartGame && quiz.questions.length > 0 && (
|
|
<p className="text-center text-amber-600 font-medium text-sm mt-3 flex items-center justify-center gap-2">
|
|
<AlertTriangle size={16} />
|
|
Some questions are incomplete
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{editingQuestion && (
|
|
<QuestionEditModal
|
|
question={editingQuestion}
|
|
onSave={handleQuestionUpdate}
|
|
onClose={() => setEditingQuestion(null)}
|
|
/>
|
|
)}
|
|
|
|
<AnimatePresence>
|
|
{showDeleteConfirm && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
|
onClick={() => setShowDeleteConfirm(null)}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.9, opacity: 0 }}
|
|
className="bg-white rounded-2xl p-6 max-w-sm w-full shadow-xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-xl font-black text-gray-900 mb-2">Delete Question?</h3>
|
|
<p className="text-gray-600 mb-6">This action cannot be undone.</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(null)}
|
|
className="flex-1 py-3 rounded-xl font-bold border-2 border-gray-200 text-gray-600 hover:bg-gray-50 transition"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => handleQuestionDelete(showDeleteConfirm)}
|
|
className="flex-1 py-3 rounded-xl font-bold bg-red-500 text-white hover:bg-red-600 transition"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
};
|