Kurs vibe coding z AI · Moduł 7: LinkedIn Clone

Feed zawodowy i sieć kontaktów - aktualizacje zawodowe

6 min czytania
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. 1. Które post types ma LinkedIn feed?

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

Przydatne artykuły