Aller au contenu principal

Leader Board

Objectif : Afficher un classement des meilleurs scores des joueurs, à partir des données stockées dans la table joueur.

20_classement.png


Pourquoi afficher un classement ?​

Afficher un classement permet de :

  • Valoriser les meilleurs joueurs
  • Créer un esprit de compétition
  • Donner envie de rejouer pour améliorer son score
  • Visualiser les performances de tous les participants

Où sont stockés les scores ?​

Dans ce TP, les scores sont enregistrés dans la table joueur, avec les colonnes suivantes :

ColonneTypeDescription
nomtextLe nom du joueur
meilleur_scorebigintLe meilleur score obtenu
meilleur_tempsbigintLe temps associé au meilleur score
date_meilleur_scoredateLa date du meilleur score

Nous allons utiliser ces données pour afficher un tableau de classement des joueurs.


Étapes du TP​

Objectif​

Créer un composant React qui affiche le classement des joueurs, trié par score décroissant.


Étape 1 – Créer un composant Classement.tsx​

Dans le dossier components, créez un fichier Classement.tsx.

Ce composant va :

  • Récupérer les joueurs depuis Supabase
  • Trier par score décroissant
  • Afficher les résultats dans un tableau

Solution​

// components/Classement.tsx

"use client";

import { useEffect, useState } from "react";
import { supabase } from "../lib/supabaseClient";

type Joueur = {
id: number;
pseudo: string;
meilleur_score: number;
meilleur_temps: number;
date_meilleur_score: string;
};

export default function Classement() {
const [joueurs, setJoueurs] = useState<Joueur[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
async function fetchClassement() {
const { data, error } = await supabase
.from("joueur")
.select("id, pseudo, meilleur_score, meilleur_temps, date_meilleur_score")
.order("meilleur_score", { ascending: false })
.limit(10);

if (error) {
console.log("Erreur récupération classement :", error);
} else {
setJoueurs(data || []);
}

setLoading(false);
}

fetchClassement();
}, []);

if (loading) {
return <p>Chargement du classement...</p>;
}

