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

Publications et curation de contenu

5 min de lecture
Dans cette leçon4

Medium a des publications - des blogs multi-auteurs avec des équipes éditoriales.

Structure d'une publication

1interface Publication {
2  id: string;
3  name: string;
4  slug: string;
5  description: string;
6  logo: string;
7  banner?: string;
8
9  // Team
10  editors: Editor[];
11  writers: Writer[];
12  followers: number;
13
14  // Content
15  articles: Article[];
16  topics: string[];
17
18  // Settings
19  isAcceptingSubmissions: boolean;
20  submissionGuidelines?: string;
21
22  createdAt: Date;
23}
24
25interface Editor {
26  userId: string;
27  role: 'owner' | 'editor' | 'writer';
28  permissions: {
29    canPublish: boolean;
30    canEdit: boolean;
31    canInvite: boolean;
32    canManageSettings: boolean;
33  };
34  joinedAt: Date;
35}
36
37interface SubmissionRequest {
38  id: string;
39  articleId: string;
40  publicationId: string;
41  author: Author;
42  status: 'pending' | 'approved' | 'rejected';
43  note?: string;
44  submittedAt: Date;
45  reviewedAt?: Date;
46  reviewedBy?: string;
47}

Page de publication

1'use client';
2
3interface PublicationPageProps {
4  publication: Publication;
5  articles: Article[];
6  isFollowing: boolean;
7}
8
9export default function PublicationPage({
10  publication,
11  articles,
12  isFollowing
13}: PublicationPageProps) {
14  const [activeTab, setActiveTab] = useState<'latest' | 'top' | 'about'>('latest');
15
16  return (
17    <div className="min-h-screen bg-white">
18      {/* Header */}
19      <div className="border-b border-gray-200">
20        <div className="max-w-5xl mx-auto px-8 py-12">
21          <div className="flex items-start gap-6">
22            <img
23              src={publication.logo}
24              alt={publication.name}
25              className="w-24 h-24 rounded"
26            />
27            <div className="flex-1">
28              <h1 className="text-4xl font-bold mb-2">{publication.name}</h1>
29              <p className="text-xl text-gray-600 mb-4">
30                {publication.description}
31              </p>
32              <div className="flex items-center gap-4 text-sm text-gray-500">
33                <span>{publication.followers.toLocaleString()} Abonnés</span>
34                <span>•</span>
35                <span>{publication.articles.length} Récits</span>
36              </div>
37            </div>
38            <button
39              className={`
40                px-6 py-2 rounded-full font-semibold
41                ${isFollowing
42                  ? 'border border-green-600 text-green-600 hover:bg-green-50'
43                  : 'bg-green-600 text-white hover:bg-green-700'
44                }
45              `}
46            >
47              {isFollowing ? 'Abonné' : 'S\'abonner'}
48            </button>
49          </div>
50        </div>
51
52        {/* Navigation */}
53        <div className="max-w-5xl mx-auto px-8">
54          <div className="flex gap-8 border-b border-gray-200">
55            {[
56              { key: 'latest', label: 'Récents' },
57              { key: 'top', label: 'Top' },
58              { key: 'about', label: 'À propos' }
59            ].map(tab => (
60              <button
61                key={tab.key}
62                onClick={() => setActiveTab(tab.key as any)}
63                className={`
64                  pb-3 font-semibold
65                  ${activeTab === tab.key
66                    ? 'border-b-2 border-black'
67                    : 'text-gray-500 hover:text-black'
68                  }
69                `}
70              >
71                {tab.label}
72              </button>
73            ))}
74          </div>
75        </div>
76      </div>
77
78      {/* Content */}
79      <div className="max-w-5xl mx-auto px-8 py-8">
80        {activeTab === 'latest' && (
81          <div className="space-y-8">
82            {articles.map(article => (
83              <ArticlePreview key={article.id} article={article} />
84            ))}
85          </div>
86        )}
87
88        {activeTab === 'about' && (
89          <div className="max-w-2xl">
90            <h2 className="text-2xl font-bold mb-4">À propos</h2>
91            <p className="text-lg text-gray-700 leading-relaxed mb-8">
92              {publication.description}
93            </p>
94
95            <h3 className="text-xl font-bold mb-4">Éditeurs</h3>
96            <div className="grid grid-cols-2 gap-4">
97              {publication.editors.map(editor => (
98                <EditorCard key={editor.userId} editor={editor} />
99              ))}
100            </div>
101          </div>
102        )}
103      </div>
104    </div>
105  );
106}
107
108function ArticlePreview({ article }: { article: Article }) {
109  return (
110    <div className="flex gap-6 pb-8 border-b border-gray-200">
111      <div className="flex-1">
112        <div className="flex items-center gap-2 mb-3">
113          <img
114            src={article.author.avatar}
115            alt={article.author.name}
116            className="w-6 h-6 rounded-full"
117          />
118          <span className="text-sm font-semibold">{article.author.name}</span>
119        </div>
120
121        <h2 className="text-2xl font-bold mb-2 hover:underline cursor-pointer">
122          {article.title}
123        </h2>
124        {article.subtitle && (
125          <p className="text-gray-600 mb-3">{article.subtitle}</p>
126        )}
127
128        <div className="flex items-center gap-3 text-sm text-gray-500">
129          <span>{new Date(article.publishedAt!).toLocaleDateString()}</span>
130          <span>•</span>
131          <span>{article.readingTime} min de lecture</span>
132          <span>•</span>
133          <span>{article.claps} applaudissements</span>
134        </div>
135      </div>
136
137      {article.coverImage && (
138        <img
139          src={article.coverImage}
140          alt={article.title}
141          className="w-40 h-40 object-cover"
142        />
143      )}
144    </div>
145  );
146}

