Ajoute L'Urne de l'Agora
Build and deploy / deploy (push) Successful in 36s

Nouvelle page /urne : vote quotidien inspiré du vote à l'urne de
l'Athènes antique. Chaque Citoyen dépose un jeton par jour sur une
autre personne (jamais lui-même), avec confirmation à deux temps
avant l'envoi — le vote est définitif pour la journée, aucune RPC de
modification. Les jetons s'accumulent sur toute la semaine dans un
classement public et cumulatif, identique pour tout le monde y
compris les Archontes : contraste volontaire avec Le Mur de la Honte,
ici personne (Archontes compris) ne voit qui a voté pour qui, seul le
total par personne est public.

Nouvelle table urn_votes (append-only, comme points_log) + deux RPC :
urn_vote_counts() agrège les votes sans jamais exposer une ligne
individuelle, cast_urn_vote() applique les règles (pas de vote pour
un juge ni pour soi-même, un vote par jour). Les Archontes ne votent
pas et ne peuvent pas recevoir de jetons.
This commit is contained in:
Valentin ROBIN
2026-08-23 18:02:12 +02:00
parent ab599d55c9
commit d02f1543e9
7 changed files with 391 additions and 2 deletions
+11 -2
View File
@@ -6,7 +6,7 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
## 1. État actuel ## 1. État actuel
V1 à V9 sont livrées : V1 à V10 sont livrées :
- **V1** : comptes, profils, leaderboard statique. - **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. - **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. - **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.
@@ -16,6 +16,7 @@ V1 à V9 sont livrées :
- **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. - **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). - **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. - **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.
- **V10** : **L'Urne de l'Agora** (`/urne`) — vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque Citoyen dépose un jeton par jour sur une personne de son choix (jamais lui-même), confirmation à deux temps avant l'envoi (vote définitif pour la journée, aucune RPC de modification). Contrairement au Mur de la Honte, les jetons s'accumulent sur toute la semaine dans un classement public et cumulatif — **identique pour tout le monde, Archontes compris** : contraste volontaire avec le Mur, ici les Archontes n'ont aucune vision privilégiée sur qui a voté pour qui, seul le total par personne est public. Les Archontes ne votent pas et ne peuvent pas recevoir de jetons.
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran. Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran.
@@ -106,6 +107,8 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
- `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. - `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). - `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). - `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).
- `urn_vote_counts()` — authenticated, `language sql`, renvoie `(target_id, votes)` agrégé sur tout l'historique (pas de filtre de date, contrairement à `wall_notes_today`) — seul moyen de calculer un total public sans jamais exposer une ligne individuelle (qui a voté pour qui).
- `cast_urn_vote(p_target_id)` — authenticated, rejette les juges (ni votants ni cibles), rejette le vote pour soi-même, vérifie que l'appelant n'a pas déjà voté aujourd'hui (heure de Paris) — vote définitif, aucune RPC de modification/suppression.
### `public.wall_notes` (Le Mur de la Honte, V9) ### `public.wall_notes` (Le Mur de la Honte, V9)
@@ -113,6 +116,11 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
- 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()`. - 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. - Pas de gel via `settings.tribunal_date` : contrairement au Char, c'est une mécanique de toute la semaine.
### `public.urn_votes` (L'Urne de l'Agora, V10)
- `id, voter_id, target_id, created_at` — journal append-only (comme `points_log`/`chariot_entries`) d'un vote par ligne, jamais un upsert : les jetons s'accumulent d'un jour sur l'autre, contrairement à `wall_notes` qui ne montre que le jour courant.
- RLS `select` : **pas d'exception juge** (contrairement à `chariot_submissions`/`wall_notes`) — le votant ne voit que sa propre ligne (pour savoir « j'ai déjà voté aujourd'hui »), personne d'autre, Archontes compris, ne peut voir qui a voté pour qui ; seul `urn_vote_counts()` expose un total agrégé, identique pour tout le monde.
--- ---
## 4. Authentification ## 4. Authentification
@@ -133,12 +141,13 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
| `/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` | « 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 | | `/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 | | `/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 |
| `/urne` | « L'Urne de l'Agora » — un jeton par Citoyen par jour sur une autre personne, classement cumulatif public identique pour tous, Archontes compris (aucune vision privilégiée ici) |
| `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges | | `/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 | | `/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) | | `/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 | | `/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), `/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). `/leaderboard`, `/profile`, `/admin`, `/journal`, `/calendrier`, `/icare`, `/char` (et donc `/char/questions`, couverte par le même préfixe), `/mur`, `/urne` 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. 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.
+80
View File
@@ -0,0 +1,80 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { UrneView } from "./urne-view";
export type CitizenRow = {
id: string;
pseudo: string;
avatar_url: string | null;
};
export type MyVoteRow = {
target_id: string;
created_at: string;
};
function isToday(iso: string): boolean {
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
const voteDate = new Date(iso).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
return voteDate === today;
}
export default async function UrnePage() {
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: citizens }, { data: counts }] = await Promise.all([
supabase
.from("profiles")
.select("id, pseudo, avatar_url")
.eq("role", "public")
.order("pseudo", { ascending: true }),
supabase.rpc("urn_vote_counts"),
]);
const voteCounts: Record<string, number> = {};
for (const row of counts ?? []) {
voteCounts[row.target_id] = row.votes;
}
let myVoteToday: MyVoteRow | null = null;
if (!isJudge) {
const { data: myVotes } = await supabase
.from("urn_votes")
.select("target_id, created_at")
.eq("voter_id", user.id)
.order("created_at", { ascending: false });
myVoteToday = (myVotes ?? []).find((v) => isToday(v.created_at)) ?? null;
}
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">L&apos;Urne de l&apos;Agora</h1>
<p className="font-serif text-sm text-marble/60 italic">
Un jeton par jour, un classement pour tous même les Archontes n&apos;en savent pas plus.
</p>
</div>
<UrneView
isJudge={isJudge}
currentUserId={user.id}
citizens={citizens ?? []}
initialVoteCounts={voteCounts}
initialMyVoteToday={myVoteToday}
/>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Avatar } from "@/components/avatar";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import type { CitizenRow, MyVoteRow } from "./page";
function CitizenVoteRow({ citizen, onVote }: { citizen: CitizenRow; onVote: () => void }) {
const [confirming, setConfirming] = useState(false);
return (
<li className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm">
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="sm" />
<span className="min-w-0 flex-1 truncate text-text-marble">{citizen.pseudo}</span>
{confirming ? (
<button
type="button"
onClick={onVote}
className="shrink-0 rounded-md bg-oxblood px-3 py-1.5 text-xs font-medium text-marble"
>
Confirmer ?
</button>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
onBlur={() => setConfirming(false)}
className="shrink-0 rounded-md border border-gold/40 px-3 py-1.5 text-xs font-medium text-text-marble hover:bg-gold/10"
>
Déposer un jeton
</button>
)}
</li>
);
}
export function UrneView({
isJudge,
currentUserId,
citizens,
initialVoteCounts,
initialMyVoteToday,
}: {
isJudge: boolean;
currentUserId: string;
citizens: CitizenRow[];
initialVoteCounts: Record<string, number>;
initialMyVoteToday: MyVoteRow | null;
}) {
const [voteCounts, setVoteCounts] = useState(initialVoteCounts);
const [myVoteToday, setMyVoteToday] = useState(initialMyVoteToday);
const [error, setError] = useState<string | null>(null);
const refetch = useCallback(async () => {
const supabase = createClient();
const [{ data: counts }, { data: myVotes }] = await Promise.all([
supabase.rpc("urn_vote_counts"),
isJudge
? Promise.resolve({ data: null })
: supabase
.from("urn_votes")
.select("target_id, created_at")
.eq("voter_id", currentUserId)
.order("created_at", { ascending: false }),
]);
if (counts) {
const next: Record<string, number> = {};
for (const row of counts) next[row.target_id] = row.votes;
setVoteCounts(next);
}
if (myVotes) {
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
setMyVoteToday(
myVotes.find(
(v) => new Date(v.created_at).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }) === today,
) ?? null,
);
}
}, [isJudge, currentUserId]);
useEffect(() => {
const supabase = createClient();
let channel: ReturnType<typeof supabase.channel> | null = null;
let cancelled = false;
waitForRealtimeAuth(supabase).then(() => {
if (cancelled) return;
channel = supabase
.channel("urn-votes-changes")
.on("postgres_changes", { event: "*", schema: "public", table: "urn_votes" }, refetch)
.subscribe();
});
return () => {
cancelled = true;
if (channel) supabase.removeChannel(channel);
};
}, [refetch]);
async function handleVote(targetId: string) {
setError(null);
const supabase = createClient();
const { error: rpcError } = await supabase.rpc("cast_urn_vote", { p_target_id: targetId });
if (rpcError) {
setError(
rpcError.message.includes("already voted")
? "Tu as déjà voté aujourd'hui."
: "Une erreur est survenue, réessaie.",
);
refetch();
return;
}
refetch();
}
const sortedCitizens = [...citizens].sort((a, b) => {
const diff = (voteCounts[b.id] ?? 0) - (voteCounts[a.id] ?? 0);
return diff !== 0 ? diff : a.pseudo.localeCompare(b.pseudo);
});
const myVoteTarget = myVoteToday ? citizens.find((c) => c.id === myVoteToday.target_id) : null;
const otherCitizens = citizens.filter((c) => c.id !== currentUserId);
return (
<div className="flex flex-col gap-6">
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-lg">
<p className="mb-2 text-center font-heading text-sm tracking-wide text-text-mut uppercase">Classement</p>
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
{sortedCitizens.map((citizen) => {
const count = voteCounts[citizen.id] ?? 0;
return (
<li key={citizen.id} className="flex min-w-0 items-center gap-1.5 text-xs">
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="xs" />
<span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}>
{citizen.pseudo}
</span>
<span className="ml-auto shrink-0 font-heading text-gold-bright">
{count > 0 ? `×${count}` : "—"}
</span>
</li>
);
})}
</ul>
</div>
{!isJudge && (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
{myVoteToday ? (
<div>
<p className="text-sm text-olive">
Tu as voté pour {myVoteTarget?.pseudo ?? "quelqu'un"} aujourd&apos;hui.
</p>
<p className="mt-2 text-xs text-text-mut">Reviens demain pour déposer un nouveau jeton.</p>
</div>
) : (
<>
<p className="mb-2 font-heading text-sm tracking-wide text-text-mut uppercase">Déposer un jeton</p>
{error && (
<p role="alert" className="mb-2 text-sm text-oxblood">
{error}
</p>
)}
<ul className="flex flex-col divide-y divide-gold/10">
{otherCitizens.map((citizen) => (
<CitizenVoteRow key={citizen.id} citizen={citizen} onVote={() => handleVote(citizen.id)} />
))}
</ul>
</>
)}
</div>
)}
</div>
);
}
+2
View File
@@ -18,6 +18,7 @@ import {
IconWings, IconWings,
IconSword, IconSword,
IconPin, IconPin,
IconVote,
} from "@/components/icons"; } from "@/components/icons";
type MenuEntry = { type MenuEntry = {
@@ -97,6 +98,7 @@ export function Header({
{ href: "/icare", label: "Le Vol d'Icare", icon: <IconWings /> }, { href: "/icare", label: "Le Vol d'Icare", icon: <IconWings /> },
{ href: "/char", label: "Le Jeu de l'Agora", icon: <IconSword /> }, { href: "/char", label: "Le Jeu de l'Agora", icon: <IconSword /> },
{ href: "/mur", label: "Le Mur de la Honte", icon: <IconPin /> }, { href: "/mur", label: "Le Mur de la Honte", icon: <IconPin /> },
{ href: "/urne", label: "L'Urne de l'Agora", icon: <IconVote /> },
{ href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> }, { href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> },
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> }, { href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> }, { href: "/profile", label: "Mon profil", icon: <IconPerson /> },
+13
View File
@@ -197,6 +197,19 @@ export function IconSword({ className = base }: IconProps) {
); );
} }
export function IconVote({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<path
d="M8 4h8M9 4v3.5L6.5 12a4 4 0 0 0-.5 2v4a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-4a4 4 0 0 0-.5-2L15 7.5V4"
strokeLinejoin="round"
strokeLinecap="round"
/>
<path d="M9 14h6" strokeLinecap="round" />
</svg>
);
}
export function IconPin({ className = base }: IconProps) { export function IconPin({ className = base }: IconProps) {
return ( return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden> <svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
+1
View File
@@ -10,6 +10,7 @@ const PROTECTED_PATHS = [
"/icare", "/icare",
"/char", "/char",
"/mur", "/mur",
"/urne",
]; ];
// /signup n'est PAS dans AUTH_PATHS : un compte fraîchement invité a déjà une // /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 // session (établie par le lien d'invitation) mais doit pouvoir rester sur
+109
View File
@@ -1131,3 +1131,112 @@ end;
$$; $$;
grant execute on function public.post_wall_note(text) to authenticated; grant execute on function public.post_wall_note(text) to authenticated;
-- 15. L'Urne de l'Agora -------------------------------------------------------
-- Vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque jour
-- chaque Citoyen dépose un jeton sur une personne de son choix. Contrairement
-- au Mur de la Honte, les jetons s'accumulent sur toute la semaine dans un
-- classement public et cumulatif — identique pour tout le monde, Archontes
-- compris : contrairement à chariot_submissions/wall_notes, les Archontes
-- n'ont ici AUCUNE vision privilégiée sur qui a voté pour qui, seul le total
-- par personne est public. Les Archontes ne peuvent ni voter ni recevoir de
-- jetons.
create table if not exists public.urn_votes (
id bigint generated always as identity primary key,
voter_id uuid not null references public.profiles (id) on delete cascade,
target_id uuid not null references public.profiles (id) on delete cascade,
created_at timestamptz not null default now()
);
alter table public.urn_votes enable row level security;
revoke insert, update, delete on public.urn_votes from authenticated, anon;
grant select on public.urn_votes to authenticated;
-- Pas d'exception juge ici (contrairement à chariot_submissions/wall_notes) :
-- même les Archontes ne voient que leur propre ligne (pour savoir "j'ai déjà
-- voté aujourd'hui") — qui a voté pour qui ne regarde personne d'autre.
drop policy if exists "urn_votes readable by voter only" on public.urn_votes;
create policy "urn_votes readable by voter only"
on public.urn_votes for select to authenticated
using (voter_id = auth.uid());
do $$
begin
alter publication supabase_realtime add table public.urn_votes;
exception
when duplicate_object then null;
end $$;
-- Seul moyen de calculer un total public par personne sans jamais exposer
-- une ligne individuelle (qui a voté pour qui) à qui que ce soit. Agrégé sur
-- tout l'historique (pas de filtre de date, contrairement à wall_notes_today
-- qui ne montre que le jour courant) : les jetons s'accumulent toute la
-- semaine.
create or replace function public.urn_vote_counts()
returns table (target_id uuid, votes bigint)
language sql
security definer
set search_path = public
as $$
select v.target_id, count(*) as votes
from public.urn_votes v
group by v.target_id;
$$;
grant execute on function public.urn_vote_counts() to authenticated;
-- Seul point d'entrée pour voter. Un vote par Citoyen par jour, définitif
-- (aucune RPC de modification/suppression).
create or replace function public.cast_urn_vote(p_target_id uuid)
returns public.urn_votes
language plpgsql
security definer
set search_path = public
as $$
declare
v_caller_role text;
v_target_role text;
v_today date := (now() at time zone 'Europe/Paris')::date;
v_already_voted boolean;
v_row public.urn_votes;
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 vote';
end if;
if p_target_id = auth.uid() then
raise exception 'cannot vote for yourself';
end if;
select role into v_target_role from public.profiles where id = p_target_id;
if v_target_role is null then
raise exception 'target member not found';
end if;
if v_target_role = 'judge' then
raise exception 'cannot vote for a judge';
end if;
select exists (
select 1 from public.urn_votes
where voter_id = auth.uid()
and (created_at at time zone 'Europe/Paris')::date = v_today
) into v_already_voted;
if v_already_voted then
raise exception 'already voted today';
end if;
insert into public.urn_votes (voter_id, target_id)
values (auth.uid(), p_target_id)
returning * into v_row;
return v_row;
end;
$$;
grant execute on function public.cast_urn_vote(uuid) to authenticated;