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 !

+ ) : ( +
+