Skip to content

Commit dccaf2e

Browse files
committed
Comments
1 parent 0e17398 commit dccaf2e

File tree

7 files changed

+714
-48
lines changed

7 files changed

+714
-48
lines changed
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { useState, useEffect, useRef } from 'react'
2+
import {
3+
useInfiniteQuery,
4+
useMutation,
5+
useQueryClient,
6+
} from '@tanstack/react-query'
7+
import { useAuth } from '@/hooks/useAuth'
8+
import { getComments, createComment, deleteComment } from '@/lib/api/comments'
9+
import type { Comment } from '@/types/comments'
10+
11+
interface CommentsProps {
12+
snippetId: string
13+
}
14+
15+
function CommentSkeleton() {
16+
return (
17+
<div className="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md animate-pulse">
18+
<div className="flex items-center gap-2 mb-2">
19+
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
20+
<div className="h-3 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
21+
</div>
22+
<div className="space-y-2">
23+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
24+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
25+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/6"></div>
26+
</div>
27+
</div>
28+
)
29+
}
30+
31+
export function Comments({ snippetId }: CommentsProps) {
32+
const { user } = useAuth()
33+
const queryClient = useQueryClient()
34+
const [content, setContent] = useState('')
35+
const [isSubmitting, setIsSubmitting] = useState(false)
36+
const observerTarget = useRef<HTMLDivElement>(null)
37+
38+
const abilities = user?.abilities || []
39+
const canCreate = abilities.includes('comments:create')
40+
const canDelete = abilities.includes('comments:delete')
41+
const canManage = abilities.includes('comments:manage')
42+
43+
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } =
44+
useInfiniteQuery({
45+
queryKey: ['comments', snippetId],
46+
queryFn: ({ pageParam = 1 }) =>
47+
getComments({ snippetId, page: pageParam, perPage: 25 }),
48+
getNextPageParam: (lastPage) => {
49+
if (lastPage.meta.currentPage < lastPage.meta.lastPage) {
50+
return lastPage.meta.currentPage + 1
51+
}
52+
return undefined
53+
},
54+
enabled: !!snippetId,
55+
retry: (failureCount, error: any) => {
56+
if (error?.status >= 400 && error?.status < 500) {
57+
return false
58+
}
59+
return failureCount < 2
60+
},
61+
initialPageParam: 1,
62+
})
63+
64+
useEffect(() => {
65+
const observer = new IntersectionObserver(
66+
(entries) => {
67+
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
68+
fetchNextPage()
69+
}
70+
},
71+
{ threshold: 0.1 },
72+
)
73+
74+
const currentTarget = observerTarget.current
75+
if (currentTarget) {
76+
observer.observe(currentTarget)
77+
}
78+
79+
return () => {
80+
if (currentTarget) {
81+
observer.unobserve(currentTarget)
82+
}
83+
}
84+
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
85+
86+
const createMutation = useMutation({
87+
mutationFn: createComment,
88+
onSuccess: () => {
89+
if (user) {
90+
const newComment: Comment = {
91+
id: `temp-${Date.now()}`,
92+
content: content.trim(),
93+
user: {
94+
id: user.id,
95+
username: user.username,
96+
email: user.email,
97+
isPrivileged: user.isPrivileged,
98+
abilities: user.abilities,
99+
},
100+
createdAt: new Date().toISOString(),
101+
}
102+
103+
queryClient.setQueryData(['comments', snippetId], (old: any) => {
104+
if (!old?.pages?.[0]) return old
105+
return {
106+
...old,
107+
pages: [
108+
{
109+
...old.pages[0],
110+
data: [newComment, ...old.pages[0].data],
111+
},
112+
...old.pages.slice(1),
113+
],
114+
}
115+
})
116+
}
117+
118+
setContent('')
119+
120+
queryClient.invalidateQueries({ queryKey: ['comments', snippetId] })
121+
},
122+
})
123+
124+
const deleteMutation = useMutation({
125+
mutationFn: deleteComment,
126+
onSuccess: () => {
127+
queryClient.invalidateQueries({ queryKey: ['comments', snippetId] })
128+
},
129+
})
130+
131+
const handleSubmit = async (e: React.FormEvent) => {
132+
e.preventDefault()
133+
if (!content.trim() || content.length > 1024) return
134+
135+
setIsSubmitting(true)
136+
try {
137+
await createMutation.mutateAsync({
138+
content: content.trim(),
139+
snippetId,
140+
})
141+
} finally {
142+
setIsSubmitting(false)
143+
}
144+
}
145+
146+
const handleDelete = async (commentId: string, commentUserId: string) => {
147+
const isOwnComment = commentUserId === user?.id
148+
const hasPermission = (isOwnComment && canDelete) || canManage
149+
150+
if (!hasPermission) return
151+
152+
if (!window.confirm('Are you sure you want to delete this comment?')) {
153+
return
154+
}
155+
156+
await deleteMutation.mutateAsync(commentId)
157+
}
158+
159+
const allComments = data?.pages.flatMap((page) => page.data) || []
160+
161+
return (
162+
<div className="mt-8 border-t border-gray-200 dark:border-gray-700 pt-6">
163+
<h2 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">
164+
Comments
165+
</h2>
166+
167+
{canCreate && user && (
168+
<form onSubmit={handleSubmit} className="mb-6">
169+
<div className="mb-2">
170+
<textarea
171+
value={content}
172+
onChange={(e) => setContent(e.target.value)}
173+
placeholder="Write a comment..."
174+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:text-white resize-none placeholder-gray-400 dark:placeholder-gray-500"
175+
rows={3}
176+
minLength={1}
177+
maxLength={1024}
178+
disabled={isSubmitting}
179+
/>
180+
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
181+
{content.length}/1024 characters
182+
</div>
183+
</div>
184+
<button
185+
type="submit"
186+
disabled={isSubmitting || !content.trim() || content.length > 1024}
187+
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
188+
>
189+
{isSubmitting ? 'Posting...' : 'Post Comment'}
190+
</button>
191+
</form>
192+
)}
193+
194+
{!user && (
195+
<div className="mb-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-md">
196+
<p className="text-gray-500 dark:text-gray-400">
197+
Please log in to post comments.
198+
</p>
199+
</div>
200+
)}
201+
202+
{isLoading && (
203+
<div className="space-y-4">
204+
<CommentSkeleton />
205+
<CommentSkeleton />
206+
<CommentSkeleton />
207+
</div>
208+
)}
209+
210+
{!isLoading && allComments.length === 0 && (
211+
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
212+
No comments yet. Be the first to comment!
213+
</div>
214+
)}
215+
216+
<div className="space-y-4">
217+
{allComments.map((comment: Comment) => {
218+
const isOwnComment = comment.user.id === user?.id
219+
const canDeleteThis = (isOwnComment && canDelete) || canManage
220+
221+
return (
222+
<div
223+
key={comment.id}
224+
className="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md"
225+
>
226+
<div className="flex items-start justify-between">
227+
<div className="flex-1">
228+
<div className="flex items-center gap-2 mb-2">
229+
<span className="font-semibold text-gray-900 dark:text-white">
230+
{comment.user.username}
231+
</span>
232+
{comment.createdAt && (
233+
<span className="text-sm text-gray-500 dark:text-gray-400">
234+
{new Date(comment.createdAt).toLocaleString()}
235+
</span>
236+
)}
237+
</div>
238+
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
239+
{comment.content}
240+
</p>
241+
</div>
242+
{canDeleteThis && (
243+
<button
244+
onClick={() => handleDelete(comment.id, comment.user.id)}
245+
className="ml-4 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
246+
>
247+
Delete
248+
</button>
249+
)}
250+
</div>
251+
</div>
252+
)
253+
})}
254+
</div>
255+
256+
{isFetchingNextPage && (
257+
<div className="space-y-4 mt-4">
258+
<CommentSkeleton />
259+
<CommentSkeleton />
260+
</div>
261+
)}
262+
263+
<div ref={observerTarget} className="h-4" />
264+
</div>
265+
)
266+
}

