Medium to unique reading experience - claps, highlights, responses.
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}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}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}✅ 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! 🚀