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

템플릿 좋아요 기능 구현 #686

Merged
merged 34 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f50d02e
feat(api): 좋아요 기능 API 함수 구현
vi-wolhwa Sep 23, 2024
6f41fc6
feat(images): 좋아요 아이콘 생성 (like, unlike, unClickable)
vi-wolhwa Sep 23, 2024
dd169ea
test(mocks): templateList 데이터에 'isLiked', 'likeCount' 추가
vi-wolhwa Sep 23, 2024
59a6e0c
feat(utils): 1000 이상의 숫자를 'k'단위로 표기하는 formatWithK 유틸함수 구현
vi-wolhwa Sep 24, 2024
1085fa8
test(mocks): templateList 데이터에 'member' 정보 추가
vi-wolhwa Sep 24, 2024
704c712
feat(images): 'like' 아이콘에 'size' 변수 추가 및 색상 변경
vi-wolhwa Sep 24, 2024
ea64410
feat(LikeWidget): 좋아요 위젯 구현
vi-wolhwa Sep 24, 2024
e134264
feat(TemplateCard): 템플릿 카드에 좋아요 위젯 적용
vi-wolhwa Sep 24, 2024
0618887
refactor(images): 좋아요 아이콘의 'unlike' 상태에서 테두리 색 변경 (white > #393E46)
vi-wolhwa Sep 24, 2024
1f448a4
refactor(LikeWidget): 좋아요 버튼의 카운터 포멧 로직을 상위컴포넌트에서 'LikeWidget' 내부로 이동
vi-wolhwa Sep 24, 2024
0ac0b88
refactor(LikeWidget): 좋아요 위젯 마우스 커서 변경(pointer) 및 import path alias
vi-wolhwa Sep 24, 2024
e7a4d05
refactor(LikeWidget): 좋아요 위젯의 핸들러 함수명 변경 (onLikeButtonClick > onLikeW…
vi-wolhwa Sep 24, 2024
bedc9b7
refactor(LikeWidget): console.log 삭제
vi-wolhwa Sep 24, 2024
0957771
refactor(api): 좋아요 API 요청 실패 조건문 변경
vi-wolhwa Sep 24, 2024
628e251
fix(routes): 좋아요 요청 endpoint 변경 (like > /like)
vi-wolhwa Sep 24, 2024
bb911ab
test(mocks): 좋아요 기능 MSW 핸들러 구현
vi-wolhwa Sep 24, 2024
5586a3b
refactor(api): api/index.ts
vi-wolhwa Sep 24, 2024
03124ef
feat(api): 좋아요 기능 Mutation 훅 구현
vi-wolhwa Sep 24, 2024
5a83c0e
test(mocks): templateList 데이터의 작성자를 두 명으로 분리
vi-wolhwa Sep 24, 2024
82f6725
feat(hooks): useLike 훅 구현
vi-wolhwa Sep 24, 2024
c25504b
feat(TemplatePage): 템플릿 상세 페이지에 좋아요 기능 구현
vi-wolhwa Sep 24, 2024
a94465c
refactor(LikeWidget): 미사용 스타일드 컴포넌트 제거
vi-wolhwa Sep 24, 2024
6b33aab
test(queries): 'useTemplateQuery' 테스트에 'AuthProvider' 추가
vi-wolhwa Sep 24, 2024
3bd211d
refactor(src): 변수명 변경 (likeCount > likesCount)
vi-wolhwa Sep 24, 2024
0c7ae56
fix(api): 템플릿 리스트 요청 시, memberId를 QueryParam으로 보내고 있는 문제 해결
vi-wolhwa Sep 24, 2024
e035923
fix(api): 좋아요 API 중복된 에러 처리 조건 제거
vi-wolhwa Sep 24, 2024
fe598ff
refactor(images): 좋아요 아이콘 디자인 변경 (stroke, 디자인시스템)
vi-wolhwa Sep 25, 2024
b3d6d15
refactor(components): LikeWidget 컴포넌트 분리 (LikeCounter, LikeButton)
vi-wolhwa Sep 25, 2024
46392d7
refactor(TemplatePage): 좋아요 취소 기능 낙관적 업데이트 적용
vi-wolhwa Sep 25, 2024
bdbc687
design(TemplatePage): 템플릿 상세 페이지 좋아요 버튼 디자인 수정
vi-wolhwa Sep 25, 2024
347b276
refactor(LikeButton): 컴포넌트명이 수정되지 않았던 문제 수정
vi-wolhwa Sep 25, 2024
b09161e
refactor(TemplatePage): useLike 훅에서 비동기 작업, 서버 요청의 처리 순서에 따른 좋아요 상태 위…
vi-wolhwa Sep 25, 2024
58362c2
design(LikeButton): 템플릿상세페이지 좋아요 버튼의 width를 유동적으로 변경
vi-wolhwa Sep 25, 2024
23d2458
refactor(TemplatePage): useLike 훅에서 setLikesCount 방식 변경
vi-wolhwa Sep 25, 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
4 changes: 3 additions & 1 deletion frontend/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { CATEGORY_API_URL, getCategoryList, postCategory, deleteCategory } from './categories';
export { getTagList } from './tags';
export { TAG_API_URL, getTagList } from './tags';
export { customFetch } from './customFetch';
export { QUERY_KEY } from './queryKeys';
export {
Expand All @@ -8,6 +8,7 @@ export {
SORTING_OPTIONS,
DEFAULT_SORTING_OPTION,
getTemplateList,
getTemplateExplore,
getTemplate,
postTemplate,
editTemplate,
Expand All @@ -25,3 +26,4 @@ export {
getLoginState,
checkName,
} from './authentication';
export { LIKE_API_URL, postLike, deleteLike } from './like';
28 changes: 28 additions & 0 deletions frontend/src/api/like.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { HttpResponse } from 'msw';

import { END_POINTS } from '@/routes';
import { LikeDeleteRequest, LikePostRequest } from '@/types';

import { customFetch } from './customFetch';

const API_URL = process.env.REACT_APP_API_URL;

export const LIKE_API_URL = `${API_URL}${END_POINTS.LIKES}`;

export const postLike = async ({ templateId }: LikePostRequest) => {
const response = await customFetch<HttpResponse>({
method: 'POST',
url: `${LIKE_API_URL}/${templateId}`,
});

return response;
};

export const deleteLike = async ({ templateId }: LikeDeleteRequest) => {
const response = await customFetch<HttpResponse>({
method: 'DELETE',
url: `${LIKE_API_URL}/${templateId}`,
});

return response;
};
16 changes: 11 additions & 5 deletions frontend/src/api/templates.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { END_POINTS } from '@/routes';
import type {
Template,
TemplateRequest,
TemplateEditRequest,
TemplateListResponse,
TemplateUploadRequest,
TemplateListRequest,
CustomError,
} from '@/types';
import { SortingOption } from '@/types';

import { customFetch } from './customFetch';

const API_URL = process.env.REACT_APP_API_URL;
Expand Down Expand Up @@ -40,12 +42,15 @@ export const getTemplateList = async ({
}: TemplateListRequest) => {
const queryParams = new URLSearchParams({
keyword,
memberId: String(memberId),
sort,
page: page.toString(),
size: size.toString(),
});

if (memberId) {
queryParams.append('memberId', memberId.toString());
}

if (categoryId) {
queryParams.append('categoryId', categoryId.toString());
}
Expand All @@ -54,7 +59,7 @@ export const getTemplateList = async ({
queryParams.append('tagIds', tagIds.toString());
}

const url = `${TEMPLATE_API_URL}?${queryParams.toString()}`;
const url = `${TEMPLATE_API_URL}${memberId ? '/login' : ''}?${queryParams.toString()}`;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jaymyong66 @Hain-tain

변경된 API 명세에 따른 반영 (현재파일 이하 변경사항 동일)
#680

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'/login' 를 붙여야하는 히스토리를 켬미에게 들어서 참고하시라고 기술해둡니다.

[기본 전제]

  • 로그인 여부와 member 정보는 쿠키로 받는다.
  • 로그인 정보가 필요한경우, 백엔드에서는 요청을 인터셉터로 가로채서 확인하고, 로그인하지 않은 사용자라고 판별이 나면 예외처리가 된다!!

[원래 상황]

  • 조회의 경우에는 로그인 여부에 상관없이 아무나 다 가능했기 때문에 인터셉터 X

[좋아요로 인해 변경된 상황]

  • 좋아요 여부를 확인하기 위해 조회에서도 로그인 정보가 필요해졌다!
  • 따라서 조회시에도 인터셉터를 하는데, 로그인하지 않은 사용자는 예외처리가 되어버린다. (기본 전제 참고)

[결론]

  • 로그인 여부에 따라 다르게 요청을 처리해주어야 해서 임시적으로 'login' 을 붙이기로 했다.
  • 추후 백엔드에서 인터셉터 로직을 변경할 예정이다.


const response = await customFetch<TemplateListResponse>({
url,
Expand All @@ -71,14 +76,15 @@ export const getTemplateExplore = async ({
sort = DEFAULT_SORTING_OPTION.key,
page = 1,
size = PAGE_SIZE,
memberId,
}: TemplateListRequest) => {
const queryParams = new URLSearchParams({
sort,
page: page.toString(),
size: size.toString(),
});

const url = `${TEMPLATE_API_URL}?${queryParams.toString()}`;
const url = `${TEMPLATE_API_URL}${memberId ? '/login' : ''}?${queryParams.toString()}`;

const response = await customFetch<TemplateListResponse>({
url,
Expand All @@ -91,9 +97,9 @@ export const getTemplateExplore = async ({
throw new Error(response.detail);
};

export const getTemplate = async (id: number) => {
export const getTemplate = async ({ id, memberId }: TemplateRequest) => {
const response = await customFetch<Template>({
url: `${TEMPLATE_API_URL}/${id}`,
url: `${TEMPLATE_API_URL}/${id}${memberId ? '/login' : ''}`,
});

if ('sourceCodes' in response) {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/assets/images/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export { default as PersonIcon } from './person.svg';
export { default as ClockIcon } from './clock.svg';
export { default as BooksIcon } from './books.svg';
export { default as CheckCircleIcon } from './checkCircle.svg';
export { default as LikeIcon } from './like';

// Logo
export { default as CodeZapLogo } from './codezapLogo.svg';
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/assets/images/like.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { theme } from '../../style/theme';

type State = 'like' | 'unlike' | 'unClickable';

interface Props {
state: State;
size: number;
}

const LikeIcon = ({ state, size }: Props) => {
const fillColor = {
like: theme.color.light.analogous_primary_400,
unlike: 'none',
unClickable: theme.color.light.secondary_800,
}[state];

const strokeColor = {
like: theme.color.light.analogous_primary_400,
unlike: theme.color.light.secondary_800,
unClickable: theme.color.light.secondary_800,
}[state];

return (
<svg width={size} height={size} viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M20.42 4.58045C19.9183 4.07702 19.3222 3.67758 18.6658 3.40503C18.0094 3.13248 17.3057 2.99219 16.595 2.99219C15.8842 2.99219 15.1805 3.13248 14.5241 3.40503C13.8678 3.67758 13.2716 4.07702 12.77 4.58045L12 5.36045L11.23 4.58045C10.7283 4.07702 10.1322 3.67758 9.47578 3.40503C8.81941 3.13248 8.11568 2.99219 7.40496 2.99219C6.69425 2.99219 5.99052 3.13248 5.33414 3.40503C4.67776 3.67758 4.08164 4.07702 3.57996 4.58045C1.45996 6.70045 1.32996 10.2804 3.99996 13.0004L12 21.0004L20 13.0004C22.67 10.2804 22.54 6.70045 20.42 4.58045Z'
fill={fillColor}
stroke={strokeColor}
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);
};

export default LikeIcon;
21 changes: 21 additions & 0 deletions frontend/src/components/LikeButton/LikeButton.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import styled from '@emotion/styled';

import { theme } from '@/style/theme';

export const LikeButtonContainer = styled.button<{ isLiked: boolean }>`
cursor: pointer;

display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;

height: 2.5rem;
padding: 0 0.75rem;

color: ${({ isLiked }) => (isLiked ? theme.color.light.analogous_primary_400 : 'white')};

border: 1px solid
${({ isLiked }) => (isLiked ? theme.color.light.analogous_primary_400 : theme.color.light.secondary_800)};
border-radius: 16px;
`;
21 changes: 21 additions & 0 deletions frontend/src/components/LikeButton/LikeButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { LikeIcon } from '@/assets/images';
import { Text } from '@/components';
import { theme } from '@/style/theme';
import { formatWithK } from '@/utils';

import * as S from './LikeButton.style';

interface Props {
likesCount: number;
isLiked: boolean;
onLikeButtonClick: () => void;
}

const LikeButton = ({ likesCount, isLiked, onLikeButtonClick }: Props) => (
<S.LikeButtonContainer isLiked={isLiked} onClick={onLikeButtonClick}>
<LikeIcon state={isLiked ? 'like' : 'unlike'} size={20} />
<Text.Medium color={theme.color.light.secondary_800}>{formatWithK(likesCount)}</Text.Medium>
</S.LikeButtonContainer>
);

export default LikeButton;
8 changes: 8 additions & 0 deletions frontend/src/components/LikeCounter/LikeCounter.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import styled from '@emotion/styled';

export const LikeCounterContainer = styled.div`
display: flex;
gap: 0.75rem;
align-items: center;
height: 1.5rem;
`;
20 changes: 20 additions & 0 deletions frontend/src/components/LikeCounter/LikeCounter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { LikeIcon } from '@/assets/images';
import { Text } from '@/components';
import { theme } from '@/style/theme';
import { formatWithK } from '@/utils';

import * as S from './LikeCounter.style';

interface Props {
likesCount: number;
isLiked: boolean;
}

const LikeCounter = ({ likesCount, isLiked }: Props) => (
<S.LikeCounterContainer>
<LikeIcon state={isLiked ? 'like' : 'unClickable'} size={14} />
<Text.Small color={theme.color.light.secondary_800}>{formatWithK(likesCount)}</Text.Small>
</S.LikeCounterContainer>
);

export default LikeCounter;
29 changes: 18 additions & 11 deletions frontend/src/components/TemplateCard/TemplateCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { type LanguageName, loadLanguage } from '@uiw/codemirror-extensions-lang
import { quietlight } from '@uiw/codemirror-theme-quietlight';
import CodeMirror, { EditorView } from '@uiw/react-codemirror';

import { PersonIcon } from '@/assets/images';
import { Button, Flex, TagButton, Text } from '@/components';
import { ClockIcon, PersonIcon } from '@/assets/images';
import { Button, Flex, LikeCounter, TagButton, Text } from '@/components';
import { useToggle } from '@/hooks';
import { theme } from '@/style/theme';
import type { Tag, TemplateListItem } from '@/types';
Expand Down Expand Up @@ -38,16 +38,23 @@ const TemplateCard = ({ template }: Props) => {
<S.TemplateCardContainer data-testid='template-card'>
<Flex direction='column' gap='1rem'>
<Flex justify='space-between' gap='3rem'>
<Flex align='center' gap='0.125rem'>
<PersonIcon width={14} />
<Text.Small color={theme.mode === 'dark' ? theme.color.dark.primary_300 : theme.color.light.primary_500}>
{member?.name || ''}
</Text.Small>
<Flex gap='0.75rem'>
<Flex align='center' gap='0.25rem'>
<PersonIcon width={14} />
<Text.Small color={theme.mode === 'dark' ? theme.color.dark.primary_300 : theme.color.light.primary_500}>
{member.name}
</Text.Small>
</Flex>
<Flex align='center' gap='0.25rem'>
<ClockIcon width={14} />
Comment on lines +43 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요 icon들 14로 거의 쓸거 같다면 기본값을 14로 해도 좋을 것 같네요!

<S.NoWrapTextWrapper>
<Text.Small color={theme.color.light.primary_500}>{formatRelativeTime(modifiedAt)}</Text.Small>
</S.NoWrapTextWrapper>
</Flex>
</Flex>
<Flex align='center'>
<LikeCounter likesCount={template.likesCount} isLiked={template.isLiked} />
</Flex>

<S.NoWrapTextWrapper>
<Text.XSmall color={theme.color.light.secondary_500}>{formatRelativeTime(modifiedAt)}</Text.XSmall>
</S.NoWrapTextWrapper>
</Flex>

<S.EllipsisTextWrapper>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export { default as Header } from './Header/Header';
export { default as Heading } from './Heading/Heading';
export { default as Input } from './Input/Input';
export { default as Layout } from './Layout/Layout';
export { default as LikeButton } from './LikeButton/LikeButton';
export { default as LikeCounter } from './LikeCounter/LikeCounter';
export { default as Modal } from './Modal/Modal';
export { default as PagingButtons } from './PagingButtons/PagingButtons';
export { default as SelectList } from './SelectList/SelectList';
Expand Down
60 changes: 58 additions & 2 deletions frontend/src/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {
LOGIN_STATE_API_URL,
LOGOUT_API_URL,
SIGNUP_API_URL,
TAG_API_URL,
LIKE_API_URL,
} from '@/api';
import { Category } from '@/types';
import { TAG_API_URL } from '../api/tags';

import mockCategoryList from './categoryList.json';
import mockTagList from './tagList.json';
import mockTemplateList from './templateList.json';
Expand Down Expand Up @@ -192,4 +194,58 @@ const categoryHandlers = [

const tagHandlers = [http.get(`${TAG_API_URL}`, () => HttpResponse.json(mockTagList))];

export const handlers = [...tagHandlers, ...templateHandlers, ...categoryHandlers, ...authenticationHandler];
const likeHandlers = [
http.post(`${LIKE_API_URL}/:templateId`, (req) => {
const { templateId } = req.params;
const template = mockTemplateList.templates.find((temp) => temp.id.toString() === templateId);

if (!template) {
return HttpResponse.json({ status: 404, message: 'Template not found' });
}

if (template.isLiked) {
return HttpResponse.json({ status: 400, message: 'Already liked' });
}

template.isLiked = true;
template.likesCount += 1;

return HttpResponse.json({
status: 200,
message: 'Liked successfully',
likesCount: template.likesCount,
isLiked: template.isLiked,
});
}),

http.delete(`${LIKE_API_URL}/:templateId`, (req) => {
const { templateId } = req.params;
const template = mockTemplateList.templates.find((temp) => temp.id.toString() === templateId);

if (!template) {
return HttpResponse.json({ status: 404, message: 'Template not found' });
}

if (!template.isLiked) {
return HttpResponse.json({ status: 400, message: 'Not liked yet' });
}

template.isLiked = false;
template.likesCount -= 1;

return HttpResponse.json({
status: 200,
message: 'Disliked successfully',
likesCount: template.likesCount,
isLiked: template.isLiked,
});
}),
];

export const handlers = [
...tagHandlers,
...templateHandlers,
...categoryHandlers,
...authenticationHandler,
...likeHandlers,
];
Loading