diff --git a/CLAUDE.md b/CLAUDE.md index e723081..35fe0e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,13 +6,14 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol ## 1. État actuel -V1 à V4 sont livrées : +V1 à V5 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. +- **V5** : navigation entièrement repliée dans le menu déroulant du chip (header épuré : logo + chip seulement), page **La Roulette** (tirage au sort animé), page **Le Calendrier des Dieux** (agenda de la semaine, panthéon, date du Tribunal), boutons de validation des points unifiés (`PointsConfirmControls`) entre podium et classement. -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. +Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, le jeu du Tribunal lui-même (Char, ostracisme, timer du Gardien), mode grand écran. --- @@ -48,6 +49,13 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo `id, target_id, judge_id, delta, reason, created_at` — écrit uniquement par la RPC `award_points`. +### `public.days` / `public.events` / `public.settings` (Calendrier des Dieux) + +- `days` : `id, date (unique), god_name, god_domain, description, created_at`. +- `events` : `id, day_id (fk days), title, description, type ('activite'|'defi'|'epreuve'|'tribunal'), start_time, location, created_by (fk profiles, forcé par trigger), created_at`. +- `settings` : ligne unique (`id boolean primary key default true`, contrainte `check (id)`) — `tribunal_date`. +- RLS : `SELECT` ouvert à tout authentifié ; `INSERT`/`UPDATE`/`DELETE` réservés au rôle `judge`, vérifié par policy (`exists (select 1 from profiles where id = auth.uid() and role = 'judge')`) — pas de trigger `BEFORE UPDATE` ici car il n'y a pas de colonne à protéger *dans une ligne par ailleurs modifiable par tous* (contrairement à `profiles`) : toute la table est verrouillée en écriture aux juges. + ### Colonnes sensibles : RLS + trigger, jamais confiance au client La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seulement si tel rôle ». Le verrouillage fin des colonnes (`role`, `points`, `pseudo_locked`, pseudo figé, `previous_rank`) passe par le trigger `enforce_profile_update()` : @@ -80,13 +88,15 @@ Email + mot de passe (Supabase Auth impose l'unicité de l'email). Confirmation | `/login` | Email + mot de passe | | `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email | | `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges | +| `/roulette` | « La Roulette » — tirage au sort animé parmi les membres (ou Citoyens uniquement) | +| `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges | | `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous | | `/profile` | Pseudo (verrouillable), photo, déconnexion | | `/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`, `/roulette`, `/calendrier` 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 ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front). -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. +Le header global (`components/header.tsx`) est rendu dans `layout.tsx` pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal », jamais masqué même à 320px) à gauche, chip utilisateur (avatar, pseudo, points, jamais masqués) à droite. **Aucun lien de navigation dans le header lui-même** — le chip ouvre un menu déroulant qui contient toutes les pages (avec icônes), fermeture au clic extérieur/Échap, entrée courante surlignée. --- @@ -101,8 +111,11 @@ Tokens couleur (`globals.css`, exposés comme utilities Tailwind via `@theme inl - `--oxblood #A5342A` : blâmes (points négatifs), sceaux, erreurs. - `--olive #5E6B3B` : honneurs (points positifs), succès. - `--sea #2E6E7E` : accent secondaire. +- `--silver #C7CDD6` / `--bronze #B08D57` : accents 2ᵉ/3ᵉ place du podium. - `--text-marble #2A2116` / `--text-mut #7A6A4C` : texte sur surfaces claires. +`components/points-confirm-controls.tsx` : boutons ✓/✕ compacts (32px, icône 16px) de validation/annulation des points en attente — utilisés à l'identique par le podium et le reste du classement (`JudgePointControls`). Ne pas dupliquer cette UI ailleurs, toujours passer par ce composant. + 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) : @@ -130,6 +143,6 @@ Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code ## 8. Roadmap (pas encore codé) - **É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, timer du Gardien. (La Roulette est livrée en V5.) - **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 e44a387..d9c2d20 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pe - 🏆 **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 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 +- 🎡 **La Roulette** : tirage au sort animé (avatars) parmi tous les membres ou les Citoyens uniquement +- 🗓️ **Le Calendrier des Dieux** : agenda de la semaine sous le patronage du Panthéon, date du Tribunal avec compte à rebours, édition réservée aux Archontes - 📱 **Responsive**, pensé mobile-first et propre en vidéoprojection --- @@ -52,7 +54,7 @@ npm install ### 2. Créer le projet Supabase 1. Créez un nouveau projet sur [supabase.com](https://supabase.com/). -2. Dans **SQL Editor**, exécutez tout le contenu de [`supabase/schema.sql`](supabase/schema.sql) (tables `profiles`/`points_log`, policies RLS, triggers, RPC, bucket `avatars`, Realtime). Le script est idempotent, vous pouvez le ré-exécuter sans risque après une mise à jour. +2. Dans **SQL Editor**, exécutez tout le contenu de [`supabase/schema.sql`](supabase/schema.sql) (tables `profiles`/`points_log`/`days`/`events`/`settings`, policies RLS, triggers, RPC, bucket `avatars`, Realtime). Le script est idempotent, vous pouvez le ré-exécuter sans risque après une mise à jour. 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. @@ -112,15 +114,19 @@ tribunal-app/ │ │ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur │ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges │ │ ├─ journal/ # « Le Crieur » : fil des décrets en direct +│ │ ├─ roulette/ # « La Roulette » : tirage au sort animé +│ │ ├─ calendrier/ # « Le Calendrier des Dieux » : agenda, panthéon, date du Tribunal │ │ ├─ admin/ # Le Conseil des Archontes : gestion des membres, reset du repère │ │ ├─ profile/ # Édition pseudo (verrou) / photo, déconnexion │ │ ├─ layout.tsx # Layout + header global │ │ ├─ icon.svg # Favicon (couronne de laurier) │ │ └─ page.tsx # Redirection selon l'état de connexion -│ ├─ components/ # Header (logo + chip utilisateur), Avatar, AvatarPicker, -│ │ │ # Podium, JudgePointControls, LaurelWreath, DiamondDivider +│ ├─ components/ # Header (logo + chip utilisateur + menu), Avatar, AvatarPicker, +│ │ │ # Podium, JudgePointControls, PointsConfirmControls, icons, +│ │ │ # LaurelWreath, DiamondDivider │ ├─ lib/ │ │ ├─ ranking.ts # Classement avec égalités + calcul des flèches +│ │ ├─ pantheon.ts # Préréglages des dieux + icônes par type d'événement │ │ ├─ image.ts # Recadrage/compression canvas côté client │ │ └─ supabase/ # Clients Supabase (browser / server / proxy) │ └─ proxy.ts # Protection des routes + rafraîchissement session @@ -177,6 +183,18 @@ Le service email intégré de Supabase (sans SMTP custom) limite l'envoi à quel --- +## Le Calendrier des Dieux + +Agenda de la semaine (`/calendrier`) : chaque journée est placée sous le patronage d'une divinité (nom, domaine, emblème SVG, couleur d'accent — 8 préréglages fournis dans `lib/pantheon.ts` : Dionysos, Arès, Athéna, Aphrodite, Hermès, Poséidon, Hadès, Zeus ; un Archonte peut en saisir d'autres librement). Chaque journée liste ses événements (activité/défi/épreuve/tribunal, heure, lieu, description). + +- **Lecture** : tous les authentifiés. +- **Écriture** (créer/modifier/supprimer une journée ou un événement, définir la date du Tribunal) : réservée aux Archontes, imposée par policies RLS sur `days`/`events`/`settings` — jamais par un simple masquage des boutons côté client. +- **Bannière** : compte à rebours avant la date du Tribunal (`settings.tribunal_date`), éditable par les Archontes. +- Le jour courant est mis en valeur, les jours passés sont estompés, le jour contenant un événement de type `tribunal` reçoit un accent oxblood distinct. +- Realtime activé sur les trois tables : toute modification est visible immédiatement par tous. + +--- + ## Sécurité - Mots de passe hachés et gérés par Supabase Auth (jamais stockés en clair). @@ -184,6 +202,7 @@ Le service email intégré de Supabase (sans SMTP custom) limite l'envoi à quel - 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. +- `days`/`events`/`settings` (Calendrier) : lecture ouverte à tous les authentifiés, écriture (insert/update/delete) réservée au rôle `judge` par policies RLS dédiées — un Citoyen qui appellerait l'API directement se ferait rejeter, pas seulement masquer les boutons. --- @@ -199,6 +218,7 @@ Thème fixe : un tribunal égéen de nuit — marbre, or, mer sombre, sceaux de | `--oxblood` | `#A5342A` | Blâmes (points négatifs), sceaux, erreurs | | `--olive` | `#5E6B3B` | Honneurs (points positifs), succès | | `--sea` | `#2E6E7E` | Accent secondaire | +| `--silver` / `--bronze` | `#C7CDD6` / `#B08D57` | Accents 2ᵉ/3ᵉ place du podium | | `--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`). @@ -212,7 +232,7 @@ Ce thème est **codé en dur** (pas de système de re-thématisation par éditio ## Roadmap - [ ] **É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, timer du Gardien (la Roulette est livrée) - [ ] **Mode grand écran** — vue leaderboard optimisée vidéoprojecteur --- diff --git a/src/app/calendrier/calendrier-view.tsx b/src/app/calendrier/calendrier-view.tsx new file mode 100644 index 0000000..13802ba --- /dev/null +++ b/src/app/calendrier/calendrier-view.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createClient } from "@/lib/supabase/client"; +import { DayCard, type DayRow } from "./day-card"; +import { PANTHEON_PRESETS, getGodPreset } from "@/lib/pantheon"; +import { IconPlus } from "@/components/icons"; + +function formatCountdown(tribunalDate: string | null): string { + if (!tribunalDate) return "Aucune date n'a encore été fixée par les Archontes."; + + const target = new Date(tribunalDate); + const now = new Date(); + const diffMs = target.getTime() - now.getTime(); + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMs < 0) return "Le Tribunal a rendu son verdict."; + if (diffDays === 0) return "Le Tribunal siège aujourd'hui !"; + if (diffDays === 1) return "Le Tribunal siège demain."; + return `Le Tribunal siège dans ${diffDays} jours.`; +} + +export function CalendrierView({ + isJudge, + initialDays, + initialTribunalDate, +}: { + isJudge: boolean; + initialDays: DayRow[]; + initialTribunalDate: string | null; +}) { + const [days, setDays] = useState(initialDays); + const [tribunalDate, setTribunalDate] = useState(initialTribunalDate); + const [editingTribunalDate, setEditingTribunalDate] = useState(false); + const [tribunalInput, setTribunalInput] = useState( + initialTribunalDate ? initialTribunalDate.slice(0, 16) : "", + ); + const [addingDay, setAddingDay] = useState(false); + const [newDate, setNewDate] = useState(""); + const [newGod, setNewGod] = useState(PANTHEON_PRESETS[0].name); + const [newGodDomain, setNewGodDomain] = useState(PANTHEON_PRESETS[0].domain); + const [customGod, setCustomGod] = useState(false); + const [error, setError] = useState(null); + + async function refetch() { + const supabase = createClient(); + const { data } = await supabase + .from("days") + .select("id, date, god_name, god_domain, description, events(*)") + .order("date", { ascending: true }); + if (data) { + setDays( + data.map((day) => ({ + ...day, + events: [...day.events].sort((a, b) => + (a.start_time ?? "99:99").localeCompare(b.start_time ?? "99:99"), + ), + })), + ); + } + + const { data: settingsRow } = await supabase + .from("settings") + .select("tribunal_date") + .eq("id", true) + .single(); + setTribunalDate(settingsRow?.tribunal_date ?? null); + } + + useEffect(() => { + const supabase = createClient(); + const channel = supabase + .channel("calendrier-changes") + .on("postgres_changes", { event: "*", schema: "public", table: "days" }, refetch) + .on("postgres_changes", { event: "*", schema: "public", table: "events" }, refetch) + .on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, []); + + async function handleSaveTribunalDate() { + const supabase = createClient(); + const isoValue = tribunalInput ? new Date(tribunalInput).toISOString() : null; + const { error: updateError } = await supabase + .from("settings") + .update({ tribunal_date: isoValue }) + .eq("id", true); + + if (!updateError) { + setTribunalDate(isoValue); + setEditingTribunalDate(false); + } + } + + async function handleAddDay(event: React.FormEvent) { + event.preventDefault(); + setError(null); + if (!newDate) { + setError("Choisis une date."); + return; + } + + const supabase = createClient(); + const { error: insertError } = await supabase.from("days").insert({ + date: newDate, + god_name: newGod, + god_domain: newGodDomain, + }); + + if (insertError) { + setError( + insertError.message.includes("duplicate key") + ? "Une journée existe déjà à cette date." + : "Une erreur est survenue.", + ); + return; + } + + setAddingDay(false); + setNewDate(""); + setNewGod(PANTHEON_PRESETS[0].name); + setNewGodDomain(PANTHEON_PRESETS[0].domain); + setCustomGod(false); + refetch(); + } + + return ( +
+
+

