1eef52a101
Build and deploy / deploy (push) Successful in 35s
/calendrier disparaît de la navigation (route toujours fonctionnelle
pour les Archontes qui y accèdent directement) : la date du Tribunal,
seule info largement utile au quotidien, est reprise en en-tête de
/leaderboard. Extrait la logique de décompte dans un helper partagé
src/lib/tribunal-date.ts plutôt que de la dupliquer.
Corrige au passage un espace JSX manquant sur /urne oublié lors du
dernier commit ("Tu as voté pour Xaujourd'hui").
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
|
import { DayCard, type DayRow } from "./day-card";
|
|
import { PANTHEON_PRESETS, getGodPreset } from "@/lib/pantheon";
|
|
import { IconPlus } from "@/components/icons";
|
|
import { formatCountdown } from "@/lib/tribunal-date";
|
|
|
|
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();
|
|
let channel: ReturnType<typeof supabase.channel> | null = null;
|
|
let cancelled = false;
|
|
|
|
waitForRealtimeAuth(supabase).then(() => {
|
|
if (cancelled) return;
|
|
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 () => {
|
|
cancelled = true;
|
|
if (channel) 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>
|
|
);
|
|
}
|