Ajoute des justifications anonymes aux votes de L'Urne de l'Agora
Build and deploy / deploy (push) Successful in 35s
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:
+155
-29
@@ -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'a laissé de justification pour l'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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user