"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar";
import { Podium } from "@/components/podium";
import { JudgePointControls } from "@/components/judge-point-controls";
import { EmptyState } from "@/components/empty-state";
import { computeRanks, computeProgress } from "@/lib/ranking";
type Profile = {
id: string;
pseudo: string;
avatar_url: string | null;
points: number;
previous_rank: number | null;
};
function ProgressBadge({ direction, amount }: { direction: string; amount: number }) {
if (direction === "up") {
return ▲{amount};
}
if (direction === "down") {
return ▼{amount};
}
if (direction === "same") {
return =;
}
return null;
}
export function LeaderboardView({
initialProfiles,
isJudge,
}: {
initialProfiles: Profile[];
isJudge: boolean;
}) {
const [profiles, setProfiles] = useState(initialProfiles);
const [flashingId, setFlashingId] = useState(null);
const pointsRef = useRef(new Map(initialProfiles.map((p) => [p.id, p.points])));
function applyOptimisticDelta(id: string, delta: number) {
setProfiles((current) =>
current.map((p) => (p.id === id ? { ...p, points: p.points + delta } : p)),
);
}
useEffect(() => {
const supabase = createClient();
let channel: ReturnType | null = null;
let cancelled = false;
waitForRealtimeAuth(supabase).then(() => {
if (cancelled) return;
channel = supabase
.channel("leaderboard-profiles")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "profiles" },
(payload) => {
if (payload.eventType === "DELETE") return;
const row = payload.new as Profile;
setProfiles((current) => {
const exists = current.some((p) => p.id === row.id);
return exists
? current.map((p) => (p.id === row.id ? { ...p, ...row } : p))
: [...current, row];
});
const previousPoints = pointsRef.current.get(row.id);
if (previousPoints !== undefined && previousPoints !== row.points) {
setFlashingId(row.id);
setTimeout(() => setFlashingId((current) => (current === row.id ? null : current)), 900);
}
pointsRef.current.set(row.id, row.points);
},
)
.subscribe();
});
return () => {
cancelled = true;
if (channel) supabase.removeChannel(channel);
};
}, []);
const ranked = useMemo(() => computeRanks(profiles), [profiles]);
// Tant que personne n'a de gloires, tout le monde est à égalité à 0 et
// partagerait le rang 1 — pas de podium tant qu'il n'y a rien à
// départager, sinon tout le groupe se retrouverait sur la 1ère marche.
const hasScores = (ranked[0]?.points ?? 0) > 0;
const podiumProfiles = hasScores ? ranked.filter((p) => p.rank <= 3) : [];
const rest = hasScores ? ranked.filter((p) => p.rank > 3) : ranked;
if (profiles.length === 0) {
return ;
}
return (
member.points}
valueLabel="gloires"
isJudge={isJudge}
onApplyDelta={applyOptimisticDelta}
/>
{/* Frise à méandre : marque la transition podium -> reste du
classement, seulement quand il y a bien un podium au-dessus. */}
{podiumProfiles.length > 0 && }
{rest.map((profile) => {
const progress = computeProgress(profile.rank, profile.previous_rank);
return (