Add api key and sorting on scoreboard

This commit is contained in:
Joey Yakimowich-Payne 2026-01-15 14:49:10 -07:00
commit 4688a73559
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
14 changed files with 791 additions and 227 deletions

View file

@ -1,9 +1,14 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { X, Key, Eye, EyeOff, Loader2 } from 'lucide-react';
import { X, Key, Eye, EyeOff, Loader2, ChevronDown, Search } from 'lucide-react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import type { AIProvider, UserPreferences } from '../types';
interface OpenAIModel {
id: string;
owned_by: string;
}
interface ApiKeyModalProps {
isOpen: boolean;
onClose: () => void;
@ -24,28 +29,96 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
useBodyScrollLock(isOpen);
const [localProvider, setLocalProvider] = useState<AIProvider>(preferences.aiProvider || 'gemini');
const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || '');
const [localGeminiModel, setLocalGeminiModel] = useState(preferences.geminiModel || '');
const [localOpenRouterKey, setLocalOpenRouterKey] = useState(preferences.openRouterApiKey || '');
const [localOpenRouterModel, setLocalOpenRouterModel] = useState(preferences.openRouterModel || '');
const [localOpenAIKey, setLocalOpenAIKey] = useState(preferences.openAIApiKey || '');
const [localOpenAIModel, setLocalOpenAIModel] = useState(preferences.openAIModel || '');
const [showGeminiKey, setShowGeminiKey] = useState(false);
const [showOpenRouterKey, setShowOpenRouterKey] = useState(false);
const [showOpenAIKey, setShowOpenAIKey] = useState(false);
const [openAIModels, setOpenAIModels] = useState<OpenAIModel[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const [modelSearchQuery, setModelSearchQuery] = useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const modelDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
setLocalProvider(preferences.aiProvider || 'gemini');
setLocalGeminiKey(preferences.geminiApiKey || '');
setLocalGeminiModel(preferences.geminiModel || '');
setLocalOpenRouterKey(preferences.openRouterApiKey || '');
setLocalOpenRouterModel(preferences.openRouterModel || '');
setLocalOpenAIKey(preferences.openAIApiKey || '');
setLocalOpenAIModel(preferences.openAIModel || '');
setModelSearchQuery('');
setIsModelDropdownOpen(false);
}
}, [isOpen, preferences]);
useEffect(() => {
const fetchOpenAIModels = async () => {
if (!localOpenAIKey || localOpenAIKey.length < 10) {
setOpenAIModels([]);
return;
}
setLoadingModels(true);
try {
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${localOpenAIKey}`,
},
});
if (response.ok) {
const data = await response.json();
const models = (data.data as (OpenAIModel & { created: number })[])
.filter(m => !m.id.includes('realtime') && !m.id.includes('audio') && !m.id.includes('tts') && !m.id.includes('whisper') && !m.id.includes('dall-e') && !m.id.includes('embedding'))
.sort((a, b) => b.created - a.created);
setOpenAIModels(models);
} else {
setOpenAIModels([]);
}
} catch {
setOpenAIModels([]);
} finally {
setLoadingModels(false);
}
};
const debounceTimer = setTimeout(fetchOpenAIModels, 500);
return () => clearTimeout(debounceTimer);
}, [localOpenAIKey]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) {
setIsModelDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredModels = openAIModels.filter(model =>
model.id.toLowerCase().includes(modelSearchQuery.toLowerCase())
);
if (!isOpen) return null;
const handleSave = async () => {
await onSave({
aiProvider: localProvider,
geminiApiKey: localGeminiKey || undefined,
geminiModel: localGeminiModel || undefined,
openRouterApiKey: localOpenRouterKey || undefined,
openRouterModel: localOpenRouterModel || undefined,
openAIApiKey: localOpenAIKey || undefined,
openAIModel: localOpenAIModel || undefined,
});
onClose();
};
@ -62,10 +135,10 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-2xl max-w-md w-full shadow-xl overflow-hidden"
className="bg-white rounded-2xl max-w-md w-full shadow-xl overflow-visible"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-gray-800 to-gray-900">
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-gray-800 to-gray-900 rounded-t-2xl">
<div className="flex items-center gap-3 text-white">
<div className="p-2 bg-white/20 rounded-xl">
<Key size={24} />
@ -84,15 +157,13 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</div>
<div className="p-6 bg-gray-50 space-y-4">
{hasAIAccess ? (
<>
<div>
<div>
<label className="block font-bold text-gray-800 mb-2">AI Provider</label>
<div className="flex bg-gray-200 p-1 rounded-xl">
<button
type="button"
onClick={() => setLocalProvider('gemini')}
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all ${
className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
localProvider === 'gemini'
? 'bg-white shadow-sm text-gray-800'
: 'text-gray-500 hover:text-gray-700'
@ -100,10 +171,21 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
>
Gemini
</button>
<button
type="button"
onClick={() => setLocalProvider('openai')}
className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
localProvider === 'openai'
? 'bg-white shadow-sm text-gray-800'
: 'text-gray-500 hover:text-gray-700'
}`}
>
OpenAI
</button>
<button
type="button"
onClick={() => setLocalProvider('openrouter')}
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all ${
className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
localProvider === 'openrouter'
? 'bg-white shadow-sm text-gray-800'
: 'text-gray-500 hover:text-gray-700'
@ -114,32 +196,164 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</div>
</div>
{localProvider === 'gemini' ? (
<div>
<label className="block font-bold text-gray-800 mb-2">
Gemini API Key
</label>
<p className="text-sm text-gray-500 mb-2">
Leave empty to use the system key.
</p>
<div className="relative">
<input
type={showGeminiKey ? 'text' : 'password'}
value={localGeminiKey}
onChange={(e) => setLocalGeminiKey(e.target.value)}
placeholder="AIza..."
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
<button
type="button"
onClick={() => setShowGeminiKey(!showGeminiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showGeminiKey ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
{localProvider === 'gemini' && (
<>
<div>
<label className="block font-bold text-gray-800 mb-2">
Gemini API Key
</label>
<p className="text-sm text-gray-500 mb-2">
Get your key from{' '}
<a
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-theme-primary hover:underline"
>
aistudio.google.com/apikey
</a>
{hasAIAccess ? ' or leave empty to use the system key.' : '.'}
</p>
<div className="relative">
<input
type={showGeminiKey ? 'text' : 'password'}
value={localGeminiKey}
onChange={(e) => setLocalGeminiKey(e.target.value)}
placeholder="AIza..."
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
<button
type="button"
onClick={() => setShowGeminiKey(!showGeminiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showGeminiKey ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
</div>
) : (
<div>
<label className="block font-bold text-gray-800 mb-2">
Model
</label>
<p className="text-sm text-gray-500 mb-2">
Default: gemini-3-flash-preview
</p>
<input
type="text"
value={localGeminiModel}
onChange={(e) => setLocalGeminiModel(e.target.value)}
placeholder="gemini-3-flash-preview"
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
</div>
</>
)}
{localProvider === 'openai' && (
<>
<div>
<label className="block font-bold text-gray-800 mb-2">
OpenAI API Key
</label>
<p className="text-sm text-gray-500 mb-2">
Get your key from{' '}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-theme-primary hover:underline"
>
platform.openai.com/api-keys
</a>
</p>
<div className="relative">
<input
type={showOpenAIKey ? 'text' : 'password'}
value={localOpenAIKey}
onChange={(e) => setLocalOpenAIKey(e.target.value)}
placeholder="sk-..."
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
<button
type="button"
onClick={() => setShowOpenAIKey(!showOpenAIKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showOpenAIKey ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div>
<label className="block font-bold text-gray-800 mb-2">
Model
</label>
<p className="text-sm text-gray-500 mb-2">
{localOpenAIKey ? 'Select a model from your account' : 'Enter API key to load models'}
</p>
<div className="relative" ref={modelDropdownRef}>
<div
className={`w-full px-4 py-3 rounded-xl border-2 flex items-center justify-between cursor-pointer transition-colors ${
isModelDropdownOpen ? 'border-theme-primary' : 'border-gray-200'
} ${!localOpenAIKey ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}`}
onClick={() => localOpenAIKey && setIsModelDropdownOpen(!isModelDropdownOpen)}
>
<span className={localOpenAIModel ? 'text-gray-800' : 'text-gray-400'}>
{localOpenAIModel || 'gpt-5-mini'}
</span>
{loadingModels ? (
<Loader2 size={18} className="animate-spin text-gray-400" />
) : (
<ChevronDown size={18} className={`text-gray-400 transition-transform ${isModelDropdownOpen ? 'rotate-180' : ''}`} />
)}
</div>
{isModelDropdownOpen && openAIModels.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border-2 border-gray-200 rounded-xl shadow-lg overflow-hidden">
<div className="p-2 border-b border-gray-100">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={modelSearchQuery}
onChange={(e) => setModelSearchQuery(e.target.value)}
placeholder="Search models..."
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
autoFocus
/>
</div>
</div>
<div className="max-h-48 overflow-y-auto">
{filteredModels.length > 0 ? (
filteredModels.map((model) => (
<button
key={model.id}
type="button"
className={`w-full px-4 py-2 text-left text-sm hover:bg-gray-50 transition-colors ${
localOpenAIModel === model.id ? 'bg-theme-primary/10 text-theme-primary font-medium' : 'text-gray-700'
}`}
onClick={() => {
setLocalOpenAIModel(model.id);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
}}
>
{model.id}
</button>
))
) : (
<div className="px-4 py-3 text-sm text-gray-500 text-center">
No models found
</div>
)}
</div>
</div>
)}
</div>
</div>
</>
)}
{localProvider === 'openrouter' && (
<>
<div>
<label className="block font-bold text-gray-800 mb-2">
@ -190,14 +404,6 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</div>
</>
)}
</>
) : (
<div className="text-center py-4">
<Key size={32} className="mx-auto mb-3 text-gray-400" />
<p className="text-gray-500 font-bold">API access not available</p>
<p className="text-sm text-gray-400 mt-1">Contact an administrator for access</p>
</div>
)}
</div>
<div className="p-6 border-t border-gray-100 flex gap-3">
@ -207,22 +413,20 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
>
Cancel
</button>
{hasAIAccess && (
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 rounded-xl font-bold bg-gray-800 text-white hover:bg-gray-700 transition disabled:opacity-50 flex items-center justify-center gap-2"
>
{saving ? (
<>
<Loader2 size={20} className="animate-spin" />
Saving...
</>
) : (
'Save'
)}
</button>
)}
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 rounded-xl font-bold bg-gray-800 text-white hover:bg-gray-700 transition disabled:opacity-50 flex items-center justify-center gap-2"
>
{saving ? (
<>
<Loader2 size={20} className="animate-spin" />
Saving...
</>
) : (
'Save'
)}
</button>
</div>
</motion.div>
</motion.div>

View file

@ -25,7 +25,9 @@ interface LandingProps {
useOcr?: boolean;
aiProvider?: AIProvider;
apiKey?: string;
geminiModel?: string;
openRouterModel?: string;
openAIModel?: string;
}) => void;
onCreateManual: () => void;
onLoadQuiz: (quiz: Quiz, quizId?: string) => void;
@ -113,9 +115,11 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences();
const hasValidApiKey = preferences.aiProvider === 'openrouter'
? !!preferences.openRouterApiKey
: (hasAIAccess || !!preferences.geminiApiKey);
const hasValidApiKey = (() => {
if (preferences.aiProvider === 'openrouter') return !!preferences.openRouterApiKey;
if (preferences.aiProvider === 'openai') return !!preferences.openAIApiKey;
return hasAIAccess || !!preferences.geminiApiKey;
})();
const canUseAI = auth.isAuthenticated && hasValidApiKey;
const {
@ -219,9 +223,14 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
e.preventDefault();
if (canGenerate && !isLoading) {
const aiProvider = preferences.aiProvider || 'gemini';
const apiKey = aiProvider === 'openrouter'
? preferences.openRouterApiKey
: preferences.geminiApiKey;
let apiKey: string | undefined;
if (aiProvider === 'openrouter') {
apiKey = preferences.openRouterApiKey;
} else if (aiProvider === 'openai') {
apiKey = preferences.openAIApiKey;
} else {
apiKey = preferences.geminiApiKey;
}
onGenerate({
topic: generateMode === 'topic' ? topic.trim() : undefined,
@ -230,7 +239,9 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined,
aiProvider,
apiKey,
geminiModel: aiProvider === 'gemini' ? preferences.geminiModel : undefined,
openRouterModel: aiProvider === 'openrouter' ? preferences.openRouterModel : undefined,
openAIModel: aiProvider === 'openai' ? preferences.openAIModel : undefined,
});
}
};
@ -518,6 +529,23 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
</>
)}
{!canUseAI && (
<button
onClick={() => setSearchParams(buildCleanParams({ modal: 'account' }))}
className="w-full p-4 bg-theme-primary/5 border-2 border-theme-primary/20 rounded-2xl text-left hover:border-theme-primary hover:bg-theme-primary/10 hover:shadow-lg hover:scale-[1.02] transition-all group"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-theme-primary/15 rounded-xl group-hover:bg-theme-primary/25 transition-colors">
<Sparkles size={20} className="text-theme-primary" />
</div>
<div>
<p className="font-bold text-gray-800">AI Quiz Generation Available</p>
<p className="text-sm text-gray-500">Configure your API key in settings to get started</p>
</div>
</div>
</button>
)}
<button
onClick={onCreateManual}
className="w-full bg-white border-2 border-theme-primary text-theme-primary py-3 rounded-2xl text-lg font-black hover:bg-theme-hover shadow-[0_4px_0_var(--theme-primary)] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"

View file

@ -1,4 +1,5 @@
import React from 'react';
import chroma from 'chroma-js';
import {
Cat, Dog, Bird, Fish, Rabbit, Turtle,
Ghost, Skull, Heart, Star, Moon, Sun,
@ -15,20 +16,13 @@ const ICONS = [
Rocket, Plane, Car, Bike, Train, Ship
];
const GRADIENT_PAIRS = [
['#f472b6', '#c026d3'],
['#fb923c', '#ea580c'],
['#facc15', '#ca8a04'],
['#4ade80', '#16a34a'],
['#2dd4bf', '#0d9488'],
['#38bdf8', '#0284c7'],
['#818cf8', '#6366f1'],
['#f87171', '#dc2626'],
['#a78bfa', '#7c3aed'],
['#fb7185', '#e11d48'],
['#34d399', '#059669'],
['#60a5fa', '#2563eb'],
];
export const getAvatarColors = (seed: number): [string, string] => {
const hue = Math.floor(seed * 360);
const primaryColor = chroma.hsl(hue, 0.85, 0.55);
const secondaryColor = chroma.hsl((hue + 35) % 360, 0.8, 0.45);
return [primaryColor.hex(), secondaryColor.hex()];
};
interface PlayerAvatarProps {
seed: number;
@ -38,10 +32,9 @@ interface PlayerAvatarProps {
export const PlayerAvatar: React.FC<PlayerAvatarProps> = ({ seed, size = 24, className = '' }) => {
const iconIndex = Math.floor(seed * 1000) % ICONS.length;
const gradientIndex = Math.floor(seed * 10000) % GRADIENT_PAIRS.length;
const Icon = ICONS[iconIndex];
const [colorFrom, colorTo] = GRADIENT_PAIRS[gradientIndex];
const [colorFrom, colorTo] = getAvatarColors(seed);
const gradientId = `avatar-gradient-${seed.toString().replace('.', '-')}`;
return (

View file

@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Player, PointsBreakdown } from '../types';
import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion';
import { Loader2, Flame, Rocket, Zap, X } from 'lucide-react';
import { PlayerAvatar } from './PlayerAvatar';
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 });
@ -53,126 +53,153 @@ const PenaltyBadge: React.FC<{ points: number; delay: number }> = ({ points, del
interface PlayerRowProps {
player: Player & { displayName: string };
index: number;
maxScore: number;
rank: number;
currentScore: number;
phase: number;
baseDelay: number;
}
const PlayerRow: React.FC<PlayerRowProps> = ({ player, index, maxScore }) => {
const [phase, setPhase] = useState(0);
const PlayerRow: React.FC<PlayerRowProps> = ({ player, maxScore, rank, currentScore, phase, baseDelay }) => {
const breakdown = player.pointsBreakdown;
const baseDelay = index * 0.3;
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
timers.push(setTimeout(() => setPhase(1), (baseDelay + 0.2) * 1000));
if (breakdown) {
if (breakdown.streakBonus > 0) timers.push(setTimeout(() => setPhase(2), (baseDelay + 0.8) * 1000));
if (breakdown.comebackBonus > 0) timers.push(setTimeout(() => setPhase(3), (baseDelay + 1.2) * 1000));
if (breakdown.firstCorrectBonus > 0) timers.push(setTimeout(() => setPhase(4), (baseDelay + 1.6) * 1000));
}
return () => timers.forEach(clearTimeout);
}, [baseDelay, breakdown]);
const barWidth = maxScore > 0 ? (currentScore / maxScore) * 100 : 0;
const getDisplayScore = () => {
if (!breakdown) return player.previousScore;
let score = player.previousScore;
if (phase >= 1) score += breakdown.basePoints - breakdown.penalty;
if (phase >= 2) score += breakdown.streakBonus;
if (phase >= 3) score += breakdown.comebackBonus;
if (phase >= 4) score += breakdown.firstCorrectBonus;
return Math.max(0, score);
};
const isFirst = rank === 1;
const isSecond = rank === 2;
const isThird = rank === 3;
const isTop3 = rank <= 3;
const barWidth = maxScore > 0 ? (getDisplayScore() / maxScore) * 100 : 0;
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
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: baseDelay, duration: 0.4 }}
className="flex flex-col gap-2 py-3"
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-between gap-3 w-full">
<div className="flex items-center gap-3 min-w-0">
<PlayerAvatar seed={player.avatarSeed} size={32} />
<span className="font-black text-lg font-display truncate">{player.displayName}</span>
<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>
<span className="font-black text-2xl font-display">
<AnimatedNumber value={getDisplayScore()} />
</span>
</div>
<div className="w-full h-10 md:h-12 bg-gray-100 rounded-full overflow-hidden relative">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: player.color }}
initial={{ width: 0 }}
animate={{ width: `${Math.max(barWidth, 2)}%` }}
transition={{ duration: 0.6, delay: phase === 0 ? baseDelay + 0.1 : 0 }}
/>
</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">
<AnimatePresence>
{breakdown === null && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: baseDelay + 0.3 }}
className="text-gray-400 font-medium text-sm"
>
No answer
</motion.span>
)}
<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 points={breakdown.penalty} delay={0} />
)}
{breakdown && breakdown.penalty > 0 && phase >= 1 && (
<PenaltyBadge key="penalty" points={breakdown.penalty} delay={0} />
)}
{breakdown && breakdown.basePoints > 0 && phase >= 1 && (
<motion.div
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-1 rounded-full bg-green-500 text-white font-bold text-sm"
>
<span>+{breakdown.basePoints}</span>
</motion.div>
)}
{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
points={breakdown.streakBonus}
label="Streak"
icon={<Flame size={14} />}
color="bg-amber-500"
delay={0}
/>
)}
{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
points={breakdown.comebackBonus}
label="Comeback"
icon={<Rocket size={14} />}
color="bg-blue-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
points={breakdown.firstCorrectBonus}
label="First!"
icon={<Zap size={14} />}
color="bg-yellow-500"
delay={0}
/>
)}
</AnimatePresence>
{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>
);
@ -185,38 +212,144 @@ interface ScoreboardProps {
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 }) => {
const playersWithDisplayName = players.map(p => ({
...p,
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name
}));
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score);
const maxScore = Math.max(...sortedPlayers.map(p => Math.max(p.score, p.previousScore)), 1);
// 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">
<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 rounded-2xl md:rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-4 md:p-12 text-gray-900 max-w-5xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-y-auto">
<div className="space-y-2">
{sortedPlayers.map((player, index) => (
<PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} />
))}
</div>
<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-5xl w-full mx-auto shrink-0">
<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"
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
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">
<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>