Files
tribunal-app/src/app/char/roulette-section.tsx
T
Valentin ROBIN 8c952b017b
Build and deploy / deploy (push) Successful in 35s
Fusionne La Roulette dans Le Jeu de l'Agora, corrige la Roulette et renomme Admin
Déplace La Roulette de sa page dédiée vers une section de /char (tout
l'outillage de la soirée du Tribunal au même endroit) et retire la
route /roulette. Corrige un bug de superposition où le pointeur doré
de la roulette passait devant le menu déroulant du header (isolation
de contexte d'empilement manquante). Renomme "Le Conseil des
Archontes" en "Admin" avec une police monospace, rupture volontaire
avec le thème grec pour cette page technique.
2026-08-11 18:45:55 +02:00

165 lines
6.0 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { Avatar } from "@/components/avatar";
import { LaurelWreath } from "@/components/laurel-wreath";
import type { ProfileRow } from "./page";
const ITEM_WIDTH = 96;
const REPEATS = 6;
const SPIN_MS = 3200;
function useReducedMotion() {
const [reduced, setReduced] = useState(
() => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = () => setReduced(query.matches);
query.addEventListener("change", handler);
return () => query.removeEventListener("change", handler);
}, []);
return reduced;
}
export function RouletteSection({ profiles }: { profiles: ProfileRow[] }) {
const reducedMotion = useReducedMotion();
const [citizensOnly, setCitizensOnly] = useState(false);
const [spinning, setSpinning] = useState(false);
const [strip, setStrip] = useState<ProfileRow[]>([]);
const [offset, setOffset] = useState(0);
const [transitionOn, setTransitionOn] = useState(false);
const [winner, setWinner] = useState<ProfileRow | null>(null);
const [lastWinner, setLastWinner] = useState<ProfileRow | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const pool = citizensOnly ? profiles.filter((m) => m.role !== "judge") : profiles;
function handleSpin() {
if (spinning || pool.length === 0) return;
const chosen = pool[Math.floor(Math.random() * pool.length)];
if (reducedMotion) {
setWinner(chosen);
setLastWinner(chosen);
return;
}
setWinner(null);
setSpinning(true);
const built: ProfileRow[] = [];
for (let i = 0; i < REPEATS; i++) built.push(...pool);
built.push(chosen);
setStrip(built);
setTransitionOn(false);
setOffset(0);
requestAnimationFrame(() => {
const containerWidth = containerRef.current?.offsetWidth ?? 320;
const finalIndex = built.length - 1;
const target = finalIndex * ITEM_WIDTH + ITEM_WIDTH / 2 - containerWidth / 2;
requestAnimationFrame(() => {
setTransitionOn(true);
setOffset(-target);
});
});
}
function handleTransitionEnd() {
if (!spinning) return;
setSpinning(false);
const chosen = strip[strip.length - 1];
setWinner(chosen);
setLastWinner(chosen);
}
return (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-lg sm:p-6">
<p className="text-center font-heading text-sm tracking-wide text-text-mut uppercase">La Roulette</p>
<p className="mb-4 text-center font-serif text-xs text-text-mut/70 italic">Que le sort désigne l&apos;élu.</p>
{profiles.length === 0 ? (
<p className="text-center text-sm text-text-mut">Personne n&apos;est encore inscrit.</p>
) : (
<>
<label className="mb-4 flex items-center justify-center gap-2 text-sm text-text-marble">
<input
type="checkbox"
checked={citizensOnly}
onChange={(event) => setCitizensOnly(event.target.checked)}
disabled={spinning}
className="h-4 w-4 accent-gold"
/>
Tirer uniquement parmi les Citoyens (exclure les Archontes)
</label>
<div
ref={containerRef}
className="relative isolate mx-auto mb-6 h-32 max-w-full overflow-hidden rounded-lg bg-ink-2/5"
>
<div
className="absolute top-0 bottom-0 left-1/2 z-10 w-0.5 -translate-x-1/2 bg-gold-bright"
aria-hidden
/>
{strip.length > 0 && (
<div
className="flex h-full items-center"
style={{
transform: `translateX(${offset}px)`,
transition: transitionOn ? `transform ${SPIN_MS}ms cubic-bezier(0.11, 0.7, 0.14, 1)` : "none",
}}
onTransitionEnd={handleTransitionEnd}
>
{strip.map((member, index) => (
<div
key={`${member.id}-${index}`}
className="flex shrink-0 flex-col items-center gap-1"
style={{ width: ITEM_WIDTH }}
>
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="md" />
<span className="max-w-[5rem] truncate text-xs text-text-marble">{member.pseudo}</span>
</div>
))}
</div>
)}
</div>
<div className="flex justify-center">
<button
type="button"
onClick={handleSpin}
disabled={spinning || pool.length === 0}
className="rounded-md bg-ink-2 px-6 py-2.5 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
{spinning ? "…" : winner ? "Rejouer" : "Tirer au sort"}
</button>
</div>
<div aria-live="polite" className="mt-8 flex flex-col items-center gap-2">
{winner && (
<div className="animate-reveal flex flex-col items-center gap-2 rounded-2xl px-8 py-6">
<LaurelWreath className="h-10 w-10" color="#C9A227" />
<div className="ring-4 ring-gold rounded-full p-1">
<Avatar pseudo={winner.pseudo} avatarUrl={winner.avatar_url} size="lg" />
</div>
<span className="font-heading text-2xl tracking-wide text-text-marble uppercase">
{winner.pseudo}
</span>
<span className="font-serif text-sm text-text-mut italic">a é désigné(e) par le sort</span>
</div>
)}
</div>
{lastWinner && !winner && (
<p className="mt-4 text-center text-xs text-text-mut">
Dernier élu : <span className="font-medium text-text-marble">{lastWinner.pseudo}</span>
</p>
)}
</>
)}
</div>
);
}