Refonte visuelle : direction artistique Grèce antique

Remplace le thème générique (tokens navy/gold/ivory re-thématisables par
édition) par une direction artistique fixe et codée en dur : tribunal
égéen de nuit — marbre, or, mer sombre, sceaux de cire.

Fondations (globals.css, layout.tsx) :
- Polices next/font/google : Cinzel (titres, tracking large), Cormorant
  Garamond (citations italiques), Manrope (interface).
- Nouveaux tokens couleur : ink/ink-2 (fond mer de nuit), marble/marble-2
  (surfaces claires), gold/gold-bright (accents), oxblood (blâmes/erreurs),
  olive (honneurs/succès), sea, text-marble/text-mut (texte sur marbre —
  jamais de doré en texte courant, contraste insuffisant).
- Classes utilitaires : .marble-surface (texture par dégradés, pas
  d'image de bruit), .meander-divider (frise à méandre grecque en SVG
  data-URI).

Composants d'ornement (aucune image bitmap) :
- components/laurel-wreath.tsx : couronne de laurier calculée par
  trigonométrie (deux arcs symétriques de feuilles), réutilisée en logo,
  favicon (icon.svg régénéré avec les mêmes coordonnées) et couronne du
  rang 1 du podium.
- components/diamond-divider.tsx : séparateur ◆ avec filets dégradés.

Header global (components/header.tsx, remplace nav-bar.tsx) :
- Logo laurier + "LE TRIBUNAL" à gauche (cliquable vers le leaderboard).
- Chip utilisateur à droite : avatar (bordure dorée), pseudo, badge de
  rôle (Archonte/Citoyen), points de gloire, menu déroulant (Mon profil,
  Le Conseil des Archontes si juge, Se déconnecter).
- Onglets L'Agora / Le Crieur, frise à méandre en séparateur.
- Responsive : nom du site et pseudo masqués sous le breakpoint sm,
  l'avatar reste toujours visible.

Vocabulaire diégétique (toujours avec le terme fonctionnel à côté) :
juges → Archontes, membres → Citoyens, leaderboard → L'Agora, journal →
Le Crieur (décrets avec sceau de cire oxblood), points positifs/négatifs
→ Honneurs/Blâmes, points → gloires, section admin → Le Conseil des
Archontes.

