Fusionne La Roulette dans Le Jeu de l'Agora, corrige la Roulette et renomme Admin
Build and deploy / deploy (push) Successful in 35s
Build and deploy / deploy (push) Successful in 35s
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.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"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'élu.</p>
|
||||
|
||||
{profiles.length === 0 ? (
|
||||
<p className="text-center text-sm text-text-mut">Personne n'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 été 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user