Cours Vibe coding avec l'IA · Module 5: Clone de Reddit
Commentaires imbriqués - Réponses en fil
Dans cette leçon7
Sous le post de la carte au trésor, les pirates lancent une discussion. Morgan demande où exactement, Redbeard répond, et Polly met en garde contre les requins. Dans une liste plate de commentaires, après une douzaine de messages, on ne sait plus qui répond à qui. Reddit règle ça avec des fils : une réponse se place sous le commentaire qu'elle concerne, et toute la discussion forme un arbre.
Des données qui se contiennent elles-mêmes
Un commentaire a un champ replies, un tableau de commentaires qui peuvent eux-mêmes avoir des réponses :
1interface Comment {
2 id: number;
3 author: string;
4 content: string;
5 upvotes: number;
6 downvotes: number;
7 userVote: 'up' | 'down' | null;
8 timestamp: Date;
9 replies: Comment[]; // Récursion - les commentaires contiennent des commentaires !
10}Le type replies: Comment[] renvoie à la même interface Comment. C'est la récursion : une structure qui contient des copies plus petites d'elle-même, comme un coffre rempli de coffres. Un tableau vide marque la fin d'une branche.
Voici une discussion d'exemple dans les données :
1const exampleComment: Comment = {
2 id: 1,
3 author: 'Redbeard',
4 content: 'J\'ai trouvé une carte menant à l\'île au trésor !',
5 upvotes: 42,
6 downvotes: 3,
7 userVote: null,
8 timestamp: new Date(),
9 replies: [
10 {
11 id: 2,
12 author: 'Morgan',
13 content: 'Où exactement ?',
14 upvotes: 15,
15 downvotes: 0,
16 userVote: null,
17 timestamp: new Date(),
18 replies: [
19 {
20 id: 3,
21 author: 'Redbeard',
22 content: 'Au nord de Tortuga !',
23 upvotes: 8,
24 downvotes: 0,
25 userVote: null,
26 timestamp: new Date(),
27 replies: [], // Aucune autre réponse
28 },
29 ],
30 },
31 ],
32};La même discussion disposée en arbre, avec un retrait pour chaque réponse, donne ceci :
1Commentaire 1 (Redbeard)
2└── Commentaire 2 (Morgan)
3 └── Commentaire 3 (Redbeard)Le retrait montre qui répond à qui : Morgan répond à Redbeard, et Redbeard répond de nouveau à Morgan. Quand tu numérotes un fil par niveaux, la réponse au Commentaire 1 est la Réponse 1.1, la réponse à celle-ci la Réponse 1.1.1, et le commentaire principal suivant, Commentaire 2, repart du bord gauche.
Un composant qui se rend lui-même
On ne sait pas combien de niveaux aura une discussion, donc une boucle à profondeur fixe ne convient pas. Le composant se rend lui-même pour chaque réponse :
1'use client';
2
3import { useState } from 'react';
4import VoteButtons from './VoteButtons';
5
6interface CommentProps {
7 comment: Comment;
8 depth?: number; // Profondeur d'imbrication (pour l'indentation)
9}
10
11export default function CommentThread({ comment, depth = 0 }: CommentProps) {
12 const [isCollapsed, setIsCollapsed] = useState(false);
13 const [showReplyForm, setShowReplyForm] = useState(false);
14
15 const score = comment.upvotes - comment.downvotes;
16
17 return (
18 <div className={`${depth > 0 ? 'ml-8 border-l-2 border-gray-200 pl-4' : ''}`}>
19 {/* En-tête du commentaire */}
20 <div className="flex gap-3 py-3">
21 <VoteButtons
22 initialUpvotes={comment.upvotes}
23 initialDownvotes={comment.downvotes}
24 initialUserVote={comment.userVote}
25 />
26
27 <div className="flex-1">
28 {/* Auteur et heure */}
29 <div className="flex items-center gap-2 text-xs text-gray-600 mb-1">
30 <span className="font-bold text-gray-900">{comment.author}</span>
31 <span>•</span>
32 <span>{formatTimeAgo(comment.timestamp)}</span>
33 {isCollapsed && (
34 <span className="text-blue-500">
35 [{comment.replies.length} enfants]
36 </span>
37 )}
38 </div>
39
40 {/* Contenu */}
41 {!isCollapsed && (
42 <>
43 <p className="text-gray-800 text-sm mb-2">{comment.content}</p>
44
45 {/* Actions */}
46 <div className="flex items-center gap-3 text-xs text-gray-500">
47 <button
48 onClick={() => setShowReplyForm(!showReplyForm)}
49 className="hover:underline"
50 >
51 Répondre
52 </button>
53 <button className="hover:underline">Partager</button>
54 <button className="hover:underline">Signaler</button>
55 {comment.replies.length > 0 && (
56 <button
57 onClick={() => setIsCollapsed(true)}
58 className="hover:underline"
59 >
60 [−] Réduire
61 </button>
62 )}
63 </div>
64
65 {/* Formulaire de réponse */}
66 {showReplyForm && (
67 <div className="mt-3 bg-gray-50 p-3 rounded">
68 <textarea
69 placeholder="Écris ta réponse..."
70 className="w-full p-2 border rounded text-sm"
71 rows={3}
72 />
73 <div className="flex gap-2 mt-2">
74 <button className="px-3 py-1 bg-blue-500 text-white rounded text-sm">
75 Répondre
76 </button>
77 <button
78 onClick={() => setShowReplyForm(false)}
79 className="px-3 py-1 bg-gray-200 rounded text-sm"
80 >
81 Annuler
82 </button>
83 </div>
84 </div>
85 )}
86 </>
87 )}
88
89 {/* État réduit */}
90 {isCollapsed && (
91 <button
92 onClick={() => setIsCollapsed(false)}
93 className="text-xs text-blue-500 hover:underline"
94 >
95 [+] Développer
96 </button>
97 )}
98 </div>
99 </div>
100
101 {/* RÉCURSION - Rendu des réponses avec le même composant */}
102 {!isCollapsed && comment.replies.length > 0 && (
103 <div className="mt-2">
104 {comment.replies.map(reply => (
105 <CommentThread
106 key={reply.id}
107 comment={reply}
108 depth={depth + 1} // Augmente la profondeur pour l'indentation
109 />
110 ))}
111 </div>
112 )}
113 </div>
114 );
115}En bas, comment.replies.map(reply => <CommentThread comment={reply} ... />) appelle le même composant pour les réponses et transmet depth={depth + 1}. La prop depth sert à définir l'indentation : un commentaire de premier niveau a depth = 0, une réponse 1, une réponse à une réponse 2, et ainsi de suite. La classe ml-8 border-l-2 est la même à chaque niveau, et le retrait augmente parce que chaque fil se trouve à l'intérieur du précédent.
L'état isCollapsed sert à réduire et développer le fil. Un commentaire réduit affiche le nombre de réponses, comment.replies.length, et sa branche n'est pas rendue. Chaque commentaire a son propre état, donc en réduire un ne touche pas ses voisins.
La limite de profondeur
Un fil très profond sortirait par le bord droit de l'écran. À ce stade, Reddit arrête de dessiner les niveaux suivants et affiche un lien Continuer ce fil → :
1const MAX_DEPTH = 10;
2
3export default function CommentThread({ comment, depth = 0 }: CommentProps) {
4 // ... code
5 // Si la profondeur est trop grande, affiche un lien au lieu de rendre les réponses
6 if (depth >= MAX_DEPTH && comment.replies.length > 0) {
7 return (
8 <div className="ml-8 pl-4 border-l-2 border-gray-200">
9 <a
10 href={`/comment/${comment.id}`}
11 className="text-blue-500 text-sm hover:underline"
12 >
13 Continuer ce fil →
14 </a>
15 </div>
16 );
17 }
18
19 // Reste du code...
20}Dans notre code, la limite est de 10 niveaux (la constante MAX_DEPTH). Les réponses ne disparaissent pas, le lien mène à une page séparée avec la suite du fil.
Trier les réponses
La fonction sortComments a trois modes, dans l'ordre best, new et top :
1function sortComments(comments: Comment[], sortMode: 'best' | 'new' | 'top'): Comment[] {
2 const sorted = [...comments];
3
4 switch (sortMode) {
5 case 'best':
6 // Algorithme du score de Wilson (simplifié)
7 sorted.sort((a, b) => {
8 const scoreA = a.upvotes - a.downvotes;
9 const scoreB = b.upvotes - b.downvotes;
10 return scoreB - scoreA;
11 });
12 break;
13 case 'new':
14 sorted.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
15 break;
16 case 'top':
17 sorted.sort((a, b) => {
18 const scoreA = a.upvotes - a.downvotes;
19 const scoreB = b.upvotes - b.downvotes;
20 return scoreB - scoreA;
21 });
22 break;
23 }
24
25 // Trie récursivement les réponses
26 return sorted.map(comment => ({
27 ...comment,
28 replies: sortComments(comment.replies, sortMode),
29 }));
30}Le tri est lui aussi récursif : après avoir ordonné un niveau, la fonction s'appelle elle-même pour replies. Le vrai mode best de Reddit utilise des statistiques plus avancées (d'où le commentaire sur l'algorithme de Wilson), mais notre version ne compte que le score, donc best et top donnent ici le même résultat.
Ajouter des réponses
Pour ajouter une réponse au fond de l'arbre, il faut d'abord trouver son parent :
1function addReplyToComment(
2 comments: Comment[],
3 parentId: number,
4 newReply: Comment
5): Comment[] {
6 return comments.map(comment => {
7 if (comment.id === parentId) {
8 // Parent trouvé - ajoute la réponse
9 return {
10 ...comment,
11 replies: [...comment.replies, newReply],
12 };
13 }
14
15 // Cherche plus profondément dans les réponses
16 return {
17 ...comment,
18 replies: addReplyToComment(comment.replies, parentId, newReply),
19 };
20 });
21}
22
23// Utilisation :
24const [comments, setComments] = useState<Comment[]>(initialComments);
25
26const handleAddReply = (parentId: number, content: string) => {
27 const newReply: Comment = {
28 id: Date.now(),
29 author: 'Toi',
30 content,
31 upvotes: 1,
32 downvotes: 0,
33 userVote: null,
34 timestamp: new Date(),
35 replies: [],
36 };
37
38 setComments(addReplyToComment(comments, parentId, newReply));
39};addReplyToComment fonctionne en quatre étapes : elle parcourt le tableau de commentaires avec map, si comment.id === parentId elle ajoute newReply à replies, sinon elle cherche plus profondément dans comment.replies, et à la fin elle renvoie le tableau de commentaires mis à jour. Les données d'origine restent intactes : un nouvel arbre est créé et passé à setComments.
Compter tous les commentaires
Le compteur sous un post doit inclure les réponses à tous les niveaux :
1function countTotalComments(comments: Comment[]): number {
2 return comments.reduce((total, comment) => {
3 return total + 1 + countTotalComments(comment.replies);
4 }, 0);
5}
6
7// Exemple :
8const total = countTotalComments(post.comments);
9// "142 commentaires"Pour chaque commentaire, on ajoute 1 pour le commentaire lui-même et le résultat de countTotalComments(comment.replies) pour toute sa branche.
Mettre en évidence les nouveaux commentaires
Quand tu reviens à une discussion, il est utile de voir tout de suite ce qui a changé :
1interface Comment {
2 // ... autres champs
3 isNew?: boolean; // Ajouté depuis la dernière visite
4}
5
6// Dans le composant :
7<div className={`${comment.isNew ? 'bg-blue-50 border-l-4 border-l-blue-500' : ''}`}>
8 {/* Contenu du commentaire */}
9</div>Le composant reconnaît un nouveau commentaire grâce au flag isNew sur l'objet Comment et lui donne un fond bleu avec une barre à gauche. Le flag est en général calculé par le serveur, en comparant la date du commentaire avec ta dernière visite.
Conseil du Capitaine Barbe-Rouge : toute fonction récursive a besoin d'une condition d'arrêt, comme tout voyage a besoin d'un port. Ici, c'est le tableau replies vide. Quand l'IA te génère une récursion, cherche d'abord cette condition.
À retenir : un fil est un arbre, et le plus simple pour gérer un arbre est une fonction qui s'appelle elle-même. Dans l'éditeur ci-dessous, tu as un fil avec quatre niveaux de commentaires : réduis le commentaire de Morgan et vois disparaître toute la branche en dessous, puis fais les exercices.
Code de cette leçon : App.jsx
1// Commentaires imbriqués - Récursif
2import { useState } from 'react';
3
4const data = {
5 id: 1,
6 author: 'Redbeard',
7 text: 'J\'ai trouvé une carte au trésor dans le nord !',
8 upvotes: 42,
9 replies: [
10 {
11 id: 2,
12 author: 'Morgan',
13 text: 'Où exactement ?',
14 upvotes: 15,
15 replies: [
16 {
17 id: 3,
18 author: 'Redbeard',
19 text: 'Près de l\'île de Tortuga',
20 upvotes: 8,
21 replies: [
22 {
23 id: 4,
24 author: 'Polly',
25 text: 'Il y a des requins là-bas !',
26 upvotes: 3,
27 replies: []
28 }
29 ]
30 }
31 ]
32 },
33 {
34 id: 5,
35 author: 'Jack',
36 text: 'As-tu besoin d\'un équipage ?',
37 upvotes: 12,
38 replies: []
39 }
40 ]
41};
42
43function Comment({ comment, depth = 0 }) {
44 const [collapsed, setCollapsed] = useState(false);
45 const [upvotes, setUpvotes] = useState(comment.upvotes);
46
47 return (
48 <div className={`${depth > 0 ? 'ml-6 border-l-2 border-gray-300 pl-3' : ''} py-2`}>
49 <div className="flex gap-2">
50 <button
51 onClick={() => setUpvotes(upvotes + 1)}
52 className="text-sm hover:text-orange-500"
53 >{upvotes}</button>
54 <div className="flex-1">
55 <div className="flex items-center gap-2 text-xs text-gray-600 mb-1">
56 <span className="font-bold text-gray-900">{comment.author}</span>
57 {collapsed && <span className="text-blue-500">[{comment.replies.length} réponses]</span>}
58 </div>
59 {!collapsed ? (
60 <>
61 <p className="text-sm mb-1">{comment.text}</p>
62 <div className="flex gap-3 text-xs text-gray-500">
63 <button className="hover:underline">Répondre</button>
64 {comment.replies.length > 0 && (
65 <button onClick={() => setCollapsed(true)} className="hover:underline">
66 [−] Réduire
67 </button>
68 )}
69 </div>
70 </>
71 ) : (
72 <button onClick={() => setCollapsed(false)} className="text-xs text-blue-500 hover:underline">
73 [+] Développer
74 </button>
75 )}
76 </div>
77 </div>
78 {!collapsed && comment.replies.map(reply => (
79 <Comment key={reply.id} comment={reply} depth={depth + 1} />
80 ))}
81 </div>
82 );
83}
84
85export default function App() {
86 return (
87 <div className="min-h-screen bg-gray-100 p-8">
88 <div className="max-w-3xl mx-auto bg-white rounded-lg shadow-lg p-6">
89 <h1 className="text-2xl font-bold mb-6">Commentaires imbriqués</h1>
90 <Comment comment={data} />
91 <div className="mt-6 bg-blue-50 p-4 rounded-lg text-sm">
92 <p className="font-bold mb-2">Structure récursive :</p>
93 <pre className="text-xs bg-white p-2 rounded overflow-x-auto">
94{`Commentaire 1 (Redbeard)
95└── Commentaire 2 (Morgan)
96 └── Commentaire 3 (Redbeard)
97 └── Commentaire 4 (Polly)
98└── Commentaire 5 (Jack)`}
99 </pre>
100 </div>
101 </div>
102 </div>
103 );
104}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Pourquoi Comment possède-t-il un champ 'replies: Comment[]' ?
2. Comment rendre des commentaires imbriqués ?
Voici 2 des 7 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Commentaires imbriqués
- Tri vertical
Ordonne la hiérarchie des commentaires :
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour construire le type du champ replies dans l'interface Comment :
- Éditeur de code
Réduire un fil de commentaires
- Tri vertical
Ordonne les étapes de l'ajout récursif d'une réponse (addReplyToComment) :
- Tri horizontal
Ordonne la lecture du nombre de réponses d'un commentaire (sur une seule ligne, de gauche à droite) :
- Tri vertical
Ordonne les commentaires par profondeur d'imbrication croissante :
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour construire l'appel récursif qui affiche les réponses :
- Tri vertical
Ordonne les modes de tri des commentaires comme dans la fonction sortComments (best, new, top) :