Avatar : bordure dorée, couleur de fallback dérivée d'un hash du pseudo
(au lieu d'une couleur fixe).

Toutes les pages (login, signup, leaderboard/podium, journal, admin,
profil) sont passées sur les nouveaux tokens/polices/vocabulaire.

README.md et CLAUDE.md : section "Ré-thématisation" remplacée par
"Direction artistique" documentant la palette, les polices et les
ornements ; toute mention du système de re-thème par édition retirée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-07-12 13:13:54 +02:00
parent e6f4463cb0
commit 9f4e58019b
25 changed files with 541 additions and 291 deletions
+32 -10
View File
@@ -2,16 +2,17 @@
# Le Tribunal # Le Tribunal
Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des olympiades pendant une semaine de vacances : comptes, rôles, leaderboard en direct, attribution de points par des juges, journal public des événements. Ré-thématisable par édition (un thème différent chaque année). Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des olympiades pendant une semaine de vacances : comptes, rôles, leaderboard en direct, attribution de points par des juges, journal public des événements. Habillée sur le thème d'un tribunal de la Grèce antique (voir section 6).
## 1. État actuel ## 1. État actuel
V1, V2 et V3 sont livrées : V1 à V4 sont livrées :
- **V1** : comptes, profils, leaderboard statique. - **V1** : comptes, profils, leaderboard statique.
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin. - **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client. - **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
- **V4** : direction artistique Grèce antique (marbre/or/mer de nuit, polices Cinzel/Cormorant Garamond/Manrope, vocabulaire Archontes/Citoyens/Agora/Crieur), header global avec chip utilisateur.
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/thèmes, le jeu du Tribunal lui-même (Char, roulette, ostracisme, timer du Gardien), mode grand écran. Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, le jeu du Tribunal lui-même (Char, roulette, ostracisme, timer du Gardien), mode grand écran.
--- ---
@@ -78,20 +79,41 @@ Email + mot de passe (Supabase Auth impose l'unicité de l'email). Confirmation
| `/` | Redirige vers `/leaderboard` si connecté, sinon `/login` | | `/` | Redirige vers `/leaderboard` si connecté, sinon `/login` |
| `/login` | Email + mot de passe | | `/login` | Email + mot de passe |
| `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email | | `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email |
| `/leaderboard` | Podium top 3 + liste, Realtime, contrôles de points pour les juges | | `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges |
| `/journal` | « Le crieur » — fil live des attributions de points, lecture pour tous | | `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous |
| `/profile` | Pseudo (verrouillable), photo, déconnexion | | `/profile` | Pseudo (verrouillable), photo, déconnexion |
| `/admin` | Juges uniquement, **vérifié côté serveur** (Server Component) — gestion des membres, reset du repère de classement | | `/admin` | « Le Conseil des Archontes » — juges uniquement, **vérifié côté serveur** (Server Component) — gestion des membres, reset du repère de classement |
`/leaderboard`, `/profile`, `/admin`, `/journal` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige. `/leaderboard`, `/profile`, `/admin`, `/journal` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige.
Le header global (`components/header.tsx`) est rendu dans `layout.tsx` pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal ») à gauche, chip utilisateur (avatar, pseudo, rôle, points) avec menu déroulant à droite, onglets L'Agora/Le Crieur en dessous.
--- ---
## 6. Look & feel ## 6. Look & feel — Grèce antique
Design tokens en variables CSS (`globals.css`) : `--navy #0F2748`, `--gold #C9A227`, `--ivory #F4ECD8`, `--ink #2A2116`. Ne jamais coder une couleur en dur dans un composant — passer par ces tokens pour que le re-thème par édition reste possible. Thème **fixe et codé en dur** (pas de système de re-thématisation par édition — voir roadmap). Un tribunal égéen de nuit : marbre, or, mer sombre, sceaux de cire.
Convention de carte : `rounded-xl border border-navy/10 bg-white shadow-sm` (formulaires, podium) ou `rounded-lg` pour les lignes de liste. Bouton primaire : `bg-navy text-ivory hover:bg-navy/90`. Bouton secondaire : `border border-navy/20 hover:bg-navy/5`. Tokens couleur (`globals.css`, exposés comme utilities Tailwind via `@theme inline`) :
- `--ink #0A1B33` / `--ink-2 #0F2748` : fond de l'app (dégradés radiaux mer de nuit), header, boutons primaires.
- `--marble #F4ECD8` / `--marble-2 #E9DEC2` : surfaces claires (`.marble-surface`, classe utilitaire avec léger dégradé/grain).
- `--gold #C9A227` / `--gold-bright #E7C560` : accents, bordures, 1ʳᵉ place. **Jamais** pour du texte courant sur `marble` (contraste insuffisant) — utiliser `--text-marble` à la place.
- `--oxblood #A5342A` : blâmes (points négatifs), sceaux, erreurs.
- `--olive #5E6B3B` : honneurs (points positifs), succès.
- `--sea #2E6E7E` : accent secondaire.
- `--text-marble #2A2116` / `--text-mut #7A6A4C` : texte sur surfaces claires.
Polices (`next/font/google`, variables CSS, exposées comme `font-heading`/`font-serif`/`font-sans`) : **Cinzel** (titres, noms, chiffres de score — majuscules, `tracking-[0.1em]`), **Cormorant Garamond** (citations/sous-titres, italique), **Manrope** (interface, corps de texte).
Ornements en SVG/CSS inline (jamais d'image bitmap) :
- `components/laurel-wreath.tsx` — couronne de laurier calculée par trigonométrie (deux arcs symétriques de feuilles), utilisée en logo, sur le rang 1 du podium, etc. Le favicon (`src/app/icon.svg`) est une version statique des mêmes coordonnées.
- `.meander-divider` (classe globale) — frise à méandre grecque en `background-image` SVG data-URI, séparateur discret.
- `components/diamond-divider.tsx` — séparateur ◆ avec filets dégradés.
- `.marble-surface` (classe globale) — texture marbre via dégradés (pas d'image de bruit).
Convention de carte : `marble-surface rounded-2xl border border-gold/40 shadow-xl` (formulaires, podium) ou `rounded-lg border-gold/25` pour les lignes de liste. Bouton primaire : `bg-ink-2 text-gold-bright font-heading uppercase hover:bg-ink`. Bouton secondaire : `border border-gold/30 text-text-marble hover:bg-gold/10`.
Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code/les commentaires, pour que ça reste maintenable) : juges/admins → **Archontes**, membres → **Citoyens**, leaderboard → **L'Agora**, journal des points → **Le Crieur** (décrets), points positifs/négatifs → **Honneurs**/**Blâmes**, points → **gloires**, section admin → **Le Conseil des Archontes**.
--- ---
@@ -107,7 +129,7 @@ Convention de carte : `rounded-xl border border-navy/10 bg-white shadow-sm` (for
## 8. Roadmap (pas encore codé) ## 8. Roadmap (pas encore codé)
- **Éditions & thèmes** : table `editions` (année, thème, mood board, vocabulaire) ; les points deviendront rattachables à une édition. - **Éditions** : table `editions` (année, mood board) ; les points deviendront rattachables à une édition donnée. Le thème visuel (Grèce antique) reste fixe, ce n'est pas un système de re-thématisation par édition.
- **Le jeu du Tribunal** : le Char (zone de pénalité), batailles de cul sec, ostracisme, roulette, timer du Gardien. - **Le jeu du Tribunal** : le Char (zone de pénalité), batailles de cul sec, ostracisme, roulette, timer du Gardien.
- **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur. - **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur.
- **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README). - **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README).
+36 -18
View File
@@ -1,6 +1,6 @@
# Le Tribunal # Le Tribunal
Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pendant une semaine de vacances : comptes, rôles, leaderboard en direct, attribution de points par les juges et journal public des événements. Le socle est **ré-thématisable par édition** (un thème différent chaque année). Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pendant une semaine de vacances : comptes, rôles, leaderboard en direct, attribution de points par les juges et journal public des événements. Habillée sur le thème d'un tribunal de la Grèce antique (voir [Direction artistique](#direction-artistique)).
--- ---
@@ -8,9 +8,9 @@ Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pe
- 🔐 **Création de compte** par email + mot de passe (email confirmé, un compte par email) - 🔐 **Création de compte** par email + mot de passe (email confirmé, un compte par email)
- 👤 **Connexion / déconnexion**, pseudo affiché partout (jamais l'email) - 👤 **Connexion / déconnexion**, pseudo affiché partout (jamais l'email)
- 🖼️ **Profil éditable** : pseudo modifiable **une seule fois** puis figé (sauf par un juge), photo recadrée en rond, redimensionnée et compressée côté client - 🖼️ **Profil éditable** : pseudo modifiable **une seule fois** puis figé (sauf par un Archonte), photo recadrée en rond, redimensionnée et compressée côté client
- 🏆 **Leaderboard en direct** (Supabase Realtime) : podium top 3 (or/argent/bronze, gestion des égalités), flèches ▲▼ de progression depuis le dernier « round » - 🏆 **Leaderboard en direct** (Supabase Realtime) : podium top 3 (or/argent/bronze, gestion des égalités), flèches ▲▼ de progression depuis le dernier « round »
- ⚖️ **Rôles** : `public` / `judge`. Les juges attribuent des points (+/- avec montant personnalisé et motif, confirmation explicite avant envoi) et gèrent les membres depuis une section **Admin** - ⚖️ **Rôles** : `public` / `judge`. Les Archontes (juges) attribuent des points (+/- avec montant personnalisé et motif, confirmation explicite avant envoi) et gèrent les Citoyens depuis **Le Conseil des Archontes**
- 📣 **Le crieur** : journal public et en direct de toutes les attributions de points - 📣 **Le crieur** : journal public et en direct de toutes les attributions de points
- 📱 **Responsive**, pensé mobile-first et propre en vidéoprojection - 📱 **Responsive**, pensé mobile-first et propre en vidéoprojection
@@ -21,7 +21,8 @@ Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pe
| Rôle | Techno | | Rôle | Techno |
|--------------------------|---------------------------------| |--------------------------|---------------------------------|
| Framework | Next.js 16 (App Router) + TypeScript | | Framework | Next.js 16 (App Router) + TypeScript |
| Style | Tailwind CSS v4 | | Style | Tailwind CSS v4 (tokens Grèce antique, voir `globals.css`) |
| Typographie | Cinzel (titres), Cormorant Garamond (citations), Manrope (interface) — `next/font/google` |
| Auth · Base · Stockage · Temps réel | Supabase (Auth, Postgres, Storage, Realtime) | | Auth · Base · Stockage · Temps réel | Supabase (Auth, Postgres, Storage, Realtime) |
| Recadrage photo | react-easy-crop + canvas (resize/compression client) | | Recadrage photo | react-easy-crop + canvas (resize/compression client) |
| Email transactionnel | SMTP custom (ex. Resend) branché sur Supabase Auth | | Email transactionnel | SMTP custom (ex. Resend) branché sur Supabase Auth |
@@ -55,7 +56,7 @@ npm install
3. Récupérez l'URL du projet et la clé `anon` dans **Project Settings → API**. 3. Récupérez l'URL du projet et la clé `anon` dans **Project Settings → API**.
4. Dans **Authentication → Providers → Email**, **activez "Confirm email"** (un vrai email par personne, condition d'unicité des comptes). 4. Dans **Authentication → Providers → Email**, **activez "Confirm email"** (un vrai email par personne, condition d'unicité des comptes).
5. Dans **Authentication → Settings → SMTP Settings**, branchez un fournisseur SMTP custom (ex. Resend) — le service email par défaut de Supabase est bridé à quelques emails/heure. Voir [Email transactionnel](#email-transactionnel) ci-dessous. 5. Dans **Authentication → Settings → SMTP Settings**, branchez un fournisseur SMTP custom (ex. Resend) — le service email par défaut de Supabase est bridé à quelques emails/heure. Voir [Email transactionnel](#email-transactionnel) ci-dessous.
6. Une fois votre propre compte créé et confirmé via `/signup`, désignez-vous juge (voir [Rôles & administration](#rôles--administration)). 6. Une fois votre propre compte créé et confirmé via `/signup`, désignez-vous Archonte (voir [Rôles & administration](#rôles--administration)).
### 3. Variables d'environnement ### 3. Variables d'environnement
@@ -110,13 +111,14 @@ tribunal-app/
│ │ ├─ leaderboard/ │ │ ├─ leaderboard/
│ │ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur │ │ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur
│ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges │ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges
│ │ ├─ journal/ # « Le crieur » : fil d'événements en direct │ │ ├─ journal/ # « Le Crieur » : fil des décrets en direct
│ │ ├─ admin/ # Gestion des membres + reset du repère (juges) │ │ ├─ admin/ # Le Conseil des Archontes : gestion des membres, reset du repère
│ │ ├─ profile/ # Édition pseudo (verrou) / photo, déconnexion │ │ ├─ profile/ # Édition pseudo (verrou) / photo, déconnexion
│ │ ├─ layout.tsx # Layout + navigation │ │ ├─ layout.tsx # Layout + header global
│ │ ├─ icon.svg # Favicon (couronne de laurier)
│ │ └─ page.tsx # Redirection selon l'état de connexion │ │ └─ page.tsx # Redirection selon l'état de connexion
│ ├─ components/ # Avatar, AvatarPicker (recadrage), NavBar, Podium, │ ├─ components/ # Header (logo + chip utilisateur), Avatar, AvatarPicker,
│ │ │ # JudgePointControls │ │ │ # Podium, JudgePointControls, LaurelWreath, DiamondDivider
│ ├─ lib/ │ ├─ lib/
│ │ ├─ ranking.ts # Classement avec égalités + calcul des flèches │ │ ├─ ranking.ts # Classement avec égalités + calcul des flèches
│ │ ├─ image.ts # Recadrage/compression canvas côté client │ │ ├─ image.ts # Recadrage/compression canvas côté client
@@ -137,7 +139,7 @@ Le profil (`public.profiles`) est créé automatiquement à l'inscription via un
### Verrou de pseudo ### Verrou de pseudo
Chaque membre peut changer son pseudo **une seule fois** depuis `/profile`. Dès l'enregistrement, `pseudo_locked` passe à `true` et le champ devient en lecture seule (« pseudo figé — contacte un juge pour le changer »). Un juge peut toujours modifier le pseudo de quiconque et, s'il le souhaite, redéverrouiller un compte depuis **Admin**. Chaque membre peut changer son pseudo **une seule fois** depuis `/profile`. Dès l'enregistrement, `pseudo_locked` passe à `true` et le champ devient en lecture seule (« pseudo figé — contacte un Archonte pour le changer »). Un Archonte peut toujours modifier le pseudo de quiconque et, s'il le souhaite, redéverrouiller un compte depuis **Le Conseil des Archontes**.
### Rôles & administration ### Rôles & administration
@@ -148,7 +150,7 @@ select id, pseudo from public.profiles;
update public.profiles set role = 'judge' where id = '<uuid-du-membre>'; update public.profiles set role = 'judge' where id = '<uuid-du-membre>';
``` ```
Ensuite, un juge peut promouvoir/rétrograder d'autres membres et éditer leur pseudo depuis la page **Admin** (visible uniquement par les juges, et vérifié côté serveur — pas seulement caché en front). Ensuite, un Archonte peut promouvoir/rétrograder d'autres membres et éditer leur pseudo depuis **Le Conseil des Archontes** (visible uniquement par les Archontes, et vérifié côté serveur — pas seulement caché en front).
### Email transactionnel ### Email transactionnel
@@ -168,10 +170,10 @@ Le service email intégré de Supabase (sans SMTP custom) limite l'envoi à quel
## Points, journal et classement ## Points, journal et classement
- **Attribution de points** : depuis le leaderboard, un juge clique `+`/`` (montant personnalisable, motif optionnel) — le delta reste **en attente** (affiché en couleur) jusqu'à un clic sur **Confirmer** (envoie la RPC `award_points`) ou **Annuler**. Le client n'écrit jamais directement la colonne `points` : la RPC vérifie le rôle juge côté serveur, applique le delta (négatif autorisé, pas de plancher à 0) et journalise l'opération. - **Attribution de points** : depuis L'Agora, un Archonte clique `+`/`` (montant personnalisable, motif optionnel) — le delta reste **en attente** (affiché en couleur) jusqu'à un clic sur **Confirmer** (envoie la RPC `award_points`) ou **Annuler**. Le client n'écrit jamais directement la colonne `points` : la RPC vérifie le rôle juge côté serveur, applique le delta (négatif autorisé, pas de plancher à 0) et journalise l'opération.
- **Le crieur** (`/journal`) : fil de tous les événements (« Untel a donné +5 à Untel — motif »), visible par tous les authentifiés, alimenté par Supabase Realtime. - **Le crieur** (`/journal`) : fil de tous les événements (« Untel a donné +5 à Untel — motif »), visible par tous les authentifiés, alimenté par Supabase Realtime.
- **Podium** : top 3 mis en avant (or/argent/bronze), gère proprement les égalités (rangs partagés) et les groupes de moins de 3 membres. - **Podium** : top 3 mis en avant (or/argent/bronze), gère proprement les égalités (rangs partagés) et les groupes de moins de 3 membres.
- **Flèches de progression** : ▲/▼/= comparent le rang actuel au dernier repère. Un juge fige un nouveau repère via **Nouveau round** dans **Admin**. - **Flèches de progression** : ▲/▼/= comparent le rang actuel au dernier repère. Un Archonte fige un nouveau repère via **Nouveau round** dans **Le Conseil des Archontes**.
--- ---
@@ -179,21 +181,37 @@ Le service email intégré de Supabase (sans SMTP custom) limite l'envoi à quel
- Mots de passe hachés et gérés par Supabase Auth (jamais stockés en clair). - Mots de passe hachés et gérés par Supabase Auth (jamais stockés en clair).
- **Row Level Security** activée sur `profiles` et `points_log`. La RLS étant au niveau ligne, les colonnes sensibles (`role`, `points`, `pseudo_locked`, pseudo figé) sont verrouillées par un **trigger `BEFORE UPDATE`** qui ne fait jamais confiance à ce qu'envoie le client. - **Row Level Security** activée sur `profiles` et `points_log`. La RLS étant au niveau ligne, les colonnes sensibles (`role`, `points`, `pseudo_locked`, pseudo figé) sont verrouillées par un **trigger `BEFORE UPDATE`** qui ne fait jamais confiance à ce qu'envoie le client.
- Les points ne sont modifiables que via la RPC `award_points` (`SECURITY DEFINER`), jamais par une écriture directe — même un juge ne peut pas modifier la colonne `points` à la main. - Les points ne sont modifiables que via la RPC `award_points` (`SECURITY DEFINER`), jamais par une écriture directe — même un Archonte ne peut pas modifier la colonne `points` à la main.
- `points_log` est en lecture seule pour les clients ; seule la RPC peut y écrire. - `points_log` est en lecture seule pour les clients ; seule la RPC peut y écrire.
- La page `/admin` vérifie le rôle côté serveur (Server Component) avant de rendre quoi que ce soit — pas de simple masquage front. - La page `/admin` vérifie le rôle côté serveur (Server Component) avant de rendre quoi que ce soit — pas de simple masquage front.
--- ---
## Ré-thématisation ## Direction artistique
Les couleurs sont définies comme **design tokens** (variables CSS), afin de pouvoir changer l'habillage d'une édition à l'autre sans toucher aux composants. Palette par défaut : navy `#0F2748`, or `#C9A227`, ivoire `#F4ECD8`, encre `#2A2116`. Thème fixe : un tribunal égéen de nuit — marbre, or, mer sombre, sceaux de cire. Tokens couleur (`globals.css`) :
| Token | Valeur | Usage |
|---|---|---|
| `--ink` / `--ink-2` | `#0A1B33` / `#0F2748` | Fond de l'app (mer de nuit), header, boutons primaires |
| `--marble` / `--marble-2` | `#F4ECD8` / `#E9DEC2` | Surfaces claires (cartes, formulaires) |
| `--gold` / `--gold-bright` | `#C9A227` / `#E7C560` | Accents, bordures, 1ʳᵉ place |
| `--oxblood` | `#A5342A` | Blâmes (points négatifs), sceaux, erreurs |
| `--olive` | `#5E6B3B` | Honneurs (points positifs), succès |
| `--sea` | `#2E6E7E` | Accent secondaire |
| `--text-marble` / `--text-mut` | `#2A2116` / `#7A6A4C` | Texte sur surfaces claires (jamais de doré sur marbre pour du texte courant — contraste insuffisant) |
Ornements en SVG/CSS inline (pas d'images bitmap) : couronne de laurier (`components/laurel-wreath.tsx`, calculée par trigonométrie), frise à méandre (`.meander-divider`), séparateur losange (`components/diamond-divider.tsx`), texture marbre (`.marble-surface`).
Vocabulaire (toujours accompagné du terme fonctionnel) : juges → **Archontes**, membres → **Citoyens**, leaderboard → **L'Agora**, journal → **Le Crieur**, points positifs/négatifs → **Honneurs**/**Blâmes**, section admin → **Le Conseil des Archontes**.
Ce thème est **codé en dur** (pas de système de re-thématisation par édition) — changer l'apparence pour une prochaine édition demande d'éditer directement les tokens et le vocabulaire.
--- ---
## Roadmap ## Roadmap
- [ ] **Éditions & thèmes** — année, nom du thème, mood board, vocabulaire configurable - [ ] **Éditions** — table `editions` (année, mood board) pour rattacher les points à une édition donnée
- [ ] **Le jeu du Tribunal** — le Char, batailles de cul sec, ostracisme, roulette, timer du Gardien - [ ] **Le jeu du Tribunal** — le Char, batailles de cul sec, ostracisme, roulette, timer du Gardien
- [ ] **Mode grand écran** — vue leaderboard optimisée vidéoprojecteur - [ ] **Mode grand écran** — vue leaderboard optimisée vidéoprojecteur
+11 -11
View File
@@ -77,14 +77,14 @@ function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
} }
return ( 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"> <li className="marble-surface flex flex-col gap-2 rounded-lg border border-gold/25 px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" /> <Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
<input <input
type="text" type="text"
value={pseudo} value={pseudo}
onChange={(event) => setPseudo(event.target.value)} onChange={(event) => setPseudo(event.target.value)}
className="w-full flex-1 rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold sm:w-auto" className="w-full flex-1 rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-sm text-text-marble outline-none focus:border-gold sm:w-auto"
/> />
</div> </div>
@@ -93,10 +93,10 @@ function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
value={role} value={role}
onChange={(event) => setRole(event.target.value as Role)} onChange={(event) => setRole(event.target.value as Role)}
disabled={isSelf} 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" className="rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-sm text-text-marble outline-none focus:border-gold disabled:opacity-50"
> >
<option value="public">public</option> <option value="public">Citoyen</option>
<option value="judge">judge</option> <option value="judge">Archonte</option>
</select> </select>
{pseudoLocked ? ( {pseudoLocked ? (
@@ -104,26 +104,26 @@ function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
type="button" type="button"
onClick={handleUnlock} onClick={handleUnlock}
disabled={saving} 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" className="whitespace-nowrap rounded-md border border-gold/30 px-2 py-1 text-xs text-text-marble hover:bg-gold/10 disabled:opacity-50"
> >
Déverrouiller le pseudo Déverrouiller le pseudo
</button> </button>
) : ( ) : (
<span className="whitespace-nowrap text-xs text-ink/50">pseudo libre</span> <span className="whitespace-nowrap text-xs text-text-mut">pseudo libre</span>
)} )}
<button <button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={saving || !dirty} 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 sm:ml-auto" className="whitespace-nowrap rounded-md bg-ink-2 px-3 py-1.5 text-sm font-medium text-gold-bright transition hover:bg-ink disabled:opacity-40 sm:ml-auto"
> >
{saving ? "…" : "Enregistrer"} {saving ? "…" : "Enregistrer"}
</button> </button>
</div> </div>
{error && <p className="text-xs text-red-700">{error}</p>} {error && <p className="text-xs text-oxblood">{error}</p>}
{success && !dirty && <p className="text-xs text-green-700">OK</p>} {success && !dirty && <p className="text-xs text-olive">OK</p>}
</li> </li>
); );
} }
@@ -136,7 +136,7 @@ export function AdminMembersTable({
currentUserId: string; currentUserId: string;
}) { }) {
if (members.length === 0) { if (members.length === 0) {
return <p className="text-sm text-ink/70">Aucun membre inscrit.</p>; return <p className="text-sm text-marble/60">Aucun Citoyen inscrit.</p>;
} }
return ( return (
+6 -1
View File
@@ -33,7 +33,12 @@ export default async function AdminPage() {
return ( return (
<div className="mx-auto w-full max-w-3xl px-4 py-8"> <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"> <div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-navy">Admin</h1> <div>
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
Le Conseil des Archontes
</h1>
<p className="font-serif text-sm text-marble/60 italic">Administration des Citoyens</p>
</div>
<ResetRoundButton /> <ResetRoundButton />
</div> </div>
<AdminMembersTable members={members ?? []} currentUserId={user.id} /> <AdminMembersTable members={members ?? []} currentUserId={user.id} />
+1 -1
View File
@@ -21,7 +21,7 @@ export function ResetRoundButton() {
type="button" type="button"
onClick={handleClick} onClick={handleClick}
disabled={state === "loading"} 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" className="marble-surface rounded-md border border-gold/40 px-3 py-1.5 text-sm font-medium text-text-marble shadow-sm transition hover:bg-gold/10 disabled:opacity-50"
> >
{state === "loading" ? "…" : state === "done" ? "Repère mis à jour ✓" : "Nouveau round (réinitialiser le repère)"} {state === "loading" ? "…" : state === "done" ? "Repère mis à jour ✓" : "Nouveau round (réinitialiser le repère)"}
</button> </button>
+54 -15
View File
@@ -1,40 +1,79 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--color-navy: #0f2748; --ink: #0a1b33;
--color-gold: #c9a227; --ink-2: #0f2748;
--color-ivory: #f4ecd8; --marble: #f4ecd8;
--color-ink: #2a2116; --marble-2: #e9dec2;
--gold: #c9a227;
--gold-bright: #e7c560;
--oxblood: #a5342a;
--sea: #2e6e7e;
--olive: #5e6b3b;
--text-marble: #2a2116;
--text-mut: #7a6a4c;
--background: var(--color-ivory); --background: var(--ink);
--foreground: var(--color-ink); --foreground: var(--marble);
} }
@theme inline { @theme inline {
--color-navy: var(--color-navy); --color-ink: var(--ink);
--color-gold: var(--color-gold); --color-ink-2: var(--ink-2);
--color-ivory: var(--color-ivory); --color-marble: var(--marble);
--color-ink: var(--color-ink); --color-marble-2: var(--marble-2);
--color-gold: var(--gold);
--color-gold-bright: var(--gold-bright);
--color-oxblood: var(--oxblood);
--color-sea: var(--sea);
--color-olive: var(--olive);
--color-text-marble: var(--text-marble);
--color-text-mut: var(--text-mut);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-heading: var(--font-cinzel);
--font-serif: var(--font-cormorant);
--font-sans: var(--font-manrope);
} }
body { body {
background: var(--background); background:
radial-gradient(ellipse 80% 50% at 20% -10%, color-mix(in srgb, var(--ink-2) 75%, transparent) 0%, transparent 60%),
radial-gradient(ellipse 70% 60% at 100% 110%, color-mix(in srgb, var(--sea) 30%, transparent) 0%, transparent 65%),
var(--ink);
background-attachment: fixed;
color: var(--foreground); color: var(--foreground);
font-family: var(--font-sans), Arial, Helvetica, sans-serif; font-family: var(--font-sans), Arial, Helvetica, sans-serif;
} }
*:focus-visible { *:focus-visible {
outline: 2px solid var(--color-gold); outline: 2px solid var(--gold-bright);
outline-offset: 2px; outline-offset: 2px;
} }
/* Carte "marbre" : surface claire avec léger grain, bordure dorée fine. */
.marble-surface {
background:
radial-gradient(circle at 18% 12%, color-mix(in srgb, var(--marble-2) 65%, transparent) 0%, transparent 42%),
radial-gradient(circle at 82% 88%, color-mix(in srgb, var(--marble-2) 55%, transparent) 0%, transparent 48%),
linear-gradient(150deg, var(--marble) 0%, var(--marble) 55%, var(--marble-2) 100%);
color: var(--text-marble);
}
/* Frise à méandre (motif grec), séparateur horizontal discret. */
.meander-divider {
height: 10px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='10' viewBox='0 0 32 10'%3E%3Cpath d='M0 8 H6 V2 H16 V8 H22 V2 H32' fill='none' stroke='%23C9A227' stroke-width='1.3'/%3E%3C/svg%3E");
background-repeat: repeat-x;
background-position: center;
background-size: 32px 10px;
opacity: 0.45;
}
@keyframes points-flash { @keyframes points-flash {
0% { 0% {
background-color: color-mix(in srgb, var(--color-gold) 55%, transparent); background-color: color-mix(in srgb, var(--gold-bright) 55%, transparent);
} }
100% { 100% {
background-color: transparent; background-color: transparent;
+17 -7
View File
@@ -1,8 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#C9A227">
<rect x="58" y="74" width="30" height="12" rx="3" fill="#0F2748" /> <rect x="0" y="0" width="100" height="100" rx="20" fill="#0A1B33" />
<rect x="54" y="70" width="38" height="6" rx="3" fill="#0F2748" opacity="0.7" /> <ellipse cx="44.18" cy="85.39" rx="7.00" ry="2.80" transform="rotate(-168.00 44.18 85.39)"/>
<g transform="rotate(45 50 46)"> <ellipse cx="31.70" cy="81.14" rx="6.71" ry="2.68" transform="rotate(-141.67 31.70 81.14)"/>
<rect x="30" y="10" width="18" height="46" rx="6" fill="#C9A227" /> <ellipse cx="21.98" cy="71.26" rx="6.42" ry="2.57" transform="rotate(-115.33 21.98 71.26)"/>
<rect x="18" y="8" width="42" height="20" rx="6" fill="#0F2748" /> <ellipse cx="17.50" cy="57.43" rx="6.13" ry="2.45" transform="rotate(-89.00 17.50 57.43)"/>
</g> <ellipse cx="19.80" cy="42.39" rx="5.83" ry="2.33" transform="rotate(-62.67 19.80 42.39)"/>
<ellipse cx="28.97" cy="29.40" rx="5.54" ry="2.22" transform="rotate(-36.33 28.97 29.40)"/>
<ellipse cx="43.58" cy="21.56" rx="5.25" ry="2.10" transform="rotate(-10.00 43.58 21.56)"/>
<ellipse cx="55.82" cy="85.39" rx="7.00" ry="2.80" transform="rotate(-168.00 55.82 85.39)"/>
<ellipse cx="68.30" cy="81.14" rx="6.71" ry="2.68" transform="rotate(-141.67 68.30 81.14)"/>
<ellipse cx="78.02" cy="71.26" rx="6.42" ry="2.57" transform="rotate(-115.33 78.02 71.26)"/>
<ellipse cx="82.50" cy="57.43" rx="6.13" ry="2.45" transform="rotate(-89.00 82.50 57.43)"/>
<ellipse cx="80.20" cy="42.39" rx="5.83" ry="2.33" transform="rotate(-62.67 80.20 42.39)"/>
<ellipse cx="71.03" cy="29.40" rx="5.54" ry="2.22" transform="rotate(-36.33 71.03 29.40)"/>
<ellipse cx="56.42" cy="21.56" rx="5.25" ry="2.10" transform="rotate(-10.00 56.42 21.56)"/>
<circle cx="50" cy="85" r="2.4"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 420 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+15 -9
View File
@@ -49,27 +49,33 @@ export function JournalView({
}, []); }, []);
if (entries.length === 0) { if (entries.length === 0) {
return <p className="text-sm text-ink/70">Aucun événement pour l&apos;instant.</p>; return <p className="text-sm text-marble/60">Aucun décret pour l&apos;instant.</p>;
} }
return ( return (
<ol className="flex flex-col gap-2"> <ol className="flex flex-col gap-2">
{entries.map((entry) => { {entries.map((entry) => {
const judgePseudo = pseudoById[entry.judge_id] ?? "Un juge"; const judgePseudo = pseudoById[entry.judge_id] ?? "Un Archonte";
const targetPseudo = pseudoById[entry.target_id] ?? "un membre"; const targetPseudo = pseudoById[entry.target_id] ?? "un Citoyen";
const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`; const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`;
return ( return (
<li <li
key={entry.id} key={entry.id}
className="rounded-lg border border-navy/10 bg-white px-4 py-3 text-sm shadow-sm" className="marble-surface flex gap-3 rounded-lg border border-gold/25 px-4 py-3 text-sm shadow-sm"
> >
<span className="font-medium text-navy">{judgePseudo}</span> a donné{" "} <span
<span className={entry.delta >= 0 ? "font-semibold text-green-700" : "font-semibold text-red-700"}> className="mt-1 h-3 w-3 shrink-0 rounded-full bg-oxblood shadow-[0_0_0_2px_rgba(165,52,42,0.25)]"
aria-hidden
/>
<div>
<span className="font-medium text-text-marble">{judgePseudo}</span> a proclamé{" "}
<span className={entry.delta >= 0 ? "font-semibold text-olive" : "font-semibold text-oxblood"}>
{sign} {sign}
</span>{" "} </span>{" "}
à <span className="font-medium text-navy">{targetPseudo}</span> à <span className="font-medium text-text-marble">{targetPseudo}</span>
{entry.reason && <span className="text-ink/70"> {entry.reason}</span>} {entry.reason && <span className="text-text-mut"> {entry.reason}</span>}
<div className="mt-1 text-xs text-ink/50">{formatTime(entry.created_at)}</div> <div className="mt-1 text-xs text-text-mut">{formatTime(entry.created_at)}</div>
</div>
</li> </li>
); );
})} })}
+8 -2
View File
@@ -23,8 +23,14 @@ export default async function JournalPage() {
return ( return (
<div className="mx-auto w-full max-w-2xl px-4 py-8"> <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> <div className="mb-6 text-center">
<p className="mb-6 text-sm text-ink/60">Journal public de toutes les attributions de points.</p> <h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
Le Crieur
</h1>
<p className="font-serif text-sm text-marble/60 italic">
Le fil des décrets chaque honneur, chaque blâme, proclamé publiquement.
</p>
</div>
<JournalView initialEntries={entries ?? []} pseudoById={pseudoById} /> <JournalView initialEntries={entries ?? []} pseudoById={pseudoById} />
</div> </div>
); );
+30 -14
View File
@@ -1,22 +1,30 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Cinzel, Cormorant_Garamond, Manrope } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { NavBar } from "@/components/nav-bar"; import { Header } from "@/components/header";
const geistSans = Geist({ const cinzel = Cinzel({
variable: "--font-geist-sans", variable: "--font-cinzel",
subsets: ["latin"], subsets: ["latin"],
weight: ["500", "600", "700"],
}); });
const geistMono = Geist_Mono({ const cormorant = Cormorant_Garamond({
variable: "--font-geist-mono", variable: "--font-cormorant",
subsets: ["latin"],
weight: ["400", "500", "600"],
style: ["italic", "normal"],
});
const manrope = Manrope({
variable: "--font-manrope",
subsets: ["latin"], subsets: ["latin"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Le Tribunal", title: "Le Tribunal",
description: "Comptes et leaderboard pour le jeu du Tribunal", description: "Comptes, rôles et classement pour le jeu du Tribunal",
}; };
export default async function RootLayout({ export default async function RootLayout({
@@ -29,23 +37,31 @@ export default async function RootLayout({
data: { user }, data: { user },
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
let isJudge = false; let profile: { pseudo: string; avatar_url: string | null; role: string; points: number } | null =
null;
if (user) { if (user) {
const { data: profile } = await supabase const { data } = await supabase
.from("profiles") .from("profiles")
.select("role") .select("pseudo, avatar_url, role, points")
.eq("id", user.id) .eq("id", user.id)
.single(); .single();
isJudge = profile?.role === "judge"; profile = data;
} }
return ( return (
<html <html
lang="fr" lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${cinzel.variable} ${cormorant.variable} ${manrope.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col bg-ivory text-ink"> <body className="min-h-full flex flex-col">
{user && <NavBar isJudge={isJudge} />} {user && profile && (
<Header
pseudo={profile.pseudo}
avatarUrl={profile.avatar_url}
isJudge={profile.role === "judge"}
points={profile.points}
/>
)}
<main className="flex flex-1 flex-col">{children}</main> <main className="flex flex-1 flex-col">{children}</main>
</body> </body>
</html> </html>
+10 -8
View File
@@ -17,13 +17,13 @@ type Profile = {
function ProgressBadge({ direction, amount }: { direction: string; amount: number }) { function ProgressBadge({ direction, amount }: { direction: string; amount: number }) {
if (direction === "up") { if (direction === "up") {
return <span className="text-xs font-semibold text-green-700">{amount}</span>; return <span className="text-xs font-semibold text-olive">{amount}</span>;
} }
if (direction === "down") { if (direction === "down") {
return <span className="text-xs font-semibold text-red-700">{amount}</span>; return <span className="text-xs font-semibold text-oxblood">{amount}</span>;
} }
if (direction === "same") { if (direction === "same") {
return <span className="text-xs text-ink/40">=</span>; return <span className="text-xs text-text-mut">=</span>;
} }
return null; return null;
} }
@@ -82,7 +82,7 @@ export function LeaderboardView({
const rest = ranked.filter((p) => p.rank > 3); const rest = ranked.filter((p) => p.rank > 3);
if (profiles.length === 0) { if (profiles.length === 0) {
return <p className="text-sm text-ink/70">Personne n&apos;est encore inscrit.</p>; return <p className="text-sm text-marble/60">Personne n&apos;est encore inscrit.</p>;
} }
return ( return (
@@ -99,21 +99,23 @@ export function LeaderboardView({
return ( return (
<li <li
key={profile.id} key={profile.id}
className={`flex flex-col gap-2 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm transition-colors sm:flex-row sm:items-center sm:gap-3 ${ className={`marble-surface flex flex-col gap-2 rounded-lg border border-gold/25 px-4 py-3 shadow-sm transition-colors sm:flex-row sm:items-center sm:gap-3 ${
flashingId === profile.id ? "animate-points-flash" : "" flashingId === profile.id ? "animate-points-flash" : ""
}`} }`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="w-6 shrink-0 text-center font-semibold text-navy/60"> <span className="w-6 shrink-0 text-center font-heading text-text-mut">
{profile.rank} {profile.rank}
</span> </span>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" /> <Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<span className="flex-1 truncate font-medium sm:flex-initial">{profile.pseudo}</span> <span className="flex-1 truncate font-medium sm:flex-initial">{profile.pseudo}</span>
<ProgressBadge direction={progress.direction} amount={progress.amount} /> <ProgressBadge direction={progress.direction} amount={progress.amount} />
<span className="font-semibold text-navy sm:hidden">{profile.points}</span> <span className="font-semibold text-text-marble sm:hidden">{profile.points}</span>
</div> </div>
<div className="flex items-center gap-3 sm:ml-auto"> <div className="flex items-center gap-3 sm:ml-auto">
<span className="hidden font-semibold text-navy sm:inline">{profile.points}</span> <span className="hidden font-semibold text-text-marble sm:inline">
{profile.points}
</span>
{isJudge && ( {isJudge && (
<JudgePointControls memberId={profile.id} onApplyDelta={applyOptimisticDelta} /> <JudgePointControls memberId={profile.id} onApplyDelta={applyOptimisticDelta} />
)} )}
+8 -3
View File
@@ -21,11 +21,16 @@ export default async function LeaderboardPage() {
return ( return (
<div className="mx-auto w-full max-w-2xl px-4 py-8"> <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> <div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
L&apos;Agora
</h1>
<p className="font-serif text-sm text-marble/60 italic">Le classement des Citoyens</p>
</div>
{error && ( {error && (
<p role="alert" className="text-sm text-red-700"> <p role="alert" className="text-sm text-oxblood">
Impossible de charger le leaderboard pour le moment. Impossible de charger le classement pour le moment.
</p> </p>
)} )}
+17 -10
View File
@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { LaurelWreath } from "@/components/laurel-wreath";
function mapSignInError(message: string): string { function mapSignInError(message: string): string {
if (message.toLowerCase().includes("email not confirmed")) { if (message.toLowerCase().includes("email not confirmed")) {
@@ -40,14 +41,20 @@ export default function LoginPage() {
return ( return (
<div className="flex flex-1 items-center justify-center px-4 py-12"> <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 shadow-sm sm:p-8"> <div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
<h1 className="mb-6 text-center text-2xl font-semibold text-navy"> <div className="mb-4 flex flex-col items-center gap-2">
<LaurelWreath className="h-10 w-10" color="#A5342A" />
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Connexion Connexion
</h1> </h1>
<p className="font-serif text-sm text-text-mut italic">
« La balance ne ment jamais aux Citoyens vertueux. »
</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="email" className="text-sm font-medium"> <label htmlFor="email" className="text-sm font-medium text-text-marble">
Email Email
</label> </label>
<input <input
@@ -57,12 +64,12 @@ export default function LoginPage() {
required required
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} 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" className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-text-marble outline-none focus:border-gold"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm font-medium"> <label htmlFor="password" className="text-sm font-medium text-text-marble">
Mot de passe Mot de passe
</label> </label>
<input <input
@@ -72,12 +79,12 @@ export default function LoginPage() {
required required
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} 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" className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-text-marble outline-none focus:border-gold"
/> />
</div> </div>
{error && ( {error && (
<p role="alert" className="text-sm text-red-700"> <p role="alert" className="text-sm text-oxblood">
{error} {error}
</p> </p>
)} )}
@@ -85,15 +92,15 @@ export default function LoginPage() {
<button <button
type="submit" type="submit"
disabled={loading} 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" className="mt-2 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
> >
{loading ? "Connexion…" : "Se connecter"} {loading ? "Connexion…" : "Se connecter"}
</button> </button>
</form> </form>
<p className="mt-6 text-center text-sm"> <p className="mt-6 text-center text-sm text-text-mut">
Pas encore de compte ?{" "} Pas encore de compte ?{" "}
<Link href="/signup" className="font-medium text-navy underline hover:text-gold"> <Link href="/signup" className="font-medium text-oxblood underline hover:text-gold">
Créer un compte Créer un compte
</Link> </Link>
</p> </p>
+17 -4
View File
@@ -18,14 +18,27 @@ export default async function ProfilePage() {
.eq("id", user.id) .eq("id", user.id)
.single(); .single();
const isJudge = profile?.role === "judge";
return ( return (
<div className="mx-auto w-full max-w-sm px-4 py-8"> <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> <div className="mb-6 text-center">
<div className="rounded-xl border border-navy/10 bg-white p-6 shadow-sm sm:p-8"> <h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
<p className="mb-4 text-sm text-ink/60">{user.email}</p> Mon profil
</h1>
<span
className={`mt-1 inline-block font-heading text-xs tracking-wide uppercase ${
isJudge ? "text-gold-bright" : "text-marble/50"
}`}
>
{isJudge ? "⚜ Archonte" : "Citoyen"}
</span>
</div>
<div className="marble-surface rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
<p className="mb-4 text-sm text-text-mut">{user.email}</p>
<ProfileForm <ProfileForm
userId={user.id} userId={user.id}
isJudge={profile?.role === "judge"} isJudge={isJudge}
initialPseudo={profile?.pseudo ?? ""} initialPseudo={profile?.pseudo ?? ""}
initialAvatarUrl={profile?.avatar_url ?? null} initialAvatarUrl={profile?.avatar_url ?? null}
initialPseudoLocked={profile?.pseudo_locked ?? false} initialPseudoLocked={profile?.pseudo_locked ?? false}
+8 -8
View File
@@ -11,7 +11,7 @@ function mapError(message: string): string {
return "Ce pseudo est déjà pris."; return "Ce pseudo est déjà pris.";
} }
if (message.includes("pseudo is locked")) { if (message.includes("pseudo is locked")) {
return "Ton pseudo est déjà figé."; return "Ton pseudo est déjà figé, contacte un Archonte.";
} }
return "Une erreur est survenue, réessaie."; return "Une erreur est survenue, réessaie.";
} }
@@ -109,7 +109,7 @@ export function ProfileForm({
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium"> <label htmlFor="pseudo" className="text-sm font-medium text-text-marble">
Pseudo Pseudo
</label> </label>
<input <input
@@ -119,26 +119,26 @@ export function ProfileForm({
disabled={!canEditPseudo} disabled={!canEditPseudo}
value={pseudo} value={pseudo}
onChange={(event) => setPseudo(event.target.value)} 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 disabled:bg-navy/5 disabled:text-ink/50" className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-text-marble outline-none focus:border-gold disabled:bg-gold/5 disabled:text-text-mut"
/> />
{!canEditPseudo && ( {!canEditPseudo && (
<p className="text-xs text-ink/60"> <p className="text-xs text-text-mut">
Pseudo figé contacte un juge pour le changer. Pseudo figé contacte un Archonte pour le changer.
</p> </p>
)} )}
</div> </div>
{error && ( {error && (
<p role="alert" className="text-sm text-red-700"> <p role="alert" className="text-sm text-oxblood">
{error} {error}
</p> </p>
)} )}
{success && <p className="text-sm text-green-700">Profil mis à jour.</p>} {success && <p className="text-sm text-olive">Profil mis à jour.</p>}
<button <button
type="submit" type="submit"
disabled={loading} 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" className="mt-2 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
> >
{loading ? "Enregistrement…" : "Enregistrer"} {loading ? "Enregistrement…" : "Enregistrer"}
</button> </button>
+29 -17
View File
@@ -5,6 +5,7 @@ import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { AvatarPicker } from "@/components/avatar-picker"; import { AvatarPicker } from "@/components/avatar-picker";
import { LaurelWreath } from "@/components/laurel-wreath";
function mapSignUpError(message: string): string { function mapSignUpError(message: string): string {
const lower = message.toLowerCase(); const lower = message.toLowerCase();
@@ -102,16 +103,19 @@ export default function SignupPage() {
if (confirmationSent) { if (confirmationSent) {
return ( return (
<div className="flex flex-1 items-center justify-center px-4 py-12"> <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"> <div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 text-center shadow-xl sm:p-8">
<h1 className="mb-4 text-2xl font-semibold text-navy">Compte créé !</h1> <LaurelWreath className="mx-auto mb-3 h-10 w-10" color="#5E6B3B" />
<p className="text-sm text-ink/80"> <h1 className="mb-4 font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Compte créé !
</h1>
<p className="text-sm text-text-marble/80">
Vérifie ta boîte mail (et les spams) pour confirmer ton adresse, puis 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 connecte-toi. Tu pourras ajouter ta photo de profil ensuite depuis ton
profil. profil.
</p> </p>
<Link <Link
href="/login" href="/login"
className="mt-6 inline-block font-medium text-navy underline hover:text-gold" className="mt-6 inline-block font-medium text-oxblood underline hover:text-gold"
> >
Aller à la connexion Aller à la connexion
</Link> </Link>
@@ -122,14 +126,20 @@ export default function SignupPage() {
return ( return (
<div className="flex flex-1 items-center justify-center px-4 py-12"> <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 shadow-sm sm:p-8"> <div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
<h1 className="mb-6 text-center text-2xl font-semibold text-navy"> <div className="mb-4 flex flex-col items-center gap-2">
<LaurelWreath className="h-10 w-10" color="#A5342A" />
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Créer un compte Créer un compte
</h1> </h1>
<p className="font-serif text-sm text-text-mut italic">
« Que ton nom soit gravé parmi les Citoyens. »
</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="email" className="text-sm font-medium"> <label htmlFor="email" className="text-sm font-medium text-text-marble">
Email Email
</label> </label>
<input <input
@@ -139,12 +149,12 @@ export default function SignupPage() {
required required
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} 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" className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-text-marble outline-none focus:border-gold"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium"> <label htmlFor="pseudo" className="text-sm font-medium text-text-marble">
Pseudo Pseudo
</label> </label>
<input <input
@@ -154,12 +164,12 @@ export default function SignupPage() {
required required
value={pseudo} value={pseudo}
onChange={(event) => setPseudo(event.target.value)} 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-gold/30 bg-white/50 px-3 py-2 text-text-marble outline-none focus:border-gold"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm font-medium"> <label htmlFor="password" className="text-sm font-medium text-text-marble">
Mot de passe Mot de passe
</label> </label>
<input <input
@@ -170,18 +180,20 @@ export default function SignupPage() {
minLength={6} minLength={6}
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} 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" className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-text-marble outline-none focus:border-gold"
/> />
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<span className="self-start text-sm font-medium">Photo de profil (optionnel)</span> <span className="self-start text-sm font-medium text-text-marble">
Photo de profil (optionnel)
</span>
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview} size="lg" /> <Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview} size="lg" />
<AvatarPicker label="Photo de profil" onCropped={handleCropped} /> <AvatarPicker label="Photo de profil" onCropped={handleCropped} />
</div> </div>
{error && ( {error && (
<p role="alert" className="text-sm text-red-700"> <p role="alert" className="text-sm text-oxblood">
{error} {error}
</p> </p>
)} )}
@@ -189,15 +201,15 @@ export default function SignupPage() {
<button <button
type="submit" type="submit"
disabled={loading} 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" className="mt-2 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
> >
{loading ? "Création…" : "Créer mon compte"} {loading ? "Création…" : "Créer mon compte"}
</button> </button>
</form> </form>
<p className="mt-6 text-center text-sm"> <p className="mt-6 text-center text-sm text-text-mut">
Déjà un compte ?{" "} Déjà un compte ?{" "}
<Link href="/login" className="font-medium text-navy underline hover:text-gold"> <Link href="/login" className="font-medium text-oxblood underline hover:text-gold">
Se connecter Se connecter
</Link> </Link>
</p> </p>
+6 -6
View File
@@ -46,7 +46,7 @@ export function AvatarPicker({
if (rawImageSrc) { if (rawImageSrc) {
return ( return (
<div className="flex flex-col items-center gap-3"> <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"> <div className="relative h-64 w-64 max-w-full overflow-hidden rounded-md bg-ink-2/10">
<Cropper <Cropper
image={rawImageSrc} image={rawImageSrc}
crop={crop} crop={crop}
@@ -73,19 +73,19 @@ export function AvatarPicker({
<button <button
type="button" type="button"
onClick={() => setRawImageSrc(null)} onClick={() => setRawImageSrc(null)}
className="rounded-md border border-navy/20 px-3 py-1.5 text-sm text-navy hover:bg-navy/5" className="rounded-md border border-gold/40 px-3 py-1.5 text-sm text-text-marble hover:bg-gold/10"
> >
Annuler Annuler
</button> </button>
<button <button
type="button" type="button"
onClick={handleValidate} onClick={handleValidate}
className="rounded-md bg-navy px-3 py-1.5 text-sm font-medium text-ivory hover:bg-navy/90" className="rounded-md bg-ink-2 px-3 py-1.5 text-sm font-medium text-gold-bright hover:bg-ink"
> >
Valider le cadrage Valider le cadrage
</button> </button>
</div> </div>
{error && <p className="text-xs text-red-700">{error}</p>} {error && <p className="text-xs text-oxblood">{error}</p>}
</div> </div>
); );
} }
@@ -97,9 +97,9 @@ export function AvatarPicker({
type="file" type="file"
accept="image/*" accept="image/*"
onChange={handleFileChange} 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" className="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-ink-2 file:px-3 file:py-1.5 file:text-gold-bright"
/> />
{error && <p className="text-xs text-red-700">{error}</p>} {error && <p className="text-xs text-oxblood">{error}</p>}
</label> </label>
); );
} }
+10 -2
View File
@@ -4,6 +4,14 @@ function initials(pseudo: string): string {
return pseudo.trim().slice(0, 2).toUpperCase(); return pseudo.trim().slice(0, 2).toUpperCase();
} }
const FALLBACK_COLORS = ["bg-oxblood", "bg-sea", "bg-olive", "bg-ink-2"] as const;
function fallbackColor(pseudo: string): string {
let hash = 0;
for (const char of pseudo) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
return FALLBACK_COLORS[hash % FALLBACK_COLORS.length];
}
const SIZE_CLASSES = { const SIZE_CLASSES = {
sm: "h-8 w-8 text-xs", sm: "h-8 w-8 text-xs",
md: "h-12 w-12 text-sm", md: "h-12 w-12 text-sm",
@@ -33,14 +41,14 @@ export function Avatar({
width={96} width={96}
height={96} height={96}
unoptimized={isLocalPreview} unoptimized={isLocalPreview}
className={`${sizeClass} rounded-full object-cover`} className={`${sizeClass} rounded-full border-2 border-gold object-cover`}
/> />
); );
} }
return ( return (
<div <div
className={`${sizeClass} flex items-center justify-center rounded-full bg-navy font-semibold text-ivory`} className={`${sizeClass} flex items-center justify-center rounded-full border-2 border-gold font-heading font-semibold text-marble ${fallbackColor(pseudo)}`}
aria-hidden aria-hidden
> >
{initials(pseudo)} {initials(pseudo)}
+9
View File
@@ -0,0 +1,9 @@
export function DiamondDivider({ className = "" }: { className?: string }) {
return (
<div className={`flex items-center gap-3 ${className}`} aria-hidden>
<span className="h-px flex-1 bg-gradient-to-r from-transparent via-gold/60 to-gold/60" />
<span className="text-xs text-gold"></span>
<span className="h-px flex-1 bg-gradient-to-l from-transparent via-gold/60 to-gold/60" />
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { LaurelWreath } from "@/components/laurel-wreath";
import { Avatar } from "@/components/avatar";
function NavTab({ href, children }: { href: string; children: React.ReactNode }) {
const pathname = usePathname();
const isActive = pathname.startsWith(href);
return (
<Link
href={href}
className={`rounded-md px-3 py-1.5 font-heading text-xs tracking-wide uppercase transition-colors ${
isActive ? "bg-gold/15 text-gold-bright" : "text-marble/80 hover:text-gold-bright"
}`}
>
{children}
</Link>
);
}
export function Header({
pseudo,
avatarUrl,
isJudge,
points,
}: {
pseudo: string;
avatarUrl: string | null;
isJudge: boolean;
points: number;
}) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState(false);
const [signingOut, setSigningOut] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
async function handleSignOut() {
setSigningOut(true);
const supabase = createClient();
await supabase.auth.signOut();
router.push("/login");
router.refresh();
}
return (
<header className="sticky top-0 z-10 bg-ink-2/95 backdrop-blur">
<div className="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
<Link href="/leaderboard" className="flex items-center gap-2">
<LaurelWreath className="h-7 w-7 shrink-0" color="#E7C560" />
<span className="hidden font-heading text-sm tracking-[0.15em] text-gold-bright uppercase sm:inline">
Le Tribunal
</span>
</Link>
<div ref={menuRef} className="relative">
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
aria-expanded={menuOpen}
className="flex items-center gap-2 rounded-full px-1.5 py-1 transition-colors hover:bg-marble/10"
>
<Avatar pseudo={pseudo} avatarUrl={avatarUrl} size="sm" />
<span className="hidden flex-col items-start leading-tight sm:flex">
<span className="text-sm font-medium text-marble">{pseudo}</span>
<span
className={`font-heading text-[0.65rem] tracking-wide uppercase ${
isJudge ? "text-gold-bright" : "text-text-mut"
}`}
>
{isJudge ? "Archonte" : "Citoyen"} · {points} gloires
</span>
</span>
<svg
viewBox="0 0 24 24"
className={`h-4 w-4 text-marble/60 transition-transform ${menuOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{menuOpen && (
<div className="marble-surface absolute right-0 z-20 mt-2 w-52 rounded-lg border border-gold/40 py-1 shadow-lg">
<Link
href="/profile"
onClick={() => setMenuOpen(false)}
className="block px-4 py-2 text-sm hover:bg-gold/10"
>
Mon profil
</Link>
{isJudge && (
<Link
href="/admin"
onClick={() => setMenuOpen(false)}
className="block px-4 py-2 text-sm hover:bg-gold/10"
>
Le Conseil des Archontes
</Link>
)}
<button
type="button"
onClick={handleSignOut}
disabled={signingOut}
className="block w-full px-4 py-2 text-left text-sm text-oxblood hover:bg-gold/10 disabled:opacity-50"
>
{signingOut ? "…" : "Se déconnecter"}
</button>
</div>
)}
</div>
</div>
<nav className="mx-auto flex max-w-4xl items-center gap-1 px-4 pb-2">
<NavTab href="/leaderboard">L&apos;Agora</NavTab>
<NavTab href="/journal">Le Crieur</NavTab>
</nav>
<div className="meander-divider" />
</header>
);
}
+10 -8
View File
@@ -59,8 +59,9 @@ export function JudgePointControls({
<button <button
type="button" type="button"
onClick={() => setPending((p) => p - parsedAmount)} onClick={() => setPending((p) => p - parsedAmount)}
className={`${buttonHeight} ${buttonWidth} shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5`} className={`${buttonHeight} ${buttonWidth} shrink-0 rounded-md border border-oxblood/40 text-sm font-bold text-oxblood hover:bg-oxblood/10`}
aria-label={`Retirer ${parsedAmount} points`} aria-label={`Retirer ${parsedAmount} points`}
title="Blâme"
> >
</button> </button>
@@ -69,13 +70,14 @@ export function JudgePointControls({
min={1} min={1}
value={amount} value={amount}
onChange={(event) => setAmount(event.target.value)} onChange={(event) => setAmount(event.target.value)}
className={`${buttonHeight} ${inputWidth} rounded-md border border-navy/20 px-1 text-center text-xs`} className={`${buttonHeight} ${inputWidth} rounded-md border border-gold/30 bg-white/50 px-1 text-center text-xs text-text-marble`}
/> />
<button <button
type="button" type="button"
onClick={() => setPending((p) => p + parsedAmount)} onClick={() => setPending((p) => p + parsedAmount)}
className={`${buttonHeight} ${buttonWidth} shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5`} className={`${buttonHeight} ${buttonWidth} shrink-0 rounded-md border border-olive/40 text-sm font-bold text-olive hover:bg-olive/10`}
aria-label={`Ajouter ${parsedAmount} points`} aria-label={`Ajouter ${parsedAmount} points`}
title="Honneur"
> >
+ +
</button> </button>
@@ -86,14 +88,14 @@ export function JudgePointControls({
value={reason} value={reason}
onChange={(event) => setReason(event.target.value)} onChange={(event) => setReason(event.target.value)}
placeholder="motif (optionnel)" placeholder="motif (optionnel)"
className="h-7 w-28 rounded-md border border-navy/20 px-2 text-xs sm:w-36" className="h-7 w-28 rounded-md border border-gold/30 bg-white/50 px-2 text-xs text-text-marble sm:w-36"
/> />
)} )}
{pending !== 0 && ( {pending !== 0 && (
<> <>
<span <span
className={`text-sm font-semibold ${pending > 0 ? "text-green-700" : "text-red-700"}`} className={`text-sm font-semibold ${pending > 0 ? "text-olive" : "text-oxblood"}`}
> >
{pending > 0 ? `+${pending}` : pending} {pending > 0 ? `+${pending}` : pending}
</span> </span>
@@ -101,7 +103,7 @@ export function JudgePointControls({
type="button" type="button"
onClick={handleConfirm} onClick={handleConfirm}
disabled={submitting} disabled={submitting}
className="rounded-md bg-navy px-2 py-1 text-xs font-medium text-ivory hover:bg-navy/90 disabled:opacity-50" className="rounded-md bg-ink-2 px-2 py-1 text-xs font-medium text-gold-bright hover:bg-ink disabled:opacity-50"
> >
{submitting ? "…" : "Confirmer"} {submitting ? "…" : "Confirmer"}
</button> </button>
@@ -109,14 +111,14 @@ export function JudgePointControls({
type="button" type="button"
onClick={handleCancel} onClick={handleCancel}
disabled={submitting} disabled={submitting}
className="rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50" className="rounded-md border border-gold/30 px-2 py-1 text-xs text-text-marble hover:bg-gold/10 disabled:opacity-50"
> >
Annuler Annuler
</button> </button>
</> </>
)} )}
{error && <span className="text-xs text-red-700">{error}</span>} {error && <span className="text-xs text-oxblood">{error}</span>}
</div> </div>
); );
} }
+52
View File
@@ -0,0 +1,52 @@
const RAD = Math.PI / 180;
const LEAVES_PER_SIDE = 7;
const CENTER = { x: 50, y: 58 };
function leafTransform(angleDeg: number, radius: number) {
const rad = angleDeg * RAD;
const x = CENTER.x + radius * Math.cos(rad);
const y = CENTER.y - radius * Math.sin(rad);
return { x, y, rotate: -angleDeg + 90 };
}
function branchLeaves(mirror: boolean) {
const leaves = [];
for (let i = 0; i < LEAVES_PER_SIDE; i++) {
const t = i / (LEAVES_PER_SIDE - 1);
const leftAngle = 258 - t * 158; // 258° (bas) -> 100° (haut), en passant par la gauche
const angle = mirror ? 180 - leftAngle : leftAngle;
const radius = 28 + t * 9;
const { x, y, rotate } = leafTransform(angle, radius);
const scale = 1 - t * 0.25;
leaves.push(
<ellipse
key={`${mirror ? "r" : "l"}-${i}`}
cx={x}
cy={y}
rx={7 * scale}
ry={2.8 * scale}
transform={`rotate(${mirror ? -rotate : rotate} ${x} ${y})`}
/>,
);
}
return leaves;
}
// Couronne de laurier stylisée : deux branches symétriques de feuilles
// placées le long d'un arc (calcul trigonométrique, pas de tracé à la main),
// ouverte en haut. Utilisée comme logo et comme couronne du rang 1.
export function LaurelWreath({
className = "h-6 w-6",
color = "#C9A227",
}: {
className?: string;
color?: string;
}) {
return (
<svg viewBox="0 0 100 100" className={className} fill={color} aria-hidden>
{branchLeaves(false)}
{branchLeaves(true)}
<circle cx={CENTER.x} cy={CENTER.y + 27} r={2.4} />
</svg>
);
}
-12
View File
@@ -1,12 +0,0 @@
export function Logo({ className = "h-7 w-7" }: { className?: string }) {
return (
<svg viewBox="0 0 100 100" fill="none" className={className} aria-hidden>
<rect x="58" y="74" width="30" height="12" rx="3" fill="#C9A227" />
<rect x="54" y="70" width="38" height="6" rx="3" fill="#C9A227" opacity="0.7" />
<g transform="rotate(45 50 46)">
<rect x="30" y="10" width="18" height="46" rx="6" fill="#F4ECD8" />
<rect x="18" y="8" width="42" height="20" rx="6" fill="#C9A227" />
</g>
</svg>
);
}
-111
View File
@@ -1,111 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { Logo } from "@/components/logo";
function NavLink({
href,
onNavigate,
children,
}: {
href: string;
onNavigate?: () => void;
children: React.ReactNode;
}) {
const pathname = usePathname();
const isActive = pathname.startsWith(href);
return (
<Link
href={href}
onClick={onNavigate}
className={`block rounded-md px-3 py-2 transition-colors sm:px-2 sm:py-1 ${
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);
const [menuOpen, setMenuOpen] = useState(false);
async function handleSignOut() {
setSigningOut(true);
const supabase = createClient();
await supabase.auth.signOut();
router.push("/login");
router.refresh();
}
const links = (
<>
<NavLink href="/leaderboard" onNavigate={() => setMenuOpen(false)}>
Leaderboard
</NavLink>
<NavLink href="/journal" onNavigate={() => setMenuOpen(false)}>
Le crieur
</NavLink>
<NavLink href="/profile" onNavigate={() => setMenuOpen(false)}>
Profil
</NavLink>
{isJudge && (
<NavLink href="/admin" onNavigate={() => setMenuOpen(false)}>
Admin
</NavLink>
)}
<button
type="button"
onClick={handleSignOut}
disabled={signingOut}
className="block w-full rounded-md px-3 py-2 text-left transition-colors hover:text-gold disabled:opacity-50 sm:w-auto sm:px-2 sm:py-1"
>
{signingOut ? "…" : "Déconnexion"}
</button>
</>
);
return (
<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">
<Link href="/leaderboard" className="flex items-center gap-2 font-semibold tracking-wide">
<Logo className="h-6 w-6 sm:h-7 sm:w-7" />
<span>Le Tribunal</span>
</Link>
<div className="hidden items-center gap-1 text-sm sm:flex sm:gap-2">{links}</div>
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
aria-label={menuOpen ? "Fermer le menu" : "Ouvrir le menu"}
aria-expanded={menuOpen}
className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-ivory/10 sm:hidden"
>
<span className="sr-only">Menu</span>
{menuOpen ? (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
) : (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M4 7h16M4 12h16M4 17h16" strokeLinecap="round" />
</svg>
)}
</button>
</div>
{menuOpen && (
<div className="flex flex-col gap-1 border-t border-ivory/10 px-4 py-2 text-sm sm:hidden">
{links}
</div>
)}
</nav>
);
}
+11 -8
View File
@@ -1,5 +1,6 @@
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { JudgePointControls } from "@/components/judge-point-controls"; import { JudgePointControls } from "@/components/judge-point-controls";
import { LaurelWreath } from "@/components/laurel-wreath";
import type { RankedProfile } from "@/lib/ranking"; import type { RankedProfile } from "@/lib/ranking";
type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null; points: number }; type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null; points: number };
@@ -8,9 +9,9 @@ const SLOT_STYLES: Record<
number, number,
{ pedestal: string; order: string; ring: string; bg: string } { 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" }, 1: { pedestal: "h-28 sm:h-36", order: "order-2", ring: "ring-gold", bg: "bg-gold/20" },
2: { pedestal: "h-20 sm:h-28", order: "order-1", ring: "ring-navy/25", bg: "bg-navy/10" }, 2: { pedestal: "h-20 sm:h-28", order: "order-1", ring: "ring-ink-2/30", bg: "bg-ink-2/10" },
3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-navy/15", bg: "bg-navy/5" }, 3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-oxblood/25", bg: "bg-oxblood/10" },
}; };
export function Podium({ export function Podium({
@@ -36,7 +37,7 @@ export function Podium({
} }
return ( return (
<div className="mb-8 overflow-x-auto rounded-xl border border-navy/10 bg-white px-2 py-6 shadow-sm sm:px-8"> <div className="marble-surface mb-8 overflow-x-auto rounded-2xl border border-gold/40 px-2 py-6 shadow-xl sm:px-8">
<div className="flex min-w-fit items-end justify-center gap-2 sm:gap-8"> <div className="flex min-w-fit items-end justify-center gap-2 sm:gap-8">
{topRanks.map((rank) => { {topRanks.map((rank) => {
const members = byRank.get(rank)!; const members = byRank.get(rank)!;
@@ -46,16 +47,18 @@ export function Podium({
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
{members.map((member) => ( {members.map((member) => (
<div key={member.id} className="flex flex-col items-center gap-1"> <div key={member.id} className="flex flex-col items-center gap-1">
{rank === 1 && <span className="text-lg leading-none">👑</span>} {rank === 1 && <LaurelWreath className="h-6 w-6" color="#C9A227" />}
<Avatar <Avatar
pseudo={member.pseudo} pseudo={member.pseudo}
avatarUrl={member.avatar_url} avatarUrl={member.avatar_url}
size={rank === 1 ? "lg" : "md"} size={rank === 1 ? "lg" : "md"}
/> />
<span className="max-w-[6rem] truncate text-sm font-medium text-ink sm:max-w-[7rem]"> <span className="max-w-[6rem] truncate font-heading text-sm text-text-marble sm:max-w-[7rem]">
{member.pseudo} {member.pseudo}
</span> </span>
<span className="text-xs font-medium text-navy/60">{member.points} pts</span> <span className="text-xs font-medium text-text-mut">
{member.points} gloires
</span>
{isJudge && onApplyDelta && ( {isJudge && onApplyDelta && (
<JudgePointControls <JudgePointControls
memberId={member.id} memberId={member.id}
@@ -69,7 +72,7 @@ export function Podium({
<div <div
className={`flex w-16 sm:w-28 items-start justify-center rounded-t-md ${style.bg} pt-2 ring-1 sm:ring-2 ${style.pedestal} ${style.ring}`} className={`flex w-16 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> <span className="font-heading text-lg text-text-marble sm:text-2xl">{rank}</span>
</div> </div>
</div> </div>
); );