reply to comment implemented
This commit is contained in:
parent
d7b82c0ec9
commit
3374eb1ec0
154
frontend/src/components/features/comments/CommentItem.tsx
Normal file
154
frontend/src/components/features/comments/CommentItem.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useCreateComment } from '../../../queries/comments';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
import { Card, CardContent } from '../../ui/card';
|
||||
import { format } from 'date-fns';
|
||||
import { mk } from 'date-fns/locale';
|
||||
import type { Comment } from '../../../lib/api';
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export function CommentItem({ comment, articleId, liveBlogId, depth = 0 }: CommentItemProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||
const [replyContent, setReplyContent] = useState('');
|
||||
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||
|
||||
const createCommentMutation = useCreateComment();
|
||||
|
||||
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!replyContent.trim() || !isAuthenticated) return;
|
||||
|
||||
setIsSubmittingReply(true);
|
||||
try {
|
||||
await createCommentMutation.mutateAsync({
|
||||
content: replyContent,
|
||||
articleId,
|
||||
liveBlogId,
|
||||
parentCommentId: comment.id,
|
||||
});
|
||||
setReplyContent('');
|
||||
setShowReplyForm(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to post reply:', error);
|
||||
} finally {
|
||||
setIsSubmittingReply(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Maximum depth to prevent infinite nesting (optional)
|
||||
const maxDepth = 5;
|
||||
const canReply = depth < maxDepth;
|
||||
|
||||
return (
|
||||
<div className={depth > 0 ? 'ml-8 mt-4 border-l-2 border-border pl-4' : ''}>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{comment.user?.username || 'Анонимен корисник'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{format(new Date(comment.createdAt), 'dd MMMM yyyy, HH:mm', { locale: mk })}
|
||||
</div>
|
||||
</div>
|
||||
{comment.user?.role === 'admin' && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary">
|
||||
Администратор
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="whitespace-pre-wrap mb-4">{comment.content}</p>
|
||||
|
||||
{/* Reply button and form */}
|
||||
{isAuthenticated && canReply && (
|
||||
<div className="mt-4">
|
||||
{!showReplyForm ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowReplyForm(true)}
|
||||
className="text-sm"
|
||||
>
|
||||
Одговори
|
||||
</Button>
|
||||
) : (
|
||||
<div className="mt-4 p-4 border rounded-lg bg-muted/20">
|
||||
<form onSubmit={handleSubmitReply}>
|
||||
<Textarea
|
||||
placeholder="Вашиот одговор..."
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
className="min-h-[80px] mb-3"
|
||||
disabled={isSubmittingReply}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowReplyForm(false);
|
||||
setReplyContent('');
|
||||
}}
|
||||
disabled={isSubmittingReply}
|
||||
>
|
||||
Откажи
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!replyContent.trim() || isSubmittingReply}
|
||||
>
|
||||
{isSubmittingReply ? 'Поставување...' : 'Постави одговор'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reactions (if implemented) */}
|
||||
{comment.reactions && (
|
||||
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-muted-foreground">👍 {comment.reactions.likes}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-muted-foreground">👎 {comment.reactions.dislikes}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Render replies recursively */}
|
||||
{comment.replies && comment.replies.length > 0 && (
|
||||
<div className="mt-4">
|
||||
{comment.replies.map((reply) => (
|
||||
<CommentItem
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
articleId={articleId}
|
||||
liveBlogId={liveBlogId}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,8 +4,7 @@ import { useComments, useCreateComment } from '../../../queries/comments';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
import { Card, CardContent } from '../../ui/card';
|
||||
import { format } from 'date-fns';
|
||||
import { mk } from 'date-fns/locale';
|
||||
import { CommentItem } from './CommentItem';
|
||||
|
||||
interface CommentSectionProps {
|
||||
articleId?: string;
|
||||
@ -102,26 +101,12 @@ export function CommentSection({ articleId, liveBlogId }: CommentSectionProps) {
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{comments.map((comment) => (
|
||||
<Card key={comment.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{comment.user?.username || 'Анонимен корисник'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{format(new Date(comment.createdAt), 'dd MMMM yyyy, HH:mm', { locale: mk })}
|
||||
</div>
|
||||
</div>
|
||||
{comment.user?.role === 'admin' && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary">
|
||||
Администратор
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{comment.content}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
articleId={articleId}
|
||||
liveBlogId={liveBlogId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -16,11 +16,11 @@ export function useLiveBlogStream(
|
||||
liveBlogId: string,
|
||||
options: LiveBlogStreamOptions = {}
|
||||
) {
|
||||
const defaultOptions: LiveBlogStreamOptions = {
|
||||
const defaultOptions = useMemo((): LiveBlogStreamOptions => ({
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 10,
|
||||
};
|
||||
}), []);
|
||||
|
||||
const mergedOptions = useMemo(() => ({ ...defaultOptions, ...options }), [options, defaultOptions]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
@ -578,6 +578,7 @@ export interface Comment {
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
};
|
||||
replies?: Comment[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@ -622,6 +623,42 @@ export interface CreateReactionDto {
|
||||
}
|
||||
|
||||
// Comment API Functions
|
||||
// Interface for backend comment response
|
||||
interface BackendComment {
|
||||
id: string;
|
||||
content: string;
|
||||
articleId: string | null;
|
||||
liveBlogId: string | null;
|
||||
parentId: string | null;
|
||||
userId: string;
|
||||
likeCount: number;
|
||||
dislikeCount: number;
|
||||
isVisible: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
replies?: BackendComment[];
|
||||
}
|
||||
|
||||
// Recursive function to map comment and its replies
|
||||
function mapBackendComment(comment: BackendComment): Comment {
|
||||
const mappedComment: Comment = {
|
||||
...comment,
|
||||
parentCommentId: comment.parentId,
|
||||
// Ensure reactions object exists
|
||||
reactions: {
|
||||
likes: comment.likeCount || 0,
|
||||
dislikes: comment.dislikeCount || 0,
|
||||
},
|
||||
// Recursively map replies if they exist
|
||||
replies: comment.replies?.map(mapBackendComment) || [],
|
||||
};
|
||||
return mappedComment;
|
||||
}
|
||||
|
||||
export async function fetchComments(params: FindCommentsParams = {}): Promise<CommentsResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
@ -649,16 +686,7 @@ export async function fetchComments(params: FindCommentsParams = {}): Promise<Co
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Map backend response to frontend interface
|
||||
const mappedData = data.map((comment: any) => ({
|
||||
...comment,
|
||||
parentCommentId: comment.parentId,
|
||||
// Ensure reactions object exists
|
||||
reactions: {
|
||||
likes: comment.likeCount || 0,
|
||||
dislikes: comment.dislikeCount || 0,
|
||||
},
|
||||
}));
|
||||
const mappedData = (data as BackendComment[]).map(mapBackendComment);
|
||||
|
||||
return {
|
||||
data: mappedData,
|
||||
@ -683,18 +711,10 @@ export async function createComment(dto: CreateCommentDto): Promise<Comment> {
|
||||
throw new Error('Failed to create comment');
|
||||
}
|
||||
|
||||
const comment = await response.json();
|
||||
const comment = await response.json() as BackendComment;
|
||||
|
||||
// Map backend response to frontend interface
|
||||
return {
|
||||
...comment,
|
||||
parentCommentId: comment.parentId,
|
||||
// Ensure reactions object exists
|
||||
reactions: {
|
||||
likes: comment.likeCount || 0,
|
||||
dislikes: comment.dislikeCount || 0,
|
||||
},
|
||||
};
|
||||
return mapBackendComment(comment);
|
||||
}
|
||||
|
||||
export async function updateComment(id: string, dto: UpdateCommentDto): Promise<Comment> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user