return (
<div className="max-w-3xl mx-auto mt-10">
<h2 className="text-3xl font-bold mb-6 text-center">Classement</h2>
<table className="w-full border-collapse border border-gray-300">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-4 py-2 text-left">#</th>
<th className="border border-gray-300 px-4 py-2 text-left">Pseudo</th>
<th className="border border-gray-300 px-4 py-2 text-left">Score</th>
<th className="border border-gray-300 px-4 py-2 text-left">Temps</th>
<th className="border border-gray-300 px-4 py-2 text-left">Date</th>
</tr>
</thead>
<tbody>
{joueurs.map((joueur, index) => (
<tr key={joueur.id} className="hover:bg-gray-50">
<td className="border border-gray-300 px-4 py-2">{index + 1}</td>
<td className="border border-gray-300 px-4 py-2">{joueur.pseudo}</td>
<td className="border border-gray-300 px-4 py-2">{joueur.meilleur_score}</td>
<td className="border border-gray-300 px-4 py-2">{joueur.meilleur_temps ?? "-"}</td>
<td className="border border-gray-300 px-4 py-2">{joueur.date_meilleur_score ?? "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

10_classement.png


Étape 2 – Afficher le composant dans la page principale​

Dans app/page.tsx ou une autre page de votre choix, importez et affichez le composant Classement.

Solution​

import Classement from "@/components/Classement";

// Dans le JSX :
<Classement />

Placez-le par exemple sous Merci pour votre participation !, à la fin du quiz.


...

</Card>

<div className="mt-8">
<p className="text-lg mb-4">
Merci {joueurNom} pour votre participation !
</p>
</div>

<Classement />

</div>

12_classement.png


Étape 3 – Ajouter un style​

Nous allons améliorer l’apparence du tableau de classement avec un style professionnel, responsive, et des pictogrammes pour les 3 premiers joueurs (une médaille d’or, d’argent ou de bronze).

Pour les pictogrammes, il faut installer la bibliothèque react-icons :

npm install react-icons

Voici le composant Classement.tsx avec tous les styles ajoutés :

// components/Classement.tsx

"use client";

import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabaseClient";
import { FaMedal } from "react-icons/fa"; // npm install react-icons

type Joueur = {
id: number;
pseudo: string;
meilleur_score: number;
meilleur_temps: number;
date_meilleur_score: string;
};

export default function Classement() {
const [joueurs, setJoueurs] = useState<Joueur[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);

const fetchClassement = async () => {
setRefreshing(true);

const { data, error } = await supabase
.from("joueur")
.select("id, pseudo, meilleur_score, meilleur_temps, date_meilleur_score")
.order("meilleur_score", { ascending: false })
.limit(10);

if (error) {
console.error("Erreur récupération classement :", error);
} else {
setJoueurs(data || []);
}

setLoading(false);
setRefreshing(false);
};

useEffect(() => {
fetchClassement();
}, []);

const getMedal = (index: number) => {
const medals = [
{ color: "text-yellow-500", title: "Or" },
{ color: "text-gray-400", title: "Argent" },
{ color: "text-orange-500", title: "Bronze" },
];
if (index < 3) {
return (
<FaMedal
className={`w-5 h-5 ${medals[index].color}`}
title={`Médaille ${medals[index].title}`}
/>
);
}
return <span>{index + 1}</span>;
};

if (loading) {
return <p className="text-center mt-10 text-gray-500">Chargement du classement...</p>;
}

return (
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 mt-12">
<div className="flex flex-col sm:flex-row items-center justify-between mb-6 gap-4">
<h2 className="text-3xl font-bold text-gray-800">Classement</h2>
</div>

<div className="overflow-x-auto shadow rounded-lg">
<table className="min-w-full bg-white border border-gray-200">
<thead className="bg-gray-100">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Rang</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Pseudo</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Score</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Temps (s)</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Date</th>
</tr>
</thead>
<tbody>
{joueurs.map((joueur, index) => (
<tr
key={joueur.id}
className="border-t border-gray-200 hover:bg-gray-50 transition"
>
<td className="px-4 py-3 text-sm text-gray-900 font-medium">
{getMedal(index)}
</td>
<td className="px-4 py-3 text-sm text-gray-800">{joueur.pseudo}</td>
<td className="px-4 py-3 text-sm text-gray-800">{joueur.meilleur_score}</td>
<td className="px-4 py-3 text-sm text-gray-800">
{joueur.meilleur_temps ?? "-"}
</td>
<td className="px-4 py-3 text-sm text-gray-800">
{joueur.date_meilleur_score
? new Date(joueur.date_meilleur_score).toLocaleDateString("fr-FR")
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

14_classement.png


Étape 4 – Trier en cas d’égalité​

Par défaut, les joueurs sont triés par meilleur_score décroissant.
En cas d’égalité, on peut trier par meilleur_temps croissant (le plus rapide en premier).

Solution (modification dans la requête Supabase)​

const { data, error } = await supabase
.from("joueur")
.select("id, pseudo, meilleur_score, meilleur_temps, date_meilleur_score")
.order("meilleur_score", { ascending: false })
.order("meilleur_temps", { ascending: true }) // tri secondaire
.limit(10);

16_classement.png


Étape 5 – N’afficher que les joueurs ayant un score​

Certains joueurs peuvent ne pas avoir encore joué.
On peut filtrer pour n’afficher que ceux dont meilleur_score est supérieur à 0.

Solution​

const { data, error } = await supabase
.from("joueur")
.select("id, pseudo, meilleur_score, meilleur_temps, date_meilleur_score")
.gt("meilleur_score", 0) // score > 0
.order("meilleur_score", { ascending: false })
.order("meilleur_temps", { ascending: true })
.limit(10);

18_classement.png


Étape 6 – Ajouter un bouton "Rafraîchir"​

Nous allons :

  1. Extraire la fonction fetchClassement pour pouvoir la réutiliser
  2. Ajouter un bouton qui déclenche cette fonction lorsqu'on clique dessus
  3. Afficher un état de chargement pendant la mise à jour

Voici le composant complet avec le bouton "Rafraîchir" intégré :

// components/Classement.tsx

"use client";

import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabaseClient";
import { FaMedal } from "react-icons/fa"; // npm install react-icons

type Joueur = {
id: number;
pseudo: string;
meilleur_score: number;
meilleur_temps: number;
date_meilleur_score: string;
};

export default function Classement() {
const [joueurs, setJoueurs] = useState<Joueur[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);

const fetchClassement = async () => {
setRefreshing(true);

const { data, error } = await supabase
.from("joueur")
.select("id, pseudo, meilleur_score, meilleur_temps, date_meilleur_score")
.gt("meilleur_score", 0) // score > 0
.order("meilleur_score", { ascending: false })
.order("meilleur_temps", { ascending: true })
.limit(10);

if (error) {
console.error("Erreur récupération classement :", error);
} else {
setJoueurs(data || []);
}

setLoading(false);
setRefreshing(false);
};

useEffect(() => {
fetchClassement();
}, []);

const getMedal = (index: number) => {
const medals = [
{ color: "text-yellow-500", title: "Or" },
{ color: "text-gray-400", title: "Argent" },
{ color: "text-orange-500", title: "Bronze" },
];
if (index < 3) {
return (
<FaMedal
className={`w-5 h-5 ${medals[index].color}`}
title={`Médaille ${medals[index].title}`}
/>
);
}
return <span>{index + 1}</span>;
};

if (loading) {
return <p className="text-center mt-10 text-gray-500">Chargement du classement...</p>;
}

return (
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 mt-12">
<div className="flex flex-col sm:flex-row items-center justify-between mb-6 gap-4">
<h2 className="text-3xl font-bold text-gray-800">Classement</h2>
<button
onClick={fetchClassement}
disabled={refreshing}
className="px-5 py-2 bg-blue-600 text-white font-medium rounded hover:bg-blue-700 disabled:opacity-50 transition"
>
{refreshing ? "Rafraîchissement..." : "Rafraîchir"}
</button>
</div>

<div className="overflow-x-auto shadow rounded-lg">
<table className="min-w-full bg-white border border-gray-200">
<thead className="bg-gray-100">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Rang</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Pseudo</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Score</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Temps (s)</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Date</th>
</tr>
</thead>
<tbody>
{joueurs.map((joueur, index) => (
<tr
key={joueur.id}
className="border-t border-gray-200 hover:bg-gray-50 transition"
>
<td className="px-4 py-3 text-sm text-gray-900 font-medium">
{getMedal(index)}
</td>
<td className="px-4 py-3 text-sm text-gray-800">{joueur.pseudo}</td>
<td className="px-4 py-3 text-sm text-gray-800">{joueur.meilleur_score}</td>
<td className="px-4 py-3 text-sm text-gray-800">
{joueur.meilleur_temps ?? "-"}
</td>
<td className="px-4 py-3 text-sm text-gray-800">
{joueur.date_meilleur_score
? new Date(joueur.date_meilleur_score).toLocaleDateString("fr-FR")
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

20_classement.png


Bonus (facultatif)​

Si vous voulez aller plus loin, vous pouvez :

  • Ajouter un rafraîchissement automatique toutes les 30 secondes
  • Ajouter un toast pour indiquer que le classement a été mis à jour