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.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="marble-surface flex min-h-[7rem] flex-col justify-between gap-2 rounded-lg border border-gold/30 p-3 shadow-md"
|
||||
style={{ transform: `rotate(${rotationFor(note.id)}deg)` }}
|
||||
>
|
||||
<p className="text-sm text-text-marble">{note.text}</p>
|
||||
<span className="self-end h-2 w-2 rounded-full bg-oxblood" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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<WallHistoryRow[]>()
|
||||
: 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<typeof supabase.channel> | 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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<p className="mb-3 text-center text-xs font-medium tracking-wide text-marble/60 uppercase">
|
||||
{notes.length} / {MAX_NOTES_PER_DAY}{" "}
|
||||
aujourd'hui
|
||||
</p>
|
||||
{notes.length === 0 ? (
|
||||
<p className="text-center text-sm text-marble/60">Le mur est encore vide aujourd'hui.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{notes.map((note) => (
|
||||
<NoteCard key={note.id} note={note} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isJudge && (
|
||||
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
|
||||
{myNoteToday ? (
|
||||
<div>
|
||||
<p className="text-sm text-olive">Ta note d'aujourd'hui est publiée.</p>
|
||||
<p className="mt-2 rounded-md border border-gold/20 bg-white/40 px-3 py-2 text-sm text-text-marble">
|
||||
{myNoteToday.text}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-text-mut">Reviens demain pour en publier une nouvelle.</p>
|
||||
</div>
|
||||
) : wallFull ? (
|
||||
<p className="text-sm text-text-mut">Le mur est complet pour aujourd'hui, reviens demain !</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={2}
|
||||
placeholder="Écrire une note anonyme…"
|
||||
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-sm text-text-marble outline-none focus:border-gold"
|
||||
/>
|
||||
<p className="text-right text-[0.65rem] text-text-mut">
|
||||
{text.length}/{MAX_LENGTH}
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-oxblood">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={posting}
|
||||
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
|
||||
>
|
||||
{posting ? "Publication…" : "Publier anonymement"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isJudge && (
|
||||
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-heading text-sm tracking-wide text-text-mut uppercase">Historique complet</p>
|
||||
{historyDates.length > 0 && (
|
||||
<select
|
||||
value={historyDateFilter}
|
||||
onChange={(event) => setHistoryDateFilter(event.target.value)}
|
||||
className="rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-xs text-text-marble outline-none focus:border-gold"
|
||||
>
|
||||
<option value="all">Tous les jours</option>
|
||||
{historyDates.map((dateKey) => (
|
||||
<option key={dateKey} value={dateKey}>
|
||||
{formatDateLabel(dateKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{filteredHistory.length === 0 ? (
|
||||
<p className="text-sm text-text-mut">
|
||||
{history.length === 0 ? "Aucune note publiée pour l'instant." : "Aucune note ce jour-là."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-gold/10">
|
||||
{filteredHistory.map((note) => (
|
||||
<li key={note.id} className="flex items-start gap-2 py-1.5 text-sm">
|
||||
<Avatar
|
||||
pseudo={note.profiles?.pseudo ?? "?"}
|
||||
avatarUrl={note.profiles?.avatar_url ?? null}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-text-mut">
|
||||
{note.profiles?.pseudo ?? "un Citoyen"} — {formatDateTime(note.created_at)}
|
||||
</p>
|
||||
<p className="text-text-marble">{note.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { MurView } from "./mur-view";
|
||||
|
||||
export type WallNoteRow = {
|
||||
id: string;
|
||||
text: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type WallHistoryRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
text: string;
|
||||
created_at: string;
|
||||
profiles: { pseudo: string; avatar_url: string | null } | null;
|
||||
};
|
||||
|
||||
function isToday(iso: string): boolean {
|
||||
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
||||
const noteDate = new Date(iso).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
||||
return noteDate === today;
|
||||
}
|
||||
|
||||
export default async function MurPage() {
|
||||
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: todayNotes } = await supabase.rpc("wall_notes_today");
|
||||
|
||||
let myNoteToday: WallNoteRow | null = null;
|
||||
if (!isJudge) {
|
||||
const { data: myNotes } = await supabase
|
||||
.from("wall_notes")
|
||||
.select("id, text, created_at")
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false });
|
||||
myNoteToday = (myNotes ?? []).find((n) => isToday(n.created_at)) ?? null;
|
||||
}
|
||||
|
||||
let history: WallHistoryRow[] = [];
|
||||
if (isJudge) {
|
||||
const { data } = await supabase
|
||||
.from("wall_notes")
|
||||
.select("id, user_id, text, created_at, profiles(pseudo, avatar_url)")
|
||||
.order("created_at", { ascending: false })
|
||||
.returns<WallHistoryRow[]>();
|
||||
history = data ?? [];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl px-4 py-8">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">Le Mur de la Honte</h1>
|
||||
<p className="font-serif text-sm text-marble/60 italic">
|
||||
Une note anonyme par jour, dix places sur le mur — la journée passée, tout s'efface.
|
||||
</p>
|
||||
</div>
|
||||
<MurView
|
||||
isJudge={isJudge}
|
||||
initialNotes={todayNotes ?? []}
|
||||
initialMyNoteToday={myNoteToday}
|
||||
initialHistory={history}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
IconCalendar,
|
||||
IconWings,
|
||||
IconSword,
|
||||
IconPin,
|
||||
} from "@/components/icons";
|
||||
|
||||
type MenuEntry = {
|
||||
@@ -95,6 +96,7 @@ export function Header({
|
||||
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
|
||||
{ href: "/icare", label: "Le Vol d'Icare", icon: <IconWings /> },
|
||||
{ href: "/char", label: "Le Jeu de l'Agora", icon: <IconSword /> },
|
||||
{ href: "/mur", label: "Le Mur de la Honte", icon: <IconPin /> },
|
||||
{ href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> },
|
||||
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
|
||||
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> },
|
||||
|
||||
@@ -197,6 +197,16 @@ export function IconSword({ className = base }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPin({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<rect x="5" y="6.5" width="14" height="14" rx="1.5" transform="rotate(-5 12 13.5)" strokeLinejoin="round" />
|
||||
<circle cx="12" cy="4.5" r="1.8" fill="currentColor" stroke="none" />
|
||||
<line x1="12" y1="6" x2="12" y2="8.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconOwl({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
|
||||
@@ -9,6 +9,7 @@ const PROTECTED_PATHS = [
|
||||
"/calendrier",
|
||||
"/icare",
|
||||
"/char",
|
||||
"/mur",
|
||||
];
|
||||
// /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
|
||||
|
||||
@@ -1019,3 +1019,115 @@ end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.admin_list_members() to authenticated;
|
||||
|
||||
-- 14. Le Mur de la Honte -------------------------------------------------------
|
||||
-- Une note anonyme par Citoyen par jour, max 10 notes visibles par jour
|
||||
-- (remise à zéro chaque jour — filtrage par date, pas de suppression), pour
|
||||
-- donner une raison de rouvrir l'app tous les jours de la semaine, pas
|
||||
-- seulement pour le classement. L'anonymat n'est qu'à moitié réel : les
|
||||
-- Archontes voient l'historique complet avec l'auteur de chaque note.
|
||||
|
||||
create table if not exists public.wall_notes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.profiles (id) on delete cascade,
|
||||
text text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.wall_notes enable row level security;
|
||||
revoke insert, update, delete on public.wall_notes from authenticated, anon;
|
||||
grant select on public.wall_notes to authenticated;
|
||||
|
||||
drop policy if exists "wall_notes readable by owner or judges" on public.wall_notes;
|
||||
create policy "wall_notes readable by owner or judges"
|
||||
on public.wall_notes for select to authenticated
|
||||
using (
|
||||
user_id = auth.uid()
|
||||
or 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.wall_notes;
|
||||
exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
-- Seul moyen pour un Citoyen de voir les notes des autres sans jamais
|
||||
-- exposer qui les a écrites côté client : la RLS ne peut pas masquer une
|
||||
-- colonne pour certaines lignes, donc cette RPC (SECURITY DEFINER) fait le
|
||||
-- filtre elle-même et ne renvoie jamais user_id.
|
||||
create or replace function public.wall_notes_today()
|
||||
returns table (id uuid, text text, created_at timestamptz)
|
||||
language sql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
select w.id, w.text, w.created_at
|
||||
from public.wall_notes w
|
||||
where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date
|
||||
order by w.created_at asc;
|
||||
$$;
|
||||
|
||||
grant execute on function public.wall_notes_today() to authenticated;
|
||||
|
||||
-- Seul point d'entrée pour publier une note. Pas de gel via tribunal_date
|
||||
-- (contrairement au Char) : contrairement à la soirée de clôture, c'est une
|
||||
-- mécanique de toute la semaine. Petite fenêtre de course possible si deux
|
||||
-- personnes postent la même seconde alors qu'il reste 1 place (double
|
||||
-- insertion à 11) — acceptée sciemment, comme d'autres arbitrages similaires
|
||||
-- déjà faits dans le projet pour un groupe de ~12 amis.
|
||||
create or replace function public.post_wall_note(p_text text)
|
||||
returns public.wall_notes
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_caller_role text;
|
||||
v_today date := (now() at time zone 'Europe/Paris')::date;
|
||||
v_already_posted boolean;
|
||||
v_count_today int;
|
||||
v_row public.wall_notes;
|
||||
begin
|
||||
if auth.uid() is null then
|
||||
raise exception 'authentication required';
|
||||
end if;
|
||||
|
||||
select role into v_caller_role from public.profiles where id = auth.uid();
|
||||
if v_caller_role = 'judge' then
|
||||
raise exception 'only citizens can post on the wall';
|
||||
end if;
|
||||
|
||||
if p_text is null or length(trim(p_text)) = 0 then
|
||||
raise exception 'note vide';
|
||||
end if;
|
||||
if length(p_text) > 200 then
|
||||
raise exception 'note trop longue';
|
||||
end if;
|
||||
|
||||
select exists (
|
||||
select 1 from public.wall_notes
|
||||
where user_id = auth.uid()
|
||||
and (created_at at time zone 'Europe/Paris')::date = v_today
|
||||
) into v_already_posted;
|
||||
if v_already_posted then
|
||||
raise exception 'already posted today';
|
||||
end if;
|
||||
|
||||
select count(*) into v_count_today
|
||||
from public.wall_notes
|
||||
where (created_at at time zone 'Europe/Paris')::date = v_today;
|
||||
if v_count_today >= 10 then
|
||||
raise exception 'wall full today';
|
||||
end if;
|
||||
|
||||
insert into public.wall_notes (user_id, text)
|
||||
values (auth.uid(), trim(p_text))
|
||||
returning * into v_row;
|
||||
|
||||
return v_row;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.post_wall_note(text) to authenticated;
|
||||
|
||||
Reference in New Issue
Block a user