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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user