Ajoute Le Jeu de l'Agora : Le Char et Le Gardien du Silence
Build and deploy / deploy (push) Successful in 36s

Page /char (16:9, projetée) pour piloter les deux jeux physiques du
Tribunal : Le Char (2 colonnes de 3 emplacements côte à côte avec un
VS et une illustration de char vu du dessus, sélection en un clic,
duels, compteur et classement des passages) et Le Gardien du Silence
(tirage aléatoire parmi les Citoyens, minuteur avec reroll auto et
manuel). Banque de questions gérée séparément sur /char/questions
(pensée pour être pilotée depuis un téléphone pendant que /char est
projetée depuis un ordinateur).
This commit is contained in:
Valentin ROBIN
2026-08-11 18:17:45 +02:00
parent 4e22001ae0
commit f30f77a394
12 changed files with 1154 additions and 4 deletions
+123
View File
@@ -0,0 +1,123 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { EntryRanking } from "./entry-ranking";
import { GardienBar } from "./gardien-bar";
import type { ProfileRow, QuestionRow, SettingsRow, SlotRow } from "./page";
import { SlotBoard } from "./slot-board";
export function CharView({
isJudge,
initialSlots,
initialEntryCounts,
initialQuestions,
initialSettings,
initialProfiles,
}: {
isJudge: boolean;
initialSlots: SlotRow[];
initialEntryCounts: Record<string, number>;
initialQuestions: QuestionRow[];
initialSettings: SettingsRow;
initialProfiles: ProfileRow[];
}) {
const [slots, setSlots] = useState(initialSlots);
const [entryCounts, setEntryCounts] = useState(initialEntryCounts);
const [questions, setQuestions] = useState(initialQuestions);
const [settings, setSettings] = useState(initialSettings);
const [profiles, setProfiles] = useState(initialProfiles);
// Une seule page projetée (16:9) : on ré-interroge tout à chaque
// changement, y compris chariot_questions/settings — la question révélée
// est désormais pilotée depuis /char/questions (un autre appareil), donc
// cette page doit réagir en direct à ces changements-là aussi.
const refetch = useCallback(async () => {
const supabase = createClient();
const [{ data: sl }, { data: en }, { data: q }, { data: s }, { data: p }] = await Promise.all([
supabase.from("chariot_slots").select("slot, user_id").order("slot", { ascending: true }),
supabase.from("chariot_entries").select("user_id"),
supabase
.from("chariot_questions")
.select("id, text, position, created_at")
.order("position", { ascending: true })
.order("created_at", { ascending: true }),
supabase
.from("settings")
.select("chariot_revealed_question_id, gardien_holder_id, gardien_expires_at")
.eq("id", true)
.single(),
supabase.from("profiles").select("id, pseudo, avatar_url, role, points").order("pseudo", { ascending: true }),
]);
if (sl) setSlots(sl as SlotRow[]);
if (en) {
const counts: Record<string, number> = {};
for (const entry of en) counts[entry.user_id] = (counts[entry.user_id] ?? 0) + 1;
setEntryCounts(counts);
}
if (q) setQuestions(q);
if (s) setSettings(s);
if (p) setProfiles(p);
}, []);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("char-changes")
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_slots" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_entries" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_questions" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [refetch]);
const revealedQuestion = questions.find((q) => q.id === settings.chariot_revealed_question_id) ?? null;
const gardienHolder = profiles.find((p) => p.id === settings.gardien_holder_id) ?? null;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="font-heading text-lg tracking-[0.1em] text-gold-bright uppercase sm:text-xl">
Le Jeu de l&apos;Agora
</h1>
{isJudge && (
<Link
href="/char/questions"
className="text-xs font-medium text-text-mut underline hover:text-gold-bright"
>
Gérer les questions
</Link>
)}
</div>
{revealedQuestion && (
<div
key={revealedQuestion.id}
className="marble-surface animate-reveal rounded-2xl border border-gold/40 px-6 py-6 text-center shadow-xl sm:py-8"
>
<p className="mb-1 font-heading text-xs tracking-[0.2em] text-text-mut uppercase sm:text-sm">
Question {questions.findIndex((q) => q.id === revealedQuestion.id) + 1}
</p>
<p className="font-heading text-xl text-text-marble sm:text-2xl lg:text-3xl">{revealedQuestion.text}</p>
</div>
)}
<SlotBoard
isJudge={isJudge}
slots={slots}
profiles={profiles}
entryCounts={entryCounts}
onChanged={refetch}
/>
<GardienBar isJudge={isJudge} holder={gardienHolder} expiresAt={settings.gardien_expires_at} />
<EntryRanking profiles={profiles} entryCounts={entryCounts} />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { Avatar } from "@/components/avatar";
import type { ProfileRow } from "./page";
export function EntryRanking({
profiles,
entryCounts,
}: {
profiles: ProfileRow[];
entryCounts: Record<string, number>;
}) {
const sorted = [...profiles].sort((a, b) => {
const diff = (entryCounts[b.id] ?? 0) - (entryCounts[a.id] ?? 0);
return diff !== 0 ? diff : a.pseudo.localeCompare(b.pseudo);
});
return (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-lg">
<p className="mb-2 text-center font-heading text-sm tracking-wide text-text-mut uppercase">
Passages dans le char
</p>
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3 lg:grid-cols-4">
{sorted.map((p) => {
const count = entryCounts[p.id] ?? 0;
return (
<li key={p.id} className="flex min-w-0 items-center gap-1.5 text-xs">
<Avatar pseudo={p.pseudo} avatarUrl={p.avatar_url} size="xs" />
<span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}>{p.pseudo}</span>
<span className="ml-auto shrink-0 font-heading text-gold-bright">{count > 0 ? `×${count}` : "—"}</span>
</li>
);
})}
</ul>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useEffect, useState } from "react";
import { Avatar } from "@/components/avatar";
import { createClient } from "@/lib/supabase/client";
import type { ProfileRow } from "./page";
function computeSecondsLeft(expiresAt: string | null): number {
if (!expiresAt) return 0;
return Math.max(0, Math.round((new Date(expiresAt).getTime() - Date.now()) / 1000));
}
function formatCountdown(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}:${remainder.toString().padStart(2, "0")}`;
}
// Volontairement discret (une barre compacte, pas une grande carte) : ce
// n'est pas le sujet principal de la page projetée, juste un rappel présent.
export function GardienBar({
isJudge,
holder,
expiresAt,
}: {
isJudge: boolean;
holder: ProfileRow | null;
expiresAt: string | null;
}) {
const [secondsLeft, setSecondsLeft] = useState(() => computeSecondsLeft(expiresAt));
const [rerolling, setRerolling] = useState(false);
useEffect(() => {
const supabase = createClient();
let firedForThisExpiry = false;
function tick() {
const remaining = computeSecondsLeft(expiresAt);
setSecondsLeft(remaining);
if (remaining <= 0 && !firedForThisExpiry) {
firedForThisExpiry = true;
supabase.rpc("reroll_gardien", { p_force: false });
}
}
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [expiresAt]);
async function handleForceReroll() {
setRerolling(true);
const supabase = createClient();
await supabase.rpc("reroll_gardien", { p_force: true });
setRerolling(false);
}
return (
<div className="flex flex-wrap items-center justify-center gap-2 self-center rounded-full border border-gold/30 bg-ink-2/60 px-3 py-1.5 text-xs">
<span className="text-marble/60">Gardien du Silence :</span>
{holder ? (
<span key={holder.id} className="animate-podium-rise flex items-center gap-1.5">
<Avatar pseudo={holder.pseudo} avatarUrl={holder.avatar_url} size="xs" />
<span className="font-medium text-marble">{holder.pseudo}</span>
</span>
) : (
<span className="text-marble/50">personne</span>
)}
{expiresAt && <span className="font-heading text-gold-bright">{formatCountdown(secondsLeft)}</span>}
{isJudge && (
<button
type="button"
onClick={handleForceReroll}
disabled={rerolling}
className="rounded-full border border-gold/30 px-2 py-0.5 text-[0.65rem] text-marble/70 hover:bg-gold/10 disabled:opacity-50"
>
{rerolling ? "…" : "Changer"}
</button>
)}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { CharView } from "./char-view";
export type ProfileRow = {
id: string;
pseudo: string;
avatar_url: string | null;
role: string;
points: number;
};
export type QuestionRow = {
id: string;
text: string;
position: number;
created_at: string;
};
export type SlotRow = {
slot: number;
user_id: string | null;
};
export type SettingsRow = {
chariot_revealed_question_id: string | null;
gardien_holder_id: string | null;
gardien_expires_at: string | null;
};
function buildEntryCounts(entries: { user_id: string }[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const entry of entries) {
counts[entry.user_id] = (counts[entry.user_id] ?? 0) + 1;
}
return counts;
}
export default async function CharPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: callerProfile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
const isJudge = callerProfile?.role === "judge";
const { data: slots } = await supabase
.from("chariot_slots")
.select("slot, user_id")
.order("slot", { ascending: true });
const { data: entries } = await supabase.from("chariot_entries").select("user_id");
const { data: questions } = await supabase
.from("chariot_questions")
.select("id, text, position, created_at")
.order("position", { ascending: true })
.order("created_at", { ascending: true });
const { data: settings } = await supabase
.from("settings")
.select("chariot_revealed_question_id, gardien_holder_id, gardien_expires_at")
.eq("id", true)
.single();
const { data: profiles } = await supabase
.from("profiles")
.select("id, pseudo, avatar_url, role, points")
.order("pseudo", { ascending: true });
return (
<div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6">
<CharView
isJudge={isJudge}
initialSlots={(slots ?? []) as SlotRow[]}
initialEntryCounts={buildEntryCounts(entries ?? [])}
initialQuestions={questions ?? []}
initialSettings={
settings ?? { chariot_revealed_question_id: null, gardien_holder_id: null, gardien_expires_at: null }
}
initialProfiles={profiles ?? []}
/>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { QuestionsView } from "./questions-view";
export type QuestionRow = {
id: string;
text: string;
position: number;
created_at: string;
};
export default async function CharQuestionsPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: callerProfile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
// Page entièrement réservée aux Archontes : contrairement à /char (projeté,
// partagé avec tous), rien ici n'a de sens pour un Citoyen — et exposer la
// liste complète des questions à venir serait un spoiler du jeu en cours.
if (callerProfile?.role !== "judge") {
redirect("/char");
}
const { data: questions } = await supabase
.from("chariot_questions")
.select("id, text, position, created_at")
.order("position", { ascending: true })
.order("created_at", { ascending: true });
const { data: settings } = await supabase
.from("settings")
.select("chariot_revealed_question_id")
.eq("id", true)
.single();
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">Les Questions du Char</h1>
<p className="font-serif text-sm text-marble/60 italic">
Réservé aux Archontes /char (projetée) n&apos;affiche que la question choisie ici.
</p>
<Link
href="/char"
className="mt-2 inline-block text-xs font-medium text-marble/60 underline hover:text-gold-bright"
>
Retourner au Char
</Link>
</div>
<QuestionsView
initialQuestions={questions ?? []}
initialRevealedId={settings?.chariot_revealed_question_id ?? null}
/>
</div>
);
}
+289
View File
@@ -0,0 +1,289 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { IconChevronDown, IconPencil, IconPlus, IconTrash } from "@/components/icons";
import { createClient } from "@/lib/supabase/client";
import type { QuestionRow } from "./page";
function QuestionForm({
initial,
nextPosition,
onDone,
}: {
initial?: QuestionRow;
nextPosition: number;
onDone: () => void;
}) {
const [text, setText] = useState(initial?.text ?? "");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!text.trim()) {
setError("Question requise.");
return;
}
setSaving(true);
setError(null);
const supabase = createClient();
const { error: opError } = initial
? await supabase.from("chariot_questions").update({ text: text.trim() }).eq("id", initial.id)
: await supabase.from("chariot_questions").insert({ text: text.trim(), position: nextPosition });
setSaving(false);
if (opError) {
setError("Une erreur est survenue.");
return;
}
onDone();
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2 rounded-md border border-gold/30 bg-white/40 p-3">
<textarea
placeholder="Écrire la question…"
value={text}
onChange={(e) => setText(e.target.value)}
rows={2}
className="rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble"
/>
{error && <p className="text-xs text-oxblood">{error}</p>}
<div className="flex gap-2">
<button
type="submit"
disabled={saving}
className="rounded-md bg-ink-2 px-3 py-1.5 text-xs font-medium text-gold-bright hover:bg-ink disabled:opacity-50"
>
{saving ? "…" : "Enregistrer"}
</button>
<button
type="button"
onClick={onDone}
className="rounded-md border border-gold/30 px-3 py-1.5 text-xs text-text-marble hover:bg-gold/10"
>
Annuler
</button>
</div>
</form>
);
}
function QuestionRowItem({
question,
orderNumber,
isFirst,
isLast,
isRevealed,
onMove,
onToggleReveal,
onChanged,
}: {
question: QuestionRow;
orderNumber: number;
isFirst: boolean;
isLast: boolean;
isRevealed: boolean;
onMove: (direction: "up" | "down") => void;
onToggleReveal: () => void;
onChanged: () => void;
}) {
const [editing, setEditing] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
async function handleDelete() {
const supabase = createClient();
await supabase.from("chariot_questions").delete().eq("id", question.id);
onChanged();
}
if (editing) {
return (
<QuestionForm
initial={question}
nextPosition={question.position}
onDone={() => {
setEditing(false);
onChanged();
}}
/>
);
}
return (
<li className={`flex items-start gap-2 rounded-md px-2 py-1.5 text-sm ${isRevealed ? "bg-gold/10" : ""}`}>
<span className="w-5 shrink-0 pt-0.5 text-right font-heading text-xs text-text-mut">{orderNumber}</span>
<div className="flex shrink-0 flex-col">
<button
type="button"
onClick={() => onMove("up")}
disabled={isFirst}
aria-label="Monter"
className="flex h-5 w-5 items-center justify-center rounded text-text-mut hover:bg-gold/10 disabled:opacity-30"
>
<IconChevronDown className="h-3.5 w-3.5 rotate-180" />
</button>
<button
type="button"
onClick={() => onMove("down")}
disabled={isLast}
aria-label="Descendre"
className="flex h-5 w-5 items-center justify-center rounded text-text-mut hover:bg-gold/10 disabled:opacity-30"
>
<IconChevronDown className="h-3.5 w-3.5" />
</button>
</div>
<p className="min-w-0 flex-1 text-text-marble">{question.text}</p>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onToggleReveal}
className={`rounded-md px-2 py-1 text-xs font-medium ${
isRevealed
? "bg-oxblood text-marble hover:bg-oxblood/80"
: "border border-gold/40 text-text-marble hover:bg-gold/10"
}`}
>
{isRevealed ? "Masquer" : "Afficher"}
</button>
<button
type="button"
onClick={() => setEditing(true)}
aria-label="Modifier"
title="Modifier"
className="flex h-7 w-7 items-center justify-center rounded-md text-text-mut hover:bg-gold/10 hover:text-text-marble"
>
<IconPencil className="h-3.5 w-3.5" />
</button>
{confirmingDelete ? (
<button
type="button"
onClick={handleDelete}
className="rounded-md bg-oxblood px-2 py-1 text-xs text-marble"
>
Confirmer ?
</button>
) : (
<button
type="button"
onClick={() => setConfirmingDelete(true)}
onBlur={() => setConfirmingDelete(false)}
aria-label="Supprimer"
title="Supprimer"
className="flex h-7 w-7 items-center justify-center rounded-md text-oxblood/70 hover:bg-oxblood/10 hover:text-oxblood"
>
<IconTrash className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
);
}
export function QuestionsView({
initialQuestions,
initialRevealedId,
}: {
initialQuestions: QuestionRow[];
initialRevealedId: string | null;
}) {
const [questions, setQuestions] = useState(initialQuestions);
const [revealedId, setRevealedId] = useState(initialRevealedId);
const [adding, setAdding] = useState(false);
const refetch = useCallback(async () => {
const supabase = createClient();
const [{ data: q }, { data: s }] = await Promise.all([
supabase
.from("chariot_questions")
.select("id, text, position, created_at")
.order("position", { ascending: true })
.order("created_at", { ascending: true }),
supabase.from("settings").select("chariot_revealed_question_id").eq("id", true).single(),
]);
if (q) setQuestions(q);
if (s) setRevealedId(s.chariot_revealed_question_id);
}, []);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("char-questions-changes")
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_questions" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [refetch]);
async function moveQuestion(question: QuestionRow, direction: "up" | "down") {
const i = questions.findIndex((q) => q.id === question.id);
const j = direction === "up" ? i - 1 : i + 1;
if (j < 0 || j >= questions.length) return;
const other = questions[j];
const supabase = createClient();
await supabase.from("chariot_questions").update({ position: other.position }).eq("id", question.id);
await supabase.from("chariot_questions").update({ position: question.position }).eq("id", other.id);
refetch();
}
async function toggleReveal(question: QuestionRow) {
const supabase = createClient();
const isRevealed = revealedId === question.id;
await supabase
.from("settings")
.update({ chariot_revealed_question_id: isRevealed ? null : question.id })
.eq("id", true);
refetch();
}
return (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
{questions.length === 0 && !adding && (
<p className="mb-2 text-sm text-text-mut">Aucune question dans la banque.</p>
)}
<ul className="flex flex-col divide-y divide-gold/10">
{questions.map((question, index) => (
<QuestionRowItem
key={question.id}
question={question}
orderNumber={index + 1}
isFirst={index === 0}
isLast={index === questions.length - 1}
isRevealed={revealedId === question.id}
onMove={(direction) => moveQuestion(question, direction)}
onToggleReveal={() => toggleReveal(question)}
onChanged={refetch}
/>
))}
</ul>
<div className="mt-3">
{adding ? (
<QuestionForm
nextPosition={(questions.at(-1)?.position ?? 0) + 1}
onDone={() => {
setAdding(false);
refetch();
}}
/>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="flex items-center gap-1.5 text-xs font-medium text-text-mut hover:text-text-marble"
>
<IconPlus className="h-3.5 w-3.5" />
Ajouter une question
</button>
)}
</div>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useState } from "react";
import { Avatar } from "@/components/avatar";
import { ChariotEmblem } from "@/components/chariot-emblem";
import { createClient } from "@/lib/supabase/client";
import type { ProfileRow, SlotRow } from "./page";
const COLUMN_A_SLOTS = [1, 2, 3];
const COLUMN_B_SLOTS = [4, 5, 6];
function SlotCard({
slot,
profile,
entryCount,
isJudge,
availableProfiles,
onAssign,
onClear,
}: {
slot: number;
profile: ProfileRow | null;
entryCount: number;
isJudge: boolean;
availableProfiles: ProfileRow[];
onAssign: (userId: string) => void;
onClear: () => void;
}) {
const [picking, setPicking] = useState(false);
if (profile) {
return (
<div
key={profile.id}
className="animate-podium-rise marble-surface flex items-center gap-2 rounded-lg border border-gold/25 px-3 py-2"
>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-text-marble">{profile.pseudo}</p>
{entryCount > 0 && <p className="text-[0.65rem] text-text-mut">×{entryCount}</p>}
</div>
{isJudge && (
<button
type="button"
onClick={onClear}
className="shrink-0 rounded-md border border-oxblood/40 px-2 py-1 text-[0.65rem] text-oxblood hover:bg-oxblood/10"
>
Retirer
</button>
)}
</div>
);
}
return (
<div className="flex min-h-[2.5rem] items-center justify-center rounded-lg border border-dashed border-gold/25 px-2 py-2 text-xs">
{isJudge ? (
picking ? (
<div className="flex w-full flex-wrap items-center justify-center gap-1">
{availableProfiles.length === 0 ? (
<span className="text-text-mut/60">Personne de libre</span>
) : (
availableProfiles.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
onAssign(p.id);
setPicking(false);
}}
className="rounded-md border border-gold/30 bg-white/60 px-2 py-1 text-[0.7rem] text-text-marble hover:bg-gold/20"
>
{p.pseudo}
</button>
))
)}
</div>
) : (
<button type="button" onClick={() => setPicking(true)} className="text-text-mut hover:text-text-marble">
+ Emplacement {slot}
</button>
)
) : (
<span className="text-text-mut/60">Vide</span>
)}
</div>
);
}
export function SlotBoard({
isJudge,
slots,
profiles,
entryCounts,
onChanged,
}: {
isJudge: boolean;
slots: SlotRow[];
profiles: ProfileRow[];
entryCounts: Record<string, number>;
onChanged: () => void;
}) {
const [celebrating, setCelebrating] = useState<"a" | "b" | null>(null);
const profileById = new Map(profiles.map((p) => [p.id, p]));
const slotByNumber = new Map(slots.map((s) => [s.slot, s]));
const assignedIds = new Set(slots.filter((s) => s.user_id).map((s) => s.user_id as string));
const availableProfiles = profiles.filter((p) => !assignedIds.has(p.id));
async function assign(slot: number, userId: string) {
const supabase = createClient();
await supabase.from("chariot_slots").update({ user_id: userId }).eq("slot", slot);
await supabase.from("chariot_entries").insert({ user_id: userId });
onChanged();
}
async function clear(slot: number) {
const supabase = createClient();
await supabase.from("chariot_slots").update({ user_id: null }).eq("slot", slot);
onChanged();
}
// Les vainqueurs sortent du char : on laisse la lueur dorée se voir un
// instant avant de vider réellement leurs emplacements.
function resolveDuel(winner: "a" | "b") {
setCelebrating(winner);
const winningSlots = winner === "a" ? COLUMN_A_SLOTS : COLUMN_B_SLOTS;
setTimeout(async () => {
const supabase = createClient();
await supabase.from("chariot_slots").update({ user_id: null }).in("slot", winningSlots);
setCelebrating(null);
onChanged();
}, 700);
}
function renderColumn(label: string, slotNumbers: number[], side: "a" | "b") {
return (
<div
className={`marble-surface flex flex-col gap-2 rounded-2xl border p-4 shadow-lg ${
celebrating === side ? "animate-points-flash border-gold" : "border-gold/40"
}`}
>
<p className="text-center font-heading text-sm tracking-wide text-text-mut uppercase">{label}</p>
<div className="flex flex-col gap-2">
{slotNumbers.map((slot) => {
const row = slotByNumber.get(slot);
const profile = row?.user_id ? (profileById.get(row.user_id) ?? null) : null;
return (
<SlotCard
key={slot}
slot={slot}
profile={profile}
entryCount={profile ? (entryCounts[profile.id] ?? 0) : 0}
isJudge={isJudge}
availableProfiles={availableProfiles}
onAssign={(userId) => assign(slot, userId)}
onClear={() => clear(slot)}
/>
);
})}
</div>
</div>
);
}
return (
<section className="flex flex-col gap-3">
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-stretch gap-2 sm:gap-4">
{renderColumn("Colonne A", COLUMN_A_SLOTS, "a")}
<div className="flex flex-col items-center justify-center gap-1 px-0.5 sm:gap-1.5 sm:px-2">
<ChariotEmblem className="h-10 w-7 sm:h-16 sm:w-10" />
<span className="font-heading text-xs tracking-[0.15em] text-gold-bright sm:text-lg">VS</span>
</div>
{renderColumn("Colonne B", COLUMN_B_SLOTS, "b")}
</div>
{isJudge && (
<div className="flex flex-wrap justify-center gap-3">
<button
type="button"
onClick={() => resolveDuel("a")}
disabled={celebrating !== null}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-xs tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
Colonne A remporte le duel
</button>
<button
type="button"
onClick={() => resolveDuel("b")}
disabled={celebrating !== null}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-xs tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
Colonne B remporte le duel
</button>
</div>
)}
</section>
);
}