Cours Vibe coding avec l'IA · Module 12: Clone de Medium

Expérience de lecture et engagement

4 min de lecture
Dans cette leçon4

Medium offre une expérience de lecture unique - applaudissements, surlignages, réponses.

Système d'applaudissements

1interface ClapData {
2  articleId: string;
3  userId: string;
4  count: number; // user can clap up to 50 times
5  createdAt: Date;
6  updatedAt: Date;
7}
8
9// Clap button with animation
10'use client';
11
12import { useState } from 'react';
13
14export default function ClapButton({ articleId }: { articleId: string }) {
15  const [claps, setClaps] = useState(0);
16  const [userClaps, setUserClaps] = useState(0);
17  const [isAnimating, setIsAnimating] = useState(false);
18  const MAX_CLAPS = 50;
19
20  const handleClap = async () => {
21    if (userClaps >= MAX_CLAPS) return;
22
23    setIsAnimating(true);
24    setClaps(prev => prev + 1);
25    setUserClaps(prev => prev + 1);
26
27    // Animation duration
28    setTimeout(() => setIsAnimating(false), 300);
29
30    // Send to API
31    await fetch(`/api/articles/${articleId}/clap`, {
32      method: 'POST'
33    });
34  };
35
36  return (
37    <div className="flex flex-col items-center gap-2">
38      <button
39        onClick={handleClap}
40        disabled={userClaps >= MAX_CLAPS}
41        className={`
42          w-12 h-12 rounded-full border-2 border-gray-300
43          flex items-center justify-center
44          hover:border-green-500 transition
45          ${isAnimating ? 'scale-125' : 'scale-100'}
46          ${userClaps >= MAX_CLAPS ? 'opacity-50 cursor-not-allowed' : ''}
47        `}
48      >
49        <span className="text-sm font-semibold">Bravo</span>
50      </button>
51
52      <div className="text-center">
53        <div className="text-sm font-semibold">{claps.toLocaleString()}</div>
54        {userClaps > 0 && (
55          <div className="text-xs text-gray-500">
56            Tu as applaudi {userClaps}x
57          </div>
58        )}
59      </div>
60    </div>
61  );
62}

Système de surlignage

1interface Highlight {
2  id: string;
3  articleId: string;
4  userId: string;
5  blockId: string;
6  startOffset: number;
7  endOffset: number;
8  text: string;
9  note?: string; // private note on highlight
10  createdAt: Date;
11}
12
13// Highlight selection
14function handleTextSelection(articleId: string, blockId: string) {
15  const selection = window.getSelection();
16  if (!selection || selection.toString().trim().length === 0) return;
17
18  const range = selection.getRangeAt(0);
19  const highlight: Omit<Highlight, 'id' | 'createdAt'> = {
20    articleId,
21    userId: getCurrentUserId(),
22    blockId,
23    startOffset: range.startOffset,
24    endOffset: range.endOffset,
25    text: selection.toString()
26  };
27
28  saveHighlight(highlight);
29}
30
31// Render highlights
32function renderWithHighlights(
33  text: string,
34  highlights: Highlight[]
35): React.ReactNode {
36  if (highlights.length === 0) return text;
37
38  const parts: Array<{ text: string; highlighted: boolean }> = [];
39  let lastIndex = 0;
40
41  highlights
42    .sort((a, b) => a.startOffset - b.startOffset)
43    .forEach(highlight => {
44      if (highlight.startOffset > lastIndex) {
45        parts.push({
46          text: text.slice(lastIndex, highlight.startOffset),
47          highlighted: false
48        });
49      }
50
51      parts.push({
52        text: text.slice(highlight.startOffset, highlight.endOffset),
53        highlighted: true
54      });
55
56      lastIndex = highlight.endOffset;
57    });
58
59  if (lastIndex < text.length) {
60    parts.push({
61      text: text.slice(lastIndex),
62      highlighted: false
63    });
64  }
65
66  return parts.map((part, i) =>
67    part.highlighted ? (
68      <mark key={i} className="bg-yellow-200">{part.text}</mark>
69    ) : (
70      <span key={i}>{part.text}</span>
71    )
72  );
73}

