V2 + V3 : rôles, points, journal, podium et recadrage photo
V2 — authentification et administration :
- Authentification par email + mot de passe (email confirmé, un compte
par email), abandon de l'email interne dérivé du pseudo.
- Rôles public/judge sur profiles, section Administration (juges
uniquement, vérifiée côté serveur) pour gérer les membres.
- Verrou de pseudo : modifiable une fois par son propriétaire puis figé,
contournable par un juge.
- RLS étendue par un trigger BEFORE UPDATE (enforce_profile_update) pour
verrouiller les colonnes sensibles (role, points, pseudo_locked, pseudo
figé) — la RLS seule ne peut pas exprimer une règle par colonne.
V3 — points, journal, podium, progression, photo :
- RPC award_points() (SECURITY DEFINER) : seul point d'écriture de la
colonne points, vérifie le rôle juge côté serveur, delta signé sans
plancher à 0, trace chaque opération dans points_log.
- Leaderboard temps réel (Supabase Realtime) avec podium top 3
(égalités gérées), flèches de progression (previous_rank), et
contrôles de points juges avec confirmation explicite (plus de
debounce auto) avant envoi.
- Page /journal ("le crieur") : fil live et public des attributions de
points.
- Recadrage photo carré + compression client (react-easy-crop + canvas)
au signup et sur le profil.
- Passe de polish visuel : cartes/boutons cohérents, lien actif dans la
nav, podium retravaillé.
schema.sql, README.md et CLAUDE.md mis à jour en conséquence (schéma
idempotent, instructions SMTP/rôles/migration, conventions RLS+trigger
documentées pour les futures colonnes sensibles).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { Podium } from "@/components/podium";
|
||||
import { JudgePointControls } from "@/components/judge-point-controls";
|
||||
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-green-700">▲{amount}</span>;
|
||||
}
|
||||
if (direction === "down") {
|
||||
return <span className="text-xs font-semibold text-red-700">▼{amount}</span>;
|
||||
}
|
||||
if (direction === "same") {
|
||||
return <span className="text-xs text-ink/40">=</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();
|
||||
const 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 () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ranked = useMemo(() => computeRanks(profiles), [profiles]);
|
||||
const rest = ranked.filter((p) => p.rank > 3);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
return <p className="text-sm text-ink/70">Personne n'est encore inscrit.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Podium
|
||||
profiles={ranked.filter((p) => p.rank <= 3)}
|
||||
isJudge={isJudge}
|
||||
onApplyDelta={applyOptimisticDelta}
|
||||
/>
|
||||
|
||||
<ol className="flex flex-col gap-2">
|
||||
{rest.map((profile) => {
|
||||
const progress = computeProgress(profile.rank, profile.previous_rank);
|
||||
return (
|
||||
<li
|
||||
key={profile.id}
|
||||
className={`flex flex-wrap items-center gap-3 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm transition-colors ${
|
||||
flashingId === profile.id ? "animate-points-flash" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 shrink-0 text-center font-semibold text-navy/60">
|
||||
{profile.rank}
|
||||
</span>
|
||||
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
|
||||
<span className="flex-1 truncate font-medium">{profile.pseudo}</span>
|
||||
<ProgressBadge direction={progress.direction} amount={progress.amount} />
|
||||
<span className="font-semibold text-navy">{profile.points}</span>
|
||||
{isJudge && (
|
||||
<JudgePointControls memberId={profile.id} onApplyDelta={applyOptimisticDelta} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user