Kurs vibe coding z AI · Moduł 9: Discord Clone
Servers i Channels - Community struktura
W tej lekcji6
Witaj! Gildia Black Pearl ma już stu członków, a cała komunikacja odbywa się w jednym czacie. Ogłoszenia kapitana giną między żartami, plany wypraw mieszają się z handlem, a nikt nie wie, kto tu dowodzi. Czas zbudować Pirate's Cove, platformę dla załóg na wzór Discorda, w której każda gildia ma własny serwer z uporządkowanymi kanałami i rangami.
Czego się nauczysz:
- opisać w TypeScripcie serwer, kategorie, kanały, rangi i członków,
- zbudować pasek kanałów ze zwijanymi kategoriami,
- zrobić listę serwerów z oznaczeniem aktywnego,
- pogrupować listę członków według rang i statusu.
Discord a Slack
Slack to przestrzeń robocza dla firm i zespołów: rozmowy zawodowe, wątki i komunikacja asynchroniczna. Discord powstał dla społeczności i graczy: serwery, stale otwarte kanały głosowe, udostępnianie ekranu, rangi i emotki. Pirate's Cove idzie drogą Discorda.
Struktura serwera
Serwer zawiera kategorie, kanały, rangi, członków i emotki. Hierarchia od najszerszej: serwer (Black Pearl Guild), kategoria (TEXT CHANNELS), kanał (#general) i członek, który w nim pisze:
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';Kategorie grupują kanały tematycznie, na przykład TEXT CHANNELS i VOICE CHANNELS. Typ kanału to unia type: 'text' | 'voice' | 'announcement', więc TypeScript nie przepuści innej wartości. Pola userLimit i bitrate mają sens tylko w kanałach głosowych, dlatego są opcjonalne. W prawdziwym API Discorda kategoria to też rodzaj kanału, a kanał wskazuje ją polem parent_id; nasz model z osobnym typem Category jest prostszy do nauki.
Rangi i członkowie
Rangi decydują o kolorze nazwy, uprawnieniach i miejscu na liście członków:
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 określa kolor (hex) plakietki rangi, czyli badge na liście członków, a position ustala hierarchię: im wyższa liczba, tym wyżej ranga. isHoisted wyświetla członków danej rangi osobno na liście członków. Status użytkownika przyjmuje jedną z czterech wartości: online, idle, dnd (nie przeszkadzać) albo offline. Czterocyfrowy tag odczytasz przez user.discriminator; sam Discord w 2023 roku zaczął zastępować te tagi unikalnymi nazwami użytkowników, ale w naszym klonie zostawiamy klasyczny zapis.
getHighestRole działa w trzech krokach: filtruje rangi należące do członka (member.roles.includes(r.id)), sortuje je malejąco po position i zwraca pierwszą albo null, gdy członek nie ma żadnej rangi.
Pasek kanałów serwera
Lewy pasek pokazuje nazwę serwera, kategorie z kanałami i status zalogowanego pirata:
'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">[głosowy]</span>}
{channel.type === 'announcement' && <span className="text-gray-500">[ogłoszenia]</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">Mikrofon</button>
<button className="p-1.5 hover:bg-gray-700 rounded">Słuchawki</button>
<button className="p-1.5 hover:bg-gray-700 rounded">Ustawienia</button>
</div>
</div>
);
})()}
</div>
</div>
);
}Nazwę serwera wyświetla wyrażenie {server.name}. Zwinięte kategorie trzymamy w zbiorze Set: kliknięcie nagłówka tworzy kopię zbioru, dodaje albo usuwa id kategorii i zapisuje kopię w stanie. Kategorie i kanały sortujemy po polu position, a oznaczenie przed nazwą zależy od typu kanału.
Lista serwerów
Wąski pasek po lewej przełącza serwery:
'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">
Start
</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>
);
}Aktywny serwer ma zaokrąglony kwadrat zamiast koła i biały pasek przy krawędzi, a serwer bez ikony pokazuje pierwszą literę nazwy. Komponent nie trzyma własnego stanu, tylko zgłasza wybór przez onServerSelect.
Lista członków
Prawy pasek grupuje członków. Członek offline trafia do grupy OFFLINE, członek z najwyższą rangą oznaczoną isHoisted do grupy tej rangi, a reszta do 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>
);
}Map zapamiętuje kolejność dodawania, a grupy online i offline trafiają do niej jako pierwsze. W Discordzie lista wygląda inaczej: najpierw rangi hoisted (na przykład ADMIN), potem ONLINE, a OFFLINE na końcu. Wystarczy posortować grupy przed renderowaniem:
const groupOrder = (id: string) => (id === 'offline' ? 2 : id === 'online' ? 1 : 0);
const groups = Array.from(membersByRole.entries())
.sort(([a], [b]) => groupOrder(a) - groupOrder(b));W JSX mapujesz wtedy groups zamiast Array.from(membersByRole.entries()). Przydział członków do grup się nie zmienia, zmienia się tylko kolejność wyświetlania.
Rada Kapitana Redbearda: zanim poprosisz AI o kolejne widoki, wklej mu interfejsy Server, Channel, Role i Member. Dobrze nazwane typy są jak mapa: przy każdym nowym komponencie wiesz, gdzie rzucić kotwicę.
Zapamiętaj: serwer składa się z kategorii, kategorie z kanałów, a rangi decydują, kto i jak pojawia się na liście. W edytorze poniżej masz pasek kanałów gildii Black Pearl: zwiń kategorię i przełącz kanał, a potem rozwiąż zadania.
Kod do tej lekcji: App.jsx
// Pasek kanałów serwera z kategoriami
import { useState } from 'react';
const server = {
name: 'Gildia Black Pearl',
icon: '',
categories: [
{
id: 1,
name: 'KANAŁY TEKSTOWE',
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: 'KANAŁY GŁOSOWE',
channels: [
{ id: 4, name: 'Koordynacja bitwy', type: 'voice' },
{ id: 5, name: 'Mesa załogi', 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">
{/* Pasek serwera */}
<div className="w-60 bg-gray-800 flex flex-col">
{/* Nagłówek serwera */}
<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>
{/* Kanały */}
<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">
{/* Nagłówek kategorii */}
<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>
{/* Kanały */}
{!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">[głosowy]</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>
{/* Status głosowy */}
<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">Kapitan Redbeard</div>
<div className="text-gray-400 text-xs">Dostępny</div>
</div>
<div className="flex gap-1">
<button className="p-1.5 hover:bg-gray-700 rounded">Mikrofon</button>
<button className="p-1.5 hover:bg-gray-700 rounded">Słuchawki</button>
<button className="p-1.5 hover:bg-gray-700 rounded">Ustawienia</button>
</div>
</div>
</div>
</div>
{/* Główny obszar */}
<div className="flex-1 flex items-center justify-center text-gray-500">
Wybierz kanał, aby go wyświetlić
</div>
</div>
);
}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Co zawiera Server w Discord?
2. Jakie typy channels ma Discord?
To 2 z 7 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w pionie
Ułóż hierarchię struktury Server (od najszerszej):
- Edytor kodu
Discord server navigation
- Klikanie w kolejności
Klikaj, aby zbudować definicję typu pola channel.type:
- Edytor kodu
Server switcher sidebar
- Układanie w poziomie
Ułóż linię definicji typu pola: type: text | voice
- Układanie w pionie
Ułóż grupy na liście członków (MemberList) w kolejności wyświetlania:
- Układanie w poziomie
Ułóż dostęp do czterocyfrowego tagu użytkownika: user.discriminator
- Klikanie w kolejności
Klikaj, aby zbudować wyrażenie JSX wyświetlające nazwę serwera: