essential forum code

This commit is contained in:
jackiettran
2025-11-11 16:55:00 -05:00
parent 4a4eee86a7
commit 825389228d
29 changed files with 2557 additions and 2861 deletions

View File

@@ -0,0 +1,397 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { forumAPI } from '../services/api';
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 [post, setPost] = useState<ForumPost | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState(false);
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) => {
if (!user) {
alert('Please log in to comment');
return;
}
try {
await forumAPI.createComment(id!, { content });
await fetchPost(); // Refresh to get new comment
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to post comment');
}
};
const handleReply = async (parentCommentId: string, content: string) => {
if (!user) {
alert('Please log in to reply');
return;
}
try {
await forumAPI.createComment(id!, { content, parentCommentId });
await fetchPost(); // Refresh to get new reply
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to post reply');
}
};
const handleEditComment = async (commentId: string, content: string) => {
try {
await forumAPI.updateComment(commentId, { content });
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 formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString();
};
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">
<div className="card-body">
<div className="d-flex justify-content-between align-items-start mb-3">
<div className="flex-grow-1">
{post.isPinned && (
<span className="badge bg-danger me-2">
<i className="bi bi-pin-angle-fill me-1"></i>
Pinned
</span>
)}
<h1 className="h3 mb-2">{post.title}</h1>
<div className="d-flex gap-2 mb-2">
<CategoryBadge category={post.category} />
<PostStatusBadge status={post.status} />
</div>
</div>
</div>
{post.tags && post.tags.length > 0 && (
<div className="mb-3">
{post.tags.map((tag) => (
<Link
key={tag.id}
to={`/forum?tag=${tag.tagName}`}
className="badge bg-light text-dark me-1 mb-1 text-decoration-none"
>
#{tag.tagName}
</Link>
))}
</div>
)}
<div className="mb-3">
<div className="d-flex align-items-center">
<div className="avatar bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2"
style={{ width: '40px', height: '40px' }}>
{post.author?.firstName?.charAt(0) || '?'}
</div>
<div>
<strong>
{post.author?.firstName || 'Unknown'} {post.author?.lastName || ''}
</strong>
<br />
<small className="text-muted">
Posted {formatDate(post.createdAt)}
{post.updatedAt !== post.createdAt && ' (edited)'}
</small>
</div>
</div>
</div>
<hr />
<div className="post-content mb-3" style={{ whiteSpace: 'pre-wrap' }}>
{post.content}
</div>
<div className="d-flex gap-3 text-muted small">
<span>
<i className="bi bi-chat me-1"></i>
{post.commentCount || 0} comments
</span>
<span>
<i className="bi bi-eye me-1"></i>
{post.viewCount || 0} views
</span>
</div>
{isAuthor && (
<>
<hr />
<div className="d-flex gap-2 flex-wrap">
{post.status === 'open' && (
<button
className="btn btn-sm btn-success"
onClick={() => handleStatusChange('solved')}
disabled={actionLoading}
>
<i className="bi bi-check-circle me-1"></i>
Mark as Solved
</button>
)}
{post.status !== 'closed' && (
<button
className="btn btn-sm btn-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-success"
onClick={() => handleStatusChange('open')}
disabled={actionLoading}
>
<i className="bi bi-arrow-counterclockwise me-1"></i>
Reopen Post
</button>
)}
<Link
to={`/forum/${post.id}/edit`}
className="btn btn-sm btn-outline-primary"
>
<i className="bi bi-pencil me-1"></i>
Edit
</Link>
<button
className="btn btn-sm btn-outline-danger"
onClick={handleDeletePost}
disabled={actionLoading}
>
<i className="bi bi-trash me-1"></i>
Delete
</button>
</div>
</>
)}
</div>
</div>
{/* Comments Section */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="bi bi-chat-dots me-2"></i>
Comments ({post.commentCount || 0})
</h5>
</div>
<div className="card-body">
{user ? (
<div className="mb-4">
<h6>Add a comment</h6>
<CommentForm
onSubmit={handleAddComment}
placeholder="Share your thoughts..."
buttonText="Post Comment"
/>
</div>
) : (
<div className="alert alert-info mb-4">
<i className="bi bi-info-circle me-2"></i>
<AuthButton mode="login" className="alert-link" asLink>Log in</AuthButton> to join the discussion.
</div>
)}
<hr />
{post.comments && post.comments.length > 0 ? (
<div className="comments-list">
{post.comments.map((comment: ForumComment) => (
<CommentThread
key={comment.id}
comment={comment}
onReply={handleReply}
onEdit={handleEditComment}
onDelete={handleDeleteComment}
currentUserId={user?.id}
/>
))}
</div>
) : (
<div className="text-center py-4 text-muted">
<i className="bi bi-chat display-4 d-block mb-2"></i>
<p>No comments yet. Be the first to comment!</p>
</div>
)}
</div>
</div>
</div>
{/* Sidebar */}
<div className="col-lg-4">
<div className="card mb-3">
<div className="card-header">
<h6 className="mb-0">About this post</h6>
</div>
<div className="card-body">
<div className="mb-2">
<small className="text-muted">Category:</small>
<div>
<CategoryBadge category={post.category} />
</div>
</div>
<div className="mb-2">
<small className="text-muted">Status:</small>
<div>
<PostStatusBadge status={post.status} />
</div>
</div>
<div className="mb-2">
<small className="text-muted">Created:</small>
<div>{formatDate(post.createdAt)}</div>
</div>
<div className="mb-2">
<small className="text-muted">Last updated:</small>
<div>{formatDate(post.updatedAt)}</div>
</div>
<div className="mb-2">
<small className="text-muted">Author:</small>
<div>
<Link to={`/users/${post.authorId}`}>
{post.author?.firstName || 'Unknown'} {post.author?.lastName || ''}
</Link>
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<h6 className="mb-0">Actions</h6>
</div>
<div className="card-body">
<div className="d-grid gap-2">
<Link to="/forum" className="btn btn-outline-secondary btn-sm">
<i className="bi bi-arrow-left me-2"></i>
Back to Forum
</Link>
{user && (
<Link to="/forum/create" className="btn btn-outline-primary btn-sm">
<i className="bi bi-plus-circle me-2"></i>
Create New Post
</Link>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ForumPostDetail;