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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user