Scoreboard ui stuff

This commit is contained in:
Joey Yakimowich-Payne 2026-01-15 08:21:38 -07:00
commit 279dc7f2c3
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
12 changed files with 1558 additions and 77 deletions

View file

@ -60,6 +60,7 @@ function App() {
gamePin, gamePin,
startQuizGen, startQuizGen,
startManualCreation, startManualCreation,
cancelCreation,
finalizeManualQuiz, finalizeManualQuiz,
loadSavedQuiz, loadSavedQuiz,
joinGame, joinGame,
@ -149,13 +150,14 @@ function App() {
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}

View file

@ -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.

View file

@ -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>

View file

@ -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"

View file

@ -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) => {

View file

@ -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>

View file

@ -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">

View file

@ -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
}; };
}; };

View file

@ -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
View file

@ -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",

View file

@ -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"

File diff suppressed because it is too large Load diff