Kurs vibe coding z AI · Moduł 8: Slack Clone
Workspace i kanały - komunikacja zespołowa
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. Co zawiera Workspace w Slack?
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: