Can mark a comment as the answer, some layout changes
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ForumComment } from '../types';
|
||||
import CommentForm from './CommentForm';
|
||||
import React, { useState } from "react";
|
||||
import { ForumComment } from "../types";
|
||||
import CommentForm from "./CommentForm";
|
||||
|
||||
interface CommentThreadProps {
|
||||
comment: ForumComment;
|
||||
onReply: (commentId: string, content: string) => Promise<void>;
|
||||
onEdit?: (commentId: string, content: string) => Promise<void>;
|
||||
onDelete?: (commentId: string) => Promise<void>;
|
||||
onMarkAsAnswer?: (commentId: string) => Promise<void>;
|
||||
currentUserId?: string;
|
||||
isPostAuthor?: boolean;
|
||||
acceptedAnswerId?: string;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
@@ -16,7 +19,10 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
onReply,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMarkAsAnswer,
|
||||
currentUserId,
|
||||
isPostAuthor = false,
|
||||
acceptedAnswerId,
|
||||
depth = 0,
|
||||
}) => {
|
||||
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||
@@ -33,7 +39,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return 'Just now';
|
||||
return "Just now";
|
||||
} else if (diffMinutes < 60) {
|
||||
return `${diffMinutes}m ago`;
|
||||
} else if (diffHours < 24) {
|
||||
@@ -60,7 +66,10 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (onDelete && window.confirm('Are you sure you want to delete this comment?')) {
|
||||
if (
|
||||
onDelete &&
|
||||
window.confirm("Are you sure you want to delete this comment?")
|
||||
) {
|
||||
await onDelete(comment.id);
|
||||
}
|
||||
};
|
||||
@@ -68,15 +77,22 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
const isAuthor = currentUserId === comment.authorId;
|
||||
const maxDepth = 5;
|
||||
const canNest = depth < maxDepth;
|
||||
const isAcceptedAnswer = acceptedAnswerId === comment.id;
|
||||
const canMarkAsAnswer =
|
||||
isPostAuthor && depth === 0 && onMarkAsAnswer && !comment.isDeleted;
|
||||
|
||||
const handleMarkAsAnswer = async () => {
|
||||
if (onMarkAsAnswer) {
|
||||
await onMarkAsAnswer(comment.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (comment.isDeleted) {
|
||||
return (
|
||||
<div className={`comment-deleted ${depth > 0 ? 'ms-4' : ''} mb-3`}>
|
||||
<div className={`comment-deleted ${depth > 0 ? "ms-4" : ""} mb-3`}>
|
||||
<div className="card bg-light">
|
||||
<div className="card-body py-2">
|
||||
<small className="text-muted fst-italic">
|
||||
[Comment deleted]
|
||||
</small>
|
||||
<small className="text-muted fst-italic">[Comment deleted]</small>
|
||||
</div>
|
||||
</div>
|
||||
{comment.replies && comment.replies.length > 0 && (
|
||||
@@ -88,7 +104,10 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
onReply={onReply}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onMarkAsAnswer={onMarkAsAnswer}
|
||||
currentUserId={currentUserId}
|
||||
isPostAuthor={isPostAuthor}
|
||||
acceptedAnswerId={acceptedAnswerId}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
@@ -99,22 +118,33 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`comment ${depth > 0 ? 'ms-4' : ''} mb-3`}>
|
||||
<div className="card">
|
||||
<div className={`comment ${depth > 0 ? "ms-4" : ""} mb-3`}>
|
||||
<div className={`card ${isAcceptedAnswer ? "border-success" : ""}`}>
|
||||
<div className="card-body">
|
||||
{isAcceptedAnswer && (
|
||||
<div className="mb-2">
|
||||
<span className="badge bg-success">
|
||||
<i className="bi bi-check-circle-fill me-1"></i>
|
||||
Answer
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<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: '32px', height: '32px', fontSize: '14px' }}>
|
||||
{comment.author?.firstName?.charAt(0) || '?'}
|
||||
<div
|
||||
className="avatar bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2"
|
||||
style={{ width: "32px", height: "32px", fontSize: "14px" }}
|
||||
>
|
||||
{comment.author?.firstName?.charAt(0) || "?"}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="d-block">
|
||||
{comment.author?.firstName || 'Unknown'} {comment.author?.lastName || ''}
|
||||
{comment.author?.firstName || "Unknown"}{" "}
|
||||
{comment.author?.lastName || ""}
|
||||
</strong>
|
||||
<small className="text-muted">
|
||||
{formatDate(comment.createdAt)}
|
||||
{comment.updatedAt !== comment.createdAt && ' (edited)'}
|
||||
{comment.updatedAt !== comment.createdAt && " (edited)"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,7 +153,9 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
className="btn btn-sm btn-link text-decoration-none"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<i className={`bi bi-chevron-${isCollapsed ? 'down' : 'up'}`}></i>
|
||||
<i
|
||||
className={`bi bi-chevron-${isCollapsed ? "down" : "up"}`}
|
||||
></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -156,7 +188,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="card-text mb-2" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
<p className="card-text mb-2" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{comment.content}
|
||||
</p>
|
||||
)}
|
||||
@@ -171,6 +203,23 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
{canMarkAsAnswer && !isEditing && (
|
||||
<button
|
||||
className={`btn btn-sm btn-link text-decoration-none p-0 ${
|
||||
isAcceptedAnswer ? "text-success" : "text-success"
|
||||
}`}
|
||||
onClick={handleMarkAsAnswer}
|
||||
>
|
||||
<i
|
||||
className={`bi ${
|
||||
isAcceptedAnswer
|
||||
? "bi-check-circle-fill"
|
||||
: "bi-check-circle"
|
||||
} me-1`}
|
||||
></i>
|
||||
{isAcceptedAnswer ? "Unmark Answer" : "Mark as Answer"}
|
||||
</button>
|
||||
)}
|
||||
{isAuthor && onEdit && !isEditing && (
|
||||
<button
|
||||
className="btn btn-sm btn-link text-decoration-none p-0"
|
||||
@@ -214,7 +263,10 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
onReply={onReply}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onMarkAsAnswer={onMarkAsAnswer}
|
||||
currentUserId={currentUserId}
|
||||
isPostAuthor={isPostAuthor}
|
||||
acceptedAnswerId={acceptedAnswerId}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ForumPost } from '../types';
|
||||
import CategoryBadge from './CategoryBadge';
|
||||
import PostStatusBadge from './PostStatusBadge';
|
||||
|
||||
interface ForumPostCardProps {
|
||||
post: ForumPost;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
const ForumPostCard: React.FC<ForumPostCardProps> = ({ post, showActions = true }) => {
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffHours < 1) {
|
||||
return 'Just now';
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}h ago`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
|
||||
// Strip HTML tags for preview
|
||||
const getTextPreview = (html: string, maxLength: number = 150) => {
|
||||
const text = html.replace(/<[^>]*>/g, '');
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card h-100 shadow-sm hover-shadow">
|
||||
<div className="card-body">
|
||||
{post.isPinned && (
|
||||
<div className="mb-2">
|
||||
<span className="badge bg-danger">
|
||||
<i className="bi bi-pin-angle-fill me-1"></i>
|
||||
Pinned
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 className="card-title text-truncate flex-grow-1 me-2">{post.title}</h5>
|
||||
<PostStatusBadge status={post.status} />
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<CategoryBadge category={post.category} />
|
||||
</div>
|
||||
|
||||
<p className="card-text text-muted small mb-2">
|
||||
{getTextPreview(post.content)}
|
||||
</p>
|
||||
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
{post.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag.id} className="badge bg-light text-dark me-1 mb-1">
|
||||
#{tag.tagName}
|
||||
</span>
|
||||
))}
|
||||
{post.tags.length > 3 && (
|
||||
<span className="badge bg-light text-dark">
|
||||
+{post.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-person me-1"></i>
|
||||
{post.author?.firstName || 'Unknown'} {post.author?.lastName || ''}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<div className="d-flex gap-3">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-chat me-1"></i>
|
||||
{post.commentCount || 0}
|
||||
</small>
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-eye me-1"></i>
|
||||
{post.viewCount || 0}
|
||||
</small>
|
||||
</div>
|
||||
<small className="text-muted">
|
||||
{formatDate(post.updatedAt)}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="d-grid gap-2 mt-3">
|
||||
<Link
|
||||
to={`/forum/${post.id}`}
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
>
|
||||
View Post
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumPostCard;
|
||||
111
frontend/src/components/ForumPostListItem.tsx
Normal file
111
frontend/src/components/ForumPostListItem.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ForumPost } from '../types';
|
||||
import CategoryBadge from './CategoryBadge';
|
||||
import PostStatusBadge from './PostStatusBadge';
|
||||
|
||||
interface ForumPostListItemProps {
|
||||
post: ForumPost;
|
||||
}
|
||||
|
||||
const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return 'Just now';
|
||||
} else if (diffMinutes < 60) {
|
||||
return `${diffMinutes}m ago`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}h ago`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
|
||||
// Strip HTML tags for preview
|
||||
const getTextPreview = (html: string, maxLength: number = 100) => {
|
||||
const text = html.replace(/<[^>]*>/g, '');
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="list-group-item list-group-item-action p-3">
|
||||
<Link to={`/forum/${post.id}`} className="text-decoration-none d-block">
|
||||
<div className="row align-items-center">
|
||||
{/* Main content - 60% */}
|
||||
<div className="col-md-7">
|
||||
<div className="d-flex align-items-center gap-2 mb-1">
|
||||
{post.isPinned && (
|
||||
<span className="badge bg-danger badge-sm">
|
||||
<i className="bi bi-pin-angle-fill"></i>
|
||||
</span>
|
||||
)}
|
||||
<CategoryBadge category={post.category} />
|
||||
<PostStatusBadge status={post.status} />
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<>
|
||||
{post.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag.id} className="badge bg-light text-dark">
|
||||
#{tag.tagName}
|
||||
</span>
|
||||
))}
|
||||
{post.tags.length > 2 && (
|
||||
<span className="badge bg-light text-dark">
|
||||
+{post.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h6 className="mb-1 text-dark">
|
||||
{post.title}
|
||||
</h6>
|
||||
|
||||
<p className="text-muted small mb-0">
|
||||
{getTextPreview(post.content)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Author - 20% */}
|
||||
<div className="col-md-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: '32px', height: '32px', fontSize: '14px', flexShrink: 0 }}>
|
||||
{post.author?.firstName?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div className="text-truncate">
|
||||
<small className="text-dark d-block text-truncate">
|
||||
{post.author?.firstName || 'Unknown'} {post.author?.lastName || ''}
|
||||
</small>
|
||||
<small className="text-muted">
|
||||
{formatDate(post.updatedAt)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats - 20% */}
|
||||
<div className="col-md-2 text-end">
|
||||
<div className="d-flex flex-column align-items-end gap-1">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-chat me-1"></i>
|
||||
{post.commentCount || 0} {post.commentCount === 1 ? 'reply' : 'replies'}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumPostListItem;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PostStatusBadgeProps {
|
||||
status: "open" | "solved" | "closed";
|
||||
status: "open" | "answered" | "closed";
|
||||
}
|
||||
|
||||
const PostStatusBadge: React.FC<PostStatusBadgeProps> = ({ status }) => {
|
||||
@@ -9,8 +9,8 @@ const PostStatusBadge: React.FC<PostStatusBadgeProps> = ({ status }) => {
|
||||
switch (stat) {
|
||||
case 'open':
|
||||
return { label: 'Open', color: 'success', icon: 'bi-circle' };
|
||||
case 'solved':
|
||||
return { label: 'Solved', color: 'info', icon: 'bi-check-circle' };
|
||||
case 'answered':
|
||||
return { label: 'Answered', color: 'info', icon: 'bi-check-circle' };
|
||||
case 'closed':
|
||||
return { label: 'Closed', color: 'secondary', icon: 'bi-x-circle' };
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user