feat(frontend): Implement threaded comment UI
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
This commit is contained in:
parent
a75921d633
commit
6bb0af937a
1 changed files with 144 additions and 33 deletions
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue