Ajoute Le Mur de la Honte
Build and deploy / deploy (push) Successful in 35s

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:
Valentin ROBIN
2026-08-23 17:14:12 +02:00
parent ec6b1920af
commit ab599d55c9
7 changed files with 486 additions and 2 deletions
+269
View File
@@ -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&apos;hui
</p>
{notes.length === 0 ? (
<p className="text-center text-sm text-marble/60">Le mur est encore vide aujourd&apos;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&apos;aujourd&apos;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&apos;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>
);
}
+80
View File
@@ -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&apos;efface.
</p>
</div>
<MurView
isJudge={isJudge}
initialNotes={todayNotes ?? []}
initialMyNoteToday={myNoteToday}
initialHistory={history}
/>
</div>
);
}
+2
View File
@@ -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 /> },
+10
View File
@@ -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>
+1
View File
@@ -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