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
+42
View File
@@ -0,0 +1,42 @@
import { useEffect } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import MainLayout from "./layouts/MainLayout";
import FullWidthLayout from "./layouts/FullWidthLayout";
import Home from "./pages/Home";
import NewGame from "./pages/NewGame";
import PlayGame from "./pages/PlayGame";
import GameOver from "./pages/GameOver";
import History from "./pages/History";
import Players from "./pages/Players";
import Settings from "./pages/Settings";
import Statistics from "./pages/Stats";
import { useAppStore } from "./stores/appStore";
function App() {
const { loadSettings } = useAppStore();
useEffect(() => {
loadSettings();
}, [loadSettings]);
return (
<BrowserRouter>
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/history" element={<History />} />
<Route path="/players" element={<Players />} />
<Route path="/stats" element={<Statistics />} />
<Route path="/settings" element={<Settings />} />
</Route>
<Route element={<FullWidthLayout />}>
<Route path="/new/:gameId" element={<NewGame />} />
<Route path="/play/:sessionId" element={<PlayGame />} />
<Route path="/gameover/:sessionId" element={<GameOver />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;
+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 }
+35
View File
@@ -0,0 +1,35 @@
import Dexie, { Table } from "dexie";
import { GameSession, AppSettings, SavedPlayer } from "../types";
export class BoardScoreDatabase extends Dexie {
sessions!: Table<GameSession, string>;
settings!: Table<AppSettings, number>;
players!: Table<SavedPlayer, string>;
constructor() {
super("BoardScoreDatabase");
this.version(1).stores({
sessions: "id, gameId, dateStart, status",
settings: "id",
});
// Version 2: Added players table
this.version(2).stores({
sessions: "id, gameId, dateStart, status",
settings: "id",
players: "id, name, createdAt",
});
}
}
export const db = new BoardScoreDatabase();
// Initialize default settings if empty
db.on("populate", async () => {
await db.settings.add({
id: 1,
theme: "system",
language: "fr",
});
});
+25
View File
@@ -0,0 +1,25 @@
import { GameConfig } from "../../types";
export const azulConfig: GameConfig = {
imagePath: "/games-assets/azul-logo.webp",
bannerPath: "/games-assets/azul-banner.webp",
rulesUrl: "/games-assets/azul-rules.pdf", // <-- Remplacez par le nom de votre fichier PDF
id: "azul",
name: "Azul",
minPlayers: 2,
maxPlayers: 4,
scoreType: "positive",
description: "Placez des tuiles pour marquer un maximum de points.",
icon: "Palette", // Using Lucide icon names as strings
colors: {
primary: "#005b8f", // Bleu profond tiré de la boite Azul
},
options: [
{
id: "variants",
label: "Variante plateau gris",
type: "boolean",
defaultValue: false,
},
],
};
+41
View File
@@ -0,0 +1,41 @@
import { GameConfig } from "../../types";
export const caboConfig: GameConfig = {
imagePath: "/games-assets/cabo-logo.webp",
bannerPath: "/games-assets/cabo-banner.webp",
rulesUrl: "/games-assets/cabo-rules.pdf",
id: "cabo",
name: "Cabo",
minPlayers: 2,
maxPlayers: 5,
scoreType: "negative",
targetScore: 100,
targetScoreCondition: "reaches",
description:
"Trouvez la licorne. La partie se termine si on atteint ou dépasse 100 points. Tomber pile sur 100 fait redescendre le score à 50 (une fois par joueur).",
icon: "Ghost",
colors: {
primary: "#6e1d5a", // Violet foncé tiré de la boite Cabo
},
calculatePlayerTotal: (rounds, playerId) => {
let total = 0;
let ruleUsed = false;
for (const round of rounds) {
const pScore = round.scores.find((s) => s.playerId === playerId);
total += pScore?.score || 0;
if (total === 100 && !ruleUsed) {
total = 50;
ruleUsed = true;
}
}
return total;
},
options: [
{
id: "kamikaze",
label: "Règle Kamikaze (2 cartes 13 + 2 cartes 12)",
type: "boolean",
defaultValue: true,
},
],
};
+26
View File
@@ -0,0 +1,26 @@
import { GameConfig } from "../../types";
export const harmonieConfig: GameConfig = {
imagePath: "/games-assets/harmonies-logo.webp",
bannerPath: "/games-assets/harmonies-banner.webp",
rulesUrl: "/games-assets/harmonies-rules.pdf",
id: "harmonie",
name: "Harmonies",
minPlayers: 1,
maxPlayers: 4,
scoreType: "positive",
fixedRounds: 1,
description: "Créez des paysages pour accueillir des animaux.",
icon: "TreePine",
colors: {
primary: "#065f46", // Vert émeraude sombre tiré de la boite Harmonies
},
scoringCategories: [
{ id: "arbres", label: "Arbres" },
{ id: "montagnes", label: "Montagnes" },
{ id: "champs", label: "Champs" },
{ id: "batiments", label: "Bâtiments" },
{ id: "eau", label: "Rivière" },
{ id: "animaux", label: "Animaux" },
],
};
+18
View File
@@ -0,0 +1,18 @@
import { GameConfig } from "../types";
import { azulConfig } from "./azul/config";
import { caboConfig } from "./cabo/config";
import { harmonieConfig } from "./harmonie/config";
import { odinCardsConfig } from "./odin_cards/config";
import { skyjoConfig } from "./skyjo/config";
export const games: GameConfig[] = [
azulConfig,
caboConfig,
skyjoConfig,
harmonieConfig,
odinCardsConfig,
];
export const getGameConfig = (id: string): GameConfig | undefined => {
return games.find((g) => g.id === id);
};
+44
View File
@@ -0,0 +1,44 @@
import { GameConfig } from "../../types";
export const odinCardsConfig: GameConfig = {
imagePath: "/games-assets/odin-logo.webp",
bannerPath: "/games-assets/odin-banner.webp",
rulesUrl: "/games-assets/odin-rules.pdf",
id: "odin_cards",
name: "Odin",
minPlayers: 2,
maxPlayers: 6,
scoreType: "negative",
targetScoreCondition: "reaches",
description:
"Videz votre main le plus vite possible. Un point par carte restante. La partie s'arrête quand le score cible est atteint, le plus petit score gagne.",
icon: "Sword",
colors: {
primary: "#c2410c", // Orange intense/feu tiré de la boite Odin
},
options: [
{
id: "target_score",
label: "Score de fin de partie",
type: "select",
defaultValue: "15",
options: [
{ label: "Partie courte (15 pts)", value: "15" },
{ label: "Partie classique (20 pts)", value: "20" },
{ label: "Personnalisé", value: "custom" },
],
},
{
id: "custom_target_score",
label: "Score personnalisé",
type: "number",
defaultValue: 25,
},
],
getTargetScore: (options) => {
if (options?.target_score === "custom") {
return parseInt(options?.custom_target_score || "25", 10);
}
return parseInt(options?.target_score || "15", 10);
},
};
+20
View File
@@ -0,0 +1,20 @@
import { GameConfig } from "../../types";
export const skyjoConfig: GameConfig = {
imagePath: "/games-assets/skyjo-logo.webp",
bannerPath: "/games-assets/skyjo-banner.webp",
rulesUrl: "/games-assets/skyjo-rules.pdf",
id: "skyjo",
name: "Skyjo",
minPlayers: 2,
maxPlayers: 8,
scoreType: "negative",
targetScore: 100,
targetScoreCondition: "reaches", // La partie se termine dès qu'un joueur atteint 100 ou plus
description:
"Remplacez vos cartes pour avoir le moins de points possible. La partie s'arrête quand un joueur dépasse 100 points.",
icon: "Layers",
colors: {
primary: "#0f766e", // Vert-bleu tiré de la boite Skyjo
},
};
+29
View File
@@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { GameConfig } from '../types';
import { hexToHslString } from '../utils/theme';
export function useGameTheme(gameConfig?: GameConfig | null) {
useEffect(() => {
if (!gameConfig?.colors?.primary) return;
const root = document.documentElement;
// Save original styles to revert them when unmounting
const originalPrimary = root.style.getPropertyValue('--primary');
const originalForeground = root.style.getPropertyValue('--primary-foreground');
const hslColor = hexToHslString(gameConfig.colors.primary);
// Apply game specific theme
root.style.setProperty('--primary', hslColor);
root.style.setProperty('--primary-foreground', '0 0% 100%'); // White text for primary buttons
return () => {
// Revert to global theme
if (originalPrimary) root.style.setProperty('--primary', originalPrimary);
else root.style.removeProperty('--primary');
if (originalForeground) root.style.setProperty('--primary-foreground', originalForeground);
else root.style.removeProperty('--primary-foreground');
};
}, [gameConfig]);
}
+16
View File
@@ -0,0 +1,16 @@
import { Outlet } from "react-router-dom";
import { CosmicBackground } from "../components/CosmicBackground";
export default function FullWidthLayout() {
return (
<div className="min-h-screen text-foreground flex flex-col font-sans selection:bg-white/30">
<CosmicBackground />
{/* Container that takes full width but centers content for very large screens */}
<main className="flex-1 w-full relative safe-area-inset-top safe-area-inset-bottom z-10 flex flex-col items-center">
<div className="w-full sm:max-w-md md:max-w-xl flex-1 flex flex-col bg-background/50 shadow-2xl relative">
<Outlet />
</div>
</main>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom";
import { CosmicBackground } from "../components/CosmicBackground";
export default function MainLayout() {
return (
<div className="min-h-screen text-foreground flex flex-col font-sans selection:bg-white/30">
<CosmicBackground />
<main className="flex-1 w-full max-w-md mx-auto relative safe-area-inset-top safe-area-inset-bottom z-10">
<Outlet />
</main>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './styles/globals.css';
import { registerSW } from 'virtual:pwa-register';
// Register service worker for PWA
const updateSW = registerSW({
onNeedRefresh() {
if (confirm('Une nouvelle version est disponible. Mettre à jour ?')) {
updateSW(true);
}
},
onOfflineReady() {
console.log('App ready to work offline');
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Trophy, Home, Share2, RotateCcw, Clock } from "lucide-react";
import * as Icons from "lucide-react";
import { db } from "../database/db";
import { GameSession } from "../types";
import { getGameConfig } from "../games";
import { useGameStore } from "../stores/gameStore";
import { useGameTheme } from "../hooks/useGameTheme";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
import { Avatar } from "../components/ui/avatar";
import { motion } from "framer-motion";
import { calculatePlayerTotalScore } from "../utils/scoring";
import { CelebrationOverlay } from "../components/CelebrationOverlay";
import { generateId } from "../utils/id";
export default function GameOver() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const [session, setSession] = useState<GameSession | null>(null);
const [showCelebration, setShowCelebration] = useState(true);
const startNewGame = useGameStore((state) => state.startNewGame);
const gameConfig = getGameConfig(session?.gameId || "");
useGameTheme(gameConfig);
useEffect(() => {
if (sessionId) {
db.sessions.get(sessionId).then((data) => {
if (data) setSession(data);
});
}
// Stop celebration after 5 seconds to let the user scroll and click buttons
const timer = setTimeout(() => {
setShowCelebration(false);
}, 5000);
return () => clearTimeout(timer);
}, [sessionId]);
if (!session)
return <div className="p-4 text-center mt-10">Chargement...</div>;
const Icon = gameConfig?.icon ? (Icons as any)[gameConfig.icon] : null;
const durationInMinutes = session.dateEnd
? Math.round((session.dateEnd - session.dateStart) / 60000)
: 0;
const calculateTotalScore = (playerId: string) => {
return calculatePlayerTotalScore(
playerId,
session.rounds,
gameConfig,
true,
);
};
const totals = session.players.map((p) => {
// If it's a categorized game (Harmonies), extract details from the first (and only) round
let details = undefined;
if (gameConfig?.scoringCategories && session.rounds.length > 0) {
const pScore = session.rounds[0].scores.find((s) => s.playerId === p.id);
details = pScore?.details;
}
return {
player: p,
score: calculateTotalScore(p.id),
details: details,
isWinner: session.winnerIds?.includes(p.id),
};
});
// Sort by score
if (gameConfig?.scoreType === "negative") {
totals.sort((a, b) => a.score - b.score);
} else {
totals.sort((a, b) => b.score - a.score);
}
const handleShare = async () => {
const text =
`Partie de ${gameConfig?.name}\n\n` +
totals
.map((t, i) => `${i + 1}. ${t.player.name} : ${t.score} pts`)
.join("\n");
if (navigator.share) {
try {
await navigator.share({
title: "Résultat de la partie",
text: text,
});
} catch (e) {}
} else {
navigator.clipboard.writeText(text);
alert("Résultats copiés dans le presse-papier !");
}
};
const handleReplay = async () => {
if (!session || !gameConfig) return;
// Create new players array with new IDs but same names and avatars
const newPlayers = session.players.map((p) => ({
id: generateId(),
name: p.name,
avatar: p.avatar,
}));
const newSessionId = await startNewGame(
gameConfig.id,
newPlayers,
session.options,
);
navigate(`/play/${newSessionId}`);
};
// Trouver le vainqueur principal (le premier dans le tableau trié)
const topWinner = totals.find((t) => t.isWinner)?.player;
return (
<div className="flex flex-col min-h-screen bg-background pb-24">
{/* 💥 Animation de Célébration (disparaît après qq secondes) */}
{showCelebration && topWinner && (
<CelebrationOverlay winner={topWinner} gameId={gameConfig?.id || ""} />
)}
{/* Hero Banner with Winner */}
<div className="w-[100vw] ml-[calc(50%-50vw)] bg-primary/10 border-b relative overflow-hidden flex flex-col items-center justify-center pt-12 pb-16 shadow-inner">
{gameConfig?.bannerPath ? (
<>
<div
className="absolute inset-0 opacity-40 pointer-events-none bg-cover bg-center"
style={{ backgroundImage: `url(${gameConfig.bannerPath})` }}
/>
<div className="absolute inset-0 bg-gradient-to-b from-background/40 to-background pointer-events-none" />
</>
) : (
<div className="absolute inset-0 opacity-10 pointer-events-none">
{Icon && (
<Icon className="w-full h-full text-primary scale-150 transform translate-x-1/4 translate-y-1/4" />
)}
</div>
)}
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="relative z-10 flex flex-col items-center text-center px-4 max-w-xl mx-auto"
>
<div className="w-24 h-24 bg-background rounded-full shadow-lg flex items-center justify-center mb-6 border-4 border-primary overflow-hidden">
{gameConfig?.imagePath ? (
<img
src={gameConfig.imagePath}
alt={`Logo ${gameConfig.name}`}
className="w-full h-full object-cover"
/>
) : Icon ? (
<Icon className="w-12 h-12 text-primary" strokeWidth={1.5} />
) : (
<Trophy className="w-12 h-12 text-primary" />
)}
</div>
<div>
<h1 className="text-4xl font-black tracking-tight mb-2">
Partie Terminée
</h1>
<p className="text-lg font-medium text-primary bg-background/50 px-4 py-1 rounded-full inline-block backdrop-blur-sm shadow-sm">
{gameConfig?.name}
</p>
</div>
<div className="flex items-center text-sm font-medium text-muted-foreground bg-background px-4 py-2 rounded-full mt-6 shadow-sm border">
<Clock className="w-4 h-4 mr-2" />
{durationInMinutes} minutes {session.rounds.length} manches
</div>
</motion.div>
</div>
<div className="space-y-4 flex-1 p-4 mt-2">
<h2 className="text-xl font-semibold px-2">Classement</h2>
<div className="space-y-3">
{totals.map((t, index) => (
<motion.div
key={t.player.id}
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: index * 0.1 }}
>
<Card
className={`overflow-hidden ${t.isWinner ? "border-primary border-2 shadow-md" : ""}`}
>
<CardContent className="p-0 flex items-stretch">
<div
className={`w-12 flex items-center justify-center font-bold text-lg ${t.isWinner ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground"}`}
>
#{index + 1}
</div>
<div className="flex-1 p-4 flex flex-col justify-center">
<div className="flex items-center justify-between w-full">
<div className="flex items-center space-x-3">
<Avatar
src={t.player.avatar}
name={t.player.name}
size="md"
/>
<span className="font-semibold text-lg">
{t.player.name}
</span>
</div>
<span className="text-2xl font-bold">{t.score}</span>
</div>
{/* Display details if it's a categorized game like Harmonies */}
{t.details && gameConfig?.scoringCategories && (
<div className="mt-3 pt-3 border-t border-border/50 grid grid-cols-3 gap-2">
{gameConfig.scoringCategories.map((cat) => {
const val = t.details?.[cat.id];
if (!val || val === "0") return null;
return (
<div
key={cat.id}
className="flex flex-col items-center bg-secondary/30 rounded-lg p-1.5"
>
<span className="text-[10px] text-muted-foreground truncate w-full text-center">
{cat.label}
</span>
<span className="text-sm font-bold">{val}</span>
</div>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
</div>
<div className="fixed bottom-0 left-0 right-0 p-4 bg-background/95 backdrop-blur-md border-t flex flex-wrap gap-3 justify-center max-w-md mx-auto">
<Button variant="outline" className="flex-1" onClick={handleReplay}>
<RotateCcw className="w-4 h-4 mr-2" />
Rejouer
</Button>
<Button variant="outline" className="flex-1" onClick={handleShare}>
<Share2 className="w-4 h-4 mr-2" />
Partager
</Button>
<Button className="w-full" onClick={() => navigate("/")}>
<Home className="w-4 h-4 mr-2" />
Retour Accueil
</Button>
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useNavigate } from "react-router-dom";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db";
import { games } from "../games";
import { Card, CardContent } from "../components/ui/card";
import { NavigationMenu } from "../components/NavigationMenu";
import { motion } from "framer-motion";
function formatDate(ms: number) {
const d = new Date(ms);
return d.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
export default function History() {
const navigate = useNavigate();
const sessions = useLiveQuery(() =>
db.sessions.orderBy("dateStart").reverse().toArray(),
);
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<NavigationMenu />
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
Historique
</h1>
</header>
{!sessions || sessions.length === 0 ? (
<div className="text-center text-muted-foreground py-10 bg-background/60 backdrop-blur-sm rounded-[2rem]">
Aucune partie enregistrée.
</div>
) : (
<div className="space-y-4">
{sessions.map((session, index) => {
const game = games.find((g) => g.id === session.gameId);
if (!game) return null;
return (
<motion.div
key={session.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: index * 0.05,
type: "spring",
stiffness: 100,
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div
className="bg-gradient-to-r p-1 rounded-[2rem] shadow-lg cursor-pointer"
style={{
backgroundImage: `linear-gradient(to right, ${game.colors.primary}, ${game.colors.primary}80)`,
}}
onClick={() =>
navigate(
session.status === "playing"
? `/play/${session.id}`
: `/gameover/${session.id}`,
)
}
>
<Card className="border-0 bg-background/90 backdrop-blur-md m-0 h-full w-full">
<CardContent className="p-4 flex items-center justify-between">
<div className="w-full">
<div className="flex justify-between items-start mb-2">
<h3
className="font-black text-xl leading-tight"
style={{ color: game.colors.primary }}
>
{game.name}
</h3>
<span
className={`text-xs font-bold px-2 py-1 rounded-full ${session.status === "playing" ? "bg-pink-500 text-white animate-pulse" : "bg-secondary text-muted-foreground"}`}
>
{session.status === "playing"
? "En cours"
: "Terminée"}
</span>
</div>
<p className="text-sm text-foreground/70 font-medium mb-2">
{formatDate(session.dateStart)}
</p>
<div className="flex flex-wrap gap-1.5 mt-1">
{session.players.map((p) => {
const isWinner = session.winnerIds?.includes(p.id);
return (
<span
key={p.id}
className={`text-xs px-2 py-1 rounded-md font-bold ${isWinner ? "bg-yellow-400 text-yellow-900 shadow-sm" : "bg-black/5 dark:bg-white/10 text-foreground/80"}`}
>
{isWinner ? "🏆 " : ""}
{p.name}
</span>
);
})}
</div>
</div>
</CardContent>
</Card>
</div>
</motion.div>
);
})}
</div>
)}
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { useNavigate } from "react-router-dom";
import { useLiveQuery } from "dexie-react-hooks";
import * as Icons from "lucide-react";
import { games } from "../games";
import { db } from "../database/db";
import { motion } from "framer-motion";
import { NavigationMenu } from "../components/NavigationMenu";
// Kurzgesagt-style Planet Logo
const PlanetLogo = () => (
<motion.div
animate={{ y: [0, -12, 0], rotate: [0, 5, 0] }}
transition={{ repeat: Infinity, duration: 5, ease: "easeInOut" }}
className="relative w-28 h-28 flex items-center justify-center mb-4"
>
{/* The Planet Body */}
<div className="absolute inset-0 bg-gradient-to-tr from-orange-600 to-yellow-400 rounded-full shadow-[inset_-8px_-8px_20px_rgba(0,0,0,0.2),0_10px_30px_rgba(245,158,11,0.5)]">
{/* Craters */}
<div className="absolute top-4 right-6 w-5 h-5 bg-orange-700/30 rounded-full shadow-[inset_2px_2px_4px_rgba(0,0,0,0.2)]" />
<div className="absolute bottom-6 left-5 w-8 h-8 bg-orange-700/30 rounded-full shadow-[inset_2px_2px_4px_rgba(0,0,0,0.2)]" />
<div className="absolute top-12 left-4 w-3 h-3 bg-orange-700/30 rounded-full shadow-[inset_1px_1px_2px_rgba(0,0,0,0.2)]" />
</div>
{/* The Planet Ring */}
<div className="absolute w-36 h-10 border-[6px] border-yellow-200/90 rounded-[100%] transform -rotate-[20deg] shadow-[0_4px_10px_rgba(0,0,0,0.1)]" />
{/* Center Icon */}
<Icons.Dices className="w-12 h-12 text-white relative z-10 drop-shadow-md transform rotate-12" />
</motion.div>
);
export default function Home() {
const navigate = useNavigate();
// Load unfinished games
const activeSessions = useLiveQuery(() =>
db.sessions.where("status").equals("playing").toArray(),
);
return (
<div className="relative flex flex-col font-sans">
<div className="relative z-10 p-4 space-y-8 pb-24">
{/* Header Section */}
<div className="flex items-center justify-between sticky top-4 z-50">
<div className="flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm p-0.5 border border-black/5 dark:border-white/5">
<NavigationMenu />
</div>
</div>
<header className="flex flex-col items-center justify-center pt-2 pb-6">
<PlanetLogo />
<h1 className="text-4xl font-black tracking-tighter text-foreground drop-shadow-sm">
Board Score
</h1>
</header>
{/* Active Game Section */}
{activeSessions && activeSessions.length > 0 && (
<section className="space-y-4">
<div className="flex items-center justify-between px-2">
<h2 className="text-xl font-bold tracking-tight text-foreground/90">
Partie en cours
</h2>
<span className="flex h-3 w-3 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-pink-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-pink-500"></span>
</span>
</div>
<div className="space-y-4">
{activeSessions.map((session) => {
const game = games.find((g) => g.id === session.gameId);
if (!game) return null;
const GameIcon = game.icon
? (Icons as any)[game.icon]
: Icons.Box;
return (
<motion.div
key={session.id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div
className="bg-gradient-to-r from-pink-500 to-orange-400 rounded-[2rem] p-1 shadow-xl cursor-pointer"
onClick={() => navigate(`/play/${session.id}`)}
>
<div className="bg-background/20 backdrop-blur-sm rounded-[1.8rem] p-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="w-14 h-14 bg-white/20 rounded-2xl flex items-center justify-center shadow-inner">
{game.imagePath ? (
<img
src={game.imagePath}
alt={game.name}
className="w-8 h-8 object-contain"
/>
) : (
<GameIcon className="w-8 h-8 text-white" />
)}
</div>
<div>
<p className="font-black text-white text-lg leading-tight drop-shadow-sm">
{game.name}
</p>
<p className="text-sm text-white/90 font-medium">
{session.players.map((p) => p.name).join(", ")}
</p>
</div>
</div>
<div className="w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-md">
<Icons.Play className="w-5 h-5 text-pink-500 ml-1" />
</div>
</div>
</div>
</motion.div>
);
})}
</div>
</section>
)}
{/* New Game Section */}
<section className="space-y-4">
<h2 className="text-xl font-bold tracking-tight px-2 text-foreground/90">
Nouveau Jeu
</h2>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3 px-1">
{games.map((game, index) => {
const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box;
return (
<motion.div
key={game.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
delay: index * 0.05,
type: "spring",
stiffness: 150,
damping: 20,
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => navigate(`/new/${game.id}`)}
className="flex flex-col items-center gap-2 cursor-pointer group"
>
<div
className="relative w-full aspect-square rounded-3xl shadow-lg flex items-center justify-center border-4 border-black/5 dark:border-white/5 overflow-hidden transition-all group-hover:shadow-2xl"
style={{
backgroundColor: game.imagePath
? "transparent"
: game.colors.primary,
}}
>
{game.imagePath ? (
<div className="absolute inset-0 bg-white dark:bg-black/20">
<img
src={game.imagePath}
alt={`Logo ${game.name}`}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
</div>
) : (
<>
<div className="absolute -top-4 -right-4 w-16 h-16 rounded-full bg-white/10" />
<div className="absolute -bottom-2 -left-2 w-12 h-12 rounded-full bg-black/10" />
<Icon
className="w-12 h-12 text-white drop-shadow-md relative z-10"
strokeWidth={1.5}
/>
</>
)}
</div>
<div className="text-center w-full">
<h3 className="font-bold text-xs leading-tight truncate px-1 drop-shadow-sm text-foreground/90">
{game.name}
</h3>
</div>
</motion.div>
);
})}
</div>
</section>
</div>
</div>
);
}
+405
View File
@@ -0,0 +1,405 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
ChevronLeft,
Plus,
X,
Play,
Users,
GripVertical,
BookOpen,
} from "lucide-react";
import * as Icons from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
import { Reorder, useDragControls } from "framer-motion";
import { db } from "../database/db";
import { getGameConfig } from "../games";
import { useGameStore } from "../stores/gameStore";
import { useGameTheme } from "../hooks/useGameTheme";
import { Player, SavedPlayer } from "../types";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Card, CardContent } from "../components/ui/card";
import { Avatar } from "../components/ui/avatar";
import { NavigationMenu } from "../components/NavigationMenu";
import { generateId } from "../utils/id";
function DraggablePlayerItem({
player,
index,
avatar,
canRemove,
updatePlayerName,
handleRemovePlayer,
}: any) {
const controls = useDragControls();
return (
<Reorder.Item
value={player}
dragListener={false}
dragControls={controls}
className="flex items-center space-x-2 bg-background/60 backdrop-blur-sm border shadow-sm relative rounded-2xl w-full p-2 pr-3"
>
<div
className="p-2 cursor-grab active:cursor-grabbing touch-none"
onPointerDown={(e) => controls.start(e)}
>
<GripVertical className="w-5 h-5 text-muted-foreground opacity-50 hover:opacity-100 transition-opacity" />
</div>
{avatar && (
<Avatar
src={avatar}
name={player.name}
size="md"
className="shrink-0 border-2 border-background shadow-sm"
/>
)}
<Input
value={player.name}
onChange={(e) => updatePlayerName(player.id, e.target.value)}
placeholder={`Joueur ${index + 1}`}
className="flex-1 bg-transparent border-0 focus-visible:ring-0 focus-visible:ring-offset-0 font-bold text-lg px-2"
/>
{canRemove && (
<Button
variant="ghost"
size="icon"
onClick={() => handleRemovePlayer(player.id)}
className="text-destructive shrink-0 hover:bg-destructive/10 rounded-xl"
>
<X className="w-5 h-5" />
</Button>
)}
</Reorder.Item>
);
}
export default function NewGame() {
const { gameId } = useParams<{ gameId: string }>();
const navigate = useNavigate();
const gameConfig = getGameConfig(gameId || "");
useGameTheme(gameConfig);
const startNewGame = useGameStore((state) => state.startNewGame);
const [players, setPlayers] = useState<Player[]>([
{ id: generateId(), name: "" },
{ id: generateId(), name: "" },
]);
const [options, setOptions] = useState<Record<string, any>>({});
const savedPlayers =
useLiveQuery(() => db.players.orderBy("name").toArray()) || [];
const availableSavedPlayers = savedPlayers.filter(
(sp) =>
!players.some(
(p) => p.name.trim().toLowerCase() === sp.name.trim().toLowerCase(),
),
);
if (!gameConfig) {
return <div className="p-4 text-center">Jeu introuvable</div>;
}
const handleAddPlayer = () => {
if (players.length < gameConfig.maxPlayers) {
setPlayers([...players, { id: generateId(), name: "" }]);
}
};
const handleQuickAdd = (savedPlayer: SavedPlayer) => {
// Fill the first empty input if any
const emptyIndex = players.findIndex((p) => p.name.trim() === "");
if (emptyIndex !== -1) {
const newPlayers = [...players];
newPlayers[emptyIndex] = {
id: savedPlayer.id,
name: savedPlayer.name,
avatar: savedPlayer.avatar,
};
setPlayers(newPlayers);
} else if (players.length < gameConfig.maxPlayers) {
// Add a new slot if we have room
setPlayers([
...players,
{
id: savedPlayer.id,
name: savedPlayer.name,
avatar: savedPlayer.avatar,
},
]);
}
};
const handleRemovePlayer = (id: string) => {
if (players.length > gameConfig.minPlayers) {
setPlayers(players.filter((p) => p.id !== id));
}
};
const updatePlayerName = (id: string, name: string) => {
setPlayers(players.map((p) => (p.id === id ? { ...p, name } : p)));
};
const handleStart = async () => {
// Basic validation
const validPlayers = players.filter((p) => p.name.trim() !== "");
if (validPlayers.length < gameConfig.minPlayers) {
alert(`Il faut au moins ${gameConfig.minPlayers} joueurs avec un nom.`);
return;
}
// Auto-save new players and generate avatar if missing
const playersToSave = [...validPlayers];
for (let i = 0; i < playersToSave.length; i++) {
const p = playersToSave[i];
const exists = await db.players
.where("name")
.equalsIgnoreCase(p.name)
.first();
if (!exists) {
let finalAvatar = p.avatar;
// Generate a random avatar automatically
if (!finalAvatar) {
try {
const seed = Math.random().toString(36).substring(7);
const url = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${seed}&backgroundColor=transparent`;
const response = await fetch(url);
const svgText = await response.text();
finalAvatar = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`;
// Update the player array we are passing to the game session
playersToSave[i].avatar = finalAvatar;
} catch (e) {
console.error("Failed to generate auto-avatar");
}
}
await db.players.add({
id: p.id,
name: p.name,
createdAt: Date.now(),
avatar: finalAvatar,
});
}
}
const sessionId = await startNewGame(gameConfig.id, playersToSave, options);
navigate(`/play/${sessionId}`);
};
const Icon = gameConfig?.icon ? (Icons as any)[gameConfig.icon] : null;
return (
<div className="relative flex flex-col font-sans pb-24 z-10">
{/* Floating Top Bar (Header replacement) */}
<div className="relative z-50 pt-12 px-4 flex items-center justify-between">
<div className="flex items-center gap-1 bg-background/40 backdrop-blur-md rounded-full shadow-sm p-0.5 border border-black/5 dark:border-white/5">
<NavigationMenu />
<Button
variant="ghost"
size="icon"
className="rounded-full hover:bg-black/10 dark:hover:bg-white/10"
onClick={() => navigate(-1)}
>
<ChevronLeft className="w-6 h-6" />
</Button>
</div>
</div>
{/* Hero Banner Description (Moved from header to body) */}
<div className="w-full relative flex flex-col items-center justify-center py-6 mt-2">
<div className="w-28 h-28 bg-background/90 backdrop-blur-sm rounded-[2rem] shadow-xl flex items-center justify-center mb-5 relative z-10 transform -rotate-3 border-4 border-primary/20 overflow-hidden">
{gameConfig.imagePath ? (
<img
src={gameConfig.imagePath}
alt={`Logo ${gameConfig.name}`}
className="w-full h-full object-cover"
/>
) : Icon ? (
<Icon
className="w-14 h-14 text-primary drop-shadow-sm"
strokeWidth={1.5}
/>
) : null}
</div>
<div className="text-center relative z-10 px-4 flex flex-col items-center">
<p className="text-sm font-medium text-foreground/80 max-w-sm mx-auto bg-background/40 backdrop-blur-md py-2 px-6 rounded-full shadow-sm border border-black/5 dark:border-white/5 mb-4">
{gameConfig.description}
</p>
{gameConfig.rulesUrl && (
<Button
variant="outline"
size="sm"
className="rounded-full bg-background/80 backdrop-blur-md border-primary/20 text-primary shadow-lg hover:bg-primary/10 transition-colors font-bold"
onClick={() => window.open(gameConfig.rulesUrl, "_blank")}
>
<BookOpen className="w-4 h-4 mr-2" />
Règles du jeu
</Button>
)}
</div>
</div>
<main className="flex-1 p-4 space-y-8">
<section className="space-y-4">
<div className="flex items-center justify-between px-2">
<h2 className="text-2xl font-black tracking-tighter drop-shadow-sm">
Joueurs
</h2>
<span className="text-sm font-bold text-primary bg-primary/10 px-3 py-1 rounded-full">
{players.length}/{gameConfig.maxPlayers}
</span>
</div>
<Card className="bg-background/80 backdrop-blur-xl border-0 shadow-lg rounded-[2rem] p-3 overflow-hidden">
<CardContent className="p-0">
<Reorder.Group
axis="y"
values={players}
onReorder={setPlayers}
className="space-y-2"
>
{players.map((player, index) => {
const dbPlayer = savedPlayers.find((p) => p.id === player.id);
const avatar = player.avatar || dbPlayer?.avatar;
return (
<DraggablePlayerItem
key={player.id}
player={player}
index={index}
avatar={avatar}
canRemove={players.length > gameConfig.minPlayers}
updatePlayerName={updatePlayerName}
handleRemovePlayer={handleRemovePlayer}
/>
);
})}
</Reorder.Group>
{players.length < gameConfig.maxPlayers && (
<Button
variant="outline"
className="w-full mt-3 border-dashed border-2 rounded-2xl h-14 font-bold text-muted-foreground hover:text-primary hover:border-primary/50 hover:bg-primary/5 transition-all"
onClick={handleAddPlayer}
>
<Plus className="w-5 h-5 mr-2" />
Ajouter une place vide
</Button>
)}
</CardContent>
</Card>
{availableSavedPlayers.length > 0 && (
<div className="mt-6 pt-2 px-1">
<p className="text-sm font-bold text-muted-foreground mb-3 flex items-center">
<Users className="w-4 h-4 mr-2" /> Ajout rapide
</p>
<div className="flex flex-wrap gap-2">
{availableSavedPlayers.map((sp) => (
<div
key={sp.id}
className="flex items-center bg-background/80 backdrop-blur-md hover:bg-primary/10 hover:text-primary px-3 py-2 rounded-full cursor-pointer active:scale-95 transition-all text-sm font-bold border-2 border-transparent hover:border-primary/20 shadow-sm"
onClick={() => handleQuickAdd(sp)}
>
{sp.avatar ? (
<Avatar
src={sp.avatar}
name={sp.name}
size="sm"
className="mr-2 border-none w-6 h-6 shadow-sm"
/>
) : (
<Plus className="w-4 h-4 mr-1.5 opacity-50" />
)}
{sp.name}
</div>
))}
</div>
</div>
)}
</section>
{gameConfig.options && gameConfig.options.length > 0 && (
<section className="space-y-4">
<h2 className="text-2xl font-black tracking-tighter px-2 drop-shadow-sm">
Options
</h2>
<Card className="bg-background/80 backdrop-blur-xl border-0 shadow-lg rounded-[2rem] overflow-hidden">
<CardContent className="p-5 space-y-5">
{gameConfig.options.map((opt) => (
<div
key={opt.id}
className="flex items-center justify-between"
>
<label htmlFor={opt.id} className="text-base font-bold">
{opt.label}
</label>
{opt.type === "boolean" && (
<input
type="checkbox"
id={opt.id}
className="w-6 h-6 accent-primary rounded-md cursor-pointer"
checked={options[opt.id] ?? opt.defaultValue}
onChange={(e) =>
setOptions({ ...options, [opt.id]: e.target.checked })
}
/>
)}
{opt.type === "number" && (
<div
className={`transition-opacity ${opt.id === "custom_target_score" && options["target_score"] !== "custom" ? "opacity-30 pointer-events-none" : "opacity-100"}`}
>
<Input
type="number"
id={opt.id}
className="w-24 text-center font-bold text-lg rounded-2xl h-12 bg-black/5 dark:bg-white/5 border-none focus-visible:ring-primary"
value={options[opt.id] ?? opt.defaultValue}
onChange={(e) =>
setOptions({ ...options, [opt.id]: e.target.value })
}
/>
</div>
)}
{opt.type === "select" && opt.options && (
<select
id={opt.id}
className="p-3 border-none shadow-sm font-bold rounded-2xl bg-black/5 dark:bg-white/5 cursor-pointer outline-none focus:ring-2 focus:ring-primary/50"
value={options[opt.id] ?? opt.defaultValue}
onChange={(e) =>
setOptions({ ...options, [opt.id]: e.target.value })
}
>
{opt.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
)}
</div>
))}
</CardContent>
</Card>
</section>
)}
</main>
<div className="fixed bottom-0 left-0 right-0 p-4 bg-background/80 backdrop-blur-xl border-t z-20 pb-safe">
<div className="max-w-md mx-auto">
<Button
size="lg"
className="w-full text-xl font-black shadow-xl h-16 rounded-[2rem] transition-transform active:scale-95"
onClick={handleStart}
>
<Play className="w-6 h-6 mr-3" fill="currentColor" />
Commencer
</Button>
</div>
</div>
</div>
);
}
+583
View File
@@ -0,0 +1,583 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Check, Undo2, Award, Trash2, BookOpen } from "lucide-react";
import * as Icons from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db";
import { useGameStore } from "../stores/gameStore";
import { getGameConfig } from "../games";
import { useGameTheme } from "../hooks/useGameTheme";
import { RoundScore } from "../types";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { NavigationMenu } from "../components/NavigationMenu";
import { motion } from "framer-motion";
import { calculatePlayerTotalScore } from "../utils/scoring";
export default function PlayGame() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const { activeSession, loadSession, addRound, undoLastRound, finishGame } =
useGameStore();
const [currentScores, setCurrentScores] = useState<Record<string, string>>(
{},
);
const [caboCalls, setCaboCalls] = useState<
Record<string, "success" | "fail" | null>
>({});
const [kamikazePlayer, setKamikazePlayer] = useState<string | null>(null);
const [detailedScores, setDetailedScores] = useState<
Record<string, Record<string, string>>
>({});
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
// Keep active session players updated if a global player rename happens
const playersInDb = useLiveQuery(() => db.players.toArray());
const gameConfig = getGameConfig(activeSession?.gameId || "");
useGameTheme(gameConfig);
useEffect(() => {
if (sessionId) {
loadSession(sessionId);
}
}, [sessionId, loadSession]);
useEffect(() => {
if (!activeSession || !gameConfig) return;
// Check end conditions after a round is added
let shouldEnd = false;
// Fixed rounds check
if (
gameConfig.fixedRounds &&
activeSession.rounds.length >= gameConfig.fixedRounds
) {
shouldEnd = true;
}
// Target score check
const currentTargetScore = gameConfig.getTargetScore
? gameConfig.getTargetScore(activeSession.options)
: gameConfig.targetScore;
if (currentTargetScore !== undefined) {
const isTargetReached = activeSession.players.some((p) => {
// Calculate total to check if it triggers end game conditions
const total = calculateTotalScore(p.id);
if (gameConfig.targetScoreCondition === "exceeds") {
return total > currentTargetScore;
}
return total >= currentTargetScore;
});
if (isTargetReached) {
shouldEnd = true;
}
}
if (shouldEnd && activeSession.status === "playing") {
handleEndGame();
}
}, [activeSession?.rounds.length]);
if (!activeSession)
return <div className="p-4 text-center mt-10">Chargement...</div>;
if (!gameConfig)
return <div className="p-4 text-center">Jeu introuvable</div>;
const Icon = gameConfig.icon ? (Icons as any)[gameConfig.icon] : null;
// Use the most up to date names if available
const displayPlayers = activeSession.players.map((p) => {
const dbPlayer = playersInDb?.find((dbp) => dbp.id === p.id);
return dbPlayer ? { ...p, name: dbPlayer.name } : p;
});
const calculateTotalScore = (playerId: string) => {
return calculatePlayerTotalScore(
playerId,
activeSession.rounds,
gameConfig,
true,
);
};
const getLiveTotalScore = (playerId: string) => {
let total = calculateTotalScore(playerId);
if (gameConfig?.scoringCategories) {
const pDetails = detailedScores[playerId] || {};
for (const key in pDetails) {
const val = parseInt(pDetails[key] || "0", 10);
if (!isNaN(val)) total += val;
}
} else {
const val = parseInt(currentScores[playerId] || "0", 10);
if (!isNaN(val)) total += val;
}
return total;
};
const handleScoreChange = (playerId: string, value: string) => {
setCurrentScores((prev) => ({ ...prev, [playerId]: value }));
};
const handleDetailedScoreChange = (
playerId: string,
categoryId: string,
value: string,
) => {
setDetailedScores((prev) => ({
...prev,
[playerId]: {
...(prev[playerId] || {}),
[categoryId]: value,
},
}));
};
const handleValidateRound = async () => {
const scores: RoundScore[] = activeSession.players.map((p) => {
// For games with categories (like Harmonies), we store the sum as the main score
// and keep the details in the `details` object
if (gameConfig?.scoringCategories) {
const pDetails = detailedScores[p.id] || {};
let total = 0;
for (const key in pDetails) {
const val = parseInt(pDetails[key] || "0", 10);
if (!isNaN(val)) total += val;
}
return {
playerId: p.id,
score: total,
details: pDetails, // <--- This saves all category scores!
};
}
// For standard games (Azul, Cabo, Odin)
let val = parseInt(currentScores[p.id] || "0", 10);
val = isNaN(val) ? 0 : val;
// Kamikaze Rule Override (Cabo)
if (kamikazePlayer) {
if (p.id === kamikazePlayer) {
val = 0; // Kamikaze player gets 0 points
} else {
val = 50; // Everyone else gets 50 points
}
}
return {
playerId: p.id,
score: val,
caboCall: caboCalls[p.id] || undefined,
kamikaze: p.id === kamikazePlayer || undefined,
};
});
await addRound(scores);
setCurrentScores({}); // reset inputs
setDetailedScores({}); // reset detailed inputs
setCaboCalls({}); // reset cabo calls
setKamikazePlayer(null); // reset kamikaze
setCurrentCategoryIndex(0); // Reset wizard
};
const handleEndGame = async () => {
// Determine winner based on scoreType
const totals = activeSession.players.map((p) => ({
id: p.id,
score: calculateTotalScore(p.id),
}));
let winnerIds: string[] = [];
if (totals.length > 0) {
if (gameConfig.scoreType === "negative") {
const minScore = Math.min(...totals.map((t) => t.score));
winnerIds = totals.filter((t) => t.score === minScore).map((t) => t.id);
} else {
const maxScore = Math.max(...totals.map((t) => t.score));
winnerIds = totals.filter((t) => t.score === maxScore).map((t) => t.id);
}
}
await finishGame(winnerIds);
navigate(`/gameover/${activeSession.id}`);
};
const handleCancelGame = async () => {
if (
window.confirm(
"Êtes-vous sûr de vouloir annuler et supprimer cette partie ?",
)
) {
await db.sessions.delete(activeSession.id);
navigate("/");
}
};
const currentRoundNum = activeSession.rounds.length + 1;
const isSingleRound = gameConfig?.fixedRounds === 1;
return (
<div className="relative flex flex-col min-h-screen pb-32 font-sans selection:bg-white/30 z-10">
{/* Floating Top Bar (Header replacement) */}
<div className="relative z-50 pt-12 px-4 pb-2 flex items-start justify-between w-full max-w-xl mx-auto">
<div className="flex items-center gap-1 bg-background/40 backdrop-blur-md rounded-full shadow-sm p-0.5 border border-black/5 dark:border-white/5">
<NavigationMenu />
</div>
<div className="text-center flex-1 flex flex-col items-center pointer-events-none mt-1">
<div className="flex items-center justify-center gap-2 mb-1.5">
{gameConfig?.imagePath ? (
<div className="w-8 h-8 rounded-full overflow-hidden bg-background/60 backdrop-blur-sm p-1 shadow-sm border border-black/5 dark:border-white/5">
<img
src={gameConfig.imagePath}
alt="Logo"
className="w-full h-full object-contain"
/>
</div>
) : Icon ? (
<div className="w-8 h-8 rounded-full bg-background/60 backdrop-blur-sm flex items-center justify-center shadow-sm border border-black/5 dark:border-white/5">
<Icon
className="w-5 h-5 text-primary drop-shadow-sm"
strokeWidth={2.5}
/>
</div>
) : null}
<h1 className="text-2xl font-black tracking-tighter truncate drop-shadow-sm">
{gameConfig?.name}
</h1>
</div>
<div className="bg-primary/20 text-primary backdrop-blur-md border border-primary/20 px-4 py-1.5 rounded-full text-xs font-black uppercase tracking-widest shadow-sm">
{isSingleRound ? "Décompte final" : `Manche ${currentRoundNum}`}
</div>
</div>
<div className="shrink-0 flex items-center bg-background/40 rounded-full backdrop-blur-md p-0.5 shadow-sm border border-black/5 dark:border-white/5">
{gameConfig?.rulesUrl && (
<Button
variant="ghost"
size="icon"
onClick={() => window.open(gameConfig.rulesUrl, "_blank")}
className="text-blue-500 hover:bg-blue-500/20 rounded-full transition-colors w-10 h-10"
title="Consulter les règles"
>
<BookOpen className="w-5 h-5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={handleCancelGame}
className="text-destructive hover:bg-destructive/20 rounded-full transition-colors w-10 h-10"
title="Annuler la partie"
>
<Trash2 className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleEndGame}
className="text-primary hover:bg-primary/20 rounded-full transition-colors w-10 h-10"
title="Terminer la partie"
>
<Award className="w-5 h-5" />
</Button>
</div>
</div>
<main className="flex-1 p-4 space-y-8 mt-2">
{/* Leaderboard Summary (Floating Chips) */}
<div className="flex overflow-x-auto pb-4 -mx-4 px-4 space-x-3 hide-scrollbar">
{displayPlayers.map((player) => {
const total = getLiveTotalScore(player.id);
return (
<motion.div
key={player.id}
className="shrink-0 flex items-center bg-background/80 backdrop-blur-xl rounded-full p-1.5 pr-4 shadow-sm border border-black/5 dark:border-white/5"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
>
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center mr-3 border border-primary/20 text-primary font-black text-lg">
{total}
</div>
<div className="text-sm font-bold truncate max-w-[80px]">
{player.name}
</div>
</motion.div>
);
})}
</div>
{/* Current Round Input Section */}
<section className="relative">
<div className="flex items-center justify-between px-2 mb-4">
<h2 className="text-2xl font-black tracking-tighter drop-shadow-sm">
{gameConfig.scoringCategories
? gameConfig.scoringCategories[currentCategoryIndex].label
: isSingleRound
? "Saisie des scores"
: "À vos marques !"}
</h2>
<div className="flex items-center gap-2">
{gameConfig.scoringCategories && currentCategoryIndex > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setCurrentCategoryIndex((prev) => prev - 1)}
className="rounded-full font-bold h-9 bg-background/50 backdrop-blur-sm border-0 shadow-sm"
>
<Undo2 className="w-4 h-4 mr-1.5" />
Retour
</Button>
)}
{activeSession.rounds.length > 0 &&
!isSingleRound &&
!gameConfig.scoringCategories && (
<Button
variant="outline"
size="sm"
onClick={undoLastRound}
className="rounded-full font-bold h-9 bg-background/50 backdrop-blur-sm border-0 shadow-sm text-muted-foreground"
>
<Undo2 className="w-4 h-4 mr-1.5" />
Annuler manche
</Button>
)}
{gameConfig.scoringCategories && (
<span className="text-xs font-black bg-primary/10 text-primary px-3 py-1.5 rounded-full">
Étape {currentCategoryIndex + 1} /{" "}
{gameConfig.scoringCategories.length}
</span>
)}
</div>
</div>
{gameConfig.scoringCategories ? (
<Card className="bg-background/80 backdrop-blur-xl border-0 shadow-xl rounded-[2.5rem] overflow-hidden">
<CardContent className="p-4 space-y-1">
{displayPlayers.map((player, idx) => {
const pDetails = detailedScores[player.id] || {};
const catId =
gameConfig.scoringCategories![currentCategoryIndex].id;
return (
<div
key={player.id}
className={`flex items-center justify-between gap-4 p-3 rounded-2xl transition-colors ${idx % 2 === 0 ? "bg-secondary/30" : "bg-transparent"}`}
>
<label className="font-bold flex-1 truncate text-lg ml-2">
{player.name}
</label>
<div className="w-32">
<Input
type="number"
inputMode="numeric"
pattern="[0-9]*"
placeholder="0"
className="text-center text-xl font-black h-14 bg-background border-none shadow-sm rounded-[1rem] focus-visible:ring-2 focus-visible:ring-primary"
value={pDetails[catId] || ""}
onChange={(e) =>
handleDetailedScoreChange(
player.id,
catId,
e.target.value,
)
}
/>
</div>
</div>
);
})}
</CardContent>
</Card>
) : (
<Card className="bg-background/80 backdrop-blur-xl border-0 shadow-xl rounded-[2.5rem] overflow-hidden">
<CardContent className="p-4 space-y-1">
{displayPlayers.map((player, idx) => (
<div
key={player.id}
className={`flex flex-col pb-3 rounded-2xl p-3 transition-colors ${idx % 2 === 0 ? "bg-secondary/30" : "bg-transparent"}`}
>
<div className="flex items-center justify-between gap-4">
<label className="font-bold flex-1 truncate text-lg ml-2">
{player.name}
</label>
<div className="w-32">
<Input
type="number"
inputMode="numeric"
pattern="[0-9]*"
placeholder="0"
className="text-center text-xl font-black h-14 bg-background border-none shadow-sm rounded-[1rem] focus-visible:ring-2 focus-visible:ring-primary"
value={currentScores[player.id] || ""}
onChange={(e) =>
handleScoreChange(player.id, e.target.value)
}
/>
</div>
</div>
{gameConfig.id === "cabo" && (
<div className="flex justify-end mt-3 gap-2 flex-wrap pr-1">
{/* We check activeSession.options, but also fallback to true if undefined as it's the default */}
{activeSession.options?.kamikaze !== false && (
<Button
variant={
kamikazePlayer === player.id
? "default"
: "outline"
}
size="sm"
className={`h-9 px-4 rounded-full text-xs font-bold transition-all border-0 shadow-sm ${kamikazePlayer === player.id ? "bg-amber-500 hover:bg-amber-600 text-white" : "bg-background text-muted-foreground hover:bg-amber-500/10"}`}
onClick={() =>
setKamikazePlayer(
kamikazePlayer === player.id ? null : player.id,
)
}
>
Kamikaze
</Button>
)}
<Button
variant={
caboCalls[player.id] === "success"
? "default"
: "outline"
}
size="sm"
className={`h-9 px-4 rounded-full text-xs font-bold transition-all border-0 shadow-sm ${caboCalls[player.id] === "success" ? "bg-green-500 hover:bg-green-600 text-white" : "bg-background text-muted-foreground hover:bg-green-500/10"}`}
onClick={() =>
setCaboCalls((prev) => ({
...prev,
[player.id]:
prev[player.id] === "success"
? null
: "success",
}))
}
>
Cabo +
</Button>
<Button
variant={
caboCalls[player.id] === "fail"
? "destructive"
: "outline"
}
size="sm"
className={`h-9 px-4 rounded-full text-xs font-bold transition-all border-0 shadow-sm ${caboCalls[player.id] === "fail" ? "bg-red-500 hover:bg-red-600 text-white" : "bg-background text-muted-foreground hover:bg-red-500/10"}`}
onClick={() =>
setCaboCalls((prev) => ({
...prev,
[player.id]:
prev[player.id] === "fail" ? null : "fail",
}))
}
>
Cabo -
</Button>
</div>
)}
</div>
))}
</CardContent>
</Card>
)}
</section>
{/* History Preview */}
{activeSession.rounds.length > 0 && (
<section className="space-y-4 pt-4">
<h3 className="text-lg font-black tracking-tight px-2 text-foreground/80">
Historique récent
</h3>
<div className="space-y-3">
{activeSession.rounds
.slice()
.reverse()
.slice(0, 3)
.map((round, index) => (
<motion.div
key={round.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="flex items-center justify-between p-4 rounded-[1.5rem] bg-background/50 backdrop-blur-md shadow-sm border border-black/5 dark:border-white/5"
>
<div className="bg-foreground/10 text-foreground font-black px-3 py-1 rounded-lg text-sm mr-4">
#{round.roundNumber}
</div>
<div className="flex-1 flex justify-around items-center">
{round.scores.map((s) => (
<div
key={s.playerId}
className="flex flex-col items-center"
>
<span
className={`font-black text-lg ${s.score > 0 ? "text-primary" : s.score < 0 ? "text-destructive" : "text-muted-foreground"}`}
>
{s.score > 0 ? `+${s.score}` : s.score}
</span>
{s.caboCall === "success" && (
<span className="text-[10px] bg-green-500/20 text-green-700 dark:text-green-400 px-1.5 py-0.5 rounded font-bold mt-1 uppercase tracking-wider">
Cabo+
</span>
)}
{s.caboCall === "fail" && (
<span className="text-[10px] bg-red-500/20 text-red-700 dark:text-red-400 px-1.5 py-0.5 rounded font-bold mt-1 uppercase tracking-wider">
Cabo-
</span>
)}
{s.kamikaze && (
<span className="text-[10px] bg-amber-500/20 text-amber-700 dark:text-amber-400 px-1.5 py-0.5 rounded font-bold mt-1 uppercase tracking-wider">
Kamikaze
</span>
)}
</div>
))}
</div>
</motion.div>
))}
</div>
</section>
)}
</main>
<div className="fixed bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-background via-background/90 to-transparent z-30 pb-safe pointer-events-none">
<div className="max-w-md mx-auto pointer-events-auto">
<Button
size="lg"
className="w-full text-xl font-black shadow-xl h-16 rounded-[2rem] transition-transform active:scale-95"
onClick={() => {
if (
gameConfig?.scoringCategories &&
currentCategoryIndex < gameConfig.scoringCategories.length - 1
) {
setCurrentCategoryIndex((prev) => prev + 1);
} else {
handleValidateRound();
}
}}
>
{gameConfig?.scoringCategories &&
currentCategoryIndex < gameConfig.scoringCategories.length - 1 ? (
"Suivant"
) : (
<>
<Check className="w-7 h-7 mr-2" strokeWidth={3} />
{gameConfig?.scoringCategories || isSingleRound
? "Valider le score"
: "Valider la manche"}
</>
)}
</Button>
</div>
</div>
</div>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db";
import {
Users,
Trash2,
Edit2,
Check,
X,
Camera,
Dices,
Loader2,
} from "lucide-react";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { NavigationMenu } from "../components/NavigationMenu";
import { Avatar } from "../components/ui/avatar";
import { resizeImage } from "../utils/image";
import { motion } from "framer-motion";
export default function Players() {
const players = useLiveQuery(() => db.players.orderBy("name").toArray());
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [isGenerating, setIsGenerating] = useState<string | null>(null);
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Êtes-vous sûr de vouloir supprimer ${name} ?`)) {
await db.players.delete(id);
}
};
const startEdit = (id: string, name: string) => {
setEditingId(id);
setEditName(name);
};
const saveEdit = async (id: string) => {
if (editName.trim()) {
const newName = editName.trim();
const playerToEdit = players?.find((p) => p.id === id);
await db.players.update(id, { name: newName });
// Update the player's name in all existing game sessions (history)
const sessions = await db.sessions.toArray();
const updatedSessions = sessions
.map((session) => {
let hasChanges = false;
const newPlayers = session.players.map((p) => {
// Check by ID or fallback to exact name matching if older data
if (p.id === id || (playerToEdit && p.name === playerToEdit.name)) {
hasChanges = true;
return { ...p, name: newName };
}
return p;
});
if (hasChanges) {
return { ...session, players: newPlayers };
}
return null;
})
.filter((s) => s !== null) as typeof sessions;
if (updatedSessions.length > 0) {
await db.sessions.bulkPut(updatedSessions);
}
}
setEditingId(null);
};
const updatePlayerAvatarGlobally = async (
playerId: string,
avatarData: string,
) => {
await db.players.update(playerId, { avatar: avatarData });
// Update historical sessions too if we want the avatar to reflect immediately everywhere
const sessions = await db.sessions.toArray();
const updatedSessions = sessions
.map((session) => {
let hasChanges = false;
const newPlayers = session.players.map((p) => {
if (p.id === playerId) {
hasChanges = true;
return { ...p, avatar: avatarData };
}
return p;
});
if (hasChanges) return { ...session, players: newPlayers };
return null;
})
.filter((s) => s !== null) as typeof sessions;
if (updatedSessions.length > 0) {
await db.sessions.bulkPut(updatedSessions);
}
};
const handleAvatarChange = async (
playerId: string,
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const base64Image = await resizeImage(file);
await updatePlayerAvatarGlobally(playerId, base64Image);
} catch (e) {
alert("Erreur lors de l'enregistrement de l'image.");
}
};
const handleGenerateRandomAvatar = async (playerId: string) => {
setIsGenerating(playerId);
try {
// Seed aléatoire
const seed = Math.random().toString(36).substring(7);
// Utilisation du style "bottts-neutral"
const url = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${seed}&backgroundColor=transparent`;
const response = await fetch(url);
const svgText = await response.text();
// On encode l'SVG proprement pour l'utiliser comme image dans l'app
const encodedSvg = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`;
await updatePlayerAvatarGlobally(playerId, encodedSvg);
} catch (e) {
alert(
"Impossible de générer l'avatar. Vérifiez votre connexion internet.",
);
} finally {
setIsGenerating(null);
}
};
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<NavigationMenu />
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
Joueurs
</h1>
</header>
<div className="space-y-4">
{!players || players.length === 0 ? (
<div className="text-center text-muted-foreground py-12 flex flex-col items-center bg-background/60 backdrop-blur-sm rounded-[2rem]">
<Users className="w-16 h-16 mb-4 opacity-20" />
<p className="font-bold text-lg">Aucun joueur enregistré.</p>
<p className="text-sm mt-1 max-w-[250px]">
Ils s'ajouteront automatiquement lors de votre prochaine partie.
</p>
</div>
) : (
players.map((player, index) => (
<motion.div
key={player.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
delay: index * 0.05,
type: "spring",
stiffness: 100,
}}
>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md overflow-visible relative group">
{/* Decorative blob */}
<div className="absolute -left-2 -top-2 w-8 h-8 rounded-full bg-primary/20 blur-md pointer-events-none group-hover:bg-primary/40 transition-colors" />
<CardContent className="p-4 flex items-center justify-between">
{editingId === player.id ? (
<div className="flex items-center space-x-2 flex-1 mr-2 relative z-10">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-12"
autoFocus
/>
<Button
variant="ghost"
size="icon"
onClick={() => saveEdit(player.id)}
className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
>
<Check className="w-6 h-6" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingId(null)}
className="text-muted-foreground bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<X className="w-6 h-6" />
</Button>
</div>
) : (
<>
<div className="flex flex-col items-center mr-4 shrink-0 gap-1.5">
<div
className="relative group/avatar cursor-pointer"
onClick={() => {
const input = document.getElementById(
`avatar-upload-${player.id}`,
);
input?.click();
}}
>
<Avatar
src={player.avatar}
name={player.name}
size="lg"
className="shadow-md border-4 border-background"
/>
<div className="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover/avatar:opacity-100 transition-opacity">
<Camera className="w-5 h-5 text-white" />
</div>
<input
type="file"
id={`avatar-upload-${player.id}`}
className="hidden"
accept="image/*"
onChange={(e) => handleAvatarChange(player.id, e)}
/>
</div>
<Button
variant="ghost"
size="sm"
disabled={isGenerating === player.id}
className="h-6 px-3 rounded-full text-[10px] font-black uppercase tracking-wider bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
onClick={() => handleGenerateRandomAvatar(player.id)}
>
{isGenerating === player.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Dices className="w-3 h-3 mr-1" />
)}
Aléatoire
</Button>
</div>
<span className="font-black text-xl truncate flex-1 tracking-tight">
{player.name}
</span>
<div className="flex items-center shrink-0 space-x-1 relative z-10">
<Button
variant="ghost"
size="icon"
onClick={() => startEdit(player.id, player.name)}
className="text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5"
>
<Edit2 className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(player.id, player.name)}
className="text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
</>
)}
</CardContent>
</Card>
</motion.div>
))
)}
</div>
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
import {
Moon,
Sun,
Monitor,
Trash2,
Download,
FileSpreadsheet,
} from "lucide-react";
import { useAppStore } from "../stores/appStore";
import { db } from "../database/db";
import { getGameConfig } from "../games";
import { calculatePlayerTotalScore } from "../utils/scoring";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { NavigationMenu } from "../components/NavigationMenu";
export default function Settings() {
const { theme, setTheme } = useAppStore();
const handleExportJson = async () => {
try {
const sessions = await db.sessions.toArray();
const settings = await db.settings.toArray();
const players = await db.players.toArray();
const data = JSON.stringify({ sessions, settings, players });
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `boardscore-backup-${new Date().toISOString().split("T")[0]}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
alert("Erreur lors de l'exportation technique.");
}
};
const handleExportCSV = async () => {
try {
const sessions = await db.sessions.toArray();
// Trier du plus récent au plus ancien
sessions.sort((a, b) => b.dateStart - a.dateStart);
// Ajouter le BOM UTF-8 pour qu'Excel lise correctement les accents
let csv = "\uFEFF";
csv += "Date;Jeu;Durée (min);Manches;Statut;Joueur;Score;Victoire\n";
sessions.forEach((session) => {
const gameConfig = getGameConfig(session.gameId);
const gameName = gameConfig?.name || session.gameId;
const date = new Date(session.dateStart).toLocaleString("fr-FR");
const duration = session.dateEnd
? Math.round((session.dateEnd - session.dateStart) / 60000).toString()
: "";
const status = session.status === "finished" ? "Terminée" : "En cours";
const rounds = session.rounds.length;
session.players.forEach((p) => {
const score = calculatePlayerTotalScore(
p.id,
session.rounds,
gameConfig,
true,
);
const isWinner = session.winnerIds?.includes(p.id) ? "Oui" : "Non";
// Echapper les guillemets pour le CSV
const safeGameName = `"${gameName.replace(/"/g, '""')}"`;
const safePlayerName = `"${p.name.replace(/"/g, '""')}"`;
csv += `${date};${safeGameName};${duration};${rounds};${status};${safePlayerName};${score};${isWinner}\n`;
});
});
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `boardscore-historique-${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
alert("Erreur lors de l'exportation CSV.");
}
};
const handleReset = async () => {
if (
window.confirm(
"Êtes-vous sûr de vouloir supprimer TOUTES vos parties ? Cette action est irréversible.",
)
) {
await db.sessions.clear();
alert("Données réinitialisées.");
}
};
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<NavigationMenu />
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
Paramètres
</h1>
</header>
<section className="space-y-4">
<h2 className="text-xl font-bold px-2 tracking-tight">Apparence</h2>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
<CardContent className="p-3 flex items-center justify-between gap-3">
<Button
variant={theme === "light" ? "default" : "ghost"}
className={`flex-1 flex flex-col items-center h-auto py-5 rounded-[1.5rem] transition-all ${theme === "light" ? "bg-amber-400 text-amber-950 hover:bg-amber-500 hover:scale-105" : "bg-black/5 dark:bg-white/5 hover:bg-black/10"}`}
onClick={() => setTheme("light")}
>
<Sun
className={`w-8 h-8 mb-3 ${theme === "light" ? "animate-[spin_10s_linear_infinite]" : ""}`}
/>
<span className="font-bold">Clair</span>
</Button>
<Button
variant={theme === "dark" ? "default" : "ghost"}
className={`flex-1 flex flex-col items-center h-auto py-5 rounded-[1.5rem] transition-all ${theme === "dark" ? "bg-indigo-600 text-white hover:bg-indigo-700 hover:scale-105" : "bg-black/5 dark:bg-white/5 hover:bg-black/10"}`}
onClick={() => setTheme("dark")}
>
<Moon
className={`w-8 h-8 mb-3 ${theme === "dark" ? "animate-pulse" : ""}`}
/>
<span className="font-bold">Sombre</span>
</Button>
<Button
variant={theme === "system" ? "default" : "ghost"}
className={`flex-1 flex flex-col items-center h-auto py-5 rounded-[1.5rem] transition-all ${theme === "system" ? "bg-slate-700 text-white hover:bg-slate-800 hover:scale-105" : "bg-black/5 dark:bg-white/5 hover:bg-black/10"}`}
onClick={() => setTheme("system")}
>
<Monitor className="w-8 h-8 mb-3" />
<span className="font-bold">Auto</span>
</Button>
</CardContent>
</Card>
</section>
<section className="space-y-4">
<h2 className="text-xl font-bold px-2 tracking-tight">Données</h2>
<div className="space-y-3">
<Button
variant="outline"
className="w-full justify-start h-auto py-4 px-6 border-0 shadow-md bg-background/90 backdrop-blur-md rounded-[1.5rem] hover:scale-[1.02] transition-transform"
onClick={handleExportCSV}
>
<div className="flex flex-col items-start text-left w-full">
<span className="flex items-center font-black text-lg">
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center mr-4">
<FileSpreadsheet className="w-5 h-5 text-emerald-600" />
</div>
Exporter l'historique (CSV)
</span>
<span className="text-sm text-muted-foreground ml-14">
Format tableur (Excel, Sheets)
</span>
</div>
</Button>
<Button
variant="outline"
className="w-full justify-start h-auto py-4 px-6 border-0 shadow-md bg-background/90 backdrop-blur-md rounded-[1.5rem] hover:scale-[1.02] transition-transform"
onClick={handleExportJson}
>
<div className="flex flex-col items-start text-left w-full">
<span className="flex items-center font-black text-lg">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center mr-4">
<Download className="w-5 h-5 text-blue-600" />
</div>
Sauvegarde complète (.json)
</span>
<span className="text-sm text-muted-foreground ml-14">
Copie brute de l'application
</span>
</div>
</Button>
<Button
variant="outline"
className="w-full justify-start h-auto py-4 px-6 border-0 shadow-md bg-destructive/10 backdrop-blur-md rounded-[1.5rem] hover:scale-[1.02] transition-transform text-destructive hover:bg-destructive/20 mt-4"
onClick={handleReset}
>
<div className="flex items-center font-black text-lg w-full">
<div className="w-10 h-10 rounded-full bg-destructive/20 flex items-center justify-center mr-4">
<Trash2 className="w-5 h-5 text-destructive" />
</div>
Réinitialiser tout
</div>
</Button>
</div>
</section>
<div className="pt-12 pb-6 text-center text-sm font-bold text-muted-foreground/60">
Board Score v1.0.0
</div>
</div>
);
}
+401
View File
@@ -0,0 +1,401 @@
import { useState, useMemo } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../../database/db";
import { games } from "../../games";
import { Card, CardContent } from "../../components/ui/card";
import { Badge } from "../../components/ui/badge";
import { NavigationMenu } from "../../components/NavigationMenu";
import { Trophy, Clock, Gamepad2, Medal, Filter } from "lucide-react";
import { Avatar } from "../../components/ui/avatar";
import { motion } from "framer-motion";
export default function Statistics() {
const allSessions = useLiveQuery(() => db.sessions.toArray()) || [];
const players = useLiveQuery(() => db.players.toArray()) || [];
const [timeFilter, setTimeFilter] = useState<"all" | "7d" | "30d" | "year">(
"all",
);
const [selectedPlayerIds, setSelectedPlayerIds] = useState<string[]>([]);
const [selectedGameId, setSelectedGameId] = useState<string>("all");
const togglePlayerFilter = (playerId: string) => {
setSelectedPlayerIds((prev) =>
prev.includes(playerId)
? prev.filter((id) => id !== playerId)
: [...prev, playerId],
);
};
// 1. Filter sessions based on criteria
const sessions = useMemo(() => {
let filtered = allSessions;
// Filter by Time
const now = Date.now();
if (timeFilter === "7d") {
filtered = filtered.filter((s) => s.dateStart > now - 7 * 86400000);
} else if (timeFilter === "30d") {
filtered = filtered.filter((s) => s.dateStart > now - 30 * 86400000);
} else if (timeFilter === "year") {
const startOfYear = new Date(new Date().getFullYear(), 0, 1).getTime();
filtered = filtered.filter((s) => s.dateStart > startOfYear);
}
// Filter by Players (Must include ALL selected players to allow "Head to Head" stats)
if (selectedPlayerIds.length > 0) {
filtered = filtered.filter((s) =>
selectedPlayerIds.every((selectedId) =>
s.players.some((p) => p.id === selectedId),
),
);
}
// Filter by Game
if (selectedGameId !== "all") {
filtered = filtered.filter((s) => s.gameId === selectedGameId);
}
return filtered;
}, [allSessions, timeFilter, selectedPlayerIds, selectedGameId]);
// 2. Calculate Global Stats
const totalGames = sessions.length;
const finishedGames = sessions.filter((s) => s.status === "finished");
let totalDurationMinutes = 0;
finishedGames.forEach((s) => {
if (s.dateEnd && s.dateStart) {
totalDurationMinutes += Math.round((s.dateEnd - s.dateStart) / 60000);
}
});
const averageDuration =
finishedGames.length > 0
? Math.round(totalDurationMinutes / finishedGames.length)
: 0;
// 2. Favorite Game
const gameCounts: Record<string, number> = {};
sessions.forEach((s) => {
gameCounts[s.gameId] = (gameCounts[s.gameId] || 0) + 1;
});
let favoriteGameId = "";
let maxGameCount = 0;
for (const [id, count] of Object.entries(gameCounts)) {
if (count > maxGameCount) {
maxGameCount = count;
favoriteGameId = id;
}
}
const favoriteGame = games.find((g) => g.id === favoriteGameId);
// 3. Player Stats (Winrate & Games Played & Cabo calls & Kamikaze)
const playerStats: Record<
string,
{
id: string;
name: string;
played: number;
won: number;
avatar?: string;
caboSuccess: number;
caboFails: number;
kamikaze: number;
}
> = {};
// Initialize with known players
players.forEach((p) => {
playerStats[p.id] = {
id: p.id,
name: p.name,
played: 0,
won: 0,
avatar: p.avatar,
caboSuccess: 0,
caboFails: 0,
kamikaze: 0,
};
});
finishedGames.forEach((session) => {
session.players.forEach((p) => {
if (!playerStats[p.id]) {
playerStats[p.id] = {
id: p.id,
name: p.name,
played: 0,
won: 0,
avatar: p.avatar,
caboSuccess: 0,
caboFails: 0,
kamikaze: 0,
};
}
playerStats[p.id].played += 1;
});
session.winnerIds?.forEach((winnerId) => {
if (playerStats[winnerId]) {
playerStats[winnerId].won += 1;
}
});
// Count Cabo calls & Kamikaze
if (session.gameId === "cabo") {
session.rounds.forEach((round) => {
round.scores.forEach((score) => {
if (score.caboCall === "success" && playerStats[score.playerId]) {
playerStats[score.playerId].caboSuccess += 1;
}
if (score.caboCall === "fail" && playerStats[score.playerId]) {
playerStats[score.playerId].caboFails += 1;
}
if (score.kamikaze && playerStats[score.playerId]) {
playerStats[score.playerId].kamikaze += 1;
}
});
});
}
});
const leaderboard = Object.values(playerStats)
.filter((stat) => stat.played > 0)
.map((stat) => ({
...stat,
winrate: stat.played > 0 ? Math.round((stat.won / stat.played) * 100) : 0,
}))
.sort((a, b) => b.winrate - a.winrate || b.played - a.played); // Sort by winrate, then games played
// 4. Hide unselected players from leaderboard if any are selected
const displayLeaderboard =
selectedPlayerIds.length > 0
? leaderboard.filter((stat) => selectedPlayerIds.includes(stat.id))
: leaderboard;
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<NavigationMenu />
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
Statistiques
</h1>
</header>
{/* Filters */}
<section className="space-y-3 bg-secondary/30 p-3 rounded-2xl border border-border/50">
<div className="flex items-center space-x-2 text-sm font-medium text-muted-foreground">
<Filter className="w-4 h-4" />
<span>Filtres</span>
</div>
<div className="flex gap-2">
<select
className="flex-1 p-2 border rounded-xl bg-background text-sm truncate"
value={timeFilter}
onChange={(e) => setTimeFilter(e.target.value as any)}
>
<option value="all">Depuis toujours</option>
<option value="7d">7 derniers jours</option>
<option value="30d">30 derniers jours</option>
<option value="year">Cette année</option>
</select>
<select
className="flex-1 p-2 border rounded-xl bg-background text-sm truncate"
value={selectedGameId}
onChange={(e) => setSelectedGameId(e.target.value)}
>
<option value="all">Tous les jeux</option>
{games.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</div>
{players.length > 0 && (
<div className="flex overflow-x-auto pb-1 -mx-3 px-3 gap-2 hide-scrollbar">
{players.map((player) => (
<Badge
key={player.id}
variant={
selectedPlayerIds.includes(player.id) ? "default" : "outline"
}
className="shrink-0 px-3 py-1.5 cursor-pointer text-sm font-normal active:scale-95 transition-all"
onClick={() => togglePlayerFilter(player.id)}
>
{player.name}
</Badge>
))}
</div>
)}
</section>
{/* Hero Stats */}
<div className="grid grid-cols-2 gap-4">
<Card className="bg-primary text-primary-foreground border-none">
<CardContent className="p-4 flex flex-col items-center justify-center text-center space-y-2">
<Gamepad2 className="w-8 h-8 opacity-80" />
<div className="text-3xl font-bold">{totalGames}</div>
<div className="text-sm opacity-90">Parties jouées</div>
</CardContent>
</Card>
<Card className="bg-secondary border-none">
<CardContent className="p-4 flex flex-col items-center justify-center text-center space-y-2">
<Clock className="w-8 h-8 opacity-60 text-secondary-foreground" />
<div className="text-3xl font-bold text-secondary-foreground">
{averageDuration}
<span className="text-lg">m</span>
</div>
<div className="text-sm text-secondary-foreground/80">
Durée moyenne
</div>
</CardContent>
</Card>
</div>
{favoriteGame && selectedGameId === "all" && (
<section className="space-y-3">
<h2 className="text-lg font-semibold px-1">Jeu préféré</h2>
<Card className="border-primary/20">
<CardContent className="p-4 flex items-center space-x-4">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center shrink-0"
style={{
backgroundColor: `${favoriteGame.colors.primary}20`,
color: favoriteGame.colors.primary,
}}
>
<Trophy className="w-8 h-8" />
</div>
<div>
<h3 className="font-bold text-xl">{favoriteGame.name}</h3>
<p className="text-muted-foreground">
{maxGameCount} parties terminées
</p>
</div>
</CardContent>
</Card>
</section>
)}
{selectedGameId === "cabo" && (
<section className="space-y-3">
<h2 className="text-lg font-semibold px-1">
Statistiques spéciales (Cabo)
</h2>
<div className="grid gap-3">
{displayLeaderboard
.filter(
(p) => p.caboSuccess > 0 || p.caboFails > 0 || p.kamikaze > 0,
)
.sort((a, b) => b.caboSuccess - a.caboSuccess)
.map((player) => (
<Card
key={`cabo-${player.id}`}
className="overflow-hidden bg-gradient-to-r from-green-500/10 via-background to-transparent border-green-500/20"
>
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="font-semibold">{player.name}</span>
</div>
<div className="flex items-center gap-4 text-sm font-medium">
<div className="flex flex-col items-center">
<span className="text-amber-600 font-bold text-lg">
{player.kamikaze}
</span>
<span className="text-xs text-muted-foreground">
Kamikazes
</span>
</div>
<div className="w-px h-8 bg-border"></div>
<div className="flex flex-col items-center">
<span className="text-green-600 font-bold text-lg">
{player.caboSuccess}
</span>
<span className="text-xs text-muted-foreground">
Cabos+
</span>
</div>
<div className="w-px h-8 bg-border"></div>
<div className="flex flex-col items-center">
<span className="text-destructive font-bold text-lg">
{player.caboFails}
</span>
<span className="text-xs text-muted-foreground">
Cabos-
</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</section>
)}
{/* Leaderboard */}
<section className="space-y-3">
<h2 className="text-lg font-semibold px-1 flex items-center">
<Medal className="w-5 h-5 mr-2 text-primary" />
Classement des joueurs (Taux de victoire)
</h2>
<div className="space-y-3">
{displayLeaderboard.length === 0 ? (
<div className="text-center text-muted-foreground py-8 bg-secondary/30 rounded-2xl">
{allSessions.length === 0
? "Terminez des parties pour voir le classement."
: "Aucune partie ne correspond à ces filtres."}
</div>
) : (
displayLeaderboard.map((player, index) => (
<motion.div
key={player.name}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card className="overflow-hidden">
<div className="flex items-stretch">
<div
className={`w-12 flex items-center justify-center font-bold ${index === 0 ? "bg-amber-400 text-amber-950" : index === 1 ? "bg-slate-300 text-slate-800" : index === 2 ? "bg-amber-700 text-amber-100" : "bg-secondary text-muted-foreground"}`}
>
#{index + 1}
</div>
<CardContent className="p-4 flex-1 flex items-center justify-between">
<div className="flex items-center space-x-3">
<Avatar
src={player.avatar}
name={player.name}
size="md"
/>
<div>
<div className="font-bold">{player.name}</div>
<div className="text-xs text-muted-foreground">
{player.won} victoire{player.won > 1 ? "s" : ""} sur{" "}
{player.played} partie{player.played > 1 ? "s" : ""}
</div>
</div>
</div>
<div className="text-right">
<div className="text-xl font-bold text-primary">
{player.winrate}%
</div>
</div>
</CardContent>
</div>
</Card>
</motion.div>
))
)}
</div>
</section>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { create } from 'zustand';
import { db } from '../database/db';
interface AppState {
theme: 'light' | 'dark' | 'system';
language: string;
loadSettings: () => Promise<void>;
setTheme: (theme: 'light' | 'dark' | 'system') => Promise<void>;
}
export const useAppStore = create<AppState>((set) => ({
theme: 'system',
language: 'fr',
loadSettings: async () => {
const settings = await db.settings.get(1);
if (settings) {
set({ theme: settings.theme, language: settings.language });
applyTheme(settings.theme);
}
},
setTheme: async (theme) => {
set({ theme });
applyTheme(theme);
const settings = await db.settings.get(1);
if (settings) {
await db.settings.put({ ...settings, theme });
} else {
await db.settings.add({ id: 1, theme, language: 'fr' });
}
}
}));
function applyTheme(theme: 'light' | 'dark' | 'system') {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}
+150
View File
@@ -0,0 +1,150 @@
import { create } from "zustand";
import { GameSession, Player, Round, RoundScore } from "../types";
import { db } from "../database/db";
import { generateId } from "../utils/id";
interface GameState {
activeSession: GameSession | null;
setActiveSession: (session: GameSession | null) => void;
loadSession: (sessionId: string) => Promise<void>;
saveSession: () => Promise<void>;
// Game Actions
startNewGame: (
gameId: string,
players: Player[],
options: Record<string, any>,
) => Promise<string>;
addRound: (scores: RoundScore[]) => Promise<void>;
updateRound: (roundId: string, scores: RoundScore[]) => Promise<void>;
removeRound: (roundId: string) => Promise<void>;
undoLastRound: () => Promise<void>;
finishGame: (winnerIds: string[]) => Promise<void>;
}
export const useGameStore = create<GameState>((set, get) => ({
activeSession: null,
setActiveSession: (session) => set({ activeSession: session }),
loadSession: async (sessionId: string) => {
const session = await db.sessions.get(sessionId);
if (session) {
set({ activeSession: session });
}
},
saveSession: async () => {
const { activeSession } = get();
if (activeSession) {
await db.sessions.put(activeSession);
}
},
startNewGame: async (gameId, players, options) => {
const newSession: GameSession = {
id: generateId(),
gameId,
dateStart: Date.now(),
players,
rounds: [],
status: "playing",
options,
};
await db.sessions.add(newSession);
set({ activeSession: newSession });
return newSession.id;
},
addRound: async (scores) => {
const { activeSession, saveSession } = get();
if (!activeSession) return;
const newRound: Round = {
id: generateId(),
roundNumber: activeSession.rounds.length + 1,
scores,
};
set({
activeSession: {
...activeSession,
rounds: [...activeSession.rounds, newRound],
},
});
await saveSession();
},
updateRound: async (roundId, scores) => {
const { activeSession, saveSession } = get();
if (!activeSession) return;
const updatedRounds = activeSession.rounds.map((r) =>
r.id === roundId ? { ...r, scores } : r,
);
set({
activeSession: {
...activeSession,
rounds: updatedRounds,
},
});
await saveSession();
},
removeRound: async (roundId) => {
const { activeSession, saveSession } = get();
if (!activeSession) return;
const updatedRounds = activeSession.rounds
.filter((r) => r.id !== roundId)
.map((r, index) => ({
...r,
roundNumber: index + 1,
}));
set({
activeSession: {
...activeSession,
rounds: updatedRounds,
},
});
await saveSession();
},
undoLastRound: async () => {
const { activeSession, saveSession } = get();
if (!activeSession || activeSession.rounds.length === 0) return;
const updatedRounds = activeSession.rounds.slice(0, -1);
set({
activeSession: {
...activeSession,
rounds: updatedRounds,
},
});
await saveSession();
},
finishGame: async (winnerIds) => {
const { activeSession, saveSession } = get();
if (!activeSession) return;
set({
activeSession: {
...activeSession,
status: "finished",
dateEnd: Date.now(),
winnerIds,
},
});
await saveSession();
},
}));
+93
View File
@@ -0,0 +1,93 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased selection:bg-primary/20 selection:text-primary;
touch-action: manipulation;
}
}
/* Utilities for PWA and Mobile */
.safe-area-inset-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-area-inset-top {
padding-top: env(safe-area-inset-top);
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
+89
View File
@@ -0,0 +1,89 @@
export type ScoreType =
"positive" | "negative" | "target" | "fixed_rounds" | "sudden_death";
export interface ScoringCategory {
id: string;
label: string;
}
export interface GameConfig {
id: string;
name: string;
minPlayers: number;
maxPlayers: number;
scoreType: ScoreType;
targetScore?: number; // Used if scoreType is 'target'
targetScoreCondition?: "reaches" | "exceeds";
getTargetScore?: (options: Record<string, any>) => number;
calculatePlayerTotal?: (
rounds: Round[],
playerId: string,
isEndGameCalculation?: boolean,
) => number;
fixedRounds?: number; // Used if scoreType is 'fixed_rounds'
scoringCategories?: ScoringCategory[];
description?: string;
icon?: string;
imagePath?: string; // Optional custom logo image (SVG, PNG, WebP...)
bannerPath?: string; // Optional custom banner/background image
rulesUrl?: string; // URL to a PDF or Markdown file (e.g. "/games-assets/azul-rules.pdf")
colors: {
primary: string;
secondary?: string;
};
options?: GameOption[];
}
export interface GameOption {
id: string;
label: string;
type: "boolean" | "number" | "select";
defaultValue: any;
options?: { label: string; value: string | number }[]; // For select type
}
export interface Player {
id: string;
name: string;
color?: string;
avatar?: string;
}
export interface RoundScore {
playerId: string;
score: number;
details?: Record<string, any>;
caboCall?: "success" | "fail"; // For Cabo game specifically
kamikaze?: boolean; // For Cabo Kamikaze rule
}
export interface Round {
id: string;
roundNumber: number;
scores: RoundScore[];
}
export interface GameSession {
id: string;
gameId: string;
dateStart: number;
dateEnd?: number;
players: Player[];
rounds: Round[];
status: "playing" | "finished";
options: Record<string, any>;
winnerIds?: string[];
}
export interface AppSettings {
id: number;
theme: "light" | "dark" | "system";
language: string;
}
export interface SavedPlayer {
id: string;
name: string;
createdAt: number;
avatar?: string;
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+14
View File
@@ -0,0 +1,14 @@
export function generateId(): string {
// If we are in a secure context (https or localhost), use the native crypto API
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for non-secure contexts (like testing on local network via http://192.168.x.x)
// Generates a UUID v4 compliant string
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
+45
View File
@@ -0,0 +1,45 @@
export const resizeImage = (file: File, maxWidth = 200): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
const scale = Math.min(maxWidth / img.width, maxWidth / img.height);
// Only resize if the image is larger than maxWidth
const finalWidth = scale < 1 ? img.width * scale : img.width;
const finalHeight = scale < 1 ? img.height * scale : img.height;
// Make it a square crop
const size = Math.min(finalWidth, finalHeight);
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
// Calculate crop position to center the image
const offsetX = (finalWidth - size) / 2;
const offsetY = (finalHeight - size) / 2;
// Draw image onto canvas (resizing and cropping it)
ctx.drawImage(
img,
offsetX / scale, offsetY / scale, size / scale, size / scale, // Source coordinates
0, 0, size, size // Destination coordinates
);
// Compress to JPEG with 80% quality
resolve(canvas.toDataURL('image/jpeg', 0.8));
};
img.onerror = (error) => reject(error);
};
reader.onerror = (error) => reject(error);
});
};
+20
View File
@@ -0,0 +1,20 @@
import { Round, GameConfig } from "../types";
export const calculatePlayerTotalScore = (
playerId: string,
rounds: Round[],
gameConfig?: GameConfig,
isEndGameCalculation = false,
): number => {
if (gameConfig?.calculatePlayerTotal) {
return gameConfig.calculatePlayerTotal(
rounds,
playerId,
isEndGameCalculation,
);
}
return rounds.reduce((total, round) => {
const pScore = round.scores.find((s) => s.playerId === playerId);
return total + (pScore?.score || 0);
}, 0);
};
+32
View File
@@ -0,0 +1,32 @@
export function hexToHslString(hex: string): string {
// Remove '#' if present
hex = hex.replace(/^#/, '');
// Handle short hex
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />