Initial commit
This commit is contained in:
commit
c87ebf0a74
22 changed files with 4973 additions and 0 deletions
142
components/GameScreen.tsx
Normal file
142
components/GameScreen.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import React from 'react';
|
||||
import { Question, AnswerOption, GameState, GameRole } from '../types';
|
||||
import { COLORS, SHAPES } from '../constants';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface GameScreenProps {
|
||||
question?: Question;
|
||||
timeLeft: number;
|
||||
totalQuestions: number;
|
||||
currentQuestionIndex: number;
|
||||
gameState: GameState;
|
||||
role: GameRole;
|
||||
onAnswer: (isCorrect: boolean) => void;
|
||||
hasAnswered: boolean;
|
||||
lastPointsEarned: number | null;
|
||||
}
|
||||
|
||||
export const GameScreen: React.FC<GameScreenProps> = ({
|
||||
question,
|
||||
timeLeft,
|
||||
totalQuestions,
|
||||
currentQuestionIndex,
|
||||
gameState,
|
||||
role,
|
||||
onAnswer,
|
||||
hasAnswered,
|
||||
}) => {
|
||||
const isClient = role === 'CLIENT';
|
||||
const displayOptions = question?.options || [];
|
||||
|
||||
// Timer styling logic
|
||||
const isUrgent = timeLeft < 5 && timeLeft > 0;
|
||||
const timerBorderColor = isUrgent ? 'border-red-500' : 'border-white';
|
||||
const timerTextColor = isUrgent ? 'text-red-500' : 'text-[#46178f]';
|
||||
const timerAnimation = isUrgent ? 'animate-ping' : '';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen max-h-screen overflow-hidden relative">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-4 md:p-6">
|
||||
<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">
|
||||
{currentQuestionIndex + 1} / {totalQuestions}
|
||||
</div>
|
||||
|
||||
{/* Whimsical Timer */}
|
||||
<div className="relative">
|
||||
<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`}>
|
||||
{timeLeft}
|
||||
</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">
|
||||
{isClient ? 'Controller' : 'Host'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Area */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-4 md:p-8 text-center relative z-10">
|
||||
{question && (
|
||||
<motion.div
|
||||
key={question.id}
|
||||
initial={{ y: 20, opacity: 0, scale: 0.95 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
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"
|
||||
>
|
||||
<h2 className="text-2xl md:text-5xl font-black text-[#333] font-display leading-tight">
|
||||
{question.text}
|
||||
</h2>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{displayOptions.map((option, idx) => {
|
||||
const ShapeIcon = SHAPES[option.shape];
|
||||
const colorClass = COLORS[option.color];
|
||||
|
||||
let opacityClass = "opacity-100";
|
||||
let scaleClass = "scale-100";
|
||||
|
||||
// If answering phase and user answered, dim everything
|
||||
if (hasAnswered) {
|
||||
opacityClass = "opacity-50 cursor-not-allowed grayscale";
|
||||
scaleClass = "scale-95";
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={idx}
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: idx * 0.03, type: 'spring', stiffness: 500, damping: 30 }}
|
||||
disabled={hasAnswered}
|
||||
onClick={() => onAnswer(option as any)}
|
||||
className={`
|
||||
${colorClass} ${opacityClass} ${scaleClass}
|
||||
rounded-3xl shadow-[0_8px_0_rgba(0,0,0,0.2)]
|
||||
flex flex-col md:flex-row items-center justify-center md:justify-start
|
||||
p-4 md:p-8
|
||||
active:shadow-none active:translate-y-[8px] active:scale-95
|
||||
transition-all duration-300 relative group overflow-hidden border-b-8 border-black/10
|
||||
`}
|
||||
>
|
||||
<ShapeIcon className="absolute -right-6 -bottom-6 w-32 h-32 text-black/10 rotate-12 group-hover:rotate-45 transition-transform duration-500" />
|
||||
|
||||
<div className="flex-shrink-0 mb-2 md:mb-0 md:mr-6 bg-black/20 p-3 md:p-4 rounded-2xl shadow-inner">
|
||||
<ShapeIcon className="w-8 h-8 md:w-12 md:h-12 text-white" fill="currentColor" />
|
||||
</div>
|
||||
|
||||
<span className="text-lg md:text-3xl font-black text-white text-center md:text-left drop-shadow-md leading-tight relative z-10">
|
||||
{option.text}
|
||||
</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* "Answer Sent" Overlay */}
|
||||
<AnimatePresence>
|
||||
{isClient && hasAnswered && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-[#46178f]/95 flex flex-col items-center justify-center z-50 p-8 text-center"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||
className="text-6xl mb-6"
|
||||
>
|
||||
🚀
|
||||
</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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
components/Landing.tsx
Normal file
127
components/Landing.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BrainCircuit, Loader2, Users, Play, PenTool } from 'lucide-react';
|
||||
|
||||
interface LandingProps {
|
||||
onGenerate: (topic: string) => void;
|
||||
onCreateManual: () => void;
|
||||
onJoin: (pin: string, name: string) => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onJoin, isLoading, error }) => {
|
||||
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const handleHostSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (topic.trim()) onGenerate(topic);
|
||||
};
|
||||
|
||||
const handleJoinSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (pin.trim() && name.trim()) onJoin(pin, name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0, rotate: -2 }}
|
||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", bounce: 0.5 }}
|
||||
className="bg-white text-gray-900 p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] max-w-md w-full border-4 border-white/50"
|
||||
>
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-[#46178f] p-4 rounded-3xl rotate-3 shadow-lg">
|
||||
<BrainCircuit size={48} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-5xl font-black mb-2 text-[#46178f] tracking-tight">OpenHoot</h1>
|
||||
<p className="text-gray-500 font-bold mb-6">The AI Quiz Party</p>
|
||||
|
||||
<div className="flex bg-gray-100 p-2 rounded-2xl mb-8">
|
||||
<button
|
||||
onClick={() => setMode('HOST')}
|
||||
className={`flex-1 py-3 rounded-xl font-black text-lg transition-all duration-200 ${mode === 'HOST' ? 'bg-white shadow-md text-[#46178f] scale-105' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
>
|
||||
Host
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('JOIN')}
|
||||
className={`flex-1 py-3 rounded-xl font-black text-lg transition-all duration-200 ${mode === 'JOIN' ? 'bg-white shadow-md text-[#46178f] scale-105' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'HOST' ? (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleHostSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Topic (e.g. 'Space')"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-[#46178f] focus:ring-4 focus:ring-[#46178f]/20 outline-none transition-all placeholder:font-medium text-center"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !topic.trim()}
|
||||
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3"
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin" /> : <><BrainCircuit size={24} /> Generate Quiz</>}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative flex py-2 items-center opacity-50">
|
||||
<div className="flex-grow border-t-2 border-gray-300"></div>
|
||||
<span className="flex-shrink mx-4 text-gray-400 font-bold">OR</span>
|
||||
<div className="flex-grow border-t-2 border-gray-300"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onCreateManual}
|
||||
className="w-full bg-white border-2 border-[#46178f] text-[#46178f] py-3 rounded-2xl text-lg font-black hover:bg-purple-50 shadow-[0_4px_0_#46178f] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<PenTool size={20} /> Create Manually
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleJoinSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Game PIN"
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-[#46178f] focus:ring-4 focus:ring-[#46178f]/20 outline-none text-center"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nickname"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-[#46178f] focus:ring-4 focus:ring-[#46178f]/20 outline-none text-center"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!pin.trim() || !name.trim()}
|
||||
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3"
|
||||
>
|
||||
<Play fill="currentColor" /> Join Game
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<motion.div initial={{ height: 0 }} animate={{ height: 'auto' }} className="overflow-hidden mt-4">
|
||||
<p className="text-red-500 font-bold bg-red-50 p-3 rounded-xl border-2 border-red-100">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
103
components/Lobby.tsx
Normal file
103
components/Lobby.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import React from 'react';
|
||||
import { Player } from '../types';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { User, Sparkles } from 'lucide-react';
|
||||
|
||||
interface LobbyProps {
|
||||
quizTitle: string;
|
||||
players: Player[];
|
||||
gamePin: string | null;
|
||||
role: 'HOST' | 'CLIENT';
|
||||
onStart: () => void;
|
||||
}
|
||||
|
||||
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart }) => {
|
||||
const isHost = role === 'HOST';
|
||||
const realPlayers = players.filter(p => p.id !== 'host');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen p-6">
|
||||
<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">
|
||||
<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>
|
||||
<div className="text-5xl md:text-6xl font-black bg-white text-[#46178f] px-8 py-2 rounded-full shadow-[0_6px_0_rgba(0,0,0,0.2)] tracking-wider">
|
||||
{gamePin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<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="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="font-bold">Live Lobby</span>
|
||||
</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]">
|
||||
<span>{realPlayers.length}</span>
|
||||
<span className="text-sm font-bold opacity-80 uppercase">Players</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex flex-col items-center justify-center">
|
||||
{isHost ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-4 justify-center w-full max-w-6xl mb-12 min-h-[200px] content-start">
|
||||
<AnimatePresence>
|
||||
{realPlayers.length === 0 && (
|
||||
<div className="flex flex-col items-center opacity-60 mt-12">
|
||||
<div className="bg-white/10 p-6 rounded-full mb-4 animate-bounce">
|
||||
<Sparkles size={48} />
|
||||
</div>
|
||||
<div className="text-3xl font-bold font-display">Waiting for players to join...</div>
|
||||
</div>
|
||||
)}
|
||||
{realPlayers.map((player) => (
|
||||
<motion.div
|
||||
key={player.id}
|
||||
initial={{ scale: 0, rotate: -10 }}
|
||||
animate={{ scale: 1, rotate: 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"
|
||||
>
|
||||
<div className={`p-2 rounded-full bg-gradient-to-br from-purple-400 to-blue-500 text-white`}>
|
||||
<User size={20} />
|
||||
</div>
|
||||
{player.name}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="fixed bottom-8"
|
||||
>
|
||||
<button
|
||||
onClick={onStart}
|
||||
disabled={realPlayers.length === 0}
|
||||
className="bg-white text-[#46178f] 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"
|
||||
>
|
||||
Start Game
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0.5 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', bounce: 0.6 }}
|
||||
className="bg-white text-[#46178f] p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] mb-8"
|
||||
>
|
||||
<User size={80} strokeWidth={2.5} />
|
||||
</motion.div>
|
||||
<h2 className="text-5xl font-black mb-4 font-display">You're in!</h2>
|
||||
<p className="text-2xl font-bold opacity-80">See your name on the big screen?</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
components/Podium.tsx
Normal file
97
components/Podium.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Player } from '../types';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Trophy, Medal, RotateCcw } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
interface PodiumProps {
|
||||
players: Player[];
|
||||
onRestart: () => void;
|
||||
}
|
||||
|
||||
export const Podium: React.FC<PodiumProps> = ({ players, onRestart }) => {
|
||||
const sorted = [...players].sort((a, b) => b.score - a.score);
|
||||
const winner = sorted[0];
|
||||
const second = sorted[1];
|
||||
const third = sorted[2];
|
||||
|
||||
useEffect(() => {
|
||||
const duration = 3 * 1000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
|
||||
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
const interval: any = setInterval(function() {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
if (timeLeft <= 0) return clearInterval(interval);
|
||||
const particleCount = 50 * (timeLeft / duration);
|
||||
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } });
|
||||
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } });
|
||||
}, 250);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-4">
|
||||
<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>
|
||||
|
||||
<div className="flex items-end justify-center gap-4 md:gap-8 mb-12 w-full max-w-4xl h-96">
|
||||
{/* Second Place */}
|
||||
{second && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "60%" }}
|
||||
transition={{ type: 'spring', bounce: 0.5, delay: 0.5 }}
|
||||
className="w-1/3 bg-gray-200 rounded-t-[3rem] flex flex-col items-center justify-end p-6 relative border-x-4 border-t-4 border-white/50 shadow-xl"
|
||||
>
|
||||
<div className="absolute -top-16 flex flex-col items-center">
|
||||
<span className="text-xl font-bold mb-2 text-white drop-shadow-md">{second.name}</span>
|
||||
<div className="bg-gray-300 p-4 rounded-full text-white shadow-lg"><Medal size={32} /></div>
|
||||
</div>
|
||||
<span className="text-3xl font-black text-gray-500 mb-4">{second.score}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* First Place */}
|
||||
{winner && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "80%" }}
|
||||
transition={{ type: 'spring', bounce: 0.6, delay: 1 }}
|
||||
className="w-1/3 bg-yellow-400 rounded-t-[3rem] flex flex-col items-center justify-end p-6 relative z-10 shadow-2xl border-x-4 border-t-4 border-white/50"
|
||||
>
|
||||
<div className="absolute -top-24 flex flex-col items-center">
|
||||
<span className="text-3xl font-bold mb-2 text-yellow-100 drop-shadow-md">{winner.name}</span>
|
||||
<div className="bg-yellow-500 p-6 rounded-full text-white shadow-lg scale-110"><Trophy size={48} /></div>
|
||||
</div>
|
||||
<span className="text-5xl font-black text-yellow-900 mb-6">{winner.score}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Third Place */}
|
||||
{third && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "40%" }}
|
||||
transition={{ type: 'spring', bounce: 0.5, delay: 0 }}
|
||||
className="w-1/3 bg-orange-400 rounded-t-[3rem] flex flex-col items-center justify-end p-6 relative border-x-4 border-t-4 border-white/50 shadow-xl"
|
||||
>
|
||||
<div className="absolute -top-16 flex flex-col items-center">
|
||||
<span className="text-xl font-bold mb-2 text-white drop-shadow-md">{third.name}</span>
|
||||
<div className="bg-orange-500 p-4 rounded-full text-white shadow-lg"><Medal size={32} /></div>
|
||||
</div>
|
||||
<span className="text-3xl font-black text-orange-900 mb-4">{third.score}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="flex items-center gap-3 bg-white text-[#46178f] px-10 py-4 rounded-2xl text-2xl font-black hover:scale-105 transition shadow-[0_8px_0_rgba(0,0,0,0.2)] active:shadow-none active:translate-y-[8px]"
|
||||
>
|
||||
<RotateCcw size={28} /> Play Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
172
components/QuizCreator.tsx
Normal file
172
components/QuizCreator.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Quiz, Question, AnswerOption } from '../types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Plus, Save, Trash2, CheckCircle, Circle, X } from 'lucide-react';
|
||||
import { COLORS, SHAPES } from '../constants';
|
||||
|
||||
interface QuizCreatorProps {
|
||||
onFinalize: (quiz: Quiz) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [qText, setQText] = useState('');
|
||||
const [options, setOptions] = useState<string[]>(['', '', '', '']);
|
||||
const [correctIdx, setCorrectIdx] = useState<number>(0);
|
||||
|
||||
const handleAddQuestion = () => {
|
||||
if (!qText.trim() || options.some(o => !o.trim())) {
|
||||
alert("Please fill in the question and all 4 options.");
|
||||
return;
|
||||
}
|
||||
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
|
||||
const colors = ['red', 'blue', 'yellow', 'green'] as const;
|
||||
|
||||
const newOptions: AnswerOption[] = options.map((text, idx) => ({
|
||||
text,
|
||||
isCorrect: idx === correctIdx,
|
||||
shape: shapes[idx],
|
||||
color: colors[idx]
|
||||
}));
|
||||
|
||||
const newQuestion: Question = {
|
||||
id: uuidv4(),
|
||||
text: qText,
|
||||
options: newOptions,
|
||||
timeLimit: 20
|
||||
};
|
||||
|
||||
setQuestions([...questions, newQuestion]);
|
||||
setQText('');
|
||||
setOptions(['', '', '', '']);
|
||||
setCorrectIdx(0);
|
||||
};
|
||||
|
||||
const handleRemoveQuestion = (id: string) => {
|
||||
setQuestions(questions.filter(q => q.id !== id));
|
||||
};
|
||||
|
||||
const handleFinalize = () => {
|
||||
if (!title.trim() || questions.length === 0) return;
|
||||
onFinalize({ title, questions });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center">
|
||||
<div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white">
|
||||
<div className="bg-[#46178f] p-8 text-white flex justify-between items-center relative overflow-hidden">
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-4xl font-black font-display">Create Quiz</h2>
|
||||
<p className="opacity-80 font-bold">Build your masterpiece</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="bg-white/20 p-3 rounded-full hover:bg-white/30 transition relative z-10"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
{/* Decorative Circles */}
|
||||
<div className="absolute -right-10 -top-10 w-40 h-40 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 className="p-8 space-y-8">
|
||||
<div>
|
||||
<label className="block text-sm font-black uppercase tracking-wider text-gray-500 mb-2 ml-2">Quiz Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full p-4 border-4 border-gray-200 rounded-2xl text-2xl font-bold focus:border-[#46178f] outline-none transition-colors"
|
||||
placeholder="e.g., The Ultimate Trivia"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.id} className="border-4 border-gray-100 p-4 rounded-2xl bg-gray-50 flex justify-between items-center group hover:border-gray-200 transition">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="bg-[#46178f] text-white w-10 h-10 flex items-center justify-center rounded-full font-black">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="font-bold text-lg">{q.text}</span>
|
||||
</div>
|
||||
<button onClick={() => handleRemoveQuestion(q.id)} className="text-gray-400 hover:text-red-500 p-2 rounded-xl hover:bg-red-50 transition">
|
||||
<Trash2 size={24} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50/50 p-6 md:p-8 rounded-[2rem] border-4 border-blue-100">
|
||||
<h3 className="text-xl font-black mb-6 flex items-center gap-2 text-blue-900">
|
||||
<Plus size={24} /> New Question
|
||||
</h3>
|
||||
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={qText}
|
||||
onChange={(e) => setQText(e.target.value)}
|
||||
className="w-full p-4 border-4 border-white shadow-sm rounded-2xl font-bold text-lg focus:border-blue-400 outline-none"
|
||||
placeholder="What is the question?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{options.map((opt, idx) => {
|
||||
const isSelected = correctIdx === idx;
|
||||
const borderColor = isSelected ? 'border-green-500' : 'border-white';
|
||||
const bgClass = COLORS[['red','blue','yellow','green'][idx] as any];
|
||||
|
||||
return (
|
||||
<div key={idx} className={`flex items-center gap-3 p-3 rounded-2xl border-4 ${borderColor} bg-white shadow-sm transition-all`}>
|
||||
<button
|
||||
onClick={() => setCorrectIdx(idx)}
|
||||
className={`p-1 rounded-full ${isSelected ? 'text-green-500' : 'text-gray-200 hover:text-gray-400'}`}
|
||||
>
|
||||
{isSelected ? <CheckCircle size={32} fill="currentColor" className="text-white" /> : <Circle size={32} />}
|
||||
</button>
|
||||
|
||||
<div className={`w-4 h-full rounded-full ${bgClass}`}></div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={opt}
|
||||
onChange={(e) => {
|
||||
const newOpts = [...options];
|
||||
newOpts[idx] = e.target.value;
|
||||
setOptions(newOpts);
|
||||
}}
|
||||
className="w-full p-2 outline-none font-bold text-gray-700 bg-transparent placeholder:font-normal"
|
||||
placeholder={`Option ${idx + 1}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddQuestion}
|
||||
className="mt-8 w-full bg-blue-600 text-white py-4 rounded-2xl font-black text-lg hover:bg-blue-700 shadow-[0_6px_0_#1e40af] active:shadow-none active:translate-y-[6px] transition-all"
|
||||
>
|
||||
Add Question
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 flex justify-end">
|
||||
<button
|
||||
onClick={handleFinalize}
|
||||
className="flex items-center gap-2 bg-green-500 text-white px-10 py-4 rounded-2xl text-xl font-black hover:bg-green-600 shadow-[0_6px_0_#15803d] active:shadow-none active:translate-y-[6px] transition-all"
|
||||
>
|
||||
<Save size={24} /> Finish & Play
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
156
components/RevealScreen.tsx
Normal file
156
components/RevealScreen.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check, X, Flame, Trophy } from 'lucide-react';
|
||||
import { AnswerOption, Player, GameRole } from '../types';
|
||||
import { SHAPES, COLORS } from '../constants';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
interface RevealScreenProps {
|
||||
isCorrect: boolean;
|
||||
pointsEarned: number;
|
||||
newScore: number;
|
||||
streak: number;
|
||||
correctOption: AnswerOption;
|
||||
role: GameRole;
|
||||
}
|
||||
|
||||
export const RevealScreen: React.FC<RevealScreenProps> = ({
|
||||
isCorrect,
|
||||
pointsEarned,
|
||||
newScore,
|
||||
streak,
|
||||
correctOption,
|
||||
role
|
||||
}) => {
|
||||
const isHost = role === 'HOST';
|
||||
|
||||
// Trigger confetti for correct answers
|
||||
useEffect(() => {
|
||||
if (isCorrect && !isHost) {
|
||||
confetti({
|
||||
particleCount: 100,
|
||||
spread: 70,
|
||||
origin: { y: 0.6 },
|
||||
colors: ['#22c55e', '#ffffff', '#fbbf24']
|
||||
});
|
||||
}
|
||||
}, [isCorrect, isHost]);
|
||||
|
||||
// -- HOST VIEW --
|
||||
if (isHost) {
|
||||
const ShapeIcon = SHAPES[correctOption.shape];
|
||||
const colorClass = COLORS[correctOption.color];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen bg-gray-900 text-white p-8 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10"></div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-4xl font-bold uppercase tracking-widest mb-12 opacity-80"
|
||||
>
|
||||
The correct answer is
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -10 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", bounce: 0.5 }}
|
||||
className={`${colorClass} p-12 rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.3)] flex flex-col items-center max-w-4xl w-full border-8 border-white/20`}
|
||||
>
|
||||
<div className="bg-black/20 p-6 rounded-full mb-6">
|
||||
<ShapeIcon size={80} fill="currentColor" />
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-7xl font-black font-display text-center drop-shadow-md leading-tight">
|
||||
{correctOption.text}
|
||||
</h1>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- CLIENT VIEW --
|
||||
const bgColor = isCorrect ? 'bg-[#22c55e]' : 'bg-[#ef4444]';
|
||||
const darkerColor = isCorrect ? 'bg-[#15803d]' : 'bg-[#b91c1c]';
|
||||
const ShapeIcon = SHAPES[correctOption.shape];
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center h-screen ${bgColor} text-white p-6 relative overflow-hidden transition-colors duration-500`}>
|
||||
|
||||
{/* Dynamic Background Circles */}
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{ repeat: Infinity, duration: 4 }}
|
||||
className="absolute w-[800px] h-[800px] bg-white rounded-full blur-3xl opacity-10 pointer-events-none"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", bounce: 0.6 }}
|
||||
className="flex flex-col items-center z-10"
|
||||
>
|
||||
<div className="bg-white p-6 rounded-full shadow-[0_10px_0_rgba(0,0,0,0.2)] mb-8">
|
||||
{isCorrect ? (
|
||||
<Check size={80} className="text-[#22c55e]" strokeWidth={4} />
|
||||
) : (
|
||||
<X size={80} className="text-[#ef4444]" strokeWidth={4} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl md:text-8xl font-black font-display mb-4 drop-shadow-lg text-center">
|
||||
{isCorrect ? "Correct!" : "Incorrect"}
|
||||
</h1>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
{isCorrect ? (
|
||||
<div className="bg-black/20 px-8 py-4 rounded-3xl backdrop-blur-sm border-4 border-white/30 flex items-center gap-4 mb-8">
|
||||
<span className="text-4xl font-black">+{pointsEarned}</span>
|
||||
<span className="font-bold uppercase opacity-80">Points</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-2xl font-bold opacity-90 mb-8 max-w-md text-center">
|
||||
Don't worry, you can catch up in the next round!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streak Indicator */}
|
||||
{streak > 1 && isCorrect && (
|
||||
<div className="flex items-center gap-2 text-yellow-200 font-black text-2xl animate-pulse">
|
||||
<Flame fill="currentColor" />
|
||||
<span>Answer Streak: {streak}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 bg-black/20 px-6 py-2 rounded-xl text-xl font-bold">
|
||||
Total Score: {newScore}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{!isCorrect && (
|
||||
<motion.div
|
||||
initial={{ y: 100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ delay: 0.5, type: 'spring' }}
|
||||
className="absolute bottom-0 left-0 right-0 bg-black/30 backdrop-blur-md p-6 pb-12"
|
||||
>
|
||||
<p className="text-center text-sm font-bold uppercase tracking-widest mb-4 opacity-70">The correct answer was</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className={`${COLORS[correctOption.color]} p-3 rounded-xl shadow-lg`}>
|
||||
<ShapeIcon size={24} fill="currentColor" />
|
||||
</div>
|
||||
<span className="text-2xl font-black">{correctOption.text}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
74
components/Scoreboard.tsx
Normal file
74
components/Scoreboard.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import React from 'react';
|
||||
import { Player } from '../types';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, LabelList } from 'recharts';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ScoreboardProps {
|
||||
players: Player[];
|
||||
onNext: () => void;
|
||||
isHost: boolean;
|
||||
}
|
||||
|
||||
export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost }) => {
|
||||
const sortedPlayers = [...players].sort((a, b) => b.score - a.score).slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen p-8">
|
||||
<header className="text-center mb-12">
|
||||
<h1 className="text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 bg-white rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-12 flex flex-col text-gray-900 max-w-6xl w-full mx-auto relative z-10 border-8 border-white/50">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={sortedPlayers}
|
||||
layout="vertical"
|
||||
margin={{ top: 20, right: 120, left: 20, bottom: 5 }}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 24, fontWeight: 800, fill: '#333', fontFamily: 'Fredoka' }}
|
||||
width={200}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Bar dataKey="score" radius={[0, 20, 20, 0]} barSize={50} animationDuration={1500}>
|
||||
{sortedPlayers.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.id.startsWith('host') ? '#46178f' : '#8884d8'}
|
||||
className="filter drop-shadow-md"
|
||||
/>
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="score"
|
||||
position="right"
|
||||
offset={15}
|
||||
style={{ fontSize: '24px', fontWeight: '900', fill: '#46178f', fontFamily: 'Fredoka' }}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-end max-w-6xl w-full mx-auto">
|
||||
{isHost ? (
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="bg-white text-[#46178f] px-12 py-4 rounded-2xl text-2xl font-black shadow-[0_8px_0_rgba(0,0,0,0.2)] hover:scale-105 active:shadow-none active:translate-y-[8px] transition-all"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 bg-white/10 px-8 py-4 rounded-2xl backdrop-blur-md border-2 border-white/20">
|
||||
<Loader2 className="animate-spin w-8 h-8" />
|
||||
<span className="text-xl font-bold">Waiting for host...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue