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

Reading Experience and Engagement

4 min read
In this lesson4

Medium offers a unique reading experience - claps, highlights, responses.

Claps System

interface ClapData {
  articleId: string;
  userId: string;
  count: number; // user can clap up to 50 times
  createdAt: Date;
  updatedAt: Date;
}

// Clap button with animation
'use client';

import { useState } from 'react';

export default function ClapButton({ articleId }: { articleId: string }) {
  const [claps, setClaps] = useState(0);
  const [userClaps, setUserClaps] = useState(0);
  const [isAnimating, setIsAnimating] = useState(false);
  const MAX_CLAPS = 50;

  const handleClap = async () => {
    if (userClaps >= MAX_CLAPS) return;

    setIsAnimating(true);
    setClaps(prev => prev + 1);
    setUserClaps(prev => prev + 1);

    // Animation duration
    setTimeout(() => setIsAnimating(false), 300);

    // Send to API
    await fetch(`/api/articles/${articleId}/clap`, {
      method: 'POST'
    });
  };

  return (
    <div className="flex flex-col items-center gap-2">
      <button
        onClick={handleClap}
        disabled={userClaps >= MAX_CLAPS}
        className={`
          w-12 h-12 rounded-full border-2 border-gray-300
          flex items-center justify-center
          hover:border-green-500 transition
          ${isAnimating ? 'scale-125' : 'scale-100'}
          ${userClaps >= MAX_CLAPS ? 'opacity-50 cursor-not-allowed' : ''}
        `}
      >
        <span className="text-sm font-semibold">Applause</span>
      </button>

      <div className="text-center">
        <div className="text-sm font-semibold">{claps.toLocaleString()}</div>
        {userClaps > 0 && (
          <div className="text-xs text-gray-500">
            You clapped {userClaps}x
          </div>
        )}
      </div>
    </div>
  );
}

Highlighting System

interface Highlight {
  id: string;
  articleId: string;
  userId: string;
  blockId: string;
  startOffset: number;
  endOffset: number;
  text: string;
  note?: string; // private note on highlight
  createdAt: Date;
}

// Highlight selection
function handleTextSelection(articleId: string, blockId: string) {
  const selection = window.getSelection();
  if (!selection || selection.toString().trim().length === 0) return;

  const range = selection.getRangeAt(0);
  const highlight: Omit<Highlight, 'id' | 'createdAt'> = {
    articleId,
    userId: getCurrentUserId(),
    blockId,
    startOffset: range.startOffset,
    endOffset: range.endOffset,
    text: selection.toString()
  };

  saveHighlight(highlight);
}

// Render highlights
function renderWithHighlights(
  text: string,
  highlights: Highlight[]
): React.ReactNode {
  if (highlights.length === 0) return text;

  const parts: Array<{ text: string; highlighted: boolean }> = [];
  let lastIndex = 0;

  highlights
    .sort((a, b) => a.startOffset - b.startOffset)
    .forEach(highlight => {
      if (highlight.startOffset > lastIndex) {
        parts.push({
          text: text.slice(lastIndex, highlight.startOffset),
          highlighted: false
        });
      }

      parts.push({
        text: text.slice(highlight.startOffset, highlight.endOffset),
        highlighted: true
      });

      lastIndex = highlight.endOffset;
    });

  if (lastIndex < text.length) {
    parts.push({
      text: text.slice(lastIndex),
      highlighted: false
    });
  }

  return parts.map((part, i) =>
    part.highlighted ? (
      <mark key={i} className="bg-yellow-200">{part.text}</mark>
    ) : (
      <span key={i}>{part.text}</span>
    )
  );
}

Responses (Comments)

interface Response {
  id: string;
  articleId: string;
  author: Author;
  content: string;
  highlightId?: string; // response to a highlight
  parentId?: string; // nested responses
  replies: Response[];
  claps: number;
  createdAt: Date;
  updatedAt: Date;
}

'use client';

export default function ResponseSection({
  articleId,
  responses
}: {
  articleId: string;
  responses: Response[];
}) {
  const [content, setContent] = useState('');

  const handleSubmit = async () => {
    if (!content.trim()) return;

    await fetch(`/api/articles/${articleId}/responses`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content })
    });

    setContent('');
  };

  return (
    <div className="max-w-2xl mx-auto px-8 py-12">
      <h2 className="text-2xl font-bold mb-6">
        Responses ({responses.length})
      </h2>

      {/* Write Response */}
      <div className="mb-8 border border-gray-200 rounded-lg p-4">
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder="What are your thoughts?"
          className="w-full outline-none resize-none text-lg"
          rows={3}
        />
        <div className="flex justify-end mt-2">
          <button
            onClick={handleSubmit}
            disabled={!content.trim()}
            className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700 disabled:bg-gray-300"
          >
            Respond
          </button>
        </div>
      </div>

      {/* Responses List */}
      <div className="space-y-6">
        {responses.map(response => (
          <ResponseItem key={response.id} response={response} />
        ))}
      </div>
    </div>
  );
}

