Kurs vibe coding z AI · Moduł 12: Medium Clone
Publications i Content Curation
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. Czym są publications w Medium?
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: