Cours Vibe coding avec l'IA · Module 5: Clone de Reddit

Système de vote - Upvotes et Downvotes

8 min de lecture
Dans cette leçon8

Bienvenue ! Une dispute éclate sur le pont : mettre le cap sur Tortuga ou chercher le trésor au nord. Si chaque pirate note son idée sur un bout de papier, au bout d'une semaine personne ne retrouvera celles que soutient la majorité. Voilà pourquoi nous construisons Crew Council, un forum pirate comme Reddit, où l'équipage vote pour ou contre les idées et où les meilleures remontent seules.

Ce que tu vas apprendre :

  • calculer le score d'un post à partir des votes pour et contre,
  • construire des boutons de vote qui retiennent le vote et permettent de l'annuler,
  • trier les posts avec les modes New, Top et Hot,
  • calculer le pourcentage de votes positifs.

Reddit face à Twitter et Instagram

Dans le modèle classique comparé ici, Twitter et Instagram classent les posts par ordre chronologique, les plus récents en haut, et ne connaissent que les likes. Reddit trie selon les votes, propose upvotes et downvotes, des commentaires imbriqués (threaded comments) et des communautés thématiques, les subreddits. Nous construirons ces trois systèmes dans ce module, l'un après l'autre.

Les données d'un post

Un post enregistre séparément les deux types de votes et retient comment l'utilisateur actuel a voté :

1interface Post {
2  id: number;
3  title: string;
4  content: string;
5  author: string;
6  community: string;
7  upvotes: number;
8  downvotes: number;
9  userVote: 'up' | 'down' | null; // Vote de l'utilisateur actuel
10  comments: number;
11  timestamp: Date;
12}

upvotes sont les votes pour, downvotes les votes contre, et userVote prend l'une de trois valeurs : 'up', 'down' ou null si l'utilisateur n'a pas encore voté.

Le score

Le score est une simple différence, upvotes - downvotes :

1function calculateScore(post: Post): number {
2  return post.upvotes - post.downvotes;
3}
4
5// Exemples :
6// 100 upvotes, 20 downvotes = score de 80
7// 50 upvotes, 60 downvotes = score de -10

Un post avec 100 votes pour et 20 contre a un score de 80, et avec 50 pour et 60 contre le résultat est négatif : 50 - 60 donne -10. La fonction n'enregistre rien, elle calcule seulement. Dans un composant, une ligne suffit, const score = upvotes - downvotes, et tu peux voir le résultat avec console.log(score).

Les boutons de vote

Un vote a trois états, et chaque bouton doit gérer toutes les transitions :

1'use client';
2
3import { useState } from 'react';
4
5interface VoteProps {
6  initialUpvotes: number;
7  initialDownvotes: number;
8  initialUserVote: 'up' | 'down' | null;
9}
10
11export default function VoteButtons({
12  initialUpvotes,
13  initialDownvotes,
14  initialUserVote
15}: VoteProps) {
16  const [upvotes, setUpvotes] = useState(initialUpvotes);
17  const [downvotes, setDownvotes] = useState(initialDownvotes);
18  const [userVote, setUserVote] = useState<'up' | 'down' | null>(initialUserVote);
19
20  const score = upvotes - downvotes;
21
22  const handleUpvote = () => {
23    if (userVote === 'up') {
24      // Annuler l'upvote
25      setUpvotes(upvotes - 1);
26      setUserVote(null);
27    } else if (userVote === 'down') {
28      // Passer du downvote à l'upvote
29      setDownvotes(downvotes - 1);
30      setUpvotes(upvotes + 1);
31      setUserVote('up');
32    } else {
33      // Nouvel upvote
34      setUpvotes(upvotes + 1);
35      setUserVote('up');
36    }
37  };
38
39  const handleDownvote = () => {
40    if (userVote === 'down') {
41      // Annuler le downvote
42      setDownvotes(downvotes - 1);
43      setUserVote(null);
44    } else if (userVote === 'up') {
45      // Passer de l'upvote au downvote
46      setUpvotes(upvotes - 1);
47      setDownvotes(downvotes + 1);
48      setUserVote('down');
49    } else {
50      // Nouveau downvote
51      setDownvotes(downvotes + 1);
52      setUserVote('down');
53    }
54  };
55
56  return (
57    <div className="flex flex-col items-center gap-1">
58      <button
59        onClick={handleUpvote}
60        className={`text-2xl transition-colors ${
61          userVote === 'up' ? 'text-orange-500' : 'text-gray-400 hover:text-orange-500'
62        }`}
63      >
64        ▲
65      </button>
66
67      <span className={`font-bold text-sm ${
68        score > 0 ? 'text-orange-500' : score < 0 ? 'text-blue-500' : 'text-gray-600'
69      }`}>
70        {score}
71      </span>
72
73      <button
74        onClick={handleDownvote}
75        className={`text-2xl transition-colors ${
76          userVote === 'down' ? 'text-blue-500' : 'text-gray-400 hover:text-blue-500'
77        }`}
78      >
79        ▼
80      </button>
81    </div>
82  );
83}

