Kurs vibe coding z AI · Moduł 12: Medium Clone
Reading Experience i Engagement
W tej lekcji4
Medium to 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">Brawo</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>Brawo</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>
);
}Podsumowanie
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
W następnym ćwiczeniu dodamy publications i curation!
Do zobaczenia!
Kod do tej lekcji: 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: "Piracki przewodnik po TypeScripcie",
content: 'TypeScript to potężne narzędzie do budowania skalowalnych aplikacji...',
lastSaved: new Date(Date.now() - 1000 * 60 * 5)
},
{
id: '2',
title: 'Budowa statku od zera',
content: 'W tym obszernym przewodniku poznamy...',
lastSaved: new Date(Date.now() - 1000 * 60 * 60 * 2)
},
{
id: '3',
title: 'Przygody na pełnym morzu',
content: 'Ocean jest ogromny i pełen tajemnic...',
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 'przed chwilą';
if (seconds < 3600) return `${Math.floor(seconds / 60)} min temu`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} godz. temu`;
return `${Math.floor(seconds / 86400)} d temu`;
};
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">Twoje szkice</h1>
<button className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700">
Nowy artykuł
</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 || 'Bez tytułu'}
</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>Ostatnia edycja: {formatRelativeTime(draft.lastSaved)}</span>
<div className="flex items-center gap-3">
<button className="hover:text-gray-700">Edytuj</button>
<button className="hover:text-red-600">Usuń</button>
</div>
</div>
</div>
))}
</div>
{drafts.length === 0 && (
<div className="text-center py-12 text-gray-500">
<p className="text-lg mb-2">Nie masz jeszcze szkiców</p>
<p>Zacznij pisać swój pierwszy tekst</p>
</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ą claps w Medium?
2. Ile razy jeden użytkownik może klasnąć (clap) na jeden artykuł?
To 2 z 6 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż kroki clapping:
- Klikanie w kolejności
Ułóż wywołanie pobierające zaznaczony tekst do highlightu:
- Edytor kodu
Clap interaction component
- Układanie w pionie
Uporządkuj proces tworzenia podświetlenia (highlight) tekstu:
- Układanie w poziomie
Ułóż znacznik renderujący podświetlony fragment tekstu:
- Edytor kodu
Comments interface
- Układanie w pionie
Uporządkuj kroki dodawania odpowiedzi (response) w handleSubmit:
- Klikanie w kolejności
Ułóż wywołanie kończące animację claps po 300ms:
- Klikanie w kolejności
Ułóż wywołanie zwiększające liczbę claps o jeden: