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