Skip to content

Commit

Permalink
솔루션 상세 페이지 댓글 리스트 및 댓글 생성 구현 (#328)
Browse files Browse the repository at this point in the history
* feat: solution 디테일 페이지 생성

* feat: 댓글 제출 창 기본 형태 구현

* feat: 댓글 제출 api 연결

* feat: CommentList 컴포넌트 생성

* refactor: CommentForm 스타일 파일 분리

* feat: 대댓글 제외한 댓글 리스트 기본 형태 구현 완료

* feat: 대댓글 리스팅 구현 완료

* feat: 로그인 시에만 댓글창이 보이도록 처리

* feat: 대댓글 작성 기능 구현 완료

* feat: comments GET api 연결

* feat: solution detail Route 추가

* chore: 불필요한 주석 삭제

* chore: 임시로 localhost로 설정해두었던 서버 url 수정

* feat: 댓글 제출 시 invalidate 로직 실행하도록 기능 추가

* feat: markdown preview 적용

* feat: md preview sanitize 적용

* feat: 댓글 프로필 이미지 오류 발생시 기본 이미지로 대체

* feat: 삭제된 댓글 표시 구현

* feat: 사용하지 않는 헤더 알림 기능 제거

* refactor: 댓글 관련 컴포넌트 가독성 개선

* feat: 댓글 관련 버튼 스타일링 수정

* refactor: 공통 스타일 적용

* feat: CommentItem 스타일 조정

* refactor: commentUserInfo를 생성날짜 정보를 포함시켜 CommentInfo로 수정

* test: usePathnameAt 테스트 케이스 작성

* test: usePathnameAt test 가독성 개선
  • Loading branch information
Parkhanyoung authored Aug 19, 2024
1 parent 309bf43 commit 862ed9f
Show file tree
Hide file tree
Showing 35 changed files with 2,632 additions and 160 deletions.
2,118 changes: 1,993 additions & 125 deletions frontend/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@
"@sentry/react": "^8.23.0",
"@sentry/webpack-plugin": "^2.21.1",
"@tanstack/react-query": "^5.50.1",
"@uiw/react-md-editor": "^4.0.4",
"dotenv": "^16.4.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.1",
"rehype-sanitize": "^6.0.0",
"styled-components": "^6.1.11"
},
"devDependencies": {
Expand Down Expand Up @@ -53,6 +55,7 @@
"@typescript-eslint/parser": "^7.15.0",
"babel-jest": "^29.7.0",
"babel-loader": "^9.1.3",
"css-loader": "^7.1.2",
"dotenv-webpack": "^8.1.0",
"eslint": "8.57",
"eslint-config-prettier": "^9.1.0",
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/apis/commentAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Comment } from '@/types';
import { develupAPIClient } from './clients/develupClient';
import { PATH_FORMATTER } from './paths';

export const getComments = async (solutionId: number): Promise<Comment[]> => {
const { data } = await develupAPIClient.get<{ data: Comment[] }>(
PATH_FORMATTER.comments(solutionId),
);

return data;
};

interface PostCommentPayload {
content: string;
parentCommentId?: number;
}

export interface PostCommentParams {
solutionId: number;
body: PostCommentPayload;
}

export interface PostCommentResponseData {
id: number;
solutionId: number;
parentCommentId: number;
content: string;
member: {
id: number;
email: string;
name: string;
imageUrl: string;
};
createdAt: string;
}

export const postComment = async ({
solutionId,
body,
}: PostCommentParams): Promise<PostCommentResponseData> => {
const { data } = await develupAPIClient.post<{ data: PostCommentResponseData }>(
PATH_FORMATTER.comments(solutionId),
body,
);

return data;
};
4 changes: 4 additions & 0 deletions frontend/src/apis/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ export const PATH = {
logout: '/auth/logout',
missionInProgress: '/missions/in-progress',
};

