Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Article Editor i Rich Text

Witaj @name! 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

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}

Rich Text Editor

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}

Draft Management

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}

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! 🚀

Przejdź do CodeWorlds