diff --git a/src/api/contents/mutations.ts b/src/api/contents/mutations.ts index a4de00a..3cf7d23 100644 --- a/src/api/contents/mutations.ts +++ b/src/api/contents/mutations.ts @@ -25,7 +25,7 @@ export const usePostComment = (contentId: number) => { return useMutation({ mutationFn: (body) => api - .post(`contents/${contentId}/comment`, { json: body }) + .post(`contents/${contentId}/comments`, { json: body }) .json(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["comments", contentId] }); diff --git a/src/api/contents/queries.ts b/src/api/contents/queries.ts index 1613f0f..d1b0d74 100644 --- a/src/api/contents/queries.ts +++ b/src/api/contents/queries.ts @@ -16,7 +16,7 @@ export const useGetContent = (contentId: number) => { export const useGetComments = ( contentId: number, size: number = 20, - sort: string = "latest" + sort: string = "latest", ) => { return useInfiniteQuery({ queryKey: ["comments", contentId, sort], @@ -29,7 +29,7 @@ export const useGetComments = ( searchParams.cursor = pageParam as string; } return api - .get(`contents/${contentId}/comment`, { searchParams }) + .get(`contents/${contentId}/comments`, { searchParams }) .json(); }, initialPageParam: "", diff --git a/src/assets/icons/backspace_dark.svg b/src/assets/icons/backspace_dark.svg new file mode 100644 index 0000000..a436126 --- /dev/null +++ b/src/assets/icons/backspace_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/send.svg b/src/assets/icons/send.svg new file mode 100644 index 0000000..b1cbf7a --- /dev/null +++ b/src/assets/icons/send.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/pages/FeedDetailPage.tsx b/src/pages/FeedDetailPage.tsx index 2dc84aa..f6ee904 100644 --- a/src/pages/FeedDetailPage.tsx +++ b/src/pages/FeedDetailPage.tsx @@ -1,76 +1,73 @@ -import { useState } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import styled from "styled-components"; import { theme } from "../styles/theme"; -import { useNavigate } from "react-router-dom"; - -// Mock 댓글 데이터 -const mockComments = [ - { - id: 1, - username: "곰돌이포수88", - content: "드디어 우리 포수가 타격왕이라니 ㅠㅠ 진짜 전설이다.", - time: "2분전", - }, - { - id: 2, - username: "두린이23", - content: "양의지 타격은 진짜 교과서지… 타격폼 보면 미쳤음.", - time: "13분전", - }, - { - id: 3, - username: "헬스곰곰이", - content: "김동주→김현수→양의지… 이 계보 진짜 실화냐 ㄷㄷ", - time: "30분전", - }, - { - id: 4, - username: "리틀베어", - content: "나 초등학생 때 김현수 봤는데, 이젠 양의지가… 세월 빠르다.", - time: "1시간전", - }, - { - id: 5, - username: "타율0점대싫어", - content: "올해 타격폼 유지한 게 진짜 대단함. 체력관리도 완벽.", - time: "1시간전", - }, - { - id: 6, - username: "야알못탈출중", - content: "야구는 몰라도 양의지는 알겠다", - time: "2시간전", - }, - { - id: 7, - username: "잠실직관러", - content: "직관 갔을 때 홈런 친 그날 잊을 수가 없다", - time: "3시간전", - }, - { - id: 8, - username: "BaseKing91", - content: "이제는 '곰의 왕'이라고 불러도 무방함", - time: "3시간전", - }, -]; - -// Mock 피드 데이터 -const mockFeedDetail = { - id: 1, - imageSrc: - "https://www.figma.com/api/mcp/asset/375a9cfa-a008-47ec-88f6-d114b3063634", - likeCount: 234, - commentCount: 7, - title: "'김동주→김현수→양의지' 곰 동굴에서 역대 3번째 타격왕 등장", - time: "3시간전", +import { useNavigate, useParams } from "react-router-dom"; +import BackSpace from "../assets/icons/backspace_dark.svg"; +import Heart from "../assets/icons/heart_01.svg"; +import Filled_Heart from "../assets/icons/heart_02.svg"; +import Comment from "../assets/icons/chat01.svg"; +import Send from "../assets/icons/send.svg"; +import { useGetContent, useGetComments } from "../api/contents/queries"; +import { usePostLike, usePostComment } from "../api/contents/mutations"; + +const formatRelativeTime = (dateString: string): string => { + const now = new Date(); + const date = new Date(dateString); + const diffMs = now.getTime() - date.getTime(); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMinutes < 1) return "방금 전"; + if (diffMinutes < 60) return `${diffMinutes}분전`; + if (diffHours < 24) return `${diffHours}시간전`; + return `${diffDays}일전`; }; const FeedDetailPage = () => { const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const contentId = Number(id); + const [sortOrder, setSortOrder] = useState<"latest" | "popular">("latest"); const [commentInput, setCommentInput] = useState(""); + const { data: content, isLoading: isContentLoading } = + useGetContent(contentId); + const { + data: commentPages, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useGetComments(contentId, 20, sortOrder); + const { mutate: toggleLike } = usePostLike(contentId); + const { mutate: postComment, isPending: isSubmitting } = + usePostComment(contentId); + + const comments = commentPages?.pages.flatMap((page) => page.comments) ?? []; + + const sentinelRef = useRef(null); + + const handleObserver = useCallback( + (entries: IntersectionObserverEntry[]) => { + const [entry] = entries; + if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + [fetchNextPage, hasNextPage, isFetchingNextPage], + ); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + const observer = new IntersectionObserver(handleObserver, { + threshold: 0.1, + }); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [handleObserver]); + const handleBack = () => { navigate(-1); }; @@ -79,13 +76,41 @@ const FeedDetailPage = () => { setSortOrder(sortOrder === "latest" ? "popular" : "latest"); }; + const handleSubmitComment = () => { + if (!commentInput.trim() || isSubmitting) return; + postComment( + { comment: commentInput.trim() }, + { onSuccess: () => setCommentInput("") }, + ); + }; + + const handleCommentKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSubmitComment(); + } + }; + + if (isContentLoading || !content) { + return ( + + + + + + + + + + ); + } + return ( {/* AppBar */} - + @@ -98,21 +123,21 @@ const FeedDetailPage = () => { {/* Feed Content - Left Side */} - - Feed + window.open(content.article_url, "_blank")}> + Feed - - - {mockFeedDetail.likeCount} + toggleLike()}> + + {content.like_count} - - {mockFeedDetail.commentCount} + + {content.comment_count} - {mockFeedDetail.title} + {content.title} @@ -120,20 +145,31 @@ const FeedDetailPage = () => { - {mockComments.map((comment) => ( + {comments.map((comment) => ( - + {comment.profile_image_url ? ( + + ) : ( + + )} - {comment.username} + {comment.nickname} {comment.content} - {comment.time} + + {formatRelativeTime(comment.created_at)} + ))} +
+ {isFetchingNextPage && 불러오는 중...} @@ -141,20 +177,27 @@ const FeedDetailPage = () => { 좋아요 - {mockFeedDetail.likeCount}개 + {content.like_count}개 - {mockFeedDetail.time} + + {formatRelativeTime(content.published_at)} + - + setCommentInput(e.target.value)} + onKeyDown={handleCommentKeyDown} + disabled={isSubmitting} /> - - + + @@ -212,25 +255,10 @@ const BackButton = styled.button` width: 44px; height: 100%; `; - -const BackIcon = styled.div` - width: 15.58px; - height: 15.17px; - position: relative; - - &::before { - content: ""; - position: absolute; - width: 10px; - height: 10px; - border-left: 2px solid ${theme.colors.dark01}; - border-bottom: 2px solid ${theme.colors.dark01}; - transform: rotate(45deg); - top: 50%; - left: 50%; - margin-left: -3px; - margin-top: -5px; - } +const BackIcon = styled.img` + width: 24px; + height: 24px; + object-fit: contain; `; const SortButton = styled.button` @@ -305,6 +333,7 @@ const FeedImage = styled.div` align-items: center; justify-content: center; background: ${theme.colors.white}; + cursor: pointer; img { width: 100%; @@ -330,44 +359,19 @@ const ActionItem = styled.div` display: flex; align-items: center; gap: 4px; + cursor: pointer; `; -const HeartIcon = styled.div` +const HeartIcon = styled.img` width: 24px; height: 24px; - position: relative; - - &::before { - content: ""; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 20px; - height: 18px; - background: url("data:image/svg+xml,%3Csvg width='20' height='18' viewBox='0 0 20 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 17.25C10 17.25 1.5 12 1.5 6.5C1.5 3.46 3.96 1 7 1C8.83 1 10.41 1.95 11.25 3.36C11.66 2.54 12.28 1.85 13.04 1.34C13.8 0.83 14.67 0.52 15.58 0.42C16.49 0.32 17.41 0.45 18.26 0.79C19.11 1.13 19.87 1.67 20.46 2.37C21.06 3.07 21.47 3.9 21.66 4.79C21.85 5.68 21.82 6.6 21.57 7.48C21.31 8.35 20.85 9.15 20.21 9.81C19.57 10.46 18.77 10.95 17.89 11.24' stroke='%23374151' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") - center no-repeat; - background-size: contain; - } + object-fit: contain; `; -const CommentIcon = styled.div` +const CommentIcon = styled.img` width: 24px; height: 24px; - position: relative; - - &::before { - content: ""; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 20px; - height: 20px; - background: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18 9.5C18 14.19 14.19 18 9.5 18L4.6 19.8C4.17 19.97 3.7 19.7 3.53 19.27C3.47 19.13 3.45 18.97 3.49 18.82L4.5 14.97C3.55 13.44 3 11.57 3 9.5C3 4.81 6.81 1 11.5 1C16.19 1 20 4.81 20 9.5' stroke='%23374151' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M7 9.5H13' stroke='%23374151' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E") - center no-repeat; - background-size: contain; - } + object-fit: contain; `; const ActionCount = styled.span` @@ -434,6 +438,13 @@ const Avatar = styled.div` align-items: center; justify-content: center; flex-shrink: 0; + overflow: hidden; +`; + +const AvatarImg = styled.img` + width: 100%; + height: 100%; + object-fit: cover; `; const PersonIcon = styled.div` @@ -484,6 +495,13 @@ const CommentTime = styled.span` color: ${theme.colors.dark04}; `; +const LoadingText = styled.div` + ${theme.typography.caption02} + color: ${theme.colors.dark04}; + text-align: center; + padding: 8px 0; +`; + const CommentInputArea = styled.div` padding: 8px 16px; border-top: 1px solid ${theme.colors.light02}; @@ -530,26 +548,6 @@ const CommentInputRow = styled.div` align-items: center; `; -const ChatIcon = styled.div` - width: 24px; - height: 24px; - flex-shrink: 0; - position: relative; - - &::before { - content: ""; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 18px; - height: 18px; - background: url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16 8.5C16 12.64 12.64 16 8.5 16L4.4 17.6C4.02 17.75 3.6 17.53 3.45 17.15C3.4 17.03 3.38 16.89 3.42 16.76L4.3 13.47C3.48 12.16 3 10.56 3 8.5C3 4.36 6.36 1 10.5 1C14.64 1 18 4.36 18 8.5' stroke='%23374151' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") - center no-repeat; - background-size: contain; - } -`; - const CommentInputWrapper = styled.div` flex: 1; background: ${theme.colors.light01}; @@ -574,6 +572,10 @@ const CommentInputField = styled.input` &:focus { outline: none; } + + &:disabled { + opacity: 0.5; + } `; const SendButton = styled.button` @@ -584,23 +586,14 @@ const SendButton = styled.button` display: flex; align-items: center; justify-content: center; -`; -const SendIcon = styled.div` + &:disabled { + opacity: 0.4; + cursor: default; + } +`; +const SendIcon = styled.img` width: 24px; height: 24px; - position: relative; - - &::before { - content: ""; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 18px; - height: 18px; - background: url("data:image/svg+xml,%3Csvg width='18' height='20' viewBox='0 0 18 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M17 1L8 10M17 1L11 19L8 10M17 1L1 8L8 10' stroke='%23374151' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") - center no-repeat; - background-size: contain; - } + object-fit: contain; `;