Kurs vibe coding z AI · Moduł 12: Medium Clone
Article Editor i Rich Text
W tej lekcji5
Witaj! Czas stworzyć Captain's Chronicle - platformę do długich tekstów jak Medium!
To będzie blogging platform - rich text editor, drafts, publishing, claps i 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()
);
}Podsumowanie
Article structure - blocks-based content (paragraph, heading, quote, image, code) Rich text editor - Medium-style block editor z formatowaniem 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
W następnym ćwiczeniu dodamy reading experience i claps!
Do zobaczenia!
Kod do tej lekcji: 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">
{/* Tytuł */}
<textarea
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Tytuł"
className="w-full text-5xl font-serif font-bold placeholder-gray-300 outline-none resize-none mb-8"
rows={1}
/>
{/* Bloki */}
<div className="space-y-4">
{blocks.map((block) => (
<div
key={block.id}
className="group relative"
onFocus={() => setFocusedBlockId(block.id)}
>
{/* Menu bloku */}
{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="Dodaj"
>
+
</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="Nagłówek"
>
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="Cytat"
>
"
</button>
</div>
)}
{/* Treść bloku */}
{block.type === 'heading' ? (
<textarea
value={block.content}
onChange={(e) => updateBlock(block.id, e.target.value)}
placeholder="Nagłówek"
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="Cytat"
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="Opowiedz swoją historię..."
className="w-full text-xl font-serif text-gray-800 placeholder-gray-300 outline-none resize-none leading-relaxed"
rows={1}
/>
)}
</div>
))}
</div>
{/* Akcje */}
<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">
Zapisz szkic
</button>
<button className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700">
Opublikuj
</button>
</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. Główna różnica Medium vs Twitter?
2. Jakie typy bloków ma Medium editor?
To 2 z 6 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Klikanie w kolejności
Ułóż wyrażenie zwracające czas czytania zaokrąglony w górę:
- Układanie w poziomie
Ułóż kroki tworzenia article:
- Układanie w pionie
Uporządkuj cykl życia artykułu od utworzenia do publikacji:
- Edytor kodu
Rich text editor component
- Klikanie w kolejności
Ułóż wywołanie dodające blok nagłówka po bieżącym bloku:
- Układanie w poziomie
Ułóż wyrażenie tworzące klucz szkicu w localStorage:
- Edytor kodu
Draft system interface
- Układanie w pionie
Uporządkuj pola interfejsu Author w kolejności z definicji: