Ajoute le tirage au sort d'un jeu par vote
Build and Publish Docker Image / build-and-push-image (push) Successful in 58s
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:
@@ -12,6 +12,7 @@ import Locations from "./pages/Locations";
|
||||
import Profiles from "./pages/Profiles";
|
||||
import Friends from "./pages/Friends";
|
||||
import Games from "./pages/Games";
|
||||
import RandomPick from "./pages/RandomPick";
|
||||
import ImportedGames from "./pages/ImportedGames";
|
||||
import Auth from "./pages/Auth";
|
||||
import Settings from "./pages/Settings";
|
||||
@@ -44,6 +45,7 @@ function App() {
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/jeux" element={<Games />} />
|
||||
<Route path="/tirage" element={<RandomPick />} />
|
||||
<Route path="/players" element={<Players />} />
|
||||
<Route path="/locations" element={<Locations />} />
|
||||
<Route path="/profiles" element={<Profiles />} />
|
||||
|
||||
+21
-1
@@ -1,6 +1,6 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import * as Icons from "lucide-react";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import { Puzzle, Dices, ChevronRight } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAllGames } from "../games";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
@@ -14,6 +14,26 @@ export default function Games() {
|
||||
<div className="p-5 space-y-5 relative z-10">
|
||||
<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">
|
||||
{games.map((game, index) => {
|
||||
const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user