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
|
|
@ -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
156
components/QuestionCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
255
components/QuestionEditModal.tsx
Normal file
255
components/QuestionEditModal.tsx
Normal 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
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>
|
||||
);
|
||||
};
|
||||
54
components/SortableQuestionCard.tsx
Normal file
54
components/SortableQuestionCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue