feat: SAPIENS Mobile App - Initial commit
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>
This commit is contained in:
429
client/src/components/CommentSection.tsx
Normal file
429
client/src/components/CommentSection.tsx
Normal file
@ -0,0 +1,429 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user