Cours Vibe coding avec l'IA · Module 12: Clone de Medium
Publications et curation de contenu
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. Que sont les publications dans Medium ?
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 :