Ajoute des justifications anonymes aux votes de L'Urne de l'Agora
Build and deploy / deploy (push) Successful in 35s

Un Citoyen peut désormais laisser un motif facultatif en déposant son
jeton (toujours deux étapes avant l'envoi). Le classement permet de
consulter les justifications reçues par une personne via une nouvelle RPC
urn_vote_reasons() — même principe que wall_notes_today : le contenu est
public, l'auteur ne l'est jamais, ce qui préserve la garantie de L'Urne
que personne (Archontes compris) ne voit qui a voté pour qui.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-23 22:48:25 +02:00
parent 0dfd3e7e90
commit e8a9a0ddb9
3 changed files with 199 additions and 38 deletions
+155 -29
View File
@@ -2,38 +2,147 @@
import { useCallback, useEffect, useState } from "react";
import { Avatar } from "@/components/avatar";
import { IconClose } from "@/components/icons";
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);
type VoteReason = { reason: string; created_at: string };
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 ? (
function CitizenVoteRow({
citizen,
onVote,
}: {
citizen: CitizenRow;
onVote: (reason: string) => void;
}) {
const [picking, setPicking] = useState(false);
const [reason, setReason] = useState("");
if (!picking) {
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>
<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)}
onClick={() => setPicking(true)}
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>
);
}
return (
<li className="flex flex-col gap-2 rounded-md bg-gold/5 px-2 py-2 text-sm">
<div className="flex items-center gap-2">
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="sm" />
<span className="min-w-0 flex-1 truncate text-text-marble">{citizen.pseudo}</span>
</div>
<input
type="text"
value={reason}
onChange={(event) => setReason(event.target.value)}
maxLength={200}
placeholder="Pourquoi ? (optionnel)"
className="rounded-md border border-gold/30 bg-white/50 px-2 py-1.5 text-xs text-text-marble outline-none focus:border-gold"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setPicking(false);
setReason("");
}}
className="rounded-md px-3 py-1.5 text-xs text-text-mut hover:bg-gold/10"
>
Annuler
</button>
<button
type="button"
onClick={() => onVote(reason.trim())}
className="rounded-md bg-oxblood px-3 py-1.5 text-xs font-medium text-marble"
>
Confirmer ?
</button>
</div>
</li>
);
}
function ReasonsModal({ citizen, onClose }: { citizen: CitizenRow; onClose: () => void }) {
const [reasons, setReasons] = useState<VoteReason[] | null>(null);
useEffect(() => {
let cancelled = false;
const supabase = createClient();
supabase.rpc("urn_vote_reasons", { p_target_id: citizen.id }).then(({ data }) => {
if (!cancelled) setReasons(data ?? []);
});
return () => {
cancelled = true;
};
}, [citizen.id]);
return (
<div
className="fixed inset-0 z-30 flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="marble-surface flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-2xl border border-gold/40 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-2 border-b border-gold/20 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="sm" />
<h2 className="truncate font-heading text-sm tracking-wide text-text-marble">
Pourquoi {citizen.pseudo} ?
</h2>
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 rounded-md p-1 text-text-mut transition hover:bg-gold/10 hover:text-text-marble"
aria-label="Fermer"
>
<IconClose className="h-5 w-5" />
</button>
</div>
<div className="overflow-y-auto px-4 py-4">
{reasons === null && <p className="text-sm text-text-mut">Chargement</p>}
{reasons !== null && reasons.length === 0 && (
<p className="text-sm text-text-mut">
Personne n&apos;a laissé de justification pour l&apos;instant voter sans motif reste
possible.
</p>
)}
{reasons !== null && reasons.length > 0 && (
<ul className="flex flex-col gap-2">
{reasons.map((r) => (
<li
key={r.created_at}
className="rounded-lg border border-gold/25 px-3 py-2 font-serif text-sm text-text-marble italic"
>
« {r.reason} »
</li>
))}
</ul>
)}
<p className="mt-4 text-center text-xs text-text-mut">
Anonyme même les Archontes ne savent pas qui a écrit quoi.
</p>
</div>
</div>
</div>
);
}
export function UrneView({
isJudge,
currentUserId,
@@ -50,6 +159,7 @@ export function UrneView({
const [voteCounts, setVoteCounts] = useState(initialVoteCounts);
const [myVoteToday, setMyVoteToday] = useState(initialMyVoteToday);
const [error, setError] = useState<string | null>(null);
const [selectedTarget, setSelectedTarget] = useState<CitizenRow | null>(null);
const refetch = useCallback(async () => {
const supabase = createClient();
@@ -97,10 +207,13 @@ export function UrneView({
};
}, [refetch]);
async function handleVote(targetId: string) {
async function handleVote(targetId: string, reason: string) {
setError(null);
const supabase = createClient();
const { error: rpcError } = await supabase.rpc("cast_urn_vote", { p_target_id: targetId });
const { error: rpcError } = await supabase.rpc("cast_urn_vote", {
p_target_id: targetId,
p_reason: reason || null,
});
if (rpcError) {
setError(
@@ -126,18 +239,25 @@ export function UrneView({
<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>
<p className="mb-3 text-center text-xs text-text-mut">Touche un nom pour voir pourquoi on a voté pour lui.</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 key={citizen.id} className="min-w-0">
<button
type="button"
onClick={() => setSelectedTarget(citizen)}
className="flex w-full min-w-0 items-center gap-1.5 rounded-md py-0.5 text-left text-xs transition hover:bg-gold/10"
>
<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>
</button>
</li>
);
})}
@@ -164,13 +284,19 @@ export function UrneView({
)}
<ul className="flex flex-col divide-y divide-gold/10">
{otherCitizens.map((citizen) => (
<CitizenVoteRow key={citizen.id} citizen={citizen} onVote={() => handleVote(citizen.id)} />
<CitizenVoteRow
key={citizen.id}
citizen={citizen}
onVote={(reason) => handleVote(citizen.id, reason)}
/>
))}
</ul>
</>
)}
</div>
)}
{selectedTarget && <ReasonsModal citizen={selectedTarget} onClose={() => setSelectedTarget(null)} />}
</div>
);
}