export const PATH_FORMATTER = {
comments: (solutionId: number) => `/solutions/${solutionId}/comments`,
};
Binary file added frontend/src/assets/images/default-user.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 0 additions & 16 deletions frontend/src/components/Header/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { Link, useLocation } from 'react-router-dom';
import * as S from './Header.styled';
import { ROUTES } from '@/constants/routes';
import NotiModal from './NotiModal';
import { useState } from 'react';
import { BASE_URL } from '@/apis/baseUrl';
import { PATH } from '@/apis/paths';
import useUserInfo from '@/hooks/useUserInfo';
Expand All @@ -13,18 +11,6 @@ export default function Header() {
const { pathname } = useLocation();
const { data: userInfo } = useUserInfo();

const [isModalOpen, setIsModalOpen] = useState(false);

const handleBellClick = () => {
setIsModalOpen((prev) => !prev);
};

const closeModal = () => {
if (isModalOpen) {
setIsModalOpen(false);
}
};

const handleUserLogout = async () => {
await deleteLogout();
};
Expand All @@ -43,7 +29,6 @@ export default function Header() {
</S.MenuWrapper>
<S.RightPart>
<HeaderMenu name="대시보드" path={ROUTES.dashboardHome} currentPath={pathname} />
{userInfo && <S.BellIcon onClick={handleBellClick} />}
{!userInfo ? (
<a href={`${BASE_URL.dev}${PATH.githubLogin}?next=${pathname}`}>
<S.LoginButton>로그인</S.LoginButton>
Expand All @@ -53,7 +38,6 @@ export default function Header() {
)}
</S.RightPart>
</S.Container>
{isModalOpen && <NotiModal closeModal={closeModal} />}
<S.Spacer />
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import infoIcon from '@/assets/images/infoIcon.svg';
import githubLogo from '@/assets/images/githubLogo.svg';
import javaIcon from '@/assets/images/java.svg';
import { CommonButton } from '../common/Button/Button.styled';
import SanitizedMDPreview from '../common/SanitizedMDPreview';

// MissionDetailHeader

Expand Down Expand Up @@ -117,6 +118,6 @@ export const MissionDescription = styled.div`
border-radius: 0.8rem;
`;

export const MissionDescriptionText = styled.p`
export const MissionDescriptionText = styled(SanitizedMDPreview)`
font-size: 2rem;
`;
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface MissionDetailContentProps {
export default function MissionDetailContent({ description }: MissionDetailContentProps) {
return (
<S.MissionDescription>
<S.MissionDescriptionText>{description}</S.MissionDescriptionText>
<S.MissionDescriptionText source={description} />
</S.MissionDescription>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import styled from 'styled-components';
import MarkdownEditor from '@uiw/react-md-editor';

export const CommentForm = styled.form``;

export const CommentTextArea = styled.textarea`
width: 100%;
padding: 1.4rem;
border: 1px solid ${({ theme }) => theme.colors.grey400};
border-radius: 1rem;
`;

export const CommentButton = styled.button`
margin-top: 1.7rem;
color: ${({ theme }) => theme.colors.primary500};
${({ theme }) => theme.font.button}
`;

export const StartFromRight = styled.div`
display: flex;
flex-direction: row-reverse;
`;

export const MDEditor = styled(MarkdownEditor)`
border: 0.05rem solid ${({ theme }) => theme.colors.grey300};
border-radius: 1rem;
overflow: overlay;
.cm-content {
font-size: 1.6rem;
padding: 1rem;
}
.md-editor-preview {
font-size: 1.6rem;
}
`;
45 changes: 45 additions & 0 deletions frontend/src/components/SolutionDetail/CommentForm/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useState } from 'react';
import usePostCommentMutation from '@/hooks/usePostCommentMutation';
import * as S from './CommentForm.styled';
import { commands } from '@uiw/react-md-editor';

interface CommentFormProps {
solutionId: number;
parentCommentId?: number;
}

export default function CommentForm({ solutionId, parentCommentId }: CommentFormProps) {
const [comment, setComment] = useState('');

const resetComment = () => setComment('');

const { mutate: postCommentMutation } = usePostCommentMutation(resetComment);

const onSubmitComment = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
postCommentMutation({ solutionId, body: { content: comment, parentCommentId } });
};

return (
<S.CommentForm onSubmit={onSubmitComment}>
<S.MDEditor
height="fit-content"
preview="edit"
visibleDragbar={false}
onChange={(v?: string) => setComment(v || '')}
value={comment}
commands={[
commands.bold,
commands.italic,
commands.strikethrough,
commands.codeBlock,
commands.image,
]}
extraCommands={[commands.codeEdit, commands.codeLive]}
/>
<S.StartFromRight>
<S.CommentButton>제출</S.CommentButton>
</S.StartFromRight>
</S.CommentForm>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { UserInfo } from '@/types/user';
import * as S from '../CommentList.styled';
import type { SyntheticEvent } from 'react';
import DefaultUserIcon from '@/assets/images/default-user.png';
import { formatDateString } from '@/utils/formatDateString';

interface CommentInfoProps {
member: UserInfo;
createdAt: string;
}

export default function CommentInfo({ member, createdAt }: CommentInfoProps) {
const { imageUrl, name } = member;

const handleImageError = ({ target }: SyntheticEvent<HTMLImageElement>) => {
if (target instanceof HTMLImageElement) {
target.src = DefaultUserIcon;
}
};

return (
<S.CommentInfoContainer>
<S.UserProfileImg src={imageUrl} onError={handleImageError} />
<S.UserName>{name}</S.UserName>
<S.CommentCreatedAt>{formatDateString(createdAt)}</S.CommentCreatedAt>
</S.CommentInfoContainer>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as S from '../CommentList.styled';
import type { CommentReply } from '@/types';
import CommentInfo from './CommentInfo';

interface CommentReplyItemProps {
commentReply: CommentReply;
}

export default function CommentReplyItem({ commentReply }: CommentReplyItemProps) {
const { member, content, createdAt } = commentReply;

return (
<S.CommentReplyItemContainer>
<CommentInfo member={member} createdAt={createdAt} />
<S.CommentContent source={content} />
</S.CommentReplyItemContainer>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as S from '../CommentList.styled';
import type { CommentReply } from '@/types';
import CommentReplyItem from './CommentReplyItem';

interface CommentReplyListProps {
commentReplies: CommentReply[];
}

export default function CommentReplyList({ commentReplies }: CommentReplyListProps) {
return (
<S.CommentReplyListContainer>
{commentReplies.map((reply) => (
<CommentReplyItem key={reply.id} commentReply={reply} />
))}
</S.CommentReplyListContainer>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useState } from 'react';
import CommentForm from '../../CommentForm';
import * as S from '../CommentList.styled';
import CommentReplyList from './CommentReplyList';
import type { Comment } from '@/types';

interface CommentReplySectionProps {
parentComment: Comment;
isLoggedIn: boolean;
}

export default function CommentReplySection({
parentComment,
isLoggedIn,
}: CommentReplySectionProps) {
const { isDeleted: isParentDeleted, solutionId, id: parentId, replies } = parentComment;

const [isReplyFormOpen, setIsReplyFormOpen] = useState(false);

const toggleReplyFormOpen = () => {
setIsReplyFormOpen((prevState) => !prevState);
};

return (
<S.CommentReplySectionContainer>
{isLoggedIn && !isParentDeleted && (
<>
<S.ReplyWriteButton onClick={toggleReplyFormOpen}>답글</S.ReplyWriteButton>
{isReplyFormOpen && (
<S.CommentReplyFormWrapper>
<CommentForm solutionId={solutionId} parentCommentId={parentId} />
</S.CommentReplyFormWrapper>
)}
</>
)}
<CommentReplyList commentReplies={replies} />
</S.CommentReplySectionContainer>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Comment } from '@/types';
import * as S from '../CommentList.styled';
import useUserInfo from '@/hooks/useUserInfo';
import CommentInfo from './CommentInfo';
import CommentReplySection from './CommentReplySection';

interface CommentItemProps {
comment: Comment;
}

export default function CommentItem({ comment }: CommentItemProps) {
const { content, member, createdAt, isDeleted } = comment;

const { data: userInfo } = useUserInfo();

return (
<S.CommentItemContainer>
<S.CommentContentWrapper>
{isDeleted ? (
<S.DeletedComment>삭제된 댓글입니다.</S.DeletedComment>
) : (
<>
<CommentInfo member={member} createdAt={createdAt} />
<S.CommentContent source={content} />
</>
)}
</S.CommentContentWrapper>
<CommentReplySection parentComment={comment} isLoggedIn={Boolean(userInfo)} />
</S.CommentItemContainer>
);
}
Loading

0 comments on commit 862ed9f

Please sign in to comment.