chore: add deployment config
This commit is contained in:
101
src/components/comment/CommentForm.tsx
Normal file
101
src/components/comment/CommentForm.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createComment } from '@/api/comments';
|
||||
import { Loader2, Send } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface CommentFormProps {
|
||||
postSlug: string;
|
||||
parentId?: number | null;
|
||||
onSuccess?: () => void; // 작성 완료 후 콜백 (답글창 닫기 등)
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function CommentForm({ postSlug, parentId = null, onSuccess, placeholder = '댓글을 남겨보세요.' }: CommentFormProps) {
|
||||
const { isLoggedIn, role } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const [guestInfo, setGuestInfo] = useState({ nickname: '', password: '' });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createComment,
|
||||
onSuccess: () => {
|
||||
setContent('');
|
||||
setGuestInfo({ nickname: '', password: '' });
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||
if (onSuccess) onSuccess();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
alert('댓글 작성 실패: ' + (err.response?.data?.message || err.message));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!content.trim()) return;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (!guestInfo.nickname.trim() || !guestInfo.password.trim()) {
|
||||
alert('닉네임과 비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
postSlug,
|
||||
content,
|
||||
parentId,
|
||||
guestNickname: isLoggedIn ? undefined : guestInfo.nickname,
|
||||
guestPassword: isLoggedIn ? undefined : guestInfo.password,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-gray-50 p-4 rounded-xl border border-gray-100">
|
||||
{/* 비회원 입력 필드 */}
|
||||
{!isLoggedIn && (
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="닉네임"
|
||||
value={guestInfo.nickname}
|
||||
onChange={(e) => setGuestInfo({ ...guestInfo, nickname: e.target.value })}
|
||||
className="w-1/2 px-3 py-2 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
value={guestInfo.password}
|
||||
onChange={(e) => setGuestInfo({ ...guestInfo, password: e.target.value })}
|
||||
className="w-1/2 px-3 py-2 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 댓글 내용 입력 */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={isLoggedIn ? placeholder : '댓글을 남겨보세요.'}
|
||||
className="w-full p-3 pr-12 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 resize-none h-24 bg-white"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="absolute bottom-3 right-3 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-300"
|
||||
title="등록"
|
||||
>
|
||||
{mutation.isPending ? <Loader2 className="animate-spin" size={16} /> : <Send size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
207
src/components/comment/CommentItem.tsx
Normal file
207
src/components/comment/CommentItem.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Comment } from '@/types';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { deleteComment, deleteAdminComment } from '@/api/comments';
|
||||
import { format } from 'date-fns';
|
||||
import { User, CheckCircle2, Trash2, MessageSquare, UserCheck, ShieldAlert, X } from 'lucide-react'; // X 아이콘 추가
|
||||
import CommentForm from './CommentForm';
|
||||
import { clsx } from 'clsx';
|
||||
import toast from 'react-hot-toast'; // 🎨 Toast 추가
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
postSlug: string;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export default function CommentItem({ comment, postSlug, depth = 0 }: CommentItemProps) {
|
||||
const { isLoggedIn, role, user } = useAuthStore();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [isReplying, setIsReplying] = useState(false);
|
||||
// 🎨 비회원 삭제용 UI 상태 추가
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [guestPassword, setGuestPassword] = useState('');
|
||||
|
||||
const isAdmin = isLoggedIn && role?.includes('ADMIN');
|
||||
const isGuestComment = !comment.memberId;
|
||||
|
||||
const isMyComment = isLoggedIn && !isGuestComment && (
|
||||
(user?.memberId && comment.memberId && user.memberId === comment.memberId) ||
|
||||
(user?.nickname === comment.author)
|
||||
);
|
||||
|
||||
const showDeleteButton = isAdmin || isGuestComment || isMyComment;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, password }: { id: number; password?: string }) => deleteComment(id, password),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||
toast.success('댓글이 삭제되었습니다.');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error('삭제 실패: ' + (err.response?.data?.message || '비밀번호가 틀렸습니다.'));
|
||||
},
|
||||
});
|
||||
|
||||
const adminDeleteMutation = useMutation({
|
||||
mutationFn: deleteAdminComment,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||
toast.success('관리자 권한으로 삭제했습니다.');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error('관리자 삭제 실패: ' + (err.response?.data?.message || err.message));
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
// A. 관리자 -> 즉시 삭제 (컨펌만)
|
||||
if (isAdmin) {
|
||||
if (confirm('관리자 권한으로 삭제하시겠습니까?')) {
|
||||
adminDeleteMutation.mutate(comment.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// B. 내 댓글 -> 즉시 삭제 (컨펌만)
|
||||
if (isMyComment) {
|
||||
if (confirm('이 댓글을 삭제하시겠습니까?')) {
|
||||
deleteMutation.mutate({ id: comment.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// C. 비회원 댓글 -> 인라인 입력창 표시 (UX 개선)
|
||||
if (isGuestComment) {
|
||||
setIsDeleting(!isDeleting);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGuestDeleteSubmit = () => {
|
||||
if (!guestPassword.trim()) {
|
||||
toast.error('비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate({ id: comment.id, password: guestPassword });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("flex flex-col", depth > 0 && "mt-3")}>
|
||||
<div
|
||||
className={clsx(
|
||||
"relative p-4 rounded-xl transition-colors group",
|
||||
comment.isPostAuthor ? "bg-blue-50/50 border border-blue-100" : "bg-white border border-gray-100"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={clsx(
|
||||
"p-1.5 rounded-full flex items-center justify-center",
|
||||
comment.isPostAuthor ? "bg-blue-100 text-blue-600" :
|
||||
!isGuestComment ? "bg-green-100 text-green-600" :
|
||||
"bg-gray-100 text-gray-500"
|
||||
)}
|
||||
>
|
||||
{comment.isPostAuthor ? <CheckCircle2 size={14} /> :
|
||||
!isGuestComment ? <UserCheck size={14} /> :
|
||||
<User size={14} />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-0 sm:gap-2">
|
||||
<span className={clsx("text-sm font-bold flex items-center gap-1", comment.isPostAuthor ? "text-blue-700" : "text-gray-700")}>
|
||||
{comment.author}
|
||||
{comment.isPostAuthor && <span className="px-1.5 py-0.5 bg-blue-100 text-blue-600 text-[10px] rounded-full font-medium">작성자</span>}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{format(new Date(comment.createdAt), 'yyyy.MM.dd HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setIsReplying(!isReplying)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="답글 달기"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
</button>
|
||||
|
||||
{showDeleteButton && (
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className={clsx(
|
||||
"p-1.5 rounded transition-colors",
|
||||
isDeleting ? "bg-red-50 text-red-600" :
|
||||
isAdmin ? "text-red-400 hover:text-red-600 hover:bg-red-50" : "text-gray-400 hover:text-red-600 hover:bg-red-50"
|
||||
)}
|
||||
title={isAdmin ? "관리자 삭제" : "삭제"}
|
||||
>
|
||||
{isAdmin ? <ShieldAlert size={14} /> : <Trash2 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-800 text-sm whitespace-pre-wrap leading-relaxed pl-1">
|
||||
{comment.content}
|
||||
</p>
|
||||
|
||||
{/* 🎨 비회원 비밀번호 입력창 (인라인) */}
|
||||
{isDeleting && isGuestComment && (
|
||||
<div className="mt-3 flex items-center gap-2 p-2 bg-gray-50 rounded-lg animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="비밀번호 입력"
|
||||
value={guestPassword}
|
||||
onChange={(e) => setGuestPassword(e.target.value)}
|
||||
className="text-xs px-2 py-1.5 border border-gray-200 rounded focus:outline-none focus:border-blue-500 bg-white"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleGuestDeleteSubmit}
|
||||
className="text-xs bg-red-500 text-white px-3 py-1.5 rounded hover:bg-red-600 transition-colors font-medium"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsDeleting(false); setGuestPassword(''); }}
|
||||
className="p-1 text-gray-400 hover:bg-gray-200 rounded-full"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isReplying && (
|
||||
<div className="mt-2 pl-4 border-l-2 border-gray-200 ml-4 animate-in fade-in slide-in-from-top-2">
|
||||
<CommentForm
|
||||
postSlug={postSlug}
|
||||
parentId={comment.id}
|
||||
onSuccess={() => setIsReplying(false)}
|
||||
placeholder={`@${comment.author}님에게 답글 남기기`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comment.children && comment.children.length > 0 && (
|
||||
<div className="mt-2 pl-4 md:pl-8 border-l-2 border-gray-100 ml-2 md:ml-4 space-y-3">
|
||||
{comment.children.map((child) => (
|
||||
<CommentItem
|
||||
key={child.id}
|
||||
comment={child}
|
||||
postSlug={postSlug}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/comment/CommentList.tsx
Normal file
64
src/components/comment/CommentList.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getComments } from '@/api/comments';
|
||||
import CommentForm from './CommentForm';
|
||||
import CommentItem from './CommentItem';
|
||||
import { Loader2, MessageCircle } from 'lucide-react';
|
||||
|
||||
interface CommentListProps {
|
||||
postSlug: string;
|
||||
}
|
||||
|
||||
export default function CommentList({ postSlug }: CommentListProps) {
|
||||
// 댓글 목록 조회
|
||||
const { data: comments, isLoading, error } = useQuery({
|
||||
queryKey: ['comments', postSlug],
|
||||
queryFn: () => getComments(postSlug),
|
||||
});
|
||||
|
||||
// 총 댓글 수 계산 (재귀)
|
||||
const countComments = (list: any[]): number => {
|
||||
if (!list) return 0;
|
||||
return list.reduce((acc, curr) => acc + 1 + countComments(curr.children), 0);
|
||||
};
|
||||
|
||||
const totalCount = comments ? countComments(comments) : 0;
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="py-10 flex justify-center"><Loader2 className="animate-spin text-blue-500" /></div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="py-10 text-center text-red-500">댓글을 불러오는데 실패했습니다.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-16 pt-10 border-t border-gray-100">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<MessageCircle className="text-blue-600" size={24} />
|
||||
<h3 className="text-xl font-bold text-gray-800">
|
||||
댓글 <span className="text-blue-600">{totalCount}</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 최상위 댓글 작성 폼 */}
|
||||
<div className="mb-10">
|
||||
<CommentForm postSlug={postSlug} />
|
||||
</div>
|
||||
|
||||
{/* 댓글 목록 */}
|
||||
<div className="space-y-6">
|
||||
{comments && comments.length > 0 ? (
|
||||
comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} postSlug={postSlug} />
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-500 text-sm">
|
||||
아직 댓글이 없습니다. 첫 번째 댓글을 남겨보세요!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user