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
+146
View File
@@ -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>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { AdminMembersTable } from "./admin-members-table";
import { ResetRoundButton } from "./reset-round-button";
export default async function AdminPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: callerProfile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
// Vérification côté serveur : un "public" ne doit jamais pouvoir
// atteindre cette page, même en tapant l'URL directement.
if (callerProfile?.role !== "judge") {
redirect("/leaderboard");
}
const { data: members } = await supabase
.from("profiles")
.select("id, pseudo, role, pseudo_locked, avatar_url")
.order("pseudo", { ascending: true });
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-navy">Administration</h1>
<ResetRoundButton />
</div>
<AdminMembersTable members={members ?? []} currentUserId={user.id} />
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export function ResetRoundButton() {
const [state, setState] = useState<"idle" | "loading" | "done">("idle");
async function handleClick() {
setState("loading");
const supabase = createClient();
const { error } = await supabase.rpc("reset_rank_reference");
setState(error ? "idle" : "done");
if (!error) {
setTimeout(() => setState("idle"), 2000);
}
}
return (
<button
type="button"
onClick={handleClick}
disabled={state === "loading"}
className="rounded-md border border-navy/20 bg-white px-3 py-1.5 text-sm font-medium text-navy shadow-sm transition hover:bg-navy/5 disabled:opacity-50"
>
{state === "loading" ? "…" : state === "done" ? "Repère mis à jour ✓" : "Nouveau round (réinitialiser le repère)"}
</button>
);
}
+13
View File
@@ -31,3 +31,16 @@ body {
outline: 2px solid var(--color-gold);
outline-offset: 2px;
}
@keyframes points-flash {
0% {
background-color: color-mix(in srgb, var(--color-gold) 55%, transparent);
}
100% {
background-color: transparent;
}
}
.animate-points-flash {
animation: points-flash 900ms ease-out;
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
type Entry = {
id: number;
target_id: string;
judge_id: string;
delta: number;
reason: string | null;
created_at: string;
};
function formatTime(iso: string): string {
return new Date(iso).toLocaleString("fr-FR", {
dateStyle: "short",
timeStyle: "short",
timeZone: "Europe/Paris",
});
}
export function JournalView({
initialEntries,
pseudoById,
}: {
initialEntries: Entry[];
pseudoById: Record<string, string>;
}) {
const [entries, setEntries] = useState<Entry[]>(initialEntries);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("journal-points-log")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "points_log" },
(payload) => {
const row = payload.new as Entry;
setEntries((current) => [row, ...current]);
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
if (entries.length === 0) {
return <p className="text-sm text-ink/70">Aucun événement pour l&apos;instant.</p>;
}
return (
<ol className="flex flex-col gap-2">
{entries.map((entry) => {
const judgePseudo = pseudoById[entry.judge_id] ?? "Un juge";
const targetPseudo = pseudoById[entry.target_id] ?? "un membre";
const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`;
return (
<li
key={entry.id}
className="rounded-lg border border-navy/10 bg-white px-4 py-3 text-sm shadow-sm"
>
<span className="font-medium text-navy">{judgePseudo}</span> a donné{" "}
<span className={entry.delta >= 0 ? "font-semibold text-green-700" : "font-semibold text-red-700"}>
{sign}
</span>{" "}
à <span className="font-medium text-navy">{targetPseudo}</span>
{entry.reason && <span className="text-ink/70"> {entry.reason}</span>}
<div className="mt-1 text-xs text-ink/50">{formatTime(entry.created_at)}</div>
</li>
);
})}
</ol>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { JournalView } from "./journal-view";
export default async function JournalPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: profiles } = await supabase.from("profiles").select("id, pseudo");
const pseudoById = Object.fromEntries((profiles ?? []).map((p) => [p.id, p.pseudo]));
const { data: entries } = await supabase
.from("points_log")
.select("id, target_id, judge_id, delta, reason, created_at")
.order("created_at", { ascending: false })
.limit(200);
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<h1 className="mb-2 text-2xl font-semibold text-navy">Le crieur</h1>
<p className="mb-6 text-sm text-ink/60">Journal public de toutes les attributions de points.</p>
<JournalView initialEntries={entries ?? []} pseudoById={pseudoById} />
</div>
);
}
+11 -1
View File
@@ -29,13 +29,23 @@ export default async function RootLayout({
data: { user },
} = await supabase.auth.getUser();
let isJudge = false;
if (user) {
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
isJudge = profile?.role === "judge";
}
return (
<html
lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-ivory text-ink">
{user && <NavBar />}
{user && <NavBar isJudge={isJudge} />}
<main className="flex flex-1 flex-col">{children}</main>
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar";
import { Podium } from "@/components/podium";
import { JudgePointControls } from "@/components/judge-point-controls";
import { computeRanks, computeProgress } from "@/lib/ranking";
type Profile = {
id: string;
pseudo: string;
avatar_url: string | null;
points: number;
previous_rank: number | null;
};
function ProgressBadge({ direction, amount }: { direction: string; amount: number }) {
if (direction === "up") {
return <span className="text-xs font-semibold text-green-700">{amount}</span>;
}
if (direction === "down") {
return <span className="text-xs font-semibold text-red-700">{amount}</span>;
}
if (direction === "same") {
return <span className="text-xs text-ink/40">=</span>;
}
return null;
}
export function LeaderboardView({
initialProfiles,
isJudge,
}: {
initialProfiles: Profile[];
isJudge: boolean;
}) {
const [profiles, setProfiles] = useState<Profile[]>(initialProfiles);
const [flashingId, setFlashingId] = useState<string | null>(null);
const pointsRef = useRef(new Map(initialProfiles.map((p) => [p.id, p.points])));
function applyOptimisticDelta(id: string, delta: number) {
setProfiles((current) =>
current.map((p) => (p.id === id ? { ...p, points: p.points + delta } : p)),
);
}
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("leaderboard-profiles")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "profiles" },
(payload) => {
if (payload.eventType === "DELETE") return;
const row = payload.new as Profile;
setProfiles((current) => {
const exists = current.some((p) => p.id === row.id);
return exists
? current.map((p) => (p.id === row.id ? { ...p, ...row } : p))
: [...current, row];
});
const previousPoints = pointsRef.current.get(row.id);
if (previousPoints !== undefined && previousPoints !== row.points) {
setFlashingId(row.id);
setTimeout(() => setFlashingId((current) => (current === row.id ? null : current)), 900);
}
pointsRef.current.set(row.id, row.points);
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
const ranked = useMemo(() => computeRanks(profiles), [profiles]);
const rest = ranked.filter((p) => p.rank > 3);
if (profiles.length === 0) {
return <p className="text-sm text-ink/70">Personne n&apos;est encore inscrit.</p>;
}
return (
<div>
<Podium
profiles={ranked.filter((p) => p.rank <= 3)}
isJudge={isJudge}
onApplyDelta={applyOptimisticDelta}
/>
<ol className="flex flex-col gap-2">
{rest.map((profile) => {
const progress = computeProgress(profile.rank, profile.previous_rank);
return (
<li
key={profile.id}
className={`flex flex-wrap items-center gap-3 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm transition-colors ${
flashingId === profile.id ? "animate-points-flash" : ""
}`}
>
<span className="w-6 shrink-0 text-center font-semibold text-navy/60">
{profile.rank}
</span>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<span className="flex-1 truncate font-medium">{profile.pseudo}</span>
<ProgressBadge direction={progress.direction} amount={progress.amount} />
<span className="font-semibold text-navy">{profile.points}</span>
{isJudge && (
<JudgePointControls memberId={profile.id} onApplyDelta={applyOptimisticDelta} />
)}
</li>
);
})}
</ol>
</div>
);
}
+14 -35
View File
@@ -1,13 +1,24 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { Avatar } from "@/components/avatar";
import { LeaderboardView } from "./leaderboard-view";
export default async function LeaderboardPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: profiles, error } = await supabase
.from("profiles")
.select("id, pseudo, avatar_url, points")
.select("id, pseudo, avatar_url, points, previous_rank, role")
.order("points", { ascending: false });
const isJudge = profiles?.find((p) => p.id === user.id)?.role === "judge";
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<h1 className="mb-6 text-2xl font-semibold text-navy">Leaderboard</h1>
@@ -18,39 +29,7 @@ export default async function LeaderboardPage() {
</p>
)}
{!error && profiles?.length === 0 && (
<p className="text-sm text-ink/70">Personne n&apos;est encore inscrit.</p>
)}
{!error && profiles && profiles.length > 0 && (
<ol className="flex flex-col gap-2">
{profiles.map((profile, index) => {
const rank = index + 1;
const isFirst = rank === 1;
return (
<li
key={profile.id}
className={`flex items-center gap-4 rounded-lg border px-4 py-3 ${
isFirst
? "border-gold bg-gold/10"
: "border-navy/10 bg-white"
}`}
>
<span
className={`w-6 shrink-0 text-center font-semibold ${
isFirst ? "text-gold" : "text-navy/60"
}`}
>
{rank}
</span>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<span className="flex-1 truncate font-medium">{profile.pseudo}</span>
<span className="font-semibold text-navy">{profile.points}</span>
</li>
);
})}
</ol>
)}
{!error && <LeaderboardView initialProfiles={profiles ?? []} isJudge={isJudge} />}
</div>
);
}
+19 -16
View File
@@ -1,14 +1,18 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client";
import { internalEmail } from "@/lib/auth";
function mapSignInError(message: string): string {
if (message.toLowerCase().includes("email not confirmed")) {
return "Confirme ton adresse email avant de te connecter (vérifie ta boîte mail).";
}
return "Email ou mot de passe incorrect.";
}
export default function LoginPage() {
const router = useRouter();
const [pseudo, setPseudo] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -20,40 +24,39 @@ export default function LoginPage() {
const supabase = createClient();
const { error: signInError } = await supabase.auth.signInWithPassword({
email: internalEmail(pseudo),
email,
password,
});
setLoading(false);
if (signInError) {
setError("Pseudo ou mot de passe incorrect.");
setError(mapSignInError(signInError.message));
return;
}
router.push("/leaderboard");
router.refresh();
window.location.href = "/leaderboard";
}
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<div className="w-full max-w-sm rounded-xl border border-navy/10 bg-white p-6 shadow-sm sm:p-8">
<h1 className="mb-6 text-center text-2xl font-semibold text-navy">
Connexion
</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium">
Pseudo
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<input
id="pseudo"
type="text"
autoComplete="username"
id="email"
type="email"
autoComplete="email"
required
value={pseudo}
onChange={(event) => setPseudo(event.target.value)}
value={email}
onChange={(event) => setEmail(event.target.value)}
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
/>
</div>
+11 -6
View File
@@ -14,18 +14,23 @@ export default async function ProfilePage() {
const { data: profile } = await supabase
.from("profiles")
.select("pseudo, avatar_url")
.select("pseudo, avatar_url, pseudo_locked, role")
.eq("id", user.id)
.single();
return (
<div className="mx-auto w-full max-w-sm px-4 py-8">
<h1 className="mb-6 text-2xl font-semibold text-navy">Mon profil</h1>
<ProfileForm
userId={user.id}
initialPseudo={profile?.pseudo ?? ""}
initialAvatarUrl={profile?.avatar_url ?? null}
/>
<div className="rounded-xl border border-navy/10 bg-white p-6 shadow-sm sm:p-8">
<p className="mb-4 text-sm text-ink/60">{user.email}</p>
<ProfileForm
userId={user.id}
isJudge={profile?.role === "judge"}
initialPseudo={profile?.pseudo ?? ""}
initialAvatarUrl={profile?.avatar_url ?? null}
initialPseudoLocked={profile?.pseudo_locked ?? false}
/>
</div>
</div>
);
}
+38 -22
View File
@@ -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 && (
+87 -42
View File
@@ -1,32 +1,43 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client";
import { internalEmail, 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") || message.includes("already registered")) {
return "Ce pseudo est déjà pris.";
function mapSignUpError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("already registered")) {
return "Un compte existe déjà avec cet email.";
}
if (lower.includes("rate limit")) {
return "Trop d'emails envoyés récemment (limite Supabase), réessaie dans quelques minutes.";
}
return "Une erreur est survenue, réessaie.";
}
export default function SignupPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [pseudo, setPseudo] = useState("");
const [password, setPassword] = useState("");
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 [loading, setLoading] = useState(false);
const [confirmationSent, setConfirmationSent] = useState(false);
function handleCropped(blob: Blob) {
setAvatarBlob(blob);
setAvatarPreview(URL.createObjectURL(blob));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
const slug = slugify(pseudo);
if (!slug) {
const trimmedPseudo = pseudo.trim();
if (!trimmedPseudo) {
setError("Choisis un pseudo valide.");
return;
}
@@ -38,59 +49,100 @@ export default function SignupPage() {
setLoading(true);
const supabase = createClient();
const { data: pseudoTaken, error: pseudoCheckError } = await supabase.rpc(
"is_pseudo_taken",
{ p_pseudo: trimmedPseudo },
);
if (!pseudoCheckError && pseudoTaken) {
setLoading(false);
setError("Ce pseudo est déjà pris.");
return;
}
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
email: internalEmail(pseudo),
email,
password,
options: { data: { pseudo: trimmedPseudo } },
});
if (signUpError || !signUpData.user) {
setLoading(false);
setError(mapError(signUpError?.message ?? ""));
setError(mapSignUpError(signUpError?.message ?? ""));
return;
}
const userId = signUpData.user.id;
let avatarUrl: string | null = null;
if (avatarFile) {
const ext = avatarFile.name.split(".").pop() ?? "jpg";
const path = `${userId}.${ext}`;
// Sans session active (confirmation email en attente), impossible
// d'uploader la photo : la policy Storage exige un utilisateur
// authentifié. On l'ajoutera depuis /profile après la 1ère connexion.
if (signUpData.session && avatarBlob) {
const path = `${signUpData.user.id}.webp`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, avatarFile, { upsert: true });
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
if (!uploadError) {
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
avatarUrl = publicUrlData.publicUrl;
await supabase
.from("profiles")
.update({ avatar_url: publicUrlData.publicUrl })
.eq("id", signUpData.user.id);
}
}
const { error: profileError } = await supabase.from("profiles").insert({
id: userId,
pseudo,
slug,
avatar_url: avatarUrl,
});
setLoading(false);
if (profileError) {
setError(mapError(profileError.message));
if (!signUpData.session) {
setConfirmationSent(true);
return;
}
router.push("/leaderboard");
router.refresh();
window.location.href = "/leaderboard";
}
if (confirmationSent) {
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm rounded-xl border border-navy/10 bg-white p-6 text-center shadow-sm sm:p-8">
<h1 className="mb-4 text-2xl font-semibold text-navy">Compte créé !</h1>
<p className="text-sm text-ink/80">
Vérifie ta boîte mail (et les spams) pour confirmer ton adresse, puis
connecte-toi. Tu pourras ajouter ta photo de profil ensuite depuis ton
profil.
</p>
<Link
href="/login"
className="mt-6 inline-block font-medium text-navy underline hover:text-gold"
>
Aller à la connexion
</Link>
</div>
</div>
);
}
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<div className="w-full max-w-sm rounded-xl border border-navy/10 bg-white p-6 shadow-sm sm:p-8">
<h1 className="mb-6 text-center text-2xl font-semibold text-navy">
Créer un compte
</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium">
Pseudo
@@ -122,17 +174,10 @@ export default function SignupPage() {
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="avatar" className="text-sm font-medium">
Photo de profil (optionnel)
</label>
<input
id="avatar"
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"
/>
<div className="flex flex-col items-center gap-2">
<span className="self-start text-sm font-medium">Photo de profil (optionnel)</span>
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview} size="lg" />
<AvatarPicker label="Photo de profil" onCropped={handleCropped} />
</div>
{error && (
+105
View File
@@ -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>
);
}
+5
View File
@@ -22,12 +22,17 @@ export function Avatar({
const sizeClass = SIZE_CLASSES[size];
if (avatarUrl) {
// blob:/data: = aperçu local avant upload (cropper). next/image essaie
// de les faire passer par l'optimiseur serveur, qui ne peut pas les
// résoudre (ils n'existent que dans la mémoire du navigateur).
const isLocalPreview = avatarUrl.startsWith("blob:") || avatarUrl.startsWith("data:");
return (
<Image
src={avatarUrl}
alt={pseudo}
width={96}
height={96}
unoptimized={isLocalPreview}
className={`${sizeClass} rounded-full object-cover`}
/>
);
+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>
);
}
+25 -11
View File
@@ -1,11 +1,27 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export function NavBar() {
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const pathname = usePathname();
const isActive = pathname.startsWith(href);
return (
<Link
href={href}
className={`rounded-md px-2 py-1 transition-colors ${
isActive ? "bg-ivory/10 text-gold" : "hover:text-gold"
}`}
>
{children}
</Link>
);
}
export function NavBar({ isJudge }: { isJudge: boolean }) {
const router = useRouter();
const [signingOut, setSigningOut] = useState(false);
@@ -18,21 +34,19 @@ export function NavBar() {
}
return (
<nav className="bg-navy text-ivory">
<nav className="sticky top-0 z-10 bg-navy text-ivory shadow-md">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<span className="font-semibold tracking-wide">Le Tribunal</span>
<div className="flex items-center gap-4 text-sm">
<Link href="/leaderboard" className="hover:text-gold">
Leaderboard
</Link>
<Link href="/profile" className="hover:text-gold">
Profil
</Link>
<div className="flex items-center gap-1 text-sm sm:gap-2">
<NavLink href="/leaderboard">Leaderboard</NavLink>
<NavLink href="/journal">Le crieur</NavLink>
<NavLink href="/profile">Profil</NavLink>
{isJudge && <NavLink href="/admin">Administration</NavLink>}
<button
type="button"
onClick={handleSignOut}
disabled={signingOut}
className="hover:text-gold disabled:opacity-50"
className="rounded-md px-2 py-1 transition-colors hover:text-gold disabled:opacity-50"
>
{signingOut ? "…" : "Déconnexion"}
</button>
+80
View File
@@ -0,0 +1,80 @@
import { Avatar } from "@/components/avatar";
import { JudgePointControls } from "@/components/judge-point-controls";
import type { RankedProfile } from "@/lib/ranking";
type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null; points: number };
const SLOT_STYLES: Record<
number,
{ pedestal: string; order: string; ring: string; bg: string }
> = {
1: { pedestal: "h-28 sm:h-36", order: "order-2", ring: "ring-gold", bg: "bg-gold/15" },
2: { pedestal: "h-20 sm:h-28", order: "order-1", ring: "ring-navy/25", bg: "bg-navy/10" },
3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-navy/15", bg: "bg-navy/5" },
};
export function Podium({
profiles,
isJudge = false,
onApplyDelta,
}: {
profiles: RankedProfile<PodiumProfile>[];
isJudge?: boolean;
onApplyDelta?: (id: string, delta: number) => void;
}) {
const byRank = new Map<number, RankedProfile<PodiumProfile>[]>();
for (const profile of profiles) {
const group = byRank.get(profile.rank) ?? [];
group.push(profile);
byRank.set(profile.rank, group);
}
const topRanks = [...byRank.keys()].sort((a, b) => a - b).slice(0, 3);
if (topRanks.length === 0) {
return null;
}
return (
<div className="mb-8 rounded-xl border border-navy/10 bg-white px-4 py-6 shadow-sm sm:px-8">
<div className="flex items-end justify-center gap-4 sm:gap-8">
{topRanks.map((rank) => {
const members = byRank.get(rank)!;
const style = SLOT_STYLES[rank] ?? SLOT_STYLES[3];
return (
<div key={rank} className={`flex flex-col items-center gap-3 ${style.order}`}>
<div className="flex flex-col items-center gap-3">
{members.map((member) => (
<div key={member.id} className="flex flex-col items-center gap-1">
{rank === 1 && <span className="text-lg leading-none">👑</span>}
<Avatar
pseudo={member.pseudo}
avatarUrl={member.avatar_url}
size={rank === 1 ? "lg" : "md"}
/>
<span className="max-w-[7rem] truncate text-sm font-medium text-ink">
{member.pseudo}
</span>
<span className="text-xs font-medium text-navy/60">{member.points} pts</span>
{isJudge && onApplyDelta && (
<JudgePointControls
memberId={member.id}
onApplyDelta={onApplyDelta}
compact
/>
)}
</div>
))}
</div>
<div
className={`flex w-20 sm:w-28 items-start justify-center rounded-t-md ${style.bg} pt-2 ring-1 sm:ring-2 ${style.pedestal} ${style.ring}`}
>
<span className="text-lg font-bold text-navy sm:text-2xl">{rank}</span>
</div>
</div>
);
})}
</div>
</div>
);
}
-19
View File
@@ -1,19 +0,0 @@
const COMBINING_DIACRITICS = /[̀-ͯ]/g;
export function slugify(pseudo: string): string {
return pseudo
.normalize("NFD")
.replace(COMBINING_DIACRITICS, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
// Supabase Auth rejette le TLD ".local" (email_address_invalid).
// ".test" est réservé par la RFC 2606 pour cet usage et passe la validation.
export function internalEmail(pseudo: string): string {
return `${slugify(pseudo)}@letribunal.test`;
}
+40
View File
@@ -0,0 +1,40 @@
export type PixelCrop = { x: number; y: number; width: number; height: number };
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.addEventListener("load", () => resolve(img));
img.addEventListener("error", () => reject(new Error("Image illisible")));
img.src = src;
});
}
// Découpe la zone sélectionnée, redimensionne (sans jamais agrandir) à
// maxSize et compresse en WebP côté client avant l'envoi vers le bucket.
export async function cropImageToBlob(
imageSrc: string,
crop: PixelCrop,
maxSize = 512,
quality = 0.9,
): Promise<Blob> {
const image = await loadImage(imageSrc);
const outputSize = Math.round(Math.min(maxSize, crop.width, crop.height)) || maxSize;
const canvas = document.createElement("canvas");
canvas.width = outputSize;
canvas.height = outputSize;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Canvas non supporté par ce navigateur");
}
ctx.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, outputSize, outputSize);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
"image/webp",
quality,
);
});
}
+29
View File
@@ -0,0 +1,29 @@
export type RankedProfile<T extends { points: number }> = T & { rank: number };
// Classement "compétition" : les ex-aequo partagent le même rang, et le
// rang suivant saute en conséquence (1, 2, 2, 4 — pas 1, 2, 2, 3).
export function computeRanks<T extends { points: number }>(profiles: T[]): RankedProfile<T>[] {
const sorted = [...profiles].sort((a, b) => b.points - a.points);
let lastPoints: number | null = null;
let lastRank = 0;
return sorted.map((profile, index) => {
if (lastPoints === null || profile.points !== lastPoints) {
lastRank = index + 1;
lastPoints = profile.points;
}
return { ...profile, rank: lastRank };
});
}
export type Progress = { direction: "up" | "down" | "same" | "none"; amount: number };
export function computeProgress(currentRank: number, previousRank: number | null): Progress {
if (previousRank == null) {
return { direction: "none", amount: 0 };
}
const amount = previousRank - currentRank;
if (amount > 0) return { direction: "up", amount };
if (amount < 0) return { direction: "down", amount: -amount };
return { direction: "same", amount: 0 };
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
const PROTECTED_PATHS = ["/leaderboard", "/profile"];
const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal"];
const AUTH_PATHS = ["/login", "/signup"];
export async function updateSession(request: NextRequest) {