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 { Button } from '../../ui/button';
|
||||||
import { Textarea } from '../../ui/textarea';
|
import { Textarea } from '../../ui/textarea';
|
||||||
import { Card, CardContent } from '../../ui/card';
|
import { Card, CardContent } from '../../ui/card';
|
||||||
import { format } from 'date-fns';
|
import { CommentItem } from './CommentItem';
|
||||||
import { mk } from 'date-fns/locale';
|
|
||||||
|
|
||||||
interface CommentSectionProps {
|
interface CommentSectionProps {
|
||||||
articleId?: string;
|
articleId?: string;
|
||||||
@ -99,29 +98,15 @@ export function CommentSection({ articleId, liveBlogId }: CommentSectionProps) {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => (
|
||||||
<Card key={comment.id}>
|
<CommentItem
|
||||||
<CardContent className="pt-6">
|
key={comment.id}
|
||||||
<div className="flex items-start justify-between mb-4">
|
comment={comment}
|
||||||
<div>
|
articleId={articleId}
|
||||||
<div className="font-medium">
|
liveBlogId={liveBlogId}
|
||||||
{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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -16,11 +16,11 @@ export function useLiveBlogStream(
|
|||||||
liveBlogId: string,
|
liveBlogId: string,
|
||||||
options: LiveBlogStreamOptions = {}
|
options: LiveBlogStreamOptions = {}
|
||||||
) {
|
) {
|
||||||
const defaultOptions: LiveBlogStreamOptions = {
|
const defaultOptions = useMemo((): LiveBlogStreamOptions => ({
|
||||||
autoReconnect: true,
|
autoReconnect: true,
|
||||||
reconnectInterval: 3000,
|
reconnectInterval: 3000,
|
||||||
maxReconnectAttempts: 10,
|
maxReconnectAttempts: 10,
|
||||||
};
|
}), []);
|
||||||
|
|
||||||
const mergedOptions = useMemo(() => ({ ...defaultOptions, ...options }), [options, defaultOptions]);
|
const mergedOptions = useMemo(() => ({ ...defaultOptions, ...options }), [options, defaultOptions]);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|||||||
@ -578,6 +578,7 @@ export interface Comment {
|
|||||||
likes: number;
|
likes: number;
|
||||||
dislikes: number;
|
dislikes: number;
|
||||||
};
|
};
|
||||||
|
replies?: Comment[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@ -622,6 +623,42 @@ export interface CreateReactionDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Comment API Functions
|
// 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> {
|
export async function fetchComments(params: FindCommentsParams = {}): Promise<CommentsResponse> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
@ -649,16 +686,7 @@ export async function fetchComments(params: FindCommentsParams = {}): Promise<Co
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Map backend response to frontend interface
|
const mappedData = (data as BackendComment[]).map(mapBackendComment);
|
||||||
const mappedData = data.map((comment: any) => ({
|
|
||||||
...comment,
|
|
||||||
parentCommentId: comment.parentId,
|
|
||||||
// Ensure reactions object exists
|
|
||||||
reactions: {
|
|
||||||
likes: comment.likeCount || 0,
|
|
||||||
dislikes: comment.dislikeCount || 0,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: mappedData,
|
data: mappedData,
|
||||||
@ -683,18 +711,10 @@ export async function createComment(dto: CreateCommentDto): Promise<Comment> {
|
|||||||
throw new Error('Failed to create 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
|
// Map backend response to frontend interface
|
||||||
return {
|
return mapBackendComment(comment);
|
||||||
...comment,
|
|
||||||
parentCommentId: comment.parentId,
|
|
||||||
// Ensure reactions object exists
|
|
||||||
reactions: {
|
|
||||||
likes: comment.likeCount || 0,
|
|
||||||
dislikes: comment.dislikeCount || 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateComment(id: string, dto: UpdateCommentDto): Promise<Comment> {
|
export async function updateComment(id: string, dto: UpdateCommentDto): Promise<Comment> {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user