Boutons de points compacts unifiés + page Le Calendrier des Dieux
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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<DayRow[]>(initialDays);
|
||||
const [tribunalDate, setTribunalDate] = useState<string | null>(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<string | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="marble-surface rounded-2xl border border-gold/40 px-6 py-5 text-center shadow-xl">
|
||||
<p className="font-heading text-lg tracking-wide text-text-marble uppercase sm:text-xl">
|
||||
{formatCountdown(tribunalDate)}
|
||||
</p>
|
||||
{tribunalDate && (
|
||||
<p className="mt-1 font-serif text-sm text-text-mut italic">
|
||||
{new Date(tribunalDate).toLocaleString("fr-FR", {
|
||||
dateStyle: "full",
|
||||
timeStyle: "short",
|
||||
timeZone: "Europe/Paris",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isJudge && (
|
||||
<div className="mt-3">
|
||||
{editingTribunalDate ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={tribunalInput}
|
||||
onChange={(e) => setTribunalInput(e.target.value)}
|
||||
className="rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-sm text-text-marble"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveTribunalDate}
|
||||
className="rounded-md bg-ink-2 px-3 py-1.5 text-xs font-medium text-gold-bright hover:bg-ink"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTribunalDate(false)}
|
||||
className="rounded-md border border-gold/30 px-3 py-1.5 text-xs text-text-marble hover:bg-gold/10"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTribunalDate(true)}
|
||||
className="rounded-md border border-gold/40 px-3 py-1.5 text-xs font-medium text-text-marble hover:bg-gold/10"
|
||||
>
|
||||
Définir la date du Tribunal
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{days.length === 0 && !addingDay && (
|
||||
<p className="text-center text-sm text-marble/60">
|
||||
Aucune journée n'a encore été inscrite au calendrier.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{days.map((day) => (
|
||||
<DayCard key={day.id} day={day} isJudge={isJudge} onChanged={refetch} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isJudge && (
|
||||
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
|
||||
{addingDay ? (
|
||||
<form onSubmit={handleAddDay} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-text-marble">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newDate}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-text-marble">Divinité</label>
|
||||
<select
|
||||
value={customGod ? "custom" : newGod}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "custom") {
|
||||
setCustomGod(true);
|
||||
setNewGod("");
|
||||
setNewGodDomain("");
|
||||
} else {
|
||||
setCustomGod(false);
|
||||
setNewGod(e.target.value);
|
||||
setNewGodDomain(getGodPreset(e.target.value)?.domain ?? "");
|
||||
}
|
||||
}}
|
||||
className="rounded-md border border-gold/30 bg-white/50 px-2 py-1.5 text-sm text-text-marble"
|
||||
>
|
||||
{PANTHEON_PRESETS.map((g) => (
|
||||
<option key={g.name} value={g.name}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Autre…</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{customGod && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-text-marble">Nom de la divinité</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newGod}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-text-marble">Domaine</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newGodDomain}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-oxblood">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase hover:bg-ink"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingDay(false)}
|
||||
className="rounded-md border border-gold/30 px-4 py-2 text-sm text-text-marble hover:bg-gold/10"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingDay(true)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-gold/40 px-4 py-2 text-sm font-medium text-text-marble hover:bg-gold/10"
|
||||
>
|
||||
<IconPlus className="h-4 w-4" />
|
||||
Ajouter une journée
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 rounded-md border border-gold/30 bg-white/40 p-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Titre de l'événement"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble"
|
||||
>
|
||||
{EVENT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Lieu (optionnel)"
|
||||
value={location}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="Description (optionnel)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble"
|
||||
/>
|
||||
{error && <p className="text-xs text-oxblood">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-md bg-ink-2 px-3 py-1.5 text-xs font-medium text-gold-bright hover:bg-ink disabled:opacity-50"
|
||||
>
|
||||
{saving ? "…" : "Enregistrer"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDone}
|
||||
className="rounded-md border border-gold/30 px-3 py-1.5 text-xs text-text-marble hover:bg-gold/10"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRowItem({
|
||||
event,
|
||||
isJudge,
|
||||
onChanged,
|
||||
}: {
|
||||
event: EventRow;
|
||||
isJudge: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
async function handleDelete() {
|
||||
const supabase = createClient();
|
||||
await supabase.from("events").delete().eq("id", event.id);
|
||||
onChanged();
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<EventForm
|
||||
dayId={event.day_id}
|
||||
initial={event}
|
||||
onDone={() => {
|
||||
setEditing(false);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex items-start gap-2 rounded-md px-2 py-1.5 text-sm">
|
||||
<EventTypeIcon type={event.type} className="mt-0.5 h-4 w-4 shrink-0 text-gold" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
{event.start_time && (
|
||||
<span className="font-medium text-text-mut">{event.start_time.slice(0, 5)}</span>
|
||||
)}
|
||||
<span className="font-medium text-text-marble">{event.title}</span>
|
||||
<span className="text-xs text-text-mut">({getEventTypeLabel(event.type)})</span>
|
||||
</div>
|
||||
{event.location && <div className="text-xs text-text-mut">{event.location}</div>}
|
||||
{event.description && <div className="text-xs text-text-mut">{event.description}</div>}
|
||||
</div>
|
||||
{isJudge && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label="Modifier l'événement"
|
||||
title="Modifier"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-text-mut hover:bg-gold/10 hover:text-text-marble"
|
||||
>
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{confirmingDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="rounded-md bg-oxblood px-2 py-1 text-xs text-marble"
|
||||
>
|
||||
Confirmer ?
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
onBlur={() => setConfirmingDelete(false)}
|
||||
aria-label="Supprimer l'événement"
|
||||
title="Supprimer"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-oxblood/70 hover:bg-oxblood/10 hover:text-oxblood"
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayCard({
|
||||
day,
|
||||
isJudge,
|
||||
onChanged,
|
||||
}: {
|
||||
day: DayRow;
|
||||
isJudge: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [addingEvent, setAddingEvent] = useState(false);
|
||||
const [confirmingDeleteDay, setConfirmingDeleteDay] = useState(false);
|
||||
const godColor = getGodColor(day.god_name);
|
||||
const { weekday, date } = formatDate(day.date);
|
||||
const today = isToday(day.date);
|
||||
const past = isPast(day.date) && !today;
|
||||
const isTribunalDay = day.events.some((e) => e.type === "tribunal");
|
||||
|
||||
async function handleDeleteDay() {
|
||||
const supabase = createClient();
|
||||
await supabase.from("days").delete().eq("id", day.id);
|
||||
onChanged();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`marble-surface rounded-2xl border p-4 shadow-sm transition-opacity sm:p-5 ${
|
||||
today ? "border-gold ring-2 ring-gold/50 podium-glow" : "border-gold/25"
|
||||
} ${isTribunalDay ? "border-oxblood/60" : ""} ${past ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs tracking-wide text-text-mut uppercase">
|
||||
{weekday} · {date}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<GodEmblem godName={day.god_name} className="h-6 w-6 shrink-0" color={godColor} />
|
||||
<span className="font-heading text-lg text-text-marble uppercase">{day.god_name}</span>
|
||||
{isTribunalDay && (
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-oxblood" title="Jour du Tribunal" />
|
||||
)}
|
||||
</div>
|
||||
<p className="font-serif text-sm text-text-mut italic">{day.god_domain}</p>
|
||||
</div>
|
||||
|
||||
{isJudge && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{confirmingDeleteDay ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteDay}
|
||||
className="rounded-md bg-oxblood px-2 py-1 text-xs text-marble"
|
||||
>
|
||||
Confirmer la suppression ?
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDeleteDay(true)}
|
||||
onBlur={() => setConfirmingDeleteDay(false)}
|
||||
aria-label="Supprimer la journée"
|
||||
title="Supprimer la journée"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-oxblood/70 hover:bg-oxblood/10 hover:text-oxblood"
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{day.description && <p className="mt-2 text-sm text-text-marble/80">{day.description}</p>}
|
||||
|
||||
{day.events.length > 0 && (
|
||||
<ul className="mt-3 flex flex-col divide-y divide-gold/10 border-t border-gold/10">
|
||||
{day.events.map((event) => (
|
||||
<EventRowItem key={event.id} event={event} isJudge={isJudge} onChanged={onChanged} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isJudge && (
|
||||
<div className="mt-3">
|
||||
{addingEvent ? (
|
||||
<EventForm
|
||||
dayId={day.id}
|
||||
onDone={() => {
|
||||
setAddingEvent(false);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingEvent(true)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-text-mut hover:text-text-marble"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
Ajouter un événement
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { CalendrierView } from "./calendrier-view";
|
||||
|
||||
export default async function CalendrierPage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("role")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from("settings")
|
||||
.select("tribunal_date")
|
||||
.eq("id", true)
|
||||
.single();
|
||||
|
||||
const { data: days } = await supabase
|
||||
.from("days")
|
||||
.select("id, date, god_name, god_domain, description, events(*)")
|
||||
.order("date", { ascending: true });
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl px-4 py-8">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
|
||||
Le Calendrier des Dieux
|
||||
</h1>
|
||||
<p className="font-serif text-sm text-marble/60 italic">
|
||||
L'agenda de la semaine, sous le regard du Panthéon.
|
||||
</p>
|
||||
</div>
|
||||
<CalendrierView
|
||||
isJudge={profile?.role === "judge"}
|
||||
initialTribunalDate={settings?.tribunal_date ?? null}
|
||||
initialDays={(days ?? []).map((day) => ({
|
||||
...day,
|
||||
events: [...day.events].sort((a, b) =>
|
||||
(a.start_time ?? "99:99").localeCompare(b.start_time ?? "99:99"),
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconColumn,
|
||||
IconLogout,
|
||||
IconChevronDown,
|
||||
IconCalendar,
|
||||
} from "@/components/icons";
|
||||
|
||||
type MenuEntry = {
|
||||
@@ -89,6 +90,7 @@ export function Header({
|
||||
const entries: MenuEntry[] = [
|
||||
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
|
||||
{ href: "/roulette", label: "La Roulette", icon: <IconWheel /> },
|
||||
{ href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> },
|
||||
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
|
||||
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> },
|
||||
];
|
||||
|
||||
@@ -89,3 +89,155 @@ export function IconChevronDown({ className = base }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCalendar({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<rect x="4" y="5.5" width="16" height="14.5" rx="2" />
|
||||
<path d="M4 9.5h16M8 3.5v3M16 3.5v3" strokeLinecap="round" />
|
||||
<path d="M8 13h2M11 13h2M14 13h2M8 16.2h2M11 16.2h2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconScales({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M12 3v18M8 21h8" strokeLinecap="round" />
|
||||
<path d="M4 7h6M14 7h6" strokeLinecap="round" />
|
||||
<path d="M4 7l-2.5 5a2.5 2.5 0 0 0 5 0Z" strokeLinejoin="round" />
|
||||
<path d="M20 7l-2.5 5a2.5 2.5 0 0 0 5 0Z" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrophy({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M7 4h10v5a5 5 0 0 1-10 0V4Z" strokeLinejoin="round" />
|
||||
<path d="M7 5.5H4a3 3 0 0 0 3 4.3M17 5.5h3a3 3 0 0 1-3 4.3" strokeLinecap="round" />
|
||||
<path d="M12 14v3M9 20.5h6M9.5 20.5l.7-3.5h3.6l.7 3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconDice({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<rect x="4" y="4" width="16" height="16" rx="3" />
|
||||
<circle cx="8.3" cy="8.3" r="1.1" fill="currentColor" stroke="none" />
|
||||
<circle cx="15.7" cy="8.3" r="1.1" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.1" fill="currentColor" stroke="none" />
|
||||
<circle cx="8.3" cy="15.7" r="1.1" fill="currentColor" stroke="none" />
|
||||
<circle cx="15.7" cy="15.7" r="1.1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconStar({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M12 3.5l2.4 5.3 5.7.6-4.3 3.9 1.2 5.7L12 16l-5 3 1.2-5.7-4.3-3.9 5.7-.6L12 3.5Z" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPencil({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M4 20l.9-3.9L15.6 5.4a1.6 1.6 0 0 1 2.3 0l.7.7a1.6 1.6 0 0 1 0 2.3L8 19.1 4 20Z" strokeLinejoin="round" strokeLinecap="round" />
|
||||
<path d="M14 7l3 3" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrash({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M5 7h14M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M7 7l1 13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1l1-13" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M10 11v6M14 11v6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPlus({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCup({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M6 4h12l-1.2 9.5a4.8 4.8 0 0 1-9.6 0L6 4Z" strokeLinejoin="round" />
|
||||
<path d="M9.5 20h5M12 17v3" strokeLinecap="round" />
|
||||
<path d="M6.3 6H3.5a2.5 2.5 0 0 0 2.5 4M17.7 6h2.8a2.5 2.5 0 0 1-2.5 4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSword({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M6 18L17 7M8 8l8 8" strokeLinecap="round" />
|
||||
<path d="M15.5 4.5l4 4M4.5 15.5l4 4M4 20l2-2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconOwl({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M6 10.5a6 6 0 0 1 12 0c0 4.5-2.2 8-6 8s-6-3.5-6-8Z" strokeLinejoin="round" />
|
||||
<circle cx="9.5" cy="10.5" r="1.6" />
|
||||
<circle cx="14.5" cy="10.5" r="1.6" />
|
||||
<path d="M12 12.5l-1 2h2l-1-2ZM4 8l2 2M20 8l-2 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHeart({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M12 20S4 14.5 4 9.2A4.2 4.2 0 0 1 12 7a4.2 4.2 0 0 1 8 2.2C20 14.5 12 20 12 20Z" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconWing({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M3 13c3-4 6-2 6 1-2 0-3 1.5-2 3 2 1 4-1 5-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M21 13c-3-4-6-2-6 1 2 0 3 1.5 2 3-2 1-4-1-5-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M12 10v9" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrident({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M12 5v16M12 5c0-1.5 1-2.5 2.3-2.5S16.5 3.5 16.5 5c0 1.7-1.5 3-2.5 4M12 5c0-1.5-1-2.5-2.3-2.5S7.5 3.5 7.5 5c0 1.7 1.5 3 2.5 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8 21h8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFlame({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M12 21c-4 0-6.5-2.6-6.5-6 0-3 2-4.8 2.6-7.6 1 1 1.6 2 1.7 3C10.5 8 11 5 14 3c-.6 2.6.4 3.8 1.7 5.3 1.3 1.5 2.8 3 2.8 6.2 0 3.5-2.5 6.5-6.5 6.5Z" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBolt({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { IconCheck, IconCross } from "@/components/icons";
|
||||
import { PointsConfirmControls } from "@/components/points-confirm-controls";
|
||||
|
||||
export function JudgePointControls({
|
||||
memberId,
|
||||
@@ -85,39 +85,24 @@ export function JudgePointControls({
|
||||
</div>
|
||||
);
|
||||
|
||||
const pendingBadge = pending !== 0 && (
|
||||
<span className={`text-sm font-semibold ${pending > 0 ? "text-olive" : "text-oxblood"}`}>
|
||||
{pending > 0 ? `+${pending}` : pending}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
{amountRow}
|
||||
{pending !== 0 && (
|
||||
<>
|
||||
<span
|
||||
className={`text-sm font-semibold ${pending > 0 ? "text-olive" : "text-oxblood"}`}
|
||||
>
|
||||
{pending > 0 ? `+${pending}` : pending}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting}
|
||||
aria-label="Valider les points"
|
||||
title="Valider les points"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-olive/50 bg-olive/10 text-olive transition hover:bg-olive/20 disabled:opacity-50"
|
||||
>
|
||||
<IconCheck className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={submitting}
|
||||
aria-label="Annuler"
|
||||
title="Annuler"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-oxblood/50 bg-oxblood/10 text-oxblood transition hover:bg-oxblood/20 disabled:opacity-50"
|
||||
>
|
||||
<IconCross className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{pendingBadge}
|
||||
<PointsConfirmControls
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{error && <span className="text-xs text-oxblood">{error}</span>}
|
||||
@@ -141,27 +126,12 @@ export function JudgePointControls({
|
||||
|
||||
{pending !== 0 && (
|
||||
<>
|
||||
<span
|
||||
className={`text-sm font-semibold ${pending > 0 ? "text-olive" : "text-oxblood"}`}
|
||||
>
|
||||
{pending > 0 ? `+${pending}` : pending}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting}
|
||||
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"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={submitting}
|
||||
className="rounded-md border border-gold/30 px-2 py-1 text-xs text-text-marble hover:bg-gold/10 disabled:opacity-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
{pendingBadge}
|
||||
<PointsConfirmControls
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
IconCup,
|
||||
IconSword,
|
||||
IconOwl,
|
||||
IconHeart,
|
||||
IconWing,
|
||||
IconTrident,
|
||||
IconFlame,
|
||||
IconBolt,
|
||||
IconStar,
|
||||
IconScales,
|
||||
IconTrophy,
|
||||
IconDice,
|
||||
} from "@/components/icons";
|
||||
import { getGodPreset } from "@/lib/pantheon";
|
||||
|
||||
// Rendu explicite par branchement (pas de variable de composant dynamique) :
|
||||
// react-hooks/static-components interdit d'assigner un composant résolu à
|
||||
// l'exécution (`const Icon = lookup(x)`) puis de le rendre comme balise JSX.
|
||||
export function GodEmblem({
|
||||
godName,
|
||||
className,
|
||||
color,
|
||||
}: {
|
||||
godName: string;
|
||||
className?: string;
|
||||
color?: string;
|
||||
}) {
|
||||
const preset = getGodPreset(godName);
|
||||
const icon = (() => {
|
||||
switch (preset?.name) {
|
||||
case "Dionysos":
|
||||
return <IconCup className={className} />;
|
||||
case "Arès":
|
||||
return <IconSword className={className} />;
|
||||
case "Athéna":
|
||||
return <IconOwl className={className} />;
|
||||
case "Aphrodite":
|
||||
return <IconHeart className={className} />;
|
||||
case "Hermès":
|
||||
return <IconWing className={className} />;
|
||||
case "Poséidon":
|
||||
return <IconTrident className={className} />;
|
||||
case "Hadès":
|
||||
return <IconFlame className={className} />;
|
||||
case "Zeus":
|
||||
return <IconBolt className={className} />;
|
||||
default:
|
||||
return <IconStar className={className} />;
|
||||
}
|
||||
})();
|
||||
|
||||
return color ? <span style={{ color }}>{icon}</span> : icon;
|
||||
}
|
||||
|
||||
export function EventTypeIcon({ type, className }: { type: string; className?: string }) {
|
||||
switch (type) {
|
||||
case "defi":
|
||||
return <IconDice className={className} />;
|
||||
case "epreuve":
|
||||
return <IconTrophy className={className} />;
|
||||
case "tribunal":
|
||||
return <IconScales className={className} />;
|
||||
case "activite":
|
||||
default:
|
||||
return <IconStar className={className} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { IconCheck, IconCross } from "@/components/icons";
|
||||
|
||||
// Boutons compacts de validation/annulation des points en attente.
|
||||
// Utilisés à l'identique sur le podium et dans le reste du classement.
|
||||
export function PointsConfirmControls({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
submitting,
|
||||
}: {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
submitting: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={submitting}
|
||||
aria-label="Valider les points"
|
||||
title="Valider les points"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-olive/50 bg-olive/10 p-1.5 text-olive transition hover:bg-olive/20 disabled:opacity-50"
|
||||
>
|
||||
<IconCheck className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
aria-label="Annuler"
|
||||
title="Annuler"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-oxblood/50 bg-oxblood/10 p-1.5 text-oxblood transition hover:bg-oxblood/20 disabled:opacity-50"
|
||||
>
|
||||
<IconCross className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export type GodPreset = {
|
||||
name: string;
|
||||
domain: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
// Préréglages proposés à l'Archonte — il reste libre d'en créer d'autres
|
||||
// (nom/domaine en texte libre) ; les dieux hors liste reçoivent une icône
|
||||
// générique (voir components/pantheon-icons.tsx).
|
||||
export const PANTHEON_PRESETS: GodPreset[] = [
|
||||
{ name: "Dionysos", domain: "Épreuves de boisson pure", color: "#A5342A" },
|
||||
{ name: "Arès", domain: "Épreuves physiques", color: "#A5342A" },
|
||||
{ name: "Athéna", domain: "Quiz de culture générale, énigmes", color: "#2E6E7E" },
|
||||
{ name: "Aphrodite", domain: "On boit beaucoup", color: "#A5342A" },
|
||||
{ name: "Hermès", domain: "Ruse, vitesse, chasse au trésor", color: "#5E6B3B" },
|
||||
{ name: "Poséidon", domain: "Épreuves d'eau, piscine, mer", color: "#2E6E7E" },
|
||||
{ name: "Hadès", domain: "Gages, sanctions, défis sombres", color: "#0A1B33" },
|
||||
{ name: "Zeus", domain: "Journée du Tribunal, grands décrets", color: "#C9A227" },
|
||||
];
|
||||
|
||||
export function getGodPreset(godName: string): GodPreset | undefined {
|
||||
return PANTHEON_PRESETS.find((g) => g.name.toLowerCase() === godName.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function getGodColor(godName: string): string {
|
||||
return getGodPreset(godName)?.color ?? "#C9A227";
|
||||
}
|
||||
|
||||
export const EVENT_TYPES = [
|
||||
{ value: "activite", label: "Activité" },
|
||||
{ value: "defi", label: "Défi" },
|
||||
{ value: "epreuve", label: "Épreuve" },
|
||||
{ value: "tribunal", label: "Tribunal" },
|
||||
] as const;
|
||||
|
||||
export type EventType = (typeof EVENT_TYPES)[number]["value"];
|
||||
|
||||
export function getEventTypeLabel(type: string): string {
|
||||
return EVENT_TYPES.find((t) => t.value === type)?.label ?? type;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal", "/roulette"];
|
||||
const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal", "/roulette", "/calendrier"];
|
||||
const AUTH_PATHS = ["/login", "/signup"];
|
||||
|
||||
export async function updateSession(request: NextRequest) {
|
||||
|
||||
+123
-1
@@ -355,7 +355,129 @@ exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
-- 9. Désigner les premiers juges ---------------------------------------------
|
||||
-- 9. Le Calendrier des Dieux --------------------------------------------------
|
||||
-- Agenda de la semaine : chaque journée est placée sous le patronage d'une
|
||||
-- divinité (nom/domaine en texte libre, l'Archonte choisit un préréglage
|
||||
-- côté client ou saisit le sien). Lecture ouverte à tous, écriture réservée
|
||||
-- aux juges (Archontes), vérifiée côté serveur par RLS — jamais par le
|
||||
-- masquage des boutons en front.
|
||||
|
||||
create table if not exists public.days (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
date date not null unique,
|
||||
god_name text not null,
|
||||
god_domain text not null,
|
||||
description text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
day_id uuid not null references public.days (id) on delete cascade,
|
||||
title text not null,
|
||||
description text,
|
||||
type text not null default 'activite' check (type in ('activite', 'defi', 'epreuve', 'tribunal')),
|
||||
start_time time,
|
||||
location text,
|
||||
created_by uuid references public.profiles (id) on delete set null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Ligne de configuration unique (id toujours = true) : date du Tribunal.
|
||||
create table if not exists public.settings (
|
||||
id boolean primary key default true,
|
||||
tribunal_date timestamptz,
|
||||
constraint settings_singleton check (id)
|
||||
);
|
||||
insert into public.settings (id) values (true) on conflict (id) do nothing;
|
||||
|
||||
alter table public.days enable row level security;
|
||||
alter table public.events enable row level security;
|
||||
alter table public.settings enable row level security;
|
||||
|
||||
grant select, insert, update, delete on public.days, public.events to authenticated;
|
||||
grant select, update on public.settings to authenticated;
|
||||
|
||||
-- Lecture : tout utilisateur authentifié.
|
||||
drop policy if exists "days viewable by authenticated" on public.days;
|
||||
create policy "days viewable by authenticated" on public.days for select to authenticated using (true);
|
||||
|
||||
drop policy if exists "events viewable by authenticated" on public.events;
|
||||
create policy "events viewable by authenticated" on public.events for select to authenticated using (true);
|
||||
|
||||
drop policy if exists "settings viewable by authenticated" on public.settings;
|
||||
create policy "settings viewable by authenticated" on public.settings for select to authenticated using (true);
|
||||
|
||||
-- Écriture : réservée aux juges.
|
||||
drop policy if exists "days insert by judges" on public.days;
|
||||
create policy "days insert by judges" on public.days for insert to authenticated
|
||||
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
drop policy if exists "days update by judges" on public.days;
|
||||
create policy "days update by judges" on public.days for update to authenticated
|
||||
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
||||
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
drop policy if exists "days delete by judges" on public.days;
|
||||
create policy "days delete by judges" on public.days for delete to authenticated
|
||||
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
drop policy if exists "events insert by judges" on public.events;
|
||||
create policy "events insert by judges" on public.events for insert to authenticated
|
||||
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
drop policy if exists "events update by judges" on public.events;
|
||||
create policy "events update by judges" on public.events for update to authenticated
|
||||
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
||||
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
drop policy if exists "events delete by judges" on public.events;
|
||||
create policy "events delete by judges" on public.events for delete to authenticated
|
||||
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
drop policy if exists "settings update by judges" on public.settings;
|
||||
create policy "settings update by judges" on public.settings for update to authenticated
|
||||
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
||||
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
-- created_by ne fait jamais confiance au client : toujours l'auteur réel.
|
||||
create or replace function public.enforce_event_insert()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
new.created_by := auth.uid();
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists events_before_insert on public.events;
|
||||
create trigger events_before_insert
|
||||
before insert on public.events
|
||||
for each row execute function public.enforce_event_insert();
|
||||
|
||||
do $$
|
||||
begin
|
||||
alter publication supabase_realtime add table public.days;
|
||||
exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
do $$
|
||||
begin
|
||||
alter publication supabase_realtime add table public.events;
|
||||
exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
do $$
|
||||
begin
|
||||
alter publication supabase_realtime add table public.settings;
|
||||
exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
-- 10. Désigner les premiers juges ---------------------------------------------
|
||||
-- À faire une fois les comptes créés (SQL Editor) :
|
||||
--
|
||||
-- update public.profiles set role = 'judge' where id = '<uuid-du-membre>';
|
||||
|
||||
Reference in New Issue
Block a user