+ {formatCountdown(tribunalDate)} +

+ {tribunalDate && ( +

+ {new Date(tribunalDate).toLocaleString("fr-FR", { + dateStyle: "full", + timeStyle: "short", + timeZone: "Europe/Paris", + })} +

+ )} + + {isJudge && ( +
+ {editingTribunalDate ? ( +
+ setTribunalInput(e.target.value)} + className="rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-sm text-text-marble" + /> + + +
+ ) : ( + + )} +
+ )} +
+ + {days.length === 0 && !addingDay && ( +

+ Aucune journée n'a encore été inscrite au calendrier. +

+ )} + +
+ {days.map((day) => ( + + ))} +
+ + {isJudge && ( +
+ {addingDay ? ( +
+
+ + setNewDate(e.target.value)} + required + className="rounded-md border border-gold/30 bg-white/50 px-2 py-1.5 text-sm text-text-marble" + /> +
+ +
+ + +
+ + {customGod && ( +
+ + setNewGod(e.target.value)} + required + className="rounded-md border border-gold/30 bg-white/50 px-2 py-1.5 text-sm text-text-marble" + /> +
+ )} + +
+ + setNewGodDomain(e.target.value)} + required + className="rounded-md border border-gold/30 bg-white/50 px-2 py-1.5 text-sm text-text-marble" + /> +
+ + {error &&

{error}

} + +
+ + +
+
+ ) : ( + + )} +
+ )} +
+ ); +} diff --git a/src/app/calendrier/day-card.tsx b/src/app/calendrier/day-card.tsx new file mode 100644 index 0000000..9dd60fd --- /dev/null +++ b/src/app/calendrier/day-card.tsx @@ -0,0 +1,341 @@ +"use client"; + +import { useState } from "react"; +import { createClient } from "@/lib/supabase/client"; +import { getGodColor, getEventTypeLabel, EVENT_TYPES } from "@/lib/pantheon"; +import { IconPlus, IconPencil, IconTrash } from "@/components/icons"; +import { GodEmblem, EventTypeIcon } from "@/components/pantheon-icons"; + +export type EventRow = { + id: string; + day_id: string; + title: string; + description: string | null; + type: string; + start_time: string | null; + location: string | null; +}; + +export type DayRow = { + id: string; + date: string; + god_name: string; + god_domain: string; + description: string | null; + events: EventRow[]; +}; + +function formatDate(dateStr: string): { weekday: string; date: string } { + const d = new Date(`${dateStr}T12:00:00`); + const weekday = d.toLocaleDateString("fr-FR", { weekday: "long", timeZone: "Europe/Paris" }); + const date = d.toLocaleDateString("fr-FR", { day: "numeric", month: "long", timeZone: "Europe/Paris" }); + return { weekday: weekday.charAt(0).toUpperCase() + weekday.slice(1), date }; +} + +function isToday(dateStr: string): boolean { + const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }); + return dateStr === today; +} + +function isPast(dateStr: string): boolean { + const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }); + return dateStr < today; +} + +function EventForm({ + dayId, + initial, + onDone, +}: { + dayId: string; + initial?: EventRow; + onDone: () => void; +}) { + const [title, setTitle] = useState(initial?.title ?? ""); + const [description, setDescription] = useState(initial?.description ?? ""); + const [type, setType] = useState(initial?.type ?? "activite"); + const [startTime, setStartTime] = useState(initial?.start_time?.slice(0, 5) ?? ""); + const [location, setLocation] = useState(initial?.location ?? ""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!title.trim()) { + setError("Titre requis."); + return; + } + setSaving(true); + setError(null); + + const supabase = createClient(); + const payload = { + title: title.trim(), + description: description.trim() || null, + type, + start_time: startTime || null, + location: location.trim() || null, + }; + + const { error: opError } = initial + ? await supabase.from("events").update(payload).eq("id", initial.id) + : await supabase.from("events").insert({ ...payload, day_id: dayId }); + + setSaving(false); + if (opError) { + setError("Une erreur est survenue."); + return; + } + onDone(); + } + + return ( +
+ setTitle(e.target.value)} + className="rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble" + /> +
+ + setStartTime(e.target.value)} + className="rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble" + /> + setLocation(e.target.value)} + className="min-w-0 flex-1 rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble" + /> +
+