Réponses (Commentaires)

1interface Response {
2  id: string;
3  articleId: string;
4  author: Author;
5  content: string;
6  highlightId?: string; // response to a highlight
7  parentId?: string; // nested responses
8  replies: Response[];
9  claps: number;
10  createdAt: Date;
11  updatedAt: Date;
12}
13
14'use client';
15
16export default function ResponseSection({
17  articleId,
18  responses
19}: {
20  articleId: string;
21  responses: Response[];
22}) {
23  const [content, setContent] = useState('');
24
25  const handleSubmit = async () => {
26    if (!content.trim()) return;
27
28    await fetch(`/api/articles/${articleId}/responses`, {
29      method: 'POST',
30      headers: { 'Content-Type': 'application/json' },
31      body: JSON.stringify({ content })
32    });
33
34    setContent('');
35  };
36
37  return (
38    <div className="max-w-2xl mx-auto px-8 py-12">
39      <h2 className="text-2xl font-bold mb-6">
40        Réponses ({responses.length})
41      </h2>
42
43      {/* Write Response */}
44      <div className="mb-8 border border-gray-200 rounded-lg p-4">
45        <textarea
46          value={content}
47          onChange={(e) => setContent(e.target.value)}
48          placeholder="Qu'en penses-tu ?"
49          className="w-full outline-none resize-none text-lg"
50          rows={3}
51        />
52        <div className="flex justify-end mt-2">
53          <button
54            onClick={handleSubmit}
55            disabled={!content.trim()}
56            className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700 disabled:bg-gray-300"
57          >
58            Répondre
59          </button>
60        </div>
61      </div>
62
63      {/* Responses List */}
64      <div className="space-y-6">
65        {responses.map(response => (
66          <ResponseItem key={response.id} response={response} />
67        ))}
68      </div>
69    </div>
70  );
71}
72
73function ResponseItem({ response }: { response: Response }) {
74  return (
75    <div className="border-b border-gray-200 pb-6">
76      <div className="flex items-start gap-3">
77        <img
78          src={response.author.avatar}
79          alt={response.author.name}
80          className="w-10 h-10 rounded-full"
81        />
82        <div className="flex-1">
83          <div className="flex items-center gap-2 mb-2">
84            <span className="font-semibold">{response.author.name}</span>
85            <span className="text-sm text-gray-500">
86              {new Date(response.createdAt).toLocaleDateString()}
87            </span>
88          </div>
89          <p className="text-gray-800 leading-relaxed mb-3">
90            {response.content}
91          </p>
92          <div className="flex items-center gap-4 text-sm text-gray-500">
93            <button className="flex items-center gap-1 hover:text-green-600">
94              <span>Bravo</span>
95              <span>{response.claps}</span>
96            </button>
97            <button className="hover:text-gray-700">Répondre</button>
98          </div>
99
100          {/* Nested Replies */}
101          {response.replies.length > 0 && (
102            <div className="mt-4 pl-6 border-l-2 border-gray-100 space-y-4">
103              {response.replies.map(reply => (
104                <ResponseItem key={reply.id} response={reply} />
105              ))}
106            </div>
107          )}
108        </div>
109      </div>
110    </div>
111  );
112}

Résumé

Système d'applaudissements - jusqu'à 50 applaudissements par utilisateur, animation Applaudissements multiples - les utilisateurs peuvent applaudir plusieurs fois Surlignage - sélectionner du texte pour le surligner Rendu des surlignages - afficher les surlignages avec un fond jaune Réponses - commentaires sur les articles Réponses imbriquées - réponses aux réponses Applaudissements des réponses - applaudir les bonnes réponses Infos sur l'auteur - avatar, nom, date dans les réponses

Dans le prochain exercice, nous ajouterons les publications et la curation !

À bientôt !

