From 5cb19caec2d5cefcfb7c92b30d6626d6b22cb00c Mon Sep 17 00:00:00 2001 From: Valentin ROBIN Date: Sun, 12 Jul 2026 21:13:53 +0200 Subject: [PATCH] =?UTF-8?q?Boutons=20de=20points=20compacts=20unifi=C3=A9s?= =?UTF-8?q?=20+=20page=20Le=20Calendrier=20des=20Dieux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boutons de validation des points : - Les icônes ✓/✕ étaient trop grandes (40px) et dupliquées entre le podium et le reste du classement. Factorisées dans un composant partagé PointsConfirmControls (32px, icône 16px, olive/oxblood, aria-label + tooltip), utilisé à l'identique par JudgePointControls sur le podium ET dans la liste. Nouvelle page /calendrier — "Le Calendrier des Dieux" : - Modèle de données : days (date, dieu, domaine), events (titre, type, heure, lieu, created_by forcé par trigger), settings (ligne unique, date du Tribunal). RLS : lecture ouverte à tout authentifié, écriture (insert/update/delete) réservée au rôle judge par policies dédiées — jamais un simple masquage des boutons côté client. - 8 préréglages de divinités (Dionysos, Arès, Athéna, Aphrodite, Hermès, Poséidon, Hadès, Zeus) avec domaine, couleur d'accent et emblème SVG ; un Archonte peut aussi saisir une divinité libre. - Bannière avec compte à rebours avant la date du Tribunal, éditable par les Archontes. Journées en cartes (jour courant mis en évidence, jours passés estompés, jour du Tribunal en accent oxblood), liste d'événements avec icône par type (activité/défi/épreuve/tribunal). Mode édition (ajout/modification/suppression avec confirmation) réservé aux Archontes. Realtime sur les trois tables. - Ajouté au menu déroulant du header, route protégée par le proxy. Rendu des icônes dynamiques (GodEmblem, EventTypeIcon) via branchement JSX explicite plutôt que variable de composant résolue à l'exécution, pour respecter react-hooks/static-components. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 23 +- README.md | 28 +- src/app/calendrier/calendrier-view.tsx | 293 ++++++++++++++++++ src/app/calendrier/day-card.tsx | 341 +++++++++++++++++++++ src/app/calendrier/page.tsx | 54 ++++ src/components/header.tsx | 2 + src/components/icons.tsx | 152 +++++++++ src/components/judge-point-controls.tsx | 68 ++-- src/components/pantheon-icons.tsx | 68 ++++ src/components/points-confirm-controls.tsx | 38 +++ src/lib/pantheon.ts | 40 +++ src/lib/supabase/middleware.ts | 2 +- supabase/schema.sql | 124 +++++++- 13 files changed, 1173 insertions(+), 60 deletions(-) create mode 100644 src/app/calendrier/calendrier-view.tsx create mode 100644 src/app/calendrier/day-card.tsx create mode 100644 src/app/calendrier/page.tsx create mode 100644 src/components/pantheon-icons.tsx create mode 100644 src/components/points-confirm-controls.tsx create mode 100644 src/lib/pantheon.ts 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" + /> +
+