Fix UI jank a bit

This commit is contained in:
Joey Yakimowich-Payne 2026-01-14 21:47:40 -07:00
commit 3d6081823c
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
18 changed files with 193 additions and 87 deletions

View file

@ -137,10 +137,10 @@ function App() {
}); });
return ( return (
<div className="min-h-screen text-white relative"> <div className="h-screen text-white relative overflow-hidden">
<FloatingShapes /> <FloatingShapes />
<div className="relative z-10"> <div className="relative z-10 h-full">
{gameState === 'LANDING' || gameState === 'GENERATING' ? ( {gameState === 'LANDING' || gameState === 'GENERATING' ? (
<Landing <Landing
onGenerate={startQuizGen} onGenerate={startQuizGen}
@ -179,6 +179,7 @@ function App() {
role={role} role={role}
onStart={startGame} onStart={startGame}
onEndGame={role === 'HOST' ? endGame : undefined} onEndGame={role === 'HOST' ? endGame : undefined}
currentPlayerId={currentPlayerId}
/> />
{auth.isAuthenticated && pendingQuizToSave && ( {auth.isAuthenticated && pendingQuizToSave && (
<SaveQuizPrompt <SaveQuizPrompt

View file

@ -18,7 +18,7 @@ export const DisconnectedScreen: React.FC<DisconnectedScreenProps> = ({
onGoHome, onGoHome,
}) => { }) => {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center"> <div className="flex flex-col items-center justify-center h-screen p-4 md:p-6 text-center overflow-hidden">
<motion.div <motion.div
initial={{ scale: 0.8, opacity: 0 }} initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp } from 'lucide-react'; import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices } from 'lucide-react';
import type { GameConfig } from '../types'; import type { GameConfig } from '../types';
interface GameConfigPanelProps { interface GameConfigPanelProps {
@ -222,6 +222,16 @@ export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
onChange={(v) => update({ hostParticipates: v })} onChange={(v) => update({ hostParticipates: v })}
/> />
<ToggleRow
icon={<Dices size={20} />}
iconActive={config.randomNamesEnabled}
label="Random Names"
description="Assign fun two-word names to players"
checked={config.randomNamesEnabled}
onChange={(v) => update({ randomNamesEnabled: v })}
tooltip="Players will be assigned random names like 'Brave Falcon' or 'Swift Tiger' instead of choosing their own nicknames."
/>
<ToggleRow <ToggleRow
icon={<Flame size={20} />} icon={<Flame size={20} />}
iconActive={config.streakBonusEnabled} iconActive={config.streakBonusEnabled}

View file

@ -40,35 +40,34 @@ export const GameScreen: React.FC<GameScreenProps> = ({
return ( return (
<div className="flex flex-col h-screen max-h-screen overflow-hidden relative"> <div className="flex flex-col h-screen max-h-screen overflow-hidden relative">
{/* Header */} {/* Header */}
<div className="flex justify-between items-center p-4 md:p-6"> <div className="flex justify-between items-center p-2 md:p-6 shrink-0">
<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"> <div className="bg-white/20 backdrop-blur-md px-3 md:px-6 py-1 md:py-2 rounded-xl md:rounded-2xl font-black text-sm md:text-xl shadow-sm border-2 border-white/10">
{currentQuestionIndex + 1} / {totalQuestions} {currentQuestionIndex + 1} / {totalQuestions}
</div> </div>
{/* Whimsical Timer */}
<div className="relative"> <div className="relative">
<div className="absolute inset-0 bg-white/20 rounded-full blur-xl animate-pulse"></div> <div className="absolute inset-0 bg-white/20 rounded-full blur-xl animate-pulse"></div>
<div className={`bg-white ${timerTextColor} rounded-full w-20 h-20 flex items-center justify-center text-4xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 ${timerBorderColor} ${timerAnimation} relative z-10 transition-colors duration-300`}> <div className={`bg-white ${timerTextColor} rounded-full w-14 h-14 md:w-20 md:h-20 flex items-center justify-center text-2xl md:text-4xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 ${timerBorderColor} ${timerAnimation} relative z-10 transition-colors duration-300`}>
{timeLeftSeconds} {timeLeftSeconds}
</div> </div>
</div> </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"> <div className="bg-white/20 backdrop-blur-md px-3 md:px-6 py-1 md:py-2 rounded-xl md:rounded-2xl font-black text-sm md:text-xl shadow-sm border-2 border-white/10">
{isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'} {isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'}
</div> </div>
</div> </div>
{/* Question Area */} {/* Question Area */}
<div className="flex-1 flex flex-col items-center justify-center p-4 md:p-8 text-center relative z-10"> <div className="shrink-0 flex flex-col items-center justify-center p-2 md:p-8 text-center relative z-10">
{question && ( {question && (
<motion.div <motion.div
key={question.id} key={question.id}
initial={{ y: 20, opacity: 0, scale: 0.95 }} initial={{ y: 20, opacity: 0, scale: 0.95 }}
animate={{ y: 0, opacity: 1, scale: 1 }} animate={{ y: 0, opacity: 1, scale: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }} transition={{ duration: 0.3, ease: "easeOut" }}
className="bg-white text-black p-8 md:p-12 rounded-[2rem] shadow-[0_12px_0_rgba(0,0,0,0.1)] max-w-5xl w-full border-b-8 border-gray-200" className="bg-white text-black p-4 md:p-12 rounded-2xl md:rounded-[2rem] shadow-[0_12px_0_rgba(0,0,0,0.1)] max-w-5xl w-full border-b-4 md:border-b-8 border-gray-200"
> >
<h2 className="text-2xl md:text-5xl font-black text-[#333] font-display leading-tight"> <h2 className="text-lg md:text-5xl font-black text-[#333] font-display leading-tight">
{question.text} {question.text}
</h2> </h2>
</motion.div> </motion.div>
@ -76,7 +75,7 @@ export const GameScreen: React.FC<GameScreenProps> = ({
</div> </div>
{/* Answer Grid */} {/* Answer Grid */}
<div className="grid grid-cols-2 gap-3 md:gap-6 p-3 md:p-6 h-1/2 md:h-[45vh] pb-8 md:pb-12"> <div className="grid grid-cols-2 gap-2 md:gap-6 p-2 md:p-6 flex-1 min-h-0">
{displayOptions.map((option, idx) => { {displayOptions.map((option, idx) => {
const ShapeIcon = SHAPES[option.shape]; const ShapeIcon = SHAPES[option.shape];
const colorClass = COLORS[option.color]; const colorClass = COLORS[option.color];

View file

@ -20,7 +20,7 @@ export const HostReconnected: React.FC<HostReconnectedProps> = ({
onEndGame, onEndGame,
}) => { }) => {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center"> <div className="flex flex-col items-center justify-center h-screen p-4 md:p-6 text-center overflow-hidden">
<motion.div <motion.div
initial={{ scale: 0.8, opacity: 0 }} initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}

View file

@ -40,6 +40,8 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const [editingDefaultConfig, setEditingDefaultConfig] = useState<GameConfig | null>(null); const [editingDefaultConfig, setEditingDefaultConfig] = useState<GameConfig | null>(null);
const [preferencesOpen, setPreferencesOpen] = useState(false); const [preferencesOpen, setPreferencesOpen] = useState(false);
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false); const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
const [gameInfo, setGameInfo] = useState<{ randomNamesEnabled: boolean; quizTitle: string } | null>(null);
const [checkingPin, setCheckingPin] = useState(false);
const hasImageFile = selectedFiles.some(f => f.type.startsWith('image/')); 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 hasDocumentFile = selectedFiles.some(f => !f.type.startsWith('image/') && !['application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/html'].includes(f.type));
@ -67,6 +69,30 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
} }
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]); }, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
useEffect(() => {
const checkGamePin = async () => {
if (pin.trim().length === 6) {
setCheckingPin(true);
try {
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
const response = await fetch(`${backendUrl}/api/games/${pin.trim()}`);
if (response.ok) {
const data = await response.json();
setGameInfo({ randomNamesEnabled: data.randomNamesEnabled, quizTitle: data.quizTitle });
} else {
setGameInfo(null);
}
} catch {
setGameInfo(null);
}
setCheckingPin(false);
} else {
setGameInfo(null);
}
};
checkGamePin();
}, [pin]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files); const newFiles = Array.from(e.target.files);
@ -128,7 +154,9 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const handleJoinSubmit = (e: React.FormEvent) => { const handleJoinSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (pin.trim() && name.trim()) onJoin(pin, name); if (pin.trim() && (gameInfo?.randomNamesEnabled || name.trim())) {
onJoin(pin, name.trim() || 'Player');
}
}; };
const handleLoadQuiz = async (id: string) => { const handleLoadQuiz = async (id: string) => {
@ -145,7 +173,7 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
}; };
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative"> <div className="flex flex-col items-center justify-center h-screen p-4 text-center relative overflow-hidden">
<div className="absolute top-4 right-4 flex items-center gap-2"> <div className="absolute top-4 right-4 flex items-center gap-2">
{auth.isAuthenticated && ( {auth.isAuthenticated && (
<> <>
@ -174,17 +202,17 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
initial={{ scale: 0.8, opacity: 0, rotate: -2 }} initial={{ scale: 0.8, opacity: 0, rotate: -2 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }} animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{ type: "spring", bounce: 0.5 }} 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" className="bg-white text-gray-900 p-6 md:p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] max-w-md w-full border-4 border-white/50 max-h-[calc(100vh-2rem)] overflow-y-auto"
> >
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-4 md:mb-6">
<div className="bg-theme-primary p-4 rounded-3xl rotate-3 shadow-lg"> <div className="bg-theme-primary p-3 md:p-4 rounded-2xl md:rounded-3xl rotate-3 shadow-lg">
<BrainCircuit size={48} className="text-white" /> <BrainCircuit size={36} className="text-white md:w-12 md:h-12" />
</div> </div>
</div> </div>
<h1 className="text-5xl font-black mb-2 text-theme-primary tracking-tight">Kaboot</h1> <h1 className="text-4xl md:text-5xl font-black mb-1 md:mb-2 text-theme-primary tracking-tight">Kaboot</h1>
<p className="text-gray-500 font-bold mb-6">The AI Quiz Party</p> <p className="text-gray-500 font-bold mb-4 md:mb-6">The AI Quiz Party</p>
<div className="flex bg-gray-100 p-2 rounded-2xl mb-8"> <div className="flex bg-gray-100 p-2 rounded-2xl mb-6 md:mb-8">
<button <button
onClick={() => setMode('HOST')} 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'}`} 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'}`}
@ -432,19 +460,27 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
onChange={(e) => setPin(e.target.value)} 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" 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 {gameInfo?.randomNamesEnabled ? (
type="text" <div className="p-4 bg-theme-primary/10 rounded-2xl border-2 border-theme-primary/20">
placeholder="Nickname" <p className="text-theme-primary font-bold text-center">
value={name} You'll get a random name!
onChange={(e) => setName(e.target.value)} </p>
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" </div>
/> ) : (
<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 <button
type="submit" type="submit"
disabled={!pin.trim() || !name.trim()} disabled={!pin.trim() || (!gameInfo?.randomNamesEnabled && !name.trim()) || checkingPin}
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" 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"
> >
<Play fill="currentColor" /> Join Game {checkingPin ? <Loader2 className="animate-spin" /> : <Play fill="currentColor" />} Join Game
</button> </button>
</form> </form>
)} )}

View file

@ -11,23 +11,25 @@ interface LobbyProps {
role: 'HOST' | 'CLIENT'; role: 'HOST' | 'CLIENT';
onStart: () => void; onStart: () => void;
onEndGame?: () => void; onEndGame?: () => void;
currentPlayerId?: string | null;
} }
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame }) => { export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId }) => {
const isHost = role === 'HOST'; const isHost = role === 'HOST';
const realPlayers = players.filter(p => p.id !== 'host'); const realPlayers = players.filter(p => p.id !== 'host');
const currentPlayer = currentPlayerId ? players.find(p => p.id === currentPlayerId) : null;
return ( return (
<div className="flex flex-col min-h-screen p-6"> <div className="flex flex-col h-screen p-4 md:p-6 overflow-hidden">
<header className="flex flex-col md:flex-row justify-between items-center bg-white/10 p-6 rounded-[2rem] backdrop-blur-md mb-8 gap-6 border-4 border-white/20 shadow-xl"> <header className="flex flex-col md:flex-row justify-between items-center bg-white/10 p-4 md:p-6 rounded-2xl md:rounded-[2rem] backdrop-blur-md mb-4 md:mb-8 gap-3 md:gap-6 border-4 border-white/20 shadow-xl shrink-0">
<div className="flex flex-col items-center md:items-start"> <div className="flex flex-col items-center md:items-start">
<span className="text-white/80 font-bold uppercase tracking-widest text-sm mb-1">Game PIN</span> <span className="text-white/80 font-bold uppercase tracking-widest text-xs md:text-sm mb-1">Game PIN</span>
<div className="text-5xl md:text-6xl font-black bg-white text-theme-primary px-8 py-2 rounded-full shadow-[0_6px_0_rgba(0,0,0,0.2)] tracking-wider"> <div className="text-4xl md:text-6xl font-black bg-white text-theme-primary px-6 md:px-8 py-1 md:py-2 rounded-full shadow-[0_6px_0_rgba(0,0,0,0.2)] tracking-wider">
{gamePin} {gamePin}
</div> </div>
</div> </div>
<div className="text-center"> <div className="text-center hidden md:block">
<div className="text-3xl font-black font-display mb-2">{quizTitle}</div> <div className="text-3xl font-black font-display mb-2">{quizTitle}</div>
<div className="inline-flex items-center gap-2 bg-[#00000040] px-4 py-1 rounded-full"> <div className="inline-flex items-center gap-2 bg-[#00000040] px-4 py-1 rounded-full">
<div className="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div> <div className="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
@ -35,23 +37,23 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
</div> </div>
</div> </div>
<div className="bg-white/20 px-6 py-3 rounded-2xl font-black text-2xl flex flex-col items-center min-w-[120px]"> <div className="bg-white/20 px-4 md:px-6 py-2 md:py-3 rounded-xl md:rounded-2xl font-black text-xl md:text-2xl flex flex-col items-center min-w-[100px] md:min-w-[120px]">
<span>{realPlayers.length}</span> <span>{realPlayers.length}</span>
<span className="text-sm font-bold opacity-80 uppercase">Players</span> <span className="text-xs md:text-sm font-bold opacity-80 uppercase">Players</span>
</div> </div>
</header> </header>
<main className="flex-1 flex flex-col items-center justify-center"> <main className="flex-1 flex flex-col items-center justify-center overflow-hidden min-h-0">
{isHost ? ( {isHost ? (
<> <>
<div className="flex flex-wrap gap-4 justify-center w-full max-w-6xl mb-12 min-h-[200px] content-start"> <div className="flex flex-wrap gap-3 md:gap-4 justify-center w-full max-w-6xl pb-24 md:pb-28 overflow-y-auto content-start">
<AnimatePresence> <AnimatePresence>
{realPlayers.length === 0 && ( {realPlayers.length === 0 && (
<div className="flex flex-col items-center opacity-60 mt-12"> <div className="flex flex-col items-center opacity-60 mt-8 md:mt-12">
<div className="bg-white/10 p-6 rounded-full mb-4 animate-bounce"> <div className="bg-white/10 p-4 md:p-6 rounded-full mb-4 animate-bounce">
<Sparkles size={48} /> <Sparkles size={36} className="md:w-12 md:h-12" />
</div> </div>
<div className="text-3xl font-bold font-display">Waiting for players to join...</div> <div className="text-xl md:text-3xl font-bold font-display text-center px-4">Waiting for players to join...</div>
</div> </div>
)} )}
{realPlayers.map((player) => ( {realPlayers.map((player) => (
@ -60,7 +62,7 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
initial={{ scale: 0, rotate: -10 }} initial={{ scale: 0, rotate: -10 }}
animate={{ scale: 1, rotate: 0 }} animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, opacity: 0 }} exit={{ scale: 0, opacity: 0 }}
className="bg-white text-black px-6 py-3 rounded-full font-black text-xl shadow-[0_4px_0_rgba(0,0,0,0.2)] flex items-center gap-3 border-b-4 border-gray-200" className="bg-white text-black px-4 md:px-6 py-2 md:py-3 rounded-full font-black text-base md:text-xl shadow-[0_4px_0_rgba(0,0,0,0.2)] flex items-center gap-2 md:gap-3 border-b-4 border-gray-200"
> >
<PlayerAvatar seed={player.avatarSeed} size={20} /> <PlayerAvatar seed={player.avatarSeed} size={20} />
{player.name} {player.name}
@ -72,38 +74,45 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
<motion.div <motion.div
initial={{ y: 50, opacity: 0 }} initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
className="fixed bottom-8 flex gap-4" className="fixed bottom-4 md:bottom-8 flex gap-2 md:gap-4 px-4"
> >
{onEndGame && ( {onEndGame && (
<button <button
onClick={onEndGame} onClick={onEndGame}
className="bg-white/20 text-white px-8 py-5 rounded-full text-xl font-bold hover:bg-white/30 active:scale-95 transition-all flex items-center gap-2" className="bg-white/20 text-white px-4 md:px-8 py-3 md:py-5 rounded-full text-base md:text-xl font-bold hover:bg-white/30 active:scale-95 transition-all flex items-center gap-2"
> >
<X size={24} /> <X size={20} className="md:w-6 md:h-6" />
End Game <span className="hidden md:inline">End Game</span>
<span className="md:hidden">End</span>
</button> </button>
)} )}
<button <button
onClick={onStart} onClick={onStart}
disabled={realPlayers.length === 0} disabled={realPlayers.length === 0}
className="bg-white text-theme-primary px-16 py-5 rounded-full text-3xl font-black hover:scale-105 active:scale-95 transition-all shadow-[0_8px_0_rgba(0,0,0,0.2)] disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none disabled:translate-y-2" className="bg-white text-theme-primary px-8 md:px-16 py-3 md:py-5 rounded-full text-xl md:text-3xl font-black hover:scale-105 active:scale-95 transition-all shadow-[0_8px_0_rgba(0,0,0,0.2)] disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none disabled:translate-y-2"
> >
Start Game Start
</button> </button>
</motion.div> </motion.div>
</> </>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-full text-center p-8"> <div className="flex flex-col items-center justify-center flex-1 text-center p-4 md:p-8">
<motion.div <motion.div
initial={{ scale: 0.5 }} initial={{ scale: 0.5 }}
animate={{ scale: 1 }} animate={{ scale: 1 }}
transition={{ type: 'spring', bounce: 0.6 }} transition={{ type: 'spring', bounce: 0.6 }}
className="bg-white text-theme-primary p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] mb-8" className="bg-white p-6 md:p-8 rounded-2xl md:rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] mb-4 md:mb-8"
> >
<User size={80} strokeWidth={2.5} /> {currentPlayer ? (
<PlayerAvatar seed={currentPlayer.avatarSeed} size={60} className="md:w-20 md:h-20" />
) : (
<User size={60} strokeWidth={2.5} className="text-theme-primary md:w-20 md:h-20" />
)}
</motion.div> </motion.div>
<h2 className="text-5xl font-black mb-4 font-display">You're in!</h2> <h2 className="text-3xl md:text-5xl font-black mb-2 md:mb-4 font-display">
<p className="text-2xl font-bold opacity-80">See your name on the big screen?</p> {currentPlayer?.name || "You're in!"}
</h2>
<p className="text-lg md:text-2xl font-bold opacity-80">Waiting for the host to start...</p>
</div> </div>
)} )}
</main> </main>

View file

@ -34,10 +34,10 @@ export const Podium: React.FC<PodiumProps> = ({ players, onRestart }) => {
}, []); }, []);
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center p-4"> <div className="h-screen flex flex-col items-center justify-center p-4 overflow-hidden">
<h1 className="text-6xl font-black text-white mb-12 font-display drop-shadow-[0_5px_0_rgba(0,0,0,0.3)] tracking-wide">Podium</h1> <h1 className="text-4xl md:text-6xl font-black text-white mb-6 md:mb-12 font-display drop-shadow-[0_5px_0_rgba(0,0,0,0.3)] tracking-wide">Podium</h1>
<div className="flex items-end justify-center gap-4 md:gap-8 mb-12 w-full max-w-4xl h-96"> <div className="flex items-end justify-center gap-2 md:gap-8 mb-6 md:mb-12 w-full max-w-4xl h-48 md:h-96 px-2">
{/* Second Place */} {/* Second Place */}
{second && ( {second && (
<motion.div <motion.div

View file

@ -60,8 +60,8 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
}; };
return ( return (
<div className="min-h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center"> <div className="h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center overflow-hidden">
<div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white"> <div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white flex-1 min-h-0 flex flex-col">
<div className="bg-theme-primary p-8 text-white flex justify-between items-center relative overflow-hidden"> <div className="bg-theme-primary p-8 text-white flex justify-between items-center relative overflow-hidden">
<div className="relative z-10"> <div className="relative z-10">
<h2 className="text-4xl font-black font-display">Create Quiz</h2> <h2 className="text-4xl font-black font-display">Create Quiz</h2>
@ -79,7 +79,7 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
<div className="absolute right-20 bottom-[-50px] w-24 h-24 bg-white/10 rounded-full"></div> <div className="absolute right-20 bottom-[-50px] w-24 h-24 bg-white/10 rounded-full"></div>
</div> </div>
<div className="p-8 space-y-8"> <div className="p-6 md:p-8 space-y-6 md:space-y-8 flex-1 min-h-0 overflow-y-auto">
<div> <div>
<label className="block text-sm font-black uppercase tracking-wider text-gray-500 mb-2 ml-2">Quiz Title</label> <label className="block text-sm font-black uppercase tracking-wider text-gray-500 mb-2 ml-2">Quiz Title</label>
<input <input

View file

@ -153,8 +153,8 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
}; };
return ( return (
<div className="min-h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center"> <div className="h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center overflow-hidden">
<div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white"> <div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white flex-1 min-h-0 flex flex-col">
<div className="bg-theme-primary p-6 text-white relative overflow-hidden"> <div className="bg-theme-primary p-6 text-white relative overflow-hidden">
<div className="relative z-10 flex items-center justify-between gap-4"> <div className="relative z-10 flex items-center justify-between gap-4">
<button <button
@ -206,7 +206,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
<div className="absolute right-20 bottom-[-50px] w-24 h-24 bg-white/10 rounded-full"></div> <div className="absolute right-20 bottom-[-50px] w-24 h-24 bg-white/10 rounded-full"></div>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4 flex-1 min-h-0 overflow-y-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-700">Questions</h2> <h2 className="text-lg font-bold text-gray-700">Questions</h2>
<button <button

View file

@ -9,7 +9,7 @@ interface WaitingToRejoinProps {
export const WaitingToRejoin: React.FC<WaitingToRejoinProps> = ({ playerName, score }) => { export const WaitingToRejoin: React.FC<WaitingToRejoinProps> = ({ playerName, score }) => {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center"> <div className="flex flex-col items-center justify-center h-screen p-4 md:p-6 text-center overflow-hidden">
<motion.div <motion.div
initial={{ scale: 0.8, opacity: 0 }} initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}

View file

@ -3,6 +3,7 @@ import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Questi
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';
import { Peer, DataConnection } from 'peerjs'; import { Peer, DataConnection } from 'peerjs';
import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
const SESSION_STORAGE_KEY = 'kaboot_session'; const SESSION_STORAGE_KEY = 'kaboot_session';
@ -86,6 +87,15 @@ export const useGame = () => {
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + ""; const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
const generateRandomName = (): string => {
return uniqueNamesGenerator({
dictionaries: [adjectives, animals],
separator: ' ',
style: 'capital',
length: 2,
});
};
const syncGameState = useCallback(async () => { const syncGameState = useCallback(async () => {
if (!gamePinRef.current || !hostSecretRef.current) return; if (!gamePinRef.current || !hostSecretRef.current) return;
if (gameStateRef.current === 'LANDING' || gameStateRef.current === 'EDITING' || gameStateRef.current === 'CREATING' || gameStateRef.current === 'GENERATING') return; if (gameStateRef.current === 'LANDING' || gameStateRef.current === 'EDITING' || gameStateRef.current === 'CREATING' || gameStateRef.current === 'GENERATING') return;
@ -510,13 +520,23 @@ export const useGame = () => {
allPlayers: playersRef.current.map(p => ({ id: p.id, name: p.name, lastAnswerCorrect: p.lastAnswerCorrect })) allPlayers: playersRef.current.map(p => ({ id: p.id, name: p.name, lastAnswerCorrect: p.lastAnswerCorrect }))
}); });
let assignedName = payload.name;
if (!reconnectedPlayer && gameConfigRef.current.randomNamesEnabled) {
assignedName = generateRandomName();
}
let updatedPlayers = playersRef.current;
let newPlayer: Player | null = null;
if (reconnectedPlayer) { if (reconnectedPlayer) {
setPlayers(prev => prev.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p)); updatedPlayers = playersRef.current.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p);
setPlayers(updatedPlayers);
assignedName = reconnectedPlayer.name;
} else if (!playersRef.current.find(p => p.id === conn.peer)) { } else if (!playersRef.current.find(p => p.id === conn.peer)) {
const colorIndex = playersRef.current.length % PLAYER_COLORS.length; const colorIndex = playersRef.current.length % PLAYER_COLORS.length;
const newPlayer: Player = { newPlayer = {
id: conn.peer, id: conn.peer,
name: payload.name, name: assignedName,
score: 0, score: 0,
previousScore: 0, previousScore: 0,
streak: 0, streak: 0,
@ -527,7 +547,8 @@ export const useGame = () => {
avatarSeed: Math.random(), avatarSeed: Math.random(),
color: PLAYER_COLORS[colorIndex] color: PLAYER_COLORS[colorIndex]
}; };
setPlayers(prev => [...prev, newPlayer]); updatedPlayers = [...playersRef.current, newPlayer];
setPlayers(updatedPlayers);
} }
const currentState = gameStateRef.current; const currentState = gameStateRef.current;
@ -538,7 +559,7 @@ export const useGame = () => {
const welcomePayload: any = { const welcomePayload: any = {
playerId: conn.peer, playerId: conn.peer,
quizTitle: currentQuiz?.title || 'Kaboot', quizTitle: currentQuiz?.title || 'Kaboot',
players: playersRef.current, players: updatedPlayers,
gameState: currentState, gameState: currentState,
currentQuestionIndex: currentIndex, currentQuestionIndex: currentIndex,
totalQuestions: currentQuiz?.questions.length || 0, totalQuestions: currentQuiz?.questions.length || 0,
@ -548,6 +569,7 @@ export const useGame = () => {
hasAnswered: false, hasAnswered: false,
lastAnswerCorrect: null, lastAnswerCorrect: null,
selectedShape: null, selectedShape: null,
assignedName,
}; };
if (currentQuestion) { if (currentQuestion) {
@ -915,6 +937,13 @@ export const useGame = () => {
if (payload.players) { if (payload.players) {
setPlayers(payload.players); setPlayers(payload.players);
} }
if (payload.assignedName) {
setCurrentPlayerName(payload.assignedName);
const session = getStoredSession();
if (session) {
storeSession({ ...session, playerName: payload.assignedName });
}
}
if (payload.selectedShape && payload.options) { if (payload.selectedShape && payload.options) {
const matchedOption = payload.options.find(opt => opt.shape === payload.selectedShape); const matchedOption = payload.options.find(opt => opt.shape === payload.selectedShape);
if (matchedOption) { if (matchedOption) {

View file

@ -1,16 +1,11 @@
html, body, #root {
height: 100%;
min-height: 100dvh;
}
.h-screen { .h-screen {
height: 100dvh; height: 100dvh !important;
} }
.min-h-screen { .min-h-screen {
min-height: 100dvh; min-height: 100dvh !important;
} }
.max-h-screen { .max-h-screen {
max-height: 100dvh; max-height: 100dvh !important;
} }

View file

@ -33,8 +33,19 @@
font-family: 'Montserrat', sans-serif; font-family: 'Montserrat', sans-serif;
background: linear-gradient(180deg, var(--theme-primary) 0%, var(--theme-primary-darker) 100%); background: linear-gradient(180deg, var(--theme-primary) 0%, var(--theme-primary-darker) 100%);
color: white; color: white;
overflow-x: hidden; overflow: hidden;
min-height: 100dvh; height: 100dvh;
margin: 0;
padding: 0;
}
html {
height: 100dvh;
overflow: hidden;
}
#root {
height: 100%;
} }
h1, h2, h3, h4, h5, h6, .font-display { h1, h2, h3, h4, h5, h6, .font-display {

10
package-lock.json generated
View file

@ -22,6 +22,7 @@
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-oidc-context": "^3.2.0", "react-oidc-context": "^3.2.0",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"unique-names-generator": "^4.7.1",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
@ -4160,6 +4161,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unique-names-generator": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
"integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View file

@ -26,6 +26,7 @@
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-oidc-context": "^3.2.0", "react-oidc-context": "^3.2.0",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"unique-names-generator": "^4.7.1",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -74,6 +74,7 @@ router.get('/:pin', (req: Request, res: Response) => {
return; return;
} }
const gameConfig = JSON.parse(session.game_config);
res.json({ res.json({
pin: session.pin, pin: session.pin,
hostPeerId: session.host_peer_id, hostPeerId: session.host_peer_id,
@ -81,6 +82,7 @@ router.get('/:pin', (req: Request, res: Response) => {
currentQuestionIndex: session.current_question_index, currentQuestionIndex: session.current_question_index,
quizTitle: JSON.parse(session.quiz_data).title, quizTitle: JSON.parse(session.quiz_data).title,
playerCount: JSON.parse(session.players_data).length, playerCount: JSON.parse(session.players_data).length,
randomNamesEnabled: gameConfig.randomNamesEnabled || false,
}); });
} catch (err) { } catch (err) {
console.error('Error getting game session:', err); console.error('Error getting game session:', err);

View file

@ -62,6 +62,7 @@ export interface GameConfig {
shuffleQuestions: boolean; shuffleQuestions: boolean;
shuffleAnswers: boolean; shuffleAnswers: boolean;
hostParticipates: boolean; hostParticipates: boolean;
randomNamesEnabled: boolean;
streakBonusEnabled: boolean; streakBonusEnabled: boolean;
streakThreshold: number; streakThreshold: number;
streakMultiplier: number; streakMultiplier: number;
@ -77,6 +78,7 @@ export const DEFAULT_GAME_CONFIG: GameConfig = {
shuffleQuestions: false, shuffleQuestions: false,
shuffleAnswers: false, shuffleAnswers: false,
hostParticipates: true, hostParticipates: true,
randomNamesEnabled: false,
streakBonusEnabled: false, streakBonusEnabled: false,
streakThreshold: 3, streakThreshold: 3,
streakMultiplier: 1.1, streakMultiplier: 1.1,
@ -170,6 +172,7 @@ export type NetworkMessage =
options?: AnswerOption[]; options?: AnswerOption[];
correctShape?: string; correctShape?: string;
timeLeft?: number; timeLeft?: number;
assignedName?: string;
} } } }
| { type: 'PLAYER_JOINED'; payload: { player: Player } } | { type: 'PLAYER_JOINED'; payload: { player: Player } }
| { type: 'GAME_START'; payload: {} } | { type: 'GAME_START'; payload: {} }