317 lines
18 KiB
TypeScript
317 lines
18 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Player } from '../types';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Sparkles, User, X, Link, Check, QrCode, Crown } from 'lucide-react';
|
|
import { QRCodeSVG } from 'qrcode.react';
|
|
import { PlayerAvatar } from './PlayerAvatar';
|
|
import toast from 'react-hot-toast';
|
|
|
|
interface LobbyProps {
|
|
quizTitle: string;
|
|
players: Player[];
|
|
gamePin: string | null;
|
|
role: 'HOST' | 'CLIENT';
|
|
onStart: () => void;
|
|
onEndGame?: () => void;
|
|
currentPlayerId?: string | null;
|
|
hostParticipates?: boolean;
|
|
presenterId?: string | null;
|
|
onSetPresenter?: (playerId: string | null) => void;
|
|
}
|
|
|
|
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId, hostParticipates = false, presenterId, onSetPresenter }) => {
|
|
const isHost = role === 'HOST';
|
|
const hostPlayer = players.find(p => p.id === 'host');
|
|
const realPlayers = players.filter(p => p.id !== 'host');
|
|
const currentPlayer = currentPlayerId ? players.find(p => p.id === currentPlayerId) : null;
|
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
const [isQrModalOpen, setIsQrModalOpen] = useState(false);
|
|
const isPresenter = currentPlayerId === presenterId;
|
|
const canSelectPresenter = isHost && !hostParticipates && onSetPresenter;
|
|
|
|
React.useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setIsQrModalOpen(false);
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, []);
|
|
|
|
const copyJoinLink = async () => {
|
|
if (!gamePin) return;
|
|
const joinUrl = `${window.location.origin}/play/${gamePin}`;
|
|
await navigator.clipboard.writeText(joinUrl);
|
|
setLinkCopied(true);
|
|
toast.success('Join link copied!');
|
|
setTimeout(() => setLinkCopied(false), 2000);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-screen p-4 md:p-6 overflow-hidden">
|
|
<header className="w-full max-w-[90rem] mx-auto mb-4 md:mb-8 shrink-0 z-20">
|
|
<div className="md:hidden flex flex-col gap-3 w-full">
|
|
<div className="bg-white/10 backdrop-blur-md p-4 rounded-2xl border-2 border-white/20 text-center shadow-lg relative overflow-hidden">
|
|
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white/50 to-transparent opacity-50"></div>
|
|
<h1 className="text-xl font-black font-display leading-tight text-white drop-shadow-md line-clamp-2">{quizTitle}</h1>
|
|
<div className="inline-flex items-center gap-1.5 mt-2 bg-black/20 px-3 py-0.5 rounded-full">
|
|
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse shadow-[0_0_8px_rgba(74,222,128,0.6)]" />
|
|
<span className="text-[10px] font-bold text-white/90 uppercase tracking-widest">Live</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="col-span-2 bg-white/10 backdrop-blur-md p-4 rounded-2xl border-2 border-white/20 shadow-lg flex flex-col items-center justify-center">
|
|
<span className="text-white/60 font-bold uppercase tracking-widest text-[10px] mb-1">Game PIN</span>
|
|
<div className="text-5xl font-black bg-white text-theme-primary px-4 py-2 rounded-xl shadow-[0_4px_0_rgba(0,0,0,0.1)] tracking-[0.15em] w-full text-center">
|
|
{gamePin}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`bg-white/10 backdrop-blur-md p-3 rounded-2xl border-2 border-white/20 shadow-lg flex flex-col items-center justify-center gap-1 ${!isHost ? 'col-span-2' : ''}`}>
|
|
<span className="text-white/60 font-bold uppercase tracking-widest text-[10px]">Players</span>
|
|
<div className="flex items-center gap-2">
|
|
<User size={20} className="text-white/90" />
|
|
<span className="text-2xl font-black text-white">{realPlayers.length}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{isHost && (
|
|
<div className="flex flex-col gap-2 h-full">
|
|
<button
|
|
onClick={() => setIsQrModalOpen(true)}
|
|
className="flex-1 bg-white/20 hover:bg-white/30 border-2 border-white/10 rounded-xl flex items-center justify-center gap-2 transition-all active:scale-95 py-2"
|
|
>
|
|
<QrCode size={16} />
|
|
<span className="font-bold text-xs">QR Code</span>
|
|
</button>
|
|
<button
|
|
onClick={copyJoinLink}
|
|
className="flex-1 bg-white/20 hover:bg-white/30 border-2 border-white/10 rounded-xl flex items-center justify-center gap-2 transition-all active:scale-95 py-2"
|
|
>
|
|
{linkCopied ? <Check size={16} className="text-green-400"/> : <Link size={16}/>}
|
|
<span className="font-bold text-xs">{linkCopied ? 'Copied' : 'Link'}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hidden md:flex justify-between items-center bg-white/10 p-5 rounded-[2.5rem] backdrop-blur-md border-4 border-white/20 shadow-2xl relative overflow-hidden min-h-[140px]">
|
|
<div className="flex-1 flex items-center gap-6">
|
|
{isHost && gamePin && (
|
|
<button
|
|
onClick={() => setIsQrModalOpen(true)}
|
|
className="bg-white p-3 rounded-2xl shadow-lg hover:scale-105 hover:shadow-xl transition-all group relative cursor-pointer active:scale-95 shrink-0"
|
|
title="Show Big QR Code"
|
|
>
|
|
<QRCodeSVG
|
|
value={`${window.location.origin}/play/${gamePin}`}
|
|
size={70}
|
|
level="M"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/60 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
<QrCode className="text-white w-8 h-8" />
|
|
</div>
|
|
</button>
|
|
)}
|
|
|
|
<div className="flex flex-col justify-center min-w-0">
|
|
<span className="text-white/60 font-bold uppercase tracking-widest text-xs mb-1.5 ml-1 truncate">Join at kaboot.com</span>
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-6xl font-black bg-white text-theme-primary px-8 py-2 rounded-2xl shadow-[0_6px_0_rgba(0,0,0,0.15)] tracking-widest whitespace-nowrap">
|
|
{gamePin}
|
|
</div>
|
|
{isHost && (
|
|
<button
|
|
onClick={copyJoinLink}
|
|
className="bg-white/10 hover:bg-white/20 border-2 border-white/20 p-4 rounded-2xl transition-all active:scale-95 group shrink-0"
|
|
title="Copy join link"
|
|
>
|
|
{linkCopied ?
|
|
<Check size={28} className="text-green-400" /> :
|
|
<Link size={28} className="text-white group-hover:text-white/100 opacity-80 group-hover:opacity-100" />
|
|
}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-[1.5] flex flex-col items-center justify-center text-center px-4 relative z-10 min-w-0">
|
|
<h1 className="text-3xl lg:text-4xl xl:text-5xl font-black font-display mb-3 drop-shadow-md leading-tight">
|
|
{quizTitle}
|
|
</h1>
|
|
<div className="inline-flex items-center gap-2.5 bg-black/30 px-5 py-1.5 rounded-full backdrop-blur-sm border border-white/10">
|
|
<div className="w-3 h-3 bg-green-400 rounded-full animate-pulse shadow-[0_0_12px_rgba(74,222,128,0.8)]"></div>
|
|
<span className="font-bold text-sm tracking-widest text-white/90 uppercase">Live Lobby</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 flex justify-end items-center">
|
|
<div className="bg-black/20 hover:bg-black/30 transition-colors px-8 py-4 rounded-[2rem] border-2 border-white/10 backdrop-blur-sm flex flex-col items-center min-w-[140px]">
|
|
<span className="text-4xl font-black text-white drop-shadow-sm">{realPlayers.length}</span>
|
|
<span className="text-xs font-bold text-white/60 uppercase tracking-widest mt-1">Players</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="flex-1 flex flex-col items-center justify-center overflow-hidden min-h-0">
|
|
{isHost ? (
|
|
<>
|
|
<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>
|
|
{realPlayers.length === 0 && !hostParticipates && (
|
|
<div className="flex flex-col items-center opacity-60 mt-8 md:mt-12">
|
|
<div className="bg-white/10 p-4 md:p-6 rounded-full mb-4 animate-bounce">
|
|
<Sparkles size={36} className="md:w-12 md:h-12" />
|
|
</div>
|
|
<div className="text-xl md:text-3xl font-bold font-display text-center px-4">Waiting for players to join...</div>
|
|
</div>
|
|
)}
|
|
{realPlayers.length > 0 && !hostParticipates && canSelectPresenter && (
|
|
<div className="w-full text-center text-white/60 text-sm mb-2">
|
|
<Crown size={14} className="inline mr-1 text-yellow-400" />
|
|
Click a player to make them presenter (can advance screens)
|
|
</div>
|
|
)}
|
|
{hostParticipates && hostPlayer && (
|
|
<motion.div
|
|
key="host"
|
|
initial={{ scale: 0, rotate: -10 }}
|
|
animate={{ scale: 1, rotate: 0 }}
|
|
exit={{ scale: 0, opacity: 0 }}
|
|
className="bg-yellow-400 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-yellow-500"
|
|
>
|
|
<PlayerAvatar seed={hostPlayer.avatarSeed} size={20} />
|
|
{hostPlayer.name}
|
|
<span className="text-xs bg-black/20 px-2 py-0.5 rounded-full">HOST</span>
|
|
</motion.div>
|
|
)}
|
|
{realPlayers.map((player) => {
|
|
const isPlayerPresenter = player.id === presenterId;
|
|
return (
|
|
<motion.div
|
|
key={player.id}
|
|
initial={{ scale: 0, rotate: -10 }}
|
|
animate={{ scale: 1, rotate: 0 }}
|
|
exit={{ scale: 0, opacity: 0 }}
|
|
onClick={() => canSelectPresenter && onSetPresenter(player.id)}
|
|
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 ${
|
|
isPlayerPresenter ? 'border-yellow-400 ring-2 ring-yellow-400' : 'border-gray-200'
|
|
} ${canSelectPresenter ? 'cursor-pointer hover:scale-105 transition-transform' : ''}`}
|
|
>
|
|
{isPlayerPresenter && (
|
|
<Crown size={18} className="text-yellow-500 -ml-1" />
|
|
)}
|
|
<PlayerAvatar seed={player.avatarSeed} size={20} />
|
|
{player.name}
|
|
{isPlayerPresenter && (
|
|
<span className="text-xs bg-yellow-400 text-yellow-900 px-2 py-0.5 rounded-full">PRESENTER</span>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<motion.div
|
|
initial={{ y: 50, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
className="fixed bottom-4 md:bottom-8 flex gap-2 md:gap-4 px-4"
|
|
>
|
|
{onEndGame && (
|
|
<button
|
|
onClick={onEndGame}
|
|
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={20} className="md:w-6 md:h-6" />
|
|
<span className="hidden md:inline">End Game</span>
|
|
<span className="md:hidden">End</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onStart}
|
|
disabled={realPlayers.length === 0}
|
|
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
|
|
</button>
|
|
</motion.div>
|
|
</>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center flex-1 text-center p-4 md:p-8">
|
|
<motion.div
|
|
initial={{ scale: 0.5 }}
|
|
animate={{ scale: 1 }}
|
|
transition={{ type: 'spring', bounce: 0.6 }}
|
|
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 relative ${isPresenter ? 'ring-4 ring-yellow-400' : ''}`}
|
|
>
|
|
{isPresenter && (
|
|
<div className="absolute -top-3 -right-3 bg-yellow-400 p-2 rounded-full shadow-lg">
|
|
<Crown size={24} className="text-yellow-900" />
|
|
</div>
|
|
)}
|
|
{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>
|
|
<h2 className="text-3xl md:text-5xl font-black mb-2 md:mb-4 font-display">
|
|
{currentPlayer?.name || "You're in!"}
|
|
</h2>
|
|
{isPresenter ? (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<span className="bg-yellow-400 text-yellow-900 px-4 py-1 rounded-full font-bold text-sm flex items-center gap-2">
|
|
<Crown size={16} />
|
|
You are the Presenter
|
|
</span>
|
|
<p className="text-lg md:text-xl font-bold opacity-80">You can advance screens during the game</p>
|
|
</div>
|
|
) : (
|
|
<p className="text-lg md:text-2xl font-bold opacity-80">Waiting for the host to start...</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
<AnimatePresence>
|
|
{isQrModalOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
|
onClick={() => setIsQrModalOpen(false)}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.8, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.8, opacity: 0 }}
|
|
className="bg-white p-8 md:p-12 rounded-[2rem] shadow-2xl flex flex-col items-center gap-6 max-w-lg w-full"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h2 className="text-3xl md:text-4xl font-black text-theme-primary font-display">Scan to Join</h2>
|
|
<div className="bg-white p-4 rounded-3xl shadow-[inset_0_4px_12px_rgba(0,0,0,0.1)] border-4 border-gray-100">
|
|
<QRCodeSVG
|
|
value={`${window.location.origin}/play/${gamePin}`}
|
|
size={300}
|
|
level="H"
|
|
className="w-full h-full max-w-[300px] max-h-[300px]"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col items-center gap-2 w-full text-center">
|
|
<div className="text-gray-400 font-bold uppercase tracking-widest text-sm">Or visit URL</div>
|
|
<div className="text-lg md:text-xl font-black bg-gray-100 text-gray-800 px-6 py-3 rounded-xl break-all w-full select-all">
|
|
{`${window.location.origin}/play/${gamePin}`}
|
|
</div>
|
|
</div>
|
|
<div className="text-sm text-gray-400 font-bold mt-2">Tap backdrop or ESC to close</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
};
|