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

Reading Experience i Engagement

4 min czytania
W tej lekcji4

Medium to 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">Brawo</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>Brawo</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>
  );
}

Podsumowanie

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

W następnym ćwiczeniu dodamy publications i curation!

Do zobaczenia!

Kod do tej lekcji: 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: "Piracki przewodnik po TypeScripcie",
      content: 'TypeScript to potężne narzędzie do budowania skalowalnych aplikacji...',
      lastSaved: new Date(Date.now() - 1000 * 60 * 5)
    },
    {
      id: '2',
      title: 'Budowa statku od zera',
      content: 'W tym obszernym przewodniku poznamy...',
      lastSaved: new Date(Date.now() - 1000 * 60 * 60 * 2)
    },
    {
      id: '3',
      title: 'Przygody na pełnym morzu',
      content: 'Ocean jest ogromny i pełen tajemnic...',
      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 'przed chwilą';
    if (seconds < 3600) return `${Math.floor(seconds / 60)} min temu`;
    if (seconds < 86400) return `${Math.floor(seconds / 3600)} godz. temu`;
    return `${Math.floor(seconds / 86400)} d temu`;
  };

  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">Twoje szkice</h1>
          <button className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700">
            Nowy artykuł
          </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 || 'Bez tytułu'}
              </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>Ostatnia edycja: {formatRelativeTime(draft.lastSaved)}</span>
                <div className="flex items-center gap-3">
                  <button className="hover:text-gray-700">Edytuj</button>
                  <button className="hover:text-red-600">Usuń</button>
                </div>
              </div>
            </div>
          ))}
        </div>

        {drafts.length === 0 && (
          <div className="text-center py-12 text-gray-500">
            <p className="text-lg mb-2">Nie masz jeszcze szkiców</p>
            <p>Zacznij pisać swój pierwszy tekst</p>
          </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. Czym są claps w Medium?

  2. 2. Ile razy jeden użytkownik może klasnąć (clap) na jeden artykuł?

To 2 z 6 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż kroki clapping:

  • Klikanie w kolejności

    Ułóż wywołanie pobierające zaznaczony tekst do highlightu:

  • Edytor kodu

    Clap interaction component

  • Układanie w pionie

    Uporządkuj proces tworzenia podświetlenia (highlight) tekstu:

  • Układanie w poziomie

    Ułóż znacznik renderujący podświetlony fragment tekstu:

  • Edytor kodu

    Comments interface

  • Układanie w pionie

    Uporządkuj kroki dodawania odpowiedzi (response) w handleSubmit:

  • Klikanie w kolejności

    Ułóż wywołanie kończące animację claps po 300ms:

  • Klikanie w kolejności

    Ułóż wywołanie zwiększające liczbę claps o jeden:

Przydatne artykuły