358bed0c2e
Corrige deux incohérences trouvées en chemin : le compteur de passages dans le char en gold-bright sur marbre (même défaut de contraste déjà vu ailleurs), et le point du Crieur toujours rouge même pour un honneur positif. Réutilise les ornements existants mais jamais appelés (meander-divider entre le podium et le classement, DiamondDivider sur /profile avec une nouvelle convention de carte secondaire). Ajoute une ambiance discrète (fond qui respire très lentement, halo doré sur les cartes marbre, désactivés sous prefers-reduced-motion), étend les confettis et un nouveau son à Icare pour la parité avec la Corne d'Abondance, des loading.tsx sur les pages les plus consultées, et un petit ornement sur les états vides plutôt qu'un texte brut. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
"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 <span className="text-xs font-semibold text-olive">▲{amount}</span>;
|
|
}
|
|
if (direction === "down") {
|
|
return <span className="text-xs font-semibold text-oxblood">▼{amount}</span>;
|
|
}
|
|
if (direction === "same") {
|
|
return <span className="text-xs text-text-mut">=</span>;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function LeaderboardView({
|
|
initialProfiles,
|
|
isJudge,
|
|
}: {
|
|
initialProfiles: Profile[];
|
|
isJudge: boolean;
|
|
}) {
|
|
const [profiles, setProfiles] = useState<Profile[]>(initialProfiles);
|
|
const [flashingId, setFlashingId] = useState<string | null>(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<typeof supabase.channel> | 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 <EmptyState message="Personne n'est encore inscrit." />;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<Podium
|
|
profiles={podiumProfiles}
|
|
renderValue={(member) => 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 && <div className="meander-divider mb-4" />}
|
|
|
|
<ol className="flex flex-col gap-2">
|
|
{rest.map((profile) => {
|
|
const progress = computeProgress(profile.rank, profile.previous_rank);
|
|
return (
|
|
<li
|
|
key={profile.id}
|
|
className={`marble-surface flex flex-col gap-2 rounded-lg border border-gold/25 px-4 py-3 shadow-sm transition-colors sm:flex-row sm:items-center sm:gap-3 ${
|
|
flashingId === profile.id ? "animate-points-flash" : ""
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="w-6 shrink-0 text-center font-heading text-text-mut">
|
|
{profile.rank}
|
|
</span>
|
|
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
|
|
<span className="flex-1 truncate font-medium sm:flex-initial">{profile.pseudo}</span>
|
|
<ProgressBadge direction={progress.direction} amount={progress.amount} />
|
|
<span className="font-semibold text-text-marble sm:hidden">{profile.points}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 sm:ml-auto">
|
|
<span className="hidden font-semibold text-text-marble sm:inline">
|
|
{profile.points}
|
|
</span>
|
|
{isJudge && (
|
|
<JudgePointControls memberId={profile.id} onApplyDelta={applyOptimisticDelta} />
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
</div>
|
|
);
|
|
}
|