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
457 lines
20 KiB
TypeScript
457 lines
20 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
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 { useUserConfig } from '../hooks/useUserConfig';
|
|
import type { Quiz, GameConfig } from '../types';
|
|
|
|
type GenerateMode = 'topic' | 'document';
|
|
|
|
interface LandingProps {
|
|
onGenerate: (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => void;
|
|
onCreateManual: () => void;
|
|
onLoadQuiz: (quiz: Quiz, quizId?: string) => void;
|
|
onJoin: (pin: string, name: string) => void;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => {
|
|
const auth = useAuth();
|
|
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
|
|
const [generateMode, setGenerateMode] = useState<GenerateMode>('topic');
|
|
const [topic, setTopic] = useState('');
|
|
const [pin, setPin] = useState('');
|
|
const [name, setName] = useState('');
|
|
const [libraryOpen, setLibraryOpen] = useState(false);
|
|
|
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
|
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,
|
|
loading: libraryLoading,
|
|
loadingQuizId,
|
|
deletingQuizId,
|
|
error: libraryError,
|
|
fetchQuizzes,
|
|
loadQuiz,
|
|
deleteQuiz,
|
|
retry: retryLibrary
|
|
} = useQuizLibrary();
|
|
|
|
useEffect(() => {
|
|
if (libraryOpen && auth.isAuthenticated) {
|
|
fetchQuizzes();
|
|
}
|
|
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
const newFiles = Array.from(e.target.files);
|
|
setSelectedFiles(prev => [...prev, ...newFiles]);
|
|
}
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
const acceptedTypes = ['.pdf', '.txt', '.md', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods', '.rtf', '.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
|
const newFiles = Array.from(e.dataTransfer.files).filter((file: File) => {
|
|
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
|
return acceptedTypes.includes(fileExtension) || file.type.startsWith('image/');
|
|
});
|
|
if (newFiles.length > 0) {
|
|
setSelectedFiles(prev => [...prev, ...newFiles]);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
};
|
|
|
|
const removeFile = (index: number) => {
|
|
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
|
|
if (selectedFiles.length <= 1) {
|
|
setUseOcr(false);
|
|
}
|
|
};
|
|
|
|
const clearAllFiles = () => {
|
|
setSelectedFiles([]);
|
|
setUseOcr(false);
|
|
};
|
|
|
|
const canGenerate = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0;
|
|
|
|
const handleHostSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (canGenerate && !isLoading) {
|
|
onGenerate({
|
|
topic: generateMode === 'topic' ? topic.trim() : undefined,
|
|
questionCount,
|
|
files: generateMode === 'document' ? selectedFiles : undefined,
|
|
useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleJoinSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (pin.trim() && name.trim()) onJoin(pin, name);
|
|
};
|
|
|
|
const handleLoadQuiz = async (id: string) => {
|
|
try {
|
|
const quiz = await loadQuiz(id);
|
|
setLibraryOpen(false);
|
|
onLoadQuiz(quiz, id);
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message.includes('redirecting')) {
|
|
return;
|
|
}
|
|
console.error('Failed to load quiz:', err);
|
|
}
|
|
};
|
|
|
|
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 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
|
|
initial={{ scale: 0.8, opacity: 0, rotate: -2 }}
|
|
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
|
transition={{ type: "spring", bounce: 0.5 }}
|
|
className="bg-white text-gray-900 p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] max-w-md w-full border-4 border-white/50"
|
|
>
|
|
<div className="flex justify-center mb-6">
|
|
<div className="bg-theme-primary p-4 rounded-3xl rotate-3 shadow-lg">
|
|
<BrainCircuit size={48} className="text-white" />
|
|
</div>
|
|
</div>
|
|
<h1 className="text-5xl font-black mb-2 text-theme-primary tracking-tight">Kaboot</h1>
|
|
<p className="text-gray-500 font-bold mb-6">The AI Quiz Party</p>
|
|
|
|
<div className="flex bg-gray-100 p-2 rounded-2xl mb-8">
|
|
<button
|
|
onClick={() => setMode('HOST')}
|
|
className={`flex-1 py-3 rounded-xl font-black text-lg transition-all duration-200 ${mode === 'HOST' ? 'bg-white shadow-md text-theme-primary scale-105' : 'text-gray-400 hover:text-gray-600'}`}
|
|
>
|
|
Host
|
|
</button>
|
|
<button
|
|
onClick={() => setMode('JOIN')}
|
|
className={`flex-1 py-3 rounded-xl font-black text-lg transition-all duration-200 ${mode === 'JOIN' ? 'bg-white shadow-md text-theme-primary scale-105' : 'text-gray-400 hover:text-gray-600'}`}
|
|
>
|
|
Join
|
|
</button>
|
|
</div>
|
|
|
|
{mode === 'HOST' ? (
|
|
<div className="space-y-4">
|
|
<form onSubmit={handleHostSubmit} className="space-y-4">
|
|
<div className="flex bg-gray-100 p-1.5 rounded-xl mb-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setGenerateMode('topic')}
|
|
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${
|
|
generateMode === 'topic'
|
|
? 'bg-white shadow-sm text-theme-primary'
|
|
: 'text-gray-400 hover:text-gray-600'
|
|
}`}
|
|
>
|
|
<Sparkles size={16} /> Topic
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setGenerateMode('document')}
|
|
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${
|
|
generateMode === 'document'
|
|
? 'bg-white shadow-sm text-theme-primary'
|
|
: 'text-gray-400 hover:text-gray-600'
|
|
}`}
|
|
>
|
|
<FileText size={16} /> Document
|
|
</button>
|
|
</div>
|
|
|
|
<AnimatePresence mode="wait">
|
|
{generateMode === 'topic' ? (
|
|
<motion.div
|
|
key="topic"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
transition={{ duration: 0.15 }}
|
|
>
|
|
<input
|
|
type="text"
|
|
placeholder="Enter a topic (e.g. 'Space')"
|
|
value={topic}
|
|
onChange={(e) => setTopic(e.target.value)}
|
|
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none transition-all placeholder:font-medium text-center"
|
|
disabled={isLoading}
|
|
/>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="document"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="space-y-3"
|
|
>
|
|
<div
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onClick={() => document.getElementById('file-upload')?.click()}
|
|
className={`border-2 border-dashed rounded-2xl p-4 text-center cursor-pointer transition-all ${
|
|
isDragging
|
|
? 'border-theme-primary bg-theme-primary/5 scale-[1.02]'
|
|
: selectedFiles.length > 0
|
|
? 'border-theme-primary/50 bg-theme-primary/5'
|
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<input
|
|
id="file-upload"
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleFileSelect}
|
|
accept=".pdf,.txt,.md,.docx,.pptx,.xlsx,.odt,.odp,.ods,.rtf,.jpg,.jpeg,.png,.gif,.webp"
|
|
/>
|
|
|
|
<div className="space-y-2">
|
|
<Upload className={`mx-auto ${isDragging ? 'text-theme-primary' : 'text-gray-400'}`} size={28} />
|
|
<p className="text-sm font-bold text-gray-600">
|
|
{isDragging ? 'Drop files here' : 'Drop files or click to browse'}
|
|
</p>
|
|
<p className="text-xs text-gray-400">PDF, DOCX, PPTX, XLSX, TXT, Images</p>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedFiles.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm font-bold text-gray-600">{selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected</span>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
clearAllFiles();
|
|
}}
|
|
className="text-xs font-bold text-gray-400 hover:text-red-500 transition-colors"
|
|
>
|
|
Clear all
|
|
</button>
|
|
</div>
|
|
<div className="max-h-32 overflow-y-auto space-y-1.5">
|
|
{selectedFiles.map((file, index) => (
|
|
<div key={index} className="flex items-center justify-between bg-white p-2 rounded-lg shadow-sm">
|
|
<div className="flex items-center gap-2 overflow-hidden text-left">
|
|
<div className="p-1.5 bg-theme-primary/10 rounded">
|
|
{file.type.startsWith('image/') ? (
|
|
<Image size={14} className="text-theme-primary" />
|
|
) : (
|
|
<FileText size={14} className="text-theme-primary" />
|
|
)}
|
|
</div>
|
|
<span className="text-xs font-medium text-gray-700 truncate max-w-[180px]">{file.name}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeFile(index);
|
|
}}
|
|
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-red-500 transition-colors"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showOcrOption && (
|
|
<label className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl cursor-pointer hover:bg-gray-100 transition-colors">
|
|
<div className="relative">
|
|
<input
|
|
type="checkbox"
|
|
checked={useOcr}
|
|
onChange={(e) => setUseOcr(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-10 h-6 bg-gray-300 rounded-full peer-checked:bg-theme-primary transition-colors"></div>
|
|
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-4 transition-transform"></div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<ScanText size={18} className="text-gray-500" />
|
|
<span className="text-sm font-bold text-gray-600">
|
|
{hasImageFile && !hasDocumentFile ? 'Extract text with OCR' : 'OCR embedded images'}
|
|
</span>
|
|
</div>
|
|
</label>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<div className="space-y-2 pt-2">
|
|
<div className="flex justify-between items-center text-sm font-bold text-gray-500">
|
|
<span>Questions</span>
|
|
<span className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded-lg text-xs">{questionCount}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="5"
|
|
max="30"
|
|
value={questionCount}
|
|
onChange={(e) => setQuestionCount(Number(e.target.value))}
|
|
className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading || !canGenerate}
|
|
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isLoading ? (
|
|
<Loader2 className="animate-spin" />
|
|
) : (
|
|
<>
|
|
<BrainCircuit size={24} />
|
|
{generateMode === 'document' ? 'Generate from Document' : 'Generate Quiz'}
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
|
|
<div className="relative flex py-2 items-center opacity-50">
|
|
<div className="flex-grow border-t-2 border-gray-300"></div>
|
|
<span className="flex-shrink mx-4 text-gray-400 font-bold">OR</span>
|
|
<div className="flex-grow border-t-2 border-gray-300"></div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={onCreateManual}
|
|
className="w-full bg-white border-2 border-theme-primary text-theme-primary py-3 rounded-2xl text-lg font-black hover:bg-theme-hover shadow-[0_4px_0_var(--theme-primary)] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
|
|
>
|
|
<PenTool size={20} /> Create Manually
|
|
</button>
|
|
|
|
{auth.isAuthenticated && (
|
|
<button
|
|
onClick={() => setLibraryOpen(true)}
|
|
className="w-full bg-gray-100 text-gray-600 py-3 rounded-2xl text-lg font-black hover:bg-gray-200 shadow-[0_4px_0_#d1d5db] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
|
|
>
|
|
<BookOpen size={20} /> My Quizzes
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleJoinSubmit} className="space-y-4">
|
|
<input
|
|
type="text"
|
|
placeholder="Game PIN"
|
|
value={pin}
|
|
onChange={(e) => setPin(e.target.value)}
|
|
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none text-center"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Nickname"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none text-center"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!pin.trim() || !name.trim()}
|
|
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3"
|
|
>
|
|
<Play fill="currentColor" /> Join Game
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{error && (
|
|
<motion.div initial={{ height: 0 }} animate={{ height: 'auto' }} className="overflow-hidden mt-4">
|
|
<p className="text-red-500 font-bold bg-red-50 p-3 rounded-xl border-2 border-red-100">{error}</p>
|
|
</motion.div>
|
|
)}
|
|
</motion.div>
|
|
|
|
<QuizLibrary
|
|
isOpen={libraryOpen}
|
|
onClose={() => setLibraryOpen(false)}
|
|
quizzes={quizzes}
|
|
loading={libraryLoading}
|
|
loadingQuizId={loadingQuizId}
|
|
deletingQuizId={deletingQuizId}
|
|
error={libraryError}
|
|
onLoadQuiz={handleLoadQuiz}
|
|
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>
|
|
);
|
|
};
|