Kurs vibe coding z AI · Moduł 8: Slack Clone

Workspace i kanały - komunikacja zespołowa

7 min czytania
W tej lekcji6

Witaj! Na statku rozkazy krążą od ust do ust i połowa z nich ginie po drodze. Zbudujemy Ship's Comms, system łączności dla pirackich załóg na wzór Slacka: z rozmowami w kanałach, wiadomościami prywatnymi, wątkami i wzmiankami.

Czego się nauczysz

  • modelować workspace, członków, kanały i wiadomości,
  • odróżniać kanały publiczne od prywatnych,
  • budować pasek boczny z kanałami i statusami,
  • tworzyć nagłówek kanału.

Slack a inne platformy

Twitter i Instagram to publiczne media społecznościowe: nadajesz do obserwujących, a oni reagują, udostępniają i komentują. LinkedIn to profile zawodowe, oferty pracy i formalne posty. Slack to prywatny workspace zespołu, czyli przestrzeń robocza z kanałami dla projektów i tematów, czatem w czasie rzeczywistym, wątkami, plikami oraz wzmiankami z powiadomieniami.

Model workspace'u

Workspace to przestrzeń jednej załogi z własnym adresem w rodzaju blackpearl.slack.com. Opisują go cztery interfejsy:

interface Workspace {
  id: string;
  name: string; // "Black Pearl Crew"
  icon: string;
  url: string; // "blackpearl.slack.com"

  members: Member[];
  channels: Channel[];
  directMessages: DirectMessage[];

  createdBy: string;
  createdAt: Date;
}

interface Member {
  id: string;
  name: string;
  avatar: string;
  role: 'owner' | 'admin' | 'member' | 'guest';
  status: 'online' | 'away' | 'offline';
  statusText?: string; // "In a meeting"
  timezone: string;
}

interface Channel {
  id: string;
  name: string; // "general", "treasure-hunts"
  description: string;
  isPrivate: boolean;

  members: string[]; // member IDs
  messages: Message[];

  createdBy: string;
  createdAt: Date;
}

interface DirectMessage {
  id: string;
  participants: string[]; // 2 member IDs
  messages: Message[];
  lastReadAt: { [userId: string]: Date };
}

Workspace zawiera członków (members), kanały (channels) i wiadomości bezpośrednie (directMessages). Każdy Member ma rolę: owner, admin, member albo guest, od największych uprawnień do najmniejszych, oraz status online, away albo offline. Gość (guest) widzi tylko kanały, do których go dodano. Channel ma nazwę, opis, flagę isPrivate, listę członków i wiadomości. DirectMessage łączy dokładnie dwie osoby, więc participants to dwa identyfikatory członków. Slack ma też rozmowy grupowe do dziewięciu osób, ale nasz model obsługuje tylko rozmowy we dwoje. To wciąż sam opis danych, bez żadnego widoku.

Model wiadomości

Wiadomość niesie więcej niż sam tekst:

interface Message {
  id: string;
  channelId: string;
  authorId: string;

  content: string;
  mentions: string[]; // @user IDs

  attachments?: Attachment[];
  reactions: Reaction[];

  // Threading
  threadId?: string; // parent message ID
  replies: Message[];
  replyCount: number;

  isEdited: boolean;
  isPinned: boolean;

  timestamp: Date;
}

interface Attachment {
  id: string;
  type: 'image' | 'video' | 'file' | 'link';
  url: string;
  name: string;
  size: number;
  thumbnail?: string;
}

interface Reaction {
  emoji: string;
  users: string[]; // user IDs who reacted
  count: number;
}

Oprócz treści i autora (authorId) są tu wzmianki, załączniki, reakcje i dane wątku: threadId wskazuje wiadomość nadrzędną, a replyCount liczy odpowiedzi. Reakcja to emoji, lista użytkowników i licznik. Te pola wykorzystasz w kolejnych lekcjach, na razie tylko opisują dane.

Pasek boczny

Pasek boczny to mapa statku. Od góry: nagłówek workspace'u z nazwą i liczbą osób online, szybkie linki (Threads, All DMs), lista kanałów, wiadomości prywatne, a na dole profil użytkownika.

'use client';

import { useState } from 'react';

interface SidebarProps {
  workspace: Workspace;
  currentUserId: string;
  onChannelSelect: (channelId: string) => void;
  onDMSelect: (dmId: string) => void;
}

