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
+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,
IconSword,
IconPin,
IconVote,
} from "@/components/icons";
type MenuEntry = {
@@ -97,6 +98,7 @@ export function Header({
{ 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: "/urne", label: "L'Urne de l'Agora", icon: <IconVote /> },
{ href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> },
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
{ 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) {
return (
<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",
"/char",
"/mur",
"/urne",
];
// /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