Cours Vibe coding avec l'IA · Module 7: Clone de LinkedIn
Fil professionnel et réseau
Dans cette leçon4
Sur Twitter, on parle de son petit-déjeuner, sur LinkedIn de sa promotion au poste de second. Un fil professionnel fonctionne comme les autres, mais le contenu parle de travail : nouveaux postes, anniversaires d'embauche, articles et analyses du secteur. C'est une différence de ton, pas de mécanique. LinkedIn, c'est du contenu professionnel, des offres et des CV ; Twitter, des sujets personnels et des échanges décontractés.
Les types de publications
Une publication peut être du simple texte ou quelque chose de spécial, comme l'annonce d'un changement de poste. Un type union liste toutes les possibilités :
1type PostType = 'text' | 'article' | 'image' | 'video' | 'job_change' | 'work_anniversary';
2
3interface FeedPost {
4 id: string;
5 type: PostType;
6 author: {
7 id: string;
8 name: string;
9 avatar: string;
10 headline: string;
11 };
12 content: string;
13 media?: {
14 type: 'image' | 'video' | 'document';
15 url: string;
16 thumbnail?: string;
17 };
18 likes: number;
19 comments: number;
20 reposts: number;
21 timestamp: Date;
22
23 // Champs spéciaux pour les posts de changement de poste
24 jobChange?: {
25 previousCompany: string;
26 newCompany: string;
27 newPosition: string;
28 };
29}type PostType = 'text' | 'article' | ... est un type union : une valeur de ce type ne peut être qu'un des six noms, de text, article, image et video à job_change et work_anniversary. Le champ jobChange est optionnel et n'a de sens que pour les publications job_change. Le type n'affiche rien, mais il protège des fautes de frappe.
La carte de publication
FeedPostCard comporte un en-tête d'auteur, le contenu, des statistiques et une rangée d'actions :
1'use client';
2
3import { useState } from 'react';
4
5function FeedPostCard({ post }: { post: FeedPost }) {
6 const [liked, setLiked] = useState(false);
7 const [showComments, setShowComments] = useState(false);
8
9 return (
10 <div className="bg-white rounded-lg shadow mb-3">
11 {/* En-tête */}
12 <div className="flex items-start gap-3 p-6 pb-3">
13 <img
14 src={post.author.avatar}
15 alt={post.author.name}
16 className="w-12 h-12 rounded-full"
17 />
18 <div className="flex-1">
19 <div className="flex items-start justify-between">
20 <div>
21 <h3 className="font-bold hover:underline cursor-pointer">
22 {post.author.name}
23 </h3>
24 <p className="text-sm text-gray-600">{post.author.headline}</p>
25 <p className="text-xs text-gray-500 mt-1">
26 {formatTimeAgo(post.timestamp)} •
27 </p>
28 </div>
29 <button className="text-gray-500 hover:bg-gray-100 rounded-full p-2">
30 •••
31 </button>
32 </div>
33 </div>
34 </div>
35
36 {/* Contenu */}
37 <div className="px-6 pb-3">
38 {/* Célébration de changement de poste */}
39 {post.type === 'job_change' && post.jobChange && (
40 <div className="mb-3 bg-gradient-to-r from-blue-50 to-green-50 border border-blue-200 rounded-lg p-4">
41 <p className="font-semibold mb-2">Started a new position</p>
42 <p className="text-sm text-gray-700">
43 {post.jobChange.newPosition} at {post.jobChange.newCompany}
44 </p>
45 </div>
46 )}
47
48 {/* Contenu texte */}
49 <p className="text-gray-900 whitespace-pre-wrap mb-3">{post.content}</p>
50
51 {/* Média */}
52 {post.media && post.media.type === 'image' && (
53 <img
54 src={post.media.url}
55 alt="Post media"
56 className="w-full rounded-lg"
57 />
58 )}
59 </div>
60
61 {/* Statistiques */}
62 <div className="px-6 py-2 border-t border-b flex items-center justify-between text-sm text-gray-600">
63 <button className="hover:underline">
64 {post.likes} likes
65 </button>
66 <div className="flex gap-3">
67 <button className="hover:underline">
68 {post.comments} comments
69 </button>
70 <button className="hover:underline">
71 {post.reposts} reposts
72 </button>
73 </div>
74 </div>
75
76 {/* Actions */}
77 <div className="px-6 py-2 flex items-center justify-around">
78 <button
79 onClick={() => setLiked(!liked)}
80 className={`flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 transition ${
81 liked ? 'text-blue-600 font-semibold' : 'text-gray-600'
82 }`}
83 >
84 <span className="text-xl">{liked ? 'Aimé' : 'J’aime'}</span>
85 Like
86 </button>
87 <button
88 onClick={() => setShowComments(!showComments)}
89 className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600"
90 >
91 <span className="text-xl">Commenter</span>
92 Comment
93 </button>
94 <button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
95 <span className="text-xl"></span>
96 Repost
97 </button>
98 <button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
99 <span className="text-xl">Partager</span>
100 Send
101 </button>
102 </div>
103
104 {/* Zone de commentaire */}
105 {showComments && (
106 <div className="px-6 pb-4 border-t pt-4">
107 <div className="flex gap-3">
108 <img
109 src="/current-user-avatar.jpg"
110 alt="You"
111 className="w-10 h-10 rounded-full"
112 />
113 <input
114 type="text"
115 className="flex-1 px-4 py-2 border rounded-full hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
116 />
117 </div>
118 </div>
119 )}
120 </div>
121 );
122}L'en-tête montre l'avatar, le nom (author.name), le headline de l'auteur et l'heure de publication via formatTimeAgo(timestamp). Quand post.type === 'job_change', une bannière de célébration avec le nouveau poste et l'entreprise (newPosition, newCompany) apparaît au-dessus du contenu. Les statistiques s'alignent en likes • comments • reposts, et dessous viennent les actions Like, Comment, Repost et Send (J’aime, Commenter, Republier, Envoyer). Un clic sur Like appelle setLiked(!liked), et le bouton reçoit la classe text-blue-600. Le compteur post.likes ne bouge pas : le serveur calculera le vrai nombre. Republier partage la publication de quelqu'un d'autre dans ton réseau, Envoyer la transmet en message privé. La classe whitespace-pre-wrap conserve les paragraphes. Côté médias, la carte n'affiche pour l'instant que les images (post.media.type === 'image') ; vidéos et documents attendent leur tour.
Créer une publication
En haut du fil, comme sur LinkedIn, se trouve le champ Démarrer une publication. Un clic ouvre la fenêtre de création :
1<button onClick={() => setShowCreatePost(true)}>Démarrer une publication</button>
2{showCreatePost && (
3 <CreatePostModal onClose={() => setShowCreatePost(false)} onSubmit={addPost} />
4)}L'état showCreatePost et la fonction addPost appartiennent à la page du fil, et la fenêtre les reçoit en props. Voici la fenêtre :
1'use client';
2
3import { useState } from 'react';
4
5export default function CreatePostModal({ onClose, onSubmit }: {
6 onClose: () => void;
7 onSubmit: (content: string) => void;
8}) {
9 const [content, setContent] = useState('');
10
11 return (
12 <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
13 <div className="bg-white rounded-lg max-w-2xl w-full">
14 <div className="flex items-center justify-between p-4 border-b">
15 <h2 className="text-xl font-bold">Create a post</h2>
16 <button
17 onClick={onClose}
18 className="text-gray-500 hover:text-gray-700 text-2xl"
19 >
20 ×
21 </button>
22 </div>
23
24 <div className="p-4">
25 <div className="flex gap-3 mb-4">
26 <img
27 src="/current-user-avatar.jpg"
28 alt="You"
29 className="w-12 h-12 rounded-full"
30 />
31 <div>
32 <h3 className="font-bold">Your Name</h3>
33 <button className="text-sm text-gray-600 hover:underline">
34 Post to anyone
35 </button>
36 </div>
37 </div>
38
39 <textarea
40 value={content}
41 onChange={(e) => setContent(e.target.value)}
42 className="w-full p-3 text-lg resize-none outline-none"
43 rows={8}
44 autoFocus
45 />
46
47 <div className="flex items-center gap-2 text-gray-500 mt-4">
48 <button className="p-2 hover:bg-gray-100 rounded text-xl">Photo</button>
49 <button className="p-2 hover:bg-gray-100 rounded text-xl">Vidéo</button>
50 <button className="p-2 hover:bg-gray-100 rounded text-xl">Document</button>
51 <button className="p-2 hover:bg-gray-100 rounded text-xl">Sondage</button>
52 </div>
53 </div>
54
55 <div className="p-4 border-t flex justify-end">
56 <button
57 onClick={() => {
58 onSubmit(content);
59 onClose();
60 }}
61 disabled={!content.trim()}
62 className="px-6 py-2 bg-blue-600 text-white rounded-full font-bold hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
63 >
64 Post
65 </button>
66 </div>
67 </div>
68 </div>
69 );
70}La saisie appelle setContent dans onChange, et le bouton Post (Publier) ne s'active que si content.trim() n'est pas vide : de simples espaces ne passent pas. Un clic sur Post appelle onSubmit(content), puis onClose() ferme la fenêtre. La rangée de boutons sous le champ de texte accueille les médias facultatifs : photo, vidéo ou document.
Le réseau de relations
Dernière page : la liste de tes relations.
1interface Connection {
2 id: string;
3 name: string;
4 avatar: string;
5 headline: string;
6 mutualConnections: number;
7 connectedDate: Date;
8}
9
10function ConnectionCard({ connection }: { connection: Connection }) {
11 return (
12 <div className="bg-white border rounded-lg p-4 hover:shadow-lg transition">
13 <div className="flex items-start gap-3">
14 <img
15 src={connection.avatar}
16 alt={connection.name}
17 className="w-16 h-16 rounded-full"
18 />
19 <div className="flex-1">
20 <h3 className="font-bold hover:underline cursor-pointer mb-1">
21 {connection.name}
22 </h3>
23 <p className="text-sm text-gray-600 mb-2 line-clamp-2">
24 {connection.headline}
25 </p>
26 <p className="text-xs text-gray-500">
27 {connection.mutualConnections} mutual connections
28 </p>
29 </div>
30 </div>
31
32 <div className="flex gap-2 mt-4">
33 <button className="flex-1 py-2 border-2 border-blue-600 text-blue-600 rounded-full font-semibold hover:bg-blue-50">
34 Message
35 </button>
36 <button className="px-4 py-2 border border-gray-300 rounded-full hover:bg-gray-50">
37 •••
38 </button>
39 </div>
40 </div>
41 );
42}
43
44export default function ConnectionsPage({ connections }: { connections: Connection[] }) {
45 return (
46 <div className="max-w-6xl mx-auto p-4">
47 <div className="bg-white rounded-lg shadow p-6 mb-4">
48 <h1 className="text-2xl font-bold mb-4">
49 {connections.length} Connections
50 </h1>
51 <input
52 type="text"
53 className="w-full px-4 py-2 border rounded-lg"
54 />
55 </div>
56
57 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
58 {connections.map(conn => (
59 <ConnectionCard key={conn.id} connection={conn} />
60 ))}
61 </div>
62 </div>
63 );
64}De haut en bas, une carte de contact affiche l'avatar, le nom et le headline, puis le nombre de relations communes et le bouton Message. mutualConnections indique combien de relations vous partagez, ce qui aide à savoir si tu connais quelqu'un. Le champ de recherche n'est pas encore branché : tu l'ajouteras avec filter sur le tableau connections.
Conseil du Capitaine Barbe-Rouge : avant d'ajouter un septième type de publication, demande-toi si tu as vraiment besoin d'un nouvel affichage. Chaque valeur de l'union est une branche de plus à maintenir dans la carte.
À retenir : le fil pro, ce sont les mêmes publications et réactions, mais sur le travail, et le réseau montre qui vous connaissez en commun. Dans l'éditeur ci-dessous, tu as un fil professionnel avec des cartes de publications, des mentions j’aime et des commentaires, et les exercices porteront sur les types de publications, les actions et les relations.
Code de cette leçon : App.jsx
1// Post de fil professionnel avec changement de poste
2import { useState } from 'react';
3
4const posts = [
5 {
6 id: 1,
7 type: 'job_change',
8 author: {
9 name: 'Jack Sparrow',
10 avatar: '',
11 headline: 'Navigator at Black Pearl'
12 },
13 content: `Excited to announce that I've joined the Black Pearl crew as First Mate! Looking forward to new adventures on the Seven Seas.`,
14 jobChange: {
15 previousCompany: 'Flying Dutchman',
16 newCompany: 'Black Pearl Crew',
17 newPosition: 'First Mate'
18 },
19 likes: 142,
20 comments: 23,
21 reposts: 8,
22 timestamp: '2h ago'
23 },
24 {
25 id: 2,
26 type: 'text',
27 author: {
28 name: 'Elizabeth Swan',
29 avatar: '',
30 headline: "Captain at Queen Anne's Revenge"
31 },
32 content: `Just completed my first successful treasure hunt as captain. Key lessons learned:
33
341. Trust your crew
352. Always verify the map
363. Never underestimate a storm
37
38#Leadership #TreasureHunting #PirateLife`,
39 likes: 89,
40 comments: 12,
41 reposts: 5,
42 timestamp: '5h ago'
43 }
44];
45
46function FeedPostCard({ post }) {
47 const [liked, setLiked] = useState(false);
48 const [showComments, setShowComments] = useState(false);
49
50 return (
51 <div className="bg-white rounded-lg shadow mb-3">
52 {/* En-tête */}
53 <div className="flex items-start gap-3 p-6 pb-3">
54 <div className="text-4xl">{post.author.avatar}</div>
55 <div className="flex-1">
56 <h3 className="font-bold hover:underline cursor-pointer">{post.author.name}</h3>
57 <p className="text-sm text-gray-600">{post.author.headline}</p>
58 <p className="text-xs text-gray-500 mt-1">{post.timestamp} •</p>
59 </div>
60 <button className="text-gray-500 hover:bg-gray-100 rounded-full p-2">•••</button>
61 </div>
62
63 {/* Contenu */}
64 <div className="px-6 pb-3">
65 {/* Badge de changement de poste */}
66 {post.type === 'job_change' && post.jobChange && (
67 <div className="mb-3 bg-gradient-to-r from-blue-50 to-green-50 border border-blue-200 rounded-lg p-4">
68 <p className="font-semibold mb-2">Started a new position</p>
69 <p className="text-sm text-gray-700">
70 {post.jobChange.newPosition} at {post.jobChange.newCompany}
71 </p>
72 </div>
73 )}
74
75 <p className="text-gray-900 whitespace-pre-wrap">{post.content}</p>
76 </div>
77
78 {/* Statistiques */}
79 <div className="px-6 py-2 border-t border-b flex items-center justify-between text-sm text-gray-600">
80 <button className="hover:underline">{post.likes + (liked ? 1 : 0)} likes</button>
81 <div className="flex gap-3">
82 <button className="hover:underline">{post.comments} comments</button>
83 <button className="hover:underline">{post.reposts} reposts</button>
84 </div>
85 </div>
86
87 {/* Actions */}
88 <div className="px-6 py-2 flex items-center justify-around">
89 <button
90 onClick={() => setLiked(!liked)}
91 className={`flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 transition ${
92 liked ? 'text-blue-600 font-semibold' : 'text-gray-600'
93 }`}
94 >
95 Like
96 </button>
97 <button
98 onClick={() => setShowComments(!showComments)}
99 className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600"
100 >
101 Comment
102 </button>
103 <button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
104 Repost
105 </button>
106 <button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
107 Send
108 </button>
109 </div>
110
111 {showComments && (
112 <div className="px-6 pb-4 border-t pt-4">
113 <input
114 type="text"
115 placeholder="Add a comment..."
116 className="w-full px-4 py-2 border rounded-full hover:bg-gray-50"
117 />
118 </div>
119 )}
120 </div>
121 );
122}
123
124export default function App() {
125 return (
126 <div className="min-h-screen bg-gray-100 p-8">
127 <div className="max-w-2xl mx-auto">
128 <h1 className="text-3xl font-bold mb-6">Professional Feed</h1>
129 {posts.map(post => (
130 <FeedPostCard key={post.id} post={post} />
131 ))}
132 </div>
133 </div>
134 );
135}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. Quels types de publications le fil LinkedIn possède-t-il ?
2. Qu'affiche de spécial FeedPostCard quand type === 'job_change' ?
Voici 2 des 7 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne sur une seule ligne les boutons d'action de FeedPostCard (de gauche à droite) :
- Éditeur de code
Publication du fil professionnel
- Tri horizontal
Ordonne les étapes de création d'une publication professionnelle :
- Tri vertical
Ordonne les étapes pour aimer une publication dans FeedPostCard :
- Tri vertical
Ordonne les étapes de publication d'un post dans CreatePostModal :
- Tri horizontal
Ordonne sur une seule ligne les statistiques de la publication de FeedPostCard : likes • comments • reposts
- Éditeur de code
Page de réseau professionnel
- Clique dans l'ordre
Ordonne le début de la définition du type union : type PostType = 'text' | 'article'
- Clique dans l'ordre
Ordonne l'appel qui bascule le like d'une publication : setLiked(!liked)
- Tri horizontal
Ordonne sur une seule ligne les métadonnées d'en-tête de la publication de FeedPostCard : author.name • author.headline • timestamp
- Tri vertical
Ordonne les éléments de ConnectionCard de haut en bas :