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
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="fr" class="light">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#ffffff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>Board Score</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7304
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "board-score",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@radix-ui/react-slot": "^1.0.2",
"@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"dexie": "^3.2.4",
"dexie-react-hooks": "^1.1.7",
"framer-motion": "^10.16.4",
"lucide-react": "^0.292.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-router-dom": "^6.20.0",
"tailwind-merge": "^2.0.0",
"zod": "^3.22.4",
"zustand": "^4.4.6"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.0.0",
"vite-plugin-pwa": "^0.17.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
+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" />
+60
View File
@@ -0,0 +1,60 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts", "tailwind.config.js", "postcss.config.js"]
}
+45
View File
@@ -0,0 +1,45 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'path';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
manifest: {
name: 'Board Score',
short_name: 'BoardScore',
description: 'Application Web de gestion des scores pour jeux de société',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
}
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});