React Native mobile application for SAPIENS news platform. Consolidated all previous history into single commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { ThumbsUp, ThumbsDown, ChevronDown, ChevronUp, ArrowUpDown } from "lucide-react";
|
|
import { formatTimeAgo } from "@/lib/utils";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import AvatarPreviewDialog from "@/components/AvatarPreviewDialog";
|
|
|
|
interface Comment {
|
|
id: string;
|
|
content: string;
|
|
nickname: string;
|
|
avatar?: string;
|
|
createdAt: string;
|
|
likesCount: number;
|
|
dislikesCount: number;
|
|
repliesCount: number;
|
|
parentId?: string;
|
|
}
|
|
|
|
interface CommentSectionProps {
|
|
articleId: string;
|
|
}
|
|
|
|
function getUserIdentifier() {
|
|
let userId = localStorage.getItem('commentUserId');
|
|
if (!userId) {
|
|
userId = 'user_' + Math.random().toString(36).substring(2, 15);
|
|
localStorage.setItem('commentUserId', userId);
|
|
}
|
|
return userId;
|
|
}
|
|
|
|
function CommentForm({
|
|
articleId,
|
|
parentId,
|
|
onSuccess,
|
|
placeholder = "Add comment...",
|
|
buttonText = "Comment"
|
|
}: {
|
|
articleId: string;
|
|
parentId?: string;
|
|
onSuccess?: () => void;
|
|
placeholder?: string;
|
|
buttonText?: string;
|
|
}) {
|
|
const [content, setContent] = useState("");
|
|
const [nickname, setNickname] = useState(() =>
|
|
localStorage.getItem('commentNickname') || ""
|
|
);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const queryClient = useQueryClient();
|
|
|
|
const createComment = useMutation({
|
|
mutationFn: async (data: { content: string; nickname: string; parentId?: string }) => {
|
|
const response = await apiRequest('POST', `/api/articles/${articleId}/comments`, data);
|
|
return response.json();
|
|
},
|
|
onSuccess: () => {
|
|
setContent("");
|
|
setShowForm(false);
|
|
setIsFocused(false);
|
|
localStorage.setItem('commentNickname', nickname);
|
|
queryClient.invalidateQueries({ queryKey: ['/api/articles', articleId, 'comments'] });
|
|
if (parentId) {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/comments', parentId, 'replies'] });
|
|
}
|
|
onSuccess?.();
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!content.trim() || !nickname.trim()) return;
|
|
|
|
createComment.mutate({
|
|
content: content.trim(),
|
|
nickname: nickname.trim(),
|
|
parentId
|
|
});
|
|
};
|
|
|
|
if (!showForm && parentId) {
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowForm(true)}
|
|
className="text-sm font-normal p-0 h-auto hover:bg-transparent text-muted-foreground hover:text-foreground"
|
|
data-testid="button-reply"
|
|
>
|
|
Reply
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
if (!showForm && !parentId) {
|
|
return (
|
|
<div className="mb-6">
|
|
<div
|
|
className="flex items-start gap-3"
|
|
onClick={() => setShowForm(true)}
|
|
data-testid="button-add-comment"
|
|
>
|
|
<Avatar className="w-10 h-10 flex-shrink-0">
|
|
<AvatarFallback>👤</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1">
|
|
<div className="border-b-2 border-muted hover:border-foreground transition-colors cursor-text py-2">
|
|
<span className="text-muted-foreground text-sm">{placeholder}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mb-6">
|
|
<form onSubmit={handleSubmit} className="space-y-3">
|
|
<div className="flex items-start gap-3">
|
|
<Avatar className="w-10 h-10 flex-shrink-0">
|
|
<AvatarFallback>👤</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 space-y-3">
|
|
{!localStorage.getItem('commentNickname') && (
|
|
<Input
|
|
placeholder="Enter your nickname"
|
|
value={nickname}
|
|
onChange={(e) => setNickname(e.target.value)}
|
|
className="border-0 border-b-2 border-muted rounded-none bg-transparent px-0 focus-visible:ring-0 focus-visible:border-foreground"
|
|
data-testid="input-nickname"
|
|
required
|
|
/>
|
|
)}
|
|
<Textarea
|
|
placeholder={placeholder}
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
onFocus={() => setIsFocused(true)}
|
|
className="min-h-[60px] border-0 border-b-2 border-muted rounded-none bg-transparent px-0 resize-none focus-visible:ring-0 focus-visible:border-foreground"
|
|
data-testid="textarea-comment"
|
|
required
|
|
/>
|
|
{(isFocused || content) && (
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setShowForm(false);
|
|
setContent("");
|
|
setIsFocused(false);
|
|
}}
|
|
data-testid="button-cancel-comment"
|
|
className="h-9 px-4 rounded-full"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
disabled={!content.trim() || !nickname.trim() || createComment.isPending}
|
|
data-testid="button-submit-comment"
|
|
className="h-9 px-4 rounded-full"
|
|
>
|
|
{createComment.isPending ? "Posting..." : buttonText}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CommentItem({ comment, articleId, level = 0 }: {
|
|
comment: Comment;
|
|
articleId: string;
|
|
level?: number;
|
|
}) {
|
|
const [showReplies, setShowReplies] = useState(false);
|
|
const [showReplyForm, setShowReplyForm] = useState(false);
|
|
const [userReaction, setUserReaction] = useState<'like' | 'dislike' | null>(null);
|
|
const queryClient = useQueryClient();
|
|
const userIdentifier = getUserIdentifier();
|
|
|
|
const { data: reactionData } = useQuery({
|
|
queryKey: ['/api/comments', comment.id, 'reactions', userIdentifier],
|
|
queryFn: async () => {
|
|
const response = await apiRequest('GET', `/api/comments/${comment.id}/reactions/${userIdentifier}`);
|
|
return response.json();
|
|
},
|
|
});
|
|
|
|
const { data: repliesData } = useQuery({
|
|
queryKey: ['/api/comments', comment.id, 'replies'],
|
|
queryFn: async () => {
|
|
const response = await apiRequest('GET', `/api/comments/${comment.id}/replies`);
|
|
return response.json();
|
|
},
|
|
enabled: showReplies && comment.repliesCount > 0,
|
|
});
|
|
|
|
const toggleReaction = useMutation({
|
|
mutationFn: async (reactionType: 'like' | 'dislike') => {
|
|
const response = await apiRequest('POST', `/api/comments/${comment.id}/reactions`, {
|
|
reactionType,
|
|
userIdentifier
|
|
});
|
|
return response.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/articles', articleId, 'comments'] });
|
|
queryClient.invalidateQueries({ queryKey: ['/api/comments', comment.id, 'reactions', userIdentifier] });
|
|
},
|
|
});
|
|
|
|
const currentReaction = reactionData?.reactionType || userReaction;
|
|
|
|
const handleReaction = (reactionType: 'like' | 'dislike') => {
|
|
setUserReaction(currentReaction === reactionType ? null : reactionType);
|
|
toggleReaction.mutate(reactionType);
|
|
};
|
|
|
|
return (
|
|
<div className={`${level > 0 ? 'ml-10 pl-6 border-l border-muted' : ''} py-3`}>
|
|
<div className="flex gap-3">
|
|
{comment.avatar ? (
|
|
<AvatarPreviewDialog
|
|
avatarSrc={comment.avatar}
|
|
avatarAlt={comment.nickname}
|
|
fallbackText={comment.nickname.charAt(0).toUpperCase()}
|
|
title={`${comment.nickname} profile picture`}
|
|
>
|
|
<Avatar className="w-10 h-10 flex-shrink-0 cursor-pointer hover:ring-2 hover:ring-primary/20 transition-all">
|
|
<AvatarImage src={comment.avatar} alt={comment.nickname} />
|
|
<AvatarFallback>{comment.nickname.charAt(0).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
</AvatarPreviewDialog>
|
|
) : (
|
|
<Avatar className="w-10 h-10 flex-shrink-0">
|
|
<AvatarFallback>👤</AvatarFallback>
|
|
</Avatar>
|
|
)}
|
|
|
|
<div className="flex-1 space-y-2">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="font-semibold" data-testid={`text-comment-author-${comment.id}`}>
|
|
{comment.nickname}
|
|
</span>
|
|
<span className="text-muted-foreground text-xs" data-testid={`text-comment-time-${comment.id}`}>
|
|
{formatTimeAgo(comment.createdAt)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="text-sm leading-relaxed whitespace-pre-wrap" data-testid={`text-comment-content-${comment.id}`}>
|
|
{comment.content}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 pt-1">
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={`h-8 px-3 rounded-full text-xs font-medium hover:bg-muted transition-colors ${
|
|
currentReaction === 'like'
|
|
? 'bg-blue-50 text-blue-600 hover:bg-blue-100'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
onClick={() => handleReaction('like')}
|
|
data-testid={`button-like-${comment.id}`}
|
|
>
|
|
<ThumbsUp className="h-3 w-3 mr-1" />
|
|
{comment.likesCount || 0}
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={`h-8 px-3 rounded-full text-xs font-medium hover:bg-muted transition-colors ${
|
|
currentReaction === 'dislike'
|
|
? 'bg-red-50 text-red-600 hover:bg-red-100'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
onClick={() => handleReaction('dislike')}
|
|
data-testid={`button-dislike-${comment.id}`}
|
|
>
|
|
<ThumbsDown className="h-3 w-3 mr-1" />
|
|
{comment.dislikesCount || 0}
|
|
</Button>
|
|
</div>
|
|
|
|
{level < 2 && (
|
|
<CommentForm
|
|
articleId={articleId}
|
|
parentId={comment.id}
|
|
placeholder="Add reply..."
|
|
buttonText="Reply"
|
|
onSuccess={() => setShowReplyForm(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{comment.repliesCount > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowReplies(!showReplies)}
|
|
className="text-blue-600 hover:text-blue-700 h-8 px-0 font-medium text-xs rounded-full hover:bg-blue-50 transition-colors"
|
|
data-testid={`button-toggle-replies-${comment.id}`}
|
|
>
|
|
{showReplies ? (
|
|
<>
|
|
<ChevronUp className="h-3 w-3 mr-1" />
|
|
Hide replies
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown className="h-3 w-3 mr-1" />
|
|
View {comment.repliesCount} replies
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
|
|
{showReplies && repliesData?.comments && (
|
|
<div className="mt-4 space-y-3">
|
|
{repliesData.comments.map((reply: Comment) => (
|
|
<CommentItem
|
|
key={reply.id}
|
|
comment={reply}
|
|
articleId={articleId}
|
|
level={level + 1}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function CommentSection({ articleId }: CommentSectionProps) {
|
|
const [sortBy, setSortBy] = useState<'newest' | 'popular'>('newest');
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['/api/articles', articleId, 'comments', sortBy],
|
|
queryFn: async () => {
|
|
const response = await apiRequest('GET', `/api/articles/${articleId}/comments?limit=20&sort=${sortBy}`);
|
|
return response.json();
|
|
},
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="bg-background p-6">
|
|
<h3 className="text-xl font-bold mb-4">Comments</h3>
|
|
<div className="space-y-6">
|
|
{[...Array(3)].map((_, i) => (
|
|
<div key={i} className="animate-pulse flex gap-3">
|
|
<div className="w-10 h-10 bg-muted rounded-full"></div>
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-4 bg-muted rounded w-32"></div>
|
|
<div className="h-12 bg-muted rounded"></div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const comments = data?.comments || [];
|
|
const total = data?.total || 0;
|
|
|
|
return (
|
|
<div className="bg-background pb-6" data-testid="section-comments">
|
|
{/* Comments Header */}
|
|
<div className="px-6 py-4 border-b">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-xl font-bold">{total} Comments</h3>
|
|
<Select value={sortBy} onValueChange={(value) => setSortBy(value as 'newest' | 'popular')}>
|
|
<SelectTrigger className="w-[140px] h-8 text-sm border-none bg-transparent hover:bg-muted/50">
|
|
<ArrowUpDown className="h-4 w-4 mr-2" />
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="newest">Newest</SelectItem>
|
|
<SelectItem value="popular">Popular</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comment Form */}
|
|
<div className="px-6 pt-6">
|
|
<CommentForm articleId={articleId} />
|
|
</div>
|
|
|
|
{/* Comments List */}
|
|
<div className="px-6">
|
|
{comments.length === 0 ? (
|
|
<div className="text-center text-muted-foreground py-12">
|
|
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
|
<span className="text-2xl">💬</span>
|
|
</div>
|
|
<p className="text-lg mb-2">No comments yet</p>
|
|
<p className="text-sm">Be the first to leave a comment!</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-muted/30">
|
|
{comments.map((comment: Comment, index: number) => (
|
|
<CommentItem key={comment.id} comment={comment} articleId={articleId} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |