Add a centralized game configuration system that allows customizable scoring mechanics and game rules. Users can now set default game configurations that persist across sessions, and individual quizzes can have their own configuration overrides. ## New Features ### Game Configuration Options - Shuffle Questions: Randomize question order when starting a game - Shuffle Answers: Randomize answer positions for each question - Host Participates: Toggle whether the host plays as a competitor or spectates (host now shows as 'Spectator' when not participating) - Streak Bonus: Multiplied points for consecutive correct answers, with configurable threshold and multiplier values - Comeback Bonus: Extra points for players ranked below top 3 - Wrong Answer Penalty: Deduct percentage of max points for incorrect answers (configurable percentage) - First Correct Bonus: Extra points for the first player to answer correctly on each question ### Default Settings Management - New Settings icon in landing page header (authenticated users only) - DefaultConfigModal for editing user-wide default game settings - Default configs are loaded when creating new quizzes - Defaults persist to database via new user API endpoints ### Reusable UI Components - GameConfigPanel: Comprehensive toggle-based settings panel with expandable sub-options, tooltips, and suggested values based on question count - DefaultConfigModal: Modal wrapper for editing default configurations ## Technical Changes ### Frontend - New useUserConfig hook for fetching/saving user default configurations - QuizEditor now uses GameConfigPanel instead of inline toggle checkboxes - GameScreen handles spectator mode with disabled answer buttons - Updated useGame hook with new scoring calculations and config state - Improved useAuthenticatedFetch with deduped silent refresh and redirect-once pattern to prevent multiple auth redirects ### Backend - Added game_config column to quizzes table (JSON storage) - Added default_game_config column to users table - New PATCH endpoint for quiz config updates: /api/quizzes/:id/config - New PUT endpoint for user defaults: /api/users/me/default-config - Auto-migration in connection.ts for existing databases ### Scoring System - New calculatePoints() function in constants.ts handles all scoring logic including streaks, comebacks, penalties, and first-correct bonus - New calculateBasePoints() for time-based point calculation - New getPlayerRank() helper for comeback bonus eligibility ### Tests - Added tests for DefaultConfigModal component - Added tests for GameConfigPanel component - Added tests for QuizEditor config integration - Added tests for useUserConfig hook - Updated API tests for new endpoints ## Type Changes - Added GameConfig interface with all configuration options - Added DEFAULT_GAME_CONFIG constant with sensible defaults - Quiz type now includes optional config property
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { ArrowLeft, Save, Plus, Play, AlertTriangle } from 'lucide-react';
|
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
|
|
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
import { Quiz, Question, GameConfig, DEFAULT_GAME_CONFIG } from '../types';
|
|
import { SortableQuestionCard } from './SortableQuestionCard';
|
|
import { QuestionEditModal } from './QuestionEditModal';
|
|
import { GameConfigPanel } from './GameConfigPanel';
|
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
interface QuizEditorProps {
|
|
quiz: Quiz;
|
|
onSave: (quiz: Quiz) => void;
|
|
onStartGame: (quiz: Quiz, config: GameConfig) => void;
|
|
onConfigChange?: (config: GameConfig) => void;
|
|
onBack: () => void;
|
|
showSaveButton?: boolean;
|
|
isSaving?: boolean;
|
|
defaultConfig?: GameConfig;
|
|
}
|
|
|
|
export const QuizEditor: React.FC<QuizEditorProps> = ({
|
|
quiz: initialQuiz,
|
|
onSave,
|
|
onStartGame,
|
|
onConfigChange,
|
|
onBack,
|
|
showSaveButton = true,
|
|
isSaving,
|
|
defaultConfig,
|
|
}) => {
|
|
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 [config, setConfig] = useState<GameConfig>(
|
|
initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG
|
|
);
|
|
|
|
useBodyScrollLock(!!showDeleteConfirm);
|
|
|
|
const handleConfigChange = useCallback((newConfig: GameConfig) => {
|
|
setConfig(newConfig);
|
|
onConfigChange?.(newConfig);
|
|
}, [onConfigChange]);
|
|
|
|
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 (config.shuffleQuestions) {
|
|
questions = questions.sort(() => Math.random() - 0.5);
|
|
}
|
|
|
|
if (config.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, config }, config);
|
|
};
|
|
|
|
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">
|
|
<GameConfigPanel
|
|
config={config}
|
|
onChange={handleConfigChange}
|
|
questionCount={quiz.questions.length}
|
|
compact
|
|
/>
|
|
|
|
<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>
|
|
);
|
|
};
|