diff --git a/CLAUDE.md b/CLAUDE.md index 3581a74..e723081 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,16 +2,17 @@ # 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 -V1, V2 et V3 sont livrées : +V1 à V4 sont livrées : - **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. - **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` | | `/login` | Email + mot de passe | | `/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 | -| `/journal` | « Le crieur » — fil live des attributions de points, lecture pour tous | +| `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges | +| `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous | | `/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. +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é) -- **É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. - **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur. - **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README). diff --git a/README.md b/README.md index fb03a4c..e44a387 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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) - 👤 **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 » -- ⚖️ **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 - 📱 **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 | |--------------------------|---------------------------------| | 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) | | Recadrage photo | react-easy-crop + canvas (resize/compression client) | | 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**. 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. -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 @@ -110,13 +111,14 @@ tribunal-app/ │ │ ├─ leaderboard/ │ │ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur │ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges -│ │ ├─ journal/ # « Le crieur » : fil d'événements en direct -│ │ ├─ admin/ # Gestion des membres + reset du repère (juges) +│ │ ├─ journal/ # « Le Crieur » : fil des décrets en direct +│ │ ├─ admin/ # Le Conseil des Archontes : gestion des membres, reset du repère │ │ ├─ 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 -│ ├─ components/ # Avatar, AvatarPicker (recadrage), NavBar, Podium, -│ │ │ # JudgePointControls +│ ├─ components/ # Header (logo + chip utilisateur), Avatar, AvatarPicker, +│ │ │ # Podium, JudgePointControls, LaurelWreath, DiamondDivider │ ├─ lib/ │ │ ├─ ranking.ts # Classement avec égalités + calcul des flèches │ │ ├─ 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 -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 @@ -148,7 +150,7 @@ select id, pseudo from public.profiles; update public.profiles set role = 'judge' where id = ''; ``` -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 @@ -168,10 +170,10 @@ Le service email intégré de Supabase (sans SMTP custom) limite l'envoi à quel ## 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. - **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). - **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. - 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 -- [ ] **É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 - [ ] **Mode grand écran** — vue leaderboard optimisée vidéoprojecteur diff --git a/src/app/admin/admin-members-table.tsx b/src/app/admin/admin-members-table.tsx index 616d0d0..8f82357 100644 --- a/src/app/admin/admin-members-table.tsx +++ b/src/app/admin/admin-members-table.tsx @@ -77,14 +77,14 @@ function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) { } return ( -
  • +
  • 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" />
    @@ -93,10 +93,10 @@ function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) { value={role} onChange={(event) => setRole(event.target.value as Role)} disabled={isSelf} - className="rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold disabled:opacity-50" + 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" > - - + + {pseudoLocked ? ( @@ -104,26 +104,26 @@ function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) { type="button" onClick={handleUnlock} disabled={saving} - className="whitespace-nowrap rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50" + 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 ) : ( - pseudo libre + pseudo libre )} - {error &&

    {error}

    } - {success && !dirty &&

    OK

    } + {error &&

    {error}

    } + {success && !dirty &&

    OK

    }
  • ); } @@ -136,7 +136,7 @@ export function AdminMembersTable({ currentUserId: string; }) { if (members.length === 0) { - return

    Aucun membre inscrit.

    ; + return

    Aucun Citoyen inscrit.

    ; } return ( diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a84f0a2..c689a74 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -33,7 +33,12 @@ export default async function AdminPage() { return (
    -

    Admin

    +
    +

    + Le Conseil des Archontes +

    +

    Administration des Citoyens

    +
    diff --git a/src/app/admin/reset-round-button.tsx b/src/app/admin/reset-round-button.tsx index 737e4fa..9dabfca 100644 --- a/src/app/admin/reset-round-button.tsx +++ b/src/app/admin/reset-round-button.tsx @@ -21,7 +21,7 @@ export function ResetRoundButton() { type="button" onClick={handleClick} disabled={state === "loading"} - className="rounded-md border border-navy/20 bg-white px-3 py-1.5 text-sm font-medium text-navy shadow-sm transition hover:bg-navy/5 disabled:opacity-50" + 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)"} diff --git a/src/app/globals.css b/src/app/globals.css index a7dff31..a1a1de3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,40 +1,79 @@ @import "tailwindcss"; :root { - --color-navy: #0f2748; - --color-gold: #c9a227; - --color-ivory: #f4ecd8; - --color-ink: #2a2116; + --ink: #0a1b33; + --ink-2: #0f2748; + --marble: #f4ecd8; + --marble-2: #e9dec2; + --gold: #c9a227; + --gold-bright: #e7c560; + --oxblood: #a5342a; + --sea: #2e6e7e; + --olive: #5e6b3b; + --text-marble: #2a2116; + --text-mut: #7a6a4c; - --background: var(--color-ivory); - --foreground: var(--color-ink); + --background: var(--ink); + --foreground: var(--marble); } @theme inline { - --color-navy: var(--color-navy); - --color-gold: var(--color-gold); - --color-ivory: var(--color-ivory); - --color-ink: var(--color-ink); + --color-ink: var(--ink); + --color-ink-2: var(--ink-2); + --color-marble: var(--marble); + --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-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 { - 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); font-family: var(--font-sans), Arial, Helvetica, sans-serif; } *:focus-visible { - outline: 2px solid var(--color-gold); + outline: 2px solid var(--gold-bright); 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 { 0% { - background-color: color-mix(in srgb, var(--color-gold) 55%, transparent); + background-color: color-mix(in srgb, var(--gold-bright) 55%, transparent); } 100% { background-color: transparent; diff --git a/src/app/icon.svg b/src/app/icon.svg index 828782b..62a2d63 100644 --- a/src/app/icon.svg +++ b/src/app/icon.svg @@ -1,8 +1,18 @@ - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/src/app/journal/journal-view.tsx b/src/app/journal/journal-view.tsx index e1206ae..56486ab 100644 --- a/src/app/journal/journal-view.tsx +++ b/src/app/journal/journal-view.tsx @@ -49,27 +49,33 @@ export function JournalView({ }, []); if (entries.length === 0) { - return

    Aucun événement pour l'instant.

    ; + return

    Aucun décret pour l'instant.

    ; } return (
      {entries.map((entry) => { - const judgePseudo = pseudoById[entry.judge_id] ?? "Un juge"; - const targetPseudo = pseudoById[entry.target_id] ?? "un membre"; + const judgePseudo = pseudoById[entry.judge_id] ?? "Un Archonte"; + const targetPseudo = pseudoById[entry.target_id] ?? "un Citoyen"; const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`; return (
    1. - {judgePseudo} a donné{" "} - = 0 ? "font-semibold text-green-700" : "font-semibold text-red-700"}> - {sign} - {" "} - à {targetPseudo} - {entry.reason && — {entry.reason}} -
      {formatTime(entry.created_at)}
      + +
      + {judgePseudo} a proclamé{" "} + = 0 ? "font-semibold text-olive" : "font-semibold text-oxblood"}> + {sign} + {" "} + à {targetPseudo} + {entry.reason && — {entry.reason}} +
      {formatTime(entry.created_at)}
      +
    2. ); })} diff --git a/src/app/journal/page.tsx b/src/app/journal/page.tsx index d2957d8..81f89ac 100644 --- a/src/app/journal/page.tsx +++ b/src/app/journal/page.tsx @@ -23,8 +23,14 @@ export default async function JournalPage() { return (
      -

      Le crieur

      -

      Journal public de toutes les attributions de points.

      +
      +

      + Le Crieur +

      +

      + Le fil des décrets — chaque honneur, chaque blâme, proclamé publiquement. +

      +
      ); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e4db8d4..b225af8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,22 +1,30 @@ 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 { createClient } from "@/lib/supabase/server"; -import { NavBar } from "@/components/nav-bar"; +import { Header } from "@/components/header"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const cinzel = Cinzel({ + variable: "--font-cinzel", subsets: ["latin"], + weight: ["500", "600", "700"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const cormorant = Cormorant_Garamond({ + variable: "--font-cormorant", + subsets: ["latin"], + weight: ["400", "500", "600"], + style: ["italic", "normal"], +}); + +const manrope = Manrope({ + variable: "--font-manrope", subsets: ["latin"], }); export const metadata: Metadata = { 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({ @@ -29,23 +37,31 @@ export default async function RootLayout({ data: { user }, } = await supabase.auth.getUser(); - let isJudge = false; + let profile: { pseudo: string; avatar_url: string | null; role: string; points: number } | null = + null; if (user) { - const { data: profile } = await supabase + const { data } = await supabase .from("profiles") - .select("role") + .select("pseudo, avatar_url, role, points") .eq("id", user.id) .single(); - isJudge = profile?.role === "judge"; + profile = data; } return ( - - {user && } + + {user && profile && ( +
      + )}
      {children}
      diff --git a/src/app/leaderboard/leaderboard-view.tsx b/src/app/leaderboard/leaderboard-view.tsx index 6462149..d9bd9b8 100644 --- a/src/app/leaderboard/leaderboard-view.tsx +++ b/src/app/leaderboard/leaderboard-view.tsx @@ -17,13 +17,13 @@ type Profile = { function ProgressBadge({ direction, amount }: { direction: string; amount: number }) { if (direction === "up") { - return ▲{amount}; + return ▲{amount}; } if (direction === "down") { - return ▼{amount}; + return ▼{amount}; } if (direction === "same") { - return =; + return =; } return null; } @@ -82,7 +82,7 @@ export function LeaderboardView({ const rest = ranked.filter((p) => p.rank > 3); if (profiles.length === 0) { - return

      Personne n'est encore inscrit.

      ; + return

      Personne n'est encore inscrit.

      ; } return ( @@ -99,21 +99,23 @@ export function LeaderboardView({ return (
    3. - + {profile.rank} {profile.pseudo} - {profile.points} + {profile.points}
      - {profile.points} + + {profile.points} + {isJudge && ( )} diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index e210117..5582728 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -21,11 +21,16 @@ export default async function LeaderboardPage() { return (
      -

      Leaderboard

      +
      +

      + L'Agora +

      +

      Le classement des Citoyens

      +
      {error && ( -

      - Impossible de charger le leaderboard pour le moment. +

      + Impossible de charger le classement pour le moment.

      )} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index e1e6cb9..b487970 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useState, type FormEvent } from "react"; import { createClient } from "@/lib/supabase/client"; +import { LaurelWreath } from "@/components/laurel-wreath"; function mapSignInError(message: string): string { if (message.toLowerCase().includes("email not confirmed")) { @@ -40,14 +41,20 @@ export default function LoginPage() { return (
      -
      -

      - Connexion -

      +
      +
      + +

      + Connexion +

      +

      + « La balance ne ment jamais aux Citoyens vertueux. » +

      +
      -
      -
      {error && ( -

      +

      {error}

      )} @@ -85,15 +92,15 @@ export default function LoginPage() {
      -

      +

      Pas encore de compte ?{" "} - + Créer un compte

      diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 7210719..66b3756 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -18,14 +18,27 @@ export default async function ProfilePage() { .eq("id", user.id) .single(); + const isJudge = profile?.role === "judge"; + return (
      -

      Mon profil

      -
      -

      {user.email}

      +
      +

      + Mon profil +

      + + {isJudge ? "⚜ Archonte" : "Citoyen"} + +
      +
      +

      {user.email}

      -
      {error && ( -

      +

      {error}

      )} - {success &&

      Profil mis à jour.

      } + {success &&

      Profil mis à jour.

      } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index b6a5330..f180c12 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -5,6 +5,7 @@ import { useState, type FormEvent } from "react"; import { createClient } from "@/lib/supabase/client"; import { Avatar } from "@/components/avatar"; import { AvatarPicker } from "@/components/avatar-picker"; +import { LaurelWreath } from "@/components/laurel-wreath"; function mapSignUpError(message: string): string { const lower = message.toLowerCase(); @@ -102,16 +103,19 @@ export default function SignupPage() { if (confirmationSent) { return (
      -
      -

      Compte créé !

      -

      +

      + +

      + Compte créé ! +

      +

      Vérifie ta boîte mail (et les spams) pour confirmer ton adresse, puis connecte-toi. Tu pourras ajouter ta photo de profil ensuite depuis ton profil.

      Aller à la connexion @@ -122,14 +126,20 @@ export default function SignupPage() { return (
      -
      -

      - Créer un compte -

      +
      +
      + +

      + Créer un compte +

      +

      + « Que ton nom soit gravé parmi les Citoyens. » +

      +
      -
      -
      -
      - Photo de profil (optionnel) + + Photo de profil (optionnel) +
      {error && ( -

      +

      {error}

      )} @@ -189,15 +201,15 @@ export default function SignupPage() {
      -

      +

      Déjà un compte ?{" "} - + Se connecter

      diff --git a/src/components/avatar-picker.tsx b/src/components/avatar-picker.tsx index 11902a7..8555723 100644 --- a/src/components/avatar-picker.tsx +++ b/src/components/avatar-picker.tsx @@ -46,7 +46,7 @@ export function AvatarPicker({ if (rawImageSrc) { return (
      -
      +
      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
      - {error &&

      {error}

      } + {error &&

      {error}

      }
      ); } @@ -97,9 +97,9 @@ export function AvatarPicker({ type="file" accept="image/*" onChange={handleFileChange} - className="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-navy file:px-3 file:py-1.5 file:text-ivory" + 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 &&

      {error}

      } + {error &&

      {error}

      } ); } diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx index 18f1d8f..848c434 100644 --- a/src/components/avatar.tsx +++ b/src/components/avatar.tsx @@ -4,6 +4,14 @@ function initials(pseudo: string): string { 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 = { sm: "h-8 w-8 text-xs", md: "h-12 w-12 text-sm", @@ -33,14 +41,14 @@ export function Avatar({ width={96} height={96} unoptimized={isLocalPreview} - className={`${sizeClass} rounded-full object-cover`} + className={`${sizeClass} rounded-full border-2 border-gold object-cover`} /> ); } return (
      {initials(pseudo)} diff --git a/src/components/diamond-divider.tsx b/src/components/diamond-divider.tsx new file mode 100644 index 0000000..d177711 --- /dev/null +++ b/src/components/diamond-divider.tsx @@ -0,0 +1,9 @@ +export function DiamondDivider({ className = "" }: { className?: string }) { + return ( +
      + + + +
      + ); +} diff --git a/src/components/header.tsx b/src/components/header.tsx new file mode 100644 index 0000000..be3d3ff --- /dev/null +++ b/src/components/header.tsx @@ -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 ( + + {children} + + ); +} + +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(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 ( +
      +
      + + + + Le Tribunal + + + +
      + + + {menuOpen && ( +
      + setMenuOpen(false)} + className="block px-4 py-2 text-sm hover:bg-gold/10" + > + Mon profil + + {isJudge && ( + setMenuOpen(false)} + className="block px-4 py-2 text-sm hover:bg-gold/10" + > + Le Conseil des Archontes + + )} + +
      + )} +
      +
      + + + +
      +
      + ); +} diff --git a/src/components/judge-point-controls.tsx b/src/components/judge-point-controls.tsx index 0577d24..a0a78a6 100644 --- a/src/components/judge-point-controls.tsx +++ b/src/components/judge-point-controls.tsx @@ -59,8 +59,9 @@ export function JudgePointControls({ @@ -69,13 +70,14 @@ export function JudgePointControls({ min={1} value={amount} 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`} /> @@ -86,14 +88,14 @@ export function JudgePointControls({ value={reason} onChange={(event) => setReason(event.target.value)} placeholder="motif (optionnel)" - className="h-7 w-28 rounded-md border border-navy/20 px-2 text-xs sm:w-36" + 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 && ( <> 0 ? "text-green-700" : "text-red-700"}`} + className={`text-sm font-semibold ${pending > 0 ? "text-olive" : "text-oxblood"}`} > {pending > 0 ? `+${pending}` : pending} @@ -101,7 +103,7 @@ export function JudgePointControls({ type="button" onClick={handleConfirm} disabled={submitting} - className="rounded-md bg-navy px-2 py-1 text-xs font-medium text-ivory hover:bg-navy/90 disabled:opacity-50" + 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"} @@ -109,14 +111,14 @@ export function JudgePointControls({ type="button" onClick={handleCancel} disabled={submitting} - className="rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50" + className="rounded-md border border-gold/30 px-2 py-1 text-xs text-text-marble hover:bg-gold/10 disabled:opacity-50" > Annuler )} - {error && {error}} + {error && {error}}
      ); } diff --git a/src/components/laurel-wreath.tsx b/src/components/laurel-wreath.tsx new file mode 100644 index 0000000..58c9234 --- /dev/null +++ b/src/components/laurel-wreath.tsx @@ -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( + , + ); + } + 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 ( + + {branchLeaves(false)} + {branchLeaves(true)} + + + ); +} diff --git a/src/components/logo.tsx b/src/components/logo.tsx deleted file mode 100644 index e0e0177..0000000 --- a/src/components/logo.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export function Logo({ className = "h-7 w-7" }: { className?: string }) { - return ( - - - - - - - - - ); -} diff --git a/src/components/nav-bar.tsx b/src/components/nav-bar.tsx deleted file mode 100644 index df581c7..0000000 --- a/src/components/nav-bar.tsx +++ /dev/null @@ -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 ( - - {children} - - ); -} - -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 = ( - <> - setMenuOpen(false)}> - Leaderboard - - setMenuOpen(false)}> - Le crieur - - setMenuOpen(false)}> - Profil - - {isJudge && ( - setMenuOpen(false)}> - Admin - - )} - - - ); - - return ( - - ); -} diff --git a/src/components/podium.tsx b/src/components/podium.tsx index b518358..38d3349 100644 --- a/src/components/podium.tsx +++ b/src/components/podium.tsx @@ -1,5 +1,6 @@ import { Avatar } from "@/components/avatar"; import { JudgePointControls } from "@/components/judge-point-controls"; +import { LaurelWreath } from "@/components/laurel-wreath"; import type { RankedProfile } from "@/lib/ranking"; type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null; points: number }; @@ -8,9 +9,9 @@ const SLOT_STYLES: Record< number, { pedestal: string; order: string; ring: string; bg: string } > = { - 1: { pedestal: "h-28 sm:h-36", order: "order-2", ring: "ring-gold", bg: "bg-gold/15" }, - 2: { pedestal: "h-20 sm:h-28", order: "order-1", ring: "ring-navy/25", bg: "bg-navy/10" }, - 3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-navy/15", bg: "bg-navy/5" }, + 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-ink-2/30", bg: "bg-ink-2/10" }, + 3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-oxblood/25", bg: "bg-oxblood/10" }, }; export function Podium({ @@ -36,7 +37,7 @@ export function Podium({ } return ( -
      +
      {topRanks.map((rank) => { const members = byRank.get(rank)!; @@ -46,16 +47,18 @@ export function Podium({
      {members.map((member) => (
      - {rank === 1 && 👑} + {rank === 1 && } - + {member.pseudo} - {member.points} pts + + {member.points} gloires + {isJudge && onApplyDelta && ( - {rank} + {rank}
      );