Welcome @name! 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.
Twitter:
Instagram:
Medium:
1interface Article {
2 id: string;
3 author: Author;
4
5 // Content
6 title: string;
7 subtitle?: string;
8 content: ArticleBlock[];
9 coverImage?: string;
10
11 // Metadata
12 topics: string[];
13 tags: string[];
14 readingTime: number; // minutes
15
16 // Publishing
17 status: 'draft' | 'published' | 'unlisted';
18 publishedAt?: Date;
19 updatedAt: Date;
20
21 // Engagement
22 claps: number;
23 responses: Response[];
24 highlights: Highlight[];
25
26 // Publication
27 publicationId?: string;
28 publication?: Publication;
29
30 // Premium
31 isPremium: boolean;
32 previewContent?: string; // for paywalled articles
33}
34
35interface Author {
36 id: string;
37 name: string;
38 username: string;
39 avatar: string;
40 bio: string;
41 followers: number;
42 following: number;
43}
44
45interface ArticleBlock {
46 id: string;
47 type: 'paragraph' | 'heading' | 'image' | 'code' | 'quote' | 'divider' | 'list';
48 content: string;
49 metadata?: {
50 level?: number; // for headings (h1, h2, h3)
51 language?: string; // for code blocks
52 alt?: string; // for images
53 caption?: string; // for images
54 ordered?: boolean; // for lists
55 };
56}
57
58// Calculate reading time
59function calculateReadingTime(blocks: ArticleBlock[]): number {
60 const text = blocks
61 .filter(b => ['paragraph', 'heading', 'quote', 'list'].includes(b.type))
62 .map(b => b.content)
63 .join(' ');
64
65 const wordsPerMinute = 200;
66 const words = text.split(/s+/).length;
67
68 return Math.ceil(words / wordsPerMinute);
69}1'use client';
2
3import { useState } from 'react';
4
5interface EditorBlock {
6 id: string;
7 type: 'paragraph' | 'heading' | 'image' | 'quote';
8 content: string;
9 level?: number;
10}
11
12export default function ArticleEditor() {
13 const [title, setTitle] = useState('');
14 const [blocks, setBlocks] = useState<EditorBlock[]>([
15 { id: '1', type: 'paragraph', content: '' }
16 ]);
17 const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
18
19 const addBlock = (afterId: string, type: EditorBlock['type']) => {
20 const index = blocks.findIndex(b => b.id === afterId);
21 const newBlock: EditorBlock = {
22 id: Date.now().toString(),
23 type,
24 content: '',
25 level: type === 'heading' ? 2 : undefined
26 };
27
28 const newBlocks = [
29 ...blocks.slice(0, index + 1),
30 newBlock,
31 ...blocks.slice(index + 1)
32 ];
33
34 setBlocks(newBlocks);
35 setFocusedBlockId(newBlock.id);
36 };
37
38 const updateBlock = (id: string, content: string) => {
39 setBlocks(blocks.map(b =>
40 b.id === id ? { ...b, content } : b
41 ));
42 };
43
44 const deleteBlock = (id: string) => {
45 if (blocks.length > 1) {
46 setBlocks(blocks.filter(b => b.id !== id));
47 }
48 };
49
50 const convertBlockType = (id: string, newType: EditorBlock['type']) => {
51 setBlocks(blocks.map(b =>
52 b.id === id ? { ...b, type: newType, level: newType === 'heading' ? 2 : undefined } : b
53 ));
54 };
55
56 return (
57 <div className="min-h-screen bg-white">
58 <div className="max-w-3xl mx-auto px-8 py-12">
59 {/* Title */}
60 <textarea
61 value={title}
62 onChange={(e) => setTitle(e.target.value)}
63 placeholder="Title"
64 className="w-full text-5xl font-serif font-bold outline-none resize-none"
65 rows={1}
66 style={{ height: 'auto' }}
67 onInput={(e) => {
68 e.currentTarget.style.height = 'auto';
69 e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
70 }}
71 />
72
73 {/* Blocks */}
74 <div className="space-y-4">
75 {blocks.map((block, index) => (
76 <div
77 key={block.id}
78 className="group relative"
79 onFocus={() => setFocusedBlockId(block.id)}
80 >
81 {/* Block Menu */}
82 {focusedBlockId === block.id && (
83 <div className="absolute -left-12 top-2 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition">
84 <button
85 onClick={() => addBlock(block.id, 'paragraph')}
86 className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-sm"
87 title="Add paragraph"
88 >
89 +
90 </button>
91 <button
92 onClick={() => convertBlockType(block.id, block.type === 'heading' ? 'paragraph' : 'heading')}
93 className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-xs font-bold"
94 title="Toggle heading"
95 >
96 H
97 </button>
98 <button
99 onClick={() => convertBlockType(block.id, 'quote')}
100 className="w-8 h-8 bg-gray-100 rounded hover:bg-gray-200 flex items-center justify-center text-lg"
101 title="Quote"
102 >
103 "
104 </button>
105 {blocks.length > 1 && (
106 <button
107 onClick={() => deleteBlock(block.id)}
108 className="w-8 h-8 bg-red-100 text-red-600 rounded hover:bg-red-200 flex items-center justify-center text-sm"
109 title="Delete"
110 >
111 ×
112 </button>
113 )}
114 </div>
115 )}
116
117 {/* Block Content */}
118 {block.type === 'heading' ? (
119 <textarea
120 value={block.content}
121 onChange={(e) => updateBlock(block.id, e.target.value)}
122 placeholder="Heading"
123 className="w-full text-3xl font-serif font-bold outline-none resize-none"
124 rows={1}
125 onInput={(e) => {
126 e.currentTarget.style.height = 'auto';
127 e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
128 }}
129 />
130 ) : block.type === 'quote' ? (
131 <div className="border-l-4 border-gray-300 pl-6">
132 <textarea
133 value={block.content}
134 onChange={(e) => updateBlock(block.id, e.target.value)}
135 placeholder="Quote"
136 className="w-full text-xl font-serif italic text-gray-700 outline-none resize-none"
137 rows={1}
138 onInput={(e) => {
139 e.currentTarget.style.height = 'auto';
140 e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
141 }}
142 />
143 </div>
144 ) : (
145 <textarea
146 value={block.content}
147 onChange={(e) => updateBlock(block.id, e.target.value)}
148 placeholder="Tell your story..."
149 className="w-full text-xl font-serif text-gray-800 outline-none resize-none"
150 rows={1}
151 onInput={(e) => {
152 e.currentTarget.style.height = 'auto';
153 e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
154 }}
155 />
156 )}
157 </div>
158 ))}
159 </div>
160
161 {/* Publish Button */}
162 <div className="fixed top-4 right-8 flex items-center gap-3">
163 <button className="px-4 py-2 text-sm text-gray-600 hover:text-black">
164 Save draft
165 </button>
166 <button className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700">
167 Publish
168 </button>
169 </div>
170 </div>
171 </div>
172 );
173}1interface Draft extends Article {
2 lastSaved: Date;
3 autoSaveEnabled: boolean;
4}
5
6function useDraftAutoSave(draft: Draft, interval = 30000) {
7 useEffect(() => {
8 if (!draft.autoSaveEnabled) return;
9
10 const timer = setInterval(() => {
11 saveDraft(draft);
12 }, interval);
13
14 return () => clearInterval(timer);
15 }, [draft, interval]);
16}
17
18async function saveDraft(draft: Draft): Promise<void> {
19 // Save to localStorage or API
20 localStorage.setItem(`draft-${draft.id}`, JSON.stringify({
21 ...draft,
22 lastSaved: new Date()
23 }));
24}
25
26async function loadDrafts(authorId: string): Promise<Draft[]> {
27 // Load from localStorage or API
28 const drafts: Draft[] = [];
29
30 for (let i = 0; i < localStorage.length; i++) {
31 const key = localStorage.key(i);
32 if (key?.startsWith('draft-')) {
33 const draft = JSON.parse(localStorage.getItem(key)!);
34 if (draft.author.id === authorId) {
35 drafts.push(draft);
36 }
37 }
38 }
39
40 return drafts.sort((a, b) =>
41 new Date(b.lastSaved).getTime() - new Date(a.lastSaved).getTime()
42 );
43}✅ 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! 🚀