kaboot/components/Scoreboard.tsx

360 lines
13 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Player, PointsBreakdown } from '../types';
import { motion, AnimatePresence, useSpring, useTransform, LayoutGroup } from 'framer-motion';
import { Loader2, Flame, Rocket, Zap, X, Crown, Medal, Trophy } from 'lucide-react';
import { PlayerAvatar, getAvatarColors } from './PlayerAvatar';
const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 600 }) => {
const spring = useSpring(0, { duration });
const display = useTransform(spring, (latest) => Math.round(latest));
const [displayValue, setDisplayValue] = useState(0);
useEffect(() => {
spring.set(value);
const unsubscribe = display.on('change', (v) => setDisplayValue(v));
return () => unsubscribe();
}, [value, spring, display]);
return <span>{displayValue}</span>;
};
interface BonusBadgeProps {
points: number;
label: string;
icon: React.ReactNode;
color: string;
delay: number;
}
const BonusBadge: React.FC<BonusBadgeProps> = ({ points, label, icon, color, delay }) => (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay, type: 'spring', stiffness: 500, damping: 15 }}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-white font-bold text-sm ${color}`}
>
{icon}
<span>+{points}</span>
<span className="text-white/80">{label}</span>
</motion.div>
);
const PenaltyBadge: React.FC<{ points: number; delay: number }> = ({ points, delay }) => (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay, type: 'spring', stiffness: 500, damping: 15 }}
className="flex items-center gap-1 px-2 py-1 rounded-full bg-red-500 text-white font-bold text-sm"
>
<X size={14} />
<span>-{points}</span>
</motion.div>
);
interface PlayerRowProps {
player: Player & { displayName: string };
maxScore: number;
rank: number;
currentScore: number;
phase: number;
baseDelay: number;
}
const PlayerRow: React.FC<PlayerRowProps> = ({ player, maxScore, rank, currentScore, phase, baseDelay }) => {
const breakdown = player.pointsBreakdown;
const barWidth = maxScore > 0 ? (currentScore / maxScore) * 100 : 0;
const isFirst = rank === 1;
const isSecond = rank === 2;
const isThird = rank === 3;
const isTop3 = rank <= 3;
let rankStyles = "bg-white border-gray-100";
let rankBadgeStyles = "bg-gray-100 text-gray-500";
if (isFirst) {
rankStyles = "bg-gradient-to-r from-yellow-50 to-amber-100 border-amber-300 shadow-lg scale-[1.02] z-10";
rankBadgeStyles = "bg-gradient-to-br from-yellow-400 to-amber-600 text-white shadow-sm";
} else if (isSecond) {
rankStyles = "bg-gradient-to-r from-gray-50 to-slate-100 border-slate-300 shadow-md z-0";
rankBadgeStyles = "bg-gradient-to-br from-slate-300 to-slate-500 text-white shadow-sm";
} else if (isThird) {
rankStyles = "bg-gradient-to-r from-orange-50 to-orange-100 border-orange-200 shadow-md z-0";
rankBadgeStyles = "bg-gradient-to-br from-orange-300 to-orange-500 text-white shadow-sm";
}
return (
<motion.div
layout
layoutId={player.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
layout: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.3 }
}}
className={`flex items-center gap-3 md:gap-4 p-3 md:p-4 rounded-xl md:rounded-2xl border-2 ${rankStyles}`}
>
<div className={`flex items-center justify-center w-8 h-8 md:w-10 md:h-10 rounded-full font-black text-lg md:text-xl shrink-0 ${rankBadgeStyles}`}>
{rank}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 w-full">
<div className="flex items-center gap-3 min-w-0">
<div className="relative">
<PlayerAvatar seed={player.avatarSeed} size={isTop3 ? 40 : 32} />
{isFirst && (
<motion.div
initial={{ scale: 0, rotate: -45 }}
animate={{ scale: 1, rotate: -15 }}
transition={{ delay: 0.5, type: 'spring' }}
className="absolute -top-3 -left-2 drop-shadow-md"
>
<Crown size={24} className="fill-yellow-400 text-yellow-600" />
</motion.div>
)}
</div>
<span className={`font-black font-display truncate ${isTop3 ? 'text-xl' : 'text-lg'}`}>
{player.displayName}
</span>
</div>
<span className={`font-black font-display ${isTop3 ? 'text-3xl text-theme-primary' : 'text-2xl text-gray-700'}`}>
<AnimatedNumber value={currentScore} />
</span>
</div>
<div className="w-full h-8 md:h-10 bg-black/5 rounded-full overflow-hidden relative">
<motion.div
className="h-full rounded-full"
style={{ background: `linear-gradient(90deg, ${getAvatarColors(player.avatarSeed)[0]}, ${getAvatarColors(player.avatarSeed)[1]})` }}
initial={{ width: 0 }}
animate={{ width: `${Math.max(barWidth, 2)}%` }}
transition={{ duration: 0.6, delay: phase === 0 ? baseDelay + 0.1 : 0 }}
/>
</div>
<div className="flex flex-wrap items-center gap-2 w-full justify-start h-6">
<AnimatePresence mode='popLayout'>
{breakdown === null && (
<motion.span
key="no-answer"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ delay: baseDelay + 0.3 }}
className="text-gray-400 font-medium text-sm"
>
No answer
</motion.span>
)}
{breakdown && breakdown.penalty > 0 && phase >= 1 && (
<PenaltyBadge key="penalty" points={breakdown.penalty} delay={0} />
)}
{breakdown && breakdown.basePoints > 0 && phase >= 1 && (
<motion.div
key="base-points"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-500 text-white font-bold text-xs md:text-sm"
>
<span>+{breakdown.basePoints}</span>
</motion.div>
)}
{breakdown && breakdown.streakBonus > 0 && phase >= 2 && (
<BonusBadge
key="streak"
points={breakdown.streakBonus}
label="Streak"
icon={<Flame size={12} />}
color="bg-amber-500"
delay={0}
/>
)}
{breakdown && breakdown.comebackBonus > 0 && phase >= 3 && (
<BonusBadge
key="comeback"
points={breakdown.comebackBonus}
label="Comeback"
icon={<Rocket size={12} />}
color="bg-blue-500"
delay={0}
/>
)}
{breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && (
<BonusBadge
key="first-correct"
points={breakdown.firstCorrectBonus}
label="First!"
icon={<Zap size={12} />}
color="bg-yellow-500"
delay={0}
/>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
);
};
interface ScoreboardProps {
players: Player[];
onNext: () => void;
isHost: boolean;
currentPlayerId: string | null;
}
interface AnimatedPlayerState extends Player {
displayName: string;
currentScore: number;
phase: number;
initialIndex: number;
}
export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost, currentPlayerId }) => {
// Initialize players sorted by previousScore to start
const [animatedPlayers, setAnimatedPlayers] = useState<AnimatedPlayerState[]>(() => {
const playersWithMeta = players.map(p => ({
...p,
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name,
currentScore: p.previousScore,
phase: 0,
}));
// Sort by previous score initially (descending)
// Add a secondary sort by ID to ensure stable sorting
return playersWithMeta
.sort((a, b) => (b.previousScore - a.previousScore) || a.id.localeCompare(b.id))
.map((p, index) => ({
...p,
initialIndex: index // Store the initial rank for staggered animation timing
}));
});
useEffect(() => {
const timers: NodeJS.Timeout[] = [];
// We use the initial state to set up the timers
// We can't use animatedPlayers in the dependency array or it will loop
// But we need a reference to the initial setup to schedule things.
// The state initializer runs once, so we need to reconstruct that list or trust the state is fresh on mount.
// However, inside useEffect, animatedPlayers might change if we include it in deps.
// We want to schedule based on the INITIAL state.
// Let's grab the initial list again to be safe and consistent with the initializer logic
const initialPlayers = players.map(p => ({
...p,
previousScore: p.previousScore,
pointsBreakdown: p.pointsBreakdown
})).sort((a, b) => (b.previousScore - a.previousScore) || a.id.localeCompare(b.id));
initialPlayers.forEach((player, initialIndex) => {
const breakdown = player.pointsBreakdown;
const baseDelay = initialIndex * 0.1;
// Helper to update state
const updatePlayerState = (phase: number, scoreToAdd: number) => {
setAnimatedPlayers(prev => {
const updated = prev.map(p => {
if (p.id !== player.id) return p;
return {
...p,
phase,
currentScore: p.currentScore + scoreToAdd
};
});
// Re-sort on every update
return updated.sort((a, b) => (b.currentScore - a.currentScore) || a.id.localeCompare(b.id));
});
};
if (!breakdown) return;
// Phase 1: Base Points + Penalty
// (baseDelay + 0.2)s
timers.push(setTimeout(() => {
const points = breakdown.basePoints - breakdown.penalty;
updatePlayerState(1, points);
}, (baseDelay + 0.2) * 1000));
// Phase 2: Streak
// (baseDelay + 0.8)s
if (breakdown.streakBonus > 0) {
timers.push(setTimeout(() => {
updatePlayerState(2, breakdown.streakBonus);
}, (baseDelay + 0.8) * 1000));
}
// Phase 3: Comeback
// (baseDelay + 1.2)s
if (breakdown.comebackBonus > 0) {
timers.push(setTimeout(() => {
updatePlayerState(3, breakdown.comebackBonus);
}, (baseDelay + 1.2) * 1000));
}
// Phase 4: First Correct
// (baseDelay + 1.6)s
if (breakdown.firstCorrectBonus > 0) {
timers.push(setTimeout(() => {
updatePlayerState(4, breakdown.firstCorrectBonus);
}, (baseDelay + 1.6) * 1000));
}
});
return () => timers.forEach(clearTimeout);
}, []); // Run once on mount
// Calculate max score based on FINAL scores (so the bar scale is consistent/correct relative to the winner)
const maxScore = Math.max(...players.map(p => p.score), 1);
return (
<div className="flex flex-col h-screen p-4 md:p-8 overflow-hidden bg-theme-bg">
<header className="text-center mb-4 md:mb-8 shrink-0">
<h1 className="text-3xl md:text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
</header>
<div className="flex-1 min-h-0 bg-white/95 backdrop-blur-sm rounded-2xl md:rounded-[3rem] shadow-[0_20px_50px_rgba(0,0,0,0.3)] p-4 md:p-8 text-gray-900 max-w-4xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-y-auto custom-scrollbar">
<LayoutGroup>
<div className="space-y-3">
{animatedPlayers.map((player, index) => (
<PlayerRow
key={player.id}
player={player}
maxScore={maxScore}
rank={index + 1}
currentScore={player.currentScore}
phase={player.phase}
baseDelay={player.initialIndex * 0.1}
/>
))}
</div>
</LayoutGroup>
</div>
<div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-4xl w-full mx-auto shrink-0 z-20">
{isHost ? (
<button
onClick={onNext}
className="bg-white text-theme-primary px-8 md:px-12 py-3 md:py-4 rounded-xl md:rounded-2xl text-xl md: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 flex items-center gap-2"
>
Next <Trophy size={24} />
</button>
) : (
<div className="flex items-center gap-2 md:gap-3 bg-white/10 px-4 md:px-8 py-3 md:py-4 rounded-xl md:rounded-2xl backdrop-blur-md border-2 border-white/20 shadow-lg text-white">
<Loader2 className="animate-spin w-6 h-6 md:w-8 md:h-8" />
<span className="text-base md:text-xl font-bold">Waiting for host...</span>
</div>
)}
</div>
</div>
);
};