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