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:
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 |
@@ -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'instant.</p>;
|
return <p className="text-sm text-marble/60">Aucun décret pour l'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)]"
|
||||||
{sign}
|
aria-hidden
|
||||||
</span>{" "}
|
/>
|
||||||
à <span className="font-medium text-navy">{targetPseudo}</span>
|
<div>
|
||||||
{entry.reason && <span className="text-ink/70"> — {entry.reason}</span>}
|
<span className="font-medium text-text-marble">{judgePseudo}</span> a proclamé{" "}
|
||||||
<div className="mt-1 text-xs text-ink/50">{formatTime(entry.created_at)}</div>
|
<span className={entry.delta >= 0 ? "font-semibold text-olive" : "font-semibold text-oxblood"}>
|
||||||
|
{sign}
|
||||||
|
</span>{" "}
|
||||||
|
à <span className="font-medium text-text-marble">{targetPseudo}</span>
|
||||||
|
{entry.reason && <span className="text-text-mut"> — {entry.reason}</span>}
|
||||||
|
<div className="mt-1 text-xs text-text-mut">{formatTime(entry.created_at)}</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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'est encore inscrit.</p>;
|
return <p className="text-sm text-marble/60">Personne n'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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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'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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+19
-12
@@ -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">
|
||||||
Connexion
|
<LaurelWreath className="h-10 w-10" color="#A5342A" />
|
||||||
</h1>
|
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
|
||||||
|
Connexion
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+31
-19
@@ -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">
|
||||||
Créer un compte
|
<LaurelWreath className="h-10 w-10" color="#A5342A" />
|
||||||
</h1>
|
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
|
||||||
|
Créer un compte
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'Agora</NavTab>
|
||||||
|
<NavTab href="/journal">Le Crieur</NavTab>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="meander-divider" />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user