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:
@@ -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'Urne de l'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'en savent pas plus.
|
||||
</p>
|
||||
</div>
|
||||
<UrneView
|
||||
isJudge={isJudge}
|
||||
currentUserId={user.id}
|
||||
citizens={citizens ?? []}
|
||||
initialVoteCounts={voteCounts}
|
||||
initialMyVoteToday={myVoteToday}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user