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
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export function JudgePointControls({
memberId,
onApplyDelta,
compact = false,
}: {
memberId: string;
onApplyDelta: (id: string, delta: number) => void;
compact?: boolean;
}) {
const [amount, setAmount] = useState("1");
const [reason, setReason] = useState("");
const [pending, setPending] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const parsedAmount = Math.max(1, Math.abs(Number.parseInt(amount, 10) || 1));
function handleCancel() {
setPending(0);
setReason("");
setError(null);
}
async function handleConfirm() {
if (pending === 0) return;
setSubmitting(true);
setError(null);
const supabase = createClient();
const { error: rpcError } = await supabase.rpc("award_points", {
p_target_id: memberId,
p_delta: pending,
p_reason: reason.trim() || null,
});
setSubmitting(false);
if (rpcError) {
setError("Échec, réessaie.");
return;
}
onApplyDelta(memberId, pending);
setPending(0);
setReason("");
}
return (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={() => setPending((p) => p - parsedAmount)}
className="h-7 w-7 shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5"
aria-label={`Retirer ${parsedAmount} points`}
>
</button>
<input
type="number"
min={1}
value={amount}
onChange={(event) => setAmount(event.target.value)}
className="h-7 w-12 rounded-md border border-navy/20 px-1 text-center text-xs"
/>
<button
type="button"
onClick={() => setPending((p) => p + parsedAmount)}
className="h-7 w-7 shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5"
aria-label={`Ajouter ${parsedAmount} points`}
>
+
</button>
{!compact && pending !== 0 && (
<input
type="text"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="motif (optionnel)"
className="h-7 w-28 rounded-md border border-navy/20 px-2 text-xs sm:w-36"
/>
)}
{pending !== 0 && (
<>
<span
className={`text-sm font-semibold ${pending > 0 ? "text-green-700" : "text-red-700"}`}
>
{pending > 0 ? `+${pending}` : pending}
</span>
<button
type="button"
onClick={handleConfirm}
disabled={submitting}
className="rounded-md bg-navy px-2 py-1 text-xs font-medium text-ivory hover:bg-navy/90 disabled:opacity-50"
>
{submitting ? "…" : "Confirmer"}
</button>
<button
type="button"
onClick={handleCancel}
disabled={submitting}
className="rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50"
>
Annuler
</button>
</>
)}
{error && <span className="text-xs text-red-700">{error}</span>}
</div>
);
}