diff --git a/CLAUDE.md b/CLAUDE.md index 304b783..4f70098 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,15 +6,16 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol ## 1. État actuel -V1 à V6 sont livrées : +V1 à V7 sont livrées : - **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. - **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. - **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. +- **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). - 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 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`. - `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. ### 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`. - `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`). +- `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 | | `/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 | +| `/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 | | `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous | | `/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 | -`/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. @@ -153,6 +167,5 @@ Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code ## 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. -- **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. - **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README). diff --git a/src/app/char/char-view.tsx b/src/app/char/char-view.tsx new file mode 100644 index 0000000..8c319aa --- /dev/null +++ b/src/app/char/char-view.tsx @@ -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; + 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 = {}; + 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 ( +
+
+

+ Le Jeu de l'Agora +

+ {isJudge && ( + + Gérer les questions + + )} +
+ + {revealedQuestion && ( +
+

+ Question {questions.findIndex((q) => q.id === revealedQuestion.id) + 1} +

+

{revealedQuestion.text}

+
+ )} + + + + + + +
+ ); +} diff --git a/src/app/char/entry-ranking.tsx b/src/app/char/entry-ranking.tsx new file mode 100644 index 0000000..885ac99 --- /dev/null +++ b/src/app/char/entry-ranking.tsx @@ -0,0 +1,35 @@ +import { Avatar } from "@/components/avatar"; +import type { ProfileRow } from "./page"; + +export function EntryRanking({ + profiles, + entryCounts, +}: { + profiles: ProfileRow[]; + entryCounts: Record; +}) { + 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 ( +
+

+ Passages dans le char +

+
    + {sorted.map((p) => { + const count = entryCounts[p.id] ?? 0; + return ( +
  • + + {p.pseudo} + {count > 0 ? `×${count}` : "—"} +
  • + ); + })} +
+
+ ); +} diff --git a/src/app/char/gardien-bar.tsx b/src/app/char/gardien-bar.tsx new file mode 100644 index 0000000..a487bdc --- /dev/null +++ b/src/app/char/gardien-bar.tsx @@ -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 ( +
+ Gardien du Silence : + {holder ? ( + + + {holder.pseudo} + + ) : ( + personne + )} + {expiresAt && {formatCountdown(secondsLeft)}} + {isJudge && ( + + )} +
+ ); +} diff --git a/src/app/char/page.tsx b/src/app/char/page.tsx new file mode 100644 index 0000000..3610608 --- /dev/null +++ b/src/app/char/page.tsx @@ -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 { + const counts: Record = {}; + 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 ( +
+ +
+ ); +} diff --git a/src/app/char/questions/page.tsx b/src/app/char/questions/page.tsx new file mode 100644 index 0000000..01cf34b --- /dev/null +++ b/src/app/char/questions/page.tsx @@ -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 ( +
+
+

Les Questions du Char

+

+ Réservé aux Archontes — /char (projetée) n'affiche que la question choisie ici. +

+ + ← Retourner au Char + +
+ +
+ ); +} diff --git a/src/app/char/questions/questions-view.tsx b/src/app/char/questions/questions-view.tsx new file mode 100644 index 0000000..79d43b2 --- /dev/null +++ b/src/app/char/questions/questions-view.tsx @@ -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(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 ( +
+