feat: add comprehensive game configuration system

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
This commit is contained in:
Joey Yakimowich-Payne 2026-01-14 01:43:23 -07:00
commit af21f2bcdc
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
23 changed files with 2925 additions and 133 deletions

View file

@ -0,0 +1,95 @@
import React from 'react';
import { motion } from 'framer-motion';
import { X, Settings, Loader2 } from 'lucide-react';
import { GameConfigPanel } from './GameConfigPanel';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import type { GameConfig } from '../types';
interface DefaultConfigModalProps {
isOpen: boolean;
onClose: () => void;
config: GameConfig;
onChange: (config: GameConfig) => void;
onSave: () => void;
saving: boolean;
}
export const DefaultConfigModal: React.FC<DefaultConfigModalProps> = ({
isOpen,
onClose,
config,
onChange,
onSave,
saving,
}) => {
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 max-w-lg 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 bg-gradient-to-r from-theme-primary to-purple-600">
<div className="flex items-center gap-3 text-white">
<div className="p-2 bg-white/20 rounded-xl">
<Settings size={24} />
</div>
<div>
<h2 className="text-xl font-black">Default Game Settings</h2>
<p className="text-sm opacity-80">Applied to all new quizzes</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/20 rounded-xl transition text-white"
>
<X size={24} />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1 bg-gray-50">
<GameConfigPanel
config={config}
onChange={onChange}
questionCount={10}
/>
</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={onSave}
disabled={saving}
className="flex-1 py-3 rounded-xl font-bold bg-theme-primary text-white hover:bg-theme-primary/90 transition disabled:opacity-50 flex items-center justify-center gap-2"
>
{saving ? (
<>
<Loader2 size={20} className="animate-spin" />
Saving...
</>
) : (
'Save Defaults'
)}
</button>
</div>
</motion.div>
</motion.div>
);
};

View file