Code de cette leçon : App.tsx
1'use client';
2
3import { useState, useEffect } from 'react';
4
5interface Draft {
6  id: string;
7  title: string;
8  content: string;
9  lastSaved: Date;
10}
11
12export default function DraftManagement() {
13  const [drafts, setDrafts] = useState<Draft[]>([
14    {
15      id: '1',
16      title: 'Le guide du pirate pour TypeScript',
17      content: 'TypeScript est un outil puissant pour construire des applications évolutives...',
18      lastSaved: new Date(Date.now() - 1000 * 60 * 5)
19    },
20    {
21      id: '2',
22      title: 'Construire un navire à partir de zéro',
23      content: 'Dans ce guide complet, nous allons explorer...',
24      lastSaved: new Date(Date.now() - 1000 * 60 * 60 * 2)
25    },
26    {
27      id: '3',
28      title: 'Aventures en haute mer',
29      content: 'L\'océan est vaste et plein de mystères...',
30      lastSaved: new Date(Date.now() - 1000 * 60 * 60 * 24)
31    }
32  ]);
33
34  const formatRelativeTime = (date: Date) => {
35    const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
36
37    if (seconds < 60) return 'à l\'instant';
38    if (seconds < 3600) return `il y a ${Math.floor(seconds / 60)} min`;
39    if (seconds < 86400) return `il y a ${Math.floor(seconds / 3600)} h`;
40    return `il y a ${Math.floor(seconds / 86400)} j`;
41  };
42
43  return (
44    <div className="min-h-screen bg-gray-50 p-8">
45      <div className="max-w-4xl mx-auto">
46        <div className="flex items-center justify-between mb-8">
47          <h1 className="text-3xl font-bold">Tes brouillons</h1>
48          <button className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700">
49            Nouveau récit
50          </button>
51        </div>
52
53        <div className="bg-white rounded-lg shadow-sm divide-y divide-gray-200">
54          {drafts.map(draft => (
55            <div
56              key={draft.id}
57              className="p-6 hover:bg-gray-50 cursor-pointer transition"
58            >
59              <h2 className="text-xl font-bold mb-2">
60                {draft.title || 'Sans titre'}
61              </h2>
62              <p className="text-gray-600 mb-3 line-clamp-2">
63                {draft.content}
64              </p>
65              <div className="flex items-center justify-between text-sm text-gray-500">
66                <span>Dernière modification {formatRelativeTime(draft.lastSaved)}</span>
67                <div className="flex items-center gap-3">
68                  <button className="hover:text-gray-700">Modifier</button>
69                  <button className="hover:text-red-600">Supprimer</button>
70                </div>
71              </div>
72            </div>
73          ))}
74        </div>
75
76        {drafts.length === 0 && (
77          <div className="text-center py-12 text-gray-500">
78            <p className="text-lg mb-2">Aucun brouillon pour le moment</p>
79            <p>Commence à écrire ton premier récit</p>
80          </div>
81        )}
82      </div>
83    </div>
84  );
85}

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. Que sont les applaudissements (claps) sur Medium ?

  2. 2. Combien de fois un même utilisateur peut-il claper sur un article ?

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

Tâches pratiques dans le jeu

  • Tri horizontal

    Ordonne les étapes d'un applaudissement :

  • Clique dans l'ordre

    Ordonne l'appel qui récupère le texte sélectionné pour un surlignage :

  • Éditeur de code

    Composant d'interaction d'applaudissement

  • Tri vertical

    Ordonne le processus de création d'un surlignage de texte :

  • Tri horizontal

    Ordonne la balise qui affiche un fragment de texte surligné :

  • Éditeur de code

    Interface de commentaires

  • Tri vertical

    Ordonne les étapes de l'ajout d'une réponse dans handleSubmit :

  • Clique dans l'ordre

    Clique pour construire l'appel qui termine l'animation du clap après 300ms :

  • Clique dans l'ordre

    Clique pour construire l'appel qui augmente le nombre de claps de un :

Articles utiles