Quand userVote === 'up' et que tu cliques encore sur upvote, l'upvote est annulé et userVote revient à null. Passer d'un downvote à un upvote retire le vote contre et ajoute un vote pour, donc le score augmente de 2. La couleur du nombre dépend de son signe : orange s'il est positif, bleu s'il est négatif.

Conseil du Capitaine Barbe-Rouge : ces boutons ne changent que l'état du navigateur, donc le vote disparaît après un rechargement. Dans un vrai forum, enregistre-le en base et veille à ce qu'un pirate n'ait qu'un vote par post.

La carte du post

La carte réunit les boutons et le contenu : communauté, auteur, date, titre et actions :

1'use client';
2
3import VoteButtons from './VoteButtons';
4
5interface PostCardProps {
6  post: Post;
7}
8
9export default function PostCard({ post }: PostCardProps) {
10  const score = post.upvotes - post.downvotes;
11
12  return (
13    <div className="bg-white rounded-lg shadow hover:shadow-lg transition-shadow border border-gray-200">
14      <div className="flex gap-3 p-4">
15        {/* Section vote */}
16        <VoteButtons
17          initialUpvotes={post.upvotes}
18          initialDownvotes={post.downvotes}
19          initialUserVote={post.userVote}
20        />
21
22        {/* Section contenu */}
23        <div className="flex-1">
24          {/* Communauté et auteur */}
25          <div className="flex items-center gap-2 text-xs text-gray-600 mb-2">
26            <span className="font-bold text-gray-900">r/{post.community}</span>
27            <span>•</span>
28            <span>Publié par u/{post.author}</span>
29            <span>•</span>
30            <span>{formatTimeAgo(post.timestamp)}</span>
31          </div>
32
33          {/* Titre */}
34          <h2 className="text-xl font-bold text-gray-900 mb-2 hover:underline cursor-pointer">
35            {post.title}
36          </h2>
37
38          {/* Aperçu du contenu */}
39          <p className="text-gray-700 text-sm mb-3 line-clamp-3">
40            {post.content}
41          </p>
42
43          {/* Actions */}
44          <div className="flex items-center gap-4 text-gray-500 text-sm">
45            <button className="flex items-center gap-1 hover:bg-gray-100 px-2 py-1 rounded">
46              {post.comments} Commentaires
47            </button>
48            <button className="flex items-center gap-1 hover:bg-gray-100 px-2 py-1 rounded">
49              Partager
50            </button>
51            <button className="flex items-center gap-1 hover:bg-gray-100 px-2 py-1 rounded">
52              Enregistrer
53            </button>
54          </div>
55        </div>
56      </div>
57    </div>
58  );
59}
60
61function formatTimeAgo(date: Date): string {
62  const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
63
64  if (seconds < 60) return `il y a ${seconds}s`;
65  if (seconds < 3600) return `il y a ${Math.floor(seconds / 60)}min`;
66  if (seconds < 86400) return `il y a ${Math.floor(seconds / 3600)}h`;
67  return `il y a ${Math.floor(seconds / 86400)}j`;
68}

Les boutons reçoivent leurs valeurs de départ par les props initialUpvotes, initialDownvotes et initialUserVote, puis comptent seuls. formatTimeAgo transforme une date en texte du type il y a 5min ou il y a 3h.

Le tri : New, Top et Hot

Tu vas découvrir les fonctions de tri dans l'ordre New, Top et Hot. New trie par horodatage (timestamp), les plus récents en haut :

1function sortByNew(posts: Post[]): Post[] {
2  return [...posts].sort((a, b) =>
3    b.timestamp.getTime() - a.timestamp.getTime()
4  );
5}

Top trie selon le score, c'est-à-dire la différence des votes, quelle que soit la date du post :

