Skip to content

Commit

Permalink
Merge pull request #686 from woowacourse-teams/feat/668-template-like
Browse files Browse the repository at this point in the history
템플릿 좋아요 기능 구현
  • Loading branch information
Jaymyong66 authored Sep 25, 2024
2 parents e7f2673 + 23d2458 commit f940ad6
Show file tree
Hide file tree
Showing 28 changed files with 516 additions and 74 deletions.
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()}`;

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} />
<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

0 comments on commit f940ad6

Please sign in to comment.