Welcome @name! Time to build Pirate's Cove - a community platform for crews and pirate guilds! 🎮
This will be like Discord - servers with text/voice channels, roles, permissions, and gaming-focused chat.
Slack:
Discord:
1interface Server {
2 id: string;
3 name: string; // "Black Pearl Guild"
4 icon: string;
5 banner?: string;
6
7 owner: string; // user ID
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; // "TEXT CHANNELS", "VOICE CHANNELS"
22 position: number;
23 channels: string[]; // channel IDs
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 // Voice specific
42 userLimit?: number; // max users in voice channel
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' // voice
58 | 'SPEAK' // voice
59 | 'MANAGE_MESSAGES'
60 | 'MANAGE_CHANNELS';1interface Role {
2 id: string;
3 serverId: string;
4
5 name: string;
6 color: string; // hex color for role badge
7 position: number; // hierarchy
8
9 permissions: Permission[];
10
11 isHoisted: boolean; // show separately in member list
12 isMentionable: boolean; // can be @mentioned
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 // Server-specific
31 nickname?: string; // override username
32 roles: string[]; // role IDs
33 joinedAt: Date;
34
35 // Voice state
36 voiceChannelId?: string;
37 isMuted: boolean;
38 isDeafened: boolean;
39}
40
41// Helper to get highest role
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}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 {/* Server Header */}
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 {/* Channels */}
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 {/* Category Header */}
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 {/* Channels */}
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">🔊</span>}
81 {channel.type === 'announcement' && <span className="text-gray-500">📢</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 {/* Voice Status */}
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">🎤</button>
129 <button className="p-1.5 hover:bg-gray-700 rounded">🎧</button>
130 <button className="p-1.5 hover:bg-gray-700 rounded">⚙️</button>
131 </div>
132 </div>
133 );
134 })()}
135 </div>
136 </div>
137 );
138}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 {/* Home Button */}
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 🏠
21 </button>
22
23 <div className="w-8 h-0.5 bg-gray-700 rounded" />
24
25 {/* Servers */}
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 {/* Active Indicator */}
48 {activeServerId === server.id && (
49 <div className="absolute left-0 w-1 h-10 bg-white rounded-r" />
50 )}
51 </button>
52 ))}
53
54 {/* Add Server */}
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}1interface MemberListProps {
2 server: Server;
3 onMemberClick: (memberId: string) => void;
4}
5
6export default function MemberList({ server, onMemberClick }: MemberListProps) {
7 // Group members by highest role
8 const membersByRole = new Map<string, Member[]>();
9
10 // Add "Online" and "Offline" default groups
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' ? 'ONLINE' :
37 roleId === 'offline' ? 'OFFLINE' :
38 role?.name.toUpperCase() || 'MEMBERS';
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}✅ Server structure - categories, channels, roles, members ✅ Channel types - text, voice, announcement ✅ Role system - colors, permissions, hierarchy, hoisted ✅ Member management - nicknames, roles, voice state ✅ Server sidebar - collapsible categories, channel list ✅ Server list - left sidebar with server icons ✅ Member list - grouped by roles, status indicators ✅ Permissions - channel-specific and role-based
In the next exercise we'll add voice channels and rich presence!
See you next time! 🚀