Vibe coding with AI course Β· Module 12: Medium Clone

Publications and Content Curation

5 min read
In this lesson4

Medium has publications - multi-author blogs with 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>
  );
}

Summary

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

You now have a complete Medium clone with editor, claps, highlights, responses, and publications!

See you soon!

Code for this lesson: 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">Try the clap button!</h2>
          <p className="text-gray-600">You can clap up to 50 times</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">Applause</span>
          </button>

          <div className="text-center">
            <div className="text-xl font-bold">{claps.toLocaleString()}</div>
            {userClaps > 0 && (
              <div className="text-sm text-gray-500">
                You clapped {userClaps}x
              </div>
            )}
            {userClaps >= MAX_CLAPS && (
              <div className="text-sm text-green-600 font-semibold mt-2">
                Maximum claps reached!
              </div>
            )}
          </div>

          {/* Progress bar */}
          <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>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What are publications in Medium?

  2. 2. What statuses can a SubmissionRequest (article submission to a publication) have?

These are 2 of 10 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Arrange the process of submitting an article to a publication:

  • Code editor

    Publication display

  • Click in order

    Arrange the expression that returns the top 20 recommendations sorted by claps:

  • Code editor

    Feed interface

  • Vertical ordering

    Arrange the publication page tabs in the order from the code:

  • Horizontal ordering

    Arrange the call that renders an article preview in the publication list:

  • Vertical ordering

    Arrange the publication roles from broadest to narrowest permissions:

  • Horizontal ordering

    Arrange the call that switches the feed to the For You tab:

  • Click in order

    Arrange the expression that displays the formatted follower count of a publication:

  • Vertical ordering

    Arrange the stages of building Captain's Chronicle in module order:

  • Horizontal ordering

    Arrange the call that renders an article card in the main feed:

Useful articles