682 lines
25 KiB
TypeScript
682 lines
25 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate, Link, useSearchParams } from 'react-router-dom';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { forumAPI } from '../services/api';
|
|
import { uploadFiles, getPublicImageUrl } from '../services/uploadService';
|
|
import { ForumPost, ForumComment } from '../types';
|
|
import CategoryBadge from '../components/CategoryBadge';
|
|
import PostStatusBadge from '../components/PostStatusBadge';
|
|
import CommentThread from '../components/CommentThread';
|
|
import CommentForm from '../components/CommentForm';
|
|
import AuthButton from '../components/AuthButton';
|
|
|
|
const ForumPostDetail: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const { user } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const [post, setPost] = useState<ForumPost | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [actionLoading, setActionLoading] = useState(false);
|
|
const [showAdminModal, setShowAdminModal] = useState(false);
|
|
const [adminAction, setAdminAction] = useState<{
|
|
type: 'deletePost' | 'deleteComment' | 'restorePost' | 'restoreComment' | 'closePost' | 'reopenPost';
|
|
id?: string;
|
|
} | null>(null);
|
|
const [deletionReason, setDeletionReason] = useState('');
|
|
|
|
// Read filter from URL query param
|
|
const filter = searchParams.get('filter') || 'active';
|
|
const isAdmin = user?.role === 'admin';
|
|
|
|
useEffect(() => {
|
|
if (id) {
|
|
fetchPost();
|
|
}
|
|
}, [id]);
|
|
|
|
const fetchPost = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await forumAPI.getPost(id!);
|
|
setPost(response.data);
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.error || 'Failed to fetch post');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAddComment = async (content: string, images: File[]) => {
|
|
if (!user) {
|
|
alert('Please log in to comment');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Upload images to S3 first (if any)
|
|
let imageFilenames: string[] = [];
|
|
if (images.length > 0) {
|
|
const uploadResults = await uploadFiles("forum", images);
|
|
imageFilenames = uploadResults.map((result) => result.key);
|
|
}
|
|
|
|
await forumAPI.createComment(id!, {
|
|
content,
|
|
imageFilenames: imageFilenames.length > 0 ? imageFilenames : undefined,
|
|
});
|
|
await fetchPost(); // Refresh to get new comment
|
|
} catch (err: any) {
|
|
throw new Error(err.response?.data?.error || err.message || 'Failed to post comment');
|
|
}
|
|
};
|
|
|
|
const handleReply = async (parentCommentId: string, content: string, images: File[] = []) => {
|
|
if (!user) {
|
|
alert('Please log in to reply');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Upload images to S3 first (if any)
|
|
let imageFilenames: string[] = [];
|
|
if (images.length > 0) {
|
|
const uploadResults = await uploadFiles("forum", images);
|
|
imageFilenames = uploadResults.map((result) => result.key);
|
|
}
|
|
|
|
await forumAPI.createComment(id!, {
|
|
content,
|
|
parentId: parentCommentId,
|
|
imageFilenames: imageFilenames.length > 0 ? imageFilenames : undefined,
|
|
});
|
|
await fetchPost(); // Refresh to get new reply
|
|
} catch (err: any) {
|
|
throw new Error(err.response?.data?.error || err.message || 'Failed to post reply');
|
|
}
|
|
};
|
|
|
|
const handleEditComment = async (
|
|
commentId: string,
|
|
content: string,
|
|
existingImageKeys: string[],
|
|
newImageFiles: File[]
|
|
) => {
|
|
try {
|
|
// Upload new images to S3
|
|
let newImageFilenames: string[] = [];
|
|
if (newImageFiles.length > 0) {
|
|
const uploadResults = await uploadFiles("forum", newImageFiles);
|
|
newImageFilenames = uploadResults.map((result) => result.key);
|
|
}
|
|
|
|
// Combine existing and new image keys
|
|
const allImageKeys = [...existingImageKeys, ...newImageFilenames];
|
|
|
|
await forumAPI.updateComment(commentId, {
|
|
content,
|
|
imageFilenames: allImageKeys,
|
|
});
|
|
await fetchPost(); // Refresh to get updated comment
|
|
} catch (err: any) {
|
|
throw new Error(err.response?.data?.error || 'Failed to update comment');
|
|
}
|
|
};
|
|
|
|
const handleDeleteComment = async (commentId: string) => {
|
|
try {
|
|
await forumAPI.deleteComment(commentId);
|
|
await fetchPost(); // Refresh to remove deleted comment
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.error || 'Failed to delete comment');
|
|
}
|
|
};
|
|
|
|
const handleStatusChange = async (newStatus: string) => {
|
|
try {
|
|
setActionLoading(true);
|
|
await forumAPI.updatePostStatus(id!, newStatus);
|
|
await fetchPost();
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.error || 'Failed to update status');
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDeletePost = async () => {
|
|
if (!window.confirm('Are you sure you want to delete this post? This action cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setActionLoading(true);
|
|
await forumAPI.deletePost(id!);
|
|
navigate('/forum');
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.error || 'Failed to delete post');
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleMarkAsAnswer = async (commentId: string) => {
|
|
try {
|
|
// If this comment is already the accepted answer, unmark it
|
|
const newCommentId = post?.acceptedAnswerId === commentId ? null : commentId;
|
|
await forumAPI.acceptAnswer(id!, newCommentId);
|
|
await fetchPost(); // Refresh to get updated post
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.error || 'Failed to mark answer');
|
|
}
|
|
};
|
|
|
|
const handleAdminDeletePost = async () => {
|
|
setAdminAction({ type: 'deletePost' });
|
|
setShowAdminModal(true);
|
|
};
|
|
|
|
const handleAdminRestorePost = async () => {
|
|
setAdminAction({ type: 'restorePost' });
|
|
setShowAdminModal(true);
|
|
};
|
|
|
|
const handleAdminClosePost = async () => {
|
|
setAdminAction({ type: 'closePost' });
|
|
setShowAdminModal(true);
|
|
};
|
|
|
|
const handleAdminReopenPost = async () => {
|
|
setAdminAction({ type: 'reopenPost' });
|
|
setShowAdminModal(true);
|
|
};
|
|
|
|
const handleAdminDeleteComment = async (commentId: string) => {
|
|
setAdminAction({ type: 'deleteComment', id: commentId });
|
|
setShowAdminModal(true);
|
|
};
|
|
|
|
const handleAdminRestoreComment = async (commentId: string) => {
|
|
setAdminAction({ type: 'restoreComment', id: commentId });
|
|
setShowAdminModal(true);
|
|
};
|
|
|
|
const confirmAdminAction = async () => {
|
|
if (!adminAction) return;
|
|
|
|
// Validate deletion reason for delete actions
|
|
if ((adminAction.type === 'deletePost' || adminAction.type === 'deleteComment') && !deletionReason.trim()) {
|
|
alert('Please provide a reason for deletion');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setActionLoading(true);
|
|
setShowAdminModal(false);
|
|
|
|
switch (adminAction.type) {
|
|
case 'deletePost':
|
|
await forumAPI.adminDeletePost(id!, deletionReason.trim());
|
|
break;
|
|
case 'restorePost':
|
|
await forumAPI.adminRestorePost(id!);
|
|
break;
|
|
case 'closePost':
|
|
await forumAPI.adminClosePost(id!);
|
|
break;
|
|
case 'reopenPost':
|
|
await forumAPI.adminReopenPost(id!);
|
|
break;
|
|
case 'deleteComment':
|
|
await forumAPI.adminDeleteComment(adminAction.id!, deletionReason.trim());
|
|
break;
|
|
case 'restoreComment':
|
|
await forumAPI.adminRestoreComment(adminAction.id!);
|
|
break;
|
|
}
|
|
|
|
await fetchPost(); // Refresh to show updated status
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.error || 'Failed to perform admin action');
|
|
} finally {
|
|
setActionLoading(false);
|
|
setAdminAction(null);
|
|
setDeletionReason('');
|
|
}
|
|
};
|
|
|
|
const cancelAdminAction = () => {
|
|
setShowAdminModal(false);
|
|
setAdminAction(null);
|
|
setDeletionReason('');
|
|
};
|
|
|
|
const handleCopyLink = async () => {
|
|
const shareUrl = `${window.location.origin}/forum/${post?.id}`;
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
} catch (err) {
|
|
console.error("Copy to clipboard failed:", err);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleString(undefined, {
|
|
year: 'numeric',
|
|
month: 'numeric',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit'
|
|
});
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="container mt-4">
|
|
<div className="text-center py-5">
|
|
<div className="spinner-border" role="status">
|
|
<span className="visually-hidden">Loading...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !post) {
|
|
return (
|
|
<div className="container mt-4">
|
|
<div className="alert alert-danger" role="alert">
|
|
{error || 'Post not found'}
|
|
</div>
|
|
<Link to="/forum" className="btn btn-secondary">
|
|
<i className="bi bi-arrow-left me-2"></i>
|
|
Back to Forum
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isAuthor = user?.id === post.authorId;
|
|
|
|
return (
|
|
<div className="container mt-4">
|
|
<nav aria-label="breadcrumb" className="mb-3">
|
|
<ol className="breadcrumb">
|
|
<li className="breadcrumb-item">
|
|
<Link to="/forum">Forum</Link>
|
|
</li>
|
|
<li className="breadcrumb-item active" aria-current="page">
|
|
{post.title}
|
|
</li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<div className="row">
|
|
<div className="col-lg-8">
|
|
{/* Post Content */}
|
|
<div className={`card mb-4 ${post.isDeleted && isAdmin ? 'border-danger' : ''}`}>
|
|
<div className="card-body">
|
|
{post.isDeleted && isAdmin && (
|
|
<div className="alert alert-danger mb-3">
|
|
<i className="bi bi-eye-slash me-2"></i>
|
|
<strong>Deleted by Admin</strong> - This post is deleted and hidden from regular users
|
|
{post.deletedAt && ` on ${formatDate(post.deletedAt)}`}
|
|
</div>
|
|
)}
|
|
{post.isPinned && (
|
|
<span className="badge bg-danger me-2 mb-2">
|
|
<i className="bi bi-pin-angle-fill me-1"></i>
|
|
Pinned
|
|
</span>
|
|
)}
|
|
<div className="d-flex align-items-center justify-content-between mb-2">
|
|
<h1 className="h3 mb-0">{post.title}</h1>
|
|
<button
|
|
className="btn btn-outline-secondary btn-sm"
|
|
onClick={handleCopyLink}
|
|
aria-label="Copy link to this post"
|
|
>
|
|
<i className="bi bi-link-45deg me-2"></i>
|
|
Copy Link
|
|
</button>
|
|
</div>
|
|
|
|
<div className="d-flex gap-2 mb-2 flex-wrap">
|
|
<div className="d-flex gap-2">
|
|
<CategoryBadge category={post.category} />
|
|
<PostStatusBadge status={post.status} />
|
|
</div>
|
|
{(post.tags || []).map((tag) => (
|
|
<Link
|
|
key={tag.id}
|
|
to={`/forum?tag=${tag.tagName}`}
|
|
className="badge bg-light text-dark text-decoration-none"
|
|
>
|
|
#{tag.tagName}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
<div className="text-muted small mb-3">
|
|
By <strong>{post.author?.firstName || 'Unknown'} {post.author?.lastName || ''}</strong>
|
|
{' • '}
|
|
Posted {formatDate(post.createdAt)}
|
|
{post.updatedAt !== post.createdAt && ' (edited)'}
|
|
{' • '}
|
|
{post.commentCount || 0} comments
|
|
</div>
|
|
|
|
<div className="post-content mb-3" style={{ whiteSpace: 'pre-wrap' }}>
|
|
{post.content}
|
|
</div>
|
|
|
|
{post.imageFilenames && post.imageFilenames.length > 0 && (
|
|
<div className="row g-2 mb-3">
|
|
{post.imageFilenames.map((image, index) => (
|
|
<div key={index} className="col-6 col-md-4">
|
|
<img
|
|
src={getPublicImageUrl(image)}
|
|
alt={`Post image`}
|
|
className="img-fluid rounded"
|
|
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain', cursor: 'pointer' }}
|
|
onClick={() => window.open(getPublicImageUrl(image), '_blank')}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{isAuthor && (
|
|
<div className="d-flex gap-2 flex-wrap mb-3">
|
|
<Link
|
|
to={`/forum/${post.id}/edit`}
|
|
className="btn btn-sm btn-outline-primary"
|
|
>
|
|
<i className="bi bi-pencil me-1"></i>
|
|
Edit
|
|
</Link>
|
|
{post.status !== 'closed' && (
|
|
<button
|
|
className="btn btn-sm btn-outline-secondary"
|
|
onClick={() => handleStatusChange('closed')}
|
|
disabled={actionLoading}
|
|
>
|
|
<i className="bi bi-x-circle me-1"></i>
|
|
Close Post
|
|
</button>
|
|
)}
|
|
{post.status === 'closed' && (
|
|
<button
|
|
className="btn btn-sm btn-outline-secondary"
|
|
onClick={() => handleStatusChange('open')}
|
|
disabled={actionLoading}
|
|
>
|
|
<i className="bi bi-arrow-counterclockwise me-1"></i>
|
|
Reopen Post
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-sm btn-outline-danger"
|
|
onClick={handleDeletePost}
|
|
disabled={actionLoading}
|
|
>
|
|
<i className="bi bi-trash me-1"></i>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isAdmin && (
|
|
<div className="d-flex gap-2 flex-wrap mb-3">
|
|
{!post.isDeleted ? (
|
|
<button
|
|
className="btn btn-danger"
|
|
onClick={handleAdminDeletePost}
|
|
disabled={actionLoading}
|
|
>
|
|
<i className="bi bi-trash me-1"></i>
|
|
Delete Post (Admin)
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="btn btn-success"
|
|
onClick={handleAdminRestorePost}
|
|
disabled={actionLoading}
|
|
>
|
|
<i className="bi bi-arrow-counterclockwise me-1"></i>
|
|
Restore Post (Admin)
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<hr />
|
|
|
|
{/* Comments Section */}
|
|
<div className="mt-4">
|
|
<h5 className="mb-3">
|
|
<i className="bi bi-chat-dots me-2"></i>
|
|
Comments ({post.commentCount || 0})
|
|
</h5>
|
|
|
|
{post.comments && post.comments.length > 0 ? (
|
|
<div className="comments-list mb-4">
|
|
{[...post.comments]
|
|
.sort((a, b) => {
|
|
// Sort accepted answer to the top
|
|
if (a.id === post.acceptedAnswerId) return -1;
|
|
if (b.id === post.acceptedAnswerId) return 1;
|
|
return 0;
|
|
})
|
|
.filter((comment: ForumComment) => {
|
|
// Filter comments based on deletion status (admin only)
|
|
if (!isAdmin) return true; // Non-admins see all non-deleted (backend already filters)
|
|
if (filter === 'deleted') return comment.isDeleted; // Only show deleted comments
|
|
return true; // 'active' and 'all' show all comments
|
|
})
|
|
.map((comment: ForumComment) => (
|
|
<CommentThread
|
|
key={comment.id}
|
|
comment={comment}
|
|
onReply={handleReply}
|
|
onEdit={handleEditComment}
|
|
onDelete={handleDeleteComment}
|
|
onMarkAsAnswer={handleMarkAsAnswer}
|
|
currentUserId={user?.id}
|
|
isPostAuthor={isAuthor}
|
|
acceptedAnswerId={post.acceptedAnswerId}
|
|
isAdmin={isAdmin}
|
|
onAdminDelete={handleAdminDeleteComment}
|
|
onAdminRestore={handleAdminRestoreComment}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-4 text-muted mb-4">
|
|
<i className="bi bi-chat display-4 d-block mb-2"></i>
|
|
<p>No comments yet. Be the first to comment!</p>
|
|
</div>
|
|
)}
|
|
|
|
<hr />
|
|
|
|
{/* Admin Close/Reopen Controls */}
|
|
{isAdmin && (
|
|
<div className="d-flex gap-2 mb-3">
|
|
{post.status === 'closed' ? (
|
|
<button
|
|
className="btn btn-success"
|
|
onClick={handleAdminReopenPost}
|
|
disabled={actionLoading}
|
|
>
|
|
<i className="bi bi-unlock me-1"></i>
|
|
Reopen Discussion (Admin)
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="btn btn-warning"
|
|
onClick={handleAdminClosePost}
|
|
disabled={actionLoading}
|
|
>
|
|
<i className="bi bi-lock me-1"></i>
|
|
Close Discussion (Admin)
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{post.status !== 'closed' && user ? (
|
|
<div>
|
|
<h6>Add a comment</h6>
|
|
<CommentForm
|
|
onSubmit={handleAddComment}
|
|
placeholder="Share your thoughts..."
|
|
buttonText="Post Comment"
|
|
/>
|
|
</div>
|
|
) : post.status !== 'closed' && !user ? (
|
|
<div className="alert alert-info">
|
|
<i className="bi bi-info-circle me-2"></i>
|
|
<AuthButton mode="login" className="alert-link" asLink>Log in</AuthButton> to join the discussion.
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Show closed banner at the bottom for all users */}
|
|
{post.status === 'closed' && post.closedBy && (
|
|
<div className="text-muted mt-3">
|
|
<i className="bi bi-lock me-2"></i>
|
|
<strong>
|
|
Closed by {post.closer ? `${post.closer.firstName} ${post.closer.lastName}` : 'Unknown'}
|
|
</strong>
|
|
{post.closedAt && ` on ${formatDate(post.closedAt)}`}
|
|
{' - '}No new comments can be added
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Admin Action Confirmation Modal */}
|
|
{showAdminModal && (
|
|
<div className="modal fade show d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
<div className="modal-dialog modal-dialog-centered">
|
|
<div className="modal-content">
|
|
<div className="modal-header">
|
|
<h5 className="modal-title">
|
|
<i className="bi bi-shield-exclamation me-2"></i>
|
|
Confirm Admin Action
|
|
</h5>
|
|
<button
|
|
type="button"
|
|
className="btn-close"
|
|
onClick={cancelAdminAction}
|
|
disabled={actionLoading}
|
|
></button>
|
|
</div>
|
|
<div className="modal-body">
|
|
{adminAction?.type === 'deletePost' && (
|
|
<>
|
|
<p>Are you sure you want to delete this post? It will be deleted and hidden from regular users but can be restored later.</p>
|
|
<div className="mb-3">
|
|
<label htmlFor="deletionReason" className="form-label">
|
|
<strong>Reason for deletion <span className="text-danger">*</span></strong>
|
|
</label>
|
|
<textarea
|
|
id="deletionReason"
|
|
className="form-control"
|
|
rows={4}
|
|
placeholder="Please explain why this post is being deleted. The author will receive this reason via email."
|
|
value={deletionReason}
|
|
onChange={(e) => setDeletionReason(e.target.value)}
|
|
required
|
|
/>
|
|
<small className="text-muted">
|
|
This reason will be sent to the post author via email.
|
|
</small>
|
|
</div>
|
|
</>
|
|
)}
|
|
{adminAction?.type === 'restorePost' && (
|
|
<p>Are you sure you want to restore this post? It will become visible to all users again.</p>
|
|
)}
|
|
{adminAction?.type === 'closePost' && (
|
|
<p>Are you sure you want to close this discussion? No users will be able to add new comments. All participants will be notified by email.</p>
|
|
)}
|
|
{adminAction?.type === 'reopenPost' && (
|
|
<p>Are you sure you want to reopen this discussion? Users will be able to add comments again.</p>
|
|
)}
|
|
{adminAction?.type === 'deleteComment' && (
|
|
<>
|
|
<p>Are you sure you want to delete this comment? It will be deleted and hidden from regular users but can be restored later.</p>
|
|
<div className="mb-3">
|
|
<label htmlFor="deletionReason" className="form-label">
|
|
<strong>Reason for deletion <span className="text-danger">*</span></strong>
|
|
</label>
|
|
<textarea
|
|
id="deletionReason"
|
|
className="form-control"
|
|
rows={4}
|
|
placeholder="Please explain why this comment is being deleted. The author will receive this reason via email."
|
|
value={deletionReason}
|
|
onChange={(e) => setDeletionReason(e.target.value)}
|
|
required
|
|
/>
|
|
<small className="text-muted">
|
|
This reason will be sent to the comment author via email.
|
|
</small>
|
|
</div>
|
|
</>
|
|
)}
|
|
{adminAction?.type === 'restoreComment' && (
|
|
<p>Are you sure you want to restore this comment? It will become visible to all users again.</p>
|
|
)}
|
|
</div>
|
|
<div className="modal-footer">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={cancelAdminAction}
|
|
disabled={actionLoading}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`btn ${
|
|
adminAction?.type.includes('delete') ? 'btn-danger' :
|
|
adminAction?.type === 'closePost' ? 'btn-warning' :
|
|
'btn-success'
|
|
}`}
|
|
onClick={confirmAdminAction}
|
|
disabled={actionLoading}
|
|
>
|
|
{actionLoading ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
|
Processing...
|
|
</>
|
|
) : (
|
|
<>
|
|
{adminAction?.type.includes('delete') ? 'Delete' :
|
|
adminAction?.type === 'closePost' ? 'Close' :
|
|
adminAction?.type === 'reopenPost' ? 'Reopen' :
|
|
'Restore'}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ForumPostDetail;
|