1function sortByTop(posts: Post[]): Post[] {
2  return [...posts].sort((a, b) => {
3    const scoreA = a.upvotes - a.downvotes;
4    const scoreB = b.upvotes - b.downvotes;
5    return scoreB - scoreA;
6  });
7}

Les deux fonctions copient le tableau avec [...posts], car sort modifie le tableau d'origine, et on ne doit pas modifier les props.

Hot combine le score et le temps. Le vrai algorithme de Reddit est plus complexe, mais l'idée reste la même :

1function calculateHotScore(post: Post): number {
2  const score = post.upvotes - post.downvotes;
3  const hoursSincePost = (new Date().getTime() - post.timestamp.getTime()) / (1000 * 60 * 60);
4
5  // Les posts plus récents avec un bon score ont un "hot score" plus élevé
6  return score / Math.pow(hoursSincePost + 2, 1.5);
7}
8
9function sortByHot(posts: Post[]): Post[] {
10  return [...posts].sort((a, b) =>
11    calculateHotScore(b) - calculateHotScore(a)
12  );
13}

On divise le score par un facteur temporel (décroissance temporelle, time decay) qui grandit à chaque heure. Ainsi, les posts plus récents avec un bon score passent au-dessus des anciens, même si ces derniers ont récolté plus de votes. Le + 2 évite de diviser par un nombre minuscule pendant les premières minutes.

Un fil avec des onglets de tri

Le fil garde le mode actuel dans l'état et choisit la fonction correspondante :

1'use client';
2
3import { useState } from 'react';
4import PostCard from './PostCard';
5
6type SortMode = 'hot' | 'new' | 'top';
7
8export default function Feed({ initialPosts }: { initialPosts: Post[] }) {
9  const [sortMode, setSortMode] = useState<SortMode>('hot');
10
11  const sortedPosts = () => {
12    switch (sortMode) {
13      case 'new':
14        return sortByNew(initialPosts);
15      case 'top':
16        return sortByTop(initialPosts);
17      case 'hot':
18      default:
19        return sortByHot(initialPosts);
20    }
21  };
22
23  return (
24    <div className="max-w-4xl mx-auto p-4">
25      {/* Onglets de tri */}
26      <div className="flex gap-2 mb-4 bg-white rounded-lg p-2 shadow">
27        <button
28          onClick={() => setSortMode('hot')}
29          className={`px-4 py-2 rounded transition ${
30            sortMode === 'hot'
31              ? 'bg-orange-500 text-white'
32              : 'hover:bg-gray-100 text-gray-700'
33          }`}
34        >
35          Hot
36        </button>
37        <button
38          onClick={() => setSortMode('new')}
39          className={`px-4 py-2 rounded transition ${
40            sortMode === 'new'
41              ? 'bg-orange-500 text-white'
42              : 'hover:bg-gray-100 text-gray-700'
43          }`}
44        >
45          New
46        </button>
47        <button
48          onClick={() => setSortMode('top')}
49          className={`px-4 py-2 rounded transition ${
50            sortMode === 'top'
51              ? 'bg-orange-500 text-white'
52              : 'hover:bg-gray-100 text-gray-700'
53          }`}
54        >
55          Top
56        </button>
57      </div>
58
59      {/* Posts */}
60      <div className="space-y-3">
61        {sortedPosts().map(post => (
62          <PostCard key={post.id} post={post} />
63        ))}
64      </div>
65    </div>
66  );
67}

Cliquer sur un onglet ne change que sortMode. Les posts d'entrée restent les mêmes, et l'ordre se calcule à chaque rendu.

Le pourcentage de votes positifs

Reddit affiche aussi la part des votes favorables :

1function calculateUpvotePercentage(upvotes: number, downvotes: number): number {
2  const total = upvotes + downvotes;
3  if (total === 0) return 0;
4  return Math.round((upvotes / total) * 100);
5}
6
7// Exemple d'utilisation :
8const percentage = calculateUpvotePercentage(post.upvotes, post.downvotes);
9// 85 upvotes, 15 downvotes = 85% d'avis positifs

La formule est upvotes / (upvotes + downvotes) * 100, arrondie à l'entier. La condition total === 0 évite une division par zéro quand personne n'a encore voté.

À retenir : le score est la différence des votes, et l'ordre du fil découle du mode de tri choisi. Dans l'éditeur ci-dessous, il y a trois jeux de boutons de vote : clique deux fois sur le même bouton et regarde le vote s'annuler, puis fais les exercices.