@ -0,0 +1,325 @@
import React, { useState } from 'react';
import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp } from 'lucide-react';
import type { GameConfig } from '../types';
interface GameConfigPanelProps {
config: GameConfig;
onChange: (config: GameConfig) => void;
questionCount: number;
compact?: boolean;
}
interface TooltipProps {
content: string;
}
const Tooltip: React.FC<TooltipProps> = ({ content }) => {
const [show, setShow] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const buttonRef = React.useRef<HTMLButtonElement>(null);
const updatePosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setCoords({
top: rect.top + rect.height / 2,
left: rect.right + 8,
});
}
};
const handleMouseEnter = () => {
updatePosition();
setShow(true);
};
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
updatePosition();
setShow(!show);
};
return (
<div className="relative inline-block">
<button
ref={buttonRef}
type="button"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShow(false)}
onClick={handleClick}
className="p-1 text-gray-400 hover:text-gray-600 transition"
>
<Info size={16} />
</button>
{show && (
<div
className="fixed z-[100] w-64 p-3 text-sm bg-gray-900 text-white rounded-lg shadow-lg"
style={{
top: coords.top,
left: coords.left,
transform: 'translateY(-50%)',
}}
>
{content}
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1 w-2 h-2 bg-gray-900 rotate-45" />
</div>
)}
</div>
);
};
interface ToggleRowProps {
icon: React.ReactNode;
iconActive: boolean;
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
tooltip?: string;
children?: React.ReactNode;
}
const ToggleRow: React.FC<ToggleRowProps> = ({
icon,
iconActive,
label: labelText,
description,
checked,
onChange,
tooltip,
children,
}) => {
const inputId = React.useId();
return (
<div className="bg-white rounded-xl border-2 border-gray-200 overflow-hidden">
<div className="flex items-center justify-between p-4 hover:bg-gray-50 transition group">
<label htmlFor={inputId} className="flex items-center gap-3 cursor-pointer flex-1">
<div className={`p-2 rounded-lg transition ${iconActive ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
{icon}
</div>
<div>
<p className="font-bold text-gray-900">{labelText}</p>
<p className="text-sm text-gray-500">{description}</p>
</div>
</label>
<div className="flex items-center gap-2">
{tooltip && <Tooltip content={tooltip} />}
<label htmlFor={inputId} className="relative cursor-pointer">
<input
id={inputId}
type="checkbox"
checked={checked}
onChange={(e) => onChange(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 className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5" />
</label>
</div>
</div>
{checked && children && (
<div className="px-4 pb-4 pt-0 border-t border-gray-100 bg-gray-50">
{children}
</div>
)}
</div>
);
};
interface NumberInputProps {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
suffix?: string;
}
const NumberInput: React.FC<NumberInputProps> = ({ label, value, onChange, min, max, step = 1, suffix }) => (
<div className="flex items-center justify-between py-2">
<span className="text-sm font-medium text-gray-700">{label}</span>
<div className="flex items-center gap-2">
<input
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
max={max}
step={step}
className="w-20 px-3 py-1.5 border-2 border-gray-200 rounded-lg text-center font-bold text-gray-900 bg-white focus:border-theme-primary focus:ring-2 focus:ring-theme-primary/20 outline-none"
/>
{suffix && <span className="text-sm text-gray-500">{suffix}</span>}
</div>
</div>
);
export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
config,
onChange,
questionCount,
compact = false,
}) => {
const [expanded, setExpanded] = useState(!compact);
const update = (partial: Partial<GameConfig>) => {
onChange({ ...config, ...partial });
};
const suggestedComebackBonus = Math.round(50 + (questionCount * 5));
const suggestedFirstCorrectBonus = Math.round(25 + (questionCount * 2.5));
if (compact && !expanded) {
return (
<button
onClick={() => setExpanded(true)}
className="w-full flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 hover:border-theme-primary transition"
>
<span className="font-bold text-gray-700">Game Settings</span>
<ChevronDown size={20} className="text-gray-400" />
</button>
);
}
return (
<div className="space-y-2">
{compact && (
<button
onClick={() => setExpanded(false)}
className="w-full flex items-center justify-between p-3 bg-gray-100 rounded-xl hover:bg-gray-200 transition"
>
<span className="font-bold text-gray-700">Game Settings</span>
<ChevronUp size={20} className="text-gray-400" />
</button>
)}
<ToggleRow
icon={<Shuffle size={20} />}
iconActive={config.shuffleQuestions}
label="Shuffle Questions"
description="Randomize question order when starting"
checked={config.shuffleQuestions}
onChange={(v) => update({ shuffleQuestions: v })}
/>
<ToggleRow
icon={<Shuffle size={20} />}
iconActive={config.shuffleAnswers}
label="Shuffle Answers"
description="Randomize answer positions for each question"
checked={config.shuffleAnswers}
onChange={(v) => update({ shuffleAnswers: v })}
/>
<ToggleRow
icon={<Eye size={20} />}
iconActive={config.hostParticipates}
label="Host Participates"
description="Join as a player and answer questions"
checked={config.hostParticipates}
onChange={(v) => update({ hostParticipates: v })}
/>
<ToggleRow
icon={<Flame size={20} />}
iconActive={config.streakBonusEnabled}
label="Streak Bonus"
description="Multiply points for consecutive correct answers"
checked={config.streakBonusEnabled}
onChange={(v) => update({ streakBonusEnabled: v })}
tooltip={`After ${config.streakThreshold} correct answers in a row, points are multiplied by ${config.streakMultiplier}x. The multiplier increases by ${((config.streakMultiplier - 1) * 100).toFixed(0)}% for each additional correct answer.`}
>
<div className="mt-3 space-y-1">
<NumberInput
label="Streak threshold"
value={config.streakThreshold}
onChange={(v) => update({ streakThreshold: v })}
min={2}
max={10}
suffix="correct"
/>
<NumberInput
label="Multiplier"
value={config.streakMultiplier}
onChange={(v) => update({ streakMultiplier: v })}
min={1.05}
max={2}
step={0.05}
suffix="x"
/>
</div>
</ToggleRow>
<ToggleRow
icon={<TrendingUp size={20} />}
iconActive={config.comebackBonusEnabled}
label="Comeback Bonus"
description="Extra points for players not in top 3"
checked={config.comebackBonusEnabled}
onChange={(v) => update({ comebackBonusEnabled: v })}
>
<div className="mt-3">
<NumberInput
label="Bonus points"
value={config.comebackBonusPoints}
onChange={(v) => update({ comebackBonusPoints: v })}
min={10}
max={500}
suffix="pts"
/>
<p className="text-xs text-gray-500 mt-1">
Suggested for {questionCount} questions: {suggestedComebackBonus} pts
</p>
</div>
</ToggleRow>
<ToggleRow
icon={<MinusCircle size={20} />}
iconActive={config.penaltyForWrongAnswer}
label="Wrong Answer Penalty"
description="Deduct points for incorrect answers"
checked={config.penaltyForWrongAnswer}
onChange={(v) => update({ penaltyForWrongAnswer: v })}
>
<div className="mt-3">
<NumberInput
label="Penalty"
value={config.penaltyPercent}
onChange={(v) => update({ penaltyPercent: v })}
min={5}
max={100}
suffix="%"
/>
<p className="text-xs text-gray-500 mt-1">
Deducts {config.penaltyPercent}% of max points ({Math.round(1000 * config.penaltyPercent / 100)} pts)
</p>
</div>
</ToggleRow>
<ToggleRow
icon={<Award size={20} />}
iconActive={config.firstCorrectBonusEnabled}
label="First Correct Bonus"
description="Extra points for first player to answer correctly"
checked={config.firstCorrectBonusEnabled}
onChange={(v) => update({ firstCorrectBonusEnabled: v })}
>
<div className="mt-3">
<NumberInput
label="Bonus points"
value={config.firstCorrectBonusPoints}
onChange={(v) => update({ firstCorrectBonusPoints: v })}
min={10}
max={500}
suffix="pts"
/>
<p className="text-xs text-gray-500 mt-1">
Suggested for {questionCount} questions: {suggestedFirstCorrectBonus} pts
</p>
</div>
</ToggleRow>
</div>
);
};

View file

@ -13,6 +13,7 @@ interface GameScreenProps {
onAnswer: (isCorrect: boolean) => void;
hasAnswered: boolean;
lastPointsEarned: number | null;
hostPlays?: boolean;
}
export const GameScreen: React.FC<GameScreenProps> = ({
@ -24,8 +25,10 @@ export const GameScreen: React.FC<GameScreenProps> = ({
role,
onAnswer,
hasAnswered,
hostPlays = true,
}) => {
const isClient = role === 'CLIENT';
const isSpectator = role === 'HOST' && !hostPlays;
const displayOptions = question?.options || [];
const timeLeftSeconds = Math.ceil(timeLeft / 1000);
@ -51,7 +54,7 @@ export const GameScreen: React.FC<GameScreenProps> = ({
</div>
<div className="bg-white/20 backdrop-blur-md px-6 py-2 rounded-2xl font-black text-xl shadow-sm border-2 border-white/10">
{isClient ? 'Controller' : 'Host'}
{isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'}
</div>
</div>
@ -80,12 +83,14 @@ export const GameScreen: React.FC<GameScreenProps> = ({
let opacityClass = "opacity-100";
let scaleClass = "scale-100";
let cursorClass = "";
// If answering phase and user answered, dim everything
if (hasAnswered) {
if (isSpectator) {
cursorClass = "cursor-default";
} else if (hasAnswered) {
opacityClass = "opacity-50 cursor-not-allowed grayscale";
scaleClass = "scale-95";
}
}
return (
<motion.button
@ -93,14 +98,14 @@ export const GameScreen: React.FC<GameScreenProps> = ({
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: idx * 0.03, type: 'spring', stiffness: 500, damping: 30 }}
disabled={hasAnswered}
onClick={() => onAnswer(option as any)}
disabled={hasAnswered || isSpectator}
onClick={() => !isSpectator && onAnswer(option as any)}
className={`
${colorClass} ${opacityClass} ${scaleClass}
${colorClass} ${opacityClass} ${scaleClass} ${cursorClass}
rounded-3xl shadow-[0_8px_0_rgba(0,0,0,0.2)]
flex flex-col md:flex-row items-center justify-center md:justify-start
p-4 md:p-8
active:shadow-none active:translate-y-[8px] active:scale-95
${!isSpectator ? 'active:shadow-none active:translate-y-[8px] active:scale-95' : ''}
transition-all duration-300 relative group overflow-hidden border-b-8 border-black/10
`}
>

View file

@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles } from 'lucide-react';
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings } from 'lucide-react';
import { useAuth } from 'react-oidc-context';
import { AuthButton } from './AuthButton';
import { QuizLibrary } from './QuizLibrary';
import { DefaultConfigModal } from './DefaultConfigModal';
import { useQuizLibrary } from '../hooks/useQuizLibrary';
import type { Quiz } from '../types';
import { useUserConfig } from '../hooks/useUserConfig';
import type { Quiz, GameConfig } from '../types';
type GenerateMode = 'topic' | 'document';
@ -31,10 +33,14 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const [questionCount, setQuestionCount] = useState(10);
const [isDragging, setIsDragging] = useState(false);
const [useOcr, setUseOcr] = useState(false);
const [defaultConfigOpen, setDefaultConfigOpen] = useState(false);
const [editingDefaultConfig, setEditingDefaultConfig] = useState<GameConfig | null>(null);
const hasImageFile = selectedFiles.some(f => f.type.startsWith('image/'));
const hasDocumentFile = selectedFiles.some(f => !f.type.startsWith('image/') && !['application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/html'].includes(f.type));
const showOcrOption = hasImageFile || hasDocumentFile;
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
const {
quizzes,
@ -132,7 +138,19 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative">
<div className="absolute top-4 right-4">
<div className="absolute top-4 right-4 flex items-center gap-2">
{auth.isAuthenticated && (
<button
onClick={() => {
setEditingDefaultConfig(defaultConfig);
setDefaultConfigOpen(true);
}}
className="p-2.5 bg-white/90 hover:bg-white rounded-xl shadow-md hover:shadow-lg transition-all text-gray-600 hover:text-theme-primary"
title="Default Game Settings"
>
<Settings size={20} />
</button>
)}
<AuthButton />
</div>
<motion.div
@ -416,6 +434,24 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
onDeleteQuiz={deleteQuiz}
onRetry={retryLibrary}
/>
<DefaultConfigModal
isOpen={defaultConfigOpen}
onClose={() => {
setDefaultConfigOpen(false);
setEditingDefaultConfig(null);
}}
config={editingDefaultConfig || defaultConfig}
onChange={setEditingDefaultConfig}
onSave={async () => {
if (editingDefaultConfig) {
await saveDefaultConfig(editingDefaultConfig);
setDefaultConfigOpen(false);
setEditingDefaultConfig(null);
}
}}
saving={savingConfig}
/>
</div>
);
};

View file

@ -1,41 +1,52 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft, Save, Plus, Play, AlertTriangle, Shuffle } from 'lucide-react';
import { ArrowLeft, Save, Plus, Play, AlertTriangle } from 'lucide-react';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Quiz, Question } from '../types';
import { 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) => 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
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 [shuffleQuestions, setShuffleQuestions] = useState(false);
const [shuffleAnswers, setShuffleAnswers] = 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 }
@ -117,11 +128,11 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
const handleStartGame = () => {
let questions = [...quiz.questions];
if (shuffleQuestions) {
if (config.shuffleQuestions) {
questions = questions.sort(() => Math.random() - 0.5);
}
if (shuffleAnswers) {
if (config.shuffleAnswers) {
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
const colors = ['red', 'blue', 'yellow', 'green'] as const;
@ -138,7 +149,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
});
}
onStartGame({ ...quiz, questions });
onStartGame({ ...quiz, questions, config }, config);
};
return (
@ -242,51 +253,12 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
</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>
<GameConfigPanel
config={config}
onChange={handleConfigChange}
questionCount={quiz.questions.length}
compact
/>
<button
onClick={handleStartGame}