Cours Vibe coding avec l'IA · Module 7: Clone de LinkedIn

Fil professionnel et réseau

7 min de lecture
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. 1. Quels types de publications le fil LinkedIn possède-t-il ?

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

Articles utiles