"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 (
  1. {profile.rank} {profile.pseudo} {profile.points}
    {profile.points} {isJudge && ( )}
  2. ); })}
); }