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,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
|
||||
type Role = "public" | "judge";
|
||||
|
||||
type Member = {
|
||||
id: string;
|
||||
pseudo: string;
|
||||
role: string;
|
||||
pseudo_locked: boolean;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
|
||||
function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
|
||||
const [savedPseudo, setSavedPseudo] = useState(member.pseudo);
|
||||
const [savedRole, setSavedRole] = useState<Role>(member.role === "judge" ? "judge" : "public");
|
||||
const [pseudo, setPseudo] = useState(member.pseudo);
|
||||
const [role, setRole] = useState<Role>(member.role === "judge" ? "judge" : "public");
|
||||
const [pseudoLocked, setPseudoLocked] = useState(member.pseudo_locked);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const dirty = pseudo.trim() !== savedPseudo || role !== savedRole;
|
||||
|
||||
async function handleSave() {
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
const trimmed = pseudo.trim();
|
||||
if (!trimmed) {
|
||||
setError("Pseudo invalide.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const supabase = createClient();
|
||||
const { error: updateError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ pseudo: trimmed, role })
|
||||
.eq("id", member.id);
|
||||
setSaving(false);
|
||||
|
||||
if (updateError) {
|
||||
setError(
|
||||
updateError.message.includes("duplicate key")
|
||||
? "Ce pseudo est déjà pris."
|
||||
: "Une erreur est survenue.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSavedPseudo(trimmed);
|
||||
setSavedRole(role);
|
||||
setSuccess(true);
|
||||
}
|
||||
|
||||
async function handleUnlock() {
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
setSaving(true);
|
||||
const supabase = createClient();
|
||||
const { error: updateError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ pseudo_locked: false })
|
||||
.eq("id", member.id);
|
||||
setSaving(false);
|
||||
|
||||
if (updateError) {
|
||||
setError("Une erreur est survenue.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPseudoLocked(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:gap-4">
|
||||
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={pseudo}
|
||||
onChange={(event) => setPseudo(event.target.value)}
|
||||
className="flex-1 rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value as Role)}
|
||||
disabled={isSelf}
|
||||
className="rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold disabled:opacity-50"
|
||||
>
|
||||
<option value="public">public</option>
|
||||
<option value="judge">judge</option>
|
||||
</select>
|
||||
|
||||
{pseudoLocked ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUnlock}
|
||||
disabled={saving}
|
||||
className="whitespace-nowrap rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50"
|
||||
>
|
||||
Déverrouiller le pseudo
|
||||
</button>
|
||||
) : (
|
||||
<span className="whitespace-nowrap text-xs text-ink/50">pseudo libre</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="whitespace-nowrap rounded-md bg-navy px-3 py-1.5 text-sm font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-40"
|
||||
>
|
||||
{saving ? "…" : "Enregistrer"}
|
||||
</button>
|
||||
|
||||
{error && <p className="text-xs text-red-700">{error}</p>}
|
||||
{success && !dirty && <p className="text-xs text-green-700">OK</p>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminMembersTable({
|
||||
members,
|
||||
currentUserId,
|
||||
}: {
|
||||
members: Member[];
|
||||
currentUserId: string;
|
||||
}) {
|
||||
if (members.length === 0) {
|
||||
return <p className="text-sm text-ink/70">Aucun membre inscrit.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{members.map((member) => (
|
||||
<MemberRow key={member.id} member={member} isSelf={member.id === currentUserId} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user