Kurs vibe coding z AI · Moduł 12: Medium Clone

Article Editor i Rich Text

5 min czytania
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. 1. Główna różnica Medium vs Twitter?

  2. 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:

Przydatne artykuły