| import { useState } from "react"; | |
| import { Link } from "react-router-dom"; | |
| import { useAuth } from "@/context/AuthContext"; | |
| import { Comment } from "@/types"; | |
| import { Loader2, MessageSquare, Edit2, Trash2, Check, X, Reply } from "lucide-react"; | |
| import { toast } from "react-toastify"; | |
| interface CommentSectionProps { | |
| comments: Comment[]; | |
| articleId: number; | |
| currentUser: any; | |
| onAddComment: (body: string, parentId?: number) => Promise<void>; | |
| onEditComment: (commentId: number, newBody: string) => Promise<void>; | |
| onDeleteComment: (commentId: number) => Promise<void>; | |
| onLoadMore: () => void; | |
| hasMore: boolean; | |
| loadingMore: boolean; | |
| commentLoading: boolean; | |
| } | |
| export function CommentSection({ | |
| comments, | |
| articleId, | |
| currentUser, | |
| onAddComment, | |
| onEditComment, | |
| onDeleteComment, | |
| onLoadMore, | |
| hasMore, | |
| loadingMore, | |
| commentLoading, | |
| }: CommentSectionProps) { | |
| const [newComment, setNewComment] = useState(""); | |
| const [editingId, setEditingId] = useState<number | null>(null); | |
| const [editBody, setEditBody] = useState(""); | |
| const [replyingTo, setReplyingTo] = useState<number | null>(null); | |
| const [replyBody, setReplyBody] = useState(""); | |
| const [submitting, setSubmitting] = useState(false); | |
| const topLevelComments = comments.filter(c => !c.parentId); | |
| const repliesMap = new Map<number, Comment[]>(); | |
| comments.forEach(c => { | |
| if (c.parentId) { | |
| const list = repliesMap.get(c.parentId) || []; | |
| list.push(c); | |
| repliesMap.set(c.parentId, list); | |
| } | |
| }); | |
| const formatDate = (dateString: string) => | |
| new Date(dateString).toLocaleString(undefined, { | |
| year: "numeric", | |
| month: "short", | |
| day: "numeric", | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }); | |
| const handleSubmit = async (parentId?: number) => { | |
| const body = parentId ? replyBody : newComment; | |
| if (!body.trim()) return toast.warning("Comment cannot be empty"); | |
| setSubmitting(true); | |
| try { | |
| await onAddComment(body, parentId); | |
| if (parentId) { | |
| setReplyBody(""); | |
| setReplyingTo(null); | |
| } else { | |
| setNewComment(""); | |
| } | |
| } catch { | |
| toast.error("Failed to post comment"); | |
| } finally { | |
| setSubmitting(false); | |
| } | |
| }; | |
| const startEdit = (comment: Comment) => { | |
| setEditingId(comment.id); | |
| setEditBody(comment.body); | |
| }; | |
| const cancelEdit = () => { | |
| setEditingId(null); | |
| setEditBody(""); | |
| }; | |
| const handleEdit = async (id: number) => { | |
| if (!editBody.trim()) return; | |
| try { | |
| await onEditComment(id, editBody); | |
| setEditingId(null); | |
| setEditBody(""); | |
| } catch { | |
| toast.error("Failed to edit comment"); | |
| } | |
| }; | |
| const startReply = (commentId: number) => setReplyingTo(commentId); | |
| const cancelReply = () => { | |
| setReplyingTo(null); | |
| setReplyBody(""); | |
| }; | |
| const renderComment = (comment: Comment, isReply = false) => { | |
| const isEditing = editingId === comment.id; | |
| const isOwn = currentUser?.username === comment.authorUsername; | |
| return ( | |
| <div key={comment.id} className={isReply ? "mt-3 pl-4 border-l-2 border-gray-200 dark:border-gray-700" : ""}> | |
| <div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm"> | |
| <div className="flex justify-between items-start"> | |
| <div> | |
| <span className="font-semibold">{comment.authorUsername}</span> | |
| <span className="text-xs text-gray-400 ml-2">{formatDate(comment.createdAt)}</span> | |
| {comment.editedAt && <span className="text-xs text-gray-400 ml-2">(edited)</span>} | |
| </div> | |
| {currentUser && isOwn && !isEditing && ( | |
| <div className="flex gap-2"> | |
| <button onClick={() => startEdit(comment)} className="text-gray-400 hover:text-blue-500"> | |
| <Edit2 className="w-4 h-4" /> | |
| </button> | |
| <button onClick={() => onDeleteComment(comment.id)} className="text-gray-400 hover:text-red-500"> | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| {isEditing ? ( | |
| <div className="mt-2"> | |
| <textarea | |
| className="w-full p-2 border rounded focus:ring-2 focus:ring-primary" | |
| value={editBody} | |
| onChange={(e) => setEditBody(e.target.value)} | |
| rows={3} | |
| autoFocus | |
| /> | |
| <div className="flex gap-2 mt-2"> | |
| <button onClick={() => handleEdit(comment.id)} className="text-green-600 text-sm flex items-center gap-1"> | |
| <Check className="w-4 h-4" /> Save | |
| </button> | |
| <button onClick={cancelEdit} className="text-gray-500 text-sm flex items-center gap-1"> | |
| <X className="w-4 h-4" /> Cancel | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <p className="mt-2 text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{comment.body}</p> | |
| )} | |
| {currentUser && !isEditing && !isReply && ( | |
| <button onClick={() => startReply(comment.id)} className="mt-2 text-xs text-primary hover:underline flex items-center gap-1"> | |
| <Reply className="w-3 h-3" /> Reply | |
| </button> | |
| )} | |
| {replyingTo === comment.id && ( | |
| <div className="mt-3 pl-4 border-l-2 border-gray-200 dark:border-gray-700"> | |
| <textarea | |
| className="w-full p-2 border rounded text-sm" | |
| rows={2} | |
| placeholder={`Reply to ${comment.authorUsername}...`} | |
| value={replyBody} | |
| onChange={(e) => setReplyBody(e.target.value)} | |
| autoFocus | |
| /> | |
| <div className="flex gap-2 mt-2"> | |
| <button | |
| onClick={() => handleSubmit(comment.id)} | |
| disabled={submitting || !replyBody.trim()} | |
| className="btn-primary px-3 py-1 text-xs rounded flex items-center gap-1" | |
| > | |
| {submitting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Reply className="w-3 h-3" />} | |
| Reply | |
| </button> | |
| <button onClick={cancelReply} className="text-gray-500 text-xs">Cancel</button> | |
| </div> | |
| </div> | |
| )} | |
| {repliesMap.has(comment.id) && ( | |
| <div className="mt-3 space-y-2"> | |
| {repliesMap.get(comment.id)!.map(reply => renderComment(reply, true))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="mt-10 border-t pt-8"> | |
| <h3 className="text-xl font-bold mb-4 flex items-center gap-2"> | |
| <MessageSquare className="w-5 h-5" /> Comments ({comments.length}) | |
| </h3> | |
| {currentUser ? ( | |
| <div className="mb-6 bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4"> | |
| <textarea | |
| className="w-full p-3 border rounded-lg resize-none focus:ring-2 focus:ring-primary" | |
| rows={3} | |
| placeholder="Add a comment..." | |
| value={newComment} | |
| onChange={(e) => setNewComment(e.target.value)} | |
| disabled={submitting || commentLoading} | |
| /> | |
| <button | |
| onClick={() => handleSubmit()} | |
| disabled={submitting || !newComment.trim()} | |
| className="mt-2 btn-primary px-4 py-2 rounded-lg text-sm disabled:opacity-50 flex items-center gap-1" | |
| > | |
| {submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <MessageSquare className="w-4 h-4" />} | |
| Post Comment | |
| </button> | |
| </div> | |
| ) : ( | |
| <div className="mb-6 rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600"> | |
| <p className="font-medium text-slate-900">Commenting is for signed-in users.</p> | |
| <p className="mt-1"> | |
| You can still read the full discussion.{" "} | |
| <Link to="/login" className="text-primary font-semibold hover:underline"> | |
| Sign in | |
| </Link>{" "} | |
| to post a comment or reply. | |
| </p> | |
| </div> | |
| )} | |
| <div className="space-y-4"> | |
| {topLevelComments.map(comment => renderComment(comment, false))} | |
| {hasMore && ( | |
| <div className="text-center py-4"> | |
| <button onClick={onLoadMore} disabled={loadingMore} className="text-sm text-primary hover:underline flex items-center gap-1 mx-auto"> | |
| {loadingMore ? <Loader2 className="w-4 h-4 animate-spin" /> : "Load more comments"} | |
| </button> | |
| </div> | |
| )} | |
| {comments.length === 0 && !commentLoading && ( | |
| <div className="text-center py-8 text-gray-400"> | |
| <MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-30" /> | |
| <p>No comments yet. Be the first to share your thoughts!</p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 8.99 kB
- Xet hash:
- 30184bd1c8ff6d2749fa66beec7b22d8f84a23eaf73756691cdd6189f2151519
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.