feat: implement leaderboard page and integrate into application routing
This commit is contained in:
@@ -9,9 +9,7 @@ import Dashboard from './pages/Dashboard'
|
||||
import Admin from './pages/Admin'
|
||||
import Leren from './pages/Leren'
|
||||
import Testen from './pages/Testen'
|
||||
|
||||
// Placeholder components for routing structure
|
||||
const Leaderboard = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leaderboard</h1><p className="mt-4">See who is on top!</p></div>
|
||||
import Leaderboard from './pages/Leaderboard'
|
||||
|
||||
// Protected Route Wrapper
|
||||
const ProtectedRoute = ({ children, requireAdmin }) => {
|
||||
|
||||
170
src/pages/Leaderboard.jsx
Normal file
170
src/pages/Leaderboard.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Trophy, Medal, Award, TrendingUp, Star } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Card from '../components/ui/Card';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { storage } from '../lib/storage';
|
||||
|
||||
const BADGE_RULES = [
|
||||
{ id: 'first_test', icon: Star, label: 'First Steps', condition: (u) => u.testsCompleted > 0 },
|
||||
{ id: 'perfectionist', icon: Award, label: 'Perfectionist', condition: (u) => u.perfectScores > 0 },
|
||||
{ id: 'veteran', icon: Medal, label: 'Veteran', condition: (u) => u.testsCompleted >= 5 },
|
||||
];
|
||||
|
||||
const Leaderboard = () => {
|
||||
const { state } = useApp();
|
||||
const [board, setBoard] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-evaluate badges before displaying
|
||||
let data = storage.get('leaderboard:current', []);
|
||||
|
||||
// Supplement with users that haven't scored yet
|
||||
const allUsers = storage.get('users:registry', []);
|
||||
const userMap = new Map(data.map(u => [u.userId, u]));
|
||||
|
||||
allUsers.forEach(user => {
|
||||
if (!userMap.has(user.id) && user.role !== 'admin') {
|
||||
data.push({
|
||||
userId: user.id,
|
||||
name: user.name,
|
||||
points: 0,
|
||||
testsCompleted: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for perfect scores by peeking at test results
|
||||
data = data.map(entry => {
|
||||
let perfectScores = 0;
|
||||
// Loop over all possible weeks
|
||||
for (let w = 1; w <= state.weekNumber; w++) {
|
||||
const result = storage.get(`quiz:result:${entry.userId}:week:${w}`);
|
||||
if (result && result.percentage === 100) perfectScores++;
|
||||
}
|
||||
return { ...entry, perfectScores };
|
||||
});
|
||||
|
||||
data.sort((a, b) => b.points - a.points);
|
||||
setBoard(data);
|
||||
}, [state.weekNumber]);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
|
||||
<div className="text-center mb-12">
|
||||
<Trophy size={56} className="mx-auto text-teal/30 mb-4" />
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Company Leaderboard</h1>
|
||||
<p className="text-fg-muted">Compete, learn, and earn badges based on your weekly knowledge tests.</p>
|
||||
</div>
|
||||
|
||||
{/* Top 3 Podium */}
|
||||
{board.length >= 3 && (
|
||||
<div className="flex justify-center items-end gap-2 md:gap-6 mb-16 h-64 mt-12">
|
||||
{/* 2nd Place */}
|
||||
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.2 }} className="w-1/3 max-w-[140px] flex flex-col items-center">
|
||||
<div className="relative mb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-slate-200 border-4 border-paper flex items-center justify-center text-slate-500 font-bold text-2xl shadow-lg z-10 relative">2</div>
|
||||
<div className="absolute -inset-2 bg-slate-100 rounded-full z-0 blur-sm"></div>
|
||||
</div>
|
||||
<p className="font-bold text-center truncate w-full px-2">{board[1].name}</p>
|
||||
<p className="text-teal font-medium text-sm">{board[1].points} pts</p>
|
||||
<div className="w-full h-24 bg-gradient-to-t from-slate-200/50 to-transparent rounded-t-[var(--r-sm)] mt-4"></div>
|
||||
</motion.div>
|
||||
|
||||
{/* 1st Place */}
|
||||
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="w-1/3 max-w-[160px] flex flex-col items-center z-10">
|
||||
<div className="relative mb-6">
|
||||
<div className="w-20 h-20 rounded-full bg-yellow-400 border-4 border-paper flex items-center justify-center text-yellow-800 font-bold text-3xl shadow-xl z-10 relative">1</div>
|
||||
<div className="absolute -inset-3 bg-yellow-300 rounded-full z-0 blur-md opacity-60"></div>
|
||||
<Trophy size={24} className="absolute -top-6 left-1/2 -translate-x-1/2 text-yellow-500" />
|
||||
</div>
|
||||
<p className="font-bold text-center text-lg truncate w-full px-2">{board[0].name}</p>
|
||||
<p className="text-teal font-bold">{board[0].points} pts</p>
|
||||
<div className="w-full h-32 bg-gradient-to-t from-yellow-400/20 to-transparent rounded-t-[var(--r-sm)] mt-4 border-t border-yellow-400/30"></div>
|
||||
</motion.div>
|
||||
|
||||
{/* 3rd Place */}
|
||||
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.4 }} className="w-1/3 max-w-[140px] flex flex-col items-center">
|
||||
<div className="relative mb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-amber-700 border-4 border-paper flex items-center justify-center text-white font-bold text-2xl shadow-lg z-10 relative">3</div>
|
||||
<div className="absolute -inset-2 bg-amber-800/20 rounded-full z-0 blur-sm"></div>
|
||||
</div>
|
||||
<p className="font-bold text-center truncate w-full px-2">{board[2].name}</p>
|
||||
<p className="text-teal font-medium text-sm">{board[2].points} pts</p>
|
||||
<div className="w-full h-20 bg-gradient-to-t from-amber-700/10 to-transparent rounded-t-[var(--r-sm)] mt-4"></div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full List */}
|
||||
<Card className="border border-bg-warm p-0 overflow-hidden">
|
||||
<div className="divide-y divide-bg-warm">
|
||||
{board.map((user, index) => {
|
||||
const earnedBadges = BADGE_RULES.filter(r => r.condition(user));
|
||||
const isMe = user.userId === state.currentUser?.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
key={user.userId}
|
||||
className={`p-4 flex flex-col sm:flex-row sm:items-center gap-4 transition-colors ${isMe ? 'bg-teal/5 border-l-4 border-teal' : 'hover:bg-bg-warm/30'}`}
|
||||
>
|
||||
{/* Rank & Name */}
|
||||
<div className="flex items-center gap-4 min-w-[200px]">
|
||||
<span className={`font-mono text-lg font-bold w-6 text-center ${
|
||||
index === 0 ? 'text-yellow-500' :
|
||||
index === 1 ? 'text-slate-400' :
|
||||
index === 2 ? 'text-amber-700' : 'text-fg-muted'
|
||||
}`}>
|
||||
#{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-bold flex items-center gap-2">
|
||||
{user.name}
|
||||
{isMe && <Tag variant="accent" className="text-[10px]">You</Tag>}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted flex items-center gap-1">
|
||||
<CheckSquare size={12} /> {user.testsCompleted || 0} tests completed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points */}
|
||||
<div className="flex-1 flex sm:justify-center">
|
||||
<div className="flex items-center gap-1.5 text-teal font-bold bg-teal/10 px-3 py-1 rounded-full text-sm">
|
||||
<TrendingUp size={14} />
|
||||
{user.points} pts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex gap-2 sm:justify-end min-w-[120px]">
|
||||
{earnedBadges.length > 0 ? (
|
||||
earnedBadges.map(b => (
|
||||
<div key={b.id} title={b.label} className="w-8 h-8 rounded-full bg-bg-warm flex items-center justify-center text-teal shadow-sm border border-bg-warm">
|
||||
<b.icon size={14} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-fg-muted italic">No badges yet</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{board.length === 0 && (
|
||||
<div className="p-8 text-center text-fg-muted">
|
||||
No one is on the leaderboard yet. Be the first to complete a test!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Leaderboard;
|
||||
Reference in New Issue
Block a user