Fusionne La Roulette dans Le Jeu de l'Agora, corrige la Roulette et renomme Admin
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:
Valentin ROBIN
2026-08-11 18:45:55 +02:00
parent f30f77a394
commit 8c952b017b
8 changed files with 182 additions and 208 deletions
+8 -2
View File
@@ -1,7 +1,13 @@
import { redirect } from "next/navigation";
import { JetBrains_Mono } from "next/font/google";
import { createClient } from "@/lib/supabase/server";
import { AdminMembersTable } from "./admin-members-table";
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
weight: ["500", "700"],
});
export default async function AdminPage() {
const supabase = await createClient();
const {
@@ -32,8 +38,8 @@ export default async function AdminPage() {
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="mb-6">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
Le Conseil des Archontes
<h1 className={`${jetbrainsMono.className} text-2xl tracking-tight text-gold-bright`}>
&gt; Admin
</h1>
<p className="font-serif text-sm text-marble/60 italic">Administration des Citoyens</p>
</div>
+3
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { EntryRanking } from "./entry-ranking";
import { GardienBar } from "./gardien-bar";
import { RouletteSection } from "./roulette-section";
import type { ProfileRow, QuestionRow, SettingsRow, SlotRow } from "./page";
import { SlotBoard } from "./slot-board";
@@ -118,6 +119,8 @@ export function CharView({
<GardienBar isJudge={isJudge} holder={gardienHolder} expiresAt={settings.gardien_expires_at} />
<EntryRanking profiles={profiles} entryCounts={entryCounts} />
<RouletteSection profiles={profiles} />
</div>
);
}
+164
View File
@@ -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&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>
);
}
-33
View File
@@ -1,33 +0,0 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { RouletteView } from "./roulette-view";
export default async function RoulettePage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: members } = await supabase
.from("profiles")
.select("id, pseudo, avatar_url, role")
.order("pseudo", { ascending: true });
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
La Roulette
</h1>
<p className="font-serif text-sm text-marble/60 italic">
Que le sort désigne l&apos;élu.
</p>
</div>
<RouletteView members={members ?? []} />
</div>
);
}
-162
View File
@@ -1,162 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Avatar } from "@/components/avatar";
import { LaurelWreath } from "@/components/laurel-wreath";
type Member = { id: string; pseudo: string; avatar_url: string | null; role: string };
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 RouletteView({ members }: { members: Member[] }) {
const reducedMotion = useReducedMotion();
const [citizensOnly, setCitizensOnly] = useState(false);
const [spinning, setSpinning] = useState(false);
const [strip, setStrip] = useState<Member[]>([]);
const [offset, setOffset] = useState(0);
const [transitionOn, setTransitionOn] = useState(false);
const [winner, setWinner] = useState<Member | null>(null);
const [lastWinner, setLastWinner] = useState<Member | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const pool = citizensOnly ? members.filter((m) => m.role !== "judge") : members;
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: Member[] = [];
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);
}
if (members.length === 0) {
return <p className="text-center text-sm text-marble/60">Personne n&apos;est encore inscrit.</p>;
}
return (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-xl sm:p-8">
<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 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>
);
}
+1 -3
View File
@@ -8,7 +8,6 @@ import { LaurelWreath } from "@/components/laurel-wreath";
import { Avatar } from "@/components/avatar";
import { AgoraLogo } from "@/components/agora-logo";
import {
IconWheel,
IconScroll,
IconPerson,
IconColumn,
@@ -92,7 +91,6 @@ export function Header({
const entries: MenuEntry[] = [
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
{ href: "/roulette", label: "La Roulette", icon: <IconWheel /> },
{ href: "/icare", label: "Le Vol d'Icare", icon: <IconWings /> },
{ href: "/char", label: "Le Jeu de l'Agora", icon: <IconSword /> },
{ href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> },
@@ -102,7 +100,7 @@ export function Header({
if (isJudge) {
entries.push({
href: "/admin",
label: "Le Conseil des Archontes",
label: "Admin",
icon: <IconColumn />,
});
}
-1
View File
@@ -6,7 +6,6 @@ const PROTECTED_PATHS = [
"/profile",
"/admin",
"/journal",
"/roulette",
"/calendrier",
"/icare",
"/char",