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:
@@ -3,42 +3,57 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { slugify } from "@/lib/auth";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { AvatarPicker } from "@/components/avatar-picker";
|
||||
|
||||
function mapError(message: string): string {
|
||||
if (message.includes("duplicate key")) {
|
||||
return "Ce pseudo est déjà pris.";
|
||||
}
|
||||
if (message.includes("pseudo is locked")) {
|
||||
return "Ton pseudo est déjà figé.";
|
||||
}
|
||||
return "Une erreur est survenue, réessaie.";
|
||||
}
|
||||
|
||||
export function ProfileForm({
|
||||
userId,
|
||||
isJudge,
|
||||
initialPseudo,
|
||||
initialAvatarUrl,
|
||||
initialPseudoLocked,
|
||||
}: {
|
||||
userId: string;
|
||||
isJudge: boolean;
|
||||
initialPseudo: string;
|
||||
initialAvatarUrl: string | null;
|
||||
initialPseudoLocked: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pseudo, setPseudo] = useState(initialPseudo);
|
||||
const [pseudoLocked, setPseudoLocked] = useState(initialPseudoLocked);
|
||||
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const previewUrl = avatarFile ? URL.createObjectURL(avatarFile) : avatarUrl;
|
||||
const pseudoChanged = pseudo.trim() !== initialPseudo;
|
||||
const canEditPseudo = !pseudoLocked || isJudge;
|
||||
|
||||
function handleCropped(blob: Blob) {
|
||||
setAvatarBlob(blob);
|
||||
setAvatarPreview(URL.createObjectURL(blob));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
const slug = slugify(pseudo);
|
||||
if (!slug) {
|
||||
const trimmedPseudo = pseudo.trim();
|
||||
if (!trimmedPseudo) {
|
||||
setError("Choisis un pseudo valide.");
|
||||
return;
|
||||
}
|
||||
@@ -48,12 +63,11 @@ export function ProfileForm({
|
||||
|
||||
let nextAvatarUrl = avatarUrl;
|
||||
|
||||
if (avatarFile) {
|
||||
const ext = avatarFile.name.split(".").pop() ?? "jpg";
|
||||
const path = `${userId}.${ext}`;
|
||||
if (avatarBlob) {
|
||||
const path = `${userId}.webp`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("avatars")
|
||||
.upload(path, avatarFile, { upsert: true });
|
||||
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
|
||||
|
||||
if (uploadError) {
|
||||
setLoading(false);
|
||||
@@ -67,7 +81,7 @@ export function ProfileForm({
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ pseudo, slug, avatar_url: nextAvatarUrl })
|
||||
.update({ pseudo: trimmedPseudo, avatar_url: nextAvatarUrl })
|
||||
.eq("id", userId);
|
||||
|
||||
setLoading(false);
|
||||
@@ -78,24 +92,20 @@ export function ProfileForm({
|
||||
}
|
||||
|
||||
setAvatarUrl(nextAvatarUrl);
|
||||
setAvatarFile(null);
|
||||
setAvatarBlob(null);
|
||||
setAvatarPreview(null);
|
||||
setSuccess(true);
|
||||
if (pseudoChanged && !isJudge) {
|
||||
setPseudoLocked(true);
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Avatar pseudo={pseudo || "?"} avatarUrl={previewUrl ?? null} size="lg" />
|
||||
<label className="text-sm">
|
||||
<span className="sr-only">Changer la photo</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
|
||||
className="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-navy file:px-3 file:py-1.5 file:text-ivory"
|
||||
/>
|
||||
</label>
|
||||
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview ?? avatarUrl} size="lg" />
|
||||
<AvatarPicker label="Changer la photo" onCropped={handleCropped} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -106,10 +116,16 @@ export function ProfileForm({
|
||||
id="pseudo"
|
||||
type="text"
|
||||
required
|
||||
disabled={!canEditPseudo}
|
||||
value={pseudo}
|
||||
onChange={(event) => setPseudo(event.target.value)}
|
||||
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
|
||||
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold disabled:bg-navy/5 disabled:text-ink/50"
|
||||
/>
|
||||
{!canEditPseudo && (
|
||||
<p className="text-xs text-ink/60">
|
||||
Pseudo figé — contacte un juge pour le changer.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
Reference in New Issue
Block a user