Initial commit

This commit is contained in:
Joey Yakimowich-Payne 2026-01-13 07:23:30 -07:00
commit c87ebf0a74
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
22 changed files with 4973 additions and 0 deletions

142
components/GameScreen.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
};