Skip to content

Commit

Permalink
Merge pull request #93 from hufs-sports-live/feat/comment
Browse files Browse the repository at this point in the history
[FEAT] 댓글 기능 구현
  • Loading branch information
seongminn authored Nov 28, 2023
2 parents d3b5128 + 54f634c commit e353306
Show file tree
Hide file tree
Showing 17 changed files with 450 additions and 33 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions src/api/match.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
MatchCheerType,
MatchCommentPayload,
MatchCommentType,
MatchLineupType,
MatchListType,
Expand Down Expand Up @@ -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<MatchCommentType[]>(
`/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);
};
54 changes: 53 additions & 1 deletion src/app/match/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,57 @@
'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<MatchCommentType[]>([]);

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: '응원댓글' },
{ label: '경기영상' },
{ label: '타임라인' },
];

const scrollRef = useRef(null);
const scrollToBottom = () => {
if (!scrollRef.current) return;

(scrollRef.current as HTMLDivElement).scrollIntoView();
};

return (
<section>
<Suspense fallback={<div>배너 로딩중...</div>}>
Expand Down Expand Up @@ -58,6 +88,28 @@ export default function Match({ params }: { params: { id: string } }) {
)}
</MatchTimelineFetcher>
)}
{selected === '응원댓글' && (
<MatchCommentFetcher matchId={params.id}>
{({ commentList, ...data }) => (
<div className="max-h-[450px] overflow-y-auto p-5">
<ul className="pb-8">
<CommentList
commentList={commentList.pages.flat()}
scrollToBottom={scrollToBottom}
{...data}
/>
<CommentList.SocketList commentList={comments} />
<li ref={scrollRef}></li>
</ul>
<CommentForm
matchId={params.id}
mutate={mutate}
scrollToBottom={scrollToBottom}
/>
</div>
)}
</MatchCommentFetcher>
)}
{selected === '경기영상' && (
<MatchVideoFetcher matchId={params.id}>
{data => (
Expand Down
8 changes: 7 additions & 1 deletion src/components/common/MatchCard/pieces/Label.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { useMatchCardContext } from '@/hooks/useMatchCardContext';
import { $ } from '@/utils/core';
import { parseTimeString } from '@/utils/time';

type LabelProps = {
className?: string;
};

export default function Label({ className }: LabelProps) {
const { gameName, sportsName, startTime } = useMatchCardContext();
const { year, month, date, weekday } = parseTimeString(startTime);

return (
<div className={$(className)}>
{startTime && <div>{startTime}</div>}
{startTime && (
<time>
{year}. {month}. {date}. ({weekday})
</time>
)}
{sportsName && <div className="text-center">{sportsName}</div>}
{gameName && <div className="text-right">{gameName}</div>}
</div>
Expand Down
54 changes: 54 additions & 0 deletions src/components/match/CommentForm/index.tsx
Original file line number Diff line number Diff line change
@@ -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<void, Error, MatchCommentPayload, unknown>;
scrollToBottom: () => void;
};

export default function CommentForm({
matchId,
mutate,
scrollToBottom,
}: CommentFormProps) {
const [inputValue, setInputValue] = useState('');
const handleCommentSubmit = (
e: FormEvent<HTMLFormElement>,
payload: MatchCommentPayload,
) => {
e.preventDefault();
mutate(payload);
setInputValue('');
scrollToBottom();
};

return (
<form
className="h-70px absolute -bottom-1 left-0 w-full"
onSubmit={e =>
handleCommentSubmit(e, {
gameTeamId: Number(matchId),
content: inputValue,
})
}
>
<div
className="grid items-center rounded-lg bg-gray-2"
style={{ gridTemplateColumns: '1fr auto' }}
>
<input
className="bg-inherit px-5 text-gray-5 outline-none"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="응원하는 팀에 댓글을 남겨보세요!"
/>
<button className="rounded-xl bg-primary px-5 py-3 text-white">
댓글
</button>
</div>
</form>
);
}
72 changes: 72 additions & 0 deletions src/components/match/CommentItem/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<li className={$('mb-1 flex items-end', isEven && 'flex-row-reverse')}>
{isBlocked ? (
<div
className={$(
'rounded-xl border px-3 py-1',
isEven ? 'bg-[#b2c3ff]' : 'bg-[#ffb2b2]',
)}
>
⚠️ 관리자에 의해 차단된 댓글입니다.
</div>
) : (
<div
className={$(
'rounded-xl border px-3 py-1',
isEven ? 'bg-[#b2c3ff]' : 'bg-[#ffb2b2]',
)}
>
{content}
</div>
)}
<div
className={$(
'mb-1 flex items-end justify-between text-xs',
isEven && 'flex-row-reverse',
)}
>
<time
className={$(
'w-max px-2 text-gray-4',
isEven ? 'border-l' : 'border-r',
)}
>
{`${period} ${hours}:${minutes.toString().padStart(2, '0')}`}
</time>
<button
onClick={() => handleClickReportButton({ commentId })}
className="mx-2 w-max text-red-400"
>
신고
</button>
</div>
</li>
);
}
60 changes: 60 additions & 0 deletions src/components/match/CommentList/index.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(
async (entry, observer) => {
observer.unobserve(entry.target);
if (hasNextPage && !isFetching) {
fetchNextPage();
}
},
);

useEffect(() => {
if (!scrollToBottom) return;

scrollToBottom();
}, [scrollToBottom]);

return (
<>
<div ref={ref}></div>
{commentList.map(comment => (
<CommentItem {...comment} key={comment.commentId} order={1} />
))}
</>
);
}

CommentList.SocketList = function SocketList({
commentList,
}: Pick<CommentListProps, 'commentList'>) {
return (
<>
{commentList.map(comment => (
<CommentItem {...comment} key={comment.commentId} order={1} />
))}
</>
);
};
33 changes: 33 additions & 0 deletions src/hooks/useInfiniteObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useCallback, useEffect, useRef } from 'react';

type IntersectHandler = (
entry: IntersectionObserverEntry,
observer: IntersectionObserver,
) => void;

export default function useIntersect<T extends HTMLElement>(
onIntersect: IntersectHandler,
options?: IntersectionObserverInit,
) {
const ref = useRef<T | null>(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 };
}
Loading

0 comments on commit e353306

Please sign in to comment.