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:
@@ -6,15 +6,16 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
|
|||||||
|
|
||||||
## 1. État actuel
|
## 1. État actuel
|
||||||
|
|
||||||
V1 à V6 sont livrées :
|
V1 à V7 sont livrées :
|
||||||
- **V1** : comptes, profils, leaderboard statique.
|
- **V1** : comptes, profils, leaderboard statique.
|
||||||
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
|
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
|
||||||
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
|
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
|
||||||
- **V4** : direction artistique Grèce antique (marbre/or/mer de nuit, polices Cinzel/Cormorant Garamond/Manrope, vocabulaire Archontes/Citoyens/Agora/Crieur), header global avec chip utilisateur.
|
- **V4** : direction artistique Grèce antique (marbre/or/mer de nuit, polices Cinzel/Cormorant Garamond/Manrope, vocabulaire Archontes/Citoyens/Agora/Crieur), header global avec chip utilisateur.
|
||||||
- **V5** : navigation entièrement repliée dans le menu déroulant du chip (header épuré : logo + chip seulement), page **La Roulette** (tirage au sort animé), page **Le Calendrier des Dieux** (agenda de la semaine, panthéon, date du Tribunal), boutons de validation des points unifiés (`PointsConfirmControls`) entre podium et classement.
|
- **V5** : navigation entièrement repliée dans le menu déroulant du chip (header épuré : logo + chip seulement), page **La Roulette** (tirage au sort animé), page **Le Calendrier des Dieux** (agenda de la semaine, panthéon, date du Tribunal), boutons de validation des points unifiés (`PointsConfirmControls`) entre podium et classement.
|
||||||
- **V6** : premier mini-jeu compétitif, **Le Vol d'Icare** (`/icare`) — Flappy Bird grec (Icare vole entre des colonnes de temple), record personnel all-time (pas de piste par jour), touche l'écran pour battre des ailes, échec net au premier contact (score remis à zéro, comme l'original). Les scores sont figés dès que la date du Tribunal est atteinte, puis les gloires du top 3 sont attribuées **automatiquement** (tâche planifiée `pg_cron`, sans intervention d'un Archonte) — seul mécanisme de points de tout le projet qui ne passe pas par une décision de juge.
|
- **V6** : premier mini-jeu compétitif, **Le Vol d'Icare** (`/icare`) — Flappy Bird grec (Icare vole entre des colonnes de temple), record personnel all-time (pas de piste par jour), touche l'écran pour battre des ailes, échec net au premier contact (score remis à zéro, comme l'original). Les scores sont figés dès que la date du Tribunal est atteinte, puis les gloires du top 3 sont attribuées **automatiquement** (tâche planifiée `pg_cron`, sans intervention d'un Archonte) — seul mécanisme de points de tout le projet qui ne passe pas par une décision de juge.
|
||||||
|
- **V7** : outils numériques pour le vrai jeu du Tribunal (soirée physique/sociale, pas un mini-jeu digital), page **Le Jeu de l'Agora** (`/char`) — pensée **format paysage 16:9** (projetée sur grand écran) plutôt que mobile-first comme le reste de l'app. **Le Char** : 2 colonnes fixes de 3 emplacements affichées côte à côte avec un « VS » et une illustration de char vu du dessus entre les deux, affectation d'un Citoyen à un emplacement vide en un clic (liste des Citoyens libres directement cliquables, pas de menu déroulant), bouton « Retirer », boutons « Colonne A/B remporte le duel » (vide la colonne gagnante après une brève animation — les vainqueurs sortent, les perdants restent), compteur du nombre de fois que chacun est monté dans le char, avec un petit classement récapitulatif en bas de page (tout le monde, y compris ceux qui n'y sont jamais monté). La banque de questions (ajout/édition/réordonnancement/bascule afficher-masquer) vit sur une page séparée réservée aux Archontes, **`/char/questions`** — pensée pour être pilotée depuis un téléphone pendant que `/char` est projetée depuis un ordinateur ; `/char` n'affiche que la question actuellement révélée, en lecture seule. **Le Gardien du Silence** : rôle tiré au sort parmi les Citoyens (jamais les Archontes), affiché de façon discrète (barre compacte, pas une grande carte), minuteur avec reroll automatique à expiration (RPC auto-protégée, filet `pg_cron`) et bouton de reroll manuel pour les Archontes. L'app n'arbitre jamais le jeu lui-même, elle affiche/synchronise un état piloté à la main.
|
||||||
|
|
||||||
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, le reste du jeu du Tribunal (ostracisme, timer du Gardien — Le Vol d'Icare est livré en V6), mode grand écran.
|
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -63,6 +64,14 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo
|
|||||||
- `settings.icarus_points_awarded` : marqueur d'idempotence (un seul événement — l'attribution au début du Tribunal — pas une clôture quotidienne comme l'ancienne Course du Char).
|
- `settings.icarus_points_awarded` : marqueur d'idempotence (un seul événement — l'attribution au début du Tribunal — pas une clôture quotidienne comme l'ancienne Course du Char).
|
||||||
- Génération des colonnes/obstacles entièrement côté client, sans graine partagée (pas de piste identique pour tout le monde à reproduire ici — chaque partie est procédurale).
|
- Génération des colonnes/obstacles entièrement côté client, sans graine partagée (pas de piste identique pour tout le monde à reproduire ici — chaque partie est procédurale).
|
||||||
|
|
||||||
|
### `public.chariot_questions` / `public.chariot_slots` / `public.chariot_entries` (Le Jeu de l'Agora — Le Char)
|
||||||
|
|
||||||
|
- `chariot_questions` : `id, text, position, created_at` — banque de questions ordonnée, réordonnée par swap de `position` (pas de contrainte unique dessus, deux `update` non transactionnels ; toujours trier `order by position, created_at`). RLS : lecture ouverte, écriture réservée aux juges (comme `days`/`events`). Gérée exclusivement depuis `/char/questions`, jamais depuis `/char`.
|
||||||
|
- `chariot_slots` : `slot (pk, 1 à 6), user_id nullable` — 6 emplacements fixes pré-remplis une fois pour toutes (1-3 = Colonne A, 4-6 = Colonne B), jamais insert/delete côté client, seulement des `update` pour affecter/vider un emplacement. Même RLS que `chariot_questions`.
|
||||||
|
- `chariot_entries` : `id, user_id, created_at` — journal append-only (comme `points_log`) d'une ligne à chaque affectation d'un Citoyen à un emplacement, sert uniquement à compter combien de fois chacun est monté dans le char (`count(*) group by user_id`, calculé côté client). RLS : lecture ouverte, insertion réservée aux juges, aucune mise à jour/suppression possible.
|
||||||
|
- `settings.chariot_revealed_question_id` : question actuellement affichée sur `/char`, directement modifiable par les juges (rien à protéger dans cette colonne-là) — bascule effectuée depuis `/char/questions`.
|
||||||
|
- Préfixe `chariot_*` (pas `char_*`) volontaire : sans rapport avec l'ancienne Course du Char digitale supprimée, qui utilisait déjà ce préfixe.
|
||||||
|
|
||||||
### Colonnes sensibles : RLS + trigger, jamais confiance au client
|
### Colonnes sensibles : RLS + trigger, jamais confiance au client
|
||||||
|
|
||||||
La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seulement si tel rôle ». Le verrouillage fin des colonnes (`role`, `points`, `pseudo_locked`, pseudo figé, `previous_rank`) passe par le trigger `enforce_profile_update()` :
|
La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seulement si tel rôle ». Le verrouillage fin des colonnes (`role`, `points`, `pseudo_locked`, pseudo figé, `previous_rank`) passe par le trigger `enforce_profile_update()` :
|
||||||
@@ -71,6 +80,8 @@ La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seul
|
|||||||
- `pseudo` : modifiable une fois par le propriétaire (auto-lock ensuite), ou à tout moment par un `judge`.
|
- `pseudo` : modifiable une fois par le propriétaire (auto-lock ensuite), ou à tout moment par un `judge`.
|
||||||
- `auth.uid() is null` (SQL Editor, migrations) bypass le trigger : ces accès sont déjà fiables par construction.
|
- `auth.uid() is null` (SQL Editor, migrations) bypass le trigger : ces accès sont déjà fiables par construction.
|
||||||
|
|
||||||
|
Même pattern sur `public.settings` depuis V7, via `enforce_settings_update()` : `gardien_holder_id`/`gardien_expires_at` sont bloqués en écriture directe pour **tout le monde**, y compris les juges — seule la RPC `reroll_gardien()` peut les modifier (flag `app.bypass_gardien_lock`), pour garantir que le Gardien est réellement tiré au sort et pas choisi à la main. `tribunal_date`, `icarus_points_awarded` et `chariot_revealed_question_id` restent, eux, directement modifiables par les juges (rien à protéger dans ces colonnes-là).
|
||||||
|
|
||||||
Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accès aux colonnes) est la convention du projet — le reproduire pour toute nouvelle colonne sensible plutôt que d'inventer autre chose.
|
Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accès aux colonnes) est la convention du projet — le reproduire pour toute nouvelle colonne sensible plutôt que d'inventer autre chose.
|
||||||
|
|
||||||
### RPC exposées
|
### RPC exposées
|
||||||
@@ -80,6 +91,7 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
|
|||||||
- `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`.
|
- `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`.
|
||||||
- `submit_icarus_score(p_score)` — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu.
|
- `submit_icarus_score(p_score)` — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu.
|
||||||
- `award_icarus_points_if_due()` — **pas de grant à authenticated**, appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (`settings.icarus_points_awarded`).
|
- `award_icarus_points_if_due()` — **pas de grant à authenticated**, appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (`settings.icarus_points_awarded`).
|
||||||
|
- `reroll_gardien(p_force default false)` — authenticated. Tire un nouveau Gardien parmi les Citoyens (jamais le détenteur actuel si possible) et repousse `gardien_expires_at` de 5 minutes. `p_force=true` (vérifie `role = 'judge'`) reroll immédiatement ; `p_force=false` (appelée par le minuteur côté client à expiration, et par `pg_cron` en filet) ne fait rien tant que `gardien_expires_at` n'est pas atteint — un seul `UPDATE ... WHERE` atomique (pas de `SELECT` puis `UPDATE`), pour qu'un reroll naturel avec plusieurs téléphones ouverts au même moment ne reroll qu'une seule fois.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -99,12 +111,14 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
|
|||||||
| `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges |
|
| `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges |
|
||||||
| `/roulette` | « La Roulette » — tirage au sort animé parmi les membres (ou Citoyens uniquement) |
|
| `/roulette` | « La Roulette » — tirage au sort animé parmi les membres (ou Citoyens uniquement) |
|
||||||
| `/icare` | « Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement |
|
| `/icare` | « Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement |
|
||||||
|
| `/char` | « Le Jeu de l'Agora » — format paysage (projeté) : Le Char (2 colonnes de 3, boutons de duel) et Le Gardien du Silence (tirage aléatoire, minuteur discret) |
|
||||||
|
| `/char/questions` | Banque de questions du Char (ajout/édition/réordonnancement/afficher-masquer) — juges uniquement, pilotée depuis un téléphone pendant que `/char` est projetée |
|
||||||
| `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges |
|
| `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges |
|
||||||
| `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous |
|
| `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous |
|
||||||
| `/profile` | Pseudo (verrouillable), photo, déconnexion |
|
| `/profile` | Pseudo (verrouillable), photo, déconnexion |
|
||||||
| `/admin` | « Le Conseil des Archontes » — juges uniquement, **vérifié côté serveur** (Server Component) — gestion des membres, reset du repère de classement |
|
| `/admin` | « Le Conseil des Archontes » — juges uniquement, **vérifié côté serveur** (Server Component) — gestion des membres, reset du repère de classement |
|
||||||
|
|
||||||
`/leaderboard`, `/profile`, `/admin`, `/journal`, `/roulette`, `/calendrier`, `/icare` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front).
|
`/leaderboard`, `/profile`, `/admin`, `/journal`, `/roulette`, `/calendrier`, `/icare`, `/char` (et donc `/char/questions`, couverte par le même préfixe) sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` et `/char/questions` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — la page elle-même vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front).
|
||||||
|
|
||||||
Le header global (`components/header.tsx`) est rendu dans `layout.tsx` pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal », jamais masqué même à 320px) à gauche, chip utilisateur (avatar, pseudo, points, jamais masqués) à droite. **Aucun lien de navigation dans le header lui-même** — le chip ouvre un menu déroulant qui contient toutes les pages (avec icônes), fermeture au clic extérieur/Échap, entrée courante surlignée.
|
Le header global (`components/header.tsx`) est rendu dans `layout.tsx` pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal », jamais masqué même à 320px) à gauche, chip utilisateur (avatar, pseudo, points, jamais masqués) à droite. **Aucun lien de navigation dans le header lui-même** — le chip ouvre un menu déroulant qui contient toutes les pages (avec icônes), fermeture au clic extérieur/Échap, entrée courante surlignée.
|
||||||
|
|
||||||
@@ -153,6 +167,5 @@ Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code
|
|||||||
## 8. Roadmap (pas encore codé)
|
## 8. Roadmap (pas encore codé)
|
||||||
|
|
||||||
- **Éditions** : table `editions` (année, mood board) ; les points deviendront rattachables à une édition donnée. Le thème visuel (Grèce antique) reste fixe, ce n'est pas un système de re-thématisation par édition.
|
- **Éditions** : table `editions` (année, mood board) ; les points deviendront rattachables à une édition donnée. Le thème visuel (Grèce antique) reste fixe, ce n'est pas un système de re-thématisation par édition.
|
||||||
- **Le jeu du Tribunal (suite)** : batailles de cul sec, ostracisme, timer du Gardien. (La Roulette est livrée en V5, Le Vol d'Icare en V6.)
|
|
||||||
- **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur.
|
- **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur.
|
||||||
- **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README).
|
- **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Char antique vu du dessus, stylisé : essieu + roues de profil (une roue
|
||||||
|
// vue depuis le dessus apparaît de champ, pas comme un disque), caisse
|
||||||
|
// en goutte, timon vers le joug. Même esprit que laurel-wreath.tsx (tracé
|
||||||
|
// SVG pur, pas d'image bitmap).
|
||||||
|
export function ChariotEmblem({
|
||||||
|
className = "h-12 w-8",
|
||||||
|
color = "#C9A227",
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 40 60"
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<line x1="6" y1="45" x2="34" y2="45" strokeWidth={2.4} />
|
||||||
|
<ellipse cx="6" cy="45" rx="2.2" ry="8" fill={color} stroke="none" />
|
||||||
|
<ellipse cx="34" cy="45" rx="2.2" ry="8" fill={color} stroke="none" />
|
||||||
|
<path d="M12 45 C12 30, 14 20, 20 14 C26 20, 28 30, 28 45 Z" />
|
||||||
|
<line x1="20" y1="14" x2="20" y2="4" />
|
||||||
|
<line x1="14" y1="4" x2="26" y2="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconWings,
|
IconWings,
|
||||||
|
IconSword,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
|
|
||||||
type MenuEntry = {
|
type MenuEntry = {
|
||||||
@@ -93,6 +94,7 @@ export function Header({
|
|||||||
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
|
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
|
||||||
{ href: "/roulette", label: "La Roulette", icon: <IconWheel /> },
|
{ href: "/roulette", label: "La Roulette", icon: <IconWheel /> },
|
||||||
{ href: "/icare", label: "Le Vol d'Icare", icon: <IconWings /> },
|
{ 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 /> },
|
{ href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> },
|
||||||
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
|
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
|
||||||
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> },
|
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const PROTECTED_PATHS = [
|
|||||||
"/roulette",
|
"/roulette",
|
||||||
"/calendrier",
|
"/calendrier",
|
||||||
"/icare",
|
"/icare",
|
||||||
|
"/char",
|
||||||
];
|
];
|
||||||
// /signup n'est PAS dans AUTH_PATHS : un compte fraîchement invité a déjà une
|
// /signup n'est PAS dans AUTH_PATHS : un compte fraîchement invité a déjà une
|
||||||
// session (établie par le lien d'invitation) mais doit pouvoir rester sur
|
// session (établie par le lien d'invitation) mais doit pouvoir rester sur
|
||||||
|
|||||||
@@ -648,3 +648,216 @@ exception
|
|||||||
when others then
|
when others then
|
||||||
raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier l''attribution automatique des gloires du Vol d''Icare.';
|
raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier l''attribution automatique des gloires du Vol d''Icare.';
|
||||||
end $$;
|
end $$;
|
||||||
|
|
||||||
|
-- 12. Le Jeu de l'Agora — Le Char et Le Gardien du Silence ---------------------
|
||||||
|
-- Outils pour la soirée du Tribunal elle-même (jeu physique/social, pas un
|
||||||
|
-- mini-jeu numérique) : l'app affiche/synchronise un état que les Archontes
|
||||||
|
-- pilotent à la main, elle ne calcule/arbitre jamais le déroulé du jeu.
|
||||||
|
-- Préfixe chariot_* (pas char_*) : sans rapport avec l'ancienne Course du
|
||||||
|
-- Char digitale déjà supprimée (section 11), qui utilisait déjà ce préfixe —
|
||||||
|
-- le reprendre ici évite de laisser croire à une résurrection de ce mini-jeu.
|
||||||
|
-- La route reste /char.
|
||||||
|
|
||||||
|
create table if not exists public.chariot_questions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
text text not null,
|
||||||
|
position integer not null default 0,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.chariot_questions enable row level security;
|
||||||
|
grant select, insert, update, delete on public.chariot_questions to authenticated;
|
||||||
|
|
||||||
|
drop policy if exists "chariot_questions viewable by authenticated" on public.chariot_questions;
|
||||||
|
create policy "chariot_questions viewable by authenticated"
|
||||||
|
on public.chariot_questions for select to authenticated using (true);
|
||||||
|
|
||||||
|
drop policy if exists "chariot_questions insert by judges" on public.chariot_questions;
|
||||||
|
create policy "chariot_questions insert by judges" on public.chariot_questions for insert to authenticated
|
||||||
|
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||||
|
|
||||||
|
drop policy if exists "chariot_questions update by judges" on public.chariot_questions;
|
||||||
|
create policy "chariot_questions update by judges" on public.chariot_questions for update to authenticated
|
||||||
|
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
||||||
|
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||||
|
|
||||||
|
drop policy if exists "chariot_questions delete by judges" on public.chariot_questions;
|
||||||
|
create policy "chariot_questions delete by judges" on public.chariot_questions for delete to authenticated
|
||||||
|
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
alter publication supabase_realtime add table public.chariot_questions;
|
||||||
|
exception
|
||||||
|
when duplicate_object then null;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- Nettoyage de l'ancien modèle par statut (team_a/team_b/out), remplacé par
|
||||||
|
-- un modèle par emplacement fixe ci-dessous — schéma jamais appliqué en
|
||||||
|
-- prod (retour utilisateur avant la première vraie migration), DROP direct.
|
||||||
|
drop table if exists public.chariot_roster;
|
||||||
|
|
||||||
|
-- Char : 6 emplacements fixes (1-3 = Colonne A, 4-6 = Colonne B), vides
|
||||||
|
-- (user_id null) ou occupés. Pré-remplis une fois pour toutes ci-dessous —
|
||||||
|
-- on ne fait jamais que les UPDATE, jamais d'insert/delete côté client.
|
||||||
|
create table if not exists public.chariot_slots (
|
||||||
|
slot integer primary key check (slot between 1 and 6),
|
||||||
|
user_id uuid references public.profiles (id) on delete set null
|
||||||
|
);
|
||||||
|
insert into public.chariot_slots (slot)
|
||||||
|
select generate_series(1, 6)
|
||||||
|
on conflict (slot) do nothing;
|
||||||
|
|
||||||
|
alter table public.chariot_slots enable row level security;
|
||||||
|
grant select, update on public.chariot_slots to authenticated;
|
||||||
|
|
||||||
|
drop policy if exists "chariot_slots viewable by authenticated" on public.chariot_slots;
|
||||||
|
create policy "chariot_slots viewable by authenticated"
|
||||||
|
on public.chariot_slots for select to authenticated using (true);
|
||||||
|
|
||||||
|
drop policy if exists "chariot_slots update by judges" on public.chariot_slots;
|
||||||
|
create policy "chariot_slots update by judges" on public.chariot_slots for update to authenticated
|
||||||
|
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
||||||
|
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
alter publication supabase_realtime add table public.chariot_slots;
|
||||||
|
exception
|
||||||
|
when duplicate_object then null;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- Journal d'entrées dans le char (append-only, comme points_log) : sert
|
||||||
|
-- uniquement à compter combien de fois chaque personne est montée —
|
||||||
|
-- une ligne ajoutée à chaque affectation d'un emplacement, jamais modifiée.
|
||||||
|
create table if not exists public.chariot_entries (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
user_id uuid not null references public.profiles (id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.chariot_entries enable row level security;
|
||||||
|
grant select, insert on public.chariot_entries to authenticated;
|
||||||
|
revoke update, delete on public.chariot_entries from authenticated, anon;
|
||||||
|
|
||||||
|
drop policy if exists "chariot_entries viewable by authenticated" on public.chariot_entries;
|
||||||
|
create policy "chariot_entries viewable by authenticated"
|
||||||
|
on public.chariot_entries for select to authenticated using (true);
|
||||||
|
|
||||||
|
drop policy if exists "chariot_entries insert by judges" on public.chariot_entries;
|
||||||
|
create policy "chariot_entries insert by judges" on public.chariot_entries for insert to authenticated
|
||||||
|
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
alter publication supabase_realtime add table public.chariot_entries;
|
||||||
|
exception
|
||||||
|
when duplicate_object then null;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- Question actuellement révélée sur la page publique (bascule afficher/
|
||||||
|
-- masquer), et état du Gardien du Silence.
|
||||||
|
alter table public.settings add column if not exists chariot_revealed_question_id uuid references public.chariot_questions (id) on delete set null;
|
||||||
|
alter table public.settings add column if not exists gardien_holder_id uuid references public.profiles (id) on delete set null;
|
||||||
|
alter table public.settings add column if not exists gardien_expires_at timestamptz;
|
||||||
|
|
||||||
|
-- Verrou de colonne pour le Gardien (même pattern que profiles.points) :
|
||||||
|
-- gardien_holder_id/gardien_expires_at ne doivent changer que via un vrai
|
||||||
|
-- tirage aléatoire (reroll_gardien), jamais par une écriture directe même
|
||||||
|
-- par un Archonte — chariot_revealed_question_id reste, lui, directement
|
||||||
|
-- modifiable par les juges (rien à protéger dans cette colonne-là).
|
||||||
|
create or replace function public.enforce_settings_update()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
bypass boolean := coalesce(current_setting('app.bypass_gardien_lock', true), 'off') = 'on';
|
||||||
|
begin
|
||||||
|
if auth.uid() is null then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not bypass and (
|
||||||
|
new.gardien_holder_id is distinct from old.gardien_holder_id
|
||||||
|
or new.gardien_expires_at is distinct from old.gardien_expires_at
|
||||||
|
) then
|
||||||
|
raise exception 'gardien_holder_id/gardien_expires_at can only change via reroll_gardien()';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists settings_before_update on public.settings;
|
||||||
|
create trigger settings_before_update
|
||||||
|
before update on public.settings
|
||||||
|
for each row execute function public.enforce_settings_update();
|
||||||
|
|
||||||
|
-- Tire un nouveau Gardien (uniquement parmi les Citoyens, jamais le
|
||||||
|
-- détenteur actuel si possible) et repousse l'expiration de 5 minutes.
|
||||||
|
-- p_force=true (réservé aux juges) reroll immédiatement ; p_force=false
|
||||||
|
-- (utilisé par le minuteur côté client, et par pg_cron en filet) ne fait
|
||||||
|
-- rien tant que gardien_expires_at n'est pas atteint.
|
||||||
|
--
|
||||||
|
-- Un seul UPDATE ... WHERE atomique (pas de SELECT puis UPDATE séparés) :
|
||||||
|
-- avec plusieurs téléphones ouverts, tous les minuteurs locaux arrivent à
|
||||||
|
-- zéro dans la même seconde et appellent cette RPC en même temps — la garde
|
||||||
|
-- dans le WHERE fait que Postgres sérialise naturellement, un seul appel
|
||||||
|
-- modifie réellement la ligne, les autres ne touchent aucune ligne.
|
||||||
|
create or replace function public.reroll_gardien(p_force boolean default false)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
caller_role text;
|
||||||
|
begin
|
||||||
|
if p_force then
|
||||||
|
select role into caller_role from public.profiles where id = auth.uid();
|
||||||
|
if caller_role is distinct from 'judge' then
|
||||||
|
raise exception 'only judges can force a reroll';
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform set_config('app.bypass_gardien_lock', 'on', true);
|
||||||
|
|
||||||
|
update public.settings
|
||||||
|
set gardien_holder_id = coalesce(
|
||||||
|
(select id from public.profiles
|
||||||
|
where role = 'public' and id is distinct from settings.gardien_holder_id
|
||||||
|
order by random() limit 1),
|
||||||
|
(select id from public.profiles where role = 'public' order by random() limit 1)
|
||||||
|
),
|
||||||
|
gardien_expires_at = now() + interval '5 minutes'
|
||||||
|
where id = true
|
||||||
|
and (p_force or gardien_expires_at is null or now() >= gardien_expires_at);
|
||||||
|
|
||||||
|
perform set_config('app.bypass_gardien_lock', 'off', true);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.reroll_gardien(boolean) to authenticated;
|
||||||
|
|
||||||
|
-- ⚠️ Même remarque que pour pg_cron plus haut : à activer une fois via le
|
||||||
|
-- Dashboard Supabase si ce n'est pas déjà fait. Cadence 1 minute : filet
|
||||||
|
-- large par rapport au minuteur de 5 minutes, le déclenchement réel se fait
|
||||||
|
-- côté client dès qu'un onglet est ouvert.
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
perform cron.unschedule('reroll-gardien');
|
||||||
|
exception
|
||||||
|
when others then null;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
perform cron.schedule(
|
||||||
|
'reroll-gardien',
|
||||||
|
'*/1 * * * *',
|
||||||
|
$cron$select public.reroll_gardien();$cron$
|
||||||
|
);
|
||||||
|
exception
|
||||||
|
when others then
|
||||||
|
raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier le reroll automatique du Gardien.';
|
||||||
|
end $$;
|
||||||
|
|||||||
Reference in New Issue
Block a user