function ResponseItem({ response }: { response: Response }) {
  return (
    <div className="border-b border-gray-200 pb-6">
      <div className="flex items-start gap-3">
        <img
          src={response.author.avatar}
          alt={response.author.name}
          className="w-10 h-10 rounded-full"
        />
        <div className="flex-1">
          <div className="flex items-center gap-2 mb-2">
            <span className="font-semibold">{response.author.name}</span>
            <span className="text-sm text-gray-500">
              {new Date(response.createdAt).toLocaleDateString()}
            </span>
          </div>
          <p className="text-gray-800 leading-relaxed mb-3">
            {response.content}
          </p>
          <div className="flex items-center gap-4 text-sm text-gray-500">
            <button className="flex items-center gap-1 hover:text-green-600">
              <span>Applause</span>
              <span>{response.claps}</span>
            </button>
            <button className="hover:text-gray-700">Reply</button>
          </div>

          {/* Nested Replies */}
          {response.replies.length > 0 && (
            <div className="mt-4 pl-6 border-l-2 border-gray-100 space-y-4">
              {response.replies.map(reply => (
                <ResponseItem key={reply.id} response={reply} />
              ))}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

Summary

Claps system - up to 50 claps per user, animation Multi-clap - users can clap multiple times Highlighting - select text to highlight Highlight rendering - show highlights with yellow background Responses - comments on articles Nested responses - replies to responses Response claps - clap for good responses Author info - avatar, name, date in responses

In the next exercise we'll add publications and curation!

See you soon!

Code for this lesson: App.tsx
'use client';

import { useState, useEffect } from 'react';

interface Draft {
  id: string;
  title: string;
  content: string;
  lastSaved: Date;
}

export default function DraftManagement() {
  const [drafts, setDrafts] = useState<Draft[]>([
    {
      id: '1',
      title: "The Pirate's Guide to TypeScript",
      content: 'TypeScript is a powerful tool for building scalable applications...',
      lastSaved: new Date(Date.now() - 1000 * 60 * 5)
    },
    {
      id: '2',
      title: 'Building a Ship from Scratch',
      content: 'In this comprehensive guide, we will explore...',
      lastSaved: new Date(Date.now() - 1000 * 60 * 60 * 2)
    },
    {
      id: '3',
      title: 'Adventures on the High Seas',
      content: 'The ocean is vast and full of mysteries...',
      lastSaved: new Date(Date.now() - 1000 * 60 * 60 * 24)
    }
  ]);

  const formatRelativeTime = (date: Date) => {
    const seconds = Math.floor((Date.now() - date.getTime()) / 1000);

    if (seconds < 60) return 'just now';
    if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
    if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
    return `${Math.floor(seconds / 86400)}d ago`;
  };

  return (
    <div className="min-h-screen bg-gray-50 p-8">
      <div className="max-w-4xl mx-auto">
        <div className="flex items-center justify-between mb-8">
          <h1 className="text-3xl font-bold">Your drafts</h1>
          <button className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700">
            New story
          </button>
        </div>

        <div className="bg-white rounded-lg shadow-sm divide-y divide-gray-200">
          {drafts.map(draft => (
            <div
              key={draft.id}
              className="p-6 hover:bg-gray-50 cursor-pointer transition"
            >
              <h2 className="text-xl font-bold mb-2">
                {draft.title || 'Untitled'}
              </h2>
              <p className="text-gray-600 mb-3 line-clamp-2">
                {draft.content}
              </p>
              <div className="flex items-center justify-between text-sm text-gray-500">
                <span>Last edited {formatRelativeTime(draft.lastSaved)}</span>
                <div className="flex items-center gap-3">
                  <button className="hover:text-gray-700">Edit</button>
                  <button className="hover:text-red-600">Delete</button>
                </div>
              </div>
            </div>
          ))}
        </div>

        {drafts.length === 0 && (
          <div className="text-center py-12 text-gray-500">
            <p className="text-lg mb-2">No drafts yet</p>
            <p>Start writing your first story</p>
          </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. What are claps in Medium?

  2. 2. How many times can a single user clap on one article?

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

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the clapping steps:

  • Click in order

    Arrange the call that gets the selected text for a highlight:

  • Code editor

    Clap interaction component

  • Vertical ordering

    Arrange the process of creating a text highlight:

  • Horizontal ordering

    Arrange the tag that renders a highlighted text fragment:

  • Code editor

    Comments interface

  • Vertical ordering

    Arrange the steps of adding a response in handleSubmit:

  • Click in order

    Arrange the call that ends the clap animation after 300ms:

  • Click in order

    Arrange the call that increases the clap count by one:

Useful articles