Compare commits
4 commits
846ba2a69c
...
90fba17a1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
90fba17a1e |
|||
|
683cd039e7 |
|||
|
bc4b0e2df7 |
|||
|
bfbba7b5ab |
26 changed files with 3593 additions and 17 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -38,3 +38,5 @@ authentik/certs/*
|
||||||
# Backend data
|
# Backend data
|
||||||
server/data/
|
server/data/
|
||||||
*.db
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
|
||||||
66
App.tsx
66
App.tsx
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useAuth } from 'react-oidc-context';
|
import { useAuth } from 'react-oidc-context';
|
||||||
import { useGame } from './hooks/useGame';
|
import { useGame } from './hooks/useGame';
|
||||||
import { useQuizLibrary } from './hooks/useQuizLibrary';
|
import { useQuizLibrary } from './hooks/useQuizLibrary';
|
||||||
|
|
@ -10,6 +10,9 @@ import { Podium } from './components/Podium';
|
||||||
import { QuizCreator } from './components/QuizCreator';
|
import { QuizCreator } from './components/QuizCreator';
|
||||||
import { RevealScreen } from './components/RevealScreen';
|
import { RevealScreen } from './components/RevealScreen';
|
||||||
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
|
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
|
||||||
|
import { QuizEditor } from './components/QuizEditor';
|
||||||
|
import { SaveOptionsModal } from './components/SaveOptionsModal';
|
||||||
|
import type { Quiz } from './types';
|
||||||
|
|
||||||
const seededRandom = (seed: number) => {
|
const seededRandom = (seed: number) => {
|
||||||
const x = Math.sin(seed * 9999) * 10000;
|
const x = Math.sin(seed * 9999) * 10000;
|
||||||
|
|
@ -38,7 +41,9 @@ const FloatingShapes = React.memo(() => {
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const { saveQuiz } = useQuizLibrary();
|
const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
|
||||||
|
const [showSaveOptions, setShowSaveOptions] = useState(false);
|
||||||
|
const [pendingEditedQuiz, setPendingEditedQuiz] = useState<Quiz | null>(null);
|
||||||
const {
|
const {
|
||||||
role,
|
role,
|
||||||
gameState,
|
gameState,
|
||||||
|
|
@ -65,7 +70,11 @@ function App() {
|
||||||
currentStreak,
|
currentStreak,
|
||||||
currentPlayerId,
|
currentPlayerId,
|
||||||
pendingQuizToSave,
|
pendingQuizToSave,
|
||||||
dismissSavePrompt
|
dismissSavePrompt,
|
||||||
|
sourceQuizId,
|
||||||
|
updateQuizFromEditor,
|
||||||
|
startGameFromEditor,
|
||||||
|
backFromEditor
|
||||||
} = useGame();
|
} = useGame();
|
||||||
|
|
||||||
const handleSaveQuiz = async () => {
|
const handleSaveQuiz = async () => {
|
||||||
|
|
@ -76,6 +85,36 @@ function App() {
|
||||||
dismissSavePrompt();
|
dismissSavePrompt();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditorSave = async (editedQuiz: Quiz) => {
|
||||||
|
updateQuizFromEditor(editedQuiz);
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
if (sourceQuizId) {
|
||||||
|
// Quiz was loaded from library - show options modal
|
||||||
|
setPendingEditedQuiz(editedQuiz);
|
||||||
|
setShowSaveOptions(true);
|
||||||
|
} else {
|
||||||
|
// New quiz (AI-generated or manual) - save as new
|
||||||
|
const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual';
|
||||||
|
const topic = pendingQuizToSave?.topic || undefined;
|
||||||
|
await saveQuiz(editedQuiz, source, topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOverwriteQuiz = async () => {
|
||||||
|
if (!pendingEditedQuiz || !sourceQuizId) return;
|
||||||
|
await updateQuiz(sourceQuizId, pendingEditedQuiz);
|
||||||
|
setShowSaveOptions(false);
|
||||||
|
setPendingEditedQuiz(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAsNew = async () => {
|
||||||
|
if (!pendingEditedQuiz) return;
|
||||||
|
await saveQuiz(pendingEditedQuiz, 'manual');
|
||||||
|
setShowSaveOptions(false);
|
||||||
|
setPendingEditedQuiz(null);
|
||||||
|
};
|
||||||
|
|
||||||
const currentQ = quiz?.questions[currentQuestionIndex];
|
const currentQ = quiz?.questions[currentQuestionIndex];
|
||||||
|
|
||||||
// Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
|
// Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
|
||||||
|
|
@ -107,6 +146,16 @@ function App() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{gameState === 'EDITING' && quiz ? (
|
||||||
|
<QuizEditor
|
||||||
|
quiz={quiz}
|
||||||
|
onSave={handleEditorSave}
|
||||||
|
onStartGame={startGameFromEditor}
|
||||||
|
onBack={backFromEditor}
|
||||||
|
sourceQuizId={sourceQuizId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{gameState === 'LOBBY' ? (
|
{gameState === 'LOBBY' ? (
|
||||||
<>
|
<>
|
||||||
<Lobby
|
<Lobby
|
||||||
|
|
@ -179,6 +228,17 @@ function App() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SaveOptionsModal
|
||||||
|
isOpen={showSaveOptions}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSaveOptions(false);
|
||||||
|
setPendingEditedQuiz(null);
|
||||||
|
}}
|
||||||
|
onOverwrite={handleOverwriteQuiz}
|
||||||
|
onSaveNew={handleSaveAsNew}
|
||||||
|
isSaving={saving}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ type GenerateMode = 'topic' | 'document';
|
||||||
interface LandingProps {
|
interface LandingProps {
|
||||||
onGenerate: (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => void;
|
onGenerate: (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => void;
|
||||||
onCreateManual: () => void;
|
onCreateManual: () => void;
|
||||||
onLoadQuiz: (quiz: Quiz) => void;
|
onLoadQuiz: (quiz: Quiz, quizId?: string) => void;
|
||||||
onJoin: (pin: string, name: string) => void;
|
onJoin: (pin: string, name: string) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
@ -121,7 +121,7 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
try {
|
try {
|
||||||
const quiz = await loadQuiz(id);
|
const quiz = await loadQuiz(id);
|
||||||
setLibraryOpen(false);
|
setLibraryOpen(false);
|
||||||
onLoadQuiz(quiz);
|
onLoadQuiz(quiz, id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.message.includes('redirecting')) {
|
if (err instanceof Error && err.message.includes('redirecting')) {
|
||||||
return;
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
258
components/QuestionEditModal.tsx
Normal file
258
components/QuestionEditModal.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
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';
|
||||||
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
useBodyScrollLock(true);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
354
components/QuizEditor.tsx
Normal file
354
components/QuizEditor.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ArrowLeft, Save, Plus, Play, AlertTriangle, Shuffle } 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 { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
interface QuizEditorProps {
|
||||||
|
quiz: Quiz;
|
||||||
|
onSave: (quiz: Quiz) => void;
|
||||||
|
onStartGame: (quiz: Quiz) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
showSaveButton?: boolean;
|
||||||
|
isSaving?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuizEditor: React.FC<QuizEditorProps> = ({
|
||||||
|
quiz: initialQuiz,
|
||||||
|
onSave,
|
||||||
|
onStartGame,
|
||||||
|
onBack,
|
||||||
|
showSaveButton = true,
|
||||||
|
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 [shuffleQuestions, setShuffleQuestions] = useState(false);
|
||||||
|
const [shuffleAnswers, setShuffleAnswers] = useState(false);
|
||||||
|
|
||||||
|
useBodyScrollLock(!!showDeleteConfirm);
|
||||||
|
|
||||||
|
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 (shuffleQuestions) {
|
||||||
|
questions = questions.sort(() => Math.random() - 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{showSaveButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => onSave(quiz)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-white/20 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="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 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 cursor-pointer hover:border-theme-primary transition group">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg transition ${shuffleQuestions ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
|
||||||
|
<Shuffle size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-900">Shuffle Questions</p>
|
||||||
|
<p className="text-sm text-gray-500">Randomize question order when starting</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={shuffleQuestions}
|
||||||
|
onChange={(e) => setShuffleQuestions(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-theme-primary transition-colors"></div>
|
||||||
|
<div className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5"></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 cursor-pointer hover:border-theme-primary transition group">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg transition ${shuffleAnswers ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
|
||||||
|
<Shuffle size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-900">Shuffle Answers</p>
|
||||||
|
<p className="text-sm text-gray-500">Randomize answer positions for each question</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={shuffleAnswers}
|
||||||
|
onChange={(e) => setShuffleAnswers(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-theme-primary transition-colors"></div>
|
||||||
|
<div className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5"></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleStartGame}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar } from 'lucide-react';
|
import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar } from 'lucide-react';
|
||||||
import { QuizListItem } from '../types';
|
import { QuizListItem } from '../types';
|
||||||
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
|
||||||
interface QuizLibraryProps {
|
interface QuizLibraryProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -31,6 +32,8 @@ export const QuizLibrary: React.FC<QuizLibraryProps> = ({
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId;
|
const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId;
|
||||||
|
|
||||||
|
useBodyScrollLock(isOpen);
|
||||||
|
|
||||||
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
|
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setConfirmDeleteId(id);
|
setConfirmDeleteId(id);
|
||||||
|
|
|
||||||
86
components/SaveOptionsModal.tsx
Normal file
86
components/SaveOptionsModal.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Save, Copy, X } from 'lucide-react';
|
||||||
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
|
||||||
|
interface SaveOptionsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaveNew: () => void;
|
||||||
|
onOverwrite: () => void;
|
||||||
|
isSaving?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveOptionsModal: React.FC<SaveOptionsModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSaveNew,
|
||||||
|
onOverwrite,
|
||||||
|
isSaving
|
||||||
|
}) => {
|
||||||
|
useBodyScrollLock(isOpen);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
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 p-6 max-w-sm w-full shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-xl font-black text-gray-900">Save Quiz</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
This quiz was loaded from your library. How would you like to save your changes?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={onOverwrite}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="w-full flex items-center gap-3 p-4 rounded-xl border-2 border-theme-primary bg-theme-primary/5 hover:bg-theme-primary/10 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="p-2 bg-theme-primary rounded-lg">
|
||||||
|
<Save size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-bold text-gray-900">Update existing quiz</p>
|
||||||
|
<p className="text-sm text-gray-500">Overwrite the original with your changes</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSaveNew}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="w-full flex items-center gap-3 p-4 rounded-xl border-2 border-gray-200 hover:border-gray-300 hover:bg-gray-50 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="p-2 bg-gray-200 rounded-lg">
|
||||||
|
<Copy size={20} className="text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-bold text-gray-900">Save as new quiz</p>
|
||||||
|
<p className="text-sm text-gray-500">Keep the original and create a copy</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Save, X, Loader2, BrainCircuit } from 'lucide-react';
|
import { Save, X, Loader2, BrainCircuit } from 'lucide-react';
|
||||||
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
|
||||||
interface SaveQuizPromptProps {
|
interface SaveQuizPromptProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -17,6 +18,8 @@ export const SaveQuizPrompt: React.FC<SaveQuizPromptProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useBodyScrollLock(isOpen);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Binary file not shown.
Binary file not shown.
232
docs/QUIZ_EDITOR_PLAN.md
Normal file
232
docs/QUIZ_EDITOR_PLAN.md
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
# Quiz Editor Feature Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add the ability to view and edit quizzes:
|
||||||
|
1. **Post-generation editing** - Review/edit AI-generated quizzes before starting a game
|
||||||
|
2. **Library editing** - Edit saved quizzes from the quiz library
|
||||||
|
|
||||||
|
## Current Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
AI Generation → Lobby (with save prompt) → Start Game
|
||||||
|
Library Load → Lobby → Start Game
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proposed Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
AI Generation → Quiz Editor → Lobby (with save prompt) → Start Game
|
||||||
|
Library Load → Quiz Editor → Lobby → Start Game
|
||||||
|
↓
|
||||||
|
Save changes back to library (if from library)
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. As a host, I want to review AI-generated questions before starting a game so I can remove inappropriate or incorrect questions
|
||||||
|
2. As a host, I want to edit question text and answers to fix mistakes or improve clarity
|
||||||
|
3. As a host, I want to delete questions I don't want to include
|
||||||
|
4. As a host, I want to add new questions to an AI-generated or saved quiz
|
||||||
|
5. As a host, I want to reorder questions
|
||||||
|
6. As a host, I want to edit quizzes from my library and save changes
|
||||||
|
|
||||||
|
## UI Design
|
||||||
|
|
||||||
|
### Quiz Editor Screen
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ ← Back [Save to Library] │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Quiz Title (editable) ✏️ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Questions (12) [+ Add Question] │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ≡ 1. What is the capital of France? ✏️ 🗑️ │ │
|
||||||
|
│ │ ○ London ○ Berlin ● Paris ○ Madrid │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ≡ 2. Which planet is known as the Red Planet? ✏️ 🗑️│ │
|
||||||
|
│ │ ○ Venus ● Mars ○ Jupiter ○ Saturn │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ... (scrollable list) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ [Start Game with 12 Questions] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question Edit Modal
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Edit Question ✕ │
|
||||||
|
│ │
|
||||||
|
│ Question Text │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ What is the capital of France? │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Time Limit: [20] seconds │
|
||||||
|
│ │
|
||||||
|
│ Options (select correct answer) │
|
||||||
|
│ │
|
||||||
|
│ ○ ┌──────────────────────────────────────────┐ 🗑️ │
|
||||||
|
│ │ London │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
│ Reason: ┌──────────────────────────────────┐ │
|
||||||
|
│ │ London is the capital of the UK │ │
|
||||||
|
│ └──────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ● ┌──────────────────────────────────────────┐ 🗑️ │
|
||||||
|
│ │ Paris │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
│ Reason: ┌──────────────────────────────────┐ │
|
||||||
|
│ │ Paris has been France's capital │ │
|
||||||
|
│ │ since the 10th century │ │
|
||||||
|
│ └──────────────────────────────────┘ │
|
||||||
|
│ ... │
|
||||||
|
│ │
|
||||||
|
│ [+ Add Option] │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────┐ ┌───────────────────────────────────┐ │
|
||||||
|
│ │ Cancel │ │ Save Changes │ │
|
||||||
|
│ └───────────────┘ └───────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Quiz Editor Component
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
- `components/QuizEditor.tsx` - Main editor screen
|
||||||
|
- `components/QuestionCard.tsx` - Collapsible question display card
|
||||||
|
- `components/QuestionEditModal.tsx` - Modal for editing a single question
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Display quiz title (editable inline)
|
||||||
|
- List all questions in collapsible cards
|
||||||
|
- Show question text and options in collapsed view
|
||||||
|
- Expand to see answer reasons
|
||||||
|
- Edit/delete buttons per question
|
||||||
|
- Drag handle for reordering (optional, Phase 2)
|
||||||
|
|
||||||
|
### Phase 2: Question Editing
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Edit question text
|
||||||
|
- Edit option text and reasons
|
||||||
|
- Change correct answer (radio button)
|
||||||
|
- Delete options (minimum 2 required)
|
||||||
|
- Add new options (maximum 6)
|
||||||
|
- Edit time limit per question
|
||||||
|
- Validation before save
|
||||||
|
|
||||||
|
### Phase 3: Add/Delete Questions
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Delete question with confirmation
|
||||||
|
- Add new blank question
|
||||||
|
- Duplicate existing question
|
||||||
|
- Minimum 1 question required to start game
|
||||||
|
|
||||||
|
### Phase 4: Drag-and-Drop Reordering
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- `@dnd-kit/core` and `@dnd-kit/sortable` (already may be available, or add)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Drag handle on each question card
|
||||||
|
- Visual feedback during drag
|
||||||
|
- Reorder questions in the list
|
||||||
|
|
||||||
|
### Phase 5: Integration with Flows
|
||||||
|
|
||||||
|
**Modifications to existing files:**
|
||||||
|
|
||||||
|
1. `App.tsx`
|
||||||
|
- Add `EDITING` game state
|
||||||
|
- Route to QuizEditor after generation or library load
|
||||||
|
- Pass quiz and callbacks to editor
|
||||||
|
|
||||||
|
2. `hooks/useGame.ts`
|
||||||
|
- Add `editQuiz()` function to update quiz state
|
||||||
|
- Add `startGameFromEditor()` to proceed to lobby
|
||||||
|
- Track if quiz came from library (for save-back)
|
||||||
|
|
||||||
|
3. `types.ts`
|
||||||
|
- Add `EDITING` to GameState type
|
||||||
|
|
||||||
|
4. `components/QuizLibrary.tsx`
|
||||||
|
- Change "Load" to open editor instead of going directly to lobby
|
||||||
|
- Or add separate "Edit" button alongside "Load"
|
||||||
|
|
||||||
|
### Phase 6: Save Changes to Library
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- "Save to Library" button in editor
|
||||||
|
- If quiz was loaded from library, offer "Save Changes" (PUT)
|
||||||
|
- If quiz is new (AI-generated), offer "Save as New" (POST)
|
||||||
|
- Show save confirmation toast
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
- Existing `PUT /api/quizzes/:id` endpoint already supports updates
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// New state in useGame or separate useQuizEditor hook
|
||||||
|
interface EditorState {
|
||||||
|
originalQuiz: Quiz | null; // For detecting changes
|
||||||
|
editedQuiz: Quiz; // Current working copy
|
||||||
|
sourceQuizId: string | null; // If loaded from library
|
||||||
|
hasUnsavedChanges: boolean;
|
||||||
|
validationErrors: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Changes Summary
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `components/QuizEditor.tsx` | NEW - Main editor component |
|
||||||
|
| `components/QuestionCard.tsx` | NEW - Question display card |
|
||||||
|
| `components/QuestionEditModal.tsx` | NEW - Edit modal |
|
||||||
|
| `App.tsx` | Add EDITING state routing |
|
||||||
|
| `hooks/useGame.ts` | Add editor-related functions |
|
||||||
|
| `types.ts` | Add EDITING to GameState |
|
||||||
|
| `components/QuizLibrary.tsx` | Add edit button/flow |
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
1. Quiz must have a title
|
||||||
|
2. Quiz must have at least 1 question
|
||||||
|
3. Each question must have text
|
||||||
|
4. Each question must have 2-6 options
|
||||||
|
5. Each question must have exactly 1 correct answer
|
||||||
|
6. Each option must have text
|
||||||
|
7. Time limit must be 5-60 seconds
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
1. **Empty quiz from AI** - Show error, allow retry or manual add
|
||||||
|
2. **Unsaved changes on back** - Confirm dialog "Discard changes?"
|
||||||
|
3. **Delete last question** - Prevent, show error
|
||||||
|
4. **Delete correct answer option** - Auto-select another as correct, or prevent
|
||||||
|
5. **Network error on save** - Show error toast, keep editor open
|
||||||
|
|
||||||
|
## Future Enhancements (Out of Scope)
|
||||||
|
|
||||||
|
- Image support in questions
|
||||||
|
- Bulk import/export (JSON, CSV)
|
||||||
|
- Question bank / templates
|
||||||
|
- Collaborative editing
|
||||||
|
- Version history
|
||||||
|
|
@ -1,4 +1,2 @@
|
||||||
- [ ] All data stored in sqlite db.
|
|
||||||
- [ ] AI generated content based on document upload
|
|
||||||
- [ ] Moderation (kick player, lock game, filter names)
|
- [ ] Moderation (kick player, lock game, filter names)
|
||||||
- [ ] Persistent game urls while game is active
|
- [ ] Persistent game urls while game is active
|
||||||
|
|
|
||||||
12
hooks/useBodyScrollLock.ts
Normal file
12
hooks/useBodyScrollLock.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export const useBodyScrollLock = (isLocked: boolean) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLocked) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isLocked]);
|
||||||
|
};
|
||||||
|
|
@ -24,6 +24,7 @@ export const useGame = () => {
|
||||||
const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
|
const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
|
||||||
const [currentPlayerName, setCurrentPlayerName] = useState<string | null>(null);
|
const [currentPlayerName, setCurrentPlayerName] = useState<string | null>(null);
|
||||||
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
|
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
|
||||||
|
const [sourceQuizId, setSourceQuizId] = useState<string | null>(null);
|
||||||
|
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const peerRef = useRef<Peer | null>(null);
|
const peerRef = useRef<Peer | null>(null);
|
||||||
|
|
@ -85,7 +86,8 @@ export const useGame = () => {
|
||||||
const generatedQuiz = await generateQuiz(generateOptions);
|
const generatedQuiz = await generateQuiz(generateOptions);
|
||||||
const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || '';
|
const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || '';
|
||||||
setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel });
|
setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel });
|
||||||
initializeHostGame(generatedQuiz);
|
setQuiz(generatedQuiz);
|
||||||
|
setGameState('EDITING');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : "Failed to generate quiz.";
|
const message = e instanceof Error ? e.message : "Failed to generate quiz.";
|
||||||
setError(message);
|
setError(message);
|
||||||
|
|
@ -109,8 +111,28 @@ export const useGame = () => {
|
||||||
initializeHostGame(manualQuiz);
|
initializeHostGame(manualQuiz);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSavedQuiz = (savedQuiz: Quiz) => {
|
const loadSavedQuiz = (savedQuiz: Quiz, quizId?: string) => {
|
||||||
initializeHostGame(savedQuiz);
|
setRole('HOST');
|
||||||
|
setQuiz(savedQuiz);
|
||||||
|
setSourceQuizId(quizId || null);
|
||||||
|
setGameState('EDITING');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuizFromEditor = (updatedQuiz: Quiz) => {
|
||||||
|
setQuiz(updatedQuiz);
|
||||||
|
setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const startGameFromEditor = (finalQuiz: Quiz) => {
|
||||||
|
setQuiz(finalQuiz);
|
||||||
|
initializeHostGame(finalQuiz);
|
||||||
|
};
|
||||||
|
|
||||||
|
const backFromEditor = () => {
|
||||||
|
setQuiz(null);
|
||||||
|
setPendingQuizToSave(null);
|
||||||
|
setSourceQuizId(null);
|
||||||
|
setGameState('LANDING');
|
||||||
};
|
};
|
||||||
|
|
||||||
// We use a ref to hold the current handleHostData function
|
// We use a ref to hold the current handleHostData function
|
||||||
|
|
@ -441,7 +463,8 @@ export const useGame = () => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId,
|
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId,
|
||||||
pendingQuizToSave, dismissSavePrompt,
|
pendingQuizToSave, dismissSavePrompt, sourceQuizId,
|
||||||
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard
|
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
|
||||||
|
updateQuizFromEditor, startGameFromEditor, backFromEditor
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -13,6 +13,7 @@ interface UseQuizLibraryReturn {
|
||||||
fetchQuizzes: () => Promise<void>;
|
fetchQuizzes: () => Promise<void>;
|
||||||
loadQuiz: (id: string) => Promise<SavedQuiz>;
|
loadQuiz: (id: string) => Promise<SavedQuiz>;
|
||||||
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
|
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
|
||||||
|
updateQuiz: (id: string, quiz: Quiz) => Promise<void>;
|
||||||
deleteQuiz: (id: string) => Promise<void>;
|
deleteQuiz: (id: string) => Promise<void>;
|
||||||
retry: () => Promise<void>;
|
retry: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
|
@ -160,6 +161,48 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
}
|
}
|
||||||
}, [authFetch]);
|
}, [authFetch]);
|
||||||
|
|
||||||
|
const updateQuiz = useCallback(async (id: string, quiz: Quiz): Promise<void> => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/quizzes/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: quiz.title,
|
||||||
|
questions: quiz.questions.map(q => ({
|
||||||
|
text: q.text,
|
||||||
|
timeLimit: q.timeLimit,
|
||||||
|
options: q.options.map(o => ({
|
||||||
|
text: o.text,
|
||||||
|
isCorrect: o.isCorrect,
|
||||||
|
shape: o.shape,
|
||||||
|
color: o.color,
|
||||||
|
reason: o.reason,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = response.status === 404
|
||||||
|
? 'Quiz not found.'
|
||||||
|
: 'Failed to update quiz.';
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Quiz updated!');
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to update quiz';
|
||||||
|
if (!message.includes('redirecting')) {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [authFetch]);
|
||||||
|
|
||||||
const deleteQuiz = useCallback(async (id: string): Promise<void> => {
|
const deleteQuiz = useCallback(async (id: string): Promise<void> => {
|
||||||
setDeletingQuizId(id);
|
setDeletingQuizId(id);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -209,6 +252,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
fetchQuizzes,
|
fetchQuizzes,
|
||||||
loadQuiz,
|
loadQuiz,
|
||||||
saveQuiz,
|
saveQuiz,
|
||||||
|
updateQuiz,
|
||||||
deleteQuiz,
|
deleteQuiz,
|
||||||
retry,
|
retry,
|
||||||
clearError,
|
clearError,
|
||||||
|
|
|
||||||
1379
package-lock.json
generated
1379
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -6,9 +6,15 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@google/genai": "^1.35.0",
|
"@google/genai": "^1.35.0",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"framer-motion": "^12.26.1",
|
"framer-motion": "^12.26.1",
|
||||||
|
|
@ -23,9 +29,15 @@
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.1",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"@vitest/coverage-v8": "^4.0.17",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -941,6 +941,322 @@ async function runTests() {
|
||||||
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('\nPUT Endpoint Edge Case Tests:');
|
||||||
|
|
||||||
|
await test('PUT /api/quizzes/:id with whitespace-only title returns 400', async () => {
|
||||||
|
const validQuiz = {
|
||||||
|
title: 'Quiz for PUT whitespace test',
|
||||||
|
source: 'manual',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Question?',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
|
||||||
|
const quizId = (data as { id: string }).id;
|
||||||
|
|
||||||
|
const invalidUpdate = {
|
||||||
|
title: ' ',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Q?',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
|
||||||
|
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PUT /api/quizzes/:id with question without text returns 400', async () => {
|
||||||
|
const validQuiz = {
|
||||||
|
title: 'Quiz for PUT empty question test',
|
||||||
|
source: 'manual',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Original question?',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
|
||||||
|
const quizId = (data as { id: string }).id;
|
||||||
|
|
||||||
|
const invalidUpdate = {
|
||||||
|
title: 'Updated title',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: '',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
|
||||||
|
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PUT /api/quizzes/:id with single option returns 400', async () => {
|
||||||
|
const validQuiz = {
|
||||||
|
title: 'Quiz for PUT single option test',
|
||||||
|
source: 'manual',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Original question?',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
|
||||||
|
const quizId = (data as { id: string }).id;
|
||||||
|
|
||||||
|
const invalidUpdate = {
|
||||||
|
title: 'Updated title',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Question with one option?',
|
||||||
|
options: [
|
||||||
|
{ text: 'Only one', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
|
||||||
|
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PUT /api/quizzes/:id with no correct answer returns 400', async () => {
|
||||||
|
const validQuiz = {
|
||||||
|
title: 'Quiz for PUT no correct test',
|
||||||
|
source: 'manual',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Original question?',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
|
||||||
|
const quizId = (data as { id: string }).id;
|
||||||
|
|
||||||
|
const invalidUpdate = {
|
||||||
|
title: 'Updated title',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Question with no correct?',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
|
||||||
|
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PUT /api/quizzes/:id with null questions returns 400', async () => {
|
||||||
|
const validQuiz = {
|
||||||
|
title: 'Quiz for PUT null questions test',
|
||||||
|
source: 'manual',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Original question?',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
|
||||||
|
const quizId = (data as { id: string }).id;
|
||||||
|
|
||||||
|
const invalidUpdate = {
|
||||||
|
title: 'Updated title',
|
||||||
|
questions: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
|
||||||
|
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PUT /api/quizzes/:id preserves source and aiTopic', async () => {
|
||||||
|
const aiQuiz = {
|
||||||
|
title: 'AI Quiz for PUT preserve test',
|
||||||
|
source: 'ai_generated',
|
||||||
|
aiTopic: 'History',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Original question?',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await request('POST', '/api/quizzes', aiQuiz, 201);
|
||||||
|
const quizId = (data as { id: string }).id;
|
||||||
|
|
||||||
|
const update = {
|
||||||
|
title: 'Updated AI Quiz',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'New question?',
|
||||||
|
options: [
|
||||||
|
{ text: 'X', isCorrect: true, shape: 'circle', color: 'yellow' },
|
||||||
|
{ text: 'Y', isCorrect: false, shape: 'square', color: 'green' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await request('PUT', `/api/quizzes/${quizId}`, update);
|
||||||
|
|
||||||
|
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
|
||||||
|
const quiz = getResult as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (quiz.source !== 'ai_generated') throw new Error('Source should be preserved');
|
||||||
|
if (quiz.aiTopic !== 'History') throw new Error('aiTopic should be preserved');
|
||||||
|
if (quiz.title !== 'Updated AI Quiz') throw new Error('Title should be updated');
|
||||||
|
|
||||||
|
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PUT /api/quizzes/:id on another users quiz returns 404', async () => {
|
||||||
|
const quiz = {
|
||||||
|
title: 'User isolation test',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Q?',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await request('PUT', '/api/quizzes/non-existent-user-quiz-id', quiz, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PUT /api/quizzes/:id with many questions succeeds', async () => {
|
||||||
|
const validQuiz = {
|
||||||
|
title: 'Quiz for PUT many questions test',
|
||||||
|
source: 'manual',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Original?',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
|
||||||
|
const quizId = (data as { id: string }).id;
|
||||||
|
|
||||||
|
const manyQuestions = Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
text: `Updated question ${i + 1}?`,
|
||||||
|
timeLimit: 15,
|
||||||
|
options: [
|
||||||
|
{ text: `A${i}`, isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: `B${i}`, isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const update = {
|
||||||
|
title: 'Quiz with 30 questions',
|
||||||
|
questions: manyQuestions,
|
||||||
|
};
|
||||||
|
|
||||||
|
await request('PUT', `/api/quizzes/${quizId}`, update);
|
||||||
|
|
||||||
|
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
|
||||||
|
const quiz = getResult as { questions: unknown[] };
|
||||||
|
if (quiz.questions.length !== 30) {
|
||||||
|
throw new Error(`Expected 30 questions, got ${quiz.questions.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PUT /api/quizzes/:id preserves reason fields in options', async () => {
|
||||||
|
const validQuiz = {
|
||||||
|
title: 'Quiz for PUT reason test',
|
||||||
|
source: 'manual',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Original?',
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
|
||||||
|
const quizId = (data as { id: string }).id;
|
||||||
|
|
||||||
|
const updateWithReasons = {
|
||||||
|
title: 'Quiz with reasons',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: 'Why is the sky blue?',
|
||||||
|
options: [
|
||||||
|
{ text: 'Rayleigh scattering', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Light scatters in atmosphere' },
|
||||||
|
{ text: 'Paint', isCorrect: false, shape: 'diamond', color: 'blue', reason: 'That is not how it works' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await request('PUT', `/api/quizzes/${quizId}`, updateWithReasons);
|
||||||
|
|
||||||
|
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
|
||||||
|
const quiz = getResult as { questions: { options: { reason?: string }[] }[] };
|
||||||
|
|
||||||
|
const correctOpt = quiz.questions[0].options.find((o: any) => o.isCorrect);
|
||||||
|
if (correctOpt?.reason !== 'Light scatters in atmosphere') {
|
||||||
|
throw new Error('Reason not preserved on update');
|
||||||
|
}
|
||||||
|
|
||||||
|
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||||
|
});
|
||||||
|
|
||||||
console.log('\nPhase 6 - Duplicate/Idempotency Tests:');
|
console.log('\nPhase 6 - Duplicate/Idempotency Tests:');
|
||||||
|
|
||||||
await test('POST /api/quizzes with same data creates separate quizzes', async () => {
|
await test('POST /api/quizzes with same data creates separate quizzes', async () => {
|
||||||
|
|
|
||||||
200
tests/components/SaveOptionsModal.test.tsx
Normal file
200
tests/components/SaveOptionsModal.test.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { SaveOptionsModal } from '../../components/SaveOptionsModal';
|
||||||
|
|
||||||
|
describe('SaveOptionsModal', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
isOpen: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSaveNew: vi.fn(),
|
||||||
|
onOverwrite: vi.fn(),
|
||||||
|
isSaving: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders nothing when isOpen is false', () => {
|
||||||
|
render(<SaveOptionsModal {...defaultProps} isOpen={false} />);
|
||||||
|
expect(screen.queryByText('Save Quiz')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders modal when isOpen is true', () => {
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Save Quiz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays explanation text', () => {
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
expect(screen.getByText(/loaded from your library/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows update existing button', () => {
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Update existing quiz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows save as new button', () => {
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Save as new quiz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows close button', () => {
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
const closeButtons = screen.getAllByRole('button');
|
||||||
|
expect(closeButtons.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interactions - happy path', () => {
|
||||||
|
it('calls onOverwrite when update existing is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Update existing quiz'));
|
||||||
|
|
||||||
|
expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSaveNew when save as new is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Save as new quiz'));
|
||||||
|
|
||||||
|
expect(defaultProps.onSaveNew).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when close button (X) is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
|
||||||
|
const closeButton = screen.getByRole('button', { name: '' });
|
||||||
|
await user.click(closeButton);
|
||||||
|
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when backdrop is clicked', async () => {
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
|
||||||
|
const backdrop = document.querySelector('.fixed.inset-0');
|
||||||
|
expect(backdrop).toBeInTheDocument();
|
||||||
|
fireEvent.click(backdrop!);
|
||||||
|
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not close when modal content is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Save Quiz'));
|
||||||
|
|
||||||
|
expect(defaultProps.onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interactions - unhappy path / edge cases', () => {
|
||||||
|
it('disables buttons when isSaving is true', () => {
|
||||||
|
render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
|
||||||
|
|
||||||
|
const updateButton = screen.getByText('Update existing quiz').closest('button');
|
||||||
|
const saveNewButton = screen.getByText('Save as new quiz').closest('button');
|
||||||
|
|
||||||
|
expect(updateButton).toBeDisabled();
|
||||||
|
expect(saveNewButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onOverwrite when disabled and clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
|
||||||
|
|
||||||
|
const button = screen.getByText('Update existing quiz').closest('button')!;
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(defaultProps.onOverwrite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onSaveNew when disabled and clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
|
||||||
|
|
||||||
|
const button = screen.getByText('Save as new quiz').closest('button')!;
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(defaultProps.onSaveNew).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles rapid clicks gracefully', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
|
||||||
|
const button = screen.getByText('Update existing quiz');
|
||||||
|
await user.click(button);
|
||||||
|
await user.click(button);
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remains functional after re-opening', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { rerender } = render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Update existing quiz'));
|
||||||
|
expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender(<SaveOptionsModal {...defaultProps} isOpen={false} />);
|
||||||
|
expect(screen.queryByText('Save Quiz')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<SaveOptionsModal {...defaultProps} isOpen={true} />);
|
||||||
|
await user.click(screen.getByText('Save as new quiz'));
|
||||||
|
expect(defaultProps.onSaveNew).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('all interactive elements are focusable', () => {
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
buttons.forEach(button => {
|
||||||
|
expect(button).not.toHaveAttribute('tabindex', '-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buttons have descriptive text for screen readers', () => {
|
||||||
|
render(<SaveOptionsModal {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Overwrite the original with your changes')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Keep the original and create a copy')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('state transitions', () => {
|
||||||
|
it('transitions from not saving to saving correctly', async () => {
|
||||||
|
const { rerender } = render(<SaveOptionsModal {...defaultProps} isSaving={false} />);
|
||||||
|
|
||||||
|
const updateButton = screen.getByText('Update existing quiz').closest('button');
|
||||||
|
expect(updateButton).not.toBeDisabled();
|
||||||
|
|
||||||
|
rerender(<SaveOptionsModal {...defaultProps} isSaving={true} />);
|
||||||
|
expect(updateButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions from saving back to not saving', async () => {
|
||||||
|
const { rerender } = render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
|
||||||
|
|
||||||
|
const updateButton = screen.getByText('Update existing quiz').closest('button');
|
||||||
|
expect(updateButton).toBeDisabled();
|
||||||
|
|
||||||
|
rerender(<SaveOptionsModal {...defaultProps} isSaving={false} />);
|
||||||
|
expect(updateButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
353
tests/hooks/useQuizLibrary.test.tsx
Normal file
353
tests/hooks/useQuizLibrary.test.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { useQuizLibrary } from '../../hooks/useQuizLibrary';
|
||||||
|
import type { Quiz } from '../../types';
|
||||||
|
|
||||||
|
const mockAuthFetch = vi.fn();
|
||||||
|
const mockIsAuthenticated = vi.fn(() => true);
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useAuthenticatedFetch', () => ({
|
||||||
|
useAuthenticatedFetch: () => ({
|
||||||
|
authFetch: mockAuthFetch,
|
||||||
|
isAuthenticated: mockIsAuthenticated(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-hot-toast', () => ({
|
||||||
|
default: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createMockQuiz = (overrides?: Partial<Quiz>): Quiz => ({
|
||||||
|
title: 'Test Quiz',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: 'q1',
|
||||||
|
text: 'What is 2+2?',
|
||||||
|
timeLimit: 20,
|
||||||
|
options: [
|
||||||
|
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
|
||||||
|
{ text: '5', isCorrect: false, shape: 'circle', color: 'yellow' },
|
||||||
|
{ text: '6', isCorrect: false, shape: 'square', color: 'green' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useQuizLibrary - updateQuiz', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockIsAuthenticated.mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('happy path', () => {
|
||||||
|
it('successfully updates a quiz', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ id: 'quiz-123' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({ title: 'Updated Quiz' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.updateQuiz('quiz-123', quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: expect.stringContaining('Updated Quiz'),
|
||||||
|
});
|
||||||
|
expect(result.current.saving).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets saving to true during update', async () => {
|
||||||
|
let resolvePromise: (value: unknown) => void;
|
||||||
|
const pendingPromise = new Promise((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
mockAuthFetch.mockReturnValueOnce(pendingPromise);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.updateQuiz('quiz-123', quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.saving).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolvePromise!({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.saving).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends correct request body structure', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({
|
||||||
|
title: 'My Quiz',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: 'q1',
|
||||||
|
text: 'Question 1',
|
||||||
|
timeLimit: 30,
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Correct!' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.updateQuiz('quiz-456', quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, options] = mockAuthFetch.mock.calls[0];
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
|
||||||
|
expect(body.title).toBe('My Quiz');
|
||||||
|
expect(body.questions).toHaveLength(1);
|
||||||
|
expect(body.questions[0].text).toBe('Question 1');
|
||||||
|
expect(body.questions[0].timeLimit).toBe(30);
|
||||||
|
expect(body.questions[0].options[0].reason).toBe('Correct!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles quiz with multiple questions', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: 'q1',
|
||||||
|
text: 'Q1',
|
||||||
|
timeLimit: 20,
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q2',
|
||||||
|
text: 'Q2',
|
||||||
|
timeLimit: 25,
|
||||||
|
options: [
|
||||||
|
{ text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' },
|
||||||
|
{ text: 'D', isCorrect: true, shape: 'square', color: 'green' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.updateQuiz('quiz-789', quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, options] = mockAuthFetch.mock.calls[0];
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
expect(body.questions).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unhappy path - API errors', () => {
|
||||||
|
it('handles 404 not found error', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.updateQuiz('non-existent', quiz);
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Quiz not found');
|
||||||
|
|
||||||
|
expect(result.current.saving).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles generic server error', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.updateQuiz('quiz-123', quiz);
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Failed to update quiz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles network error', async () => {
|
||||||
|
mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.updateQuiz('quiz-123', quiz);
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Network error');
|
||||||
|
|
||||||
|
expect(result.current.saving).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles timeout/abort error', async () => {
|
||||||
|
const abortError = new Error('The operation was aborted');
|
||||||
|
abortError.name = 'AbortError';
|
||||||
|
mockAuthFetch.mockRejectedValueOnce(abortError);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.updateQuiz('quiz-123', quiz);
|
||||||
|
})
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(result.current.saving).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unhappy path - edge cases', () => {
|
||||||
|
it('resets saving state even on error', async () => {
|
||||||
|
mockAuthFetch.mockRejectedValueOnce(new Error('Server error'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.updateQuiz('quiz-123', quiz);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Expected to throw
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.current.saving).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty quiz ID', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.updateQuiz('', quiz);
|
||||||
|
})
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles quiz with empty title', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({ title: '' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.updateQuiz('quiz-123', quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, options] = mockAuthFetch.mock.calls[0];
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
expect(body.title).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips undefined reason fields to null', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: 'q1',
|
||||||
|
text: 'Q',
|
||||||
|
timeLimit: 20,
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: undefined },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.updateQuiz('quiz-123', quiz);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, options] = mockAuthFetch.mock.calls[0];
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
expect(body.questions[0].options[0]).not.toHaveProperty('reason');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('concurrent operations', () => {
|
||||||
|
it('allows update after save completes', async () => {
|
||||||
|
mockAuthFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ id: 'new-quiz' }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.updateQuiz('new-quiz', { ...quiz, title: 'Updated' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockAuthFetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
20
tests/setup.tsx
Normal file
20
tests/setup.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React from 'react';
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('framer-motion', async () => {
|
||||||
|
const actual = await vi.importActual('framer-motion');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
motion: {
|
||||||
|
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
|
||||||
|
button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
|
||||||
|
},
|
||||||
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": [
|
"types": [
|
||||||
"node",
|
"node",
|
||||||
"vite/client"
|
"vite/client",
|
||||||
|
"vitest/globals"
|
||||||
],
|
],
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
|
|
||||||
1
types.ts
1
types.ts
|
|
@ -2,6 +2,7 @@ export type GameState =
|
||||||
| 'LANDING'
|
| 'LANDING'
|
||||||
| 'CREATING'
|
| 'CREATING'
|
||||||
| 'GENERATING'
|
| 'GENERATING'
|
||||||
|
| 'EDITING'
|
||||||
| 'LOBBY'
|
| 'LOBBY'
|
||||||
| 'COUNTDOWN'
|
| 'COUNTDOWN'
|
||||||
| 'QUESTION'
|
| 'QUESTION'
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,17 @@ export default defineConfig(({ mode }) => {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, '.'),
|
'@': path.resolve(__dirname, '.'),
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./tests/setup.tsx'],
|
||||||
|
include: ['tests/**/*.test.{ts,tsx}'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
include: ['components/**', 'hooks/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue