tahamajs/IE / Projects /front /src /components /article /CommentSection.tsx
tahamajs's picture
download
raw
8.99 kB
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.