Cours Vibe coding avec l'IA · Module 9: Clone de Discord
Serveurs et salons - Structure d'une communauté
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. Que contient un serveur dans Discord ?
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 :