Cours Vibe coding avec l'IA · Module 9: Clone de Discord

Serveurs et salons - Structure d'une communauté

10 min de lecture
Dans cette leçon6

Bienvenue ! La guilde Black Pearl compte déjà cent membres, qui communiquent tous dans un seul chat. Les annonces du capitaine se perdent entre les blagues, les plans de voyage se mêlent au commerce, et personne ne sait qui commande. Construisons Pirate's Cove, une plateforme d'équipages inspirée de Discord, où chaque guilde a son serveur avec des salons et des rôles en ordre.

Ce que tu vas apprendre :

  • décrire en TypeScript un serveur, ses catégories, salons, rôles et membres,
  • construire une barre de salons avec des catégories repliables,
  • faire une liste de serveurs qui signale le serveur actif,
  • regrouper la liste des membres par rôles et statut.

Discord face à Slack

Slack est un espace de travail d'entreprise : échanges professionnels, fils de discussion, communication asynchrone. Discord est né pour les communautés et les joueurs : serveurs, salons vocaux toujours ouverts, partage d'écran, rôles et emotes. Pirate's Cove suit Discord.

La structure d'un serveur

Un serveur contient des catégories, des salons, des rôles, des membres et des emojis. Hiérarchie, du plus large : serveur (Black Pearl Guild), catégorie (SALONS TEXTUELS), salon (#general), membre :

1interface Server {
2  id: string;
3  name: string; // "Black Pearl Guild"
4  icon: string;
5  banner?: string;
6
7  owner: string; // ID de l'utilisateur
8  members: Member[];
9
10  categories: Category[];
11  channels: Channel[];
12
13  roles: Role[];
14  emojis: Emoji[];
15
16  createdAt: Date;
17}
18
19interface Category {
20  id: string;
21  name: string; // "SALONS TEXTUELS", "SALONS VOCAUX"
22  position: number;
23  channels: string[]; // IDs des salons
24}
25
26interface Channel {
27  id: string;
28  serverId: string;
29  categoryId: string;
30
31  name: string;
32  type: 'text' | 'voice' | 'announcement';
33  topic?: string;
34
35  position: number;
36  isNSFW: boolean;
37
38  // Permissions
39  permissions: ChannelPermission[];
40
41  // Spécifique au vocal
42  userLimit?: number; // nombre max d'utilisateurs dans un salon vocal
43  bitrate?: number;
44
45  createdAt: Date;
46}
47
48interface ChannelPermission {
49  roleId: string;
50  allow: Permission[];
51  deny: Permission[];
52}
53
54type Permission =
55  | 'VIEW_CHANNEL'
56  | 'SEND_MESSAGES'
57  | 'CONNECT' // vocal
58  | 'SPEAK' // vocal
59  | 'MANAGE_MESSAGES'
60  | 'MANAGE_CHANNELS';

Les catégories regroupent les salons par thème, par exemple SALONS TEXTUELS et SALONS VOCAUX. Le type d'un salon est une union, type: 'text' | 'voice' | 'announcement', donc TypeScript refuse toute autre valeur. Les champs userLimit et bitrate, propres aux salons vocaux, sont optionnels. Dans la vraie API de Discord, une catégorie est elle-même un salon, que les autres salons désignent par leur champ parent_id ; notre type Category séparé est plus simple à apprendre.

Rôles et membres

Les rôles fixent la couleur du nom, les permissions et la place dans la liste des membres :

1interface Role {
2  id: string;
3  serverId: string;
4
5  name: string;
6  color: string; // couleur hexadécimale du badge de rôle
7  position: number; // hiérarchie
8
9  permissions: Permission[];
10
11  isHoisted: boolean; // affiché séparément dans la liste des membres
12  isMentionable: boolean; // peut être @mentionné
13
14  memberCount: number;
15  createdAt: Date;
16}
17
18interface Member {
19  userId: string;
20  user: {
21    id: string;
22    username: string;
23    discriminator: string; // "#1234"
24    avatar: string;
25    banner?: string;
26    status: 'online' | 'idle' | 'dnd' | 'offline';
27    customStatus?: string;
28  };
29
30  // Spécifique au serveur
31  nickname?: string; // remplace le nom d'utilisateur
32  roles: string[]; // IDs des rôles
33  joinedAt: Date;
34
35  // État vocal
36  voiceChannelId?: string;
37  isMuted: boolean;
38  isDeafened: boolean;
39}
40
41// Fonction utilitaire pour obtenir le rôle le plus élevé
42function getHighestRole(member: Member, roles: Role[]): Role | null {
43  const memberRoles = roles
44    .filter(r => member.roles.includes(r.id))
45    .sort((a, b) => b.position - a.position);
46
47  return memberRoles[0] || null;
48}

color définit la couleur (hex) du badge de rôle dans la liste des membres, et position fixe la hiérarchie : plus le nombre est élevé, plus le rôle est haut. isHoisted affiche séparément les membres de ce rôle dans la liste des membres. Le statut d'un utilisateur prend l'une de quatre valeurs : online, idle, dnd (ne pas déranger) ou offline. Le tag à quatre chiffres se lit avec user.discriminator ; Discord a commencé en 2023 à remplacer ces tags par des noms d'utilisateur uniques, mais notre clone garde le format classique.

getHighestRole fonctionne en trois étapes : elle filtre les rôles qui appartiennent au membre (member.roles.includes(r.id)), les trie par position décroissante et renvoie le premier, ou null s'il n'en a aucun.

La barre des salons du serveur

La barre de gauche affiche le nom du serveur, les catégories avec leurs salons et le statut du pirate connecté :

1'use client';
2
3import { useState } from 'react';
4
5interface ServerSidebarProps {
6  server: Server;
7  currentUserId: string;
8  onChannelSelect: (channelId: string) => void;
9}
10
11export default function ServerSidebar({
12  server,
13  currentUserId,
14  onChannelSelect
15}: ServerSidebarProps) {
16  const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
17  const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
18
19  const toggleCategory = (categoryId: string) => {
20    const newCollapsed = new Set(collapsedCategories);
21    if (newCollapsed.has(categoryId)) {
22      newCollapsed.delete(categoryId);
23    } else {
24      newCollapsed.add(categoryId);
25    }
26    setCollapsedCategories(newCollapsed);
27  };
28
29  const handleChannelClick = (channelId: string) => {
30    setActiveChannelId(channelId);
31    onChannelSelect(channelId);
32  };
33
34  return (
35    <div className="w-60 bg-gray-800 flex flex-col h-screen">
36      {/* En-tête du serveur */}
37      <div className="h-12 px-4 flex items-center justify-between border-b border-gray-900 shadow-md hover:bg-gray-750 cursor-pointer">
38        <h2 className="font-bold text-white">{server.name}</h2>
39        <button className="text-gray-400 hover:text-white">▼</button>
40      </div>
41
42      {/* Salons */}
43      <div className="flex-1 overflow-y-auto pt-4">
44        {server.categories
45          .sort((a, b) => a.position - b.position)
46          .map(category => {
47            const categoryChannels = server.channels
48              .filter(ch => ch.categoryId === category.id)
49              .sort((a, b) => a.position - b.position);
50
51            const isCollapsed = collapsedCategories.has(category.id);
52
53            return (
54              <div key={category.id} className="mb-4">
55                {/* En-tête de catégorie */}
56                <button
57                  onClick={() => toggleCategory(category.id)}
58                  className="w-full px-2 py-1 flex items-center gap-1 text-xs font-semibold text-gray-400 uppercase hover:text-gray-300"
59                >
60                  <span className={`transform transition ${isCollapsed ? '' : 'rotate-90'}`}>
61                    ▶
62                  </span>
63                  {category.name}
64                </button>
65
66                {/* Salons */}
67                {!isCollapsed && (
68                  <div className="mt-1">
69                    {categoryChannels.map(channel => (
70                      <button
71                        key={channel.id}
72                        onClick={() => handleChannelClick(channel.id)}
73                        className={`w-full px-2 py-1 mx-2 rounded flex items-center gap-2 text-gray-400 hover:bg-gray-700 hover:text-gray-300 ${
74                          activeChannelId === channel.id
75                            ? 'bg-gray-700 text-white'
76                            : ''
77                        }`}
78                      >
79                        {channel.type === 'text' && <span className="text-gray-500">#</span>}
80                        {channel.type === 'voice' && <span className="text-gray-500">[vocal]</span>}
81                        {channel.type === 'announcement' && <span className="text-gray-500">[annonces]</span>}
82                        <span className="text-sm flex-1 text-left truncate">
83                          {channel.name}
84                        </span>
85                        {channel.isNSFW && (
86                          <span className="text-xs bg-red-500 px-1 rounded">NSFW</span>
87                        )}
88                      </button>
89                    ))}
90                  </div>
91                )}
92              </div>
93            );
94          })}
95      </div>
96
97      {/* Statut vocal */}
98      <div className="bg-gray-900 p-2">
99        {(() => {
100          const currentMember = server.members.find(m => m.userId === currentUserId);
101          if (!currentMember) return null;
102
103          return (
104            <div className="flex items-center gap-2 bg-gray-800 rounded p-2">
105              <div className="relative">
106                <img
107                  src={currentMember.user.avatar}
108                  alt={currentMember.user.username}
109                  className="w-8 h-8 rounded-full"
110                />
111                <div
112                  className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-gray-800 ${
113                    currentMember.user.status === 'online' ? 'bg-green-500' :
114                    currentMember.user.status === 'idle' ? 'bg-yellow-500' :
115                    currentMember.user.status === 'dnd' ? 'bg-red-500' : 'bg-gray-500'
116                  }`}
117                />
118              </div>
119              <div className="flex-1 min-w-0">
120                <div className="text-white text-sm font-semibold truncate">
121                  {currentMember.nickname || currentMember.user.username}
122                </div>
123                <div className="text-gray-400 text-xs truncate">
124                  {currentMember.user.customStatus || `#${currentMember.user.discriminator}`}
125                </div>
126              </div>
127              <div className="flex gap-1">
128                <button className="p-1.5 hover:bg-gray-700 rounded">Micro</button>
129                <button className="p-1.5 hover:bg-gray-700 rounded">Casque</button>
130                <button className="p-1.5 hover:bg-gray-700 rounded">Paramètres</button>
131              </div>
132            </div>
133          );
134        })()}
135      </div>
136    </div>
137  );
138}

Le nom du serveur s'affiche avec l'expression {server.name}. Les catégories repliées sont dans un Set : un clic sur l'en-tête copie l'ensemble, ajoute ou retire l'id de la catégorie et enregistre la copie dans l'état. Catégories et salons sont triés par position, et l'indication devant un nom dépend du type.

La liste des serveurs

Une barre étroite à gauche permet de changer de serveur :

1'use client';
2
3interface ServerListProps {
4  servers: Server[];
5  activeServerId: string;
6  onServerSelect: (serverId: string) => void;
7  onCreateServer: () => void;
8}
9
10export default function ServerList({
11  servers,
12  activeServerId,
13  onServerSelect,
14  onCreateServer
15}: ServerListProps) {
16  return (
17    <div className="w-18 bg-gray-900 flex flex-col items-center py-3 gap-2 h-screen overflow-y-auto">
18      {/* Bouton Accueil */}
19      <button className="w-12 h-12 bg-gray-700 hover:bg-blue-600 rounded-full flex items-center justify-center text-white transition-all hover:rounded-2xl">
20        Accueil
21      </button>
22
23      <div className="w-8 h-0.5 bg-gray-700 rounded" />
24
25      {/* Serveurs */}
26      {servers.map(server => (
27        <button
28          key={server.id}
29          onClick={() => onServerSelect(server.id)}
30          className={`w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all hover:rounded-2xl relative ${
31            activeServerId === server.id
32              ? 'rounded-2xl'
33              : ''
34          }`}
35          style={{
36            backgroundColor: server.icon ? 'transparent' : '#5865F2'
37          }}
38        >
39          {server.icon ? (
40            <img src={server.icon} alt={server.name} className="w-full h-full rounded-full hover:rounded-2xl" />
41          ) : (
42            <span className="text-white font-bold">
43              {server.name.charAt(0).toUpperCase()}
44            </span>
45          )}
46
47          {/* Indicateur actif */}
48          {activeServerId === server.id && (
49            <div className="absolute left-0 w-1 h-10 bg-white rounded-r" />
50          )}
51        </button>
52      ))}
53
54      {/* Ajouter un serveur */}
55      <button
56        onClick={onCreateServer}
57        className="w-12 h-12 bg-gray-700 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-2xl transition-all hover:rounded-2xl"
58      >
59        +
60      </button>
61    </div>
62  );
63}

Le serveur actif a un carré arrondi au lieu d'un cercle et une barre blanche sur le bord, et un serveur sans icône affiche la première lettre de son nom. Le composant n'a pas d'état propre, il signale seulement le choix via onServerSelect.

La liste des membres

La barre de droite regroupe les membres. Un membre hors ligne va dans OFFLINE, un membre dont le rôle le plus haut est marqué isHoisted dans le groupe de ce rôle, les autres dans ONLINE :

1interface MemberListProps {
2  server: Server;
3  onMemberClick: (memberId: string) => void;
4}
5
6export default function MemberList({ server, onMemberClick }: MemberListProps) {
7  // Regrouper les membres par rôle le plus élevé
8  const membersByRole = new Map<string, Member[]>();
9
10  // Ajouter les groupes par défaut "En ligne" et "Hors ligne"
11  membersByRole.set('online', []);
12  membersByRole.set('offline', []);
13
14  server.members.forEach(member => {
15    const highestRole = getHighestRole(member, server.roles);
16
17    if (member.user.status === 'offline') {
18      membersByRole.get('offline')!.push(member);
19    } else if (highestRole && highestRole.isHoisted) {
20      if (!membersByRole.has(highestRole.id)) {
21        membersByRole.set(highestRole.id, []);
22      }
23      membersByRole.get(highestRole.id)!.push(member);
24    } else {
25      membersByRole.get('online')!.push(member);
26    }
27  });
28
29  return (
30    <div className="w-60 bg-gray-800 h-screen overflow-y-auto">
31      <div className="p-4">
32        {Array.from(membersByRole.entries()).map(([roleId, members]) => {
33          if (members.length === 0) return null;
34
35          const role = server.roles.find(r => r.id === roleId);
36          const groupName = roleId === 'online' ? 'EN LIGNE' :
37                           roleId === 'offline' ? 'HORS LIGNE' :
38                           role?.name.toUpperCase() || 'MEMBRES';
39
40          const groupColor = role?.color;
41
42          return (
43            <div key={roleId} className="mb-4">
44              <div
45                className="text-xs font-semibold mb-2 px-2"
46                style={{ color: groupColor || '#96989D' }}
47              >
48                {groupName} - {members.length}
49              </div>
50
51              <div>
52                {members.map(member => {
53                  const displayName = member.nickname || member.user.username;
54                  const memberRole = getHighestRole(member, server.roles);
55
56                  return (
57                    <button
58                      key={member.userId}
59                      onClick={() => onMemberClick(member.userId)}
60                      className="w-full flex items-center gap-2 px-2 py-1 hover:bg-gray-700 rounded group"
61                    >
62                      <div className="relative">
63                        <img
64                          src={member.user.avatar}
65                          alt={displayName}
66                          className="w-8 h-8 rounded-full"
67                        />
68                        <div
69                          className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-gray-800 ${
70                            member.user.status === 'online' ? 'bg-green-500' :
71                            member.user.status === 'idle' ? 'bg-yellow-500' :
72                            member.user.status === 'dnd' ? 'bg-red-500' : 'bg-gray-500'
73                          }`}
74                        />
75                      </div>
76
77                      <div className="flex-1 min-w-0 text-left">
78                        <div
79                          className="text-sm font-medium truncate"
80                          style={{
81                            color: memberRole?.color || '#DCDDDE'
82                          }}
83                        >
84                          {displayName}
85                        </div>
86                        {member.user.customStatus && (
87                          <div className="text-xs text-gray-400 truncate">
88                            {member.user.customStatus}
89                          </div>
90                        )}
91                      </div>
92                    </button>
93                  );
94                })}
95              </div>
96            </div>
97          );
98        })}
99      </div>
100    </div>
101  );
102}

Une Map retient l'ordre d'insertion, et les groupes online et offline y sont ajoutés en premier. Dans Discord, la liste est différente : d'abord les rôles hoisted (par exemple ADMIN), puis ONLINE, et OFFLINE à la fin. Il suffit de trier les groupes avant le rendu :

1const groupOrder = (id: string) => (id === 'offline' ? 2 : id === 'online' ? 1 : 0);
2
3const groups = Array.from(membersByRole.entries())
4  .sort(([a], [b]) => groupOrder(a) - groupOrder(b));

Dans le JSX, tu parcours alors groups. La répartition des membres ne change pas, seul l'ordre d'affichage change.

Conseil du Capitaine Barbe-Rouge : avant de demander d'autres vues à l'IA, colle-lui les interfaces Server, Channel, Role et Member. Des types bien nommés sont comme une carte : à chaque nouveau composant, tu sais où jeter l'ancre.

À retenir : un serveur se compose de catégories, les catégories de salons, et les rôles décident qui apparaît dans la liste et comment. Dans l'éditeur ci-dessous, tu as la barre des salons de la guilde Black Pearl : replie une catégorie et change de salon, puis fais les exercices.

Code de cette leçon : App.jsx
1// Barre latérale du serveur avec catégories
2import { useState } from 'react';
3
4const server = {
5  name: 'Black Pearl Guild',
6  icon: '',
7  categories: [
8    {
9      id: 1,
10      name: 'SALONS TEXTUELS',
11      channels: [
12        { id: 1, name: 'general', type: 'text', isNSFW: false },
13        { id: 2, name: 'treasure-hunts', type: 'text', isNSFW: false },
14        { id: 3, name: 'ship-repairs', type: 'text', isNSFW: false }
15      ]
16    },
17    {
18      id: 2,
19      name: 'SALONS VOCAUX',
20      channels: [
21        { id: 4, name: 'Battle Coordination', type: 'voice' },
22        { id: 5, name: 'Crew Hangout', type: 'voice' }
23      ]
24    }
25  ]
26};
27
28export default function App() {
29  const [activeChannelId, setActiveChannelId] = useState(1);
30  const [collapsedCategories, setCollapsedCategories] = useState(new Set());
31
32  const toggleCategory = (categoryId) => {
33    const newCollapsed = new Set(collapsedCategories);
34    if (newCollapsed.has(categoryId)) {
35      newCollapsed.delete(categoryId);
36    } else {
37      newCollapsed.add(categoryId);
38    }
39    setCollapsedCategories(newCollapsed);
40  };
41
42  return (
43    <div className="flex h-screen bg-gray-100">
44      {/* Barre latérale du serveur */}
45      <div className="w-60 bg-gray-800 flex flex-col">
46        {/* En-tête du serveur */}
47        <div className="h-12 px-4 flex items-center justify-between border-b border-gray-900 hover:bg-gray-750 cursor-pointer">
48          <h2 className="font-bold text-white">{server.name}</h2>
49          <button className="text-gray-400 hover:text-white">▼</button>
50        </div>
51
52        {/* Salons */}
53        <div className="flex-1 overflow-y-auto pt-4">
54          {server.categories.map(category => {
55            const isCollapsed = collapsedCategories.has(category.id);
56
57            return (
58              <div key={category.id} className="mb-4">
59                {/* En-tête de catégorie */}
60                <button
61                  onClick={() => toggleCategory(category.id)}
62                  className="w-full px-2 py-1 flex items-center gap-1 text-xs font-semibold text-gray-400 uppercase hover:text-gray-300"
63                >
64                  <span className={`transform transition ${isCollapsed ? '' : 'rotate-90'}`}>▶</span>
65                  {category.name}
66                </button>
67
68                {/* Salons */}
69                {!isCollapsed && (
70                  <div className="mt-1">
71                    {category.channels.map(channel => (
72                      <button
73                        key={channel.id}
74                        onClick={() => setActiveChannelId(channel.id)}
75                        className={`w-full px-2 py-1 mx-2 rounded flex items-center gap-2 text-gray-400 hover:bg-gray-700 hover:text-gray-300 ${
76                          activeChannelId === channel.id ? 'bg-gray-700 text-white' : ''
77                        }`}
78                      >
79                        {channel.type === 'text' && <span className="text-gray-500">#</span>}
80                        {channel.type === 'voice' && <span className="text-gray-500">[vocal]</span>}
81                        <span className="text-sm flex-1 text-left truncate">{channel.name}</span>
82                        {channel.isNSFW && (
83                          <span className="text-xs bg-red-500 px-1 rounded">NSFW</span>
84                        )}
85                      </button>
86                    ))}
87                  </div>
88                )}
89              </div>
90            );
91          })}
92        </div>
93
94        {/* Statut vocal */}
95        <div className="bg-gray-900 p-2">
96          <div className="flex items-center gap-2 bg-gray-800 rounded p-2">
97            <div className="text-3xl">◆</div>
98            <div className="flex-1">
99              <div className="text-white text-sm font-semibold">Captain Redbeard</div>
100              <div className="text-gray-400 text-xs">En ligne</div>
101            </div>
102            <div className="flex gap-1">
103              <button className="p-1.5 hover:bg-gray-700 rounded">Micro</button>
104              <button className="p-1.5 hover:bg-gray-700 rounded">Casque</button>
105              <button className="p-1.5 hover:bg-gray-700 rounded">Paramètres</button>
106            </div>
107          </div>
108        </div>
109      </div>
110
111      {/* Zone principale */}
112      <div className="flex-1 flex items-center justify-center text-gray-500">
113        Sélectionne un salon à afficher
114      </div>
115    </div>
116  );
117}

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 serveur dans Discord ?

  2. 2. Quels types de salons Discord propose-t-il ?

Voici 2 des 7 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne la hiérarchie de la structure du Server (du plus large) :

  • Éditeur de code

    Navigation d'un serveur Discord

  • Clique dans l'ordre

    Clique pour construire la définition de type du champ channel.type :

  • Éditeur de code

    Barre latérale de changement de serveur

  • Tri horizontal

    Ordonne la ligne de définition de type : type: text | voice

  • Tri vertical

    Ordonne les groupes de la liste des membres (MemberList) dans l'ordre d'affichage :

  • Tri horizontal

    Ordonne l'accès au tag utilisateur à quatre chiffres : user.discriminator

  • Clique dans l'ordre

    Clique pour construire l'expression JSX qui affiche le nom du serveur :

Articles utiles