Files
rentall-app/frontend/src/pages/ForumPostDetail.tsx
2025-12-13 20:32:25 -05:00

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;