src/lib/api/comments.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { getApiUrl } from '../api-config'
2+
import type { CommentsResponse, CreateCommentData } from '../../types/comments'
3+
4+
export interface GetCommentsParams {
5+
snippetId: string
6+
page?: number
7+
perPage?: number
8+
}
9+
10+
export async function getComments(
11+
params: GetCommentsParams,
12+
): Promise<CommentsResponse> {
13+
const { snippetId, page = 1, perPage = 25 } = params
14+
15+
const url = new URL(getApiUrl(`/snippets/${snippetId}/comments`))
16+
url.searchParams.set('page', String(page))
17+
url.searchParams.set('perPage', String(perPage))
18+
19+
const response = await fetch(url.toString(), {
20+
credentials: 'include',
21+
})
22+
23+
if (!response.ok) {
24+
const error: any = new Error(`Failed to fetch comments: ${response.statusText}`)
25+
error.status = response.status
26+
throw error
27+
}
28+
29+
return response.json()
30+
}
31+
32+
export async function createComment(
33+
data: CreateCommentData,
34+
): Promise<void> {
35+
const response = await fetch(getApiUrl('/comments/create'), {
36+
method: 'POST',
37+
headers: {
38+
'Content-Type': 'application/json',
39+
},
40+
credentials: 'include',
41+
body: JSON.stringify(data),
42+
})
43+
44+
if (!response.ok) {
45+
const error = await response.json().catch(() => ({}))
46+
throw new Error(
47+
error.message || `Failed to create comment: ${response.statusText}`,
48+
)
49+
}
50+
}
51+
52+
export async function deleteComment(id: string): Promise<void> {
53+
const response = await fetch(getApiUrl(`/comments/${id}/delete`), {
54+
method: 'DELETE',
55+
credentials: 'include',
56+
})
57+
58+
if (!response.ok) {
59+
const error = await response.json().catch(() => ({}))
60+
throw new Error(
61+
error.message || `Failed to delete comment: ${response.statusText}`,
62+
)
63+
}
64+
}
65+
66+
export interface GetUserCommentsParams {
67+
userId: string
68+
page?: number
69+
perPage?: number
70+
}
71+
72+
export async function getUserComments(
73+
params: GetUserCommentsParams,
74+
): Promise<CommentsResponse> {
75+
const { userId, page = 1, perPage = 25 } = params
76+
77+
const url = new URL(getApiUrl(`/moderation/comments-by-user/${userId}`))
78+
url.searchParams.set('page', String(page))
79+
url.searchParams.set('perPage', String(perPage))
80+
81+
const response = await fetch(url.toString(), {
82+
credentials: 'include',
83+
})
84+
85+
if (!response.ok) {
86+
const error: any = new Error(`Failed to fetch user comments: ${response.statusText}`)
87+
error.status = response.status
88+
throw error
89+
}
90+
91+
return response.json()
92+
}

0 commit comments

Comments
 (0)