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,105 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Cropper, { type Area } from "react-easy-crop";
|
||||
import { cropImageToBlob } from "@/lib/image";
|
||||
|
||||
export function AvatarPicker({
|
||||
label,
|
||||
onCropped,
|
||||
}: {
|
||||
label: string;
|
||||
onCropped: (blob: Blob) => void;
|
||||
}) {
|
||||
const [rawImageSrc, setRawImageSrc] = useState<string | null>(null);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setRawImageSrc(reader.result as string);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setError(null);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
async function handleValidate() {
|
||||
if (!rawImageSrc || !croppedAreaPixels) return;
|
||||
try {
|
||||
const blob = await cropImageToBlob(rawImageSrc, croppedAreaPixels);
|
||||
onCropped(blob);
|
||||
setRawImageSrc(null);
|
||||
} catch {
|
||||
setError("Impossible de traiter cette image, réessaie.");
|
||||
}
|
||||
}
|
||||
|
||||
if (rawImageSrc) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative h-64 w-64 max-w-full overflow-hidden rounded-md bg-navy/10">
|
||||
<Cropper
|
||||
image={rawImageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.05}
|
||||
value={zoom}
|
||||
onChange={(event) => setZoom(Number(event.target.value))}
|
||||
className="w-48"
|
||||
aria-label="Zoom"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRawImageSrc(null)}
|
||||
className="rounded-md border border-navy/20 px-3 py-1.5 text-sm text-navy hover:bg-navy/5"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleValidate}
|
||||
className="rounded-md bg-navy px-3 py-1.5 text-sm font-medium text-ivory hover:bg-navy/90"
|
||||
>
|
||||
Valider le cadrage
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-700">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="text-sm">
|
||||
<span className="sr-only">{label}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
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"
|
||||
/>
|
||||
{error && <p className="text-xs text-red-700">{error}</p>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user