Vibe coding with AI course Β· Module 12: Medium Clone

Article Editor and Rich Text

5 min read
In this lesson5

Welcome! 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.

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()
  );
}

Summary

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!

Code for this lesson: 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">
        {/* Title */}
        <textarea
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="Title"
          className="w-full text-5xl font-serif font-bold placeholder-gray-300 outline-none resize-none mb-8"
          rows={1}
        />

        {/* Blocks */}
        <div className="space-y-4">
          {blocks.map((block) => (
            <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">
                  <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="Add"
                  >
                    +
                  </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="Heading"
                  >
                    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="Quote"
                  >
                    "
                  </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 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="Quote"
                    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="Tell your story..."
                  className="w-full text-xl font-serif text-gray-800 placeholder-gray-300 outline-none resize-none leading-relaxed"
                  rows={1}
                />
              )}
            </div>
          ))}
        </div>

        {/* Actions */}
        <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>
  );
}

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. 1. Main difference Medium vs Twitter?

  2. 2. What block types does the Medium editor have?

These are 2 of 6 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Click in order

    Arrange the expression that returns the reading time rounded up:

  • Horizontal ordering

    Arrange the steps for creating an article:

  • Vertical ordering

    Arrange an article's lifecycle from creation to publication:

  • Code editor

    Rich text editor component

  • Click in order

    Arrange the call that adds a heading block after the current block:

  • Horizontal ordering

    Arrange the expression that builds the draft key in localStorage:

  • Code editor

    Draft system interface

  • Vertical ordering

    Arrange the Author interface fields in the order from the definition:

Useful articles