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

View file

@ -12,7 +12,7 @@ type GenerateMode = 'topic' | 'document';
interface LandingProps {
onGenerate: (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => void;
onCreateManual: () => void;
onLoadQuiz: (quiz: Quiz) => void;
onLoadQuiz: (quiz: Quiz, quizId?: string) => void;
onJoin: (pin: string, name: string) => void;
isLoading: boolean;
error: string | null;
@ -121,7 +121,7 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
try {
const quiz = await loadQuiz(id);
setLibraryOpen(false);
onLoadQuiz(quiz);
onLoadQuiz(quiz, id);
} catch (err) {
if (err instanceof Error && err.message.includes('redirecting')) {
return;

156
components/QuestionCard.tsx Normal file
View file

@ -0,0 +1,156 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { GripVertical, ChevronDown, ChevronUp, Pencil, Trash2, Check, X, Triangle, Diamond, Circle, Square } from 'lucide-react';
import { Question } from '../types';
interface QuestionCardProps {
question: Question;
index: number;
onEdit: () => void;
onDelete: () => void;
isExpanded: boolean;
onToggleExpand: () => void;
isDragging?: boolean;
dragListeners?: any;
}
const ShapeIcon: React.FC<{ shape: string; className?: string }> = ({ shape, className }) => {
const props = { size: 16, className };
switch (shape) {
case 'triangle': return <Triangle {...props} />;
case 'diamond': return <Diamond {...props} />;
case 'circle': return <Circle {...props} />;
case 'square': return <Square {...props} />;
default: return <Circle {...props} />;
}
};
const colorMap: Record<string, string> = {
red: 'bg-red-500',
blue: 'bg-blue-500',
yellow: 'bg-yellow-500',
green: 'bg-green-500'
};
export const QuestionCard: React.FC<QuestionCardProps> = ({
question,
index,
onEdit,
onDelete,
isExpanded,
onToggleExpand,
isDragging,
dragListeners
}) => {
const correctOption = question.options.find(o => o.isCorrect);
const truncatedText = question.text.length > 60
? question.text.slice(0, 60) + '...'
: question.text;
return (
<motion.div
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className={`bg-white rounded-2xl shadow-md border-2 transition-all ${
isDragging ? 'border-theme-primary shadow-lg scale-[1.02]' : 'border-gray-100'
}`}
>
<div
className="flex items-center gap-3 p-4 cursor-pointer"
onClick={onToggleExpand}
>
<div
className="text-gray-300 hover:text-gray-400 cursor-grab active:cursor-grabbing touch-none"
{...dragListeners}
>
<GripVertical size={20} />
</div>
<div className="flex-shrink-0 w-8 h-8 bg-theme-primary text-white rounded-lg flex items-center justify-center font-black text-sm">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-gray-800 truncate">{truncatedText}</p>
</div>
{correctOption && (
<div className={`flex-shrink-0 px-3 py-1 rounded-full text-white text-xs font-bold flex items-center gap-1.5 ${colorMap[correctOption.color]}`}>
<ShapeIcon shape={correctOption.shape} className="fill-current" />
<span className="hidden sm:inline max-w-[100px] truncate">{correctOption.text}</span>
</div>
)}
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={(e) => { e.stopPropagation(); onEdit(); }}
className="p-2 text-gray-400 hover:text-theme-primary hover:bg-theme-primary/10 rounded-lg transition-colors"
>
<Pencil size={18} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 size={18} />
</button>
<div className="p-2 text-gray-400">
{isExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</div>
</div>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-4 pb-4 pt-0 border-t-2 border-gray-50">
<p className="text-gray-700 font-medium py-3">{question.text}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{question.options.map((option, idx) => (
<div
key={idx}
className={`p-3 rounded-xl border-2 ${
option.isCorrect
? 'border-green-500 bg-green-50'
: 'border-gray-200 bg-gray-50'
}`}
>
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded flex items-center justify-center ${colorMap[option.color]}`}>
<ShapeIcon shape={option.shape} className="text-white fill-white" />
</div>
<span className={`font-bold flex-1 ${option.isCorrect ? 'text-green-700' : 'text-gray-700'}`}>
{option.text}
</span>
{option.isCorrect ? (
<Check size={18} className="text-green-500" />
) : (
<X size={18} className="text-gray-300" />
)}
</div>
{option.reason && (
<p className="text-xs text-gray-500 mt-2 pl-8">{option.reason}</p>
)}
</div>
))}
</div>
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
<span>Time limit: <strong>{question.timeLimit}s</strong></span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};

View file

@ -0,0 +1,255 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { X, Plus, Trash2, Triangle, Diamond, Circle, Square, Clock } from 'lucide-react';
import { Question, AnswerOption } from '../types';
interface QuestionEditModalProps {
question: Question;
onSave: (question: Question) => void;
onClose: () => void;
}
const ShapeIcon: React.FC<{ shape: string; className?: string; size?: number }> = ({ shape, className, size = 20 }) => {
const props = { size, className };
switch (shape) {
case 'triangle': return <Triangle {...props} />;
case 'diamond': return <Diamond {...props} />;
case 'circle': return <Circle {...props} />;
case 'square': return <Square {...props} />;
default: return <Circle {...props} />;
}
};
const colorMap: Record<string, string> = {
red: 'bg-red-500 border-red-500',
blue: 'bg-blue-500 border-blue-500',
yellow: 'bg-yellow-500 border-yellow-500',
green: 'bg-green-500 border-green-500'
};
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
const colors = ['red', 'blue', 'yellow', 'green'] as const;
export const QuestionEditModal: React.FC<QuestionEditModalProps> = ({
question,
onSave,
onClose
}) => {
const [text, setText] = useState(question.text);
const [options, setOptions] = useState<AnswerOption[]>(question.options);
const [timeLimit, setTimeLimit] = useState(question.timeLimit);
const handleOptionTextChange = (index: number, newText: string) => {
setOptions(prev => prev.map((opt, i) =>
i === index ? { ...opt, text: newText } : opt
));
};
const handleReasonChange = (index: number, reason: string) => {
setOptions(prev => prev.map((opt, i) =>
i === index ? { ...opt, reason } : opt
));
};
const handleCorrectChange = (index: number) => {
setOptions(prev => prev.map((opt, i) => ({
...opt,
isCorrect: i === index
})));
};
const handleDeleteOption = (index: number) => {
if (options.length <= 2) return;
const deletedWasCorrect = options[index].isCorrect;
const newOptions = options.filter((_, i) => i !== index);
if (deletedWasCorrect && newOptions.length > 0) {
newOptions[0].isCorrect = true;
}
setOptions(newOptions.map((opt, i) => ({
...opt,
shape: shapes[i % 4],
color: colors[i % 4]
})));
};
const handleAddOption = () => {
if (options.length >= 6) return;
const newIndex = options.length;
setOptions(prev => [...prev, {
text: '',
isCorrect: false,
shape: shapes[newIndex % 4],
color: colors[newIndex % 4],
reason: ''
}]);
};
const handleSave = () => {
if (!text.trim()) return;
if (options.some(o => !o.text.trim())) return;
if (!options.some(o => o.isCorrect)) return;
onSave({
...question,
text: text.trim(),
options,
timeLimit
});
};
const isValid = text.trim() &&
options.every(o => o.text.trim()) &&
options.some(o => o.isCorrect) &&
options.length >= 2;
return (
<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={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-2xl max-w-2xl w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-2xl font-black text-gray-900">Edit Question</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-xl transition"
>
<X size={24} />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1 space-y-6">
<div>
<label className="block text-sm font-bold text-gray-600 mb-2">Question Text</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full p-4 border-2 border-gray-200 rounded-xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none transition-all resize-none font-medium"
rows={3}
placeholder="Enter your question..."
/>
</div>
<div className="flex items-center gap-4">
<label className="text-sm font-bold text-gray-600 flex items-center gap-2">
<Clock size={18} />
Time Limit
</label>
<div className="flex items-center gap-2">
<input
type="range"
min="5"
max="60"
value={timeLimit}
onChange={(e) => setTimeLimit(Number(e.target.value))}
className="w-32 accent-theme-primary"
/>
<span className="font-bold text-theme-primary w-12">{timeLimit}s</span>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-bold text-gray-600">
Options ({options.length}/6) - Select the correct answer
</label>
{options.length < 6 && (
<button
onClick={handleAddOption}
className="text-sm font-bold text-theme-primary hover:underline flex items-center gap-1"
>
<Plus size={16} /> Add Option
</button>
)}
</div>
<div className="space-y-3">
{options.map((option, index) => (
<div
key={index}
className={`p-4 rounded-xl border-2 transition-all ${
option.isCorrect
? 'border-green-500 bg-green-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-start gap-3">
<button
onClick={() => handleCorrectChange(index)}
className={`mt-1 w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 transition-all ${
option.isCorrect
? colorMap[option.color]
: 'bg-gray-200 hover:bg-gray-300'
}`}
>
<ShapeIcon
shape={option.shape}
className={option.isCorrect ? 'text-white fill-white' : 'text-gray-500'}
size={16}
/>
</button>
<div className="flex-1 space-y-2">
<input
type="text"
value={option.text}
onChange={(e) => handleOptionTextChange(index, e.target.value)}
className="w-full p-2 border-2 border-gray-200 rounded-lg focus:border-theme-primary outline-none font-medium"
placeholder={`Option ${index + 1}`}
/>
<input
type="text"
value={option.reason || ''}
onChange={(e) => handleReasonChange(index, e.target.value)}
className="w-full p-2 border-2 border-gray-100 rounded-lg focus:border-theme-primary outline-none text-sm text-gray-600"
placeholder="Explanation (optional)"
/>
</div>
{options.length > 2 && (
<button
onClick={() => handleDeleteOption(index)}
className="mt-1 p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition"
>
<Trash2 size={18} />
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
<div className="p-6 border-t border-gray-100 flex gap-3">
<button
onClick={onClose}
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={handleSave}
disabled={!isValid}
className="flex-1 py-3 rounded-xl font-bold bg-theme-primary text-white hover:bg-theme-primary/90 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Save Changes
</button>
</div>
</motion.div>
</motion.div>
);
};

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>
);
};

View file

@ -0,0 +1,54 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { QuestionCard } from './QuestionCard';
import { Question } from '../types';
interface SortableQuestionCardProps {
question: Question;
index: number;
onEdit: () => void;
onDelete: () => void;
isExpanded: boolean;
onToggleExpand: () => void;
}
export const SortableQuestionCard: React.FC<SortableQuestionCardProps> = ({
question,
index,
onEdit,
onDelete,
isExpanded,
onToggleExpand
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: question.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: 'relative' as const
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<QuestionCard
question={question}
index={index}
onEdit={onEdit}
onDelete={onDelete}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
isDragging={isDragging}
dragListeners={listeners}
/>
</div>
);
};