Cours Vibe coding avec l'IA · Module 12: Clone de Medium
Éditeur d'articles et texte enrichi
Dans cette leçon5
Bienvenue ! Il est temps de créer La Chronique du Capitaine - une plateforme d'écriture long format comme Medium !
Ce sera une plateforme de blog - éditeur de texte enrichi, brouillons, publication, applaudissements et temps de lecture.
Medium vs Twitter/Instagram
Twitter :
- Format court (280 caractères)
- Mises à jour rapides
- Fil en temps réel
Instagram :
- Priorité au visuel
- Photos/vidéos avec légendes
- Format stories
Medium :
- Articles long format (5 à 20 min de lecture)
- Édition de texte enrichi (titres, images, blocs de code, citations)
- Applaudissements au lieu de mentions J’aime
- Surlignage et réponses
- Publications (blogs multi-auteurs)
- Estimation du temps de lecture
- Paywall pour le contenu premium
Structure d'un article
1interface Article {
2 id: string;
3 author: Author;
4
5 // Content
6 title: string;
7 subtitle?: string;
8 content: ArticleBlock[];
9 coverImage?: string;
10
11 // Metadata
12 topics: string[];
13 tags: string[];
14 readingTime: number; // minutes
15
16 // Publishing
17 status: 'draft' | 'published' | 'unlisted';
18 publishedAt?: Date;
19 updatedAt: Date;
20
21 // Engagement
22 claps: number;
23 responses: Response[];
24 highlights: Highlight[];
25
26 // Publication
27 publicationId?: string;
28 publication?: Publication;
29
30 // Premium
31 isPremium: boolean;
32 previewContent?: string; // for paywalled articles
33}
34
35interface Author {
36 id: string;
37 name: string;
38 username: string;
39 avatar: string;
40 bio: string;
41 followers: number;
42 following: number;
43}
44
45interface ArticleBlock {
46 id: string;
47 type: 'paragraph' | 'heading' | 'image' | 'code' | 'quote' | 'divider' | 'list';
48 content: string;
49 metadata?: {
50 level?: number; // for headings (h1, h2, h3)
51 language?: string; // for code blocks
52 alt?: string; // for images
53 caption?: string; // for images
54 ordered?: boolean; // for lists
55 };
56}
57
58// Calculate reading time
59function calculateReadingTime(blocks: ArticleBlock[]): number {
60 const text = blocks
61 .filter(b => ['paragraph', 'heading', 'quote', 'list'].includes(b.type))
62 .map(b => b.content)
63 .join(' ');
64
65 const wordsPerMinute = 200;
66 const words = text.split(/\s+/).length;
67
68 return Math.ceil(words / wordsPerMinute);
69}Éditeur de texte enrichi
1'use client';
2
3import { useState } from 'react';
4
5interface EditorBlock {
6 id: string;
7 type: 'paragraph' | 'heading' | 'image' | 'quote';
8 content: string;
9 level?: number;
10}
11
12export default function ArticleEditor() {
13 const [title, setTitle] = useState('');
14 const [blocks, setBlocks] = useState<EditorBlock[]>([
15 { id: '1', type: 'paragraph', content: '' }
16 ]);
17 const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
18
19 const addBlock = (afterId: string, type: EditorBlock['type']) => {
20 const index = blocks.findIndex(b => b.id === afterId);
21 const newBlock: EditorBlock = {
22 id: Date.now().toString(),
23 type,
24 content: '',
25 level: type === 'heading' ? 2 : undefined
26 };
27
28 const newBlocks = [
29 ...blocks.slice(0, index + 1),
30 newBlock,
31 ...blocks.slice(index + 1)
32 ];
33
34 setBlocks(newBlocks);
35 setFocusedBlockId(newBlock.id);
36 };
37
38 const updateBlock = (id: string, content: string) => {
39 setBlocks(blocks.map(b =>
40 b.id === id ? { ...b, content } : b
41 ));
42 };
43
44 const deleteBlock = (id: string) => {
45 if (blocks.length > 1) {
46 setBlocks(blocks.filter(b => b.id !== id));
47 }
48 };
49
50 const convertBlockType = (id: string, newType: EditorBlock['type']) => {
51 setBlocks(blocks.map(b =>
52 b.id === id ? { ...b, type: newType, level: newType === 'heading' ? 2 : undefined } : b
53 ));
54 };
55
56 return (
57 <div className="min-h-screen bg-white">
58 <div className="max-w-3xl mx-auto px-8 py-12">
59 {/* Title */}
60 <textarea
61 value={title}
62 onChange={(e) => setTitle(e.target.value)}
63 placeholder="Titre"
64 className="w-full text-5xl font-serif font-bold outline-none resize-none"
65 rows={1}
66 style={{ height: 'auto' }}
67 onInput={(e) => {
68 e.currentTarget.style.height = 'auto';
69 e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
70 }}
71 />
72
73 {/* Blocks */}
74 <div className="space-y-4">
75 {blocks.map((block, index) => (
76 <div
77 key={block.id}
78 className="group relative"
79 onFocus={() => setFocusedBlockId(block.id)}
80 >
81 {/* Block Menu */}
82 {focusedBlockId === block.id && (
83 <div className="absolute -left-12 top-2 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition">
84 <button
85 onClick={() => addBlock(block.id, 'paragraph')}
86 className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-sm"
87 title="Ajouter un paragraphe"
88 >
89 +
90 </button>
91 <button
92 onClick={() => convertBlockType(block.id, block.type === 'heading' ? 'paragraph' : 'heading')}
93 className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-xs font-bold"
94 title="Basculer en titre"
95 >
96 H
97 </button>
98 <button
99 onClick={() => convertBlockType(block.id, 'quote')}
100 className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-lg"
101 title="Citation"
102 >
103 "
104 </button>
105 {blocks.length > 1 && (
106 <button
107 onClick={() => deleteBlock(block.id)}
108 className="w-8 h-8 bg-red-100 text-red-600 rounded hover:bg-red-200 flex items-center justify-center text-sm"
109 title="Supprimer"
110 >
111 ×
112 </button>
113 )}
114 </div>
115 )}
116
117 {/* Block Content */}
118 {block.type === 'heading' ? (
119 <textarea
120 value={block.content}
121 onChange={(e) => updateBlock(block.id, e.target.value)}
122 placeholder="Titre"
123 className="w-full text-3xl font-serif font-bold outline-none resize-none"
124 rows={1}
125 onInput={(e) => {
126 e.currentTarget.style.height = 'auto';
127 e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
128 }}
129 />
130 ) : block.type === 'quote' ? (
131 <div className="border-l-4 border-gray-300 pl-6">
132 <textarea
133 value={block.content}
134 onChange={(e) => updateBlock(block.id, e.target.value)}
135 placeholder="Citation"
136 className="w-full text-xl font-serif italic text-gray-700 outline-none resize-none"
137 rows={1}
138 onInput={(e) => {
139 e.currentTarget.style.height = 'auto';
140 e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
141 }}
142 />
143 </div>
144 ) : (
145 <textarea
146 value={block.content}
147 onChange={(e) => updateBlock(block.id, e.target.value)}
148 placeholder="Raconte ton histoire..."
149 className="w-full text-xl font-serif text-gray-800 outline-none resize-none"
150 rows={1}
151 onInput={(e) => {
152 e.currentTarget.style.height = 'auto';
153 e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
154 }}
155 />
156 )}
157 </div>
158 ))}
159 </div>
160
161 {/* Publish Button */}
162 <div className="fixed top-4 right-8 flex items-center gap-3">
163 <button className="px-4 py-2 text-sm text-gray-600 hover:text-black">
164 Enregistrer le brouillon
165 </button>
166 <button className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700">
167 Publier
168 </button>
169 </div>
170 </div>
171 </div>
172 );
173}Gestion des brouillons
1interface Draft extends Article {
2 lastSaved: Date;
3 autoSaveEnabled: boolean;
4}
5
6function useDraftAutoSave(draft: Draft, interval = 30000) {
7 useEffect(() => {
8 if (!draft.autoSaveEnabled) return;
9
10 const timer = setInterval(() => {
11 saveDraft(draft);
12 }, interval);
13
14 return () => clearInterval(timer);
15 }, [draft, interval]);
16}
17
18async function saveDraft(draft: Draft): Promise<void> {
19 // Save to localStorage or API
20 localStorage.setItem(`draft-${draft.id}`, JSON.stringify({
21 ...draft,
22 lastSaved: new Date()
23 }));
24}
25
26async function loadDrafts(authorId: string): Promise<Draft[]> {
27 // Load from localStorage or API
28 const drafts: Draft[] = [];
29
30 for (let i = 0; i < localStorage.length; i++) {
31 const key = localStorage.key(i);
32 if (key?.startsWith('draft-')) {
33 const draft = JSON.parse(localStorage.getItem(key)!);
34 if (draft.author.id === authorId) {
35 drafts.push(draft);
36 }
37 }
38 }
39
40 return drafts.sort((a, b) =>
41 new Date(b.lastSaved).getTime() - new Date(a.lastSaved).getTime()
42 );
43}Résumé
Structure d'article - contenu basé sur des blocs (paragraphe, titre, citation, image, code) Éditeur de texte enrichi - éditeur par blocs façon Medium avec mise en forme Menu de bloc - ajouter, convertir, supprimer des blocs Redimensionnement automatique - les zones de texte s'adaptent au contenu Système de brouillons - enregistrer les brouillons, sauvegarde automatique Temps de lecture - calcul basé sur le nombre de mots Édition du titre - grande police serif Flux de publication - brouillon → publication
Dans le prochain exercice, nous ajouterons l'expérience de lecture et les applaudissements !
À bientôt !
Code de cette leçon : App.tsx
1'use client';
2
3import { useState } from 'react';
4
5interface EditorBlock {
6 id: string;
7 type: 'paragraph' | 'heading' | 'quote';
8 content: string;
9}
10
11export default function ArticleEditor() {
12 const [title, setTitle] = useState('');
13 const [blocks, setBlocks] = useState<EditorBlock[]>([
14 { id: '1', type: 'paragraph', content: '' }
15 ]);
16 const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
17
18 const addBlock = (afterId: string) => {
19 const index = blocks.findIndex(b => b.id === afterId);
20 const newBlock: EditorBlock = {
21 id: Date.now().toString(),
22 type: 'paragraph',
23 content: ''
24 };
25
26 const newBlocks = [
27 ...blocks.slice(0, index + 1),
28 newBlock,
29 ...blocks.slice(index + 1)
30 ];
31
32 setBlocks(newBlocks);
33 setFocusedBlockId(newBlock.id);
34 };
35
36 const updateBlock = (id: string, content: string) => {
37 setBlocks(blocks.map(b => b.id === id ? { ...b, content } : b));
38 };
39
40 const convertToHeading = (id: string) => {
41 setBlocks(blocks.map(b =>
42 b.id === id ? { ...b, type: b.type === 'heading' ? 'paragraph' : 'heading' } : b
43 ));
44 };
45
46 const convertToQuote = (id: string) => {
47 setBlocks(blocks.map(b =>
48 b.id === id ? { ...b, type: 'quote' } : b
49 ));
50 };
51
52 return (
53 <div className="min-h-screen bg-white">
54 <div className="max-w-3xl mx-auto px-8 py-12">
55 {/* Title */}
56 <textarea
57 value={title}
58 onChange={(e) => setTitle(e.target.value)}
59 placeholder="Titre"
60 className="w-full text-5xl font-serif font-bold placeholder-gray-300 outline-none resize-none mb-8"
61 rows={1}
62 />
63
64 {/* Blocks */}
65 <div className="space-y-4">
66 {blocks.map((block) => (
67 <div
68 key={block.id}
69 className="group relative"
70 onFocus={() => setFocusedBlockId(block.id)}
71 >
72 {/* Block Menu */}
73 {focusedBlockId === block.id && (
74 <div className="absolute -left-12 top-2 flex flex-col gap-1">
75 <button
76 onClick={() => addBlock(block.id)}
77 className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-sm"
78 title="Ajouter"
79 >
80 +
81 </button>
82 <button
83 onClick={() => convertToHeading(block.id)}
84 className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-xs font-bold"
85 title="Titre"
86 >
87 H
88 </button>
89 <button
90 onClick={() => convertToQuote(block.id)}
91 className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-lg"
92 title="Citation"
93 >
94 "
95 </button>
96 </div>
97 )}
98
99 {/* Block Content */}
100 {block.type === 'heading' ? (
101 <textarea
102 value={block.content}
103 onChange={(e) => updateBlock(block.id, e.target.value)}
104 placeholder="Titre"
105 className="w-full text-3xl font-serif font-bold placeholder-gray-300 outline-none resize-none"
106 rows={1}
107 />
108 ) : block.type === 'quote' ? (
109 <div className="border-l-4 border-gray-300 pl-6">
110 <textarea
111 value={block.content}
112 onChange={(e) => updateBlock(block.id, e.target.value)}
113 placeholder="Citation"
114 className="w-full text-xl font-serif italic text-gray-700 placeholder-gray-300 outline-none resize-none"
115 rows={1}
116 />
117 </div>
118 ) : (
119 <textarea
120 value={block.content}
121 onChange={(e) => updateBlock(block.id, e.target.value)}
122 placeholder="Raconte ton histoire..."
123 className="w-full text-xl font-serif text-gray-800 placeholder-gray-300 outline-none resize-none leading-relaxed"
124 rows={1}
125 />
126 )}
127 </div>
128 ))}
129 </div>
130
131 {/* Actions */}
132 <div className="fixed top-4 right-8 flex items-center gap-3">
133 <button className="px-4 py-2 text-sm text-gray-600 hover:text-black">
134 Enregistrer le brouillon
135 </button>
136 <button className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700">
137 Publier
138 </button>
139 </div>
140 </div>
141 </div>
142 );
143}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. Principale différence entre Medium et Twitter ?
2. Quels types de blocs l'éditeur Medium possède-t-il ?
Voici 2 des 6 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Clique dans l'ordre
Ordonne l'expression qui retourne le temps de lecture arrondi au supérieur :
- Tri horizontal
Ordonne les étapes de création d'un article :
- Tri vertical
Ordonne le cycle de vie d'un article de la création à la publication :
- Éditeur de code
Composant d'éditeur de texte enrichi
- Clique dans l'ordre
Ordonne l'appel qui ajoute un bloc de titre après le bloc actuel :
- Tri horizontal
Ordonne l'expression qui construit la clé du brouillon dans localStorage :
- Éditeur de code
Interface du système de brouillons
- Tri vertical
Ordonne les champs de l'interface Author dans l'ordre de la définition :