Ajoute le tirage au sort d'un jeu par vote
Build and Publish Docker Image / build-and-push-image (push) Successful in 58s

Nouvelle page /tirage, accessible depuis "Mes jeux" :
- on saisit le nombre de joueurs (jeux filtrés selon ce nombre)
- chaque joueur vote pour un jeu, à tour de rôle
- plus de 50% des voix : le jeu est élu à la majorité
- sinon : tirage au sort parmi les jeux votés
- révélation animée (défilement qui ralentit + confettis), détail des
  votes, puis "Lancer" enchaîne sur la création de partie

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zed
2026-08-04 18:25:39 +02:00
parent 16ad86c019
commit 0a963d991b
3 changed files with 416 additions and 1 deletions
+2
View File
@@ -12,6 +12,7 @@ import Locations from "./pages/Locations";
import Profiles from "./pages/Profiles"; import Profiles from "./pages/Profiles";
import Friends from "./pages/Friends"; import Friends from "./pages/Friends";
import Games from "./pages/Games"; import Games from "./pages/Games";
import RandomPick from "./pages/RandomPick";
import ImportedGames from "./pages/ImportedGames"; import ImportedGames from "./pages/ImportedGames";
import Auth from "./pages/Auth"; import Auth from "./pages/Auth";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
@@ -44,6 +45,7 @@ function App() {
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/history" element={<History />} /> <Route path="/history" element={<History />} />
<Route path="/jeux" element={<Games />} /> <Route path="/jeux" element={<Games />} />
<Route path="/tirage" element={<RandomPick />} />
<Route path="/players" element={<Players />} /> <Route path="/players" element={<Players />} />
<Route path="/locations" element={<Locations />} /> <Route path="/locations" element={<Locations />} />
<Route path="/profiles" element={<Profiles />} /> <Route path="/profiles" element={<Profiles />} />
+21 -1
View File
@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import * as Icons from "lucide-react"; import * as Icons from "lucide-react";
import { Puzzle } from "lucide-react"; import { Puzzle, Dices, ChevronRight } from "lucide-react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useAllGames } from "../games"; import { useAllGames } from "../games";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
@@ -14,6 +14,26 @@ export default function Games() {
<div className="p-5 space-y-5 relative z-10"> <div className="p-5 space-y-5 relative z-10">
<PageHeader title="Mes jeux" /> <PageHeader title="Mes jeux" />
{/* Choisir un jeu par vote + tirage au sort */}
<motion.button
whileTap={{ scale: 0.98 }}
onClick={() => navigate("/tirage")}
className="w-full flex items-center gap-3 p-4 rounded-[1.75rem] bg-primary/10 dark:bg-primary/20 border border-primary/20 shadow-sm text-left"
>
<div className="w-11 h-11 rounded-2xl bg-primary text-primary-foreground grid place-items-center shrink-0 shadow-sm">
<Dices className="w-6 h-6" />
</div>
<div className="min-w-0 flex-1">
<p className="font-black tracking-tight leading-tight">
On joue à quoi ?
</p>
<p className="text-xs text-muted-foreground">
Chacun vote, le sort tranche
</p>
</div>
<ChevronRight className="w-5 h-5 text-primary shrink-0" />
</motion.button>
<div className="grid grid-cols-3 gap-3.5"> <div className="grid grid-cols-3 gap-3.5">
{games.map((game, index) => { {games.map((game, index) => {
const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box; const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box;
+393
View File
@@ -0,0 +1,393 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import * as Icons from "lucide-react";
import { Dices, Minus, Plus, Play, RotateCcw, Users, Crown } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import confetti from "canvas-confetti";
import { useAllGames } from "../games";
import { GameConfig } from "../types";
import { PageHeader } from "../components/PageHeader";
import { Button } from "../components/ui/button";
type Step = "setup" | "vote" | "reveal";
// Small square game tile shared by the vote grid and the reveal animation.
function GameTile({
game,
size = "md",
}: {
game: GameConfig;
size?: "md" | "lg";
}) {
const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box;
const box =
size === "lg" ? "w-40 h-40 rounded-[2rem]" : "w-full aspect-square rounded-[1.5rem]";
return (
<div
className={`relative ${box} shadow-md flex items-center justify-center overflow-hidden ring-1 ring-black/5 dark:ring-white/10`}
style={{
backgroundColor: game.imagePath ? "transparent" : game.colors.primary,
}}
>
{game.imagePath ? (
<img
src={game.imagePath}
alt={game.name}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<Icon
className={`${size === "lg" ? "w-20 h-20" : "w-11 h-11"} text-white drop-shadow-md`}
strokeWidth={1.5}
/>
)}
</div>
);
}
export default function RandomPick() {
const navigate = useNavigate();
const games = useAllGames();
const [step, setStep] = useState<Step>("setup");
const [playerCount, setPlayerCount] = useState(3);
const [votes, setVotes] = useState<string[]>([]);
const [winnerId, setWinnerId] = useState<string | null>(null);
const [byMajority, setByMajority] = useState(false);
const [spinningId, setSpinningId] = useState<string | null>(null);
const timers = useRef<number[]>([]);
useEffect(
() => () => timers.current.forEach((t) => clearTimeout(t)),
[],
);
// Games that can actually be played with this many players (fallback: all).
const eligible = games.filter(
(g) => playerCount >= g.minPlayers && playerCount <= g.maxPlayers,
);
const votableGames = eligible.length > 0 ? eligible : games;
const gameById = (id: string) => games.find((g) => g.id === id);
const winner = winnerId ? gameById(winnerId) : undefined;
const reset = () => {
timers.current.forEach((t) => clearTimeout(t));
timers.current = [];
setVotes([]);
setWinnerId(null);
setSpinningId(null);
setStep("setup");
};
const castVote = (gameId: string) => {
const next = [...votes, gameId];
setVotes(next);
if (next.length >= playerCount) startReveal(next);
};
const startReveal = (finalVotes: string[]) => {
// Tally the votes
const tally = new Map<string, number>();
finalVotes.forEach((id) => tally.set(id, (tally.get(id) ?? 0) + 1));
const candidates = [...tally.keys()];
// Strict majority (> 50%), otherwise a draw among the voted games
const majority = [...tally.entries()].find(
([, n]) => n > finalVotes.length / 2,
);
const picked = majority
? majority[0]
: candidates[Math.floor(Math.random() * candidates.length)];
setStep("reveal");
// Slot-machine animation: cycle through the candidates, slowing down.
const reel = candidates.length > 1 ? candidates : votableGames.map((g) => g.id);
let delay = 70;
let elapsed = 0;
let i = 0;
const tick = () => {
setSpinningId(reel[i % reel.length]);
i++;
elapsed += delay;
delay *= 1.13; // ease-out
if (elapsed < 2200) {
timers.current.push(window.setTimeout(tick, delay));
} else {
timers.current.push(
window.setTimeout(() => {
setSpinningId(picked);
setWinnerId(picked);
setByMajority(Boolean(majority));
confetti({
particleCount: 90,
spread: 75,
origin: { y: 0.35 },
colors: ["#14b8a6", "#22d3ee", "#fde68a", "#a5f3fc"],
});
}, delay),
);
}
};
tick();
};
const tallyEntries = () => {
const tally = new Map<string, number>();
votes.forEach((id) => tally.set(id, (tally.get(id) ?? 0) + 1));
return [...tally.entries()].sort((a, b) => b[1] - a[1]);
};
return (
<div className="p-5 space-y-5 relative z-10">
<PageHeader title="Tirage au sort" />
<AnimatePresence mode="wait">
{/* ---------- 1. Nombre de joueurs ---------- */}
{step === "setup" && (
<motion.div
key="setup"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-5"
>
<div className="rounded-[1.75rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-6 text-center space-y-5">
<div className="w-16 h-16 rounded-full bg-primary/15 text-primary grid place-items-center mx-auto">
<Dices className="w-8 h-8" />
</div>
<div>
<p className="font-black text-xl tracking-tight">
Combien de joueurs ?
</p>
<p className="text-sm text-muted-foreground mt-1">
Chacun votera pour un jeu, puis le sort décidera.
</p>
</div>
<div className="flex items-center justify-center gap-5">
<Button
variant="outline"
size="icon"
className="rounded-full w-12 h-12"
onClick={() => setPlayerCount((n) => Math.max(2, n - 1))}
disabled={playerCount <= 2}
aria-label="Un joueur de moins"
>
<Minus className="w-5 h-5" />
</Button>
<span className="text-5xl font-black tabular-nums w-20">
{playerCount}
</span>
<Button
variant="outline"
size="icon"
className="rounded-full w-12 h-12"
onClick={() => setPlayerCount((n) => Math.min(12, n + 1))}
disabled={playerCount >= 12}
aria-label="Un joueur de plus"
>
<Plus className="w-5 h-5" />
</Button>
</div>
<p className="text-xs font-bold text-muted-foreground">
{votableGames.length} jeu{votableGames.length > 1 ? "x" : ""}{" "}
{eligible.length > 0 ? "compatible" : "disponible"}
{votableGames.length > 1 ? "s" : ""}
</p>
</div>
<Button
className="w-full h-14 rounded-[1.5rem] font-black text-lg shadow-lg"
onClick={() => {
setVotes([]);
setStep("vote");
}}
disabled={votableGames.length === 0}
>
<Play className="w-5 h-5 mr-2" fill="currentColor" />
Commencer les votes
</Button>
</motion.div>
)}
{/* ---------- 2. Vote de chaque joueur ---------- */}
{step === "vote" && (
<motion.div
key="vote"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-4"
>
<div className="rounded-[1.5rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-4 flex items-center gap-3">
<div className="w-11 h-11 rounded-full bg-primary/15 text-primary grid place-items-center font-black shrink-0">
{votes.length + 1}
</div>
<div className="min-w-0 flex-1">
<p className="font-black tracking-tight leading-tight">
Joueur {votes.length + 1}, à toi !
</p>
<p className="text-xs text-muted-foreground">
Choisis ton jeu · {votes.length}/{playerCount} vote
{votes.length > 1 ? "s" : ""}
</p>
</div>
<Users className="w-5 h-5 text-muted-foreground shrink-0" />
</div>
{/* Barre de progression des votes */}
<div className="h-2 rounded-full bg-secondary/70 dark:bg-white/10 overflow-hidden">
<motion.div
className="h-full rounded-full bg-primary"
animate={{ width: `${(votes.length / playerCount) * 100}%` }}
transition={{ type: "spring", stiffness: 120, damping: 18 }}
/>
</div>
<div className="grid grid-cols-3 gap-3.5">
{votableGames.map((game) => (
<motion.button
key={game.id}
whileTap={{ scale: 0.93 }}
onClick={() => castVote(game.id)}
className="flex flex-col items-center gap-2"
>
<GameTile game={game} />
<span className="text-xs font-bold leading-tight truncate max-w-full px-0.5 text-foreground/90">
{game.name}
</span>
</motion.button>
))}
</div>
<Button
variant="ghost"
className="w-full rounded-2xl font-bold text-muted-foreground"
onClick={reset}
>
Annuler
</Button>
</motion.div>
)}
{/* ---------- 3. Révélation ---------- */}
{step === "reveal" && (
<motion.div
key="reveal"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-5"
>
<div className="rounded-[2rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-6 flex flex-col items-center text-center">
{!winner ? (
<>
<p className="text-sm font-black uppercase tracking-widest text-muted-foreground mb-4">
Tirage en cours
</p>
<motion.div
key={spinningId ?? "spin"}
initial={{ scale: 0.86, opacity: 0.5, rotate: -3 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{ duration: 0.12 }}
>
{spinningId && gameById(spinningId) && (
<GameTile game={gameById(spinningId)!} size="lg" />
)}
</motion.div>
<p className="mt-4 font-black text-xl tracking-tight h-7">
{spinningId ? gameById(spinningId)?.name : ""}
</p>
</>
) : (
<>
<motion.div
initial={{ scale: 0.6, y: 20, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 200, damping: 14 }}
className="flex flex-col items-center"
>
<div className="flex items-center gap-1.5 text-primary font-black uppercase tracking-widest text-xs mb-3">
<Crown className="w-4 h-4" />
{byMajority ? "Élu à la majorité" : "Tiré au sort"}
</div>
<GameTile game={winner} size="lg" />
<p className="mt-4 font-black text-3xl tracking-tighter">
{winner.name}
</p>
<p className="text-sm text-muted-foreground mt-1">
{byMajority
? `${tallyEntries()[0]?.[1]} voix sur ${playerCount}`
: `Tirage parmi ${tallyEntries().length} jeux à égalité`}
</p>
</motion.div>
</>
)}
</div>
{/* Détail des votes */}
{winner && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="rounded-[1.75rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-4 space-y-2"
>
<p className="text-xs font-black uppercase tracking-wide text-muted-foreground">
Votes
</p>
{tallyEntries().map(([id, n]) => {
const g = gameById(id);
if (!g) return null;
return (
<div key={id} className="flex items-center gap-2.5">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: g.colors.primary }}
/>
<span
className={`flex-1 truncate ${id === winnerId ? "font-black" : "font-medium text-foreground/80"}`}
>
{g.name}
</span>
<span className="font-black tabular-nums text-muted-foreground">
{n}
</span>
</div>
);
})}
</motion.div>
)}
{winner && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="grid grid-cols-2 gap-3"
>
<Button
variant="outline"
className="h-14 rounded-[1.5rem] font-bold"
onClick={reset}
>
<RotateCcw className="w-5 h-5 mr-2" />
Rejouer
</Button>
<Button
className="h-14 rounded-[1.5rem] font-black"
onClick={() => navigate(`/new/${winner.id}`)}
>
<Play className="w-5 h-5 mr-2" fill="currentColor" />
Lancer
</Button>
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}