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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user