Vibe coding with AI course ยท Module 9: Discord Clone
Servers and Channels - Community Structure
In this lesson6
Welcome! The Black Pearl guild already has a hundred members, and all communication happens in a single chat. The captain's announcements get lost among jokes, voyage plans mix with trading, and nobody knows who is in charge. Time to build Pirate's Cove, a platform for crews modeled on Discord, where every guild has its own server with organized channels and roles.
What you will learn:
- describe a server, categories, channels, roles and members in TypeScript,
- build a channel sidebar with collapsible categories,
- make a server list that marks the active one,
- group the member list by roles and status.
Discord versus Slack
Slack is a workspace for companies and teams: work conversations, threads and asynchronous communication. Discord was created for communities and gamers: servers, always-open voice channels, screen sharing, roles and emotes. Pirate's Cove follows Discord's path.
Server structure
A server contains categories, channels, roles, members and emojis. The hierarchy from the broadest: server (Black Pearl Guild), category (TEXT CHANNELS), channel (#general) and the member who writes in it:
interface Server {
id: string;
name: string; // "Black Pearl Guild"
icon: string;
banner?: string;
owner: string; // user ID
members: Member[];
categories: Category[];
channels: Channel[];
roles: Role[];
emojis: Emoji[];
createdAt: Date;
}
interface Category {
id: string;
name: string; // "TEXT CHANNELS", "VOICE CHANNELS"
position: number;
channels: string[]; // channel IDs
}
interface Channel {
id: string;
serverId: string;
categoryId: string;
name: string;
type: 'text' | 'voice' | 'announcement';
topic?: string;
position: number;
isNSFW: boolean;
// Permissions
permissions: ChannelPermission[];
// Voice specific
userLimit?: number; // max users in voice channel
bitrate?: number;
createdAt: Date;
}
interface ChannelPermission {
roleId: string;
allow: Permission[];
deny: Permission[];
}
type Permission =
| 'VIEW_CHANNEL'
| 'SEND_MESSAGES'
| 'CONNECT' // voice
| 'SPEAK' // voice
| 'MANAGE_MESSAGES'
| 'MANAGE_CHANNELS';Categories group channels by topic, for example TEXT CHANNELS and VOICE CHANNELS. The channel type is a union, type: 'text' | 'voice' | 'announcement', so TypeScript won't let any other value through. The userLimit and bitrate fields only make sense in voice channels, which is why they are optional. In Discord's real API a category is also a kind of channel, and a channel points to it with the parent_id field; our model with a separate Category type is simpler to learn.
Roles and members
Roles decide the color of a name, the permissions and the place on the member list:
interface Role {
id: string;
serverId: string;
name: string;
color: string; // hex color for role badge
position: number; // hierarchy
permissions: Permission[];
isHoisted: boolean; // show separately in member list
isMentionable: boolean; // can be @mentioned
memberCount: number;
createdAt: Date;
}
interface Member {
userId: string;
user: {
id: string;
username: string;
discriminator: string; // "#1234"
avatar: string;
banner?: string;
status: 'online' | 'idle' | 'dnd' | 'offline';
customStatus?: string;
};
// Server-specific
nickname?: string; // override username
roles: string[]; // role IDs
joinedAt: Date;
// Voice state
voiceChannelId?: string;
isMuted: boolean;
isDeafened: boolean;
}
// Helper to get highest role
function getHighestRole(member: Member, roles: Role[]): Role | null {
const memberRoles = roles
.filter(r => member.roles.includes(r.id))
.sort((a, b) => b.position - a.position);
return memberRoles[0] || null;
}color sets the color (hex) of the role badge in the member list, and position sets the hierarchy: the higher the number, the higher the role. isHoisted displays the members of that role separately in the member list. A user's status takes one of four values: online, idle, dnd (do not disturb) or offline. You read the four-digit tag with user.discriminator; Discord itself started replacing these tags with unique usernames in 2023, but in our clone we keep the classic format.
getHighestRole works in three steps: it filters the roles that belong to the member (member.roles.includes(r.id)), sorts them by position in descending order and returns the first one, or null when the member has no role.
The server's channel sidebar
The left sidebar shows the server name, the categories with channels and the status of the logged-in pirate:
'use client';
import { useState } from 'react';
interface ServerSidebarProps {
server: Server;
currentUserId: string;
onChannelSelect: (channelId: string) => void;
}
export default function ServerSidebar({
server,
currentUserId,
onChannelSelect
}: ServerSidebarProps) {
const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
const toggleCategory = (categoryId: string) => {
const newCollapsed = new Set(collapsedCategories);
if (newCollapsed.has(categoryId)) {
newCollapsed.delete(categoryId);
} else {
newCollapsed.add(categoryId);
}
setCollapsedCategories(newCollapsed);
};
const handleChannelClick = (channelId: string) => {
setActiveChannelId(channelId);
onChannelSelect(channelId);
};
return (
<div className="w-60 bg-gray-800 flex flex-col h-screen">
{/* Server Header */}
<div className="h-12 px-4 flex items-center justify-between border-b border-gray-900 shadow-md hover:bg-gray-750 cursor-pointer">
<h2 className="font-bold text-white">{server.name}</h2>
<button className="text-gray-400 hover:text-white">โผ</button>
</div>
{/* Channels */}
<div className="flex-1 overflow-y-auto pt-4">
{server.categories
.sort((a, b) => a.position - b.position)
.map(category => {
const categoryChannels = server.channels
.filter(ch => ch.categoryId === category.id)
.sort((a, b) => a.position - b.position);
const isCollapsed = collapsedCategories.has(category.id);
return (
<div key={category.id} className="mb-4">
{/* Category Header */}
<button
onClick={() => toggleCategory(category.id)}
className="w-full px-2 py-1 flex items-center gap-1 text-xs font-semibold text-gray-400 uppercase hover:text-gray-300"
>
<span className={`transform transition ${isCollapsed ? '' : 'rotate-90'}`}>
โถ
</span>
{category.name}
</button>
{/* Channels */}
{!isCollapsed && (
<div className="mt-1">
{categoryChannels.map(channel => (
<button
key={channel.id}
onClick={() => handleChannelClick(channel.id)}
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 ${
activeChannelId === channel.id
? 'bg-gray-700 text-white'
: ''
}`}
>
{channel.type === 'text' && <span className="text-gray-500">#</span>}
{channel.type === 'voice' && <span className="text-gray-500">[voice]</span>}
{channel.type === 'announcement' && <span className="text-gray-500">[news]</span>}
<span className="text-sm flex-1 text-left truncate">
{channel.name}
</span>
{channel.isNSFW && (
<span className="text-xs bg-red-500 px-1 rounded">NSFW</span>
)}
</button>
))}
</div>
)}
</div>
);
})}
</div>
{/* Voice Status */}
<div className="bg-gray-900 p-2">
{(() => {
const currentMember = server.members.find(m => m.userId === currentUserId);
if (!currentMember) return null;
return (
<div className="flex items-center gap-2 bg-gray-800 rounded p-2">
<div className="relative">
<img
src={currentMember.user.avatar}
alt={currentMember.user.username}
className="w-8 h-8 rounded-full"
/>
<div
className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-gray-800 ${
currentMember.user.status === 'online' ? 'bg-green-500' :
currentMember.user.status === 'idle' ? 'bg-yellow-500' :
currentMember.user.status === 'dnd' ? 'bg-red-500' : 'bg-gray-500'
}`}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-semibold truncate">
{currentMember.nickname || currentMember.user.username}
</div>
<div className="text-gray-400 text-xs truncate">
{currentMember.user.customStatus || `#${currentMember.user.discriminator}`}
</div>
</div>
<div className="flex gap-1">
<button className="p-1.5 hover:bg-gray-700 rounded">Mic</button>
<button className="p-1.5 hover:bg-gray-700 rounded">Headphones</button>
<button className="p-1.5 hover:bg-gray-700 rounded">Settings</button>
</div>
</div>
);
})()}
</div>
</div>
);
}The server name is displayed by the {server.name} expression. We keep collapsed categories in a Set: clicking a header creates a copy of the set, adds or removes the category id and saves the copy in state. Categories and channels are sorted by the position field, and the label before a name depends on the channel type.
Server list
A narrow bar on the left switches servers:
'use client';
interface ServerListProps {
servers: Server[];
activeServerId: string;
onServerSelect: (serverId: string) => void;
onCreateServer: () => void;
}
export default function ServerList({
servers,
activeServerId,
onServerSelect,
onCreateServer
}: ServerListProps) {
return (
<div className="w-18 bg-gray-900 flex flex-col items-center py-3 gap-2 h-screen overflow-y-auto">
{/* Home Button */}
<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">
Home
</button>
<div className="w-8 h-0.5 bg-gray-700 rounded" />
{/* Servers */}
{servers.map(server => (
<button
key={server.id}
onClick={() => onServerSelect(server.id)}
className={`w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all hover:rounded-2xl relative ${
activeServerId === server.id
? 'rounded-2xl'
: ''
}`}
style={{
backgroundColor: server.icon ? 'transparent' : '#5865F2'
}}
>
{server.icon ? (
<img src={server.icon} alt={server.name} className="w-full h-full rounded-full hover:rounded-2xl" />
) : (
<span className="text-white font-bold">
{server.name.charAt(0).toUpperCase()}
</span>
)}
{/* Active Indicator */}
{activeServerId === server.id && (
<div className="absolute left-0 w-1 h-10 bg-white rounded-r" />
)}
</button>
))}
{/* Add Server */}
<button
onClick={onCreateServer}
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"
>
+
</button>
</div>
);
}The active server has a rounded square instead of a circle and a white bar at the edge, and a server without an icon shows the first letter of its name. The component keeps no state of its own; it only reports the choice through onServerSelect.
Member list
The right sidebar groups members. An offline member goes to the OFFLINE group, a member whose highest role is marked isHoisted goes to that role's group, and everyone else goes to ONLINE:
interface MemberListProps {
server: Server;
onMemberClick: (memberId: string) => void;
}
export default function MemberList({ server, onMemberClick }: MemberListProps) {
// Group members by highest role
const membersByRole = new Map<string, Member[]>();
// Add "Online" and "Offline" default groups
membersByRole.set('online', []);
membersByRole.set('offline', []);
server.members.forEach(member => {
const highestRole = getHighestRole(member, server.roles);
if (member.user.status === 'offline') {
membersByRole.get('offline')!.push(member);
} else if (highestRole && highestRole.isHoisted) {
if (!membersByRole.has(highestRole.id)) {
membersByRole.set(highestRole.id, []);
}
membersByRole.get(highestRole.id)!.push(member);
} else {
membersByRole.get('online')!.push(member);
}
});
return (
<div className="w-60 bg-gray-800 h-screen overflow-y-auto">
<div className="p-4">
{Array.from(membersByRole.entries()).map(([roleId, members]) => {
if (members.length === 0) return null;
const role = server.roles.find(r => r.id === roleId);
const groupName = roleId === 'online' ? 'ONLINE' :
roleId === 'offline' ? 'OFFLINE' :
role?.name.toUpperCase() || 'MEMBERS';
const groupColor = role?.color;
return (
<div key={roleId} className="mb-4">
<div
className="text-xs font-semibold mb-2 px-2"
style={{ color: groupColor || '#96989D' }}
>
{groupName} - {members.length}
</div>
<div>
{members.map(member => {
const displayName = member.nickname || member.user.username;
const memberRole = getHighestRole(member, server.roles);
return (
<button
key={member.userId}
onClick={() => onMemberClick(member.userId)}
className="w-full flex items-center gap-2 px-2 py-1 hover:bg-gray-700 rounded group"
>
<div className="relative">
<img
src={member.user.avatar}
alt={displayName}
className="w-8 h-8 rounded-full"
/>
<div
className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-gray-800 ${
member.user.status === 'online' ? 'bg-green-500' :
member.user.status === 'idle' ? 'bg-yellow-500' :
member.user.status === 'dnd' ? 'bg-red-500' : 'bg-gray-500'
}`}
/>
</div>
<div className="flex-1 min-w-0 text-left">
<div
className="text-sm font-medium truncate"
style={{
color: memberRole?.color || '#DCDDDE'
}}
>
{displayName}
</div>
{member.user.customStatus && (
<div className="text-xs text-gray-400 truncate">
{member.user.customStatus}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}A Map remembers insertion order, and the online and offline groups are added to it first. In Discord the list looks different: hoisted roles first (for example ADMIN), then ONLINE, and OFFLINE at the end. It's enough to sort the groups before rendering:
const groupOrder = (id: string) => (id === 'offline' ? 2 : id === 'online' ? 1 : 0);
const groups = Array.from(membersByRole.entries())
.sort(([a], [b]) => groupOrder(a) - groupOrder(b));In JSX you then map over groups instead of Array.from(membersByRole.entries()). The assignment of members to groups doesn't change; only the display order does.
Captain Redbeard's tip: before you ask AI for more views, paste in the Server, Channel, Role and Member interfaces. Well-named types are like a map: with every new component you know where to drop anchor.
Remember: a server consists of categories, categories of channels, and roles decide who appears on the list and how. In the editor below you have the channel sidebar of the Black Pearl guild: collapse a category and switch channels, then solve the tasks.
Code for this lesson: App.jsx
// Server Sidebar with Categories
import { useState } from 'react';
const server = {
name: 'Black Pearl Guild',
icon: '',
categories: [
{
id: 1,
name: 'TEXT CHANNELS',
channels: [
{ id: 1, name: 'general', type: 'text', isNSFW: false },
{ id: 2, name: 'treasure-hunts', type: 'text', isNSFW: false },
{ id: 3, name: 'ship-repairs', type: 'text', isNSFW: false }
]
},
{
id: 2,
name: 'VOICE CHANNELS',
channels: [
{ id: 4, name: 'Battle Coordination', type: 'voice' },
{ id: 5, name: 'Crew Hangout', type: 'voice' }
]
}
]
};
export default function App() {
const [activeChannelId, setActiveChannelId] = useState(1);
const [collapsedCategories, setCollapsedCategories] = useState(new Set());
const toggleCategory = (categoryId) => {
const newCollapsed = new Set(collapsedCategories);
if (newCollapsed.has(categoryId)) {
newCollapsed.delete(categoryId);
} else {
newCollapsed.add(categoryId);
}
setCollapsedCategories(newCollapsed);
};
return (
<div className="flex h-screen bg-gray-100">
{/* Server Sidebar */}
<div className="w-60 bg-gray-800 flex flex-col">
{/* Server Header */}
<div className="h-12 px-4 flex items-center justify-between border-b border-gray-900 hover:bg-gray-750 cursor-pointer">
<h2 className="font-bold text-white">{server.name}</h2>
<button className="text-gray-400 hover:text-white">โผ</button>
</div>
{/* Channels */}
<div className="flex-1 overflow-y-auto pt-4">
{server.categories.map(category => {
const isCollapsed = collapsedCategories.has(category.id);
return (
<div key={category.id} className="mb-4">
{/* Category Header */}
<button
onClick={() => toggleCategory(category.id)}
className="w-full px-2 py-1 flex items-center gap-1 text-xs font-semibold text-gray-400 uppercase hover:text-gray-300"
>
<span className={`transform transition ${isCollapsed ? '' : 'rotate-90'}`}>โถ</span>
{category.name}
</button>
{/* Channels */}
{!isCollapsed && (
<div className="mt-1">
{category.channels.map(channel => (
<button
key={channel.id}
onClick={() => setActiveChannelId(channel.id)}
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 ${
activeChannelId === channel.id ? 'bg-gray-700 text-white' : ''
}`}
>
{channel.type === 'text' && <span className="text-gray-500">#</span>}
{channel.type === 'voice' && <span className="text-gray-500">[voice]</span>}
<span className="text-sm flex-1 text-left truncate">{channel.name}</span>
{channel.isNSFW && (
<span className="text-xs bg-red-500 px-1 rounded">NSFW</span>
)}
</button>
))}
</div>
)}
</div>
);
})}
</div>
{/* Voice Status */}
<div className="bg-gray-900 p-2">
<div className="flex items-center gap-2 bg-gray-800 rounded p-2">
<div className="text-3xl">โ</div>
<div className="flex-1">
<div className="text-white text-sm font-semibold">Captain Redbeard</div>
<div className="text-gray-400 text-xs">Online</div>
</div>
<div className="flex gap-1">
<button className="p-1.5 hover:bg-gray-700 rounded">Mic</button>
<button className="p-1.5 hover:bg-gray-700 rounded">Headphones</button>
<button className="p-1.5 hover:bg-gray-700 rounded">Settings</button>
</div>
</div>
</div>
</div>
{/* Main Area */}
<div className="flex-1 flex items-center justify-center text-gray-500">
Select a channel to view
</div>
</div>
);
}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does a Server in Discord contain?
2. What channel types does Discord have?
These are 2 of 7 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Vertical ordering
Arrange the Server structure hierarchy (from widest):
- Code editor
Discord server navigation
- Click in order
Click to build the type definition of the channel.type field:
- Code editor
Server switcher sidebar
- Horizontal ordering
Arrange the type definition line: type: text | voice
- Vertical ordering
Arrange the groups in the member list (MemberList) in display order:
- Horizontal ordering
Arrange access to the four-digit user tag: user.discriminator
- Click in order
Click to build the JSX expression that displays the server name: