Kurs vibe coding z AI · Moduł 9: Discord Clone

Servers i Channels - Community struktura

9 min czytania
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. 1. Co zawiera Server w Discord?

  2. 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:

Przydatne artykuły