Sujets et découverte

1interface Topic {
2  id: string;
3  name: string;
4  slug: string;
5  description: string;
6  followers: number;
7  relatedTopics: string[];
8}
9
10// Recommendation engine
11function getRecommendedArticles(
12  userId: string,
13  readingHistory: Article[],
14  followedTopics: Topic[],
15  followedAuthors: Author[]
16): Article[] {
17  // Algorithm:
18  // 1. Analyze user's reading history for topics
19  // 2. Find articles from followed topics
20  // 3. Include articles from followed authors
21  // 4. Add trending articles in related topics
22  // 5. Weight by recency and engagement
23
24  const recommendations: Article[] = [];
25
26  // ... implementation
27
28  return recommendations
29    .sort((a, b) => b.claps - a.claps)
30    .slice(0, 20);
31}
32
33// Home feed
34'use client';
35
36export default function HomeFeed() {
37  const [activeTab, setActiveTab] = useState<'for-you' | 'following'>('for-you');
38  const [articles, setArticles] = useState<Article[]>([]);
39
40  useEffect(() => {
41    if (activeTab === 'for-you') {
42      loadRecommendations();
43    } else {
44      loadFollowingFeed();
45    }
46  }, [activeTab]);
47
48  return (
49    <div className="min-h-screen bg-white">
50      <div className="max-w-6xl mx-auto px-8 py-8">
51        <div className="flex gap-12">
52          {/* Main Feed */}
53          <div className="flex-1">
54            {/* Tabs */}
55            <div className="flex gap-8 mb-8 border-b border-gray-200">
56              <button
57                onClick={() => setActiveTab('for-you')}
58                className={`
59                  pb-3 font-semibold
60                  ${activeTab === 'for-you'
61                    ? 'border-b-2 border-black'
62                    : 'text-gray-500'
63                  }
64                `}
65              >
66                Pour toi
67              </button>
68              <button
69                onClick={() => setActiveTab('following')}
70                className={`
71                  pb-3 font-semibold
72                  ${activeTab === 'following'
73                    ? 'border-b-2 border-black'
74                    : 'text-gray-500'
75                  }
76                `}
77              >
78                Abonnements
79              </button>
80            </div>
81
82            {/* Articles */}
83            <div className="space-y-8">
84              {articles.map(article => (
85                <ArticleCard key={article.id} article={article} />
86              ))}
87            </div>
88          </div>
89
90          {/* Sidebar */}
91          <div className="w-80">
92            <DiscoverySidebar />
93          </div>
94        </div>
95      </div>
96    </div>
97  );
98}
99
100function DiscoverySidebar() {
101  return (
102    <div className="space-y-8">
103      {/* Topics */}
104      <div>
105        <h3 className="font-bold mb-4">Découvre ce qui compte pour toi</h3>
106        <div className="flex flex-wrap gap-2">
107          {['Programming', 'Design', 'Data Science', 'Self Improvement', 'Writing', 'Technology'].map(topic => (
108            <button
109              key={topic}
110              className="px-4 py-2 bg-gray-100 rounded-full text-sm hover:bg-gray-200"
111            >
112              {topic}
113            </button>
114          ))}
115        </div>
116      </div>
117
118      {/* Trending */}
119      <div>
120        <h3 className="font-bold mb-4">Tendances sur La Chronique du Capitaine</h3>
121        <div className="space-y-4">
122          {[1, 2, 3, 4, 5].map(i => (
123            <TrendingArticle key={i} rank={i} />
124          ))}
125        </div>
126      </div>
127    </div>
128  );
129}

Résumé

Publications - blogs multi-auteurs Équipe éditoriale - éditeurs, rédacteurs, permissions Système de soumission - les rédacteurs soumettent aux publications Page de publication - logo, description, articles Sujets - catégoriser le contenu Recommandations - fil personnalisé (Pour toi) Fil d'abonnements - des auteurs/publications suivis Barre latérale des tendances - découvrir le contenu populaire Suivi de sujets - suivre les sujets qui t'intéressent

Tu as maintenant un clone complet de Medium avec éditeur, applaudissements, surlignages, réponses et publications !

À bientôt !

Code de cette leçon : App.tsx
1'use client';
2
3import { useState } from 'react';
4
5export default function ClapButton() {
6  const [claps, setClaps] = useState(142);
7  const [userClaps, setUserClaps] = useState(0);
8  const [isAnimating, setIsAnimating] = useState(false);
9  const MAX_CLAPS = 50;
10
11  const handleClap = () => {
12    if (userClaps >= MAX_CLAPS) return;
13
14    setIsAnimating(true);
15    setClaps(prev => prev + 1);
16    setUserClaps(prev => prev + 1);
17
18    setTimeout(() => setIsAnimating(false), 300);
19  };
20
21  return (
22    <div className="min-h-screen bg-gray-50 flex items-center justify-center">
23      <div className="text-center">
24        <div className="mb-8">
25          <h2 className="text-2xl font-bold mb-2">Essaie le bouton d'applaudissement !</h2>
26          <p className="text-gray-600">Tu peux applaudir jusqu'à 50 fois</p>
27        </div>
28
29        <div className="flex flex-col items-center gap-4">
30          <button
31            onClick={handleClap}
32            disabled={userClaps >= MAX_CLAPS}
33            className={`
34              w-16 h-16 rounded-full border-2 border-gray-300
35              flex items-center justify-center
36              hover:border-green-500 transition-all
37              ${isAnimating ? 'scale-125' : 'scale-100'}
38              ${userClaps >= MAX_CLAPS ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
39            `}
40          >
41            <span className="text-sm font-semibold">Bravo</span>
42          </button>
43
44          <div className="text-center">
45            <div className="text-xl font-bold">{claps.toLocaleString()}</div>
46            {userClaps > 0 && (
47              <div className="text-sm text-gray-500">
48                Tu as applaudi {userClaps}x
49              </div>
50            )}
51            {userClaps >= MAX_CLAPS && (
52              <div className="text-sm text-green-600 font-semibold mt-2">
53                Nombre maximal d'applaudissements atteint !
54              </div>
55            )}
56          </div>
57
58          {/* Progress bar */}
59          <div className="w-64 bg-gray-200 rounded-full h-2 overflow-hidden">
60            <div
61              className="bg-green-500 h-full transition-all duration-300"
62              style={{ width: `${(userClaps / MAX_CLAPS) * 100}%` }}
63            />
64          </div>
65        </div>
66      </div>
67    </div>
68  );
69}

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 publications dans Medium ?

  2. 2. Quels statuts un SubmissionRequest (soumission d'article à une publication) peut-il avoir ?

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

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne le processus de soumission d'un article à une publication :

  • Éditeur de code

    Affichage de la publication

  • Clique dans l'ordre

    Clique pour construire l'expression qui renvoie les 20 meilleures recommandations triées par claps :

  • Éditeur de code

    Interface du fil

  • Tri vertical

    Ordonne les onglets de la page de publication dans l'ordre du code :

  • Tri horizontal

    Ordonne l'appel qui affiche un aperçu d'article dans la liste de la publication :

  • Tri vertical

    Ordonne les rôles d'une publication du plus large au plus restreint :

  • Tri horizontal

    Ordonne l'appel qui bascule le fil sur l'onglet Pour toi :

  • Clique dans l'ordre

    Clique pour construire l'expression qui affiche le nombre d'abonnés formaté d'une publication :

  • Tri vertical

    Ordonne les étapes de construction de Chronique du Capitaine dans l'ordre des modules :

  • Tri horizontal

    Ordonne l'appel qui affiche une carte d'article dans le fil principal :

Articles utiles