Scaffold V1: auth, profils et leaderboard

Next.js 16 + Tailwind + Supabase (Auth, Postgres, Storage). Connexion
par pseudo/mot de passe via email interne dérivé, RLS avec points
protégés au niveau colonne, pages login/signup/leaderboard/profile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-07-11 14:52:18 +02:00
parent b216d59b94
commit 87cd286e5a
31 changed files with 8022 additions and 19 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+33
View File
@@ -0,0 +1,33 @@
@import "tailwindcss";
:root {
--color-navy: #0f2748;
--color-gold: #c9a227;
--color-ivory: #f4ecd8;
--color-ink: #2a2116;
--background: var(--color-ivory);
--foreground: var(--color-ink);
}
@theme inline {
--color-navy: var(--color-navy);
--color-gold: var(--color-gold);
--color-ivory: var(--color-ivory);
--color-ink: var(--color-ink);
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}
*:focus-visible {
outline: 2px solid var(--color-gold);
outline-offset: 2px;
}
+43
View File
@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { createClient } from "@/lib/supabase/server";
import { NavBar } from "@/components/nav-bar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Le Tribunal",
description: "Comptes et leaderboard pour le jeu du Tribunal",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
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 />}
<main className="flex flex-1 flex-col">{children}</main>
</body>
</html>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { createClient } from "@/lib/supabase/server";
import { Avatar } from "@/components/avatar";
export default async function LeaderboardPage() {
const supabase = await createClient();
const { data: profiles, error } = await supabase
.from("profiles")
.select("id, pseudo, avatar_url, points")
.order("points", { ascending: false });
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>
{error && (
<p role="alert" className="text-sm text-red-700">
Impossible de charger le leaderboard pour le moment.
</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>
)}
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
"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";
export default function LoginPage() {
const router = useRouter();
const [pseudo, setPseudo] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setLoading(true);
const supabase = createClient();
const { error: signInError } = await supabase.auth.signInWithPassword({
email: internalEmail(pseudo),
password,
});
setLoading(false);
if (signInError) {
setError("Pseudo ou mot de passe incorrect.");
return;
}
router.push("/leaderboard");
router.refresh();
}
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<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>
<input
id="pseudo"
type="text"
autoComplete="username"
required
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"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm font-medium">
Mot de passe
</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="mt-2 rounded-md bg-navy px-4 py-2 font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-50"
>
{loading ? "Connexion…" : "Se connecter"}
</button>
</form>
<p className="mt-6 text-center text-sm">
Pas encore de compte ?{" "}
<Link href="/signup" className="font-medium text-navy underline hover:text-gold">
Créer un compte
</Link>
</p>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export default async function Home() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
redirect(user ? "/leaderboard" : "/login");
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { ProfileForm } from "./profile-form";
export default async function ProfilePage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: profile } = await supabase
.from("profiles")
.select("pseudo, avatar_url")
.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>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
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";
function mapError(message: string): string {
if (message.includes("duplicate key")) {
return "Ce pseudo est déjà pris.";
}
return "Une erreur est survenue, réessaie.";
}
export function ProfileForm({
userId,
initialPseudo,
initialAvatarUrl,
}: {
userId: string;
initialPseudo: string;
initialAvatarUrl: string | null;
}) {
const router = useRouter();
const [pseudo, setPseudo] = useState(initialPseudo);
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl);
const [avatarFile, setAvatarFile] = useState<File | 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;
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setSuccess(false);
const slug = slugify(pseudo);
if (!slug) {
setError("Choisis un pseudo valide.");
return;
}
setLoading(true);
const supabase = createClient();
let nextAvatarUrl = avatarUrl;
if (avatarFile) {
const ext = avatarFile.name.split(".").pop() ?? "jpg";
const path = `${userId}.${ext}`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, avatarFile, { upsert: true });
if (uploadError) {
setLoading(false);
setError("Impossible d'envoyer la photo, réessaie.");
return;
}
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
nextAvatarUrl = `${publicUrlData.publicUrl}?t=${Date.now()}`;
}
const { error: updateError } = await supabase
.from("profiles")
.update({ pseudo, slug, avatar_url: nextAvatarUrl })
.eq("id", userId);
setLoading(false);
if (updateError) {
setError(mapError(updateError.message));
return;
}
setAvatarUrl(nextAvatarUrl);
setAvatarFile(null);
setSuccess(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>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium">
Pseudo
</label>
<input
id="pseudo"
type="text"
required
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"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
{success && <p className="text-sm text-green-700">Profil mis à jour.</p>}
<button
type="submit"
disabled={loading}
className="mt-2 rounded-md bg-navy px-4 py-2 font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-50"
>
{loading ? "Enregistrement…" : "Enregistrer"}
</button>
</form>
);
}
+162
View File
@@ -0,0 +1,162 @@
"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";
function mapError(message: string): string {
if (message.includes("duplicate key") || message.includes("already registered")) {
return "Ce pseudo est déjà pris.";
}
return "Une erreur est survenue, réessaie.";
}
export default function SignupPage() {
const router = useRouter();
const [pseudo, setPseudo] = useState("");
const [password, setPassword] = useState("");
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
const slug = slugify(pseudo);
if (!slug) {
setError("Choisis un pseudo valide.");
return;
}
if (password.length < 6) {
setError("Le mot de passe doit faire au moins 6 caractères.");
return;
}
setLoading(true);
const supabase = createClient();
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
email: internalEmail(pseudo),
password,
});
if (signUpError || !signUpData.user) {
setLoading(false);
setError(mapError(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}`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, avatarFile, { upsert: true });
if (!uploadError) {
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
avatarUrl = publicUrlData.publicUrl;
}
}
const { error: profileError } = await supabase.from("profiles").insert({
id: userId,
pseudo,
slug,
avatar_url: avatarUrl,
});
setLoading(false);
if (profileError) {
setError(mapError(profileError.message));
return;
}
router.push("/leaderboard");
router.refresh();
}
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<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="pseudo" className="text-sm font-medium">
Pseudo
</label>
<input
id="pseudo"
type="text"
autoComplete="username"
required
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"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm font-medium">
Mot de passe
</label>
<input
id="password"
type="password"
autoComplete="new-password"
required
minLength={6}
value={password}
onChange={(event) => setPassword(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="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>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="mt-2 rounded-md bg-navy px-4 py-2 font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-50"
>
{loading ? "Création…" : "Créer mon compte"}
</button>
</form>
<p className="mt-6 text-center text-sm">
Déjà un compte ?{" "}
<Link href="/login" className="font-medium text-navy underline hover:text-gold">
Se connecter
</Link>
</p>
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import Image from "next/image";
function initials(pseudo: string): string {
return pseudo.trim().slice(0, 2).toUpperCase();
}
const SIZE_CLASSES = {
sm: "h-8 w-8 text-xs",
md: "h-12 w-12 text-sm",
lg: "h-24 w-24 text-2xl",
} as const;
export function Avatar({
pseudo,
avatarUrl,
size = "md",
}: {
pseudo: string;
avatarUrl: string | null;
size?: keyof typeof SIZE_CLASSES;
}) {
const sizeClass = SIZE_CLASSES[size];
if (avatarUrl) {
return (
<Image
src={avatarUrl}
alt={pseudo}
width={96}
height={96}
className={`${sizeClass} rounded-full object-cover`}
/>
);
}
return (
<div
className={`${sizeClass} flex items-center justify-center rounded-full bg-navy font-semibold text-ivory`}
aria-hidden
>
{initials(pseudo)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export function NavBar() {
const router = useRouter();
const [signingOut, setSigningOut] = useState(false);
async function handleSignOut() {
setSigningOut(true);
const supabase = createClient();
await supabase.auth.signOut();
router.push("/login");
router.refresh();
}
return (
<nav className="bg-navy text-ivory">
<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>
<button
type="button"
onClick={handleSignOut}
disabled={signingOut}
className="hover:text-gold disabled:opacity-50"
>
{signingOut ? "…" : "Déconnexion"}
</button>
</div>
</div>
</nav>
);
}
+19
View File
@@ -0,0 +1,19 @@
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`;
}
+8
View File
@@ -0,0 +1,8 @@
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
}
+50
View File
@@ -0,0 +1,50 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
const PROTECTED_PATHS = ["/leaderboard", "/profile"];
const AUTH_PATHS = ["/login", "/signup"];
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
},
);
const {
data: { user },
} = await supabase.auth.getUser();
const { pathname } = request.nextUrl;
const isProtected = PROTECTED_PATHS.some((path) => pathname.startsWith(path));
const isAuthPath = AUTH_PATHS.some((path) => pathname.startsWith(path));
if (!user && isProtected) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
if (user && isAuthPath) {
const url = request.nextUrl.clone();
url.pathname = "/leaderboard";
return NextResponse.redirect(url);
}
return response;
}
+27
View File
@@ -0,0 +1,27 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// called from a Server Component; middleware refreshes the session instead
}
},
},
},
);
}
+10
View File
@@ -0,0 +1,10 @@
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
export async function proxy(request: NextRequest) {
return updateSession(request);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
};