Kurs vibe coding z AI · Moduł 7: LinkedIn Clone
Feed zawodowy i sieć kontaktów - aktualizacje zawodowe
W tej lekcji4
Na Twitterze ludzie piszą o śniadaniu, a na LinkedInie o awansie na pierwszego oficera. Feed zawodowy działa jak każdy inny, ale treści dotyczą pracy: nowych stanowisk, rocznic zatrudnienia, artykułów i branżowych wniosków. To różnica tonu, nie mechaniki. LinkedIn to treści zawodowe, oferty pracy i CV, a Twitter to sprawy osobiste i luźne rozmowy.
Typy postów
Post może być zwykłym tekstem albo czymś specjalnym, np. ogłoszeniem zmiany pracy. Typ unii wylicza wszystkie możliwości:
type PostType = 'text' | 'article' | 'image' | 'video' | 'job_change' | 'work_anniversary';
interface FeedPost {
id: string;
type: PostType;
author: {
id: string;
name: string;
avatar: string;
headline: string;
};
content: string;
media?: {
type: 'image' | 'video' | 'document';
url: string;
thumbnail?: string;
};
likes: number;
comments: number;
reposts: number;
timestamp: Date;
// Special fields for job change posts
jobChange?: {
previousCompany: string;
newCompany: string;
newPosition: string;
};
}type PostType = 'text' | 'article' | ... to typ unii: wartość tego typu może być tylko jedną z sześciu nazw, od text, article, image i video po job_change i work_anniversary. Pole jobChange jest opcjonalne i ma sens tylko w postach typu job_change. Sam typ niczego nie wyświetla, ale chroni przed literówką.
Karta posta
FeedPostCard ma nagłówek autora, treść, statystyki i rząd akcji:
'use client';
import { useState } from 'react';
function FeedPostCard({ post }: { post: FeedPost }) {
const [liked, setLiked] = useState(false);
const [showComments, setShowComments] = useState(false);
return (
<div className="bg-white rounded-lg shadow mb-3">
{/* Header */}
<div className="flex items-start gap-3 p-6 pb-3">
<img
src={post.author.avatar}
alt={post.author.name}
className="w-12 h-12 rounded-full"
/>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h3 className="font-bold hover:underline cursor-pointer">
{post.author.name}
</h3>
<p className="text-sm text-gray-600">{post.author.headline}</p>
<p className="text-xs text-gray-500 mt-1">
{formatTimeAgo(post.timestamp)} •
</p>
</div>
<button className="text-gray-500 hover:bg-gray-100 rounded-full p-2">
•••
</button>
</div>
</div>
</div>
{/* Content */}
<div className="px-6 pb-3">
{/* Job Change Celebration */}
{post.type === 'job_change' && post.jobChange && (
<div className="mb-3 bg-gradient-to-r from-blue-50 to-green-50 border border-blue-200 rounded-lg p-4">
<p className="font-semibold mb-2">Started a new position</p>
<p className="text-sm text-gray-700">
{post.jobChange.newPosition} at {post.jobChange.newCompany}
</p>
</div>
)}
{/* Text Content */}
<p className="text-gray-900 whitespace-pre-wrap mb-3">{post.content}</p>
{/* Media */}
{post.media && post.media.type === 'image' && (
<img
src={post.media.url}
alt="Post media"
className="w-full rounded-lg"
/>
)}
</div>
{/* Stats */}
<div className="px-6 py-2 border-t border-b flex items-center justify-between text-sm text-gray-600">
<button className="hover:underline">
{post.likes} likes
</button>
<div className="flex gap-3">
<button className="hover:underline">
{post.comments} comments
</button>
<button className="hover:underline">
{post.reposts} reposts
</button>
</div>
</div>
{/* Actions */}
<div className="px-6 py-2 flex items-center justify-around">
<button
onClick={() => setLiked(!liked)}
className={`flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 transition ${
liked ? 'text-blue-600 font-semibold' : 'text-gray-600'
}`}
>
<span className="text-xl">{liked ? 'Polubione' : 'Lubię to'}</span>
Like
</button>
<button
onClick={() => setShowComments(!showComments)}
className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600"
>
<span className="text-xl">Komentarz</span>
Comment
</button>
<button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
<span className="text-xl"></span>
Repost
</button>
<button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
<span className="text-xl">Udostępnij</span>
Send
</button>
</div>
{/* Comment Input */}
{showComments && (
<div className="px-6 pb-4 border-t pt-4">
<div className="flex gap-3">
<img
src="/current-user-avatar.jpg"
alt="You"
className="w-10 h-10 rounded-full"
/>
<input
type="text"
className="flex-1 px-4 py-2 border rounded-full hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
)}
</div>
);
}Nagłówek pokazuje awatar, imię (author.name), headline autora i czas publikacji z formatTimeAgo(timestamp). Gdy post.type === 'job_change', nad treścią pojawia się baner gratulacyjny z nowym stanowiskiem i firmą (newPosition, newCompany). Statystyki układają się w wiersz likes • comments • reposts, a pod nimi stoją akcje Like, Comment, Repost i Send. Kliknięcie Like wywołuje setLiked(!liked), a przycisk dostaje klasę text-blue-600. Licznik post.likes się przy tym nie zmienia, prawdziwą liczbę policzy serwer. Repost to udostępnienie cudzego posta dalej, w Twojej sieci, a Send wysyła go w prywatnej wiadomości. Klasa whitespace-pre-wrap zachowuje akapity z treści posta. Z mediów karta wyświetla na razie tylko obrazki (post.media.type === 'image'), film i dokument czekają na swoją kolej.
Tworzenie posta
Na górze feedu, tak jak w LinkedInie, stoi pole Start a post. Jego kliknięcie otwiera okno tworzenia posta:
<button onClick={() => setShowCreatePost(true)}>Start a post</button>
{showCreatePost && (
<CreatePostModal onClose={() => setShowCreatePost(false)} onSubmit={addPost} />
)}Stan showCreatePost i funkcja addPost należą do strony feedu, a okno dostaje je przez propsy. Samo okno wygląda tak:
'use client';
import { useState } from 'react';
export default function CreatePostModal({ onClose, onSubmit }: {
onClose: () => void;
onSubmit: (content: string) => void;
}) {
const [content, setContent] = useState('');
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg max-w-2xl w-full">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-xl font-bold">Create a post</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-2xl"
>
×
</button>
</div>
<div className="p-4">
<div className="flex gap-3 mb-4">
<img
src="/current-user-avatar.jpg"
alt="You"
className="w-12 h-12 rounded-full"
/>
<div>
<h3 className="font-bold">Your Name</h3>
<button className="text-sm text-gray-600 hover:underline">
Post to anyone
</button>
</div>
</div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full p-3 text-lg resize-none outline-none"
rows={8}
autoFocus
/>
<div className="flex items-center gap-2 text-gray-500 mt-4">
<button className="p-2 hover:bg-gray-100 rounded text-xl">Zdjęcie</button>
<button className="p-2 hover:bg-gray-100 rounded text-xl">Wideo</button>
<button className="p-2 hover:bg-gray-100 rounded text-xl">Dokument</button>
<button className="p-2 hover:bg-gray-100 rounded text-xl">Ankieta</button>
</div>
</div>
<div className="p-4 border-t flex justify-end">
<button
onClick={() => {
onSubmit(content);
onClose();
}}
disabled={!content.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-full font-bold hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
Post
</button>
</div>
</div>
</div>
);
}Wpisywanie wywołuje setContent w onChange, a przycisk Post jest aktywny dopiero wtedy, gdy content.trim() nie jest pusty, więc same spacje nie przejdą. Kliknięcie Post wywołuje onSubmit(content), a potem onClose() zamyka okno. Rząd przycisków pod polem tekstowym to miejsce na opcjonalne media: zdjęcie, film czy dokument.
Sieć kontaktów
Ostatnia strona to lista Twoich kontaktów:
interface Connection {
id: string;
name: string;
avatar: string;
headline: string;
mutualConnections: number;
connectedDate: Date;
}
function ConnectionCard({ connection }: { connection: Connection }) {
return (
<div className="bg-white border rounded-lg p-4 hover:shadow-lg transition">
<div className="flex items-start gap-3">
<img
src={connection.avatar}
alt={connection.name}
className="w-16 h-16 rounded-full"
/>
<div className="flex-1">
<h3 className="font-bold hover:underline cursor-pointer mb-1">
{connection.name}
</h3>
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
{connection.headline}
</p>
<p className="text-xs text-gray-500">
{connection.mutualConnections} mutual connections
</p>
</div>
</div>
<div className="flex gap-2 mt-4">
<button className="flex-1 py-2 border-2 border-blue-600 text-blue-600 rounded-full font-semibold hover:bg-blue-50">
Message
</button>
<button className="px-4 py-2 border border-gray-300 rounded-full hover:bg-gray-50">
•••
</button>
</div>
</div>
);
}
export default function ConnectionsPage({ connections }: { connections: Connection[] }) {
return (
<div className="max-w-6xl mx-auto p-4">
<div className="bg-white rounded-lg shadow p-6 mb-4">
<h1 className="text-2xl font-bold mb-4">
{connections.length} Connections
</h1>
<input
type="text"
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{connections.map(conn => (
<ConnectionCard key={conn.id} connection={conn} />
))}
</div>
</div>
);
}Karta kontaktu ma od góry awatar, imię i headline, a pod spodem liczbę wspólnych kontaktów i przycisk Message. mutualConnections mówi, ilu znajomych macie wspólnych, co pomaga ocenić, czy kogoś znasz. Pole wyszukiwania nie ma jeszcze obsługi, dodasz ją przez filter na tablicy connections.
Rada Kapitana Redbearda: zanim dodasz siódmy typ posta, zapytaj, czy naprawdę potrzebujesz nowego wyglądu. Każda wartość w unii to osobna gałąź w karcie, którą trzeba utrzymać.
Zapamiętaj: feed zawodowy to te same posty i reakcje, tylko o pracy, a sieć kontaktów pokazuje, kogo znacie wspólnie. W edytorze poniżej masz feed zawodowy z kartami postów, polubieniami i komentarzami, a zadania sprawdzą typy postów, akcje i kontakty.
Kod do tej lekcji: App.jsx
// Post w feedzie zawodowym o zmianie pracy
import { useState } from 'react';
const posts = [
{
id: 1,
type: 'job_change',
author: {
name: 'Jack Sparrow',
avatar: '',
headline: 'Nawigator na Czarnej Perle'
},
content: `Z radością ogłaszam, że dołączyłem do załogi Czarnej Perły jako pierwszy oficer! Nie mogę się doczekać nowych przygód na siedmiu morzach.`,
jobChange: {
previousCompany: 'Latający Holender',
newCompany: 'Załoga Czarnej Perły',
newPosition: 'Pierwszy oficer'
},
likes: 142,
comments: 23,
reposts: 8,
timestamp: '2 godz. temu'
},
{
id: 2,
type: 'text',
author: {
name: 'Elizabeth Swan',
avatar: '',
headline: "Kapitan na Zemście Królowej Anny"
},
content: `Właśnie zakończyłam swoją pierwszą udaną wyprawę po skarb jako kapitan. Najważniejsze wnioski:
1. Ufaj swojej załodze
2. Zawsze sprawdzaj mapę
3. Nigdy nie lekceważ sztormu
#Przywództwo #PoszukiwanieSkarbów #ŻyciePirata`,
likes: 89,
comments: 12,
reposts: 5,
timestamp: '5 godz. temu'
}
];
function FeedPostCard({ post }) {
const [liked, setLiked] = useState(false);
const [showComments, setShowComments] = useState(false);
return (
<div className="bg-white rounded-lg shadow mb-3">
{/* Nagłówek */}
<div className="flex items-start gap-3 p-6 pb-3">
<div className="text-4xl">{post.author.avatar}</div>
<div className="flex-1">
<h3 className="font-bold hover:underline cursor-pointer">{post.author.name}</h3>
<p className="text-sm text-gray-600">{post.author.headline}</p>
<p className="text-xs text-gray-500 mt-1">{post.timestamp} •</p>
</div>
<button className="text-gray-500 hover:bg-gray-100 rounded-full p-2">•••</button>
</div>
{/* Treść */}
<div className="px-6 pb-3">
{/* Plakietka zmiany pracy */}
{post.type === 'job_change' && post.jobChange && (
<div className="mb-3 bg-gradient-to-r from-blue-50 to-green-50 border border-blue-200 rounded-lg p-4">
<p className="font-semibold mb-2">Nowe stanowisko</p>
<p className="text-sm text-gray-700">
{post.jobChange.newPosition}, {post.jobChange.newCompany}
</p>
</div>
)}
<p className="text-gray-900 whitespace-pre-wrap">{post.content}</p>
</div>
{/* Statystyki */}
<div className="px-6 py-2 border-t border-b flex items-center justify-between text-sm text-gray-600">
<button className="hover:underline">Reakcje: {post.likes + (liked ? 1 : 0)}</button>
<div className="flex gap-3">
<button className="hover:underline">Komentarze: {post.comments}</button>
<button className="hover:underline">Udostępnienia: {post.reposts}</button>
</div>
</div>
{/* Akcje */}
<div className="px-6 py-2 flex items-center justify-around">
<button
onClick={() => setLiked(!liked)}
className={`flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 transition ${
liked ? 'text-blue-600 font-semibold' : 'text-gray-600'
}`}
>
Lubię to
</button>
<button
onClick={() => setShowComments(!showComments)}
className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600"
>
Komentuj
</button>
<button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
Podaj dalej
</button>
<button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
Wyślij
</button>
</div>
{showComments && (
<div className="px-6 pb-4 border-t pt-4">
<input
type="text"
placeholder="Dodaj komentarz..."
className="w-full px-4 py-2 border rounded-full hover:bg-gray-50"
/>
</div>
)}
</div>
);
}
export default function App() {
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-6">Feed zawodowy</h1>
{posts.map(post => (
<FeedPostCard key={post.id} post={post} />
))}
</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. Które post types ma LinkedIn feed?
2. Co specjalnego renderuje FeedPostCard, gdy type === 'job_change'?
To 2 z 7 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż w jednej linii przyciski akcji FeedPostCard (od lewej do prawej):
- Edytor kodu
Professional feed post
- Układanie w poziomie
Ułóż kroki tworzenia professional post:
- Układanie w pionie
Uporządkuj kroki polubienia posta w FeedPostCard:
- Układanie w pionie
Uporządkuj kroki publikacji posta w CreatePostModal:
- Układanie w poziomie
Ułóż w jednej linii statystyki posta z FeedPostCard: likes • comments • reposts
- Edytor kodu
Professional network page
- Klikanie w kolejności
Ułóż początek definicji typu unii: type PostType = 'text' | 'article'
- Klikanie w kolejności
Ułóż wywołanie przełączające polubienie posta: setLiked(!liked)
- Układanie w poziomie
Ułóż w jednej linii meta nagłówka posta z FeedPostCard: author.name • author.headline • timestamp
- Układanie w pionie
Uporządkuj elementy ConnectionCard od góry do dołu: