Kurs vibe coding z AI · Moduł 12: Medium Clone

Publications i Content Curation

5 min czytania
W tej lekcji4

Medium ma publications - multi-author blogs z editorial teams.

Publication Structure

interface Publication {
  id: string;
  name: string;
  slug: string;
  description: string;
  logo: string;
  banner?: string;

  // Team
  editors: Editor[];
  writers: Writer[];
  followers: number;

  // Content
  articles: Article[];
  topics: string[];

  // Settings
  isAcceptingSubmissions: boolean;
  submissionGuidelines?: string;

  createdAt: Date;
}

interface Editor {
  userId: string;
  role: 'owner' | 'editor' | 'writer';
  permissions: {
    canPublish: boolean;
    canEdit: boolean;
    canInvite: boolean;
    canManageSettings: boolean;
  };
  joinedAt: Date;
}

interface SubmissionRequest {
  id: string;
  articleId: string;
  publicationId: string;
  author: Author;
  status: 'pending' | 'approved' | 'rejected';
  note?: string;
  submittedAt: Date;
  reviewedAt?: Date;
  reviewedBy?: string;
}

Publication Page

'use client';

interface PublicationPageProps {
  publication: Publication;
  articles: Article[];
  isFollowing: boolean;
}

export default function PublicationPage({
  publication,
  articles,
  isFollowing
}: PublicationPageProps) {
  const [activeTab, setActiveTab] = useState<'latest' | 'top' | 'about'>('latest');

  return (
    <div className="min-h-screen bg-white">
      {/* Header */}
      <div className="border-b border-gray-200">
        <div className="max-w-5xl mx-auto px-8 py-12">
          <div className="flex items-start gap-6">
            <img
              src={publication.logo}
              alt={publication.name}
              className="w-24 h-24 rounded"
            />
            <div className="flex-1">
              <h1 className="text-4xl font-bold mb-2">{publication.name}</h1>
              <p className="text-xl text-gray-600 mb-4">
                {publication.description}
              </p>
              <div className="flex items-center gap-4 text-sm text-gray-500">
                <span>{publication.followers.toLocaleString()} Followers</span>
                <span>•</span>
                <span>{publication.articles.length} Stories</span>
              </div>
            </div>
            <button
              className={`
                px-6 py-2 rounded-full font-semibold
                ${isFollowing
                  ? 'border border-green-600 text-green-600 hover:bg-green-50'
                  : 'bg-green-600 text-white hover:bg-green-700'
                }
              `}
            >
              {isFollowing ? 'Following' : 'Follow'}
            </button>
          </div>
        </div>

        {/* Navigation */}
        <div className="max-w-5xl mx-auto px-8">
          <div className="flex gap-8 border-b border-gray-200">
            {[
              { key: 'latest', label: 'Latest' },
              { key: 'top', label: 'Top' },
              { key: 'about', label: 'About' }
            ].map(tab => (
              <button
                key={tab.key}
                onClick={() => setActiveTab(tab.key as any)}
                className={`
                  pb-3 font-semibold
                  ${activeTab === tab.key
                    ? 'border-b-2 border-black'
                    : 'text-gray-500 hover:text-black'
                  }
                `}
              >
                {tab.label}
              </button>
            ))}
          </div>
        </div>
      </div>

      {/* Content */}
      <div className="max-w-5xl mx-auto px-8 py-8">
        {activeTab === 'latest' && (
          <div className="space-y-8">
            {articles.map(article => (
              <ArticlePreview key={article.id} article={article} />
            ))}
          </div>
        )}

        {activeTab === 'about' && (
          <div className="max-w-2xl">
            <h2 className="text-2xl font-bold mb-4">About</h2>
            <p className="text-lg text-gray-700 leading-relaxed mb-8">
              {publication.description}
            </p>

            <h3 className="text-xl font-bold mb-4">Editors</h3>
            <div className="grid grid-cols-2 gap-4">
              {publication.editors.map(editor => (
                <EditorCard key={editor.userId} editor={editor} />
              ))}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

function ArticlePreview({ article }: { article: Article }) {
  return (
    <div className="flex gap-6 pb-8 border-b border-gray-200">
      <div className="flex-1">
        <div className="flex items-center gap-2 mb-3">
          <img
            src={article.author.avatar}
            alt={article.author.name}
            className="w-6 h-6 rounded-full"
          />
          <span className="text-sm font-semibold">{article.author.name}</span>
        </div>

        <h2 className="text-2xl font-bold mb-2 hover:underline cursor-pointer">
          {article.title}
        </h2>
        {article.subtitle && (
          <p className="text-gray-600 mb-3">{article.subtitle}</p>
        )}

        <div className="flex items-center gap-3 text-sm text-gray-500">
          <span>{new Date(article.publishedAt!).toLocaleDateString()}</span>
          <span>•</span>
          <span>{article.readingTime} min read</span>
          <span>•</span>
          <span>{article.claps} claps</span>
        </div>
      </div>

      {article.coverImage && (
        <img
          src={article.coverImage}
          alt={article.title}
          className="w-40 h-40 object-cover"
        />
      )}
    </div>
  );
}

Topics and Discovery

interface Topic {
  id: string;
  name: string;
  slug: string;
  description: string;
  followers: number;
  relatedTopics: string[];
}

// Recommendation engine
function getRecommendedArticles(
  userId: string,
  readingHistory: Article[],
  followedTopics: Topic[],
  followedAuthors: Author[]
): Article[] {
  // Algorithm:
  // 1. Analyze user's reading history for topics
  // 2. Find articles from followed topics
  // 3. Include articles from followed authors
  // 4. Add trending articles in related topics
  // 5. Weight by recency and engagement

  const recommendations: Article[] = [];

  // ... implementation

  return recommendations
    .sort((a, b) => b.claps - a.claps)
    .slice(0, 20);
}

// Home feed
'use client';

export default function HomeFeed() {
  const [activeTab, setActiveTab] = useState<'for-you' | 'following'>('for-you');
  const [articles, setArticles] = useState<Article[]>([]);

  useEffect(() => {
    if (activeTab === 'for-you') {
      loadRecommendations();
    } else {
      loadFollowingFeed();
    }
  }, [activeTab]);

  return (
    <div className="min-h-screen bg-white">
      <div className="max-w-6xl mx-auto px-8 py-8">
        <div className="flex gap-12">
          {/* Main Feed */}
          <div className="flex-1">
            {/* Tabs */}
            <div className="flex gap-8 mb-8 border-b border-gray-200">
              <button
                onClick={() => setActiveTab('for-you')}
                className={`
                  pb-3 font-semibold
                  ${activeTab === 'for-you'
                    ? 'border-b-2 border-black'
                    : 'text-gray-500'
                  }
                `}
              >
                For you
              </button>
              <button
                onClick={() => setActiveTab('following')}
                className={`
                  pb-3 font-semibold
                  ${activeTab === 'following'
                    ? 'border-b-2 border-black'
                    : 'text-gray-500'
                  }
                `}
              >
                Following
              </button>
            </div>

            {/* Articles */}
            <div className="space-y-8">
              {articles.map(article => (
                <ArticleCard key={article.id} article={article} />
              ))}
            </div>
          </div>

          {/* Sidebar */}
          <div className="w-80">
            <DiscoverySidebar />
          </div>
        </div>
      </div>
    </div>
  );
}

function DiscoverySidebar() {
  return (
    <div className="space-y-8">
      {/* Topics */}
      <div>
        <h3 className="font-bold mb-4">Discover more of what matters to you</h3>
        <div className="flex flex-wrap gap-2">
          {['Programming', 'Design', 'Data Science', 'Self Improvement', 'Writing', 'Technology'].map(topic => (
            <button
              key={topic}
              className="px-4 py-2 bg-gray-100 rounded-full text-sm hover:bg-gray-200"
            >
              {topic}
            </button>
          ))}
        </div>
      </div>

      {/* Trending */}
      <div>
        <h3 className="font-bold mb-4">Trending on Captain's Chronicle</h3>
        <div className="space-y-4">
          {[1, 2, 3, 4, 5].map(i => (
            <TrendingArticle key={i} rank={i} />
          ))}
        </div>
      </div>
    </div>
  );
}

Podsumowanie

Publications - multi-author blogs Editorial team - editors, writers, permissions Submission system - writers submit to publications Publication page - logo, description, articles Topics - categorize content Recommendations - personalized feed (For You) Following feed - from followed authors/publications Trending sidebar - discover popular content Topic following - follow topics of interest

Masz teraz kompletny Medium clone z editor, claps, highlights, responses i publications!

Do zobaczenia!

Kod do tej lekcji: App.tsx
'use client';

import { useState } from 'react';

export default function ClapButton() {
  const [claps, setClaps] = useState(142);
  const [userClaps, setUserClaps] = useState(0);
  const [isAnimating, setIsAnimating] = useState(false);
  const MAX_CLAPS = 50;

  const handleClap = () => {
    if (userClaps >= MAX_CLAPS) return;

    setIsAnimating(true);
    setClaps(prev => prev + 1);
    setUserClaps(prev => prev + 1);

    setTimeout(() => setIsAnimating(false), 300);
  };

  return (
    <div className="min-h-screen bg-gray-50 flex items-center justify-center">
      <div className="text-center">
        <div className="mb-8">
          <h2 className="text-2xl font-bold mb-2">Wypróbuj przycisk oklasków!</h2>
          <p className="text-gray-600">Możesz klasnąć maksymalnie 50 razy</p>
        </div>

        <div className="flex flex-col items-center gap-4">
          <button
            onClick={handleClap}
            disabled={userClaps >= MAX_CLAPS}
            className={`
              w-16 h-16 rounded-full border-2 border-gray-300
              flex items-center justify-center
              hover:border-green-500 transition-all
              ${isAnimating ? 'scale-125' : 'scale-100'}
              ${userClaps >= MAX_CLAPS ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
            `}
          >
            <span className="text-sm font-semibold">Brawo</span>
          </button>

          <div className="text-center">
            <div className="text-xl font-bold">{claps.toLocaleString()}</div>
            {userClaps > 0 && (
              <div className="text-sm text-gray-500">
                Twoje oklaski: {userClaps}x
              </div>
            )}
            {userClaps >= MAX_CLAPS && (
              <div className="text-sm text-green-600 font-semibold mt-2">
                Osiągnięto limit oklasków!
              </div>
            )}
          </div>

          {/* Pasek postępu */}
          <div className="w-64 bg-gray-200 rounded-full h-2 overflow-hidden">
            <div
              className="bg-green-500 h-full transition-all duration-300"
              style={{ width: `${(userClaps / MAX_CLAPS) * 100}%` }}
            />
          </div>
        </div>
      </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. Czym są publications w Medium?

  2. 2. Jakie statusy może mieć SubmissionRequest (zgłoszenie artykułu do publikacji)?

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

Zadania praktyczne w grze

  • Układanie w pionie

    Uporządkuj proces zgłaszania artykułu do publikacji:

  • Edytor kodu

    Publication display

  • Klikanie w kolejności

    Ułóż wyrażenie zwracające top 20 rekomendacji posortowanych po claps:

  • Edytor kodu

    Feed interface

  • Układanie w pionie

    Uporządkuj zakładki strony publikacji w kolejności z kodu:

  • Układanie w poziomie

    Ułóż wywołanie renderujące podgląd artykułu na liście publikacji:

  • Układanie w pionie

    Uporządkuj role w publikacji od najszerszych do najwęższych uprawnień:

  • Układanie w poziomie

    Ułóż wywołanie przełączające feed na zakładkę For You:

  • Klikanie w kolejności

    Ułóż wyrażenie wyświetlające sformatowaną liczbę followersów publikacji:

  • Układanie w pionie

    Uporządkuj etapy budowy Captain's Chronicle w kolejności modułu:

  • Układanie w poziomie

    Ułóż wywołanie renderujące kartę artykułu w głównym feedzie:

Przydatne artykuły