Affiche le n°1 d'Icare et de la Corne d'Abondance sur /leaderboard
Build and deploy / deploy (push) Successful in 36s

Nouvelle section (game-highlights.tsx) juste sous les raccourcis : avatar, pseudo et score du meneur actuel de chaque mini-jeu, cliquable vers le jeu correspondant. Même règle que les podiums existants : rien affiché tant que le meneur est à 0. Score en text-sea (pas gold-bright, peu lisible sur fond marbre — même choix déjà fait pour Icare/Corne/l'Urne).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-25 19:39:38 +02:00
parent 3aa2ea451c
commit 187cdec0e2
3 changed files with 88 additions and 13 deletions
+1 -1
View File
@@ -179,7 +179,7 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul
| `/` | Redirige vers `/leaderboard` si connecté, sinon `/login` | | `/` | Redirige vers `/leaderboard` si connecté, sinon `/login` |
| `/login` | Email + mot de passe | | `/login` | Email + mot de passe |
| `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email | | `/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, date/décompte du Tribunal en en-tête (`src/lib/tribunal-date.ts`, partagé avec `/calendrier`). Joue le rôle de page d'accueil (`/` y redirige) : une rangée de raccourcis (`quick-links.tsx`, icônes réutilisées du menu du header) vers les pages d'activité — jeux, Mur, Urne, Crieur — juste au-dessus du classement, avec une pastille façon iOS sur le raccourci du Mur (nombre de publications du jour, via `wall_notes_for_date()` — pas de lecture directe de `wall_notes`, dont la RLS ne laisserait voir que ses propres notes) | | `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges, date/décompte du Tribunal en en-tête (`src/lib/tribunal-date.ts`, partagé avec `/calendrier`). Joue le rôle de page d'accueil (`/` y redirige) : une rangée de raccourcis (`quick-links.tsx`, icônes réutilisées du menu du header) vers les pages d'activité — jeux, Mur, Urne, Crieur — juste au-dessus du classement, avec une pastille façon iOS sur le raccourci du Mur (nombre de publications du jour, via `wall_notes_for_date()` — pas de lecture directe de `wall_notes`, dont la RLS ne laisserait voir que ses propres notes). Juste en dessous, `game-highlights.tsx` affiche le n°1 actuel d'Icare et de la Corne d'Abondance (lecture directe de `icarus_scores`/`melon_scores`, RLS déjà ouverte à tout authentifié) — même règle que les podiums de ces deux jeux : pas de champion affiché tant que le meneur est à 0 |
| `/icare` | « Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement | | `/icare` | « Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement |
| `/corne` | « La Corne d'Abondance » — jeu de fusion façon Watermelon Game (avatars aléatoires, physique `matter-js`), même mécanique de gloires que Le Vol d'Icare | | `/corne` | « La Corne d'Abondance » — jeu de fusion façon Watermelon Game (avatars aléatoires, physique `matter-js`), même mécanique de gloires que Le Vol d'Icare |
| `/char` | « Le Jeu de l'Agora » — format paysage (projeté) : Le Char (2 colonnes de 3, boutons de duel), Le Gardien du Silence (tirage aléatoire, minuteur discret) et La Roulette (tirage au sort animé) | | `/char` | « Le Jeu de l'Agora » — format paysage (projeté) : Le Char (2 colonnes de 3, boutons de duel), Le Gardien du Silence (tirage aléatoire, minuteur discret) et La Roulette (tirage au sort animé) |
+57
View File
@@ -0,0 +1,57 @@
import Link from "next/link";
import type { ComponentType } from "react";
import { Avatar } from "@/components/avatar";
import { IconHorn, IconWings } from "@/components/icons";
export type GameLeader = { pseudo: string; avatarUrl: string | null; score: number } | null;
function LeaderCard({
href,
label,
icon: Icon,
leader,
}: {
href: string;
label: string;
icon: ComponentType<{ className?: string }>;
leader: GameLeader;
}) {
return (
<Link
href={href}
className="marble-surface flex items-center gap-3 rounded-xl border border-gold/25 p-3 shadow-sm transition hover:border-gold/50 hover:shadow-md"
>
<Icon className="h-5 w-5 shrink-0 text-gold" />
<div className="min-w-0 flex-1">
<p className="text-[0.65rem] font-medium tracking-wide text-text-mut uppercase">{label}</p>
{leader ? (
<div className="mt-1 flex items-center gap-2">
<Avatar pseudo={leader.pseudo} avatarUrl={leader.avatarUrl} size="xs" />
<span className="truncate text-sm font-medium text-text-marble">{leader.pseudo}</span>
</div>
) : (
<p className="mt-1 truncate text-sm text-text-mut">Personne n&apos;a encore joué</p>
)}
</div>
{leader && <span className="shrink-0 font-heading text-sm font-bold text-sea">{leader.score}</span>}
</Link>
);
}
// Comme les podiums (voir leaderboard/icare/corne) : un meneur à 0 n'est pas
// un vrai champion, juste un score par défaut — rien à afficher tant que
// personne n'a vraiment marqué (leader passé à null par la page).
export function GameHighlights({
icarusLeader,
melonLeader,
}: {
icarusLeader: GameLeader;
melonLeader: GameLeader;
}) {
return (
<div className="mb-6 grid grid-cols-1 gap-3 sm:grid-cols-2">
<LeaderCard href="/icare" label="Le Vol d'Icare" icon={IconWings} leader={icarusLeader} />
<LeaderCard href="/corne" label="La Corne d'Abondance" icon={IconHorn} leader={melonLeader} />
</div>
);
}
+19 -1
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { LeaderboardView } from "./leaderboard-view"; import { LeaderboardView } from "./leaderboard-view";
import { QuickLinks } from "./quick-links"; import { QuickLinks } from "./quick-links";
import { GameHighlights, type GameLeader } from "./game-highlights";
import { AgoraLogo } from "@/components/agora-logo"; import { AgoraLogo } from "@/components/agora-logo";
import { formatCountdown, formatTribunalDateTime } from "@/lib/tribunal-date"; import { formatCountdown, formatTribunalDateTime } from "@/lib/tribunal-date";
@@ -15,7 +16,8 @@ export default async function LeaderboardPage() {
redirect("/login"); redirect("/login");
} }
const [{ data: profiles, error }, { data: settings }, { data: wallNotesToday }] = await Promise.all([ const [{ data: profiles, error }, { data: settings }, { data: wallNotesToday }, { data: icarusTop }, { data: melonTop }] =
await Promise.all([
supabase supabase
.from("profiles") .from("profiles")
.select("id, pseudo, avatar_url, points, previous_rank, role") .select("id, pseudo, avatar_url, points, previous_rank, role")
@@ -26,11 +28,25 @@ export default async function LeaderboardPage() {
// connaître le nombre total de publications du jour sans exposer qui a // connaître le nombre total de publications du jour sans exposer qui a
// publié quoi — sert juste de compteur pour la pastille du raccourci. // publié quoi — sert juste de compteur pour la pastille du raccourci.
supabase.rpc("wall_notes_for_date"), supabase.rpc("wall_notes_for_date"),
supabase.from("icarus_scores").select("user_id, best_score").order("best_score", { ascending: false }).limit(1),
supabase.from("melon_scores").select("user_id, best_score").order("best_score", { ascending: false }).limit(1),
]); ]);
const isJudge = profiles?.find((p) => p.id === user.id)?.role === "judge"; const isJudge = profiles?.find((p) => p.id === user.id)?.role === "judge";
const tribunalDate = settings?.tribunal_date ?? null; const tribunalDate = settings?.tribunal_date ?? null;
const profileById = new Map((profiles ?? []).map((p) => [p.id, p]));
// Comme les podiums d'Icare/Corne : un meneur à 0 (personne n'a encore
// joué) n'est pas un champion, pas de couronne pour un score par défaut.
function buildLeader(rows: { user_id: string; best_score: number }[] | null): GameLeader {
const top = rows?.[0];
if (!top || top.best_score <= 0) return null;
const profile = profileById.get(top.user_id);
return { pseudo: profile?.pseudo ?? "Un Citoyen", avatarUrl: profile?.avatar_url ?? null, score: top.best_score };
}
const icarusLeader = buildLeader(icarusTop);
const melonLeader = buildLeader(melonTop);
return ( return (
<div className="mx-auto w-full max-w-2xl px-4 py-8"> <div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 flex flex-col items-center text-center"> <div className="mb-6 flex flex-col items-center text-center">
@@ -44,6 +60,8 @@ export default async function LeaderboardPage() {
<QuickLinks wallNoteCount={wallNotesToday?.length ?? 0} /> <QuickLinks wallNoteCount={wallNotesToday?.length ?? 0} />
<GameHighlights icarusLeader={icarusLeader} melonLeader={melonLeader} />
{error && ( {error && (
<p role="alert" className="text-sm text-oxblood"> <p role="alert" className="text-sm text-oxblood">
Impossible de charger le classement pour le moment. Impossible de charger le classement pour le moment.