kaboot/components/QuizEditor.tsx

372 lines
14 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft, Save, Plus, Play, AlertTriangle, List, Settings } 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, GameConfig, DEFAULT_GAME_CONFIG } from '../types';
import { SortableQuestionCard } from './SortableQuestionCard';
import { QuestionEditModal } from './QuestionEditModal';
import { GameConfigPanel } from './GameConfigPanel';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import { v4 as uuidv4 } from 'uuid';
interface QuizEditorProps {
quiz: Quiz;
onSave: (quiz: Quiz) => void;
onStartGame: (quiz: Quiz, config: GameConfig) => void;
onConfigChange?: (config: GameConfig) => void;
onBack: () => void;
showSaveButton?: boolean;
isSaving?: boolean;
defaultConfig?: GameConfig;
}
export const QuizEditor: React.FC<QuizEditorProps> = ({
quiz: initialQuiz,
onSave,
onStartGame,
onConfigChange,
onBack,
showSaveButton = true,
isSaving,
defaultConfig,
}) => {
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 [config, setConfig] = useState<GameConfig>(
initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG
);
const [hasAppliedDefaultConfig, setHasAppliedDefaultConfig] = useState(!!initialQuiz.config);
const [activeTab, setActiveTab] = useState<'questions' | 'settings'>('questions');
useEffect(() => {
if (!hasAppliedDefaultConfig && defaultConfig && defaultConfig !== DEFAULT_GAME_CONFIG) {
setConfig(defaultConfig);
setHasAppliedDefaultConfig(true);
}
}, [defaultConfig, hasAppliedDefaultConfig]);
useBodyScrollLock(!!showDeleteConfirm);
const handleConfigChange = useCallback((newConfig: GameConfig) => {
setConfig(newConfig);
setHasAppliedDefaultConfig(true);
onConfigChange?.(newConfig);
}, [onConfigChange]);
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 (config.shuffleQuestions) {
questions = questions.sort(() => Math.random() - 0.5);
}
if (config.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, config }, config);
};
return (
<div className="h-screen bg-gray-100 text-gray-900 p-2 md:p-8 flex flex-col items-center overflow-hidden">
<div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white flex-1 min-h-0 flex flex-col">
<div className="bg-theme-primary p-3 md: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-1.5 md: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-xl md:text-3xl font-black text-center w-full max-w-md px-2 md:px-4 py-1 md:py-2 rounded-xl outline-none placeholder:text-white/50"
placeholder="Quiz Title"
/>
) : (
<h1
onClick={() => setTitleEditing(true)}
className="text-xl md:text-3xl font-black cursor-pointer hover:bg-white/10 px-2 md:px-4 py-1 md: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, config })}
disabled={isSaving}
className="bg-white/20 p-1.5 md: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="flex bg-white border-b border-gray-100">
<button
onClick={() => setActiveTab('questions')}
className={`flex-1 py-2 md:py-3 font-bold text-center border-b-2 transition-all ${
activeTab === 'questions'
? 'border-theme-primary text-theme-primary bg-theme-primary/5'
: 'border-transparent text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-center gap-2">
<List size={18} />
<span>Questions</span>
</div>
</button>
<button
onClick={() => setActiveTab('settings')}
className={`flex-1 py-2 md:py-3 font-bold text-center border-b-2 transition-all ${
activeTab === 'settings'
? 'border-theme-primary text-theme-primary bg-theme-primary/5'
: 'border-transparent text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-center gap-2">
<Settings size={18} />
<span>Settings</span>
</div>
</button>
</div>
<div className="p-3 md:p-6 space-y-4 flex-1 min-h-0 overflow-y-auto">
{activeTab === 'questions' ? (
<>
<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 className="space-y-6">
<h2 className="text-lg font-bold text-gray-700">Game Settings</h2>
<GameConfigPanel
config={config}
onChange={handleConfigChange}
questionCount={quiz.questions.length}
compact={false}
/>
</div>
)}
</div>
<div className="p-3 md:p-6 bg-gray-50 border-t-2 border-gray-100 space-y-4">
<button
onClick={handleStartGame}
disabled={!canStartGame}
className="w-full bg-green-500 text-white py-3 md:py-4 rounded-2xl text-lg md: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-2 md: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>
);
};