d02f1543e9
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.
176 lines
6.0 KiB
TypeScript
176 lines
6.0 KiB
TypeScript
"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'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>
|
||
);
|
||
}
|