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:
jungwoo choi
2025-10-23 14:30:25 +09:00
commit 919afe56f2
1516 changed files with 64072 additions and 0 deletions

View 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>
);
}