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