diff --git a/public/reportCancle.svg b/public/reportCancle.svg new file mode 100644 index 00000000..b958560e --- /dev/null +++ b/public/reportCancle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index b30937e5..7e67a1d2 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -121,7 +121,6 @@ export default function HomePage() { onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })} hideSubscribeButton={story.writtenByMe} onClick={() => router.push(`/stories/${story.bookStoryId}`)} - onLikeClick={() => toggleLike(story.bookStoryId)} /> ))} @@ -178,7 +177,6 @@ export default function HomePage() { onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })} hideSubscribeButton={story.writtenByMe} onClick={() => router.push(`/stories/${story.bookStoryId}`)} - onLikeClick={() => toggleLike(story.bookStoryId)} /> ))} @@ -237,7 +235,6 @@ export default function HomePage() { onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })} hideSubscribeButton={story.writtenByMe} onClick={() => router.push(`/stories/${story.bookStoryId}`)} - onLikeClick={() => toggleLike(story.bookStoryId)} /> ))} diff --git a/src/app/(main)/profile/[nickname]/page.tsx b/src/app/(main)/profile/[nickname]/page.tsx index df4119d4..884d20f1 100644 --- a/src/app/(main)/profile/[nickname]/page.tsx +++ b/src/app/(main)/profile/[nickname]/page.tsx @@ -19,7 +19,7 @@ export default async function OtherUserProfilePage({ params }: PageProps) {
- +
); diff --git a/src/app/(main)/stories/page.tsx b/src/app/(main)/stories/page.tsx index 9294033b..6a1c09ca 100644 --- a/src/app/(main)/stories/page.tsx +++ b/src/app/(main)/stories/page.tsx @@ -112,6 +112,7 @@ export default function StoriesPage() { isFollowing={story.authorInfo.following} onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })} hideSubscribeButton={story.writtenByMe} + onProfileClick={() => router.push(`/profile/${story.authorInfo.nickname}`)} onClick={() => handleCardClick(story.bookStoryId)} onLikeClick={() => toggleLike(story.bookStoryId)} /> @@ -149,6 +150,7 @@ export default function StoriesPage() { isFollowing={story.authorInfo.following} onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })} hideSubscribeButton={story.writtenByMe} + onProfileClick={() => router.push(`/profile/${story.authorInfo.nickname}`)} onClick={() => handleCardClick(story.bookStoryId)} onLikeClick={() => toggleLike(story.bookStoryId)} /> diff --git a/src/components/base-ui/BookStory/bookstory_card.tsx b/src/components/base-ui/BookStory/bookstory_card.tsx index 7473a93f..a7ac4dad 100644 --- a/src/components/base-ui/BookStory/bookstory_card.tsx +++ b/src/components/base-ui/BookStory/bookstory_card.tsx @@ -21,6 +21,7 @@ type Props = { isFollowing?: boolean; hideSubscribeButton?: boolean; onClick?: () => void; + onProfileClick?: () => void; }; export default function BookStoryCard({ @@ -41,6 +42,7 @@ export default function BookStoryCard({ isFollowing = false, hideSubscribeButton = false, onClick, + onProfileClick, }: Props) { const heartIcon = likedByMe ? "/red_heart.svg" : "/gray_heart.svg"; @@ -54,7 +56,15 @@ export default function BookStoryCard({ md:w-[336px] md:h-[380px]" > {/* 1. 상단 프로필 (모바일 숨김 / 데스크탑 노출) */} -
+
{ + if (onProfileClick) { + e.stopPropagation(); + onProfileClick(); + } + }} + >
void; hideSubscribeButton?: boolean; + onProfileClick?: () => void; }; export default function BookStoryCardLarge({ @@ -41,6 +42,7 @@ export default function BookStoryCardLarge({ isFollowing = false, onClick, hideSubscribeButton = false, + onProfileClick, }: Props) { const heartIcon = likedByMe ? "/red_heart.svg" : "/gray_heart.svg"; @@ -51,7 +53,15 @@ export default function BookStoryCardLarge({ w-[336px] h-[380px]" > {/* 상단 프로필 */} -
+
{ + if (onProfileClick) { + e.stopPropagation(); + onProfileClick(); + } + }} + >
(null); const heartIcon = likedByMe ? "/red_heart.svg" : "/gray_heart.svg"; + const { mutate: reportMember } = useReportMemberMutation(); // 바깥 클릭 시 메뉴 닫기 useEffect(() => { @@ -84,6 +89,19 @@ export default function BookstoryDetail({ return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + const handleReportSubmit = (type: string, content: string) => { + let mappedType: ReportType = "GENERAL"; + if (type === "책 이야기") mappedType = "BOOK_STORY"; + if (type === "책이야기(댓글)") mappedType = "COMMENT"; + if (type === "책모임 내부") mappedType = "CLUB_MEETING"; + + reportMember({ + reportedMemberNickname: authorNickname, + reportType: mappedType, + content, + }); + }; + return (
{subscribeText} @@ -147,7 +165,7 @@ export default function BookstoryDetail({
+ {/* Report Modal */} + setIsReportModalOpen(false)} + onSubmit={handleReportSubmit} + defaultReportType="책 이야기" + />
); } diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx index b1e6a418..de52f29f 100644 --- a/src/components/base-ui/Comment/comment_section.tsx +++ b/src/components/base-ui/Comment/comment_section.tsx @@ -11,6 +11,9 @@ import { } from "@/hooks/mutations/useStoryMutations"; import { toast } from "react-hot-toast"; import ConfirmModal from "@/components/common/ConfirmModal"; +import ReportModal from "@/components/common/ReportModal"; +import { useReportMemberMutation } from "@/hooks/mutations/useMemberMutations"; +import { ReportType } from "@/types/member"; // 어떤 글의 댓글인지 구분 type CommentSectionProps = { @@ -28,6 +31,7 @@ export default function CommentSection({ const createCommentMutation = useCreateCommentMutation(storyId); const updateCommentMutation = useUpdateCommentMutation(storyId); const deleteCommentMutation = useDeleteCommentMutation(storyId); + const { mutate: reportMember } = useReportMemberMutation(); // API 데이터를 UI용 Comment 형식으로 변환 및 계층 구조화 const mapApiToUiComments = (apiComments: CommentInfo[]): Comment[] => { @@ -73,6 +77,9 @@ export default function CommentSection({ const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [commentToDelete, setCommentToDelete] = useState(null); + const [isReportModalOpen, setIsReportModalOpen] = useState(false); + const [reportTargetNickname, setReportTargetNickname] = useState(""); + // 데이터가 변경되면 상태 업데이트 useEffect(() => { setComments(mapApiToUiComments(initialComments)); @@ -144,9 +151,42 @@ export default function CommentSection({ }; const handleReportComment = (id: number) => { - // TODO: 댓글 신고 API 연동 - console.log("댓글 신고:", id); - toast.success("신고가 접수되었습니다."); + // 찾기: 최상단 댓글과 대댓글 모두 탐색 + let targetComment: Comment | undefined; + + for (const c of comments) { + if (c.id === id) { + targetComment = c; + break; + } + if (c.replies) { + const found = c.replies.find(r => r.id === id); + if (found) { + targetComment = found; + break; + } + } + } + + if (targetComment) { + setReportTargetNickname(targetComment.authorName); + setIsReportModalOpen(true); + } + }; + + const handleReportSubmit = (type: string, content: string) => { + let mappedType: ReportType = "GENERAL"; + if (type === "책 이야기") mappedType = "BOOK_STORY"; + if (type === "책이야기(댓글)") mappedType = "COMMENT"; + if (type === "책모임 내부") mappedType = "CLUB_MEETING"; + + if (reportTargetNickname) { + reportMember({ + reportedMemberNickname: reportTargetNickname, + reportType: mappedType, + content, + }); + } }; return ( @@ -168,6 +208,17 @@ export default function CommentSection({ setCommentToDelete(null); }} /> + + {/* Report Modal */} + { + setIsReportModalOpen(false); + setReportTargetNickname(""); + }} + onSubmit={handleReportSubmit} + defaultReportType="책이야기(댓글)" + /> ); } diff --git a/src/components/base-ui/Profile/BookStoryList.tsx b/src/components/base-ui/Profile/BookStoryList.tsx index d39220a1..9b407e85 100644 --- a/src/components/base-ui/Profile/BookStoryList.tsx +++ b/src/components/base-ui/Profile/BookStoryList.tsx @@ -1,87 +1,100 @@ "use client"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; import BookStoryCard from "@/components/base-ui/BookStory/bookstory_card"; +import { useOtherMemberInfiniteStoriesQuery } from "@/hooks/queries/useStoryQueries"; +import { useInView } from "react-intersection-observer"; +import { useToggleStoryLikeMutation } from "@/hooks/mutations/useStoryMutations"; +import { useToggleFollowMutation } from "@/hooks/mutations/useMemberMutations"; -const MOCK_STORIES = [ - { - id: 1, - authorName: "hy", - createdAt: new Date().toISOString(), - viewCount: 128, - title: "한밤의 도서관에서 발견한 기적", - content: "조용한 도서관 구석에서...", - likeCount: 42, - commentCount: 12, - }, - { - id: 2, - authorName: "hy", - createdAt: new Date().toISOString(), - viewCount: 350, - title: "이기적 유전자, 다시 읽기", - content: "대학 시절 읽었던...", - likeCount: 85, - commentCount: 24, - }, - { - id: 3, - authorName: "hy", - createdAt: new Date().toISOString(), - viewCount: 95, - title: "여행의 이유를 찾아서", - content: "김영하 작가님의...", - likeCount: 30, - commentCount: 5, - }, - { - id: 4, - authorName: "hy", - createdAt: new Date().toISOString(), - viewCount: 420, - title: "돈의 심리학: 부의 비밀", - content: "부자가 되는 것보다...", - likeCount: 150, - commentCount: 45, - }, - { - id: 5, - authorName: "hy", - createdAt: new Date().toISOString(), - viewCount: 210, - title: "불편한 편의점의 따뜻한 위로", - content: "제목과는 달리...", - likeCount: 67, - commentCount: 18, - }, - { - id: 6, - authorName: "hy", - createdAt: new Date().toISOString(), - viewCount: 88, - title: "코스모스, 우주를 향한 항해", - content: "칼 세이건의 코스모스는...", - likeCount: 55, - commentCount: 9, - }, -]; - -export default function BookStoryList() { +export default function BookStoryList({ nickname }: { nickname: string }) { const router = useRouter(); + const { mutate: toggleLike } = useToggleStoryLikeMutation(); + const { mutate: toggleFollow } = useToggleFollowMutation(); + + const decodedNickname = decodeURIComponent(nickname); + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isError + } = useOtherMemberInfiniteStoriesQuery(decodedNickname); + + const { ref, inView } = useInView({ threshold: 0 }); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isError) { + return ( +
+ 책 이야기를 불러오는 중 오류가 발생했습니다. +
+ ); + } + + const stories = data?.pages.flatMap((page) => page.basicInfoList) || []; + + if (stories.length === 0) { + return ( +
+ 작성된 책 이야기가 없습니다. +
+ ); + } + return (
-
- {MOCK_STORIES.map(({ id, ...storyData }) => ( +
+ {stories.map((story) => ( router.push(`/stories/${id}`)} + key={story.bookStoryId} + id={story.bookStoryId} + authorName={story.authorInfo.nickname} + profileImgSrc={story.authorInfo.profileImageUrl} + createdAt={story.createdAt} + viewCount={story.viewCount} + title={story.bookStoryTitle} + content={story.description} + likeCount={story.likes} + commentCount={story.commentCount} + likedByMe={story.likedByMe} + coverImgSrc={story.bookInfo.imgUrl} + subscribeText={story.authorInfo.following ? "구독 중" : "구독"} + isFollowing={story.authorInfo.following} + onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })} + hideSubscribeButton={story.writtenByMe} + onClick={() => router.push(`/stories/${story.bookStoryId}`)} + onLikeClick={() => toggleLike(story.bookStoryId)} /> ))}
+ + {/* 무한 스크롤 타겟 */} + {hasNextPage && ( +
+ {isFetchingNextPage ? ( +
+ ) : ( +
+ )} +
+ )}
); } diff --git a/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx b/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx index b2a483fb..d18db73d 100644 --- a/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx +++ b/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx @@ -8,11 +8,11 @@ import BookStoryList from "@/components/base-ui/Profile/BookStoryList"; const TABS = ["책 이야기", "서재", "모임"] as const; type Tab = (typeof TABS)[number]; -export default function OtherUserProfileTabs() { +export default function OtherUserProfileTabs({ nickname }: { nickname: string }) { const [activeTab, setActiveTab] = useState("책 이야기"); const TAB_CONTENT: Record = { - "책 이야기": , + "책 이야기": , 서재: , 모임: , }; @@ -28,10 +28,9 @@ export default function OtherUserProfileTabs() { onClick={() => setActiveTab(tab)} className={`flex-1 py-[10px] text-center transition-colors body_1_2 t:subhead_3 - ${ - activeTab === tab - ? "border-b-2 border-primary-3 text-primary-3 -mb-[2px]" - : "text-Gray-3 border-b-2 border-transparent" + ${activeTab === tab + ? "border-b-2 border-primary-3 text-primary-3 -mb-[2px]" + : "text-Gray-3 border-b-2 border-transparent" }`} > {tab} diff --git a/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx b/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx index fffe001d..f53cd7ce 100644 --- a/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx +++ b/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx @@ -1,14 +1,22 @@ "use client"; import Image from "next/image"; +import { useOtherProfileQuery } from "@/hooks/queries/useMemberQueries"; + +import { useToggleFollowMutation, useReportMemberMutation } from "@/hooks/mutations/useMemberMutations"; +import { ReportType } from "@/types/member"; +import { useState } from "react"; +import ReportModal from "@/components/common/ReportModal"; // [보조 컴포넌트] 액션 버튼 (구독하기 / 신고하기) function ActionButton({ variant, label, + onClick, }: { - variant: "primary" | "secondary"; + variant: "primary" | "secondary" | "following"; label: string; + onClick?: () => void; }) { const baseStyles = "flex items-center justify-center rounded-[8px] transition-colors"; @@ -17,6 +25,9 @@ function ActionButton({ primary: "bg-primary-1 text-White font-semibold t:font-medium w-[220px] h-[32px] t:w-[486px] t:h-[48px] d:w-[532px]", + following: + "bg-[var(--Subbrown_4)] text-primary-3 font-semibold t:font-medium w-[220px] h-[32px] t:w-[486px] t:h-[48px] d:w-[532px]", + secondary: "bg-White border border-Subbrown-3 text-Gray-4 font-medium hover:bg-gray-50 w-[100px] h-[32px] t:w-[178px] t:h-[48px]", }; @@ -24,7 +35,7 @@ function ActionButton({ const textStyles = "body_1_2 t:subhead_4_1"; return ( - ); @@ -43,6 +54,45 @@ function StatItem({ label, count }: { label: string; count: number }) { } export default function ProfileUserInfo({ nickname }: { nickname: string }) { + const decodedNickname = decodeURIComponent(nickname); + const { data: profile, isLoading } = useOtherProfileQuery(decodedNickname); + const { mutate: toggleFollow } = useToggleFollowMutation(); + const { mutate: reportMember } = useReportMemberMutation(); + const [isReportModalOpen, setIsReportModalOpen] = useState(false); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!profile) { + return ( +
+ 프로필 정보를 불러올 수 없습니다. +
+ ); + } + + const handleToggleFollow = () => { + toggleFollow({ nickname: decodedNickname, isFollowing: profile.following }); + }; + + const handleReportSubmit = (type: string, content: string) => { + let mappedType: ReportType = "GENERAL"; + if (type === "책 이야기") mappedType = "BOOK_STORY"; + if (type === "책이야기(댓글)") mappedType = "COMMENT"; + if (type === "책모임 내부") mappedType = "CLUB_MEETING"; + + reportMember({ + reportedMemberNickname: decodedNickname, + reportType: mappedType, + content, + }); + }; + return (
{`${nickname}님의 @@ -78,29 +128,39 @@ export default function ProfileUserInfo({ nickname }: { nickname: string }) { {/* 닉네임 & 통계 */}
{/* 닉네임 */} -

{nickname}

+

{profile.nickname}

{/* 통계 그룹 */}
- - + +
{/* 소개글 */}

- 이제 다양한 책을 함께 읽고 서로의 생각을 나누는 특별한 시간을 - 시작해보세요. 한 권의 책이 주는 작은 울림이 일상에 큰 변화를 - 가져올지도 모릅니다. + {profile.description || "이 사용자는 소개를 작성하지 않았습니다."}

{/* 2. 하단 버튼 그룹 */}
- - + + setIsReportModalOpen(true)} />
+ + {/* Report Modal */} + setIsReportModalOpen(false)} + onSubmit={handleReportSubmit} + defaultReportType="일반" + />
); } diff --git a/src/components/common/ReportModal.tsx b/src/components/common/ReportModal.tsx new file mode 100644 index 00000000..64d8a232 --- /dev/null +++ b/src/components/common/ReportModal.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; + +type ReportType = "일반" | "책 이야기" | "책이야기(댓글)" | "책모임 내부" | null; + +type ReportModalProps = { + isOpen: boolean; + onClose: () => void; + onSubmit: (type: string, content: string) => void; + defaultReportType?: ReportType; +}; + +export default function ReportModal({ isOpen, onClose, onSubmit, defaultReportType = null }: ReportModalProps) { + const [reportType, setReportType] = useState(defaultReportType); + const [reportContent, setReportContent] = useState(""); + + const reportTypes: ReportType[] = ["일반", "책 이야기", "책이야기(댓글)", "책모임 내부"]; + const isSubmitEnabled = reportType !== null && reportContent.trim().length > 0; + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen) { + onClose(); + } + }; + + if (isOpen) { + document.body.style.overflow = "hidden"; + document.addEventListener("keydown", handleEscape); + setReportType(defaultReportType); + } else { + document.body.style.overflow = ""; + // Reset state when closed + setReportType(defaultReportType); + setReportContent(""); + } + + return () => { + document.body.style.overflow = ""; + document.removeEventListener("keydown", handleEscape); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const handleContentChange = (e: React.ChangeEvent) => { + if (e.target.value.length <= 400) { + setReportContent(e.target.value); + } + }; + + return ( +
+ {/* Modal Container */} +
e.stopPropagation()} + > + {/* Header: Title and Close button */} +
+

신고하기

+ +
+ + {/* Body: Report Type and Content */} +
+ + {/* Report Type Section */} +
+ 종류 +
+ {reportTypes.map((type) => { + const isSelected = reportType === type; + return ( + + ); + })} +
+
+ + {/* Report Content Section */} +
+ 내용 +