Initial commit

Create the Board Score PWA with offline support, Kurzgesagt-style themes, multiple game configs, dynamic players, avatars via DiceBear, and complete statistics.
This commit is contained in:
Zed
2026-07-06 16:12:25 +02:00
commit bf67c58171
62 changed files with 54527 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
import { useEffect } from 'react';
import confetti from 'canvas-confetti';
import { motion, AnimatePresence } from 'framer-motion';
import { Avatar } from './ui/avatar';
import { Trophy } from 'lucide-react';
interface CelebrationOverlayProps {
winner: { id: string; name: string; avatar?: string };
gameId: string;
}
export function CelebrationOverlay({ winner, gameId }: CelebrationOverlayProps) {
// 1. Déclenchement de l'animation de confettis
useEffect(() => {
const duration = 3000;
const end = Date.now() + duration;
const frame = () => {
confetti({
particleCount: 5,
angle: 60,
spread: 55,
origin: { x: 0 },
colors: ['#ffb703', '#fbbf24', '#f59e0b', '#d97706', '#b45309']
});
confetti({
particleCount: 5,
angle: 120,
spread: 55,
origin: { x: 1 },
colors: ['#ffb703', '#fbbf24', '#f59e0b', '#d97706', '#b45309']
});
if (Date.now() < end) {
requestAnimationFrame(frame);
}
};
frame();
}, [gameId]);
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 pointer-events-none flex items-center justify-center overflow-hidden"
>
{/* Rayons de soleil tournants en fond */}
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
className="absolute w-[200vw] h-[200vw] max-w-[1500px] max-h-[1500px] opacity-20"
style={{
background: 'repeating-conic-gradient(from 0deg, transparent 0deg 15deg, #f59e0b 15deg 30deg)'
}}
/>
<motion.div
initial={{ scale: 0.5, y: 50, opacity: 0 }}
animate={{
scale: [0.5, 1.2, 1],
y: [50, -20, 0],
opacity: 1
}}
transition={{
duration: 0.8,
ease: "easeOut",
type: "spring",
damping: 12
}}
className="relative z-10 flex flex-col items-center drop-shadow-2xl"
>
{/* La couronne flottante */}
<motion.div
animate={{ y: [-5, 5, -5] }}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
className="mb-[-15px] z-20"
>
<Trophy className="w-16 h-16 text-yellow-400 drop-shadow-lg fill-yellow-400" />
</motion.div>
{/* L'avatar géant du gagnant */}
<div className="relative">
<div className="absolute inset-0 bg-yellow-400 blur-xl opacity-50 rounded-full animate-pulse" />
<Avatar
src={winner.avatar}
name={winner.name}
size="xl"
className="w-32 h-32 border-4 border-yellow-400 shadow-[0_0_30px_rgba(250,204,21,0.5)] relative z-10 bg-background"
/>
</div>
{/* Nom du vainqueur */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.5 }}
className="mt-6 text-center"
>
<div className="text-yellow-400 font-black tracking-widest uppercase text-sm drop-shadow-md mb-1">
Grand Vainqueur
</div>
<div className="text-4xl font-black text-white drop-shadow-[0_4px_4px_rgba(0,0,0,0.5)] tracking-tighter">
{winner.name}
</div>
</motion.div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { motion } from "framer-motion";
export const CosmicBackground = () => (
<div className="fixed inset-0 z-[-1] pointer-events-none overflow-hidden bg-sky-200 dark:bg-[#0F1423] transition-colors duration-700">
{/* Light Mode Elements (Sun & Clouds) */}
<motion.div
animate={{ y: [0, -10, 0] }}
transition={{ repeat: Infinity, duration: 6, ease: "easeInOut" }}
className="absolute -top-20 -right-20 w-80 h-80 bg-yellow-300 rounded-full opacity-80 dark:opacity-0 transition-opacity duration-700 blur-2xl"
/>
<div className="absolute top-32 left-10 w-40 h-12 bg-white/60 rounded-full blur-xl dark:opacity-0 transition-opacity duration-700" />
<div className="absolute top-60 -right-10 w-56 h-16 bg-white/40 rounded-full blur-xl dark:opacity-0 transition-opacity duration-700" />
<div className="absolute bottom-20 left-20 w-64 h-20 bg-white/50 rounded-full blur-2xl dark:opacity-0 transition-opacity duration-700" />
{/* Dark Mode Elements (Nebulas & Stars) */}
<div className="absolute -top-20 -left-20 w-[30rem] h-[30rem] bg-purple-600/30 rounded-full blur-[100px] opacity-0 dark:opacity-100 transition-opacity duration-700" />
<div className="absolute bottom-[-10%] -right-10 w-96 h-96 bg-cyan-600/20 rounded-full blur-[80px] opacity-0 dark:opacity-100 transition-opacity duration-700" />
{/* Twinkling Stars */}
<motion.div animate={{ opacity: [0.2, 0.8, 0.2] }} transition={{ repeat: Infinity, duration: 3 }} className="absolute top-24 left-1/4 w-2 h-2 bg-white rounded-full opacity-0 dark:opacity-100" />
<motion.div animate={{ opacity: [0.3, 1, 0.3] }} transition={{ repeat: Infinity, duration: 4, delay: 1 }} className="absolute top-40 right-1/4 w-1.5 h-1.5 bg-white rounded-full opacity-0 dark:opacity-100" />
<motion.div animate={{ opacity: [0.1, 0.6, 0.1] }} transition={{ repeat: Infinity, duration: 5, delay: 2 }} className="absolute top-1/3 left-10 w-2 h-2 bg-white rounded-full opacity-0 dark:opacity-100" />
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 3.5, delay: 0.5 }} className="absolute bottom-1/3 right-20 w-2 h-2 bg-white rounded-full opacity-0 dark:opacity-100" />
</div>
);
+83
View File
@@ -0,0 +1,83 @@
import { useState, useRef, useEffect } from "react";
import { Menu, Home, History, Settings, Users, BarChart2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "./ui/button";
export function NavigationMenu() {
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const nav = (path: string) => {
setIsOpen(false);
navigate(path);
};
return (
<div ref={menuRef} className="relative z-50">
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(!isOpen)}
className="rounded-full hover:bg-black/10 dark:hover:bg-white/10"
>
<Menu className="w-6 h-6" />
</Button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95, transformOrigin: "top left" }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute top-full left-0 mt-2 w-56 bg-card border shadow-xl rounded-2xl overflow-hidden"
>
<div className="flex flex-col">
<button
onClick={() => nav("/")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors"
>
<Home className="w-5 h-5 mr-3 text-primary" /> Accueil
</button>
<button
onClick={() => nav("/history")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<History className="w-5 h-5 mr-3 text-primary" /> Historique
</button>
<button
onClick={() => nav("/players")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<Users className="w-5 h-5 mr-3 text-primary" /> Joueurs
</button>
<button
onClick={() => nav("/stats")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<BarChart2 className="w-5 h-5 mr-3 text-primary" /> Statistiques
</button>
<button
onClick={() => nav("/settings")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<Settings className="w-5 h-5 mr-3 text-primary" /> Paramètres
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { cn } from "../../utils/classnames";
interface AvatarProps {
src?: string;
name: string;
className?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
export function Avatar({ src, name, className, size = 'md' }: AvatarProps) {
const initials = name
.split(' ')
.map(n => n[0])
.join('')
.substring(0, 2)
.toUpperCase() || '?';
const sizeClasses = {
sm: 'w-6 h-6 text-[10px]',
md: 'w-10 h-10 text-xs',
lg: 'w-14 h-14 text-lg',
xl: 'w-20 h-20 text-2xl',
};
return (
<div
className={cn(
"relative flex shrink-0 overflow-hidden rounded-full border-2 border-background shadow-sm bg-secondary items-center justify-center font-bold text-muted-foreground",
sizeClasses[size],
className
)}
>
{src ? (
<img src={src} alt={name} className="w-full h-full object-cover" />
) : (
<span>{initials}</span>
)}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../utils/classnames"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../utils/classnames"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-2xl text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-95 duration-200",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border-2 border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-12 px-4 py-2",
sm: "h-9 rounded-xl px-3",
lg: "h-14 rounded-2xl px-8 text-lg",
icon: "h-12 w-12",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "../../utils/classnames"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-3xl border bg-card text-card-foreground shadow-sm overflow-hidden", className)}
{...props}
/>
)
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
)
)
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
)
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
)
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
)
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "../../utils/classnames"
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-12 w-full rounded-2xl border border-input bg-background px-4 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }