feat(frontend): Implement threaded comment UI
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-21 02:44:26 -08:00
parent a75921d633
commit 6bb0af937a

View file

@ -3,15 +3,18 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { MessageCircle, CornerDownRight, Heart } from "lucide-react"
import { cn } from "@/lib/utils"
interface Comment { interface Comment {
id: number id: number
user_id: number user_id: number
content: string content: string
created_at: string created_at: string
parent_id: number | null
} }
interface CommentSectionProps { interface CommentSectionProps {
@ -30,7 +33,7 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
const fetchComments = async () => { const fetchComments = async () => {
try { try {
const res = await fetch(`${getApiUrl()}/social/comments?${entityType}_id=${entityId}`) const res = await fetch(`${getApiUrl()}/social/comments?${entityType}_id=${entityId}&limit=100`)
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setComments(data) setComments(data)
@ -40,9 +43,9 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
} }
} }
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent, parentId: number | null = null, content: string = newComment) => {
e.preventDefault() e.preventDefault()
if (!newComment.trim()) return if (!content.trim()) return
const token = localStorage.getItem("token") const token = localStorage.getItem("token")
if (!token) { if (!token) {
@ -52,7 +55,10 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
setLoading(true) setLoading(true)
try { try {
const body: any = { content: newComment } const body: any = {
content: content,
parent_id: parentId
}
body[`${entityType}_id`] = entityId body[`${entityType}_id`] = entityId
const res = await fetch(`${getApiUrl()}/social/comments`, { const res = await fetch(`${getApiUrl()}/social/comments`, {
@ -77,41 +83,146 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
} }
} }
// Tree builder
const rootComments = comments.filter(c => c.parent_id === null)
const getReplies = (parentId: number) => comments.filter(c => c.parent_id === parentId).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<h3 className="text-xl font-semibold">Comments</h3> <h3 className="text-xl font-semibold flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
Discussion
</h3>
<form onSubmit={handleSubmit} className="space-y-4"> {/* Root Post Form */}
<Textarea <div className="flex gap-4">
placeholder="Join the discussion..." <div className="flex-1">
value={newComment} <form onSubmit={(e) => handleSubmit(e)} className="space-y-4">
onChange={(e) => setNewComment(e.target.value)} <Textarea
required placeholder="Share your thoughts..."
/> value={newComment}
<Button type="submit" disabled={loading || !newComment.trim()}> onChange={(e) => setNewComment(e.target.value)}
{loading ? "Posting..." : "Post Comment"} required
</Button> className="min-h-[100px]"
</form> />
<div className="flex justify-end">
<Button type="submit" disabled={loading || !newComment.trim()}>
{loading ? "Posting..." : "Post Comment"}
</Button>
</div>
</form>
</div>
</div>
<div className="space-y-4"> <div className="space-y-6">
{comments.length === 0 ? ( {rootComments.length === 0 ? (
<p className="text-muted-foreground">No comments yet.</p> <p className="text-muted-foreground text-center py-8">No comments yet. Be the first!</p>
) : ( ) : (
comments.map(comment => ( rootComments.map(comment => (
<Card key={comment.id}> <CommentItem
<CardContent className="pt-4"> key={comment.id}
<div className="flex justify-between items-start mb-2"> comment={comment}
<span className="font-semibold text-sm">User #{comment.user_id}</span> getReplies={getReplies}
<span className="text-xs text-muted-foreground"> onReply={handleSubmit}
{formatDistanceToNow(new Date(comment.created_at), { addSuffix: true })} />
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
</CardContent>
</Card>
)) ))
)} )}
</div> </div>
</div> </div>
) )
} }
function CommentItem({ comment, getReplies, onReply }: {
comment: Comment,
getReplies: (id: number) => Comment[],
onReply: (e: React.FormEvent, parentId: number, content: string) => Promise<void>
}) {
const [isReplying, setIsReplying] = useState(false)
const [replyContent, setReplyContent] = useState("")
const [submitting, setSubmitting] = useState(false)
const replies = getReplies(comment.id)
const handleReplySubmit = async (e: React.FormEvent) => {
setSubmitting(true)
await onReply(e, comment.id, replyContent)
setSubmitting(false)
setIsReplying(false)
setReplyContent("")
}
return (
<div className="group">
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
{comment.user_id}
</div>
{(replies.length > 0) && <div className="w-px h-full bg-border my-2" />}
</div>
<div className="flex-1 space-y-2 pb-6">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">User #{comment.user_id}</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.created_at), { addSuffix: true })}
</span>
</div>
<div className="text-sm leading-relaxed whitespace-pre-wrap">
{comment.content}
</div>
<div className="flex items-center gap-4 pt-1">
<Button
variant="ghost"
size="sm"
className={cn("h-6 px-2 text-xs text-muted-foreground gap-1 hover:text-primary", isReplying && "text-primary bg-primary/5")}
onClick={() => setIsReplying(!isReplying)}
>
<MessageCircle className="h-3 w-3" />
Reply
</Button>
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground gap-1 hover:text-red-500">
<Heart className="h-3 w-3" />
Like
</Button>
</div>
{isReplying && (
<div className="mt-4 pl-4 border-l-2 border-primary/20">
<form onSubmit={handleReplySubmit} className="space-y-3">
<Textarea
placeholder={`Reply to User #${comment.user_id}...`}
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
autoFocus
className="min-h-[80px]"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setIsReplying(false)}>Cancel</Button>
<Button type="submit" size="sm" disabled={submitting || !replyContent.trim()}>
Reply
</Button>
</div>
</form>
</div>
)}
{/* Recursive Replies */}
{replies.length > 0 && (
<div className="mt-4 space-y-4">
{replies.map(reply => (
<CommentItem
key={reply.id}
comment={reply}
getReplies={getReplies}
onReply={onReply}
/>
))}
</div>
)}
</div>
</div>
</div>
)
}