Add ability to edit AI generated content

This commit is contained in:
Joey Yakimowich-Payne 2026-01-13 23:37:08 -07:00
commit bfbba7b5ab
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
15 changed files with 1089 additions and 10 deletions

273
components/QuizEditor.tsx Normal file
View 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>
);
};