Cours Vibe coding avec l'IA · Module 12: Clone de Medium
Expérience de lecture et engagement
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. Que sont les applaudissements (claps) sur Medium ?
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 :