Vibe coding with AI course Β· Module 12: Medium Clone
Article Editor and Rich Text
In this lesson5
Welcome! Time to create Captain's Chronicle - a platform for long-form writing like Medium!
This will be a blogging platform - rich text editor, drafts, publishing, claps, and reading time.
Medium vs Twitter/Instagram
Twitter:
- Short-form (280 chars)
- Quick updates
- Real-time feed
Instagram:
- Visual-first
- Photos/videos with captions
- Stories format
Medium:
- Long-form articles (5-20 min read)
- Rich text editing (headings, images, code blocks, quotes)
- Claps instead of likes
- Highlighting and responses
- Publications (multi-author blogs)
- Reading time estimation
- Paywall for premium content
Article Structure
interface Article {
id: string;
author: Author;
// Content
title: string;
subtitle?: string;
content: ArticleBlock[];
coverImage?: string;
// Metadata
topics: string[];
tags: string[];
readingTime: number; // minutes
// Publishing
status: 'draft' | 'published' | 'unlisted';
publishedAt?: Date;
updatedAt: Date;
// Engagement
claps: number;
responses: Response[];
highlights: Highlight[];
// Publication
publicationId?: string;
publication?: Publication;
// Premium
isPremium: boolean;
previewContent?: string; // for paywalled articles
}
interface Author {
id: string;
name: string;
username: string;
avatar: string;
bio: string;
followers: number;
following: number;
}
interface ArticleBlock {
id: string;
type: 'paragraph' | 'heading' | 'image' | 'code' | 'quote' | 'divider' | 'list';
content: string;
metadata?: {
level?: number; // for headings (h1, h2, h3)
language?: string; // for code blocks
alt?: string; // for images
caption?: string; // for images
ordered?: boolean; // for lists
};
}
// Calculate reading time
function calculateReadingTime(blocks: ArticleBlock[]): number {
const text = blocks
.filter(b => ['paragraph', 'heading', 'quote', 'list'].includes(b.type))
.map(b => b.content)
.join(' ');
const wordsPerMinute = 200;
const words = text.split(/\s+/).length;
return Math.ceil(words / wordsPerMinute);
}Rich Text Editor
'use client';
import { useState } from 'react';
interface EditorBlock {
id: string;
type: 'paragraph' | 'heading' | 'image' | 'quote';
content: string;
level?: number;
}
export default function ArticleEditor() {
const [title, setTitle] = useState('');
const [blocks, setBlocks] = useState<EditorBlock[]>([
{ id: '1', type: 'paragraph', content: '' }
]);
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
const addBlock = (afterId: string, type: EditorBlock['type']) => {
const index = blocks.findIndex(b => b.id === afterId);
const newBlock: EditorBlock = {
id: Date.now().toString(),
type,
content: '',
level: type === 'heading' ? 2 : undefined
};
const newBlocks = [
...blocks.slice(0, index + 1),
newBlock,
...blocks.slice(index + 1)
];
setBlocks(newBlocks);
setFocusedBlockId(newBlock.id);
};
const updateBlock = (id: string, content: string) => {
setBlocks(blocks.map(b =>
b.id === id ? { ...b, content } : b
));
};
const deleteBlock = (id: string) => {
if (blocks.length > 1) {
setBlocks(blocks.filter(b => b.id !== id));
}
};
const convertBlockType = (id: string, newType: EditorBlock['type']) => {
setBlocks(blocks.map(b =>
b.id === id ? { ...b, type: newType, level: newType === 'heading' ? 2 : undefined } : b
));
};
return (
<div className="min-h-screen bg-white">
<div className="max-w-3xl mx-auto px-8 py-12">
{/* Title */}
<textarea
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
className="w-full text-5xl font-serif font-bold outline-none resize-none"
rows={1}
style={{ height: 'auto' }}
onInput={(e) => {
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}}
/>
{/* Blocks */}
<div className="space-y-4">
{blocks.map((block, index) => (
<div
key={block.id}
className="group relative"
onFocus={() => setFocusedBlockId(block.id)}
>
{/* Block Menu */}
{focusedBlockId === block.id && (
<div className="absolute -left-12 top-2 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={() => addBlock(block.id, 'paragraph')}
className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-sm"
title="Add paragraph"
>
+
</button>
<button
onClick={() => convertBlockType(block.id, block.type === 'heading' ? 'paragraph' : 'heading')}
className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-xs font-bold"
title="Toggle heading"
>
H
</button>
<button
onClick={() => convertBlockType(block.id, 'quote')}
className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-lg"
title="Quote"
>
"
</button>
{blocks.length > 1 && (
<button
onClick={() => deleteBlock(block.id)}
className="w-8 h-8 bg-red-100 text-red-600 rounded hover:bg-red-200 flex items-center justify-center text-sm"
title="Delete"
>
Γ
</button>
)}
</div>
)}
{/* Block Content */}
{block.type === 'heading' ? (
<textarea
value={block.content}
onChange={(e) => updateBlock(block.id, e.target.value)}
placeholder="Heading"
className="w-full text-3xl font-serif font-bold outline-none resize-none"
rows={1}
onInput={(e) => {
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}}
/>
) : block.type === 'quote' ? (
<div className="border-l-4 border-gray-300 pl-6">
<textarea
value={block.content}
onChange={(e) => updateBlock(block.id, e.target.value)}
placeholder="Quote"
className="w-full text-xl font-serif italic text-gray-700 outline-none resize-none"
rows={1}
onInput={(e) => {
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}}
/>
</div>
) : (
<textarea
value={block.content}
onChange={(e) => updateBlock(block.id, e.target.value)}
placeholder="Tell your story..."
className="w-full text-xl font-serif text-gray-800 outline-none resize-none"
rows={1}
onInput={(e) => {
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}}
/>
)}
</div>
))}
</div>
{/* Publish Button */}
<div className="fixed top-4 right-8 flex items-center gap-3">
<button className="px-4 py-2 text-sm text-gray-600 hover:text-black">
Save draft
</button>
<button className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700">
Publish
</button>
</div>
</div>
</div>
);
}Draft Management
interface Draft extends Article {
lastSaved: Date;
autoSaveEnabled: boolean;
}
function useDraftAutoSave(draft: Draft, interval = 30000) {
useEffect(() => {
if (!draft.autoSaveEnabled) return;
const timer = setInterval(() => {
saveDraft(draft);
}, interval);
return () => clearInterval(timer);
}, [draft, interval]);
}
async function saveDraft(draft: Draft): Promise<void> {
// Save to localStorage or API
localStorage.setItem(`draft-${draft.id}`, JSON.stringify({
...draft,
lastSaved: new Date()
}));
}
async function loadDrafts(authorId: string): Promise<Draft[]> {
// Load from localStorage or API
const drafts: Draft[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith('draft-')) {
const draft = JSON.parse(localStorage.getItem(key)!);
if (draft.author.id === authorId) {
drafts.push(draft);
}
}
}
return drafts.sort((a, b) =>
new Date(b.lastSaved).getTime() - new Date(a.lastSaved).getTime()
);
}Summary
Article structure - blocks-based content (paragraph, heading, quote, image, code) Rich text editor - Medium-style block editor with formatting Block menu - add, convert, delete blocks Auto-resize - textareas adjust to content Draft system - save drafts, auto-save Reading time - calculate based on word count Title editing - large, serif font Publish workflow - draft β publish
In the next exercise we'll add reading experience and claps!
See you soon!
Code for this lesson: App.tsx
'use client';
import { useState } from 'react';
interface EditorBlock {
id: string;
type: 'paragraph' | 'heading' | 'quote';
content: string;
}
export default function ArticleEditor() {
const [title, setTitle] = useState('');
const [blocks, setBlocks] = useState<EditorBlock[]>([
{ id: '1', type: 'paragraph', content: '' }
]);
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
const addBlock = (afterId: string) => {
const index = blocks.findIndex(b => b.id === afterId);
const newBlock: EditorBlock = {
id: Date.now().toString(),
type: 'paragraph',
content: ''
};
const newBlocks = [
...blocks.slice(0, index + 1),
newBlock,
...blocks.slice(index + 1)
];
setBlocks(newBlocks);
setFocusedBlockId(newBlock.id);
};
const updateBlock = (id: string, content: string) => {
setBlocks(blocks.map(b => b.id === id ? { ...b, content } : b));
};
const convertToHeading = (id: string) => {
setBlocks(blocks.map(b =>
b.id === id ? { ...b, type: b.type === 'heading' ? 'paragraph' : 'heading' } : b
));
};
const convertToQuote = (id: string) => {
setBlocks(blocks.map(b =>
b.id === id ? { ...b, type: 'quote' } : b
));
};
return (
<div className="min-h-screen bg-white">
<div className="max-w-3xl mx-auto px-8 py-12">
{/* Title */}
<textarea
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
className="w-full text-5xl font-serif font-bold placeholder-gray-300 outline-none resize-none mb-8"
rows={1}
/>
{/* Blocks */}
<div className="space-y-4">
{blocks.map((block) => (
<div
key={block.id}
className="group relative"
onFocus={() => setFocusedBlockId(block.id)}
>
{/* Block Menu */}
{focusedBlockId === block.id && (
<div className="absolute -left-12 top-2 flex flex-col gap-1">
<button
onClick={() => addBlock(block.id)}
className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-sm"
title="Add"
>
+
</button>
<button
onClick={() => convertToHeading(block.id)}
className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-xs font-bold"
title="Heading"
>
H
</button>
<button
onClick={() => convertToQuote(block.id)}
className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-lg"
title="Quote"
>
"
</button>
</div>
)}
{/* Block Content */}
{block.type === 'heading' ? (
<textarea
value={block.content}
onChange={(e) => updateBlock(block.id, e.target.value)}
placeholder="Heading"
className="w-full text-3xl font-serif font-bold placeholder-gray-300 outline-none resize-none"
rows={1}
/>
) : block.type === 'quote' ? (
<div className="border-l-4 border-gray-300 pl-6">
<textarea
value={block.content}
onChange={(e) => updateBlock(block.id, e.target.value)}
placeholder="Quote"
className="w-full text-xl font-serif italic text-gray-700 placeholder-gray-300 outline-none resize-none"
rows={1}
/>
</div>
) : (
<textarea
value={block.content}
onChange={(e) => updateBlock(block.id, e.target.value)}
placeholder="Tell your story..."
className="w-full text-xl font-serif text-gray-800 placeholder-gray-300 outline-none resize-none leading-relaxed"
rows={1}
/>
)}
</div>
))}
</div>
{/* Actions */}
<div className="fixed top-4 right-8 flex items-center gap-3">
<button className="px-4 py-2 text-sm text-gray-600 hover:text-black">
Save draft
</button>
<button className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700">
Publish
</button>
</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. Main difference Medium vs Twitter?
2. What block types does the Medium editor have?
These are 2 of 6 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Click in order
Arrange the expression that returns the reading time rounded up:
- Horizontal ordering
Arrange the steps for creating an article:
- Vertical ordering
Arrange an article's lifecycle from creation to publication:
- Code editor
Rich text editor component
- Click in order
Arrange the call that adds a heading block after the current block:
- Horizontal ordering
Arrange the expression that builds the draft key in localStorage:
- Code editor
Draft system interface
- Vertical ordering
Arrange the Author interface fields in the order from the definition: