Add ability to edit AI generated content
This commit is contained in:
parent
846ba2a69c
commit
bfbba7b5ab
15 changed files with 1089 additions and 10 deletions
273
components/QuizEditor.tsx
Normal file
273
components/QuizEditor.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowLeft, Save, Plus, Play, AlertTriangle } 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 { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface QuizEditorProps {
|
||||
quiz: Quiz;
|
||||
onSave: (quiz: Quiz) => void;
|
||||
onStartGame: (quiz: Quiz) => void;
|
||||
onBack: () => void;
|
||||
sourceQuizId?: string | null;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
export const QuizEditor: React.FC<QuizEditorProps> = ({
|
||||
quiz: initialQuiz,
|
||||
onSave,
|
||||
onStartGame,
|
||||
onBack,
|
||||
sourceQuizId,
|
||||
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 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)
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
<button
|
||||
onClick={() => onSave(quiz)}
|
||||
disabled={isSaving}
|
||||
className="bg-white/20 p-2 rounded-full hover:bg-white/30 transition disabled:opacity-50"
|
||||
title={sourceQuizId ? 'Save changes' : 'Save to library'}
|
||||
>
|
||||
<Save size={24} />
|
||||
</button>
|
||||
</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">
|
||||
<button
|
||||
onClick={() => onStartGame(quiz)}
|
||||
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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue