Files
tribunal-app/src/app/urne/urne-view.tsx
T
Valentin ROBIN d02f1543e9
Build and deploy / deploy (push) Successful in 36s
Ajoute L'Urne de l'Agora
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.
2026-08-23 18:02:12 +02:00

176 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}