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
+2
View File
@@ -0,0 +1,2 @@
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=votre_cle_anon
+42
View File
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.local.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+178
View File
@@ -0,0 +1,178 @@
@AGENTS.md
# Le Tribunal — Brief de démarrage V1
> **À l'attention de Claude Code.** Ce document décrit **uniquement la V1**. Reste simple, ne construis rien qui n'est pas listé dans « Périmètre V1 ». Les fonctionnalités futures sont listées en fin de doc **seulement** pour que l'architecture reste extensible — ne les implémente pas maintenant.
---
## 1. But du projet
Application web pour un groupe d'amis (~12 personnes) qui animera un jeu et des olympiades pendant une semaine de vacances. On démarre par le socle : **comptes utilisateurs + leaderboard**. Tout le reste (jeu du « Tribunal », roulette, ostracisme, etc.) viendra ensuite.
## 2. Périmètre V1 (et seulement ça)
1. **Site responsive** (mobile-first, propre aussi sur ordinateur).
2. **Création de compte** : pseudo + mot de passe + photo de profil.
3. **Connexion / déconnexion**.
4. **Page profil** : voir et modifier son pseudo et sa photo.
5. **Leaderboard** : liste de toutes les personnes inscrites, triée par points décroissants, affichant **rang, photo, pseudo, points**.
### Hors périmètre V1 (ne pas coder maintenant)
Attribution de points depuis l'app, rôles admin/juges, jeu du Char, roulette, ostracisme, timer du Gardien, thèmes/éditions, temps réel. En V1, les points existent en base (défaut `0`) mais **il n'y a pas d'interface pour les modifier** — on les ajustera plus tard (ou manuellement via la console Supabase pour tester).
---
## 3. Stack technique
Choisie pour être simple à construire, sécurisée et gratuite à héberger pour 12 utilisateurs :
- **Framework** : Next.js (App Router) + TypeScript
- **Style** : Tailwind CSS (mobile-first)
- **Auth + Base de données + Stockage photos** : **Supabase**
- Supabase Auth pour la connexion
- Postgres pour les profils
- Supabase Storage (bucket `avatars`) pour les photos
- **Déploiement** : Vercel (front) + Supabase (managé)
Pas d'autre dépendance lourde. Composants UI faits main avec Tailwind (pas de librairie de composants pour l'instant).
---
## 4. Authentification (pseudo + mot de passe)
L'utilisateur se connecte avec **un pseudo et un mot de passe** — pas d'email visible.
**Implémentation :** on utilise Supabase Auth (email/password) en **dérivant un email interne** à partir du pseudo, invisible pour l'utilisateur :
```ts
const internalEmail = `${slugify(pseudo)}@letribunal.test`;
// signup: supabase.auth.signUp({ email: internalEmail, password })
// login : supabase.auth.signInWithPassword({ email: internalEmail, password })
```
- `slugify` : minuscules, sans accents, espaces → `-`, caractères non alphanumériques retirés.
- Le pseudo affiché (avec accents/casse) est stocké dans la table `profiles`.
- Vérifier l'**unicité du pseudo** au signup (le slug sert de clé technique).
- Mots de passe : gérés/hashés par Supabase Auth (ne jamais stocker de mot de passe en clair).
> Règle de sécurité produit : on ne collecte pas d'email réel, c'est un cercle d'amis. Pas de reset par email en V1 (un admin pourra réinitialiser via Supabase si besoin).
---
## 5. Modèle de données
Table `profiles` (liée à `auth.users`) :
| Colonne | Type | Notes |
|--------------|-------------|----------------------------------------------|
| `id` | uuid (PK) | = `auth.users.id` |
| `pseudo` | text | affiché, unique (insensible à la casse) |
| `slug` | text | unique, dérivé du pseudo (clé technique) |
| `avatar_url` | text (null) | URL publique de la photo dans Storage |
| `points` | int | défaut `0` |
| `created_at` | timestamptz | défaut `now()` |
**Storage :** bucket public `avatars`, un fichier par utilisateur (`{user_id}.<ext>`).
**Row Level Security (RLS) — à activer :**
- `SELECT` sur `profiles` : autorisé à tout utilisateur **authentifié** (nécessaire pour afficher le leaderboard de tout le monde).
- `INSERT` : un utilisateur ne peut créer que **sa propre** ligne (`auth.uid() = id`).
- `UPDATE` : un utilisateur ne peut modifier que **sa propre** ligne, et **uniquement** `pseudo`, `slug`, `avatar_url`**pas `points`** (les points seront gérés plus tard côté admin).
- `DELETE` : interdit en V1.
Fournis le SQL des tables + policies dans un fichier `supabase/schema.sql` versionné.
---
## 6. Pages & navigation
| Route | Contenu |
|---------------|-------------------------------------------------------------------------|
| `/` | Redirige vers `/leaderboard` si connecté, sinon vers `/login` |
| `/login` | Formulaire pseudo + mot de passe ; lien vers `/signup` |
| `/signup` | Pseudo + mot de passe + upload photo ; crée le compte et le profil |
| `/leaderboard`| Liste triée par points ↓ : rang, photo, pseudo, points (protégée) |
| `/profile` | Voir/modifier son pseudo et sa photo ; bouton déconnexion (protégée) |
- Les routes `/leaderboard` et `/profile` sont **protégées** : rediriger vers `/login` si non connecté (via middleware Next.js + session Supabase).
- Une barre de navigation simple (Leaderboard · Profil · Déconnexion) visible une fois connecté.
---
## 7. Look & feel
Sobre, lisible, **mobile-first**, agréable en vidéoprojection plus tard. Définir les couleurs comme **variables CSS / tokens Tailwind** pour pouvoir re-thématiser facilement par la suite (un thème par édition est prévu plus tard — ne pas coder de thème « en dur » dans les composants).
Palette par défaut (tokens) :
- `--navy: #0F2748` (fond / barres)
- `--gold: #C9A227` (accent, rang 1)
- `--ivory: #F4ECD8` (surfaces claires)
- `--ink: #2A2116` (texte sur clair)
Leaderboard : le **rang 1** est mis en valeur (accent doré). Photos en avatars ronds. Fallback si pas de photo : initiales du pseudo sur fond de couleur.
Qualité minimale : responsive jusqu'au mobile, focus clavier visible, images optimisées, états de chargement et messages d'erreur clairs (« Ce pseudo est déjà pris », « Mot de passe incorrect », etc. — pas de message technique brut).
---
## 8. Plan de construction (ordre conseillé)
1. Scaffolder Next.js + TypeScript + Tailwind.
2. Configurer le client Supabase (`.env.local`) et le middleware de session.
3. Écrire `supabase/schema.sql` (table `profiles` + RLS + bucket `avatars`).
4. Auth : pages `/signup` et `/login` avec la logique pseudo → email interne.
5. Upload de la photo au signup + stockage dans le bucket + `avatar_url` en base.
6. Page `/leaderboard` (lecture de tous les profils, tri par points).
7. Page `/profile` (édition pseudo/photo, déconnexion).
8. Middleware de protection des routes + redirections.
9. Polissage responsive + états vides/chargement/erreurs.
10. `README.md` avec instructions de setup et de déploiement.
---
## 9. Configuration & commandes
Variables d'environnement (`.env.local`) :
```
NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
```
Documenter dans le `README.md` : création du projet Supabase, exécution de `schema.sql`, création du bucket `avatars`, lancement (`npm run dev`) et déploiement Vercel.
---
## 10. Definition of Done (V1)
- [ ] On peut créer un compte (pseudo + mot de passe + photo) et être connecté ensuite.
- [ ] On peut se déconnecter puis se reconnecter avec pseudo + mot de passe.
- [ ] Un pseudo déjà pris est refusé avec un message clair.
- [ ] Le leaderboard affiche **tous** les inscrits, triés par points, avec rang/photo/pseudo/points.
- [ ] On peut changer sa photo et son pseudo depuis `/profile`.
- [ ] Les routes protégées redirigent vers `/login` si non connecté.
- [ ] Le site est propre et utilisable sur téléphone **et** ordinateur.
- [ ] RLS activée : personne ne peut modifier les points ni le profil d'autrui.
---
## 11. Roadmap (ne pas coder maintenant — juste pour garder l'archi extensible)
Prévoir que ces éléments s'ajouteront ensuite, sans refonte :
- **Rôles** : juge/admin (`role` sur `profiles`) → attribution de points, modération.
- **Notion d'édition/thème** : une table `editions` (année, nom du thème, mood board, vocabulaire) ; les points deviendront rattachables à une édition.
- **Jeu du Tribunal** : le Char (zone de pénalité), batailles de cul sec, ostracisme, roulette, timer du Gardien.
- **Temps réel** : leaderboard et fil d'événements live via Supabase Realtime, mode « grand écran » pour vidéoprojecteur.
Garde le code modulaire (composants réutilisables, accès aux données isolé) pour que ces ajouts restent simples.
---
## 12. À reporter dans `CLAUDE.md` (conventions durables du repo)
- Stack : Next.js (App Router) + TS + Tailwind + Supabase.
- Auth = pseudo + mot de passe via email interne dérivé (`slug@letribunal.test`).
- Toujours passer par les policies RLS ; ne jamais exposer la `service_role key` côté client.
- Design tokens = variables CSS (thème swappable) ; pas de couleur codée en dur dans les composants.
- Commandes : `npm run dev`, `npm run build`, `npm run lint`.
- Garder chaque fonctionnalité future isolée et le schéma SQL versionné dans `supabase/schema.sql`.
+22 -19
View File
@@ -29,7 +29,7 @@ Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pe
## Prérequis ## Prérequis
- [Node.js](https://nodejs.org/) 18 ou supérieur - [Node.js](https://nodejs.org/) 20.19+ (Next.js 16 / React 19)
- Un compte [Supabase](https://supabase.com/) (offre gratuite suffisante) - Un compte [Supabase](https://supabase.com/) (offre gratuite suffisante)
- Un compte [Vercel](https://vercel.com/) pour le déploiement (optionnel en local) - Un compte [Vercel](https://vercel.com/) pour le déploiement (optionnel en local)
@@ -40,17 +40,17 @@ Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pe
### 1. Cloner et installer ### 1. Cloner et installer
```bash ```bash
git clone https://github.com/<votre-compte>/le-tribunal.git git clone ssh://git@git.alxczl.fr:7878/valentin/tribunal-app.git
cd le-tribunal cd tribunal-app
npm install npm install
``` ```
### 2. Créer le projet Supabase ### 2. Créer le projet Supabase
1. Créez un nouveau projet sur [supabase.com](https://supabase.com/). 1. Créez un nouveau projet sur [supabase.com](https://supabase.com/).
2. Dans **SQL Editor**, exécutez le contenu de [`supabase/schema.sql`](supabase/schema.sql) (table `profiles` + policies RLS). 2. Dans **SQL Editor**, exécutez le contenu de [`supabase/schema.sql`](supabase/schema.sql) (table `profiles`, policies RLS, bucket `avatars`).
3. Dans **Storage**, créez un bucket **public** nommé `avatars`. 3. Récupérez l'URL du projet et la clé `anon` dans **Project Settings → API**.
4. Récupérez l'URL du projet et la clé `anon` dans **Project Settings → API**. 4. Dans **Authentication → Providers → Email**, **désactivez "Confirm email"**. L'app utilise des adresses techniques fictives (`pseudo@letribunal.test`) qui ne peuvent recevoir aucun email : si la confirmation reste activée, personne ne peut se connecter après inscription.
### 3. Variables d'environnement ### 3. Variables d'environnement
@@ -99,18 +99,21 @@ L'application tourne sur [http://localhost:3000](http://localhost:3000).
## Structure du projet ## Structure du projet
``` ```
le-tribunal/ tribunal-app/
├─ app/ ├─ src/
│ ├─ login/page.tsx # Connexion (pseudo + mot de passe) │ ├─ app/
│ ├─ signup/page.tsx # Création de compte + upload photo │ ├─ login/page.tsx # Connexion (pseudo + mot de passe)
│ ├─ leaderboard/page.tsx # Classement de tous les inscrits │ ├─ signup/page.tsx # Création de compte + upload photo
│ ├─ profile/page.tsx # Édition du profil + déconnexion │ ├─ leaderboard/page.tsx # Classement de tous les inscrits
│ ├─ layout.tsx # Layout + navigation │ ├─ profile/ # Édition du profil + déconnexion
└─ page.tsx # Redirection selon l'état de connexion │ ├─ layout.tsx # Layout + navigation
├─ components/ # Composants UI réutilisables │ │ └─ page.tsx # Redirection selon l'état de connexion
├─ lib/supabase/ # Clients Supabase (browser / server) │ ├─ components/ # Composants UI réutilisables (Avatar, NavBar)
├─ supabase/schema.sql # Schéma + policies RLS (versionné) │ ├─ lib/
├─ middleware.ts # Protection des routes + session │ │ ├─ auth.ts # slugify / email interne dérivé du pseudo
│ │ └─ supabase/ # Clients Supabase (browser / server / proxy)
│ └─ proxy.ts # Protection des routes + rafraîchissement session
├─ supabase/schema.sql # Schéma + policies RLS + bucket avatars (versionné)
├─ .env.local.example ├─ .env.local.example
└─ README.md └─ README.md
``` ```
@@ -119,7 +122,7 @@ le-tribunal/
## Authentification ## Authentification
La connexion se fait par **pseudo + mot de passe**, sans email visible. En interne, un email technique est dérivé du pseudo (`slug@letribunal.local`) pour s'appuyer sur Supabase Auth. Le pseudo affiché est stocké dans la table `profiles`, et son unicité est vérifiée à l'inscription. La connexion se fait par **pseudo + mot de passe**, sans email visible. En interne, un email technique est dérivé du pseudo (`slug@letribunal.test`) pour s'appuyer sur Supabase Auth`.test` est un TLD réservé (RFC 2606), accepté par le validateur d'email de Supabase (`.local` est rejeté). Le pseudo affiché est stocké dans la table `profiles`, et son unicité est vérifiée à l'inscription.
> Pas de réinitialisation de mot de passe par email en V1 (cercle d'amis, pas d'email réel collecté). Un administrateur peut réinitialiser un mot de passe depuis la console Supabase si nécessaire. > Pas de réinitialisation de mot de passe par email en V1 (cercle d'amis, pas d'email réel collecté). Un administrateur peut réinitialiser un mot de passe depuis la console Supabase si nécessaire.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+15
View File
@@ -0,0 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "kuichrzrbdwydixbdfon.supabase.co",
pathname: "/storage/v1/object/public/**",
},
],
},
};
export default nextConfig;
+6824
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "tribunal-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.109.0",
"next": "16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

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)$).*)"],
};
+76
View File
@@ -0,0 +1,76 @@
-- Le Tribunal — schéma V1 (table profiles + RLS + bucket avatars)
-- À exécuter dans l'éditeur SQL du projet Supabase.
-- 1. Table profiles ----------------------------------------------------
create table if not exists public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
pseudo text not null,
slug text not null,
avatar_url text,
points integer not null default 0,
created_at timestamptz not null default now()
);
-- Unicité du pseudo affiché (insensible à la casse) et du slug technique.
create unique index if not exists profiles_pseudo_lower_key on public.profiles (lower(pseudo));
create unique index if not exists profiles_slug_key on public.profiles (slug);
alter table public.profiles enable row level security;
-- Lecture : tout utilisateur authentifié peut voir tous les profils (leaderboard).
create policy "Profiles are viewable by authenticated users"
on public.profiles for select
to authenticated
using (true);
-- Création : un utilisateur ne peut créer que sa propre ligne.
create policy "Users can insert their own profile"
on public.profiles for insert
to authenticated
with check (auth.uid() = id);
-- Modification : un utilisateur ne peut modifier que sa propre ligne.
create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using (auth.uid() = id)
with check (auth.uid() = id);
-- Aucune policy delete => suppression interdite en V1.
-- RLS est au niveau ligne : on verrouille aussi la colonne `points` au niveau
-- colonne pour qu'un utilisateur ne puisse jamais la modifier via l'app,
-- même en modifiant sa propre ligne (les points seront gérés plus tard côté admin).
revoke update on public.profiles from authenticated;
grant update (pseudo, slug, avatar_url) on public.profiles to authenticated;
-- 2. Bucket avatars -------------------------------------------------------
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
on conflict (id) do nothing;
-- Bucket public => la lecture se fait via l'URL publique, pas besoin de policy SELECT.
-- Upload : un utilisateur ne peut créer que le fichier `{user_id}.<ext>`.
create policy "Users can upload their own avatar"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'avatars'
and split_part(name, '.', 1) = auth.uid()::text
);
-- Remplacement de la photo (upsert) : même règle en update.
create policy "Users can update their own avatar"
on storage.objects for update
to authenticated
using (
bucket_id = 'avatars'
and split_part(name, '.', 1) = auth.uid()::text
)
with check (
bucket_id = 'avatars'
and split_part(name, '.', 1) = auth.uid()::text
);
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}