From ab599d55c9674c748c5e8153f4724c0283f70d5e Mon Sep 17 00:00:00 2001 From: Valentin ROBIN Date: Sun, 23 Aug 2026 17:14:12 +0200 Subject: [PATCH] Ajoute Le Mur de la Honte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle page /mur : chaque Citoyen peut publier une note anonyme par jour (dix places max sur le mur, remis à zéro chaque jour — filtrage par date, jamais de suppression). L'anonymat n'est qu'à moitié réel : les Archontes voient une section "Historique complet" (filtrable par jour) avec l'auteur de chaque note, cohérent avec le thème du site où les Archontes ont un ascendant sur les Citoyens. Nouvelle table wall_notes + deux RPC : wall_notes_today() ne renvoie jamais l'auteur (seul moyen de garantir l'anonymat côté serveur, la RLS ne pouvant pas masquer une colonne pour certaines lignes) et post_wall_note() applique les règles (un juge ne publie pas, une note par jour, mur plafonné à 10). Pas de gel lié à la date du Tribunal : contrairement au Char, c'est une mécanique de toute la semaine. --- CLAUDE.md | 14 +- src/app/mur/mur-view.tsx | 269 +++++++++++++++++++++++++++++++++ src/app/mur/page.tsx | 80 ++++++++++ src/components/header.tsx | 2 + src/components/icons.tsx | 10 ++ src/lib/supabase/middleware.ts | 1 + supabase/schema.sql | 112 ++++++++++++++ 7 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 src/app/mur/mur-view.tsx create mode 100644 src/app/mur/page.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 4f6b76a..67559db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol ## 1. État actuel -V1 à V8 sont livrées : +V1 à V9 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. @@ -15,6 +15,7 @@ V1 à V8 sont livrées : - **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. **La Roulette** (tirage au sort animé parmi les membres, ou les Citoyens uniquement), initialement livrée en V5 sur sa propre page `/roulette`, a été fusionnée dans `/char` comme troisième section — la route et le lien de menu dédiés ont été retirés, tout l'outillage de la soirée du Tribunal vit maintenant au même endroit. - **V8** : les Citoyens peuvent désormais proposer une question pour Le Char — une par personne (upsert), modifiable autant de fois que voulu depuis une nouvelle section sur `/profile`, jusqu'à ce que la date du Tribunal (`settings.tribunal_date`) soit atteinte (même mécanisme de gel que les scores d'Icare, no-op silencieux plutôt qu'une erreur). Les Archontes modèrent ces suggestions depuis une nouvelle section « Suggestions des Citoyens » sur `/char/questions` : chaque proposition affiche l'auteur, avec un bouton « Ajouter à la banque » (la copie dans `chariot_questions`, à la fin de l'ordre existant) et un bouton de rejet (suppression, confirmation à deux temps comme le reste de la page). Par ailleurs, `/admin` affiche maintenant l'adresse email de chaque membre (RPC `admin_list_members()`, seul moyen d'exposer `auth.users.email` aux juges sans passer par la `service_role key` côté client). +- **V9** : **Le Mur de la Honte** (`/mur`) — chaque Citoyen peut publier une note anonyme par jour (dix places maximum sur le mur, remis à zéro chaque jour — pas de suppression, juste un filtrage par date). L'anonymat n'est qu'à moitié réel : les Archontes voient une section « Historique complet » (filtrable par jour) avec l'auteur de chaque note jamais publiée, cohérent avec le thème du site (les Archontes ont un ascendant sur les Citoyens). Pas de gel lié à la date du Tribunal : contrairement au Char, c'est une mécanique de toute la semaine, pas de la seule soirée de clôture. Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran. @@ -103,6 +104,14 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè - `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. - `submit_chariot_question(p_text)` — authenticated, rejette les juges (seuls les Citoyens proposent), valide un texte non vide (≤ 300 caractères), et applique le même gel que `submit_icarus_score` via `settings.tribunal_date` (no-op silencieux une fois l'Agora commencée). `insert ... on conflict (user_id) do update` : une ligne par personne dans `chariot_submissions`, plus une ligne append-only dans `chariot_submission_history` à chaque écriture réelle (pas lors du no-op figé). - `admin_list_members()` — authenticated, vérifie `role = 'judge'`, seule façon d'exposer `auth.users.email` (jamais stocké dans `profiles`) sur `/admin` sans passer par la `service_role key` côté client. +- `wall_notes_today()` — authenticated, `language sql`, renvoie les notes du jour (heure de Paris) **sans `user_id`** — seul moyen de laisser un Citoyen voir les notes des autres sans jamais exposer l'auteur côté client (la RLS ne peut pas masquer une colonne pour certaines lignes). +- `post_wall_note(p_text)` — authenticated, rejette les juges (seuls les Citoyens publient), valide un texte non vide (≤ 200 caractères), puis vérifie que l'appelant n'a pas déjà publié aujourd'hui et que le mur du jour a moins de 10 notes (sinon `raise exception` dans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur). + +### `public.wall_notes` (Le Mur de la Honte, V9) + +- `id, user_id, text, created_at` — une ligne par note, jamais éditée/supprimée (comme `points_log`) ; le « reset quotidien » n'est qu'un filtrage par date dans `wall_notes_today()`, pas une suppression. +- RLS `select` : le propriétaire voit sa propre note (sert à afficher « tu as déjà publié aujourd'hui » et à réafficher son propre texte), les juges voient tout avec l'auteur (embed `profiles(pseudo, avatar_url)`, section « Historique complet » sur `/mur`) — pas de visibilité entre Citoyens, c'est tout l'intérêt de `wall_notes_today()`. +- Pas de gel via `settings.tribunal_date` : contrairement au Char, c'est une mécanique de toute la semaine. --- @@ -123,12 +132,13 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè | `/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), Le Gardien du Silence (tirage aléatoire, minuteur discret) et La Roulette (tirage au sort animé) | | `/char/questions` | Banque de questions du Char (ajout/édition/réordonnancement/afficher-masquer) + modération des suggestions des Citoyens (ajouter à la banque/rejeter) — juges uniquement, pilotée depuis un téléphone pendant que `/char` est projetée | +| `/mur` | « Le Mur de la Honte » — une note anonyme par Citoyen par jour (dix places, remis à zéro chaque jour) ; les Archontes y voient en plus l'historique complet avec l'auteur de chaque note | | `/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, et pour les Citoyens : proposition d'une question pour Le Char (modifiable jusqu'au Tribunal) | | `/admin` | « Admin » — juges uniquement, **vérifié côté serveur** (Server Component), habillée d'une police monospace (rupture volontaire avec le thème grec) — gestion des membres, reset du repère de classement | -`/leaderboard`, `/profile`, `/admin`, `/journal`, `/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). +`/leaderboard`, `/profile`, `/admin`, `/journal`, `/calendrier`, `/icare`, `/char` (et donc `/char/questions`, couverte par le même préfixe), `/mur` 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. diff --git a/src/app/mur/mur-view.tsx b/src/app/mur/mur-view.tsx new file mode 100644 index 0000000..5af8cca --- /dev/null +++ b/src/app/mur/mur-view.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { Avatar } from "@/components/avatar"; +import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; +import type { WallHistoryRow, WallNoteRow } from "./page"; + +const MAX_LENGTH = 200; +const MAX_NOTES_PER_DAY = 10; + +// Rotation déterministe par note (jamais Math.random() au rendu, cf. la +// même contrainte déjà rencontrée cette session avec les hooks React) — +// donne l'effet "punaisé en vrac" sans risquer un hydration mismatch. +function rotationFor(id: string): number { + let hash = 0; + for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0; + return (Math.abs(hash) % 7) - 3; +} + +function formatDateTime(iso: string): string { + return new Date(iso).toLocaleString("fr-FR", { + dateStyle: "short", + timeStyle: "short", + timeZone: "Europe/Paris", + }); +} + +// Clé de date locale (Europe/Paris) au format sv-SE (YYYY-MM-DD, triable), +// même pattern que calendrier/day-card.tsx. +function localDateKey(iso: string): string { + return new Date(iso).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }); +} + +function formatDateLabel(dateKey: string): string { + const label = new Date(`${dateKey}T12:00:00`).toLocaleDateString("fr-FR", { + weekday: "long", + day: "numeric", + month: "long", + timeZone: "Europe/Paris", + }); + return label.charAt(0).toUpperCase() + label.slice(1); +} + +function NoteCard({ note }: { note: WallNoteRow }) { + return ( +
+

