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:
Valentin ROBIN
2026-07-11 16:14:10 +02:00
parent 87cd286e5a
commit abbd079cd2
27 changed files with 1528 additions and 354 deletions
+14 -35
View File
@@ -1,13 +1,24 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { Avatar } from "@/components/avatar";
import { LeaderboardView } from "./leaderboard-view";
export default async function LeaderboardPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: profiles, error } = await supabase
.from("profiles")
.select("id, pseudo, avatar_url, points")
.select("id, pseudo, avatar_url, points, previous_rank, role")
.order("points", { ascending: false });
const isJudge = profiles?.find((p) => p.id === user.id)?.role === "judge";
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<h1 className="mb-6 text-2xl font-semibold text-navy">Leaderboard</h1>
@@ -18,39 +29,7 @@ export default async function LeaderboardPage() {
</p>
)}
{!error && profiles?.length === 0 && (
<p className="text-sm text-ink/70">Personne n&apos;est encore inscrit.</p>
)}
{!error && profiles && profiles.length > 0 && (
<ol className="flex flex-col gap-2">
{profiles.map((profile, index) => {
const rank = index + 1;
const isFirst = rank === 1;
return (
<li
key={profile.id}
className={`flex items-center gap-4 rounded-lg border px-4 py-3 ${
isFirst
? "border-gold bg-gold/10"
: "border-navy/10 bg-white"
}`}
>
<span
className={`w-6 shrink-0 text-center font-semibold ${
isFirst ? "text-gold" : "text-navy/60"
}`}
>
{rank}
</span>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<span className="flex-1 truncate font-medium">{profile.pseudo}</span>
<span className="font-semibold text-navy">{profile.points}</span>
</li>
);
})}
</ol>
)}
{!error && <LeaderboardView initialProfiles={profiles ?? []} isJudge={isJudge} />}
</div>
);
}