export default function Sidebar({
  workspace,
  currentUserId,
  onChannelSelect,
  onDMSelect
}: SidebarProps) {
  const [showAllChannels, setShowAllChannels] = useState(false);

  const unreadDMs = workspace.directMessages.filter(dm => {
    const lastRead = dm.lastReadAt[currentUserId];
    const lastMessage = dm.messages[dm.messages.length - 1];
    return lastMessage && (!lastRead || lastMessage.timestamp > lastRead);
  });

  return (
    <div className="w-64 bg-purple-900 text-white flex flex-col h-screen">
      {/* Workspace Header */}
      <div className="p-4 border-b border-purple-800">
        <div className="flex items-center gap-2 mb-2">
          <span className="text-2xl">{workspace.icon}</span>
          <h2 className="font-bold text-lg">{workspace.name}</h2>
        </div>
        <div className="flex items-center gap-2 text-sm opacity-80">
          <div className="w-2 h-2 bg-green-400 rounded-full"></div>
          <span>{workspace.members.filter(m => m.status === 'online').length} online</span>
        </div>
      </div>

      {/* Navigation */}
      <div className="flex-1 overflow-y-auto p-2">
        {/* Quick Links */}
        <div className="mb-4">
          <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
            <span>Komentarz</span>
            <span>Threads</span>
          </button>
          <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
            <span>Wiadomość</span>
            <span>All DMs</span>
            {unreadDMs.length > 0 && (
              <span className="ml-auto bg-red-500 text-xs px-2 py-0.5 rounded-full">
                {unreadDMs.length}
              </span>
            )}
          </button>
          <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
            <span>Zapisane</span>
            <span>Saved items</span>
          </button>
        </div>

        {/* Channels */}
        <div className="mb-4">
          <button
            onClick={() => setShowAllChannels(!showAllChannels)}
            className="w-full text-left px-3 py-1 text-sm font-semibold opacity-80 hover:opacity-100 flex items-center gap-1"
          >
            <span className={`transform transition ${showAllChannels ? 'rotate-90' : ''}`}>▶</span>
            Channels
          </button>
          {showAllChannels && (
            <div className="mt-1">
              {workspace.channels.map(channel => (
                <button
                  key={channel.id}
                  onClick={() => onChannelSelect(channel.id)}
                  className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2"
                >
                  <span>{channel.isPrivate ? '[prywatny]' : '#'}</span>
                  <span>{channel.name}</span>
                </button>
              ))}
              <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded text-sm opacity-80">
                + Add channels
              </button>
            </div>
          )}
        </div>

        {/* Direct Messages */}
        <div>
          <div className="px-3 py-1 text-sm font-semibold opacity-80">
            Direct Messages
          </div>
          <div className="mt-1">
            {workspace.directMessages.slice(0, 5).map(dm => {
              const otherUser = workspace.members.find(
                m => dm.participants.includes(m.id) && m.id !== currentUserId
              );
              if (!otherUser) return null;

              return (
                <button
                  key={dm.id}
                  onClick={() => onDMSelect(dm.id)}
                  className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2"
                >
                  <div className="relative">
                    <img
                      src={otherUser.avatar}
                      alt={otherUser.name}
                      className="w-6 h-6 rounded"
                    />
                    <div
                      className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border border-purple-900 ${
                        otherUser.status === 'online' ? 'bg-green-400' :
                        otherUser.status === 'away' ? 'bg-yellow-400' : 'bg-gray-400'
                      }`}
                    />
                  </div>
                  <span>{otherUser.name}</span>
                </button>
              );
            })}
          </div>
        </div>
      </div>

      {/* User Profile */}
      <div className="p-3 border-t border-purple-800">
        {(() => {
          const currentUser = workspace.members.find(m => m.id === currentUserId);
          if (!currentUser) return null;

          return (
            <div className="flex items-center gap-2">
              <div className="relative">
                <img
                  src={currentUser.avatar}
                  alt={currentUser.name}
                  className="w-8 h-8 rounded"
                />
                <div className="absolute bottom-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-purple-900"></div>
              </div>
              <div className="flex-1 min-w-0">
                <div className="font-semibold text-sm truncate">{currentUser.name}</div>
                <div className="text-xs opacity-80 truncate">
                  {currentUser.statusText || 'Active'}
                </div>
              </div>
              <button className="hover:bg-purple-800 p-1 rounded">Ustawienia</button>
            </div>
          );
        })()}
      </div>
    </div>
  );
}

Kanał publiczny ma przed nazwą znak #, więc treasure-hunts wyświetla się jako „# treasure-hunts” i każdy członek workspace'u może go znaleźć i do niego dołączyć. Prywatny zamiast # dostaje ikonę kłódki i widzą go tylko zaproszeni członkowie. Kolory statusów wybraliśmy sami: zielona kropka przy awatarze to online, żółta to away, a szara to offline. Nagłówek liczy osoby online przez filter(m => m.status === 'online').length, a stopka pokazuje statusText bieżącego użytkownika albo napis Active. unreadDMs wybiera rozmowy z wiadomościami nowszymi niż ostatnia wizyta (lastReadAt), a ich liczba trafia na czerwoną plakietkę. Komponent tylko czyta dane i niczego w workspace'ie nie zmienia.

Nagłówek kanału

Nad rozmową wisi nagłówek, jak tabliczka na drzwiach pomieszczenia:

interface ChannelHeaderProps {
  channel: Channel;
  memberCount: number;
  onAddPeople: () => void;
  onShowDetails: () => void;
}

export default function ChannelHeader({
  channel,
  memberCount,
  onAddPeople,
  onShowDetails
}: ChannelHeaderProps) {
  return (
    <div className="border-b bg-white px-6 py-3">
      <div className="flex items-center justify-between">
        <div className="flex-1">
          <div className="flex items-center gap-2 mb-1">
            <h2 className="text-xl font-bold">
              {channel.isPrivate ? '[prywatny]' : '#'} {channel.name}
            </h2>
            <button className="hover:bg-gray-100 p-1 rounded">Ulubione</button>
          </div>
          <p className="text-sm text-gray-600">
            {channel.description || 'No description'} • {memberCount} members
          </p>
        </div>

        <div className="flex items-center gap-2">
          <button
            onClick={onAddPeople}
            className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-50 text-sm font-semibold"
          >
            + Add people
          </button>
          <button
            onClick={onShowDetails}
            className="p-2 hover:bg-gray-100 rounded"
          >
            ℹ
          </button>
        </div>
      </div>
    </div>
  );
}

Nagłówek pokazuje nazwę z # albo kłódką, opis i liczbę członków. Gdy kanał nie ma opisu, w jego miejscu pojawia się No description. Przycisk + Add people nie dodaje nikogo sam, tylko wywołuje onAddPeople, a decyzję podejmuje rodzic.

Rada Kapitana Redbearda: nazywaj kanały małymi literami i myślnikami, np. treasure-hunts albo general. Krótka, czytelna nazwa to połowa porządku na pokładzie.

Zapamiętaj: workspace to statek, kanały to pomieszczenia, a # albo kłódka mówią, kto może wejść. W edytorze poniżej masz gotowy pasek boczny, pobaw się statusami i przejdź do zadań.

Kod do tej lekcji: App.jsx
// Nawigacja w pasku bocznym workspace'u
import { useState } from 'react';

const workspace = {
  name: 'Załoga Czarnej Perły',
  icon: '',
  onlineCount: 12,
  channels: [
    { id: 1, name: 'general', isPrivate: false },
    { id: 2, name: 'treasure-hunts', isPrivate: false },
    { id: 3, name: 'ship-repairs', isPrivate: false },
    { id: 4, name: 'secret-plans', isPrivate: true }
  ],
  directMessages: [
    { id: 1, name: 'Jack Sparrow', avatar: '', status: 'online', unread: 2 },
    { id: 2, name: 'Will Turner', avatar: '', status: 'away', unread: 0 },
    { id: 3, name: 'Elizabeth Swan', avatar: '', status: 'offline', unread: 0 }
  ]
};

export default function App() {
  const [showChannels, setShowChannels] = useState(true);
  const [activeChannel, setActiveChannel] = useState(null);
  const [activeDM, setActiveDM] = useState(null);

  return (
    <div className="flex h-screen bg-gray-100">
      {/* Pasek boczny */}
      <div className="w-64 bg-purple-900 text-white flex flex-col">
        {/* Nagłówek workspace'u */}
        <div className="p-4 border-b border-purple-800">
          <div className="flex items-center gap-2 mb-2">
            <span className="text-2xl">{workspace.icon}</span>
            <h2 className="font-bold text-lg">{workspace.name}</h2>
          </div>
          <div className="flex items-center gap-2 text-sm opacity-80">
            <div className="w-2 h-2 bg-green-400 rounded-full"></div>
            <span>{workspace.onlineCount} osób online</span>
          </div>
        </div>

        {/* Nawigacja */}
        <div className="flex-1 overflow-y-auto p-2">
          {/* Szybkie linki */}
          <div className="mb-4">
            <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
              <span>Komentarz</span>
              <span>Wątki</span>
            </button>
            <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
              <span>Wiadomość</span>
              <span>Prywatne</span>
              <span className="ml-auto bg-red-500 text-xs px-2 py-0.5 rounded-full">2</span>
            </button>
            <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
              <span>Zapisane</span>
              <span>Zapisane elementy</span>
            </button>
          </div>

          {/* Kanały */}
          <div className="mb-4">
            <button
              onClick={() => setShowChannels(!showChannels)}
              className="w-full text-left px-3 py-1 text-sm font-semibold opacity-80 hover:opacity-100 flex items-center gap-1"
            >
              <span className={`transform transition ${showChannels ? 'rotate-90' : ''}`}>▶</span>
              Kanały
            </button>
            {showChannels && (
              <div className="mt-1">
                {workspace.channels.map(channel => (
                  <button
                    key={channel.id}
                    onClick={() => setActiveChannel(channel.id)}
                    className={`w-full text-left px-3 py-1.5 rounded flex items-center gap-2 ${
                      activeChannel === channel.id ? 'bg-blue-600' : 'hover:bg-purple-800'
                    }`}
                  >
                    <span>{channel.isPrivate ? '[prywatny]' : '#'}</span>
                    <span>{channel.name}</span>
                  </button>
                ))}
                <button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded text-sm opacity-80">
                  + Dodaj kanały
                </button>
              </div>
            )}
          </div>

          {/* Wiadomości bezpośrednie */}
          <div>
            <div className="px-3 py-1 text-sm font-semibold opacity-80">
              Wiadomości bezpośrednie
            </div>
            <div className="mt-1">
              {workspace.directMessages.map(dm => (
                <button
                  key={dm.id}
                  onClick={() => setActiveDM(dm.id)}
                  className={`w-full text-left px-3 py-1.5 rounded flex items-center gap-2 ${
                    activeDM === dm.id ? 'bg-blue-600' : 'hover:bg-purple-800'
                  }`}
                >
                  <div className="relative text-xl">
                    {dm.avatar}
                    <div
                      className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border border-purple-900 ${
                        dm.status === 'online' ? 'bg-green-400' :
                        dm.status === 'away' ? 'bg-yellow-400' : 'bg-gray-400'
                      }`}
                    />
                  </div>
                  <span className="flex-1">{dm.name}</span>
                  {dm.unread > 0 && (
                    <span className="bg-red-500 text-xs px-1.5 py-0.5 rounded-full">
                      {dm.unread}
                    </span>
                  )}
                </button>
              ))}
            </div>
          </div>
        </div>

        {/* Profil użytkownika */}
        <div className="p-3 border-t border-purple-800">
          <div className="flex items-center gap-2">
            <div className="text-2xl">◆</div>
            <div className="flex-1">
              <div className="font-semibold text-sm">Kapitan Redbeard</div>
              <div className="text-xs opacity-80">Aktywny</div>
            </div>
            <button className="hover:bg-purple-800 p-1 rounded">Ustawienia</button>
          </div>
        </div>
      </div>

      {/* Główny obszar */}
      <div className="flex-1 flex items-center justify-center text-gray-500">
        Wybierz kanał albo rozmowę prywatną, aby zacząć pisać
      </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 Workspace w Slack?

  2. 2. Różnica między public a private channel?

To 2 z 7 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Slack workspace navigation

  • Klikanie w kolejności

    Zbuduj odwołanie do publicznego kanału treasure-hunts (klikaj tokeny):

  • Klikanie w kolejności

    Zbuduj nazwę prywatnego kanału tak, jak wyświetla ją pasek boczny (kliknij tylko potrzebne elementy):

  • Układanie w pionie

    Uporządkuj role workspace od najwyższych uprawnień do najniższych:

  • Edytor kodu

    Channel header component

  • Układanie w poziomie

    Ułóż adres URL workspace blackpearl.slack.com (jedna linia, lewo-prawo):

  • Układanie w pionie

    Ułóż sekcje Sidebara w kolejności od góry do dołu:

  • Klikanie w kolejności

    Zbuduj wyświetlaną nazwę publicznego kanału klikając tokeny:

Przydatne artykuły