{note.text}

+ +
+ ); +} + +export function MurView({ + isJudge, + initialNotes, + initialMyNoteToday, + initialHistory, +}: { + isJudge: boolean; + initialNotes: WallNoteRow[]; + initialMyNoteToday: WallNoteRow | null; + initialHistory: WallHistoryRow[]; +}) { + const [notes, setNotes] = useState(initialNotes); + const [myNoteToday, setMyNoteToday] = useState(initialMyNoteToday); + const [history, setHistory] = useState(initialHistory); + const [text, setText] = useState(""); + const [posting, setPosting] = useState(false); + const [error, setError] = useState(null); + const [historyDateFilter, setHistoryDateFilter] = useState("all"); + + const refetch = useCallback(async () => { + const supabase = createClient(); + const [{ data: todayNotes }, { data: myNotes }, { data: hist }] = await Promise.all([ + supabase.rpc("wall_notes_today"), + isJudge + ? Promise.resolve({ data: null }) + : supabase + .from("wall_notes") + .select("id, text, created_at") + .order("created_at", { ascending: false }), + isJudge + ? supabase + .from("wall_notes") + .select("id, user_id, text, created_at, profiles(pseudo, avatar_url)") + .order("created_at", { ascending: false }) + .returns() + : Promise.resolve({ data: null }), + ]); + if (todayNotes) setNotes(todayNotes); + if (myNotes) { + const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }); + setMyNoteToday( + myNotes.find( + (n) => new Date(n.created_at).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }) === today, + ) ?? null, + ); + } + if (hist) setHistory(hist); + }, [isJudge]); + + useEffect(() => { + const supabase = createClient(); + let channel: ReturnType | null = null; + let cancelled = false; + + waitForRealtimeAuth(supabase).then(() => { + if (cancelled) return; + channel = supabase + .channel("wall-notes-changes") + .on("postgres_changes", { event: "*", schema: "public", table: "wall_notes" }, refetch) + .subscribe(); + }); + + return () => { + cancelled = true; + if (channel) supabase.removeChannel(channel); + }; + }, [refetch]); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + + const trimmed = text.trim(); + if (!trimmed) { + setError("Écris une note avant de publier."); + return; + } + + setPosting(true); + const supabase = createClient(); + const { error: rpcError } = await supabase.rpc("post_wall_note", { p_text: trimmed }); + setPosting(false); + + if (rpcError) { + setError( + rpcError.message.includes("already posted") + ? "Tu as déjà publié une note aujourd'hui." + : rpcError.message.includes("wall full") + ? "Le mur est déjà complet pour aujourd'hui." + : "Une erreur est survenue, réessaie.", + ); + refetch(); + return; + } + + setText(""); + refetch(); + } + + const wallFull = notes.length >= MAX_NOTES_PER_DAY; + + const historyDates = Array.from(new Set(history.map((n) => localDateKey(n.created_at)))).sort((a, b) => + b.localeCompare(a), + ); + const filteredHistory = + historyDateFilter === "all" ? history : history.filter((n) => localDateKey(n.created_at) === historyDateFilter); + + return ( +
+
+

+ {notes.length} / {MAX_NOTES_PER_DAY}{" "} + aujourd'hui +

+ {notes.length === 0 ? ( +

Le mur est encore vide aujourd'hui.

+ ) : ( +
+ {notes.map((note) => ( + + ))} +
+ )} +
+ + {!isJudge && ( +
+ {myNoteToday ? ( +
+

Ta note d'aujourd'hui est publiée.

+

+ {myNoteToday.text} +

+

Reviens demain pour en publier une nouvelle.

+
+ ) : wallFull ? ( +

Le mur est complet pour aujourd'hui, reviens demain !

+ ) : ( +
+