Vibe coding with AI course Β· Module 12: Medium Clone
Publications and Content Curation
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. What are publications in Medium?
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: