Ajoute Le Jeu de l'Agora : Le Char et Le Gardien du Silence
Build and deploy / deploy (push) Successful in 36s
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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user