Cours Vibe coding avec l'IA · Module 8: Clone de Slack
Espace de travail et canaux - communication d'équipe
Dans cette leçon6
Bienvenue à bord ! Sur un navire, les ordres passent de bouche à oreille et la moitié se perd en route. Construisons Communications du navire, la messagerie des équipages pirates sur le modèle de Slack : canaux, messages privés, fils de discussion et mentions.
Au programme
- modéliser l'espace de travail, les membres, les canaux et les messages,
- distinguer canaux publics et privés,
- construire une sidebar avec les canaux et les statuts,
- créer un en-tête de canal.
Slack face aux autres plateformes
Twitter et Instagram sont des réseaux sociaux publics : tu diffuses à tes abonnés, qui réagissent, partagent et commentent. LinkedIn : profils professionnels, offres d'emploi et publications formelles. Slack est l'espace de travail privé (workspace) d'une équipe : canaux par projet ou sujet, chat en temps réel, fils de discussion, fichiers et mentions avec notifications.
Le modèle de l'espace de travail
Un workspace est l'espace d'un équipage, avec sa propre adresse comme blackpearl.slack.com. Quatre interfaces le décrivent :
1interface Workspace {
2 id: string;
3 name: string; // "Black Pearl Crew"
4 icon: string;
5 url: string; // "blackpearl.slack.com"
6
7 members: Member[];
8 channels: Channel[];
9 directMessages: DirectMessage[];
10
11 createdBy: string;
12 createdAt: Date;
13}
14
15interface Member {
16 id: string;
17 name: string;
18 avatar: string;
19 role: 'owner' | 'admin' | 'member' | 'guest';
20 status: 'online' | 'away' | 'offline';
21 statusText?: string; // "En réunion"
22 timezone: string;
23}
24
25interface Channel {
26 id: string;
27 name: string; // "general", "treasure-hunts"
28 description: string;
29 isPrivate: boolean;
30
31 members: string[]; // IDs des membres
32 messages: Message[];
33
34 createdBy: string;
35 createdAt: Date;
36}
37
38interface DirectMessage {
39 id: string;
40 participants: string[]; // 2 IDs de membres
41 messages: Message[];
42 lastReadAt: { [userId: string]: Date };
43}Workspace contient les membres (members), les canaux (channels) et les messages privés (directMessages). Chaque Member a un rôle (owner, admin, member ou guest, des droits les plus larges aux plus restreints) et un statut online, away ou offline. Un invité (guest) ne voit que les canaux où on l'a ajouté. Channel a un nom, une description, un indicateur isPrivate, une liste de membres et des messages. DirectMessage relie exactement deux personnes : participants contient deux identifiants de membres. Slack propose aussi des groupes jusqu'à neuf personnes, mais notre modèle ne gère que les échanges à deux. Ce n'est encore qu'une description des données, sans affichage.
Le modèle de message
Un message transporte plus que du texte :
1interface Message {
2 id: string;
3 channelId: string;
4 authorId: string;
5
6 content: string;
7 mentions: string[]; // IDs @user
8
9 attachments?: Attachment[];
10 reactions: Reaction[];
11
12 // Fils de discussion
13 threadId?: string; // ID du message parent
14 replies: Message[];
15 replyCount: number;
16
17 isEdited: boolean;
18 isPinned: boolean;
19
20 timestamp: Date;
21}
22
23interface Attachment {
24 id: string;
25 type: 'image' | 'video' | 'file' | 'link';
26 url: string;
27 name: string;
28 size: number;
29 thumbnail?: string;
30}
31
32interface Reaction {
33 emoji: string;
34 users: string[]; // IDs des utilisateurs ayant réagi
35 count: number;
36}Outre le contenu et l'auteur (authorId), on trouve mentions, pièces jointes, réactions et données de fil : threadId désigne le message parent et replyCount compte les réponses. Une réaction : un emoji, une liste d'utilisateurs et un compteur. Ces champs serviront plus tard ; pour l'instant, ils décrivent seulement les données.
La sidebar
La sidebar est la carte du navire. De haut en bas : l'en-tête de l'espace avec le nom et le nombre de personnes en ligne, les liens rapides (Threads, All DMs), la liste des canaux, les messages privés et, tout en bas, le profil de l'utilisateur.
1'use client';
2
3import { useState } from 'react';
4
5interface SidebarProps {
6 workspace: Workspace;
7 currentUserId: string;
8 onChannelSelect: (channelId: string) => void;
9 onDMSelect: (dmId: string) => void;
10}
11
12export default function Sidebar({
13 workspace,
14 currentUserId,
15 onChannelSelect,
16 onDMSelect
17}: SidebarProps) {
18 const [showAllChannels, setShowAllChannels] = useState(false);
19
20 const unreadDMs = workspace.directMessages.filter(dm => {
21 const lastRead = dm.lastReadAt[currentUserId];
22 const lastMessage = dm.messages[dm.messages.length - 1];
23 return lastMessage && (!lastRead || lastMessage.timestamp > lastRead);
24 });
25
26 return (
27 <div className="w-64 bg-purple-900 text-white flex flex-col h-screen">
28 {/* En-tête de l'espace de travail */}
29 <div className="p-4 border-b border-purple-800">
30 <div className="flex items-center gap-2 mb-2">
31 <span className="text-2xl">{workspace.icon}</span>
32 <h2 className="font-bold text-lg">{workspace.name}</h2>
33 </div>
34 <div className="flex items-center gap-2 text-sm opacity-80">
35 <div className="w-2 h-2 bg-green-400 rounded-full"></div>
36 <span>{workspace.members.filter(m => m.status === 'online').length} en ligne</span>
37 </div>
38 </div>
39
40 {/* Navigation */}
41 <div className="flex-1 overflow-y-auto p-2">
42 {/* Liens rapides */}
43 <div className="mb-4">
44 <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
45 <span>Commenter</span>
46 <span>Threads</span>
47 </button>
48 <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
49 <span>Message</span>
50 <span>All DMs</span>
51 {unreadDMs.length > 0 && (
52 <span className="ml-auto bg-red-500 text-xs px-2 py-0.5 rounded-full">
53 {unreadDMs.length}
54 </span>
55 )}
56 </button>
57 <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
58 <span>Enregistré</span>
59 <span>Saved items</span>
60 </button>
61 </div>
62
63 {/* Canaux */}
64 <div className="mb-4">
65 <button
66 onClick={() => setShowAllChannels(!showAllChannels)}
67 className="w-full text-left px-3 py-1 text-sm font-semibold opacity-80 hover:opacity-100 flex items-center gap-1"
68 >
69 <span className={`transform transition ${showAllChannels ? 'rotate-90' : ''}`}>▶</span>
70 Channels
71 </button>
72 {showAllChannels && (
73 <div className="mt-1">
74 {workspace.channels.map(channel => (
75 <button
76 key={channel.id}
77 onClick={() => onChannelSelect(channel.id)}
78 className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2"
79 >
80 <span>{channel.isPrivate ? '[privé]' : '#'}</span>
81 <span>{channel.name}</span>
82 </button>
83 ))}
84 <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded text-sm opacity-80">
85 + Add channels
86 </button>
87 </div>
88 )}
89 </div>
90
91 {/* Messages privés */}
92 <div>
93 <div className="px-3 py-1 text-sm font-semibold opacity-80">
94 Direct Messages
95 </div>
96 <div className="mt-1">
97 {workspace.directMessages.slice(0, 5).map(dm => {
98 const otherUser = workspace.members.find(
99 m => dm.participants.includes(m.id) && m.id !== currentUserId
100 );
101 if (!otherUser) return null;
102
103 return (
104 <button
105 key={dm.id}
106 onClick={() => onDMSelect(dm.id)}
107 className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2"
108 >
109 <div className="relative">
110 <img
111 src={otherUser.avatar}
112 alt={otherUser.name}
113 className="w-6 h-6 rounded"
114 />
115 <div
116 className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border border-purple-900 ${
117 otherUser.status === 'online' ? 'bg-green-400' :
118 otherUser.status === 'away' ? 'bg-yellow-400' : 'bg-gray-400'
119 }`}
120 />
121 </div>
122 <span>{otherUser.name}</span>
123 </button>
124 );
125 })}
126 </div>
127 </div>
128 </div>
129
130 {/* Profil utilisateur */}
131 <div className="p-3 border-t border-purple-800">
132 {(() => {
133 const currentUser = workspace.members.find(m => m.id === currentUserId);
134 if (!currentUser) return null;
135
136 return (
137 <div className="flex items-center gap-2">
138 <div className="relative">
139 <img
140 src={currentUser.avatar}
141 alt={currentUser.name}
142 className="w-8 h-8 rounded"
143 />
144 <div className="absolute bottom-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-purple-900"></div>
145 </div>
146 <div className="flex-1 min-w-0">
147 <div className="font-semibold text-sm truncate">{currentUser.name}</div>
148 <div className="text-xs opacity-80 truncate">
149 {currentUser.statusText || 'Active'}
150 </div>
151 </div>
152 <button className="hover:bg-purple-800 p-1 rounded">Paramètres</button>
153 </div>
154 );
155 })()}
156 </div>
157 </div>
158 );
159}Un canal public a un # devant son nom : treasure-hunts s'affiche « # treasure-hunts », et tout membre de l'espace peut le trouver et le rejoindre. Un canal privé prend un cadenas au lieu du #, et seuls les membres invités le voient. Nous avons choisi les couleurs : point vert près de l'avatar pour online, jaune pour away (absent), gris pour offline. L'en-tête compte les personnes en ligne avec filter(m => m.status === 'online').length, et le pied affiche le statusText de l'utilisateur ou le libellé Active. unreadDMs retient les conversations ayant des messages plus récents que la dernière visite (lastReadAt), comptées sur un badge rouge. Le composant lit les données sans rien modifier dans l'espace.
L'en-tête de canal
Au-dessus de la conversation se trouve un en-tête, comme une plaque sur la porte d'une salle :
1interface ChannelHeaderProps {
2 channel: Channel;
3 memberCount: number;
4 onAddPeople: () => void;
5 onShowDetails: () => void;
6}
7
8export default function ChannelHeader({
9 channel,
10 memberCount,
11 onAddPeople,
12 onShowDetails
13}: ChannelHeaderProps) {
14 return (
15 <div className="border-b bg-white px-6 py-3">
16 <div className="flex items-center justify-between">
17 <div className="flex-1">
18 <div className="flex items-center gap-2 mb-1">
19 <h2 className="text-xl font-bold">
20 {channel.isPrivate ? '[privé]' : '#'} {channel.name}
21 </h2>
22 <button className="hover:bg-gray-100 p-1 rounded">Favori</button>
23 </div>
24 <p className="text-sm text-gray-600">
25 {channel.description || 'No description'} • {memberCount} members
26 </p>
27 </div>
28
29 <div className="flex items-center gap-2">
30 <button
31 onClick={onAddPeople}
32 className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-50 text-sm font-semibold"
33 >
34 + Add people
35 </button>
36 <button
37 onClick={onShowDetails}
38 className="p-2 hover:bg-gray-100 rounded"
39 >
40 ℹ
41 </button>
42 </div>
43 </div>
44 </div>
45 );
46}L'en-tête montre le nom avec # ou un cadenas, la description et le nombre de membres. Sans description, No description s'affiche à la place. Le bouton + Add people n'ajoute personne lui-même : il appelle onAddPeople, et le parent décide.
Conseil du Capitaine Barbe-Rouge : nomme les canaux en minuscules avec des tirets, par exemple treasure-hunts ou general. Un nom court et lisible, c'est la moitié de l'ordre sur le pont.
À retenir : l'espace de travail est le navire, les canaux ses salles, et le # ou le cadenas indique qui peut entrer. Dans l'éditeur ci-dessous, tu as une sidebar complète : joue avec les statuts, puis passe aux exercices.
Code de cette leçon : App.jsx
1// Navigation de la sidebar de l'espace de travail
2import { useState } from 'react';
3
4const workspace = {
5 name: 'Black Pearl Crew',
6 icon: '',
7 onlineCount: 12,
8 channels: [
9 { id: 1, name: 'general', isPrivate: false },
10 { id: 2, name: 'treasure-hunts', isPrivate: false },
11 { id: 3, name: 'ship-repairs', isPrivate: false },
12 { id: 4, name: 'secret-plans', isPrivate: true }
13 ],
14 directMessages: [
15 { id: 1, name: 'Jack Sparrow', avatar: '', status: 'online', unread: 2 },
16 { id: 2, name: 'Will Turner', avatar: '', status: 'away', unread: 0 },
17 { id: 3, name: 'Elizabeth Swan', avatar: '', status: 'offline', unread: 0 }
18 ]
19};
20
21export default function App() {
22 const [showChannels, setShowChannels] = useState(true);
23 const [activeChannel, setActiveChannel] = useState(null);
24 const [activeDM, setActiveDM] = useState(null);
25
26 return (
27 <div className="flex h-screen bg-gray-100">
28 {/* Sidebar */}
29 <div className="w-64 bg-purple-900 text-white flex flex-col">
30 {/* En-tête de l'espace de travail */}
31 <div className="p-4 border-b border-purple-800">
32 <div className="flex items-center gap-2 mb-2">
33 <span className="text-2xl">{workspace.icon}</span>
34 <h2 className="font-bold text-lg">{workspace.name}</h2>
35 </div>
36 <div className="flex items-center gap-2 text-sm opacity-80">
37 <div className="w-2 h-2 bg-green-400 rounded-full"></div>
38 <span>{workspace.onlineCount} en ligne</span>
39 </div>
40 </div>
41
42 {/* Navigation */}
43 <div className="flex-1 overflow-y-auto p-2">
44 {/* Liens rapides */}
45 <div className="mb-4">
46 <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
47 <span>Commenter</span>
48 <span>Threads</span>
49 </button>
50 <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
51 <span>Message</span>
52 <span>All DMs</span>
53 <span className="ml-auto bg-red-500 text-xs px-2 py-0.5 rounded-full">2</span>
54 </button>
55 <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
56 <span>Enregistré</span>
57 <span>Saved items</span>
58 </button>
59 </div>
60
61 {/* Canaux */}
62 <div className="mb-4">
63 <button
64 onClick={() => setShowChannels(!showChannels)}
65 className="w-full text-left px-3 py-1 text-sm font-semibold opacity-80 hover:opacity-100 flex items-center gap-1"
66 >
67 <span className={`transform transition ${showChannels ? 'rotate-90' : ''}`}>▶</span>
68 Channels
69 </button>
70 {showChannels && (
71 <div className="mt-1">
72 {workspace.channels.map(channel => (
73 <button
74 key={channel.id}
75 onClick={() => setActiveChannel(channel.id)}
76 className={`w-full text-left px-3 py-1.5 rounded flex items-center gap-2 ${
77 activeChannel === channel.id ? 'bg-blue-600' : 'hover:bg-purple-800'
78 }`}
79 >
80 <span>{channel.isPrivate ? '[privé]' : '#'}</span>
81 <span>{channel.name}</span>
82 </button>
83 ))}
84 <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded text-sm opacity-80">
85 + Add channels
86 </button>
87 </div>
88 )}
89 </div>
90
91 {/* Messages privés */}
92 <div>
93 <div className="px-3 py-1 text-sm font-semibold opacity-80">
94 Direct Messages
95 </div>
96 <div className="mt-1">
97 {workspace.directMessages.map(dm => (
98 <button
99 key={dm.id}
100 onClick={() => setActiveDM(dm.id)}
101 className={`w-full text-left px-3 py-1.5 rounded flex items-center gap-2 ${
102 activeDM === dm.id ? 'bg-blue-600' : 'hover:bg-purple-800'
103 }`}
104 >
105 <div className="relative text-xl">
106 {dm.avatar}
107 <div
108 className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border border-purple-900 ${
109 dm.status === 'online' ? 'bg-green-400' :
110 dm.status === 'away' ? 'bg-yellow-400' : 'bg-gray-400'
111 }`}
112 />
113 </div>
114 <span className="flex-1">{dm.name}</span>
115 {dm.unread > 0 && (
116 <span className="bg-red-500 text-xs px-1.5 py-0.5 rounded-full">
117 {dm.unread}
118 </span>
119 )}
120 </button>
121 ))}
122 </div>
123 </div>
124 </div>
125
126 {/* Profil utilisateur */}
127 <div className="p-3 border-t border-purple-800">
128 <div className="flex items-center gap-2">
129 <div className="text-2xl">◆</div>
130 <div className="flex-1">
131 <div className="font-semibold text-sm">Captain Redbeard</div>
132 <div className="text-xs opacity-80">Active</div>
133 </div>
134 <button className="hover:bg-purple-800 p-1 rounded">Paramètres</button>
135 </div>
136 </div>
137 </div>
138
139 {/* Zone principale */}
140 <div className="flex-1 flex items-center justify-center text-gray-500">
141 Sélectionne un canal ou un MP pour commencer à discuter
142 </div>
143 </div>
144 );
145}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 contient un Workspace dans Slack ?
2. Quelle est la différence entre un canal public et un canal privé ?
Voici 2 des 7 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Navigation de l'espace de travail Slack
- Clique dans l'ordre
Construis une référence au canal public treasure-hunts (clique sur les éléments) :
- Clique dans l'ordre
Construis le nom du canal privé tel que la barre latérale l'affiche (clique seulement les éléments nécessaires) :
- Tri vertical
Ordonne les rôles de l'espace de travail des permissions les plus élevées aux plus basses :
- Éditeur de code
Composant en-tête de canal
- Tri horizontal
Ordonne l'URL de l'espace de travail blackpearl.slack.com (une ligne, de gauche à droite) :
- Tri vertical
Ordonne les sections de la Sidebar de haut en bas :
- Clique dans l'ordre
Construis le nom affiché du canal public en cliquant sur les éléments :