Vibe coding with AI course Β· Module 12: Medium Clone
Reading Experience and Engagement
In this lesson4
Medium offers a unique reading experience - claps, highlights, responses.
Claps System
interface ClapData {
articleId: string;
userId: string;
count: number; // user can clap up to 50 times
createdAt: Date;
updatedAt: Date;
}
// Clap button with animation
'use client';
import { useState } from 'react';
export default function ClapButton({ articleId }: { articleId: string }) {
const [claps, setClaps] = useState(0);
const [userClaps, setUserClaps] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
const MAX_CLAPS = 50;
const handleClap = async () => {
if (userClaps >= MAX_CLAPS) return;
setIsAnimating(true);
setClaps(prev => prev + 1);
setUserClaps(prev => prev + 1);
// Animation duration
setTimeout(() => setIsAnimating(false), 300);
// Send to API
await fetch(`/api/articles/${articleId}/clap`, {
method: 'POST'
});
};
return (
<div className="flex flex-col items-center gap-2">
<button
onClick={handleClap}
disabled={userClaps >= MAX_CLAPS}
className={`
w-12 h-12 rounded-full border-2 border-gray-300
flex items-center justify-center
hover:border-green-500 transition
${isAnimating ? 'scale-125' : 'scale-100'}
${userClaps >= MAX_CLAPS ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
<span className="text-sm font-semibold">Applause</span>
</button>
<div className="text-center">
<div className="text-sm font-semibold">{claps.toLocaleString()}</div>
{userClaps > 0 && (
<div className="text-xs text-gray-500">
You clapped {userClaps}x
</div>
)}
</div>
</div>
);
}Highlighting System
interface Highlight {
id: string;
articleId: string;
userId: string;
blockId: string;
startOffset: number;
endOffset: number;
text: string;
note?: string; // private note on highlight
createdAt: Date;
}
// Highlight selection
function handleTextSelection(articleId: string, blockId: string) {
const selection = window.getSelection();
if (!selection || selection.toString().trim().length === 0) return;
const range = selection.getRangeAt(0);
const highlight: Omit<Highlight, 'id' | 'createdAt'> = {
articleId,
userId: getCurrentUserId(),
blockId,
startOffset: range.startOffset,
endOffset: range.endOffset,
text: selection.toString()
};
saveHighlight(highlight);
}
// Render highlights
function renderWithHighlights(
text: string,
highlights: Highlight[]
): React.ReactNode {
if (highlights.length === 0) return text;
const parts: Array<{ text: string; highlighted: boolean }> = [];
let lastIndex = 0;
highlights
.sort((a, b) => a.startOffset - b.startOffset)
.forEach(highlight => {
if (highlight.startOffset > lastIndex) {
parts.push({
text: text.slice(lastIndex, highlight.startOffset),
highlighted: false
});
}
parts.push({
text: text.slice(highlight.startOffset, highlight.endOffset),
highlighted: true
});
lastIndex = highlight.endOffset;
});
if (lastIndex < text.length) {
parts.push({
text: text.slice(lastIndex),
highlighted: false
});
}
return parts.map((part, i) =>
part.highlighted ? (
<mark key={i} className="bg-yellow-200">{part.text}</mark>
) : (
<span key={i}>{part.text}</span>
)
);
}Responses (Comments)
interface Response {
id: string;
articleId: string;
author: Author;
content: string;
highlightId?: string; // response to a highlight
parentId?: string; // nested responses
replies: Response[];
claps: number;
createdAt: Date;
updatedAt: Date;
}
'use client';
export default function ResponseSection({
articleId,
responses
}: {
articleId: string;
responses: Response[];
}) {
const [content, setContent] = useState('');
const handleSubmit = async () => {
if (!content.trim()) return;
await fetch(`/api/articles/${articleId}/responses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
setContent('');
};
return (
<div className="max-w-2xl mx-auto px-8 py-12">
<h2 className="text-2xl font-bold mb-6">
Responses ({responses.length})
</h2>
{/* Write Response */}
<div className="mb-8 border border-gray-200 rounded-lg p-4">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="What are your thoughts?"
className="w-full outline-none resize-none text-lg"
rows={3}
/>
<div className="flex justify-end mt-2">
<button
onClick={handleSubmit}
disabled={!content.trim()}
className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700 disabled:bg-gray-300"
>
Respond
</button>
</div>
</div>
{/* Responses List */}
<div className="space-y-6">
{responses.map(response => (
<ResponseItem key={response.id} response={response} />
))}
</div>
</div>
);
}
function ResponseItem({ response }: { response: Response }) {
return (
<div className="border-b border-gray-200 pb-6">
<div className="flex items-start gap-3">
<img
src={response.author.avatar}
alt={response.author.name}
className="w-10 h-10 rounded-full"
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-semibold">{response.author.name}</span>
<span className="text-sm text-gray-500">
{new Date(response.createdAt).toLocaleDateString()}
</span>
</div>
<p className="text-gray-800 leading-relaxed mb-3">
{response.content}
</p>
<div className="flex items-center gap-4 text-sm text-gray-500">
<button className="flex items-center gap-1 hover:text-green-600">
<span>Applause</span>
<span>{response.claps}</span>
</button>
<button className="hover:text-gray-700">Reply</button>
</div>
{/* Nested Replies */}
{response.replies.length > 0 && (
<div className="mt-4 pl-6 border-l-2 border-gray-100 space-y-4">
{response.replies.map(reply => (
<ResponseItem key={reply.id} response={reply} />
))}
</div>
)}
</div>
</div>
</div>
);
}Summary
Claps system - up to 50 claps per user, animation Multi-clap - users can clap multiple times Highlighting - select text to highlight Highlight rendering - show highlights with yellow background Responses - comments on articles Nested responses - replies to responses Response claps - clap for good responses Author info - avatar, name, date in responses
In the next exercise we'll add publications and curation!
See you soon!
Code for this lesson: App.tsx
'use client';
import { useState, useEffect } from 'react';
interface Draft {
id: string;
title: string;
content: string;
lastSaved: Date;
}
export default function DraftManagement() {
const [drafts, setDrafts] = useState<Draft[]>([
{
id: '1',
title: "The Pirate's Guide to TypeScript",
content: 'TypeScript is a powerful tool for building scalable applications...',
lastSaved: new Date(Date.now() - 1000 * 60 * 5)
},
{
id: '2',
title: 'Building a Ship from Scratch',
content: 'In this comprehensive guide, we will explore...',
lastSaved: new Date(Date.now() - 1000 * 60 * 60 * 2)
},
{
id: '3',
title: 'Adventures on the High Seas',
content: 'The ocean is vast and full of mysteries...',
lastSaved: new Date(Date.now() - 1000 * 60 * 60 * 24)
}
]);
const formatRelativeTime = (date: Date) => {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
};
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold">Your drafts</h1>
<button className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700">
New story
</button>
</div>
<div className="bg-white rounded-lg shadow-sm divide-y divide-gray-200">
{drafts.map(draft => (
<div
key={draft.id}
className="p-6 hover:bg-gray-50 cursor-pointer transition"
>
<h2 className="text-xl font-bold mb-2">
{draft.title || 'Untitled'}
</h2>
<p className="text-gray-600 mb-3 line-clamp-2">
{draft.content}
</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>Last edited {formatRelativeTime(draft.lastSaved)}</span>
<div className="flex items-center gap-3">
<button className="hover:text-gray-700">Edit</button>
<button className="hover:text-red-600">Delete</button>
</div>
</div>
</div>
))}
</div>
{drafts.length === 0 && (
<div className="text-center py-12 text-gray-500">
<p className="text-lg mb-2">No drafts yet</p>
<p>Start writing your first story</p>
</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 claps in Medium?
2. How many times can a single user clap on one article?
These are 2 of 6 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Horizontal ordering
Arrange the clapping steps:
- Click in order
Arrange the call that gets the selected text for a highlight:
- Code editor
Clap interaction component
- Vertical ordering
Arrange the process of creating a text highlight:
- Horizontal ordering
Arrange the tag that renders a highlighted text fragment:
- Code editor
Comments interface
- Vertical ordering
Arrange the steps of adding a response in handleSubmit:
- Click in order
Arrange the call that ends the clap animation after 300ms:
- Click in order
Arrange the call that increases the clap count by one: