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

Reading Experience i Engagement

Medium to unique reading experience - claps, highlights, responses.

Claps System

1interface ClapData {
2  articleId: string;
3  userId: string;
4  count: number; // user can clap up to 50 times
5  createdAt: Date;
6  updatedAt: Date;
7}
8
9// Clap button with animation
10'use client';
11
12import { useState } from 'react';
13
14export default function ClapButton({ articleId }: { articleId: string }) {
15  const [claps, setClaps] = useState(0);
16  const [userClaps, setUserClaps] = useState(0);
17  const [isAnimating, setIsAnimating] = useState(false);
18  const MAX_CLAPS = 50;
19
20  const handleClap = async () => {
21    if (userClaps >= MAX_CLAPS) return;
22
23    setIsAnimating(true);
24    setClaps(prev => prev + 1);
25    setUserClaps(prev => prev + 1);
26
27    // Animation duration
28    setTimeout(() => setIsAnimating(false), 300);
29
30    // Send to API
31    await fetch(`/api/articles/${articleId}/clap`, {
32      method: 'POST'
33    });
34  };
35
36  return (
37    <div className="flex flex-col items-center gap-2">
38      <button
39        onClick={handleClap}
40        disabled={userClaps >= MAX_CLAPS}
41        className={`
42          w-12 h-12 rounded-full border-2 border-gray-300
43          flex items-center justify-center
44          hover:border-green-500 transition
45          ${isAnimating ? 'scale-125' : 'scale-100'}
46          ${userClaps >= MAX_CLAPS ? 'opacity-50 cursor-not-allowed' : ''}
47        `}
48      >
49        <span className="text-2xl">👏</span>
50      </button>
51
52      <div className="text-center">
53        <div className="text-sm font-semibold">{claps.toLocaleString()}</div>
54        {userClaps > 0 && (
55          <div className="text-xs text-gray-500">
56            You clapped {userClaps}x
57          </div>
58        )}
59      </div>
60    </div>
61  );
62}

Highlighting System

1interface Highlight {
2  id: string;
3  articleId: string;
4  userId: string;
5  blockId: string;
6  startOffset: number;
7  endOffset: number;
8  text: string;
9  note?: string; // private note on highlight
10  createdAt: Date;
11}
12
13// Highlight selection
14function handleTextSelection(articleId: string, blockId: string) {
15  const selection = window.getSelection();
16  if (!selection || selection.toString().trim().length === 0) return;
17
18  const range = selection.getRangeAt(0);
19  const highlight: Omit<Highlight, 'id' | 'createdAt'> = {
20    articleId,
21    userId: getCurrentUserId(),
22    blockId,
23    startOffset: range.startOffset,
24    endOffset: range.endOffset,
25    text: selection.toString()
26  };
27
28  saveHighlight(highlight);
29}
30
31// Render highlights
32function renderWithHighlights(
33  text: string,
34  highlights: Highlight[]
35): React.ReactNode {
36  if (highlights.length === 0) return text;
37
38  const parts: Array<{ text: string; highlighted: boolean }> = [];
39  let lastIndex = 0;
40
41  highlights
42    .sort((a, b) => a.startOffset - b.startOffset)
43    .forEach(highlight => {
44      if (highlight.startOffset > lastIndex) {
45        parts.push({
46          text: text.slice(lastIndex, highlight.startOffset),
47          highlighted: false
48        });
49      }
50
51      parts.push({
52        text: text.slice(highlight.startOffset, highlight.endOffset),
53        highlighted: true
54      });
55
56      lastIndex = highlight.endOffset;
57    });
58
59  if (lastIndex < text.length) {
60    parts.push({
61      text: text.slice(lastIndex),
62      highlighted: false
63    });
64  }
65
66  return parts.map((part, i) =>
67    part.highlighted ? (
68      <mark key={i} className="bg-yellow-200">{part.text}</mark>
69    ) : (
70      <span key={i}>{part.text}</span>
71    )
72  );
73}

Responses (Comments)

1interface Response {
2  id: string;
3  articleId: string;
4  author: Author;
5  content: string;
6  highlightId?: string; // response to a highlight
7  parentId?: string; // nested responses
8  replies: Response[];
9  claps: number;
10  createdAt: Date;
11  updatedAt: Date;
12}
13
14'use client';
15
16export default function ResponseSection({
17  articleId,
18  responses
19}: {
20  articleId: string;
21  responses: Response[];
22}) {
23  const [content, setContent] = useState('');
24
25  const handleSubmit = async () => {
26    if (!content.trim()) return;
27
28    await fetch(`/api/articles/${articleId}/responses`, {
29      method: 'POST',
30      headers: { 'Content-Type': 'application/json' },
31      body: JSON.stringify({ content })
32    });
33
34    setContent('');
35  };
36
37  return (
38    <div className="max-w-2xl mx-auto px-8 py-12">
39      <h2 className="text-2xl font-bold mb-6">
40        Responses ({responses.length})
41      </h2>
42
43      {/* Write Response */}
44      <div className="mb-8 border border-gray-200 rounded-lg p-4">
45        <textarea
46          value={content}
47          onChange={(e) => setContent(e.target.value)}
48          placeholder="What are your thoughts?"
49          className="w-full outline-none resize-none text-lg"
50          rows={3}
51        />
52        <div className="flex justify-end mt-2">
53          <button
54            onClick={handleSubmit}
55            disabled={!content.trim()}
56            className="px-6 py-2 bg-green-600 text-white rounded-full font-semibold hover:bg-green-700 disabled:bg-gray-300"
57          >
58            Respond
59          </button>
60        </div>
61      </div>
62
63      {/* Responses List */}
64      <div className="space-y-6">
65        {responses.map(response => (
66          <ResponseItem key={response.id} response={response} />
67        ))}
68      </div>
69    </div>
70  );
71}
72
73function ResponseItem({ response }: { response: Response }) {
74  return (
75    <div className="border-b border-gray-200 pb-6">
76      <div className="flex items-start gap-3">
77        <img
78          src={response.author.avatar}
79          alt={response.author.name}
80          className="w-10 h-10 rounded-full"
81        />
82        <div className="flex-1">
83          <div className="flex items-center gap-2 mb-2">
84            <span className="font-semibold">{response.author.name}</span>
85            <span className="text-sm text-gray-500">
86              {new Date(response.createdAt).toLocaleDateString()}
87            </span>
88          </div>
89          <p className="text-gray-800 leading-relaxed mb-3">
90            {response.content}
91          </p>
92          <div className="flex items-center gap-4 text-sm text-gray-500">
93            <button className="flex items-center gap-1 hover:text-green-600">
94              <span>👏</span>
95              <span>{response.claps}</span>
96            </button>
97            <button className="hover:text-gray-700">Reply</button>
98          </div>
99
100          {/* Nested Replies */}
101          {response.replies.length > 0 && (
102            <div className="mt-4 pl-6 border-l-2 border-gray-100 space-y-4">
103              {response.replies.map(reply => (
104                <ResponseItem key={reply.id} response={reply} />
105              ))}
106            </div>
107          )}
108        </div>
109      </div>
110    </div>
111  );
112}

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

Przejdź do CodeWorlds