Cours Vibe coding avec l'IA · Module 8: Clone de Slack

Espace de travail et canaux - communication d'équipe

7 min de lecture
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. 1. Que contient un Workspace dans Slack ?

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

Articles utiles