diff --git a/package.json b/package.json index 3c18d55..dbe5580 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-icons": "^1.3.0", "@sentry/nextjs": "^7.73.0", + "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.8.2", "@tanstack/react-query-devtools": "^5.8.2", "axios": "^1.5.1", diff --git a/src/api/match.ts b/src/api/match.ts index 30c8f5a..e341ffd 100644 --- a/src/api/match.ts +++ b/src/api/match.ts @@ -1,5 +1,6 @@ import { MatchCheerType, + MatchCommentPayload, MatchCommentType, MatchLineupType, MatchListType, @@ -75,3 +76,23 @@ export const getMatchVideoById = async (matchId: string) => { return data; }; + +export const getMatchCommentById = async ( + matchId: string, + cursor: number | string, + size = 20, +) => { + const { data } = await instance.get( + `/games/${matchId}/comments?cursor=${cursor}&size=${size}`, + ); + + return data; +}; + +export const postMatchComment = async (payload: MatchCommentPayload) => { + await instance.post(`/comments`, payload); +}; + +export const postReportComment = async (payload: { commentId: number }) => { + await instance.post(`/reports`, payload); +}; diff --git a/src/app/match/[id]/page.tsx b/src/app/match/[id]/page.tsx index 06e1dbd..27375d0 100644 --- a/src/app/match/[id]/page.tsx +++ b/src/app/match/[id]/page.tsx @@ -1,20 +1,43 @@ 'use client'; -import { Suspense } from 'react'; +import { Suspense, useRef, useState } from 'react'; import MatchBanner from '@/components/match/Banner'; import Cheer from '@/components/match/Cheer'; +import CommentForm from '@/components/match/CommentForm'; +import CommentList from '@/components/match/CommentList'; import Lineup from '@/components/match/LineupList'; import Panel from '@/components/match/Panel'; import RecordList from '@/components/match/RecordList'; import Video from '@/components/match/Video'; +import useSocket from '@/hooks/useSocket'; import MatchByIdFetcher from '@/queries/useMatchById/Fetcher'; import MatchCheerByIdFetcher from '@/queries/useMatchCheerById/Fetcher'; +import MatchCommentFetcher from '@/queries/useMatchCommentById/Fetcher'; import MatchLineupFetcher from '@/queries/useMatchLineupById/Fetcher'; import MatchTimelineFetcher from '@/queries/useMatchTimelineById/Fetcher'; import MatchVideoFetcher from '@/queries/useMatchVideoById/Fetcher'; +import useSaveCommentMutation from '@/queries/useSaveCommentMutation/query'; +import { MatchCommentType } from '@/types/match'; export default function Match({ params }: { params: { id: string } }) { + const [comments, setComments] = useState([]); + + const handleSocketMessage = (comment: MatchCommentType) => { + if (comment) { + setComments(prev => [...prev, comment]); + } + }; + + const { connect } = useSocket({ + url: 'wss://api.hufstreaming.site/ws', + destination: `/topic/games/${params.id}`, + callback: handleSocketMessage, + }); + + connect(); + + const { mutate } = useSaveCommentMutation(); const options = [ { label: '라인업' }, { label: '응원댓글' }, @@ -22,6 +45,13 @@ export default function Match({ params }: { params: { id: string } }) { { label: '타임라인' }, ]; + const scrollRef = useRef(null); + const scrollToBottom = () => { + if (!scrollRef.current) return; + + (scrollRef.current as HTMLDivElement).scrollIntoView(); + }; + return (
배너 로딩중...}> @@ -58,6 +88,28 @@ export default function Match({ params }: { params: { id: string } }) { )} )} + {selected === '응원댓글' && ( + + {({ commentList, ...data }) => ( +
+
    + + +
  • +
+ +
+ )} +
+ )} {selected === '경기영상' && ( {data => ( diff --git a/src/components/common/MatchCard/pieces/Label.tsx b/src/components/common/MatchCard/pieces/Label.tsx index a4dfeb9..7e8e16b 100644 --- a/src/components/common/MatchCard/pieces/Label.tsx +++ b/src/components/common/MatchCard/pieces/Label.tsx @@ -1,5 +1,6 @@ import { useMatchCardContext } from '@/hooks/useMatchCardContext'; import { $ } from '@/utils/core'; +import { parseTimeString } from '@/utils/time'; type LabelProps = { className?: string; @@ -7,10 +8,15 @@ type LabelProps = { export default function Label({ className }: LabelProps) { const { gameName, sportsName, startTime } = useMatchCardContext(); + const { year, month, date, weekday } = parseTimeString(startTime); return (
- {startTime &&
{startTime}
} + {startTime && ( + + )} {sportsName &&
{sportsName}
} {gameName &&
{gameName}
}
diff --git a/src/components/match/CommentForm/index.tsx b/src/components/match/CommentForm/index.tsx new file mode 100644 index 0000000..0f075fb --- /dev/null +++ b/src/components/match/CommentForm/index.tsx @@ -0,0 +1,54 @@ +import { UseMutateFunction } from '@tanstack/react-query'; +import { FormEvent, useState } from 'react'; + +import { MatchCommentPayload } from '@/types/match'; + +type CommentFormProps = { + matchId: string; + mutate: UseMutateFunction; + scrollToBottom: () => void; +}; + +export default function CommentForm({ + matchId, + mutate, + scrollToBottom, +}: CommentFormProps) { + const [inputValue, setInputValue] = useState(''); + const handleCommentSubmit = ( + e: FormEvent, + payload: MatchCommentPayload, + ) => { + e.preventDefault(); + mutate(payload); + setInputValue(''); + scrollToBottom(); + }; + + return ( +
+ handleCommentSubmit(e, { + gameTeamId: Number(matchId), + content: inputValue, + }) + } + > +
+ setInputValue(e.target.value)} + placeholder="응원하는 팀에 댓글을 남겨보세요!" + /> + +
+
+ ); +} diff --git a/src/components/match/CommentItem/index.tsx b/src/components/match/CommentItem/index.tsx new file mode 100644 index 0000000..fcd98aa --- /dev/null +++ b/src/components/match/CommentItem/index.tsx @@ -0,0 +1,72 @@ +import useReportCommentMutation from '@/queries/useReportCommentMutation/query'; +import { $ } from '@/utils/core'; +import { parseTimeString } from '@/utils/time'; + +type CommentItemProps = { + commentId: number; + content: string; + order: number; + isBlocked: boolean; + createdAt: string; +}; + +export default function CommentItem({ + commentId, + content, + order, + isBlocked, + createdAt, +}: CommentItemProps) { + const { mutate } = useReportCommentMutation(); + const handleClickReportButton = (payload: { commentId: number }) => { + mutate(payload); + }; + + const isEven = order % 2 === 0; + const { period, hours, minutes } = parseTimeString(createdAt); + + return ( +
  • + {isBlocked ? ( +
    + ⚠️ 관리자에 의해 차단된 댓글입니다. +
    + ) : ( +
    + {content} +
    + )} +
    + + +
    +
  • + ); +} diff --git a/src/components/match/CommentList/index.tsx b/src/components/match/CommentList/index.tsx new file mode 100644 index 0000000..1628aef --- /dev/null +++ b/src/components/match/CommentList/index.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useEffect } from 'react'; + +import useInfiniteObserver from '@/hooks/useInfiniteObserver'; +import { MatchCommentType } from '@/types/match'; + +import CommentItem from '../CommentItem'; + +type CommentListProps = { + commentList: MatchCommentType[]; + hasNextPage: boolean; + fetchNextPage: () => void; + isFetching: boolean; + scrollToBottom: () => void; +}; + +export default function CommentList({ + commentList, + fetchNextPage, + hasNextPage, + isFetching, + scrollToBottom, +}: CommentListProps) { + const { ref } = useInfiniteObserver( + async (entry, observer) => { + observer.unobserve(entry.target); + if (hasNextPage && !isFetching) { + fetchNextPage(); + } + }, + ); + + useEffect(() => { + if (!scrollToBottom) return; + + scrollToBottom(); + }, [scrollToBottom]); + + return ( + <> +
    + {commentList.map(comment => ( + + ))} + + ); +} + +CommentList.SocketList = function SocketList({ + commentList, +}: Pick) { + return ( + <> + {commentList.map(comment => ( + + ))} + + ); +}; diff --git a/src/hooks/useInfiniteObserver.ts b/src/hooks/useInfiniteObserver.ts new file mode 100644 index 0000000..3283634 --- /dev/null +++ b/src/hooks/useInfiniteObserver.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useRef } from 'react'; + +type IntersectHandler = ( + entry: IntersectionObserverEntry, + observer: IntersectionObserver, +) => void; + +export default function useIntersect( + onIntersect: IntersectHandler, + options?: IntersectionObserverInit, +) { + const ref = useRef(null); + const callback = useCallback( + (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => { + entries.forEach(entry => { + if (entry.isIntersecting) onIntersect(entry, observer); + }); + }, + [onIntersect], + ); + + useEffect(() => { + if (!ref.current) return; + + const observer = new IntersectionObserver(callback, options); + + observer.observe(ref.current); + + return () => observer.disconnect(); + }, [ref, options, callback]); + + return { ref }; +} diff --git a/src/hooks/useSocket.ts b/src/hooks/useSocket.ts new file mode 100644 index 0000000..c3b9639 --- /dev/null +++ b/src/hooks/useSocket.ts @@ -0,0 +1,44 @@ +import { Client } from '@stomp/stompjs'; +import { useRef } from 'react'; + +type useSocketParams = { + url: string; + destination: string; + callback: (message: T) => void; +}; + +export default function useSocket({ + url, + destination, + callback, +}: useSocketParams) { + const stompRef = useRef(null); + const client = new Client({ + brokerURL: url, + }); + + const connect = () => { + if (stompRef.current) return; + + stompRef.current = client; + + client.activate(); + client.onConnect = () => { + client.subscribe(destination, message => { + try { + callback(JSON.parse(message.body)); + } catch (error) { + console.error(error); + } + }); + }; + }; + + const disconnect = () => { + if (!stompRef.current) return; + + client.deactivate(); + }; + + return { connect, disconnect, stompRef }; +} diff --git a/src/queries/useMatchCommentById/Fetcher.tsx b/src/queries/useMatchCommentById/Fetcher.tsx new file mode 100644 index 0000000..1ac5e28 --- /dev/null +++ b/src/queries/useMatchCommentById/Fetcher.tsx @@ -0,0 +1,33 @@ +import { InfiniteData } from '@tanstack/react-query'; +import { ReactNode } from 'react'; + +import { MatchCommentType } from '@/types/match'; + +import useMatchCommentById from './query'; + +type MatchCommentFetcherProps = { + matchId: string; + children: ({ + commentList, + fetchNextPage, + hasNextPage, + isFetching, + }: { + commentList: InfiniteData; + fetchNextPage: () => void; + hasNextPage: boolean; + isFetching: boolean; + }) => ReactNode; +}; + +export default function MatchCommentFetcher({ + matchId, + children, +}: MatchCommentFetcherProps) { + const { commentList, error, fetchNextPage, hasNextPage, isFetching } = + useMatchCommentById(matchId); + + if (error) throw error; + + return children({ commentList, fetchNextPage, hasNextPage, isFetching }); +} diff --git a/src/queries/useMatchCommentById/query.ts b/src/queries/useMatchCommentById/query.ts new file mode 100644 index 0000000..ae4d537 --- /dev/null +++ b/src/queries/useMatchCommentById/query.ts @@ -0,0 +1,24 @@ +import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; + +import { getMatchCommentById } from '@/api/match'; +export default function useMatchCommentById(matchId: string) { + const { data, error, fetchNextPage, hasNextPage, isFetching } = + useSuspenseInfiniteQuery({ + queryKey: ['match-comment', matchId], + initialPageParam: 0, + queryFn: ({ pageParam }) => getMatchCommentById(matchId, pageParam || ''), + getNextPageParam: lastPage => lastPage[0]?.commentId || null, + select: data => ({ + pages: [...data.pages].reverse(), + pageParams: [...data.pageParams].reverse(), + }), + }); + + return { + commentList: data, + fetchNextPage, + hasNextPage, + isFetching, + error, + }; +} diff --git a/src/queries/useReportCommentMutation/query.ts b/src/queries/useReportCommentMutation/query.ts new file mode 100644 index 0000000..a2238d4 --- /dev/null +++ b/src/queries/useReportCommentMutation/query.ts @@ -0,0 +1,10 @@ +import { useMutation } from '@tanstack/react-query'; + +import { postReportComment } from '@/api/match'; + +export default function useReportCommentMutation() { + return useMutation({ + mutationKey: ['report-comment'], + mutationFn: postReportComment, + }); +} diff --git a/src/queries/useSaveCommentMutation/query.ts b/src/queries/useSaveCommentMutation/query.ts new file mode 100644 index 0000000..0c16d76 --- /dev/null +++ b/src/queries/useSaveCommentMutation/query.ts @@ -0,0 +1,10 @@ +import { useMutation } from '@tanstack/react-query'; + +import { postMatchComment } from '@/api/match'; + +export default function useSaveCommentMutation() { + return useMutation({ + mutationKey: ['save-comment'], + mutationFn: postMatchComment, + }); +} diff --git a/src/types/match.ts b/src/types/match.ts index f9e0aeb..4acda13 100644 --- a/src/types/match.ts +++ b/src/types/match.ts @@ -47,12 +47,18 @@ export type MatchPlayerType = { }; export type MatchCommentType = { - id: number; + commentId: number; content: string; + gameTeamId: number; createdAt: string; isBlocked: boolean; }; +export type MatchCommentPayload = Pick< + MatchCommentType, + 'gameTeamId' | 'content' +>; + export type MatchVideoType = { videoId: string; }; diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 0000000..7065b4f --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,16 @@ +const weekdays = ['일', '월', '화', '수', '목', '금', '토'] as const; + +export const parseTimeString = (timeString: string) => { + const date = new Date(timeString + 'Z'); + + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + date: date.getDate(), + weekday: weekdays[date.getDay()], + period: date.getHours() >= 12 ? '오후' : '오전', + hours: date.getHours() > 12 ? date.getHours() - 12 : date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds(), + }; +}; diff --git a/src/utils/utc-times.ts b/src/utils/utc-times.ts deleted file mode 100644 index 5adf9bd..0000000 --- a/src/utils/utc-times.ts +++ /dev/null @@ -1,30 +0,0 @@ -type UtcHoursProps = { - year?: number; - month?: number; - date?: number; - hour?: number; - minute?: number; - second?: number; -}; - -export const getUtcHours = (props: UtcHoursProps) => { - const currentDate = new Date(); - const { - year = currentDate.getFullYear(), - month = currentDate.getMonth() + 1, - date = currentDate.getDate(), - hour = currentDate.getHours(), - minute = currentDate.getMinutes(), - second = currentDate.getSeconds(), - } = props; - - return new Date( - `${year}-${parseTime(month)}-${parseTime(date)}T${parseTime( - hour, - )}:${parseTime(minute)}:${parseTime(second)}Z`, - ); -}; - -export const parseTime = (value: number) => { - return value.toString().padStart(2, '0'); -}; diff --git a/yarn.lock b/yarn.lock index f1197b8..023201f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -703,6 +703,11 @@ "@sentry/cli" "^1.74.6" webpack-sources "^2.0.0 || ^3.0.0" +"@stomp/stompjs@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@stomp/stompjs/-/stompjs-7.0.0.tgz#46b5c454a9dc8262e0b20f3b3dbacaa113993077" + integrity sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw== + "@swc/helpers@0.5.2": version "0.5.2" resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d"