12 KiB
🏁 Énoncé FINAL — « Boss de fin »
Dernier jour. Tu as tout l'arsenal : composants, props, state, lifting state up, useEffect, fetch, formulaire, tri, filtre, React Router, localStorage. Aujourd'hui, place à la pratique libre.
Un seul module est obligatoire : le Module L (la page détail, le vrai boss). Les autres forment un menu : pioche dans l'ordre que tu veux, fais-en le plus possible. Chacun est court et indépendant.
| Module | Tu fais | Niveau |
|---|---|---|
| L | Page détail complète (données centralisées dans App) — obligatoire |
⭐⭐⭐ |
| M | Bouton « au hasard » (useNavigate) |
⭐⭐ |
| N | Le Hall of Fame des jeux les plus likés | ⭐⭐ |
| O | Bouton « supprimer » un jeu | ⭐⭐ |
| P | Thème sombre qui se souvient du choix | ⭐⭐ |
| Q | Mode Versus : deux jeux s'affrontent | ⭐⭐ |
| R | Filtre par Type (2d / 3d) | ⭐⭐ |
| S | Statistiques (total, moyenne d'année) | ⭐⭐ |
| T | Page « 404 » pour les URL inconnues | ⭐⭐ |
| U | Tri ascendant ↔ descendant | ⭐ |
| V | Custom hook useLocalStorage (refacto) |
⭐⭐⭐ |
🔑 Module L — Le boss : centraliser les données dans App (obligatoire)
Dans CORRECTION_page_detail.md tu as fait une version simple (la page détail re-fetch toute seule). Ses limites : les jeux ajoutés au formulaire et les likes n'arrivent pas dans le détail, parce que chacun vit dans son coin. On corrige ça proprement.
L'idée : le fetch, la liste jeux et les likes doivent vivre au-dessus des deux pages → dans App, qui les passe ensuite en props aux routes.
L.1 — Remonter le fetch et les états dans App.jsx
import { useState, useEffect } from "react";
import { Routes, Route } from "react-router-dom";
import Header from "./components/Header";
import Footer from "./components/Footer";
import Accueil from "./pages/Accueil";
import Detail from "./pages/Detail";
function App() {
const [jeux, setJeux] = useState([]);
const [likes, setLikes] = useState(() =>
JSON.parse(localStorage.getItem("likes") || "{}")
);
useEffect(() => {
fetch("/zelda.json").then((r) => r.json()).then(setJeux);
}, []);
useEffect(() => {
localStorage.setItem("likes", JSON.stringify(likes));
}, [likes]);
function liker(id) {
setLikes((prev) => ({ ...prev, [id]: (prev[id] || 0) + 1 }));
}
return (
<>
<Header />
<Routes>
<Route path="/" element={
<Accueil jeux={jeux} setJeux={setJeux} likes={likes} onLike={liker} />
} />
<Route path="/jeu/:id" element={
<Detail jeux={jeux} likes={likes} />
} />
</Routes>
<Footer />
</>
);
}
Le déclic : une route reçoit des props comme n'importe quel composant —
element={<Detail jeux={jeux} />}. C'est ça qui « branche » la donnée sur les deux pages.
Nouveau réflexe likes : avant, chaque
Cartegardait son like dans son coin (localStorage.getItem("likes-" + id)). Maintenant c'est un seul objetlikes(genre{ "2": 3, "4": 1 }) rangé d'un coup. C'est ce qui rend possible le Hall of Fame du Module N (impossible quand chaque like est éparpillé).
L.2 — Accueil et Carte lisent via les props
Accueil({ jeux, setJeux, likes, onLike }): enlève sonuseState(jeux)et sonfetch(ils sont dansApp). Garderecherche,tri, les champs du formulaire en local.- Passe à chaque carte le bon compteur :
likes={likes[jeu.id] || 0}etonLike={() => onLike(jeu.id)}. - Dans
Carte, supprime leuseState/useEffectde likes : afficheprops.likeset appelleprops.onLikeau clic.
L.3 — Detail affiche le vrai jeu avec ses likes
import { useParams, Link } from "react-router-dom";
function Detail({ jeux, likes }) {
const { id } = useParams();
const jeu = jeux.find((j) => String(j.id) === id);
if (!jeu) return <p>Introuvable — <Link to="/">retour</Link></p>;
return (
<div className="detail">
<Link to="/">← Retour</Link>
<h1>{jeu.titre}</h1>
<p>{jeu.console} — {jeu.annee} — {jeu.Type}</p>
<p>❤️ Likes : {likes[jeu.id] || 0}</p>
</div>
);
}
Test du boss : ajoute un jeu au formulaire → clique « détails » dessus → il s'affiche (alors qu'avant il était introuvable). Like-le sur l'accueil → son détail affiche le bon nombre. Recharge la page → les likes sont toujours là.
Ce que tu viens de faire s'appelle « lifting state up » poussé jusqu'au routeur : l'état monte au plus petit ancêtre commun (
App) des composants qui en ont besoin. C'est le réflexe d'archi central en React.
Module M — Bouton « au hasard »
Un bouton qui t'envoie sur la fiche d'un jeu au hasard. On découvre useNavigate (naviguer en JS, pas via un clic sur un <Link>).
import { useNavigate } from "react-router-dom";
const navigate = useNavigate();
function hasard() {
const i = Math.floor(Math.random() * jeux.length);
navigate(`/jeu/${jeux[i].id}`);
}
// <button onClick={hasard}>🎲 Au hasard</button>
<Link>= naviguer au clic sur un lien.useNavigate()= naviguer depuis du code (après un calcul, un envoi de formulaire…).
Test : clique plusieurs fois → tu tombes sur des jeux différents.
Module N — Le Hall of Fame
En haut de l'accueil, le Top 3 des jeux les plus likés, avec des médailles. (Possible uniquement parce que les likes sont centralisés depuis le Module L.)
const medailles = ["🥇", "🥈", "🥉"];
const top = [...jeux]
.sort((a, b) => (likes[b.id] || 0) - (likes[a.id] || 0))
.slice(0, 3);
// <div className="hall">
// {top.map((j, index) => (
// <p key={j.id}>{medailles[index]} {j.titre} — {likes[j.id] || 0} ❤️</p>
// ))}
// </div>
.slice(0, 3)garde les indices 0, 1, 2. Le 2ᵉ paramètre de.map((j, index) => ...), c'est la position → parfait pour la médaille.
Test : like beaucoup un jeu → il grimpe en haut du Hall of Fame en direct.
Module O — Supprimer un jeu
Un bouton « supprimer » sur chaque carte. La clé : l'immutabilité (un nouveau tableau sans l'élément).
function supprimer(id) {
setJeux(jeux.filter((j) => j.id !== id));
}
// sur la Carte : <button onClick={() => props.onDelete()}>🗑️</button>
// et dans Accueil : onDelete={() => supprimer(jeu.id)}
filterrenvoie un nouveau tableau contenant tout sauf l'élément visé. On ne touche jamais l'ancien (immutabilité, comme pour l'ajout).
Test : clique 🗑️ → la carte disparaît, les autres restent.
Module P — Thème sombre (qui se souvient)
Un bouton qui bascule la page en sombre et garde le choix après un refresh — exactement le réflexe localStorage que tu connais des likes.
const [sombre, setSombre] = useState(
() => localStorage.getItem("theme") === "sombre"
);
useEffect(() => {
localStorage.setItem("theme", sombre ? "sombre" : "clair");
}, [sombre]);
// <div className={sombre ? "app sombre" : "app"}>
// <button onClick={() => setSombre(!sombre)}>{sombre ? "☀️" : "🌙"}</button>
.sombre { background-color: #1a1a2e; color: #eaeaea; }
.sombre .carte { background-color: #16213e; color: #eaeaea; }
Tu fais piloter du CSS par du state :
classNamechange selonsombre, le navigateur applique les bonnes règles. C'est comme ça que marchent tous les dark modes.
Test : passe en sombre → recharge (F5) → c'est toujours sombre.
Module Q — Mode Versus
Deux <select> pour choisir deux jeux ; on compare leurs likes (ou leurs années) et on affiche le gagnant.
const [a, setA] = useState(jeux[0].id);
const [b, setB] = useState(jeux[1].id);
const likesA = likes[a] || 0;
const likesB = likes[b] || 0;
const gagnant = likesA === likesB ? "Égalité" :
(likesA > likesB ? jeux.find(j => String(j.id) === String(a)).titre
: jeux.find(j => String(j.id) === String(b)).titre);
Deux <select> (un pour a, un pour b) listant jeux.map(j => <option value={j.id}>{j.titre}</option>), puis un <p> qui annonce le gagnant.
Attention au type de la valeur d'un
<select>(elle revient en chaîne) : compare avecString(...)des deux côtés.
Test : change les deux menus → le verdict se met à jour tout seul.
Module R — Filtre par Type
Tes jeux ont un champ Type ("2d" / "3d"). Ajoute trois boutons (Tous / 2d / 3d) qui filtrent la liste, en plus de ta recherche.
const [filtreType, setFiltreType] = useState("tous");
// dans ta chaîne filtre → tri → map, ajoute une étape :
const affiches = [...jeux]
.filter((j) => j.titre.toLowerCase().includes(recherche.toLowerCase()))
.filter((j) => filtreType === "tous" || j.Type === filtreType)
.sort(/* ... */);
// <button onClick={() => setFiltreType("tous")}>Tous</button>
// <button onClick={() => setFiltreType("2d")}>2D</button>
// <button onClick={() => setFiltreType("3d")}>3D</button>
On enchaîne deux
.filter(): recherche puis type. Chaque filtre renvoie un nouveau tableau, on les compose tranquillement.
Test : clique « 2D » → seuls les jeux 2d restent, et la recherche marche toujours par-dessus.
Module S — Statistiques
Une petite barre de stats calculée à partir des données. On muscle reduce.
const nbJeux = jeux.length;
const totalLikes = Object.values(likes).reduce((s, n) => s + n, 0);
const anneeMoyenne = nbJeux
? Math.round(jeux.reduce((s, j) => s + Number(j.annee), 0) / nbJeux)
: 0;
// <p>{nbJeux} jeux · {totalLikes} likes · année moyenne {anneeMoyenne}</p>
reduceréduit un tableau à une seule valeur en accumulant. Ici on additionne likes et années.
Test : ajoute/like des jeux → les chiffres bougent.
Module T — Page « 404 »
Que se passe-t-il si on tape /jeu/banane ou une URL qui n'existe pas ? Rien d'affiché. On ajoute une route attrape-tout.
import { Link } from "react-router-dom";
function NotFound() {
return <div><h1>404 — page introuvable</h1><Link to="/">Retour à l'accueil</Link></div>;
}
// dans <Routes>, en DERNIER :
// <Route path="*" element={<NotFound />} />
path="*"= « toutes les URL non reconnues ». À mettre en dernier, sinon il attrape tout avant les autres.
Test : tape une URL bidon → la page 404 s'affiche.
Module U — Tri ascendant ↔ descendant
Ajoute un bouton qui inverse l'ordre du tri en cours.
const [ordre, setOrdre] = useState("asc");
// après ton .sort(...) :
const affiches = [...maListeTriee];
if (ordre === "desc") affiches.reverse();
// <button onClick={() => setOrdre(ordre === "asc" ? "desc" : "asc")}>
// {ordre === "asc" ? "A→Z" : "Z→A"}
// </button>
Test : clique le bouton → l'ordre des cartes s'inverse.
Module V — Custom hook useLocalStorage (refacto)
Tu répètes le même schéma useState + useEffect(localStorage…) pour les likes et le thème. On range ça dans un hook réutilisable.
// src/useLocalStorage.js
import { useState, useEffect } from "react";
export function useLocalStorage(cle, valeurParDefaut) {
const [valeur, setValeur] = useState(() => {
const stockee = localStorage.getItem(cle);
return stockee !== null ? JSON.parse(stockee) : valeurParDefaut;
});
useEffect(() => {
localStorage.setItem(cle, JSON.stringify(valeur));
}, [cle, valeur]);
return [valeur, setValeur];
}
Puis dans App : const [likes, setLikes] = useLocalStorage("likes", {}); remplace les deux blocs likes d'un coup.
Un custom hook, c'est juste une fonction qui commence par
useet qui utilise d'autres hooks. Ça permet de réutiliser de la logique entre composants.
Test : like des jeux → recharge → les likes sont toujours là, mais avec deux fois moins de code.
✅ Checklist
- L —
fetch+jeux+likesremontés dansApp, page détail complète (obligatoire) - M — au hasard · [ ] N — Hall of Fame · [ ] O — supprimer · [ ] P — thème sombre
- Q — versus · [ ] R — filtre Type · [ ] S — stats · [ ] T — page 404
- U — tri inversé · [ ] V — custom hook
🧠 Concepts rencontrés aujourd'hui
useNavigate · fetch + état remontés au niveau du routeur + props passées à une <Route> · likes centralisés dans un objet · filter (supprimer, filtrer) · sort/slice/index (classement) · reduce (stats) · route attrape-tout path="*" · CSS piloté par le state · custom hook.
Bravo pour ces deux semaines. Montre-moi tes modules battus quand tu en tiens un.