Scoreboard ui stuff
This commit is contained in:
parent
f0d177feeb
commit
279dc7f2c3
12 changed files with 1558 additions and 77 deletions
8
App.tsx
8
App.tsx
|
|
@ -60,6 +60,7 @@ function App() {
|
||||||
gamePin,
|
gamePin,
|
||||||
startQuizGen,
|
startQuizGen,
|
||||||
startManualCreation,
|
startManualCreation,
|
||||||
|
cancelCreation,
|
||||||
finalizeManualQuiz,
|
finalizeManualQuiz,
|
||||||
loadSavedQuiz,
|
loadSavedQuiz,
|
||||||
joinGame,
|
joinGame,
|
||||||
|
|
@ -148,14 +149,15 @@ function App() {
|
||||||
onLoadQuiz={loadSavedQuiz}
|
onLoadQuiz={loadSavedQuiz}
|
||||||
onJoin={joinGame}
|
onJoin={joinGame}
|
||||||
isLoading={gameState === 'GENERATING'}
|
isLoading={gameState === 'GENERATING'}
|
||||||
error={error}
|
error={error}
|
||||||
|
initialPin={gamePin}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{gameState === 'CREATING' ? (
|
{gameState === 'CREATING' ? (
|
||||||
<QuizCreator
|
<QuizCreator
|
||||||
onFinalize={finalizeManualQuiz}
|
onFinalize={finalizeManualQuiz}
|
||||||
onCancel={() => window.location.reload()}
|
onCancel={cancelCreation}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -261,7 +263,7 @@ function App() {
|
||||||
{gameState === 'PODIUM' ? (
|
{gameState === 'PODIUM' ? (
|
||||||
<Podium
|
<Podium
|
||||||
players={players}
|
players={players}
|
||||||
onRestart={() => window.location.reload()}
|
onRestart={endGame}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
<div align="center">
|
|
||||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
# Kaboot
|
# Kaboot
|
||||||
|
|
||||||
Kaboot is an AI-powered quiz party game inspired by Kahoot. It leverages the Google Gemini API to instantly generate engaging quizzes on any topic, allowing users to host and join multiplayer games with ease.
|
Kaboot is an AI-powered quiz party game inspired by Kahoot. It leverages the Google Gemini API to instantly generate engaging quizzes on any topic, allowing users to host and join multiplayer games with ease.
|
||||||
|
|
|
||||||
|
|
@ -126,18 +126,38 @@ export const GameScreen: React.FC<GameScreenProps> = ({
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isClient && hasAnswered && (
|
{isClient && hasAnswered && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
className="absolute inset-0 bg-theme-primary/95 flex flex-col items-center justify-center z-50 p-8 text-center"
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-[2px] flex flex-col items-center justify-center z-50 p-4"
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ scale: [1, 1.2, 1] }}
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
className="text-6xl mb-6"
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
className="bg-theme-primary/40 backdrop-blur-xl border border-white/20 p-8 md:p-12 rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] text-center max-w-md w-full relative overflow-hidden"
|
||||||
>
|
>
|
||||||
🚀
|
<div className="absolute -inset-full bg-gradient-to-tr from-transparent via-white/10 to-transparent rotate-45 animate-pulse pointer-events-none" />
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-white/10 to-transparent pointer-events-none" />
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
y: [0, -15, 0],
|
||||||
|
rotate: [0, 5, -5, 0],
|
||||||
|
scale: [1, 1.1, 1]
|
||||||
|
}}
|
||||||
|
transition={{ repeat: Infinity, duration: 3, ease: "easeInOut" }}
|
||||||
|
className="text-7xl mb-6 drop-shadow-2xl relative z-10 inline-block filter"
|
||||||
|
>
|
||||||
|
🚀
|
||||||
|
</motion.div>
|
||||||
|
<h2 className="text-4xl font-black text-white font-display mb-3 drop-shadow-lg relative z-10 tracking-tight">
|
||||||
|
Answer Sent!
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg font-bold text-white/80 relative z-10">
|
||||||
|
Cross your fingers...
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h2 className="text-4xl md:text-5xl font-black text-white font-display mb-4">Answer Sent!</h2>
|
|
||||||
<p className="text-xl font-bold opacity-80">Cross your fingers...</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings, Palette, Lock } from 'lucide-react';
|
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings, Palette, Lock } from 'lucide-react';
|
||||||
import { useAuth } from 'react-oidc-context';
|
import { useAuth } from 'react-oidc-context';
|
||||||
|
|
@ -21,25 +22,87 @@ interface LandingProps {
|
||||||
onJoin: (pin: string, name: string) => void;
|
onJoin: (pin: string, name: string) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
initialPin?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => {
|
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error, initialPin }) => {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [mode, setMode] = useState<'HOST' | 'JOIN'>('JOIN');
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const getModeFromUrl = (): 'HOST' | 'JOIN' => {
|
||||||
|
if (initialPin) return 'JOIN';
|
||||||
|
const modeParam = searchParams.get('mode');
|
||||||
|
return modeParam === 'host' ? 'HOST' : 'JOIN';
|
||||||
|
};
|
||||||
|
|
||||||
|
const [mode, setModeState] = useState<'HOST' | 'JOIN'>(getModeFromUrl);
|
||||||
|
|
||||||
|
const setMode = (newMode: 'HOST' | 'JOIN') => {
|
||||||
|
setModeState(newMode);
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
if (newMode === 'HOST') {
|
||||||
|
newParams.set('mode', 'host');
|
||||||
|
} else {
|
||||||
|
newParams.delete('mode');
|
||||||
|
}
|
||||||
|
setSearchParams(newParams, { replace: true });
|
||||||
|
};
|
||||||
const [generateMode, setGenerateMode] = useState<GenerateMode>('topic');
|
const [generateMode, setGenerateMode] = useState<GenerateMode>('topic');
|
||||||
const [topic, setTopic] = useState('');
|
const [topic, setTopic] = useState('');
|
||||||
const [pin, setPin] = useState('');
|
const [pin, setPin] = useState(initialPin || '');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
|
||||||
|
const modalParam = searchParams.get('modal');
|
||||||
|
const libraryOpen = modalParam === 'library';
|
||||||
|
const preferencesOpen = modalParam === 'preferences';
|
||||||
|
const defaultConfigOpen = modalParam === 'settings';
|
||||||
|
const accountSettingsOpen = modalParam === 'account';
|
||||||
|
|
||||||
|
const setLibraryOpen = (open: boolean) => {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
if (open) {
|
||||||
|
newParams.set('modal', 'library');
|
||||||
|
} else {
|
||||||
|
newParams.delete('modal');
|
||||||
|
}
|
||||||
|
setSearchParams(newParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPreferencesOpen = (open: boolean) => {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
if (open) {
|
||||||
|
newParams.set('modal', 'preferences');
|
||||||
|
} else {
|
||||||
|
newParams.delete('modal');
|
||||||
|
}
|
||||||
|
setSearchParams(newParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDefaultConfigOpen = (open: boolean) => {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
if (open) {
|
||||||
|
newParams.set('modal', 'settings');
|
||||||
|
} else {
|
||||||
|
newParams.delete('modal');
|
||||||
|
}
|
||||||
|
setSearchParams(newParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAccountSettingsOpen = (open: boolean) => {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
if (open) {
|
||||||
|
newParams.set('modal', 'account');
|
||||||
|
} else {
|
||||||
|
newParams.delete('modal');
|
||||||
|
}
|
||||||
|
setSearchParams(newParams);
|
||||||
|
};
|
||||||
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
const [questionCount, setQuestionCount] = useState(10);
|
const [questionCount, setQuestionCount] = useState(10);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [useOcr, setUseOcr] = useState(false);
|
const [useOcr, setUseOcr] = useState(false);
|
||||||
const [defaultConfigOpen, setDefaultConfigOpen] = useState(false);
|
|
||||||
const [editingDefaultConfig, setEditingDefaultConfig] = useState<GameConfig | null>(null);
|
const [editingDefaultConfig, setEditingDefaultConfig] = useState<GameConfig | null>(null);
|
||||||
const [preferencesOpen, setPreferencesOpen] = useState(false);
|
|
||||||
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
|
|
||||||
const [gameInfo, setGameInfo] = useState<{ randomNamesEnabled: boolean; quizTitle: string } | null>(null);
|
const [gameInfo, setGameInfo] = useState<{ randomNamesEnabled: boolean; quizTitle: string } | null>(null);
|
||||||
const [checkingPin, setCheckingPin] = useState(false);
|
const [checkingPin, setCheckingPin] = useState(false);
|
||||||
|
|
||||||
|
|
@ -69,6 +132,14 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
}
|
}
|
||||||
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
|
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultConfigOpen) {
|
||||||
|
setEditingDefaultConfig(defaultConfig);
|
||||||
|
} else {
|
||||||
|
setEditingDefaultConfig(null);
|
||||||
|
}
|
||||||
|
}, [defaultConfigOpen, defaultConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkGamePin = async () => {
|
const checkGamePin = async () => {
|
||||||
if (pin.trim().length === 6) {
|
if (pin.trim().length === 6) {
|
||||||
|
|
@ -404,7 +475,7 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="5"
|
min="5"
|
||||||
max="30"
|
max="50"
|
||||||
value={questionCount}
|
value={questionCount}
|
||||||
onChange={(e) => setQuestionCount(Number(e.target.value))}
|
onChange={(e) => setQuestionCount(Number(e.target.value))}
|
||||||
className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { X, Palette, Loader2, Check } from 'lucide-react';
|
import { X, Palette, Loader2, Check } from 'lucide-react';
|
||||||
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
|
@ -25,6 +25,12 @@ export const PreferencesModal: React.FC<PreferencesModalProps> = ({
|
||||||
useBodyScrollLock(isOpen);
|
useBodyScrollLock(isOpen);
|
||||||
const [localPrefs, setLocalPrefs] = useState<UserPreferences>(preferences);
|
const [localPrefs, setLocalPrefs] = useState<UserPreferences>(preferences);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setLocalPrefs(preferences);
|
||||||
|
}
|
||||||
|
}, [isOpen, preferences]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleColorSelect = (schemeId: string) => {
|
const handleColorSelect = (schemeId: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useAuth } from 'react-oidc-context';
|
import { useAuth } from 'react-oidc-context';
|
||||||
import { Quiz, Question, AnswerOption } from '../types';
|
import { Quiz, Question, AnswerOption } from '../types';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
@ -18,7 +18,14 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
|
||||||
const [options, setOptions] = useState<string[]>(['', '', '', '']);
|
const [options, setOptions] = useState<string[]>(['', '', '', '']);
|
||||||
const [reasons, setReasons] = useState<string[]>(['', '', '', '']);
|
const [reasons, setReasons] = useState<string[]>(['', '', '', '']);
|
||||||
const [correctIdx, setCorrectIdx] = useState<number>(0);
|
const [correctIdx, setCorrectIdx] = useState<number>(0);
|
||||||
const [saveToLibrary, setSaveToLibrary] = useState(false);
|
const [saveToLibrary, setSaveToLibrary] = useState(auth.isAuthenticated);
|
||||||
|
const [hasToggledSave, setHasToggledSave] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.isAuthenticated && !hasToggledSave) {
|
||||||
|
setSaveToLibrary(true);
|
||||||
|
}
|
||||||
|
}, [auth.isAuthenticated, hasToggledSave]);
|
||||||
|
|
||||||
const handleAddQuestion = () => {
|
const handleAddQuestion = () => {
|
||||||
if (!qText.trim() || options.some(o => !o.trim())) {
|
if (!qText.trim() || options.some(o => !o.trim())) {
|
||||||
|
|
@ -182,12 +189,15 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
|
||||||
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 flex justify-between items-center">
|
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 flex justify-between items-center">
|
||||||
{auth.isAuthenticated ? (
|
{auth.isAuthenticated ? (
|
||||||
<label className="flex items-center gap-3 cursor-pointer select-none group">
|
<label className="flex items-center gap-3 cursor-pointer select-none group">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={saveToLibrary}
|
checked={saveToLibrary}
|
||||||
onChange={(e) => setSaveToLibrary(e.target.checked)}
|
onChange={(e) => {
|
||||||
className="sr-only peer"
|
setSaveToLibrary(e.target.checked);
|
||||||
/>
|
setHasToggledSave(true);
|
||||||
|
}}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
<div className="w-6 h-6 border-2 border-gray-300 rounded-lg flex items-center justify-center peer-checked:bg-theme-primary peer-checked:border-theme-primary transition-all group-hover:border-gray-400">
|
<div className="w-6 h-6 border-2 border-gray-300 rounded-lg flex items-center justify-center peer-checked:bg-theme-primary peer-checked:border-theme-primary transition-all group-hover:border-gray-400">
|
||||||
{saveToLibrary && <CheckCircle size={16} className="text-white" />}
|
{saveToLibrary && <CheckCircle size={16} className="text-white" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ const PlayerRow: React.FC<PlayerRowProps> = ({ player, index, maxScore }) => {
|
||||||
style={{ backgroundColor: player.color }}
|
style={{ backgroundColor: player.color }}
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ width: `${Math.max(barWidth, 2)}%` }}
|
animate={{ width: `${Math.max(barWidth, 2)}%` }}
|
||||||
transition={{ duration: 0.6, delay: baseDelay + 0.1 }}
|
transition={{ duration: 0.6, delay: phase === 0 ? baseDelay + 0.1 : 0 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -191,7 +191,7 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
|
||||||
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name
|
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name
|
||||||
}));
|
}));
|
||||||
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score);
|
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score);
|
||||||
const maxScore = Math.max(...sortedPlayers.map(p => p.score), 1);
|
const maxScore = Math.max(...sortedPlayers.map(p => Math.max(p.score, p.previousScore)), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen p-4 md:p-8 overflow-hidden">
|
<div className="flex flex-col h-screen p-4 md:p-8 overflow-hidden">
|
||||||
|
|
|
||||||
133
hooks/useGame.ts
133
hooks/useGame.ts
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
|
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
|
||||||
import { generateQuiz } from '../services/geminiService';
|
import { generateQuiz } from '../services/geminiService';
|
||||||
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
|
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
|
||||||
|
|
@ -35,6 +36,9 @@ const clearStoredSession = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useGame = () => {
|
export const useGame = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const [role, setRole] = useState<GameRole>('HOST');
|
const [role, setRole] = useState<GameRole>('HOST');
|
||||||
const [gameState, setGameState] = useState<GameState>('LANDING');
|
const [gameState, setGameState] = useState<GameState>('LANDING');
|
||||||
const [quiz, setQuiz] = useState<Quiz | null>(null);
|
const [quiz, setQuiz] = useState<Quiz | null>(null);
|
||||||
|
|
@ -87,6 +91,53 @@ export const useGame = () => {
|
||||||
useEffect(() => { firstCorrectPlayerIdRef.current = firstCorrectPlayerId; }, [firstCorrectPlayerId]);
|
useEffect(() => { firstCorrectPlayerIdRef.current = firstCorrectPlayerId; }, [firstCorrectPlayerId]);
|
||||||
useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]);
|
useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]);
|
||||||
|
|
||||||
|
const isInitializingFromUrl = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitializingFromUrl.current) return;
|
||||||
|
if (location.pathname === '/callback') return;
|
||||||
|
|
||||||
|
const getTargetPath = () => {
|
||||||
|
switch (gameState) {
|
||||||
|
case 'LANDING':
|
||||||
|
if (gamePin && location.pathname.startsWith('/play/')) {
|
||||||
|
return `/play/${gamePin}`;
|
||||||
|
}
|
||||||
|
return '/';
|
||||||
|
case 'CREATING':
|
||||||
|
case 'GENERATING':
|
||||||
|
return '/create';
|
||||||
|
case 'EDITING':
|
||||||
|
return '/edit';
|
||||||
|
case 'LOBBY':
|
||||||
|
case 'COUNTDOWN':
|
||||||
|
case 'QUESTION':
|
||||||
|
case 'REVEAL':
|
||||||
|
case 'SCOREBOARD':
|
||||||
|
case 'PODIUM':
|
||||||
|
case 'HOST_RECONNECTED':
|
||||||
|
if (gamePin) {
|
||||||
|
return role === 'HOST' ? `/host/${gamePin}` : `/play/${gamePin}`;
|
||||||
|
}
|
||||||
|
return '/';
|
||||||
|
case 'DISCONNECTED':
|
||||||
|
case 'WAITING_TO_REJOIN':
|
||||||
|
if (gamePin) {
|
||||||
|
return `/play/${gamePin}`;
|
||||||
|
}
|
||||||
|
return '/';
|
||||||
|
default:
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetPath = getTargetPath();
|
||||||
|
if (location.pathname !== targetPath) {
|
||||||
|
const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(gameState);
|
||||||
|
navigate(targetPath + location.search, { replace: useReplace });
|
||||||
|
}
|
||||||
|
}, [gameState, gamePin, role, navigate, location.pathname, location.search]);
|
||||||
|
|
||||||
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
|
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
|
||||||
|
|
||||||
const generateRandomName = (): string => {
|
const generateRandomName = (): string => {
|
||||||
|
|
@ -391,14 +442,69 @@ export const useGame = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const session = getStoredSession();
|
const initializeFromUrl = async () => {
|
||||||
if (session) {
|
const path = location.pathname;
|
||||||
if (session.role === 'HOST') {
|
|
||||||
reconnectAsHost(session);
|
if (path === '/callback') {
|
||||||
} else {
|
return;
|
||||||
reconnectAsClient(session);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const hostMatch = path.match(/^\/host\/(\d+)$/);
|
||||||
|
const playMatch = path.match(/^\/play\/(\d+)$/);
|
||||||
|
|
||||||
|
if (hostMatch) {
|
||||||
|
const pin = hostMatch[1];
|
||||||
|
const session = getStoredSession();
|
||||||
|
if (session && session.pin === pin && session.role === 'HOST') {
|
||||||
|
isInitializingFromUrl.current = true;
|
||||||
|
await reconnectAsHost(session);
|
||||||
|
isInitializingFromUrl.current = false;
|
||||||
|
} else {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playMatch) {
|
||||||
|
const pin = playMatch[1];
|
||||||
|
const session = getStoredSession();
|
||||||
|
if (session && session.pin === pin && session.role === 'CLIENT') {
|
||||||
|
isInitializingFromUrl.current = true;
|
||||||
|
await reconnectAsClient(session);
|
||||||
|
isInitializingFromUrl.current = false;
|
||||||
|
} else {
|
||||||
|
setGamePin(pin);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/create') {
|
||||||
|
isInitializingFromUrl.current = true;
|
||||||
|
setGameState('CREATING');
|
||||||
|
setRole('HOST');
|
||||||
|
isInitializingFromUrl.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/edit') {
|
||||||
|
const session = getStoredSession();
|
||||||
|
if (!session) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getStoredSession();
|
||||||
|
if (session) {
|
||||||
|
if (session.role === 'HOST') {
|
||||||
|
reconnectAsHost(session);
|
||||||
|
} else {
|
||||||
|
reconnectAsClient(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeFromUrl();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
|
@ -465,6 +571,10 @@ export const useGame = () => {
|
||||||
setGameState('CREATING');
|
setGameState('CREATING');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelCreation = () => {
|
||||||
|
setGameState('LANDING');
|
||||||
|
};
|
||||||
|
|
||||||
const finalizeManualQuiz = (manualQuiz: Quiz, saveToLibrary: boolean = false) => {
|
const finalizeManualQuiz = (manualQuiz: Quiz, saveToLibrary: boolean = false) => {
|
||||||
if (saveToLibrary) {
|
if (saveToLibrary) {
|
||||||
setPendingQuizToSave({ quiz: manualQuiz, topic: '' });
|
setPendingQuizToSave({ quiz: manualQuiz, topic: '' });
|
||||||
|
|
@ -940,6 +1050,7 @@ export const useGame = () => {
|
||||||
if (peerRef.current) {
|
if (peerRef.current) {
|
||||||
peerRef.current.destroy();
|
peerRef.current.destroy();
|
||||||
}
|
}
|
||||||
|
setGamePin(null);
|
||||||
setGameState('LANDING');
|
setGameState('LANDING');
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
@ -1175,7 +1286,11 @@ export const useGame = () => {
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
if (syncTimerRef.current) clearInterval(syncTimerRef.current);
|
if (syncTimerRef.current) clearInterval(syncTimerRef.current);
|
||||||
if (peerRef.current) peerRef.current.destroy();
|
if (peerRef.current) peerRef.current.destroy();
|
||||||
window.location.reload();
|
setGamePin(null);
|
||||||
|
setQuiz(null);
|
||||||
|
setPlayers([]);
|
||||||
|
setGameState('LANDING');
|
||||||
|
navigate('/', { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1187,7 +1302,7 @@ export const useGame = () => {
|
||||||
return {
|
return {
|
||||||
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
|
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
|
||||||
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName,
|
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName,
|
||||||
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
|
startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
|
||||||
updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame
|
updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
71
index.tsx
71
index.tsx
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { AuthProvider } from 'react-oidc-context';
|
import { AuthProvider } from 'react-oidc-context';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
@ -11,45 +12,47 @@ if (!rootElement) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSigninCallback = () => {
|
const onSigninCallback = () => {
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, '/');
|
||||||
};
|
};
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AuthProvider
|
<BrowserRouter>
|
||||||
{...oidcConfig}
|
<AuthProvider
|
||||||
onSigninCallback={onSigninCallback}
|
{...oidcConfig}
|
||||||
onRemoveUser={() => {
|
onSigninCallback={onSigninCallback}
|
||||||
window.localStorage.clear();
|
onRemoveUser={() => {
|
||||||
}}
|
localStorage.removeItem('kaboot_session');
|
||||||
>
|
|
||||||
<Toaster
|
|
||||||
position="top-center"
|
|
||||||
toastOptions={{
|
|
||||||
duration: 4000,
|
|
||||||
style: {
|
|
||||||
background: '#333',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
borderRadius: '1rem',
|
|
||||||
padding: '12px 20px',
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
iconTheme: {
|
|
||||||
primary: '#22c55e',
|
|
||||||
secondary: '#fff',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
iconTheme: {
|
|
||||||
primary: '#ef4444',
|
|
||||||
secondary: '#fff',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<App />
|
<Toaster
|
||||||
</AuthProvider>
|
position="top-center"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
style: {
|
||||||
|
background: '#333',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
padding: '12px 20px',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#22c55e',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#ef4444',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
58
package-lock.json
generated
58
package-lock.json
generated
|
|
@ -21,6 +21,7 @@
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-oidc-context": "^3.2.0",
|
"react-oidc-context": "^3.2.0",
|
||||||
|
"react-router-dom": "^7.12.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"unique-names-generator": "^4.7.1",
|
"unique-names-generator": "^4.7.1",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
|
|
@ -2235,6 +2236,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -3648,6 +3662,44 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||||
|
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
|
||||||
|
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.12.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
||||||
|
|
@ -3845,6 +3897,12 @@
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-oidc-context": "^3.2.0",
|
"react-oidc-context": "^3.2.0",
|
||||||
|
"react-router-dom": "^7.12.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"unique-names-generator": "^4.7.1",
|
"unique-names-generator": "^4.7.1",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
|
|
|
||||||
1199
tests/hooks/useGame.navigation.test.tsx
Normal file
1199
tests/hooks/useGame.navigation.test.tsx
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue