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:
Valentin ROBIN
2026-07-12 21:13:53 +02:00
parent 0ea6a36189
commit 5cb19caec2
13 changed files with 1173 additions and 60 deletions
+293
View File
@@ -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&apos;a encore é 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>
);
}
+341
View File
@@ -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>
);
}
+54
View File
@@ -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&apos;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>
);
}
+2
View File
@@ -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 /> },
];
+152
View File
@@ -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>
);
}
+19 -49
View File
@@ -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}
/>
</>
)}
+68
View File
@@ -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>
);
}
+40
View File
@@ -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 -1
View File
@@ -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) {