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

Éditeur d'articles et texte enrichi

6 min de lecture
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. 1. Principale différence entre Medium et Twitter ?

  2. 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 :

Articles utiles