Code de cette leçon : App.jsx
1// Boutons de vote - Système Upvote/Downvote
2import { useState } from 'react';
3
4function VoteButtons({ initialUpvotes, initialDownvotes }) {
5  const [upvotes, setUpvotes] = useState(initialUpvotes);
6  const [downvotes, setDownvotes] = useState(initialDownvotes);
7  const [userVote, setUserVote] = useState(null);
8
9  const score = upvotes - downvotes;
10
11  const handleUpvote = () => {
12    if (userVote === 'up') {
13      setUpvotes(upvotes - 1);
14      setUserVote(null);
15    } else if (userVote === 'down') {
16      setDownvotes(downvotes - 1);
17      setUpvotes(upvotes + 1);
18      setUserVote('up');
19    } else {
20      setUpvotes(upvotes + 1);
21      setUserVote('up');
22    }
23  };
24
25  const handleDownvote = () => {
26    if (userVote === 'down') {
27      setDownvotes(downvotes - 1);
28      setUserVote(null);
29    } else if (userVote === 'up') {
30      setUpvotes(upvotes - 1);
31      setDownvotes(downvotes + 1);
32      setUserVote('down');
33    } else {
34      setDownvotes(downvotes + 1);
35      setUserVote('down');
36    }
37  };
38
39  return (
40    <div className="flex flex-col items-center gap-1 bg-gray-50 rounded-lg p-2">
41      <button
42        onClick={handleUpvote}
43        className={`text-3xl transition ${userVote === 'up' ? 'text-orange-500' : 'text-gray-400 hover:text-orange-500'}`}
44      >
45        ▲
46      </button>
47      <span className={`font-bold text-lg ${score > 0 ? 'text-orange-500' : score < 0 ? 'text-blue-500' : 'text-gray-600'}`}>
48        {score}
49      </span>
50      <button
51        onClick={handleDownvote}
52        className={`text-3xl transition ${userVote === 'down' ? 'text-blue-500' : 'text-gray-400 hover:text-blue-500'}`}
53      >
54        ▼
55      </button>
56    </div>
57  );
58}
59
60export default function App() {
61  return (
62    <div className="min-h-screen bg-gray-100 p-8 flex items-center justify-center">
63      <div className="bg-white rounded-lg shadow-xl p-8 max-w-md">
64        <h1 className="text-2xl font-bold mb-6 text-center">
65          Système de vote
66        </h1>
67        <div className="flex gap-6 items-center justify-center">
68          <div className="text-center">
69            <p className="text-sm text-gray-600 mb-2">Proposition populaire</p>
70            <VoteButtons initialUpvotes={156} initialDownvotes={23} />
71          </div>
72          <div className="text-center">
73            <p className="text-sm text-gray-600 mb-2">Controversée</p>
74            <VoteButtons initialUpvotes={89} initialDownvotes={92} />
75          </div>
76          <div className="text-center">
77            <p className="text-sm text-gray-600 mb-2">Nouvelle</p>
78            <VoteButtons initialUpvotes={5} initialDownvotes={1} />
79          </div>
80        </div>
81        <div className="mt-8 bg-blue-50 p-4 rounded-lg text-sm text-gray-700">
82          <p className="font-bold mb-2">Règles de vote :</p>
83          <ul className="list-disc list-inside space-y-1">
84            <li>Upvote = +1 au score</li>
85            <li>Downvote = -1 au score</li>
86            <li>Reclique = annule le vote</li>
87            <li>Score = upvotes - downvotes</li>
88          </ul>
89        </div>
90      </div>
91    </div>
92  );
93}

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. 1. Comment le score est-il calculé dans un système de vote ?

  2. 2. Quelles valeurs userVote peut-il prendre ?

Voici 2 des 8 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Composant de boutons de vote

  • Clique dans l'ordre

    Construis le calcul du score en cliquant sur les éléments dans l'ordre :

  • Tri vertical

    Ordonne les fonctions de tri comme dans la leçon (New, Top, Hot) :

  • Éditeur de code

    Trier les posts par mode

  • Tri horizontal

    Ordonne l'expression qui calcule le score (une ligne, de gauche à droite) :

  • Tri horizontal

    Ordonne l'appel qui affiche le score dans la console (une ligne, de gauche à droite) :

  • Clique dans l'ordre

    Construis l'expression donnant un score négatif (50 upvotes, 60 downvotes) :

Articles utiles