Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

솔루션 상세 페이지 댓글 리스트 및 댓글 생성 구현 (issue #289) #328

Merged
merged 30 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e3c3958
feat: solution 디테일 페이지 생성
Parkhanyoung Aug 13, 2024
a350aa2
feat: 댓글 제출 창 기본 형태 구현
Parkhanyoung Aug 13, 2024
f75cfee
feat: 댓글 제출 api 연결
Parkhanyoung Aug 13, 2024
4364ca0
feat: CommentList 컴포넌트 생성
Parkhanyoung Aug 13, 2024
6435641
refactor: CommentForm 스타일 파일 분리
Parkhanyoung Aug 13, 2024
46c352a
Merge branch 'feat/#280' into feat/#289
Parkhanyoung Aug 13, 2024
c3bcbdb
feat: 대댓글 제외한 댓글 리스트 기본 형태 구현 완료
Parkhanyoung Aug 14, 2024
cd60f79
feat: 대댓글 리스팅 구현 완료
Parkhanyoung Aug 14, 2024
d7a9699
feat: 로그인 시에만 댓글창이 보이도록 처리
Parkhanyoung Aug 14, 2024
baaa49f
feat: 대댓글 작성 기능 구현 완료
Parkhanyoung Aug 14, 2024
a9cf114
feat: comments GET api 연결
Parkhanyoung Aug 14, 2024
60cdf97
fix: 충돌 해결
Parkhanyoung Aug 16, 2024
d16df60
Merge branch 'main' of https://github.com/woowacourse-teams/2024-deve…
Parkhanyoung Aug 16, 2024
2f10e99
feat: solution detail Route 추가
Parkhanyoung Aug 16, 2024
fba805b
chore: 불필요한 주석 삭제
Parkhanyoung Aug 16, 2024
6250497
chore: 임시로 localhost로 설정해두었던 서버 url 수정
Parkhanyoung Aug 16, 2024
ec01d25
feat: 댓글 제출 시 invalidate 로직 실행하도록 기능 추가
Parkhanyoung Aug 16, 2024
64bb374
feat: markdown preview 적용
Parkhanyoung Aug 16, 2024
9503712
feat: md preview sanitize 적용
Parkhanyoung Aug 16, 2024
0a910b9
feat: 댓글 프로필 이미지 오류 발생시 기본 이미지로 대체
Parkhanyoung Aug 16, 2024
140cd0f
feat: 삭제된 댓글 표시 구현
Parkhanyoung Aug 16, 2024
1c330f9
feat: 사용하지 않는 헤더 알림 기능 제거
Parkhanyoung Aug 16, 2024
8f1b506
refactor: 댓글 관련 컴포넌트 가독성 개선
Parkhanyoung Aug 16, 2024
3129fad
feat: 댓글 관련 버튼 스타일링 수정
Parkhanyoung Aug 19, 2024
125471b
fix: 충돌 해결
Parkhanyoung Aug 19, 2024
6f78ff1
refactor: 공통 스타일 적용
Parkhanyoung Aug 19, 2024
f609d67
feat: CommentItem 스타일 조정
Parkhanyoung Aug 19, 2024
6b617e3
refactor: commentUserInfo를 생성날짜 정보를 포함시켜 CommentInfo로 수정
Parkhanyoung Aug 19, 2024
014fbab
test: usePathnameAt 테스트 케이스 작성
Parkhanyoung Aug 19, 2024
d8c048c
test: usePathnameAt test 가독성 개선
Parkhanyoung Aug 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading