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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user