├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── backend-dev-cd.yml │ ├── backend-dev-ci.yml │ ├── backend-prod-cd.yml │ ├── backend-prod-ci.yml │ ├── frontend-cd.yml │ ├── frontend-ci.yml │ └── slack-notification.yml ├── .gitignore ├── README.md ├── backend ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── docker-compose.local.yml ├── docker-compose.prod.yml ├── docker-compose.yml ├── init-letsencrypt.dev.sh ├── init-letsencrypt.prod.sh ├── local │ └── nginx.conf ├── nest-cli.json ├── nginx.Dockerfile ├── nginx │ ├── dev │ │ └── conf │ │ │ └── nginx.conf │ └── prod │ │ └── nginx.conf ├── package-lock.json ├── package.json ├── scripts │ ├── dev-deploy.sh │ ├── dummy.ts │ └── prod-deploy.sh ├── sql │ └── category.sql ├── src │ ├── app.module.ts │ ├── app │ │ ├── auth │ │ │ ├── auth.controller.ts │ │ │ ├── auth.module.ts │ │ │ ├── auth.service.ts │ │ │ ├── github-auth.guard.ts │ │ │ ├── github-profile.decorator.ts │ │ │ ├── github.strategy.ts │ │ │ └── type │ │ │ │ └── github-profile.ts │ │ ├── comment │ │ │ ├── comment.controller.ts │ │ │ ├── comment.module.ts │ │ │ ├── comment.repository.ts │ │ │ ├── comment.service.ts │ │ │ ├── dto │ │ │ │ ├── comment-response.dto.ts │ │ │ │ ├── comment-writing-request.dto.ts │ │ │ │ ├── comment-writing-response.dto.ts │ │ │ │ ├── get-all-comment-query-request.dto.ts │ │ │ │ └── group-article-comment-get-response.dto.ts │ │ │ ├── entity │ │ │ │ └── comment.entity.ts │ │ │ └── exception │ │ │ │ ├── comment-not-found.exception.ts │ │ │ │ ├── group-not-found.exception.ts │ │ │ │ └── not-author.exception.ts │ │ ├── group-application │ │ │ ├── __test__ │ │ │ │ └── group-application.fixture.ts │ │ │ ├── dto │ │ │ │ ├── application-with-user-info-response.dto.ts │ │ │ │ ├── attend-group-response.dto.ts │ │ │ │ ├── check-joining-group-response.dto.ts │ │ │ │ ├── group-application-request.dto.ts │ │ │ │ ├── group-article-response.dto.ts │ │ │ │ ├── my-application-result.interface.ts │ │ │ │ ├── my-group-request.dto.ts │ │ │ │ ├── my-group-response.dto.ts │ │ │ │ ├── my-group-result.interface.ts │ │ │ │ └── user-info.dto.ts │ │ │ ├── entity │ │ │ │ └── group-application.entity.ts │ │ │ ├── exception │ │ │ │ ├── application-not-found.exception.ts │ │ │ │ ├── cannot-applicate.exception.ts │ │ │ │ ├── closed-group.exception.ts │ │ │ │ ├── duplicate-application.exception.ts │ │ │ │ ├── group-not-found.exception.ts │ │ │ │ └── not-author.exception.ts │ │ │ ├── group-application.controller.ts │ │ │ ├── group-application.module.ts │ │ │ ├── group-application.repository.ts │ │ │ ├── group-application.service.ts │ │ │ └── type │ │ │ │ └── group-application-status.transformer.ts │ │ ├── group-article │ │ │ ├── __test__ │ │ │ │ ├── group-article.fixture.ts │ │ │ │ ├── group-category.fixture.ts │ │ │ │ └── group.fixture.ts │ │ │ ├── constants │ │ │ │ └── group-article.constants.ts │ │ │ ├── dto │ │ │ │ ├── author.dto.ts │ │ │ │ ├── get-cateogories-response.dto.ts │ │ │ │ ├── get-group-article-detail-response.dto.ts │ │ │ │ ├── get-group-chat-url-response.dto.ts │ │ │ │ ├── get-my-group-article-response.dto.ts │ │ │ │ ├── group-article-detail.interface.ts │ │ │ │ ├── group-article-register-request.dto.ts │ │ │ │ ├── group-article-register-response.dto.ts │ │ │ │ ├── group-article-search-result.dto.ts │ │ │ │ ├── group-article-search-result.interface.ts │ │ │ │ ├── search-group-articles-request.dto.ts │ │ │ │ ├── search-group-articles-response.dto.ts │ │ │ │ ├── update-group-article-request.dto.ts │ │ │ │ ├── v2-search-group-articles-request.dto.ts │ │ │ │ └── v2-search-group-articles-response.dto.ts │ │ │ ├── entity │ │ │ │ ├── article.entity.ts │ │ │ │ ├── group-article.entity.ts │ │ │ │ ├── group-category.entity.ts │ │ │ │ └── group.entity.ts │ │ │ ├── exception │ │ │ │ ├── group-article-not-found.exception.ts │ │ │ │ ├── group-category-not-found.exception.ts │ │ │ │ ├── not-author.exception.ts │ │ │ │ ├── not-participant.exception.ts │ │ │ │ ├── not-progress-group.exception.ts │ │ │ │ └── not-success-group.exception.ts │ │ │ ├── group-article.controller.ts │ │ │ ├── group-article.module.ts │ │ │ ├── group-article.service.ts │ │ │ ├── my-group-article.controller.ts │ │ │ ├── my-group-article.service.ts │ │ │ └── repository │ │ │ │ ├── group-article.repository.ts │ │ │ │ └── group-category.repository.ts │ │ ├── image │ │ │ ├── dto │ │ │ │ └── images-upload-response.dto.ts │ │ │ ├── image.controller.ts │ │ │ ├── image.module.ts │ │ │ └── image.service.ts │ │ ├── myinfo │ │ │ ├── dto │ │ │ │ ├── myinfo-get-response.dto.ts │ │ │ │ └── profile-modifying-request.dto.ts │ │ │ ├── exception │ │ │ │ └── username-duplicate.exception.ts │ │ │ ├── myinfo.controller.ts │ │ │ ├── myinfo.module.ts │ │ │ └── myinfo.service.ts │ │ ├── notification │ │ │ ├── constants │ │ │ │ └── notification.constants.ts │ │ │ ├── dto │ │ │ │ ├── get-notification-settings-response.dto.ts │ │ │ │ ├── get-user-notification-result.dto.ts │ │ │ │ ├── get-user-notifications-response.dto.ts │ │ │ │ ├── notification-sse.dto.ts │ │ │ │ ├── patch-notification-setting-request.dto.ts │ │ │ │ └── v2-get-user-notifications-response.dto.ts │ │ │ ├── entity │ │ │ │ ├── notification-contents.ts │ │ │ │ ├── notification-setting.entity.ts │ │ │ │ ├── notification.entity.ts │ │ │ │ └── user-notification.entity.ts │ │ │ ├── event │ │ │ │ ├── comment-added.event.ts │ │ │ │ ├── group-failed.event.ts │ │ │ │ └── group-succeed.event.ts │ │ │ ├── exception │ │ │ │ ├── not-accessible.exception.ts │ │ │ │ ├── notification-setting-not-found.exception.ts │ │ │ │ └── user-notification-not-found.exception.ts │ │ │ ├── notification.controller.ts │ │ │ ├── notification.listener.ts │ │ │ ├── notification.module.ts │ │ │ ├── notification.service.ts │ │ │ └── repository │ │ │ │ ├── notification-setting.repository.ts │ │ │ │ └── user-notification.repository.ts │ │ ├── scrap │ │ │ └── entity │ │ │ │ └── scrap.entity.ts │ │ └── user │ │ │ ├── __test__ │ │ │ └── user.fixture.ts │ │ │ ├── dto │ │ │ ├── user-info-response.dto.ts │ │ │ ├── username-unique-request.dto.ts │ │ │ └── username-unique-response.dto.ts │ │ │ ├── entity │ │ │ └── user.entity.ts │ │ │ ├── exception │ │ │ └── user-not-found.exception.ts │ │ │ ├── user.controller.ts │ │ │ ├── user.module.ts │ │ │ ├── user.repository.ts │ │ │ └── user.service.ts │ ├── common │ │ ├── config │ │ │ ├── app │ │ │ │ ├── __test__ │ │ │ │ │ └── config.service.spec.ts │ │ │ │ ├── config.module.ts │ │ │ │ ├── config.service.ts │ │ │ │ └── configuration.ts │ │ │ ├── cookie │ │ │ │ ├── config.module.ts │ │ │ │ ├── config.service.ts │ │ │ │ └── configuration.ts │ │ │ ├── database │ │ │ │ ├── database.module.ts │ │ │ │ ├── mysql │ │ │ │ │ ├── __test__ │ │ │ │ │ │ └── config.service.spec.ts │ │ │ │ │ ├── config.module.ts │ │ │ │ │ ├── config.service.ts │ │ │ │ │ └── configuration.ts │ │ │ │ └── typeorm │ │ │ │ │ ├── config.module.ts │ │ │ │ │ └── config.service.ts │ │ │ ├── github │ │ │ │ ├── __test__ │ │ │ │ │ └── config.service.spec.ts │ │ │ │ ├── config.module.ts │ │ │ │ ├── config.service.ts │ │ │ │ └── configuration.ts │ │ │ ├── jwt │ │ │ │ ├── __test__ │ │ │ │ │ └── config.service.spec.ts │ │ │ │ ├── config.module.ts │ │ │ │ ├── config.service.ts │ │ │ │ └── configuration.ts │ │ │ ├── s3 │ │ │ │ ├── __test__ │ │ │ │ │ └── config.service.spec.ts │ │ │ │ ├── config.module.ts │ │ │ │ ├── config.service.ts │ │ │ │ └── configuration.ts │ │ │ └── validate.ts │ │ ├── decorator │ │ │ ├── api-error-response.decorator.ts │ │ │ ├── api-success-resposne.decorator.ts │ │ │ ├── current-user.decorator.ts │ │ │ └── jwt-auth.decorator.ts │ │ ├── dto │ │ │ └── image-with-blur-response.dto.ts │ │ ├── exception │ │ │ ├── api-not-found.exception.ts │ │ │ ├── bad-parameter.exception.ts │ │ │ └── invalid-token.exception.ts │ │ ├── filter │ │ │ └── all-exception.filter.ts │ │ ├── guard │ │ │ └── jwt-auth.guard.ts │ │ ├── middleware │ │ │ ├── api-exception-logger.middleware.ts │ │ │ └── api-success-logger.middleware.ts │ │ ├── module │ │ │ ├── jwt-token │ │ │ │ ├── __test__ │ │ │ │ │ └── jwt-token.service.spec.ts │ │ │ │ ├── jwt-token.module.ts │ │ │ │ ├── jwt-token.service.ts │ │ │ │ └── type │ │ │ │ │ ├── auth-token-payload.ts │ │ │ │ │ └── token-type.ts │ │ │ └── sse │ │ │ │ ├── sse-type.ts │ │ │ │ ├── sse.module.ts │ │ │ │ └── sse.service.ts │ │ ├── response-entity.ts │ │ └── util │ │ │ ├── __test__ │ │ │ ├── date-time.spec.ts │ │ │ ├── no-offset-page-result.spec.ts │ │ │ └── page-result.spec.ts │ │ │ ├── date-time.ts │ │ │ ├── get-blur-image.ts │ │ │ ├── no-offset-page-request.ts │ │ │ ├── no-offset-page-result.ts │ │ │ ├── page-request.ts │ │ │ └── page-result.ts │ ├── main.ts │ ├── setNestApp.ts │ ├── setSwagger.ts │ └── sse.controller.ts ├── test │ ├── group-application.e2e-spec.ts │ ├── group-article.e2e-spec.ts │ ├── jest-e2e.json │ └── utils │ │ └── jwt-test.utils.ts ├── tsconfig.build.json ├── tsconfig.json └── tsconfig.paths.json └── frontend ├── .dockerignore ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .husky └── pre-commit ├── .lighthouserc.js ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .storybook ├── main.ts ├── preview-head.html ├── preview.tsx └── webpack.config.js ├── Dockerfile ├── Dockerfile.development ├── README.md ├── dev ├── default │ └── nginx.conf └── nginx │ └── nginx.conf ├── docker-compose.local.yml ├── docker-compose.yml ├── fonts.d.ts ├── init-letsencrypt.sh ├── next.config.js ├── package-lock.json ├── package.json ├── public ├── android-icon-144x144.png ├── android-icon-192x192.png ├── android-icon-36x36.png ├── android-icon-48x48.png ├── android-icon-72x72.png ├── android-icon-96x96.png ├── apple-icon-114x114.png ├── apple-icon-120x120.png ├── apple-icon-144x144.png ├── apple-icon-152x152.png ├── apple-icon-180x180.png ├── apple-icon-57x57.png ├── apple-icon-60x60.png ├── apple-icon-72x72.png ├── apple-icon-76x76.png ├── apple-icon-precomposed.png ├── apple-icon.png ├── avatar.jpg ├── browserconfig.xml ├── default.jpg ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── favicon.ico ├── fonts │ ├── NanumSquareNeo-Bold.woff2 │ ├── NanumSquareNeo-ExtraBold.woff2 │ ├── NanumSquareNeo-Heavy.woff2 │ ├── NanumSquareNeo-Light.woff2 │ └── NanumSquareNeo-Regular.woff2 ├── icons │ ├── logo-lg.svg │ └── logo-md.svg ├── manifest.json ├── mockServiceWorker.js ├── ms-icon-144x144.png ├── ms-icon-150x150.png ├── ms-icon-310x310.png └── ms-icon-70x70.png ├── scripts ├── dev-deploy.sh └── dev-env.sh ├── src ├── apis │ └── test │ │ ├── getTestData.ts │ │ ├── getTestGroupArticles.ts │ │ └── getTestMyGroupArticles.ts ├── components │ ├── article │ │ ├── ArticleComments │ │ │ └── index.tsx │ │ ├── ArticleEditor │ │ │ ├── ArticleEditor.stories.tsx │ │ │ └── index.tsx │ │ ├── ArticlePostInput │ │ │ └── index.tsx │ │ ├── Comment │ │ │ ├── Comment.stories.tsx │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── CommentInput │ │ │ ├── CommentInput.stories.tsx │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── ImageThumbnail │ │ │ ├── ImageThumbnail.stories.tsx │ │ │ └── index.tsx │ │ ├── MenuButton │ │ │ └── index.tsx │ │ ├── ParticipantsModal │ │ │ ├── ParticipantItem │ │ │ │ ├── ParticipantItem.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── ParticipantsModal.stories.tsx │ │ │ └── index.tsx │ │ └── ParticipateButton │ │ │ ├── ApplyButton │ │ │ └── index.tsx │ │ │ ├── CancelButton │ │ │ └── index.tsx │ │ │ ├── ChatLinkButton │ │ │ └── index.tsx │ │ │ ├── ParticipateButton.stories.tsx │ │ │ └── index.tsx │ ├── common │ │ ├── AlertModal │ │ │ ├── AlertModal.stories.tsx │ │ │ └── index.tsx │ │ ├── ArticleListLoading │ │ │ └── index.tsx │ │ ├── ArticleTag │ │ │ ├── ArticleTag.stories.tsx │ │ │ └── index.tsx │ │ ├── ArticleViewLoading │ │ │ └── index.tsx │ │ ├── Avatar │ │ │ └── index.tsx │ │ ├── ConfirmModal │ │ │ ├── ConfirmModal.stories.tsx │ │ │ └── index.tsx │ │ ├── DropDown │ │ │ ├── DropDown.stories.tsx │ │ │ └── index.tsx │ │ ├── EmptyMessage │ │ │ ├── EmptyMessage.stories.tsx │ │ │ └── index.tsx │ │ ├── ErrorBoundary │ │ │ ├── ApiErrorBoundary.tsx │ │ │ ├── AuthErrorBoundary.tsx │ │ │ └── ErrorBoundary.tsx │ │ ├── ErrorMessage │ │ │ ├── ErrorMessage.stories.tsx │ │ │ └── index.tsx │ │ ├── FaviconConfig │ │ │ └── index.tsx │ │ ├── FloatingButton │ │ │ ├── FloatingButton.stories.tsx │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── FloatingUtilButton │ │ │ ├── FloatingUtilButton.stories.tsx │ │ │ └── index.tsx │ │ ├── GroupArticleCard │ │ │ ├── GroupArticleCard.stories.tsx │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── HeadMeta │ │ │ └── index.tsx │ │ ├── Header │ │ │ ├── DetailTitle │ │ │ │ ├── DetailTitle.stories.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.tsx │ │ │ ├── Header.stories.tsx │ │ │ ├── LoginButton │ │ │ │ ├── LoginButton.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── RootTitle │ │ │ │ ├── RootTitle.stories.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.tsx │ │ │ ├── UserLoginItem │ │ │ │ └── index.tsx │ │ │ ├── UtilButton │ │ │ │ ├── UtilButton.stories.tsx │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── Image │ │ │ └── index.tsx │ │ ├── Joiner │ │ │ ├── Joiner.stories.tsx │ │ │ └── index.tsx │ │ ├── LoginRedirect │ │ │ └── index.tsx │ │ ├── Modals │ │ │ └── index.tsx │ │ ├── NavigationTab │ │ │ ├── NavigationTab.stories.tsx │ │ │ └── index.tsx │ │ ├── NotificationLoading │ │ │ └── NotificationLoading.tsx │ │ ├── NotificationToast │ │ │ └── index.tsx │ │ ├── PageLayout │ │ │ ├── PageLayout.stories.tsx │ │ │ └── index.tsx │ │ ├── Profile │ │ │ ├── Profile.stories.tsx │ │ │ └── index.tsx │ │ ├── ProfileLoading │ │ │ └── ProfileLoading.tsx │ │ ├── RedirectHomeModal │ │ │ └── index.tsx │ │ ├── RouterTransition │ │ │ └── index.tsx │ │ ├── ScrollHandler │ │ │ └── index.tsx │ │ ├── StatCounter │ │ │ ├── StatCounter.stories.tsx │ │ │ └── index.tsx │ │ └── TextInput │ │ │ ├── TextInput.stories.tsx │ │ │ └── index.tsx │ ├── login │ │ └── GitLoginButton │ │ │ ├── GitLoginButton.stories.tsx │ │ │ └── index.tsx │ └── notification │ │ ├── NotificationIcon │ │ └── index.tsx │ │ └── NotificationItem │ │ ├── NotificationItem.stories.tsx │ │ └── index.tsx ├── constants │ ├── article.ts │ ├── category.ts │ ├── color.ts │ ├── dummy.ts │ ├── error.ts │ ├── location.ts │ ├── notification.ts │ ├── pageTitle.ts │ └── participateButton.ts ├── hooks │ ├── queries │ │ ├── useAddComment.ts │ │ ├── useApplyGroup.ts │ │ ├── useCancelApplication.ts │ │ ├── useCancelRecruitment.ts │ │ ├── useCompleteRecruitment.ts │ │ ├── useDeleteArticle.ts │ │ ├── useDeleteComment.ts │ │ ├── useDeleteNotification.ts │ │ ├── useEditMyArticle.ts │ │ ├── useEditMyProfile.ts │ │ ├── useFetchApplicationStatus.ts │ │ ├── useFetchArticle.ts │ │ ├── useFetchChatUrl.ts │ │ ├── useFetchComments.ts │ │ ├── useFetchGroupArticles.ts │ │ ├── useFetchMyArticle.ts │ │ ├── useFetchMyInfo.ts │ │ ├── useFetchMyParticipateArticles.ts │ │ ├── useFetchMyWriteArticles.ts │ │ ├── useFetchNotifications.ts │ │ ├── useFetchParticipants.ts │ │ ├── useFetchProfile.ts │ │ └── useLogout.ts │ ├── useAsyncError.ts │ ├── useAuthInfiniteQuery.ts │ ├── useAuthMutation.ts │ ├── useAuthQuery.ts │ ├── useClipboard.ts │ ├── useDeferredResponse.ts │ ├── useGeneralQuery.ts │ ├── useIntersect.ts │ ├── useModals.ts │ └── useNotificationEvent.ts ├── mocks │ ├── browser.ts │ ├── handlers.ts │ ├── index.ts │ ├── resolvers │ │ ├── getGroupArticles.ts │ │ ├── getMyInfo.ts │ │ ├── postTest.ts │ │ └── test.ts │ └── server.ts ├── pages │ ├── 404.tsx │ ├── 500.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── article │ │ ├── [id].tsx │ │ ├── edit │ │ │ └── [id].tsx │ │ └── write │ │ │ └── index.tsx │ ├── index.tsx │ ├── login.tsx │ ├── my │ │ ├── edit.tsx │ │ ├── index.tsx │ │ ├── participate.tsx │ │ └── write.tsx │ ├── notification.tsx │ └── user │ │ └── [id].tsx ├── recoil │ └── atoms.ts ├── styles │ ├── CommonStyles.tsx │ ├── CustomFont.tsx │ ├── GlobalStyles.tsx │ ├── global.css │ ├── theme.ts │ └── utils.ts ├── typings │ ├── custom.d.ts │ ├── emotion.d.ts │ └── types.ts └── utils │ ├── commonAxios.ts │ ├── dateTime.ts │ ├── errors │ ├── AuthError.ts │ ├── GetError.ts │ └── RequestError.ts │ ├── toast.tsx │ └── uploadImage.ts └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUGFIX]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | --- 11 | name: Bug request 12 | about: 수정 작성해주세요. 13 | title: 'fix: ' 14 | label: '🐛 수정' 15 | assignees: '' 16 | --- 17 | 18 | ## 버그 상세 설명 19 | 20 | - 버그에 대한 상세한 설명과 정상적인 동작 방식에 대해 작성해주세요. 버그를 타인이 해결할 경우에도 현상과 작업 목표를 정확히 파악할 수 있어야 합니다. 21 | - 가능하면 버그를 재현하는 방법도 작성해주세요. 22 | 23 | ## 예상되는 원인과 해결 방법 제시 24 | 25 | - 예상되는 원인을 자유롭게 작성해주세요. 처리하는 사람에게 도움이 될 수도 있습니다. 26 | - 구체적인 해결 방법을 제시할 수 있다면 작성해주세요. 27 | 28 | ## 비고 및 추가 정보 29 | 30 | - 기타 참고할만한 링크나 도움이 될만한 정보를 추가해주세요. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | --- 11 | name: Feature request 12 | about: 기능 구현 예정을 작성해주세요. 13 | title: '[backlog-number] ' 14 | label: '✨ feature' 15 | assignees: '' 16 | --- 17 | 18 | ## 요구 사항 19 | 20 | - 요구 사항을 상세하게 적어주세요. 21 | - 이렇게 하위 리스트를 적극 활용하는 것도 좋은 방법입니다. 22 | 23 | ## 체크 리스트 24 | 25 | - [ ] 작업 목록을 작성합니다. 26 | - [ ] 작업자가 완료하면 체크를 할 수 있기 때문에 PM이 확인하고 싶은 단위로 나누어 작성하면 개발 진척도를 확인하기 용이합니다. 27 | 28 | ## 비고 29 | 30 | - 참고 사항을 적어주세요. 해당 작업을 하는 사람이 참고해야 하는 내용을 자유로운 형식으로 적을 수 있습니다. 31 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 체크 리스트 2 | 3 | - [ ] 적절한 제목으로 수정했나요? 4 | - [ ] 관련된 이슈와 연결 시켰나요? 5 | - [ ] Target Branch를 올바르게 설정했나요? 6 | - [ ] Label을 알맞게 설정했나요? 7 | 8 | ## 작업 내역 9 | 10 | - 작업한 내용을 간략하게 작성해주세요. 11 | 12 | ## 문제 상황과 해결 13 | 14 | - 작업 중 마주한 문제상황 및 해결방법을 남겨주세요. 15 | 16 | ## 비고 17 | 18 | - 참고했던 링크 등 참고 사항을 적어주세요. 코드 리뷰하는 사람이 참고해야 하는 내용을 자유로운 형식으로 적을 수 있습니다. 19 | -------------------------------------------------------------------------------- /.github/workflows/slack-notification.yml: -------------------------------------------------------------------------------- 1 | name: Slack Notification 2 | run-name: ${{ github.actor }} notify slack 🚀 3 | 4 | on: 5 | workflow_call: 6 | inputs: 7 | title: 8 | required: true 9 | type: string 10 | status: 11 | required: true 12 | type: string 13 | commit_url: 14 | required: true 15 | type: string 16 | secrets: 17 | webhook_url: 18 | required: true 19 | 20 | jobs: 21 | slack-notification: 22 | runs-on: ubuntu-20.04 23 | steps: 24 | - name: Slack Notifications 25 | id: slack 26 | uses: slackapi/slack-github-action@v1.23.0 27 | with: 28 | payload: | 29 | { 30 | "text": "${{ inputs.title }}: ${{ inputs.status }}\n${{ inputs.commit_url }}", 31 | "blocks": [ 32 | { 33 | "type": "section", 34 | "text": { 35 | "type": "mrkdwn", 36 | "text": "${{ inputs.title }}: ${{ inputs.status }}\n${{ inputs.commit_url }}" 37 | } 38 | } 39 | ] 40 | } 41 | env: 42 | SLACK_WEBHOOK_URL: ${{ secrets.webhook_url }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | *.iml 4 | 5 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.git 4 | **/.gitignore 5 | **/.project 6 | **/.settings 7 | **/.toolstarget 8 | **/.vs 9 | **/.vscode 10 | **/*.*proj.user 11 | **/*.dbmdl 12 | **/*.jfm 13 | **/charts 14 | **/docker-compose* 15 | **/compose* 16 | **/Dockerfile* 17 | **/node_modules 18 | **/npm-debug.log 19 | **/obj 20 | **/secrets.dev.yaml 21 | **/values.dev.yaml 22 | README.md 23 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | 'no-console': 'warn', 25 | 'prettier/prettier': [ 26 | 'error', 27 | { 28 | endOfLine: 'auto', 29 | }, 30 | ], 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .DS.Store 4 | .DS_Store 5 | 6 | dist 7 | 8 | .env 9 | .env.* 10 | 11 | .vscode -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.18.1-alpine 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm ci 8 | 9 | COPY . . 10 | 11 | EXPOSE 3000 12 | -------------------------------------------------------------------------------- /backend/local/nginx.conf: -------------------------------------------------------------------------------- 1 | 2 | upstream backend-server { 3 | server moyeo-server:3000; 4 | } 5 | 6 | server { 7 | listen 80 default_server; 8 | server_name _; 9 | 10 | location / { 11 | proxy_pass http://backend-server; 12 | proxy_http_version 1.1; 13 | } 14 | 15 | location = /v1/sse { 16 | proxy_pass http://backend-server; 17 | proxy_http_version 1.1; 18 | proxy_read_timeout 600s; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /backend/nginx.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | 3 | RUN rm -rf /etc/nginx/conf.d/* 4 | 5 | COPY ./local/nginx.conf /etc/nginx/conf.d/nginx.conf 6 | 7 | EXPOSE 80 8 | -------------------------------------------------------------------------------- /backend/nginx/dev/conf/nginx.conf: -------------------------------------------------------------------------------- 1 | 2 | upstream backend-server { 3 | server moyeo-server-blue:3000; 4 | } 5 | 6 | server { 7 | listen 80 default_server; 8 | server_name dev.moyeomoyeo.com; 9 | server_tokens off; 10 | 11 | # certbot이 발급한 challenge 파일을 nginx가 서빙 12 | location /.well-known/acme-challenge/ { 13 | allow all; 14 | root /var/www/certbot; 15 | } 16 | 17 | # 모든 http(80포트) 요청을 https로 리다이렉팅 18 | location / { 19 | return 301 https://$host$request_uri; 20 | } 21 | 22 | } 23 | 24 | server { 25 | listen 443 ssl; 26 | server_name dev.moyeomoyeo.com; 27 | server_tokens off; 28 | 29 | ssl_certificate /etc/letsencrypt/live/dev.moyeomoyeo.com/fullchain.pem; 30 | ssl_certificate_key /etc/letsencrypt/live/dev.moyeomoyeo.com/privkey.pem; 31 | include /etc/letsencrypt/options-ssl-nginx.conf; 32 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; 33 | 34 | location / { 35 | proxy_pass http://backend-server; 36 | proxy_http_version 1.1; 37 | } 38 | 39 | location = /v1/sse { 40 | proxy_pass http://backend-server; 41 | proxy_http_version 1.1; 42 | proxy_read_timeout 600s; 43 | } 44 | } -------------------------------------------------------------------------------- /backend/nginx/prod/nginx.conf: -------------------------------------------------------------------------------- 1 | 2 | upstream backend-server { 3 | server moyeo-server:3000; 4 | } 5 | 6 | server { 7 | listen 80 default_server; 8 | server_name api.moyeomoyeo.com; 9 | server_tokens off; 10 | 11 | # certbot이 발급한 challenge 파일을 nginx가 서빙 12 | location /.well-known/acme-challenge/ { 13 | allow all; 14 | root /var/www/certbot; 15 | } 16 | 17 | # 모든 http(80포트) 요청을 https로 리다이렉팅 18 | location / { 19 | return 301 https://$host$request_uri; 20 | } 21 | 22 | } 23 | 24 | server { 25 | listen 443 ssl; 26 | server_name api.moyeomoyeo.com; 27 | server_tokens off; 28 | 29 | ssl_certificate /etc/letsencrypt/live/api.moyeomoyeo.com/fullchain.pem; 30 | ssl_certificate_key /etc/letsencrypt/live/api.moyeomoyeo.com/privkey.pem; 31 | include /etc/letsencrypt/options-ssl-nginx.conf; 32 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; 33 | 34 | location / { 35 | proxy_pass http://backend-server; 36 | proxy_http_version 1.1; 37 | } 38 | 39 | location = /v1/sse { 40 | proxy_pass http://backend-server; 41 | proxy_http_version 1.1; 42 | proxy_read_timeout 600s; 43 | } 44 | } -------------------------------------------------------------------------------- /backend/scripts/prod-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo $2 | docker login -u $1 $3 --password-stdin 4 | 5 | echo "docker logined" 6 | 7 | cd backend || exit 1 8 | 9 | touch .env 10 | 11 | echo -e $4 > .env 12 | 13 | echo "create .env" 14 | 15 | # docker down 16 | docker compose -f docker-compose.prod.yml down --rmi all --remove-orphans 17 | 18 | # docker up 19 | docker compose -f docker-compose.prod.yml up -d --build -------------------------------------------------------------------------------- /backend/sql/category.sql: -------------------------------------------------------------------------------- 1 | USE development; 2 | 3 | INSERT INTO group_category (name) 4 | VALUES 5 | ('MEAL'), 6 | ('STUDY'), 7 | ('ETC'), 8 | ('COMPETITION'), 9 | ('PROJECT'); -------------------------------------------------------------------------------- /backend/src/app/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthController } from '@app/auth/auth.controller'; 3 | import { AuthService } from '@app/auth/auth.service'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | import { GithubConfigModule } from '@config/github/config.module'; 6 | import { GithubStrategy } from '@app/auth/github.strategy'; 7 | import { UserModule } from '@app/user/user.module'; 8 | 9 | @Module({ 10 | imports: [UserModule, PassportModule, GithubConfigModule], 11 | controllers: [AuthController], 12 | providers: [AuthService, GithubStrategy], 13 | }) 14 | export class AuthModule {} 15 | -------------------------------------------------------------------------------- /backend/src/app/auth/github-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { AuthGuard } from '@nestjs/passport'; 2 | import { ExecutionContext, Injectable } from '@nestjs/common'; 3 | 4 | @Injectable() 5 | export class GithubAuthGuard extends AuthGuard('github') { 6 | canActivate(context: ExecutionContext) { 7 | return super.canActivate(context); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/app/auth/github-profile.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { GithubProfile } from '@app/auth/type/github-profile'; 3 | 4 | export const RequestGithubProfile = createParamDecorator( 5 | (data, ctx: ExecutionContext): GithubProfile => { 6 | const req = ctx.switchToHttp().getRequest(); 7 | return req.user; 8 | }, 9 | ); 10 | -------------------------------------------------------------------------------- /backend/src/app/auth/github.strategy.ts: -------------------------------------------------------------------------------- 1 | import { PassportStrategy } from '@nestjs/passport'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { Strategy } from 'passport-github'; 4 | import { VerifyCallback } from 'passport-oauth2'; 5 | import { GithubConfigService } from '@config/github/config.service'; 6 | 7 | @Injectable() 8 | export class GithubStrategy extends PassportStrategy(Strategy, 'github') { 9 | constructor(githubConfigService: GithubConfigService) { 10 | super({ 11 | clientID: githubConfigService.clientId, 12 | clientSecret: githubConfigService.clientSecret, 13 | callbackURL: githubConfigService.callbackUrl, 14 | }); 15 | } 16 | 17 | validate( 18 | accessToken: string, 19 | refreshToken: string, 20 | profile: any, 21 | done: VerifyCallback, 22 | ) { 23 | done(null, profile); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/app/auth/type/github-profile.ts: -------------------------------------------------------------------------------- 1 | export interface GithubProfile { 2 | id: string; 3 | 4 | profileUrl: string; 5 | 6 | _json: { 7 | avatar_url: string; 8 | blog: string; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/app/comment/comment.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CommentController } from '@app/comment/comment.controller'; 3 | import { CommentService } from '@app/comment/comment.service'; 4 | import { CommentRepository } from '@app/comment/comment.repository'; 5 | import { GroupArticleModule } from '@app/group-article/group-article.module'; 6 | 7 | @Module({ 8 | imports: [GroupArticleModule], 9 | controllers: [CommentController], 10 | providers: [CommentService, CommentRepository], 11 | exports: [CommentRepository], 12 | }) 13 | export class CommentModule {} 14 | -------------------------------------------------------------------------------- /backend/src/app/comment/dto/comment-writing-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber, IsString } from 'class-validator'; 3 | 4 | export class CommentWritingRequest { 5 | @ApiProperty({ 6 | example: 1, 7 | description: '모집 게시판 아이디', 8 | }) 9 | @IsNumber() 10 | articleId: number; 11 | 12 | @ApiProperty({ 13 | example: '정확히 어떤 걸 공부하는 걸까요?', 14 | description: '댓글 내용', 15 | }) 16 | @IsString() 17 | contents: string; 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/app/comment/dto/comment-writing-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Comment } from '@app/comment/entity/comment.entity'; 3 | 4 | export class CommentWritingResponse { 5 | @ApiProperty({ 6 | example: 1, 7 | description: '댓글 아이디', 8 | }) 9 | id: number; 10 | 11 | static from(comment: Comment) { 12 | const response = new CommentWritingResponse(); 13 | response.id = comment.id; 14 | return response; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/app/comment/dto/get-all-comment-query-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { PageRequest } from '@src/common/util/page-request'; 3 | import { Type } from 'class-transformer'; 4 | import { IsNumber } from 'class-validator'; 5 | 6 | export class GetAllCommentQueryRequest extends PageRequest { 7 | @Type(() => Number) 8 | @IsNumber() 9 | @ApiProperty({ 10 | example: 1, 11 | description: '모집 게시글 아이디', 12 | required: false, 13 | }) 14 | articleId: number; 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/app/comment/dto/group-article-comment-get-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { CommentResponse } from './comment-response.dto'; 3 | import { PageResult } from '@src/common/util/page-result'; 4 | import { Expose } from 'class-transformer'; 5 | 6 | export class GroupArticleCommentGetResponse extends PageResult { 7 | @Expose() 8 | @ApiProperty({ type: CommentResponse, isArray: true }) 9 | get data() { 10 | return this._data; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/app/comment/exception/comment-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class CommentNotFoundException extends NotFoundException { 4 | constructor(message = '해당 댓글이 존재하지 않습니다.') { 5 | super({ status: 'COMMENT_NOT_FOUND', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/comment/exception/group-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class GroupNotFoundException extends NotFoundException { 4 | constructor(message = '해당 그룹이 존재하지 않습니다.') { 5 | super({ status: 'GROUP_NOT_FOUND', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/comment/exception/not-author.exception.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException } from '@nestjs/common'; 2 | 3 | export class NotAuthorException extends ForbiddenException { 4 | constructor(message = '해당 댓글의 작성자가 아닙니다.') { 5 | super({ status: 'NOT_AUTHOR', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-application/__test__/group-application.fixture.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { Group } from '@app/group-article/entity/group.entity'; 3 | import { GroupApplication } from '@app/group-application/entity/group-application.entity'; 4 | import { GROUP_APPLICATION_STATUS } from '@src/app/group-article/constants/group-article.constants'; 5 | 6 | export const getGroupApplicationRegisterFixture = async ( 7 | group: Group, 8 | groupApplication: Partial = {}, 9 | ) => { 10 | const fixture = new GroupApplication(); 11 | fixture.id = groupApplication.id || faker.datatype.number(); 12 | fixture.userId = (await groupApplication.user).id; 13 | fixture.user = groupApplication.user; 14 | fixture.groupId = group.id; 15 | fixture.group = new Promise((res) => res(group)); 16 | fixture.status = groupApplication.status || GROUP_APPLICATION_STATUS.REGISTER; 17 | fixture.createdAt = new Date(); 18 | fixture.updatedAt = new Date(); 19 | return fixture; 20 | }; 21 | -------------------------------------------------------------------------------- /backend/src/app/group-application/dto/application-with-user-info-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { UserInfo } from '@app/group-application/dto/user-info.dto'; 3 | import { GroupApplication } from '@app/group-application/entity/group-application.entity'; 4 | 5 | export class ApplicationWithUserInfoResponse { 6 | @ApiProperty({ 7 | example: 1, 8 | description: '', 9 | required: true, 10 | }) 11 | id: number; 12 | 13 | @ApiProperty() 14 | user: UserInfo; 15 | 16 | static from(userInfo: UserInfo, application: GroupApplication) { 17 | const response = new ApplicationWithUserInfoResponse(); 18 | response.id = application.id; 19 | response.user = userInfo; 20 | return response; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/app/group-application/dto/attend-group-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class AttendGroupResponse { 4 | @ApiProperty({ 5 | example: 1, 6 | description: '참가신청 아이디', 7 | required: true, 8 | }) 9 | id: number; 10 | 11 | static from(id: number) { 12 | const response = new AttendGroupResponse(); 13 | response.id = id; 14 | return response; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/app/group-application/dto/check-joining-group-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class CheckJoiningGroupResonse { 4 | @ApiProperty({ 5 | example: true, 6 | description: '모임의 신청 여부를 조회', 7 | required: true, 8 | }) 9 | isJoined: boolean; 10 | 11 | static from(isJoined: boolean) { 12 | const response = new CheckJoiningGroupResonse(); 13 | response.isJoined = isJoined; 14 | return response; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/app/group-application/dto/group-application-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export class GroupApplicationRequest { 5 | @ApiProperty({ 6 | example: 1, 7 | description: '그룹 아이디', 8 | required: true, 9 | }) 10 | @IsNumber() 11 | groupArticleId: number; 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/app/group-application/dto/my-application-result.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IMyApplicationResult { 2 | id: number; 3 | 4 | groupId: number; 5 | } 6 | -------------------------------------------------------------------------------- /backend/src/app/group-application/dto/my-group-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { PageRequest } from '@src/common/util/page-request'; 2 | 3 | export class MyGroupRequest extends PageRequest {} 4 | -------------------------------------------------------------------------------- /backend/src/app/group-application/dto/my-group-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { GroupArticleResponse } from '@app/group-application/dto/group-article-response.dto'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { PageResult } from '@src/common/util/page-result'; 4 | import { Expose } from 'class-transformer'; 5 | 6 | export class MyGroupResponse extends PageResult { 7 | @Expose() 8 | @ApiProperty({ type: GroupArticleResponse, isArray: true }) 9 | get data() { 10 | return this._data; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/app/group-application/dto/my-group-result.interface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CATEGORY, 3 | GROUP_STATUS, 4 | LOCATION, 5 | } from '@src/app/group-article/constants/group-article.constants'; 6 | 7 | export interface IMyGroupResult { 8 | groupArticleId: number; 9 | 10 | title: string; 11 | 12 | location: LOCATION; 13 | 14 | category: CATEGORY; 15 | 16 | commentCount: number; 17 | 18 | scrapCount: number; 19 | 20 | thumbnail: string; 21 | 22 | blurThumbnail: string; 23 | 24 | maxCapacity: number; 25 | 26 | currentCapacity: string; 27 | 28 | status: GROUP_STATUS; 29 | 30 | createdAt: Date; 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/app/group-application/dto/user-info.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { User } from '@src/app/user/entity/user.entity'; 3 | 4 | export class UserInfo { 5 | @ApiProperty({ 6 | example: 1, 7 | description: '유저의 아이디', 8 | required: true, 9 | }) 10 | id: number; 11 | 12 | @ApiProperty({ 13 | example: 'username1103', 14 | description: '유저의 유저 이름', 15 | required: true, 16 | }) 17 | userName: string; 18 | 19 | @ApiProperty({ 20 | example: '안녕하세요 예비 인프런 개발자 김명일입니다.', 21 | description: '유저의 간단한 자기소개', 22 | required: true, 23 | }) 24 | description: string; 25 | 26 | @ApiProperty({ 27 | example: 28 | 'https://kr.object.ncloudstorage.com/uploads/images/1669276833875-64adca9c-94cd-4162-a53f-f75e951e39d', 29 | description: '유저의 프로필 이미지', 30 | required: true, 31 | }) 32 | profileImage: string; 33 | 34 | static from(user: User) { 35 | const response = new UserInfo(); 36 | response.id = user.id; 37 | response.userName = user.userName; 38 | response.description = user.description; 39 | response.profileImage = user.profileImage; 40 | return response; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/app/group-application/exception/application-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class ApplicationNotFoundException extends NotFoundException { 4 | constructor(message = '신청 내역을 확인할 수 없습니다.') { 5 | super({ status: 'APPLICATION_NOT_FOUND', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-application/exception/cannot-applicate.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | export class CannotApplicateException extends BadRequestException { 4 | constructor( 5 | message = '당신이 만든 그룹에 신청을 하거나 취소할 수 없습니다.', 6 | ) { 7 | super({ status: 'CAN_NOT_APPLICATE_BAD_REQUEST', message }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/app/group-application/exception/closed-group.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | export class ClosedGroupException extends BadRequestException { 4 | constructor(message = '모임이 모집완료 혹은 모집중단 상태입니다.') { 5 | super({ status: 'CLOSED_GROUP', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-application/exception/duplicate-application.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | export class DuplicateApplicationException extends BadRequestException { 4 | constructor(message = '이미 신청되어 있는 유저입니다.') { 5 | super({ status: 'DUPLICATE_APPLICATION_BAD_REQUEST', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-application/exception/group-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class GroupNotFoundException extends NotFoundException { 4 | constructor(message = '해당 그룹이 존재하지 않습니다.') { 5 | super({ status: 'GROUP_NOT_FOUND', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-application/exception/not-author.exception.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException } from '@nestjs/common'; 2 | 3 | export class NotAuthorException extends ForbiddenException { 4 | constructor(message = '해당 참가신청의 본인이 아닙니다.') { 5 | super({ status: 'NOT_AUTHOR', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-application/group-application.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GroupApplicationController } from '@app/group-application/group-application.controller'; 3 | import { GroupApplicationRepository } from '@app/group-application/group-application.repository'; 4 | import { GroupApplicationService } from '@app/group-application/group-application.service'; 5 | import { GroupArticleModule } from '@app/group-article/group-article.module'; 6 | 7 | @Module({ 8 | imports: [GroupArticleModule], 9 | controllers: [GroupApplicationController], 10 | providers: [GroupApplicationService, GroupApplicationRepository], 11 | exports: [GroupApplicationRepository], 12 | }) 13 | export class GroupApplicationModule {} 14 | -------------------------------------------------------------------------------- /backend/src/app/group-application/type/group-application-status.transformer.ts: -------------------------------------------------------------------------------- 1 | import { GROUP_APPLICATION_STATUS } from '@src/app/group-article/constants/group-article.constants'; 2 | import { ValueTransformer } from 'typeorm'; 3 | 4 | export class GroupApplicationStatusTransformer implements ValueTransformer { 5 | to(value: string) { 6 | return value === GROUP_APPLICATION_STATUS.CANCEL 7 | ? null 8 | : GROUP_APPLICATION_STATUS.REGISTER; 9 | } 10 | 11 | from(value: string | null) { 12 | return value 13 | ? GROUP_APPLICATION_STATUS.REGISTER 14 | : GROUP_APPLICATION_STATUS.CANCEL; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/app/group-article/__test__/group-article.fixture.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { Group } from '@app/group-article/entity/group.entity'; 3 | import { GroupArticle } from '@app/group-article/entity/group-article.entity'; 4 | 5 | export const getGroupArticleFixture = async ( 6 | group: Group, 7 | groupArticle: Partial = {}, 8 | ) => { 9 | const fixture = new GroupArticle(); 10 | fixture.id = groupArticle.id || faker.datatype.number({ min: 1, max: 10000 }); 11 | fixture.group = group; 12 | group.article = new Promise((res) => { 13 | res(fixture); 14 | }); 15 | fixture.userId = (await groupArticle.user).id; 16 | fixture.user = groupArticle.user; 17 | fixture.title = groupArticle.title || faker.commerce.product(); 18 | fixture.contents = 19 | groupArticle.contents || faker.commerce.productDescription(); 20 | fixture.type = 'GROUP'; 21 | fixture.createdAt = new Date(); 22 | fixture.updatedAt = new Date(); 23 | return fixture; 24 | }; 25 | -------------------------------------------------------------------------------- /backend/src/app/group-article/__test__/group-category.fixture.ts: -------------------------------------------------------------------------------- 1 | import { GroupCategory } from '@app/group-article/entity/group-category.entity'; 2 | 3 | export const getGroupCategoryFixture = () => { 4 | const categories = ['MEAL', 'STUDY', 'ETC', 'COMPETITION', 'PROJECT']; 5 | return categories.map((category, index) => { 6 | const fixture = new GroupCategory(); 7 | fixture.id = index + 1; 8 | fixture.name = category; 9 | fixture.createdAt = new Date(); 10 | fixture.updatedAt = new Date(); 11 | return fixture; 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /backend/src/app/group-article/__test__/group.fixture.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { 3 | GROUP_STATUS, 4 | LOCATION, 5 | } from '@app/group-article/constants/group-article.constants'; 6 | import { GroupCategory } from '@app/group-article/entity/group-category.entity'; 7 | import { Group } from '@app/group-article/entity/group.entity'; 8 | 9 | export const getGroupFixture = ( 10 | groupCategory: GroupCategory, 11 | group: Partial = {}, 12 | ) => { 13 | const fixture = new Group(); 14 | fixture.id = group.id || faker.datatype.number({ min: 1, max: 10000 }); 15 | fixture.category = groupCategory; 16 | fixture.location = LOCATION.ONLINE; 17 | fixture.maxCapacity = group.maxCapacity || 10; 18 | fixture.status = group.status || GROUP_STATUS.PROGRESS; 19 | fixture.chatUrl = group.chatUrl || faker.internet.url(); 20 | fixture.thumbnail = group.thumbnail || faker.internet.url(); 21 | fixture.blurThumbnail = group.blurThumbnail || ''; 22 | fixture.createdAt = new Date(); 23 | fixture.updatedAt = new Date(); 24 | return fixture; 25 | }; 26 | -------------------------------------------------------------------------------- /backend/src/app/group-article/constants/group-article.constants.ts: -------------------------------------------------------------------------------- 1 | export enum CATEGORY { 2 | MEAL = 'MEAL', 3 | STUDY = 'STUDY', 4 | ETC = 'ETC', 5 | COMPETITION = 'COMPETITION', 6 | PROJECT = 'PROJECT', 7 | } 8 | 9 | export enum LOCATION { 10 | ONLINE = 'ONLINE', 11 | SEOUL = 'SEOUL', 12 | INCHEON = 'INCHEON', 13 | BUSAN = 'BUSAN', 14 | DAEGU = 'DAEGU', 15 | GWANGJU = 'GWANGJU', 16 | DAEJEON = 'DAEJEON', 17 | ULSAN = 'ULSAN', 18 | SEJONG = 'SEJONG', 19 | GYEONGGI = 'GYEONGGI', 20 | GANGWON = 'GANGWON', 21 | CHUNGBUK = 'CHUNGBUK', 22 | CHUNGNAM = 'CHUNGNAM', 23 | JEONBUK = 'JEONBUK', 24 | JEONNAM = 'JEONNAM', 25 | GYEONGBUK = 'GYEONGBUK', 26 | GYEONGNAM = 'GYEONGNAM', 27 | JEJU = 'JEJU', 28 | } 29 | 30 | export enum GROUP_APPLICATION_STATUS { 31 | REGISTER = 'REGISTER', 32 | CANCEL = 'CANCEL', 33 | } 34 | 35 | export enum GROUP_STATUS { 36 | PROGRESS = 'PROGRESS', 37 | SUCCEED = 'SUCCEED', 38 | FAIL = 'FAIL', 39 | } 40 | 41 | export enum ARTICLE { 42 | GROUP = 'GROUP', 43 | FREE = 'FREE', 44 | QUESTION = 'QUESTION', 45 | PROJECT = 'PROJECT', 46 | } 47 | -------------------------------------------------------------------------------- /backend/src/app/group-article/dto/author.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class Author { 4 | @ApiProperty({ example: 1, description: '작성자 아이디' }) 5 | id: number; 6 | 7 | @ApiProperty({ example: '박종혁', description: '작성자 이름' }) 8 | userName: string; 9 | 10 | @ApiProperty({ 11 | example: 'https://avatars.githubusercontent.com/u/67570061?v=4', 12 | description: '작성자 프로필 이미지', 13 | }) 14 | profileImage: string; 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/app/group-article/dto/get-cateogories-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { GroupCategory } from '@app/group-article/entity/group-category.entity'; 3 | 4 | export class GroupCategoryResponse { 5 | @ApiProperty({ example: 1 }) 6 | id: number; 7 | 8 | @ApiProperty({ example: '스터디' }) 9 | name: string; 10 | 11 | static from(groupCategory: GroupCategory) { 12 | const dto = new GroupCategoryResponse(); 13 | dto.id = groupCategory.id; 14 | dto.name = groupCategory.name; 15 | return dto; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/app/group-article/dto/get-group-chat-url-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class GetGroupChatUrlResponseDto { 4 | @ApiProperty({ 5 | example: 'https://open.kakao.com/오픈채팅방path', 6 | description: '카카오톡과 기타 채팅서비스의 주소를 담아놓을 수 있다.', 7 | }) 8 | chatUrl: string; 9 | 10 | static from(charUrl: string) { 11 | const res = new GetGroupChatUrlResponseDto(); 12 | res.chatUrl = charUrl; 13 | return res; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/app/group-article/dto/group-article-detail.interface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GROUP_STATUS, 3 | LOCATION, 4 | } from '@app/group-article/constants/group-article.constants'; 5 | 6 | export interface IGroupArticleDetail { 7 | id: number; 8 | title: string; 9 | contents: string; 10 | userId: number; 11 | userName: string; 12 | userProfileImage: string; 13 | maxCapacity: number; 14 | thumbnail: string; 15 | status: GROUP_STATUS; 16 | location: LOCATION; 17 | groupCategoryId: number; 18 | groupCategoryName: string; 19 | scrapCount: number; 20 | commentCount: number; 21 | createdAt: Date; 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/app/group-article/dto/group-article-register-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { GroupArticle } from '@app/group-article/entity/group-article.entity'; 3 | 4 | export class GroupArticleRegisterResponse { 5 | @ApiProperty({ 6 | example: 1, 7 | description: '모집게시글 ID', 8 | required: true, 9 | }) 10 | id: number; 11 | 12 | static from(groupArticle: GroupArticle) { 13 | const response = new GroupArticleRegisterResponse(); 14 | response.id = groupArticle.id; 15 | return response; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/app/group-article/dto/group-article-search-result.interface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GROUP_STATUS, 3 | LOCATION, 4 | } from '@app/group-article/constants/group-article.constants'; 5 | 6 | export interface IGroupArticleSearchResult { 7 | id: number; 8 | 9 | title: string; 10 | 11 | thumbnail: string; 12 | 13 | blurThumbnail: string; 14 | 15 | status: GROUP_STATUS; 16 | 17 | location: LOCATION; 18 | 19 | groupCategoryId: number; 20 | 21 | groupCategoryName: string; 22 | 23 | maxCapacity: number; 24 | 25 | currentCapacity: number; 26 | 27 | scrapCount: number; 28 | 29 | commentCount: number; 30 | 31 | createdAt: Date; 32 | } 33 | -------------------------------------------------------------------------------- /backend/src/app/group-article/dto/search-group-articles-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { PageRequest } from '@common/util/page-request'; 2 | import { 3 | CATEGORY, 4 | GROUP_STATUS, 5 | LOCATION, 6 | } from '@app/group-article/constants/group-article.constants'; 7 | import { IsEnum, IsOptional } from 'class-validator'; 8 | import { ApiProperty } from '@nestjs/swagger'; 9 | 10 | export class SearchGroupArticlesRequest extends PageRequest { 11 | @IsOptional() 12 | @IsEnum(CATEGORY) 13 | @ApiProperty({ example: CATEGORY.STUDY, enum: CATEGORY, required: false }) 14 | category?: CATEGORY; 15 | 16 | @IsOptional() 17 | @IsEnum(LOCATION) 18 | @ApiProperty({ example: LOCATION.ONLINE, enum: LOCATION, required: false }) 19 | location?: LOCATION; 20 | 21 | @IsOptional() 22 | @IsEnum(GROUP_STATUS) 23 | @ApiProperty({ 24 | example: GROUP_STATUS.PROGRESS, 25 | enum: GROUP_STATUS, 26 | required: false, 27 | }) 28 | status?: GROUP_STATUS; 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/app/group-article/dto/search-group-articles-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { PageResult } from '@common/util/page-result'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | import { GroupArticleSearchResult } from '@app/group-article/dto/group-article-search-result.dto'; 5 | 6 | export class SearchGroupArticleResponse extends PageResult { 7 | @Expose() 8 | @ApiProperty({ type: GroupArticleSearchResult, isArray: true }) 9 | get data() { 10 | return this._data; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/app/group-article/dto/update-group-article-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, Length } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class UpdateGroupArticleRequest { 5 | @IsString() 6 | @Length(1) 7 | @ApiProperty({ 8 | example: '수정할 제목', 9 | description: '게시글 제목', 10 | required: true, 11 | }) 12 | title: string; 13 | 14 | @IsString() 15 | @Length(1) 16 | @ApiProperty({ 17 | example: '수정할 내용', 18 | description: '게시글 내용', 19 | required: true, 20 | }) 21 | contents: string; 22 | 23 | @IsString() 24 | @Length(1) 25 | @ApiProperty({ 26 | example: '1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png', 27 | description: '썸네일 이미지가 저장되어있는 주소', 28 | required: true, 29 | }) 30 | thumbnail: string; 31 | 32 | @IsString() 33 | @Length(1) 34 | @ApiProperty({ 35 | example: 'https://open.kakao.com/오픈채팅방path', 36 | description: '카카오톡과 기타 채팅서비스의 주소를 담아놓을 수 있다.', 37 | required: false, 38 | }) 39 | chatUrl: string; 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/app/group-article/dto/v2-search-group-articles-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsOptional } from 'class-validator'; 2 | import { 3 | CATEGORY, 4 | GROUP_STATUS, 5 | LOCATION, 6 | } from '@app/group-article/constants/group-article.constants'; 7 | import { ApiProperty } from '@nestjs/swagger'; 8 | import { NoOffsetPageRequest } from '@common/util/no-offset-page-request'; 9 | 10 | export class V2SearchGroupArticlesRequest extends NoOffsetPageRequest { 11 | @IsOptional() 12 | @IsEnum(CATEGORY) 13 | @ApiProperty({ example: CATEGORY.STUDY, enum: CATEGORY, required: false }) 14 | category?: CATEGORY; 15 | 16 | @IsOptional() 17 | @IsEnum(LOCATION) 18 | @ApiProperty({ example: LOCATION.ONLINE, enum: LOCATION, required: false }) 19 | location?: LOCATION; 20 | 21 | @IsOptional() 22 | @IsEnum(GROUP_STATUS) 23 | @ApiProperty({ 24 | example: GROUP_STATUS.PROGRESS, 25 | enum: GROUP_STATUS, 26 | required: false, 27 | }) 28 | status?: GROUP_STATUS; 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/app/group-article/dto/v2-search-group-articles-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { NoOffsetPageResult } from '@common/util/no-offset-page-result'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Expose } from 'class-transformer'; 4 | import { GroupArticleSearchResult } from '@app/group-article/dto/group-article-search-result.dto'; 5 | 6 | export class V2SearchGroupArticlesResponse extends NoOffsetPageResult { 7 | @Expose() 8 | @ApiProperty({ type: GroupArticleSearchResult, isArray: true }) 9 | get data(): GroupArticleSearchResult[] { 10 | return this._data; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/app/group-article/entity/article.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | TableInheritance, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | import { User } from '@app/user/entity/user.entity'; 12 | 13 | @Entity({ name: 'article' }) 14 | @TableInheritance({ pattern: 'STI', column: { type: 'varchar', name: 'type' } }) 15 | export abstract class Article { 16 | @PrimaryGeneratedColumn({ unsigned: true }) 17 | id: number; 18 | 19 | @Column({ unsigned: true }) 20 | userId: number; 21 | 22 | @ManyToOne(() => User, { lazy: true, nullable: false }) 23 | @JoinColumn({ referencedColumnName: 'id', name: 'user_id' }) 24 | user: Promise; 25 | 26 | @Column({ type: 'varchar', length: 100 }) 27 | title: string; 28 | 29 | @Column({ type: 'text' }) 30 | contents: string; 31 | 32 | @Column({ type: 'varchar', length: 30 }) 33 | type: string; 34 | 35 | @CreateDateColumn({ type: 'timestamp' }) 36 | createdAt: Date; 37 | 38 | @UpdateDateColumn({ type: 'timestamp' }) 39 | updatedAt: Date; 40 | 41 | @Column({ type: 'timestamp', nullable: true }) 42 | deletedAt: Date | null; 43 | } 44 | -------------------------------------------------------------------------------- /backend/src/app/group-article/entity/group-category.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | UpdateDateColumn, 7 | } from 'typeorm'; 8 | 9 | @Entity({ name: 'group_category' }) 10 | export class GroupCategory { 11 | @PrimaryGeneratedColumn({ unsigned: true }) 12 | id: number; 13 | 14 | @Column({ type: 'varchar', length: 30, unique: true }) 15 | name: string; 16 | 17 | @CreateDateColumn() 18 | createdAt: Date; 19 | 20 | @UpdateDateColumn() 21 | updatedAt: Date; 22 | 23 | @Column({ type: 'timestamp', nullable: true }) 24 | deletedAt: Date | null; 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/app/group-article/exception/group-article-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class GroupArticleNotFoundException extends NotFoundException { 4 | constructor(message = '해당하는 모집 게시글이 존재하지 않습니다') { 5 | super({ status: 'GROUP_ARTICLE_NOT_FOUND', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-article/exception/group-category-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class GroupCategoryNotFoundException extends NotFoundException { 4 | constructor(message = '해당 카테고리가 존재하지 않습니다.') { 5 | super({ status: 'GROUP_CATEGORY_NOT_FOUND', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-article/exception/not-author.exception.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException } from '@nestjs/common'; 2 | 3 | export class NotAuthorException extends ForbiddenException { 4 | constructor(message = '작성자가 아닙니다') { 5 | super({ status: 'NOT_AUTHOR', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-article/exception/not-participant.exception.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException } from '@nestjs/common'; 2 | 3 | export class NotParticipantException extends ForbiddenException { 4 | constructor(message = '모집 게시글의 참가자가 아닙니다') { 5 | super({ status: 'NOT_PARTICIPANT', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-article/exception/not-progress-group.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | export class NotProgressGroupException extends BadRequestException { 4 | constructor(message = '모집중이 아닙니다') { 5 | super({ status: 'NOT_PROGRESS_GROUP', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-article/exception/not-success-group.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | export class NotSuccessGroupException extends BadRequestException { 4 | constructor(message = '모집완료되지 않은 게시글입니다') { 5 | super({ status: 'NOT_SUCCESS_GROUP', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/group-article/group-article.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GroupArticleController } from '@app/group-article/group-article.controller'; 3 | import { GroupArticleService } from '@app/group-article/group-article.service'; 4 | import { GroupCategoryRepository } from '@app/group-article/repository/group-category.repository'; 5 | import { GroupArticleRepository } from '@app/group-article/repository/group-article.repository'; 6 | import { MyGroupArticleController } from '@app/group-article/my-group-article.controller'; 7 | import { MyGroupArticleService } from '@app/group-article/my-group-article.service'; 8 | 9 | @Module({ 10 | controllers: [GroupArticleController, MyGroupArticleController], 11 | providers: [ 12 | GroupArticleService, 13 | GroupCategoryRepository, 14 | GroupArticleRepository, 15 | MyGroupArticleService, 16 | ], 17 | exports: [GroupArticleRepository], 18 | }) 19 | export class GroupArticleModule {} 20 | -------------------------------------------------------------------------------- /backend/src/app/group-article/my-group-article.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { GroupArticleRepository } from '@app/group-article/repository/group-article.repository'; 3 | import { User } from '@app/user/entity/user.entity'; 4 | import { GroupArticleNotFoundException } from '@app/group-article/exception/group-article-not-found.exception'; 5 | import { NotAuthorException } from '@app/group-article/exception/not-author.exception'; 6 | 7 | @Injectable() 8 | export class MyGroupArticleService { 9 | constructor( 10 | private readonly groupArticleRepository: GroupArticleRepository, 11 | ) {} 12 | 13 | async getById(user: User, id: number) { 14 | const groupArticle = await this.groupArticleRepository.findById(id); 15 | if (!groupArticle) { 16 | throw new GroupArticleNotFoundException(); 17 | } 18 | 19 | if (!groupArticle.isAuthor(user)) { 20 | throw new NotAuthorException(); 21 | } 22 | 23 | return groupArticle; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/app/group-article/repository/group-category.repository.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, IsNull, Repository } from 'typeorm'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { GroupCategory } from '@app/group-article/entity/group-category.entity'; 4 | 5 | @Injectable() 6 | export class GroupCategoryRepository extends Repository { 7 | constructor(private readonly dataSource: DataSource) { 8 | const baseRepository = dataSource.getRepository(GroupCategory); 9 | super( 10 | baseRepository.target, 11 | baseRepository.manager, 12 | baseRepository.queryRunner, 13 | ); 14 | } 15 | 16 | findByCategoryName(categoryName: string) { 17 | return this.findOne({ 18 | select: { 19 | id: true, 20 | }, 21 | where: { 22 | name: categoryName, 23 | deletedAt: IsNull(), 24 | }, 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/app/image/dto/images-upload-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ImagesUploadResponse { 4 | @ApiProperty({ 5 | example: '1669276833875-64adca9c-94cd-4162-a53f-f75e951e39db', 6 | description: '이미지 key', 7 | required: true, 8 | }) 9 | key: string; 10 | 11 | @ApiProperty({ 12 | example: 13 | 'https://kr.object.ncloudstorage.com/uploads/images/1669276833875-64adca9c-94cd-4162-a53f-f75e951e39db', 14 | description: '버킷 이미지 url', 15 | required: true, 16 | }) 17 | url: string; 18 | 19 | constructor(key: string, url: string) { 20 | this.key = key; 21 | this.url = url; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/app/image/image.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ImageController } from '@app/image/image.controller'; 3 | import { ImageService } from '@app/image/image.service'; 4 | import { S3ConfigModule } from '@src/common/config/s3/config.module'; 5 | 6 | @Module({ 7 | imports: [S3ConfigModule], 8 | controllers: [ImageController], 9 | providers: [ImageService], 10 | exports: [ImageService], 11 | }) 12 | export class ImageModule {} 13 | -------------------------------------------------------------------------------- /backend/src/app/myinfo/dto/profile-modifying-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsUrl } from 'class-validator'; 3 | 4 | export class ProfileModifyingRequest { 5 | @ApiProperty({ 6 | example: 'pythonstrup', 7 | description: 'userName', 8 | required: true, 9 | }) 10 | @IsString() 11 | userName: string; 12 | 13 | @ApiProperty({ 14 | example: 15 | 'https://kr.object.ncloudstorage.com/uploads/images/1669276833875-64adca9c-94cd-4162-a53f-f75e951e39db', 16 | description: '프로필 이미지', 17 | required: true, 18 | }) 19 | @IsString() 20 | profileImage: string; 21 | 22 | @ApiProperty({ 23 | example: '안녕하세요 웹풀스택 개발자 pythonstrup입니다!', 24 | description: '간단한 자기소개', 25 | required: true, 26 | }) 27 | @IsString() 28 | description: string; 29 | 30 | @ApiProperty({ 31 | example: 'https://github.com/pythonstrup', 32 | description: '깃허브 주소', 33 | required: true, 34 | }) 35 | @IsUrl() 36 | githubUrl: string; 37 | 38 | @ApiProperty({ 39 | example: 'https://myvelop.tistory.com/', 40 | description: '블로그 주소', 41 | }) 42 | @IsString() 43 | blogUrl: string; 44 | } 45 | -------------------------------------------------------------------------------- /backend/src/app/myinfo/exception/username-duplicate.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | export class UserNameDuplicateException extends BadRequestException { 4 | constructor( 5 | message = '중복된 유저이름으로 요청했습니다! 해당 유저이름으로 바꿀 수 없습니다.', 6 | ) { 7 | super({ status: 'USER_NAME_DUPLICATE_BAD_REQUEST', message }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/app/myinfo/myinfo.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserRepository } from '@app/user/user.repository'; 3 | import { MyInfoController } from '@app/myinfo/myinfo.controller'; 4 | import { MyInfoService } from '@app/myinfo/myinfo.service'; 5 | 6 | @Module({ 7 | controllers: [MyInfoController], 8 | providers: [UserRepository, MyInfoService], 9 | }) 10 | export class MyInfoModule {} 11 | -------------------------------------------------------------------------------- /backend/src/app/myinfo/myinfo.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { UserRepository } from '@app/user/user.repository'; 3 | import { ProfileModifyingRequest } from '@app/myinfo/dto/profile-modifying-request.dto'; 4 | import { UserNameDuplicateException } from '@app/myinfo/exception/username-duplicate.exception'; 5 | import { User } from '../user/entity/user.entity'; 6 | 7 | @Injectable() 8 | export class MyInfoService { 9 | constructor(private readonly userRepository: UserRepository) {} 10 | 11 | async updateProfile(user: User, ModifyingContents: ProfileModifyingRequest) { 12 | const currentUser = await this.userRepository.findByUsername( 13 | ModifyingContents.userName, 14 | ); 15 | if (currentUser && user.userName !== ModifyingContents.userName) { 16 | throw new UserNameDuplicateException(); 17 | } 18 | 19 | user.updateProfile({ 20 | userName: ModifyingContents.userName, 21 | profileImage: ModifyingContents.profileImage, 22 | description: ModifyingContents.description, 23 | githubUrl: ModifyingContents.githubUrl, 24 | blogUrl: ModifyingContents.blogUrl, 25 | }); 26 | this.userRepository.updateUser(user); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/app/notification/constants/notification.constants.ts: -------------------------------------------------------------------------------- 1 | export enum NOTIFICATION_SETTING_TYPE { 2 | COMMENT = 'COMMENT', 3 | GROUP = 'GROUP', 4 | } 5 | 6 | export enum NOTIFICATION_SETTING_STATUS { 7 | ON = 'ON', 8 | OFF = 'OFF', 9 | } 10 | 11 | export enum NOTIFICATION_TYPE { 12 | GROUP_SUCCEED = 'GROUP_SUCCEED', 13 | GROUP_FAILED = 'GROUP_FAILED', 14 | COMMENT_ADDED = 'COMMENT_ADDED', 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/app/notification/dto/get-notification-settings-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NOTIFICATION_SETTING_STATUS, 3 | NOTIFICATION_SETTING_TYPE, 4 | } from '@app/notification/constants/notification.constants'; 5 | import { ApiProperty } from '@nestjs/swagger'; 6 | import { NotificationSetting } from '@app/notification/entity/notification-setting.entity'; 7 | 8 | export class GetNotificationSettingsResponse { 9 | @ApiProperty({ example: 1, description: '알림설정아이디' }) 10 | id: number; 11 | 12 | @ApiProperty({ 13 | example: NOTIFICATION_SETTING_TYPE.GROUP, 14 | description: '알림설정타입', 15 | }) 16 | type: NOTIFICATION_SETTING_TYPE; 17 | 18 | @ApiProperty({ 19 | example: NOTIFICATION_SETTING_STATUS.ON, 20 | description: '알림설정상태', 21 | }) 22 | status: NOTIFICATION_SETTING_STATUS; 23 | 24 | static from(notificationSetting: NotificationSetting) { 25 | const res = new GetNotificationSettingsResponse(); 26 | res.id = notificationSetting.id; 27 | res.type = notificationSetting.type; 28 | res.status = notificationSetting.status; 29 | return res; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/app/notification/dto/get-user-notifications-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | import { PageResult } from '@common/util/page-result'; 4 | import { GetUserNotificationResult } from '@app/notification/dto/get-user-notification-result.dto'; 5 | 6 | export class GetUserNotificationsResponse extends PageResult { 7 | @Expose() 8 | @ApiProperty({ type: GetUserNotificationResult, isArray: true }) 9 | get data() { 10 | return this._data; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/app/notification/dto/notification-sse.dto.ts: -------------------------------------------------------------------------------- 1 | import { MessageEvent } from '@nestjs/common'; 2 | import { GetUserNotificationResult } from '@app/notification/dto/get-user-notification-result.dto'; 3 | import { UserNotification } from '@app/notification/entity/user-notification.entity'; 4 | import { randomUUID } from 'crypto'; 5 | 6 | export class NotificationSse implements MessageEvent { 7 | id: string; 8 | data: GetUserNotificationResult; 9 | type: string; 10 | 11 | retry: number; 12 | 13 | static async from(userNotification: UserNotification) { 14 | const notificationSse = new NotificationSse(); 15 | notificationSse.id = randomUUID(); 16 | notificationSse.data = await GetUserNotificationResult.from( 17 | userNotification, 18 | ); 19 | notificationSse.type = 'NOTIFICATION'; 20 | notificationSse.retry = 3 * 1000; 21 | return notificationSse; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/app/notification/dto/patch-notification-setting-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { NOTIFICATION_SETTING_STATUS } from '@app/notification/constants/notification.constants'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsEnum } from 'class-validator'; 4 | 5 | export class PatchNotificationSettingRequest { 6 | @ApiProperty({ 7 | enum: NOTIFICATION_SETTING_STATUS, 8 | example: NOTIFICATION_SETTING_STATUS.ON, 9 | description: '알림상태(ON|OFF)', 10 | required: true, 11 | }) 12 | @IsEnum(NOTIFICATION_SETTING_STATUS) 13 | status: NOTIFICATION_SETTING_STATUS; 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/app/notification/dto/v2-get-user-notifications-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { GetUserNotificationResult } from '@app/notification/dto/get-user-notification-result.dto'; 2 | import { NoOffsetPageResult } from '@common/util/no-offset-page-result'; 3 | import { Expose } from 'class-transformer'; 4 | import { ApiProperty } from '@nestjs/swagger'; 5 | 6 | export class V2GetUserNotificationsResponse extends NoOffsetPageResult { 7 | @Expose() 8 | @ApiProperty({ type: GetUserNotificationResult, isArray: true }) 9 | get data(): GetUserNotificationResult[] { 10 | return this._data; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/app/notification/entity/notification-contents.ts: -------------------------------------------------------------------------------- 1 | export interface NotificationContents { 2 | title: string; 3 | subTitle: string; 4 | } 5 | 6 | export interface GroupSucceedContents extends NotificationContents { 7 | groupArticleId: number; 8 | } 9 | 10 | export interface GroupFailedContents extends NotificationContents { 11 | groupArticleId: number; 12 | } 13 | 14 | export interface CommnetAddedContents extends NotificationContents { 15 | groupArticleId: number; 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/app/notification/event/comment-added.event.ts: -------------------------------------------------------------------------------- 1 | import { GroupArticle } from '@app/group-article/entity/group-article.entity'; 2 | import { Comment } from '@src/app/comment/entity/comment.entity'; 3 | 4 | export class CommentAddedEvent { 5 | groupArticle: GroupArticle; 6 | comment: Comment; 7 | 8 | constructor(groupArticle: GroupArticle, comment: Comment) { 9 | this.groupArticle = groupArticle; 10 | this.comment = comment; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/app/notification/event/group-failed.event.ts: -------------------------------------------------------------------------------- 1 | import { GroupArticle } from '@app/group-article/entity/group-article.entity'; 2 | 3 | export class GroupFailedEvent { 4 | groupArticle: GroupArticle; 5 | 6 | constructor(groupArticle: GroupArticle) { 7 | this.groupArticle = groupArticle; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/app/notification/event/group-succeed.event.ts: -------------------------------------------------------------------------------- 1 | import { GroupArticle } from '@app/group-article/entity/group-article.entity'; 2 | 3 | export class GroupSucceedEvent { 4 | groupArticle: GroupArticle; 5 | 6 | constructor(groupArticle: GroupArticle) { 7 | this.groupArticle = groupArticle; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/app/notification/exception/not-accessible.exception.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException } from '@nestjs/common'; 2 | 3 | export class NotAccessibleException extends ForbiddenException { 4 | constructor(message = '정보에 접근할 수 없습니다') { 5 | super({ status: 'NOT_ACCESSIBLE', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/notification/exception/notification-setting-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class NotificationSettingNotFoundException extends NotFoundException { 4 | constructor(message = '알림설정 데이터가 존재하지 않습니다') { 5 | super({ status: 'NOTIFICATION_SETTING_NOT_FOUND', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/notification/exception/user-notification-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class UserNotificationNotFoundException extends NotFoundException { 4 | constructor(message = '해당하는 알림 내역이 존재하지 않습니다') { 5 | super({ status: 'USER_NOTIFICATION_NOT_FOUND', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/notification/notification.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { NotificationController } from '@app/notification/notification.controller'; 3 | import { NotificationSettingRepository } from '@app/notification/repository/notification-setting.repository'; 4 | import { NotificationService } from '@app/notification/notification.service'; 5 | import { NotificationListener } from '@app/notification/notification.listener'; 6 | import { GroupApplicationModule } from '@app/group-application/group-application.module'; 7 | import { CommentModule } from '@app/comment/comment.module'; 8 | import { UserNotificationRepository } from '@app/notification/repository/user-notification.repository'; 9 | import { SseModule } from '@common/module/sse/sse.module'; 10 | 11 | @Module({ 12 | imports: [GroupApplicationModule, CommentModule, SseModule], 13 | controllers: [NotificationController], 14 | providers: [ 15 | NotificationService, 16 | NotificationSettingRepository, 17 | UserNotificationRepository, 18 | NotificationListener, 19 | ], 20 | }) 21 | export class NotificationModule {} 22 | -------------------------------------------------------------------------------- /backend/src/app/scrap/entity/scrap.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | Unique, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | import { User } from '@app/user/entity/user.entity'; 12 | import { Article } from '@app/group-article/entity/article.entity'; 13 | 14 | @Entity() 15 | @Unique('UNIQUE_USER_ID_ARTICLE_ID', ['userId', 'articleId']) 16 | export class Scrap { 17 | @PrimaryGeneratedColumn({ unsigned: true }) 18 | id: number; 19 | 20 | @Column({ unsigned: true }) 21 | userId: number; 22 | 23 | @ManyToOne(() => User, { lazy: true, createForeignKeyConstraints: false }) 24 | @JoinColumn({ referencedColumnName: 'id', name: 'user_id' }) 25 | user: Promise; 26 | 27 | @Column({ unsigned: true }) 28 | articleId: number; 29 | 30 | @ManyToOne(() => Article, { lazy: true }) 31 | @JoinColumn({ referencedColumnName: 'id', name: 'article_id' }) 32 | article: Promise
; 33 | 34 | @CreateDateColumn({ type: 'timestamp' }) 35 | createdAt: Date; 36 | 37 | @UpdateDateColumn({ type: 'timestamp' }) 38 | updatedAt: Date; 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/app/user/__test__/user.fixture.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@app/user/entity/user.entity'; 2 | import { faker } from '@faker-js/faker'; 3 | 4 | export const getUserFixture = (user: Partial = {}) => { 5 | const fixture = new User(); 6 | fixture.id = user.id || faker.datatype.number({ min: 1, max: 10000 }); 7 | fixture.userName = user.userName || faker.name.fullName(); 8 | fixture.githubUrl = user.githubUrl || faker.internet.url(); 9 | fixture.blogUrl = user.blogUrl || faker.internet.url(); 10 | fixture.description = user.description || faker.commerce.productDescription(); 11 | fixture.profileImage = user.profileImage || faker.internet.url(); 12 | fixture.socialId = user.socialId || '123'; 13 | fixture.socialType = user.socialType || 'GITHUB'; 14 | fixture.createdAt = new Date(); 15 | fixture.updatedAt = new Date(); 16 | return fixture; 17 | }; 18 | -------------------------------------------------------------------------------- /backend/src/app/user/dto/username-unique-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class UserNameUniqueRequest { 5 | @ApiProperty({ 6 | example: 'pythonstrup', 7 | description: '유저 이름', 8 | required: true, 9 | }) 10 | @IsString() 11 | userName: string; 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/app/user/dto/username-unique-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class UserNameUniqueResponse { 4 | @ApiProperty({ 5 | example: true, 6 | description: '이미 점유된 닉네임인지 불리언 값으로 알려줌', 7 | required: true, 8 | }) 9 | isOccupied: boolean; 10 | 11 | static from(isOccupied: boolean) { 12 | const response = new UserNameUniqueResponse(); 13 | response.isOccupied = isOccupied; 14 | return response; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/app/user/exception/user-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class UserNotFoundException extends NotFoundException { 4 | constructor(message = '해당 유저가 존재하지 않습니다.') { 5 | super({ status: 'USER_NOT_FOUND', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/app/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserRepository } from '@app/user/user.repository'; 3 | import { UserService } from '@app/user/user.service'; 4 | import { UserController } from '@app/user/user.controller'; 5 | 6 | @Module({ 7 | controllers: [UserController], 8 | providers: [UserService, UserRepository], 9 | exports: [UserRepository], 10 | }) 11 | export class UserModule {} 12 | -------------------------------------------------------------------------------- /backend/src/app/user/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, IsNull, Repository } from 'typeorm'; 2 | import { User } from '@app/user/entity/user.entity'; 3 | import { Injectable } from '@nestjs/common'; 4 | 5 | @Injectable() 6 | export class UserRepository extends Repository { 7 | constructor(private readonly dataSource: DataSource) { 8 | const baseRepository = dataSource.getRepository(User); 9 | super( 10 | baseRepository.target, 11 | baseRepository.manager, 12 | baseRepository.queryRunner, 13 | ); 14 | } 15 | 16 | findBySocial(socialId: string, socialType: string) { 17 | return this.findOneBy({ socialId, socialType, deletedAt: IsNull() }); 18 | } 19 | 20 | findByUsername(userName: string) { 21 | return this.findOneBy({ userName }); 22 | } 23 | 24 | findById(id: number) { 25 | return this.findOneBy({ id, deletedAt: IsNull() }); 26 | } 27 | 28 | updateUser(user: User) { 29 | return this.update( 30 | { id: user.id }, 31 | { 32 | userName: user.userName, 33 | profileImage: user.profileImage, 34 | description: user.description, 35 | githubUrl: user.githubUrl, 36 | blogUrl: user.blogUrl, 37 | }, 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/app/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { UserRepository } from '@app/user/user.repository'; 3 | import { UserNotFoundException } from '@app/user/exception/user-not-found.exception'; 4 | 5 | @Injectable() 6 | export class UserService { 7 | constructor(private readonly userRepository: UserRepository) {} 8 | 9 | async checkUsernameUnique(userName: string) { 10 | const user = await this.userRepository.findByUsername(userName); 11 | return user ? true : false; 12 | } 13 | 14 | async findUserById(id: number) { 15 | const user = await this.userRepository.findById(id); 16 | if (!user) { 17 | throw new UserNotFoundException(); 18 | } 19 | return user; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/common/config/app/__test__/config.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { AppConfigService } from '../config.service'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppConfigModule } from '../config.module'; 4 | 5 | describe('App Config Service Test', () => { 6 | let appConfigService: AppConfigService; 7 | 8 | beforeEach(async () => { 9 | const module = await Test.createTestingModule({ 10 | imports: [AppConfigModule], 11 | }).compile(); 12 | 13 | appConfigService = module.get(AppConfigService); 14 | }); 15 | test('환경변수로 설정된 PORT 번호를 잘 가져오는가', async () => { 16 | // given 17 | 18 | // when 19 | 20 | // then 21 | expect(appConfigService.port).toEqual(parseInt(process.env.PORT, 10)); 22 | }); 23 | 24 | test('환경변수로 설정된 NODE_ENV를 잘 가져오는가', async () => { 25 | // given 26 | 27 | // when 28 | 29 | // then 30 | expect(appConfigService.env).toEqual(process.env.NODE_ENV); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /backend/src/common/config/app/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppConfigService } from './config.service'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { appConfig } from '@config/app/configuration'; 5 | 6 | @Module({ 7 | imports: [ConfigModule.forFeature(appConfig)], 8 | providers: [AppConfigService], 9 | exports: [AppConfigService], 10 | }) 11 | export class AppConfigModule {} 12 | -------------------------------------------------------------------------------- /backend/src/common/config/app/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ConfigType } from '@nestjs/config'; 3 | import { appConfig, NodeEnv } from '@config/app/configuration'; 4 | 5 | @Injectable() 6 | export class AppConfigService { 7 | constructor( 8 | @Inject(appConfig.KEY) 9 | private readonly appConfiguration: ConfigType, 10 | ) {} 11 | 12 | get port() { 13 | return this.appConfiguration.PORT; 14 | } 15 | 16 | get env() { 17 | return this.appConfiguration.NODE_ENV; 18 | } 19 | 20 | isDevelopment() { 21 | return this.env === NodeEnv.DEVELOPMENT; 22 | } 23 | 24 | isPrduction() { 25 | return this.env === NodeEnv.PRODUCTION; 26 | } 27 | 28 | isTest() { 29 | return this.env === NodeEnv.TEST; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/common/config/app/configuration.ts: -------------------------------------------------------------------------------- 1 | import { validate } from '@config/validate'; 2 | import { IsEnum, IsNumber } from 'class-validator'; 3 | import { Expose, Type } from 'class-transformer'; 4 | import { registerAs } from '@nestjs/config'; 5 | 6 | export enum NodeEnv { 7 | DEVELOPMENT = 'development', 8 | TEST = 'test', 9 | PRODUCTION = 'production', 10 | } 11 | 12 | export class AppConfig { 13 | @IsNumber() 14 | @Type(() => Number) 15 | @Expose() 16 | PORT: number; 17 | 18 | @IsEnum(NodeEnv) 19 | @Expose() 20 | NODE_ENV: NodeEnv; 21 | } 22 | 23 | export const appConfig = registerAs('APP', () => 24 | validate(process.env, AppConfig), 25 | ); 26 | -------------------------------------------------------------------------------- /backend/src/common/config/cookie/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { CookieConfigService } from '@config/cookie/config.service'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { cookieConfig } from '@config/cookie/configuration'; 5 | 6 | @Global() 7 | @Module({ 8 | imports: [ConfigModule.forFeature(cookieConfig)], 9 | providers: [CookieConfigService], 10 | exports: [CookieConfigService], 11 | }) 12 | export class CookieConfigModule {} 13 | -------------------------------------------------------------------------------- /backend/src/common/config/cookie/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { cookieConfig } from '@config/cookie/configuration'; 3 | import { ConfigType } from '@nestjs/config'; 4 | 5 | @Injectable() 6 | export class CookieConfigService { 7 | constructor( 8 | @Inject(cookieConfig.KEY) 9 | private readonly cookieConfiguration: ConfigType, 10 | ) {} 11 | 12 | get secure() { 13 | return this.cookieConfiguration.SECURE; 14 | } 15 | 16 | get sameSite() { 17 | if (this.cookieConfiguration.SECURE) return 'none'; 18 | 19 | return 'lax'; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/common/config/cookie/configuration.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { IsBoolean } from 'class-validator'; 3 | import { validate } from '@config/validate'; 4 | import { Expose } from 'class-transformer'; 5 | 6 | export class CookieConfig { 7 | @Expose() 8 | @IsBoolean() 9 | SECURE: boolean; 10 | } 11 | 12 | export const cookieConfig = registerAs('Cookie', () => { 13 | const { SECURE } = process.env; 14 | return validate({ SECURE: SECURE === 'true' }, CookieConfig); 15 | }); 16 | -------------------------------------------------------------------------------- /backend/src/common/config/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { TypeOrmConfigModule } from '@config/database/typeorm/config.module'; 4 | import { TypeOrmConfigService } from '@config/database/typeorm/config.service'; 5 | 6 | @Module({ 7 | imports: [ 8 | TypeOrmModule.forRootAsync({ 9 | imports: [TypeOrmConfigModule], 10 | useExisting: TypeOrmConfigService, 11 | }), 12 | ], 13 | }) 14 | export class DatabaseModule {} 15 | -------------------------------------------------------------------------------- /backend/src/common/config/database/mysql/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { MysqlConfigService } from '@config/database/mysql/config.service'; 4 | import { mysqlConfig } from '@config/database/mysql/configuration'; 5 | 6 | @Module({ 7 | imports: [ConfigModule.forFeature(mysqlConfig)], 8 | providers: [MysqlConfigService], 9 | exports: [MysqlConfigService], 10 | }) 11 | export class MysqlConfigModule {} 12 | -------------------------------------------------------------------------------- /backend/src/common/config/database/mysql/config.service.ts: -------------------------------------------------------------------------------- 1 | import { ConfigType } from '@nestjs/config'; 2 | import { Inject, Injectable } from '@nestjs/common'; 3 | import { mysqlConfig } from '@config/database/mysql/configuration'; 4 | 5 | @Injectable() 6 | export class MysqlConfigService { 7 | constructor( 8 | @Inject(mysqlConfig.KEY) 9 | private readonly mysqlConfiguration: ConfigType, 10 | ) {} 11 | 12 | get port() { 13 | return this.mysqlConfiguration.MYSQL_PORT; 14 | } 15 | 16 | get hostname() { 17 | return this.mysqlConfiguration.MYSQL_HOST; 18 | } 19 | 20 | get username() { 21 | return this.mysqlConfiguration.MYSQL_USER; 22 | } 23 | 24 | get password() { 25 | return this.mysqlConfiguration.MYSQL_PASSWORD; 26 | } 27 | 28 | get database() { 29 | return this.mysqlConfiguration.MYSQL_DATABASE; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/common/config/database/mysql/configuration.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | import { Expose, Type } from 'class-transformer'; 3 | import { validate } from '@config/validate'; 4 | import { registerAs } from '@nestjs/config'; 5 | 6 | export class MysqlConfig { 7 | @IsString() 8 | @Expose() 9 | MYSQL_HOST: string; 10 | 11 | @IsString() 12 | @Expose() 13 | MYSQL_DATABASE: string; 14 | 15 | @IsString() 16 | @Expose() 17 | MYSQL_USER: string; 18 | 19 | @IsString() 20 | @Expose() 21 | MYSQL_PASSWORD: string; 22 | 23 | @IsNumber() 24 | @Type(() => Number) 25 | @Expose() 26 | MYSQL_PORT: number; 27 | } 28 | 29 | export const mysqlConfig = registerAs('MYSQL', () => 30 | validate(process.env, MysqlConfig), 31 | ); 32 | -------------------------------------------------------------------------------- /backend/src/common/config/database/typeorm/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MysqlConfigModule } from '@config/database/mysql/config.module'; 3 | import { AppConfigModule } from '@config/app/config.module'; 4 | import { TypeOrmConfigService } from '@config/database/typeorm/config.service'; 5 | 6 | @Module({ 7 | imports: [MysqlConfigModule, AppConfigModule], 8 | providers: [TypeOrmConfigService], 9 | exports: [TypeOrmConfigService], 10 | }) 11 | export class TypeOrmConfigModule {} 12 | -------------------------------------------------------------------------------- /backend/src/common/config/github/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { GithubConfigService } from '@config/github/config.service'; 4 | import { githubConfig } from '@config/github/configuration'; 5 | 6 | @Module({ 7 | imports: [ConfigModule.forFeature(githubConfig)], 8 | providers: [GithubConfigService], 9 | exports: [GithubConfigService], 10 | }) 11 | export class GithubConfigModule {} 12 | -------------------------------------------------------------------------------- /backend/src/common/config/github/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ConfigType } from '@nestjs/config'; 3 | import { githubConfig } from '@config/github/configuration'; 4 | 5 | @Injectable() 6 | export class GithubConfigService { 7 | constructor( 8 | @Inject(githubConfig.KEY) 9 | private readonly githubConfiguration: ConfigType, 10 | ) {} 11 | 12 | get clientId() { 13 | return this.githubConfiguration.GITHUB_CLIENT_ID; 14 | } 15 | 16 | get clientSecret() { 17 | return this.githubConfiguration.GITHUB_CLIENT_SECRET; 18 | } 19 | 20 | get callbackUrl() { 21 | return this.githubConfiguration.GITHUB_CALLBACK_URL; 22 | } 23 | 24 | get redirectUrl() { 25 | return this.githubConfiguration.GITHUB_REDIRECT_URL; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/common/config/github/configuration.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | import { Expose } from 'class-transformer'; 3 | import { validate } from '@config/validate'; 4 | import { registerAs } from '@nestjs/config'; 5 | 6 | export class GithubConfig { 7 | @IsString() 8 | @Expose() 9 | GITHUB_CLIENT_ID: string; 10 | 11 | @IsString() 12 | @Expose() 13 | GITHUB_CLIENT_SECRET: string; 14 | 15 | @IsString() 16 | @Expose() 17 | GITHUB_CALLBACK_URL: string; 18 | 19 | @IsString() 20 | @Expose() 21 | GITHUB_REDIRECT_URL: string; 22 | } 23 | 24 | export const githubConfig = registerAs('GITHUB', () => 25 | validate(process.env, GithubConfig), 26 | ); 27 | -------------------------------------------------------------------------------- /backend/src/common/config/jwt/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { JwtConfigService } from '@config/jwt/config.service'; 4 | import { jwtConfig } from '@config/jwt/configuration'; 5 | 6 | @Module({ 7 | imports: [ConfigModule.forFeature(jwtConfig)], 8 | providers: [JwtConfigService], 9 | exports: [JwtConfigService], 10 | }) 11 | export class JwtConfigModule {} 12 | -------------------------------------------------------------------------------- /backend/src/common/config/jwt/config.service.ts: -------------------------------------------------------------------------------- 1 | import { ConfigType } from '@nestjs/config'; 2 | import { Inject, Injectable } from '@nestjs/common'; 3 | import { jwtConfig } from '@config/jwt/configuration'; 4 | 5 | @Injectable() 6 | export class JwtConfigService { 7 | constructor( 8 | @Inject(jwtConfig.KEY) 9 | private readonly jwtConfiguration: ConfigType, 10 | ) {} 11 | 12 | get secret() { 13 | return this.jwtConfiguration.JWT_SECRET; 14 | } 15 | 16 | get accessTokenExpirationMinutes() { 17 | return this.jwtConfiguration.JWT_ACCESS_TOKEN_EXPIRATION_MINUTES; 18 | } 19 | 20 | get refreshTokenExpirationDays() { 21 | return this.jwtConfiguration.JWT_REFRESH_TOKEN_EXPIRATION_DAYS; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/common/config/jwt/configuration.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | import { Expose, Type } from 'class-transformer'; 3 | import { validate } from '@config/validate'; 4 | import { registerAs } from '@nestjs/config'; 5 | 6 | export class JwtConfig { 7 | @IsString() 8 | @Expose() 9 | JWT_SECRET: string; 10 | 11 | @IsNumber() 12 | @Type(() => Number) 13 | @Expose() 14 | JWT_ACCESS_TOKEN_EXPIRATION_MINUTES: number; 15 | 16 | @IsNumber() 17 | @Type(() => Number) 18 | @Expose() 19 | JWT_REFRESH_TOKEN_EXPIRATION_DAYS: number; 20 | } 21 | 22 | export const jwtConfig = registerAs('JWT', () => 23 | validate(process.env, JwtConfig), 24 | ); 25 | -------------------------------------------------------------------------------- /backend/src/common/config/s3/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { S3ConfigService } from '@config/s3/config.service'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { s3Config } from '@config/s3/configuration'; 5 | 6 | @Module({ 7 | imports: [ConfigModule.forFeature(s3Config)], 8 | providers: [S3ConfigService], 9 | exports: [S3ConfigService], 10 | }) 11 | export class S3ConfigModule {} 12 | -------------------------------------------------------------------------------- /backend/src/common/config/s3/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ConfigType } from '@nestjs/config'; 3 | import { s3Config } from '@config/s3/configuration'; 4 | 5 | @Injectable() 6 | export class S3ConfigService { 7 | constructor( 8 | @Inject(s3Config.KEY) 9 | private readonly s3Configuration: ConfigType, 10 | ) {} 11 | 12 | get accessKey() { 13 | return this.s3Configuration.STORAGE_ACCESSKEY; 14 | } 15 | 16 | get secretKey() { 17 | return this.s3Configuration.STORAGE_SECRETKEY; 18 | } 19 | 20 | get region() { 21 | return this.s3Configuration.STORAGE_REGION; 22 | } 23 | 24 | get endpoint() { 25 | return this.s3Configuration.STORAGE_ENDPOINT; 26 | } 27 | 28 | get bucket() { 29 | return this.s3Configuration.STORAGE_BUCKET; 30 | } 31 | 32 | get path() { 33 | return this.s3Configuration.STORAGE_BUCKET_PATH; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/src/common/config/s3/configuration.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsUrl } from 'class-validator'; 2 | import { Expose } from 'class-transformer'; 3 | import { registerAs } from '@nestjs/config'; 4 | import { validate } from '@config/validate'; 5 | 6 | export class S3Config { 7 | @IsString() 8 | @Expose() 9 | STORAGE_ACCESSKEY: string; 10 | 11 | @IsString() 12 | @Expose() 13 | STORAGE_SECRETKEY: string; 14 | 15 | @IsString() 16 | @Expose() 17 | STORAGE_REGION: string; 18 | 19 | @IsUrl() 20 | @Expose() 21 | STORAGE_ENDPOINT: string; 22 | 23 | @IsString() 24 | @Expose() 25 | STORAGE_BUCKET: string; 26 | 27 | @IsString() 28 | @Expose() 29 | STORAGE_BUCKET_PATH: string; 30 | } 31 | 32 | export const s3Config = registerAs('S3', () => validate(process.env, S3Config)); 33 | -------------------------------------------------------------------------------- /backend/src/common/config/validate.ts: -------------------------------------------------------------------------------- 1 | import { plainToClass, ClassConstructor } from 'class-transformer'; 2 | import { validateSync } from 'class-validator'; 3 | 4 | export const validate = ( 5 | config: Record, 6 | envClass: ClassConstructor, 7 | ): T => { 8 | const validatedConfig = plainToClass(envClass, config, { 9 | enableImplicitConversion: true, 10 | excludeExtraneousValues: true, 11 | }); 12 | 13 | const errors = validateSync(validatedConfig, { 14 | skipMissingProperties: false, 15 | }); 16 | 17 | if (errors.length > 0) { 18 | throw new Error(errors.toString()); 19 | } 20 | 21 | return validatedConfig; 22 | }; 23 | -------------------------------------------------------------------------------- /backend/src/common/decorator/api-success-resposne.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, HttpCode, HttpStatus, Type } from '@nestjs/common'; 2 | import { ApiProperty, ApiResponse } from '@nestjs/swagger'; 3 | import { ResponseEntity } from '../response-entity'; 4 | 5 | export function ApiSuccessResponse( 6 | status: HttpStatus, 7 | dataType: Type = String, 8 | { isArray } = { isArray: false }, 9 | ) { 10 | class Temp extends ResponseEntity { 11 | @ApiProperty({ 12 | type: dataType, 13 | example: dataType === String ? '' : () => dataType, 14 | isArray, 15 | }) 16 | get data() { 17 | return super.data; 18 | } 19 | } 20 | 21 | Object.defineProperty(Temp, 'name', { 22 | value: `ResponseEntity<${dataType.name}${isArray ? 'Array' : ''}>`, 23 | }); 24 | 25 | return applyDecorators( 26 | HttpCode(status), 27 | ApiResponse({ 28 | type: HttpStatus.NO_CONTENT === status ? undefined : dataType && Temp, 29 | status, 30 | description: HttpStatus[status], 31 | }), 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/common/decorator/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const CurrentUser = createParamDecorator( 4 | (data: unknown, context: ExecutionContext) => { 5 | const request = context.switchToHttp().getRequest(); 6 | return request.user; 7 | }, 8 | ); 9 | -------------------------------------------------------------------------------- /backend/src/common/decorator/jwt-auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, UseGuards } from '@nestjs/common'; 2 | import { JwtAuthGuard } from '@common/guard/jwt-auth.guard'; 3 | import { ApiCookieAuth } from '@nestjs/swagger'; 4 | import { ApiErrorResponse } from '@decorator/api-error-response.decorator'; 5 | import { InvalidTokenException } from '@exception/invalid-token.exception'; 6 | 7 | export function JwtAuth() { 8 | return applyDecorators( 9 | UseGuards(JwtAuthGuard), 10 | ApiCookieAuth('cookieAuth'), 11 | ApiErrorResponse(InvalidTokenException), 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/common/dto/image-with-blur-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ImageWithBlurResponse { 4 | @ApiProperty({ 5 | example: 6 | 'https://kr.object.ncloudstorage.com/uploads/images/1669276833875-64adca9c-94cd-4162-a53f-f75e951e39db', 7 | description: '이미지 원본 url', 8 | required: true, 9 | }) 10 | originUrl: string; 11 | 12 | @ApiProperty({ 13 | example: 14 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAACXBIWXMAACE3AAAhNwEzWJ96AAAAP0lEQVR4nAE0AMv/APPz8v///9O8uOba1wDn4ea3v8qgn6bs5OMAtc6/AANBABtT6d/HANvn3ZawsdbO2fr59MePI7dvVudoAAAAAElFTkSuQmCC', 15 | description: 'blur 이미지 url', 16 | required: true, 17 | }) 18 | blurUrl: string; 19 | 20 | static from(thumbnail: string, blurThumbnail: string) { 21 | const res = new ImageWithBlurResponse(); 22 | res.originUrl = thumbnail; 23 | res.blurUrl = blurThumbnail === '' ? thumbnail : blurThumbnail; 24 | return res; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/common/exception/api-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class ApiNotFoundException extends NotFoundException { 4 | constructor(message = '해당하는 API가 존재하지 않습니다') { 5 | super({ status: 'API_NOT_FOUND', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/common/exception/bad-parameter.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | export class BadParameterException extends BadRequestException { 4 | constructor(message = '입력값이 올바르지 않습니다') { 5 | super({ status: 'BAD_PARAMETER', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/common/exception/invalid-token.exception.ts: -------------------------------------------------------------------------------- 1 | import { UnauthorizedException } from '@nestjs/common'; 2 | 3 | export class InvalidTokenException extends UnauthorizedException { 4 | constructor(message = '토큰정보가 유효하지 않습니다') { 5 | super({ status: 'INVALID_TOKEN', message }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/common/middleware/api-exception-logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import * as morgan from 'morgan'; 2 | import { Logger, NestMiddleware } from '@nestjs/common'; 3 | import { Request, Response } from 'express'; 4 | 5 | export class ApiExceptionLoggerMiddleware implements NestMiddleware { 6 | private readonly errorResponseFormat = `:ip - :method :url :status - :response-time ms`; 7 | 8 | private readonly logger = new Logger(ApiExceptionLoggerMiddleware.name); 9 | 10 | constructor() { 11 | morgan.token('ip', (req: Request) => req.ip); 12 | } 13 | 14 | use(req: any, res: any, next: (error?: any) => void): any { 15 | return morgan(this.errorResponseFormat, { 16 | skip: (_req: Request, _res: Response) => _res.statusCode < 400, 17 | stream: { 18 | write: (message) => this.logger.error(message.trim()), 19 | }, 20 | })(req, res, next); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/common/middleware/api-success-logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import * as morgan from 'morgan'; 2 | import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; 3 | import { Request } from 'express'; 4 | 5 | @Injectable() 6 | export class ApiSuccessLoggerMiddleware implements NestMiddleware { 7 | private readonly successResponseFormat = `:ip - :method :url :status :response-time ms - :res[content-length]`; 8 | 9 | private readonly logger = new Logger(ApiSuccessLoggerMiddleware.name); 10 | 11 | constructor() { 12 | morgan.token('ip', (req: Request) => req.ip); 13 | } 14 | 15 | use(req: any, res: any, next: (error?: any) => void) { 16 | return morgan(this.successResponseFormat, { 17 | skip: (_req, _res) => _res.statusCode >= 400, 18 | stream: { 19 | write: (message) => this.logger.log(message.trim()), 20 | }, 21 | })(req, res, next); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/common/module/jwt-token/jwt-token.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { JwtConfigModule } from '@config/jwt/config.module'; 4 | import { JwtConfigService } from '@config/jwt/config.service'; 5 | import { JwtTokenService } from '@common/module/jwt-token/jwt-token.service'; 6 | 7 | @Global() 8 | @Module({ 9 | imports: [ 10 | JwtConfigModule, 11 | JwtModule.registerAsync({ 12 | imports: [JwtConfigModule], 13 | useFactory: async (jwtConfigService: JwtConfigService) => ({ 14 | secret: jwtConfigService.secret, 15 | }), 16 | inject: [JwtConfigService], 17 | }), 18 | ], 19 | providers: [JwtTokenService], 20 | exports: [JwtTokenService], 21 | }) 22 | export class JwtTokenModule {} 23 | -------------------------------------------------------------------------------- /backend/src/common/module/jwt-token/type/auth-token-payload.ts: -------------------------------------------------------------------------------- 1 | import { TokenType } from '@common/module/jwt-token/type/token-type'; 2 | import { IsEnum, IsNumber, Min } from 'class-validator'; 3 | 4 | export class AuthTokenPayload { 5 | @IsNumber() 6 | @Min(1) 7 | userId: number; 8 | 9 | @IsEnum(TokenType) 10 | tokenType: TokenType; 11 | } 12 | -------------------------------------------------------------------------------- /backend/src/common/module/jwt-token/type/token-type.ts: -------------------------------------------------------------------------------- 1 | export enum TokenType { 2 | ACCESS = 'ACCESS', 3 | REFRESH = 'REFRESH', 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/common/module/sse/sse-type.ts: -------------------------------------------------------------------------------- 1 | export enum SseType { 2 | NOTIFICATION = 'NOTIFICATION', 3 | } 4 | -------------------------------------------------------------------------------- /backend/src/common/module/sse/sse.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SseService } from '@common/module/sse/sse.service'; 3 | 4 | @Module({ 5 | providers: [SseService], 6 | exports: [SseService], 7 | }) 8 | export class SseModule {} 9 | -------------------------------------------------------------------------------- /backend/src/common/module/sse/sse.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, MessageEvent } from '@nestjs/common'; 2 | import { EventEmitter } from 'events'; 3 | import { fromEvent } from 'rxjs'; 4 | import { User } from '@app/user/entity/user.entity'; 5 | 6 | @Injectable() 7 | export class SseService { 8 | private readonly eventEmitter: EventEmitter; 9 | 10 | constructor() { 11 | this.eventEmitter = new EventEmitter(); 12 | } 13 | 14 | subscribe(user: User) { 15 | return fromEvent(this.eventEmitter, `${user.id}`); 16 | } 17 | 18 | async emit(user: User, event: MessageEvent) { 19 | this.eventEmitter.emit(`${user.id}`, event); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/common/util/__test__/date-time.spec.ts: -------------------------------------------------------------------------------- 1 | import { DateTimeFormatter, LocalDateTime } from '@js-joda/core'; 2 | import { toDate, toLocalDateTime } from '../date-time'; 3 | 4 | describe('Date Time Utils Test', () => { 5 | describe('toDate', () => { 6 | test('LocalDateTime to Date', async () => { 7 | // given 8 | const dateString = '2022-09-01T15:00:00.000Z'; 9 | const localDateTime = LocalDateTime.parse( 10 | dateString, 11 | DateTimeFormatter.ISO_ZONED_DATE_TIME, 12 | ); 13 | 14 | // when 15 | const date = toDate(localDateTime); 16 | 17 | // then 18 | expect(date).toEqual(new Date(dateString)); 19 | expect(date.toISOString()).toEqual(dateString); 20 | }); 21 | }); 22 | 23 | describe('toLocalDateTime', () => { 24 | test('Date To LocalDateTime', async () => { 25 | // given 26 | const dateString = '2022-09-01T15:00:00.000Z'; 27 | const date = new Date(dateString); 28 | 29 | // when 30 | const localDateTime = toLocalDateTime(date); 31 | 32 | // then 33 | expect(localDateTime).toEqual( 34 | LocalDateTime.parse(dateString, DateTimeFormatter.ISO_ZONED_DATE_TIME), 35 | ); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /backend/src/common/util/__test__/page-result.spec.ts: -------------------------------------------------------------------------------- 1 | import { PageResult } from '@common/util/page-result'; 2 | 3 | describe('Paging Result Test', () => { 4 | test.each([ 5 | { currentPage: 1, countPerPage: 20, totalCount: 40, totalPage: 2 }, 6 | { currentPage: 1, countPerPage: 7, totalCount: 40, totalPage: 6 }, 7 | { currentPage: 1, countPerPage: 50, totalCount: 40, totalPage: 1 }, 8 | { currentPage: 1, countPerPage: 12, totalCount: 40, totalPage: 4 }, 9 | ])( 10 | 'currentPage=$currentPage, countPerPage=$countPerPAge, totalCount=$totalCount 이면 totalPage=$totalPage', 11 | async ({ countPerPage, currentPage, totalCount, totalPage }) => { 12 | // given 13 | const Test = class extends PageResult { 14 | get data() { 15 | return this._data; 16 | } 17 | }; 18 | // when 19 | const result = new Test(totalCount, currentPage, countPerPage, []); 20 | 21 | // then 22 | expect(result.totalPage).toEqual(totalPage); 23 | expect(result.currentPage).toEqual(currentPage); 24 | expect(result.countPerPage).toEqual(countPerPage); 25 | }, 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /backend/src/common/util/date-time.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convert, 3 | DateTimeFormatter, 4 | LocalDateTime, 5 | ZoneId, 6 | } from '@js-joda/core'; 7 | 8 | export const toDate = (date: LocalDateTime): Date => { 9 | return convert(date, ZoneId.UTC).toDate(); 10 | }; 11 | 12 | export const toLocalDateTime = (date: Date): LocalDateTime => { 13 | return LocalDateTime.parse( 14 | date.toISOString(), 15 | DateTimeFormatter.ISO_ZONED_DATE_TIME, 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /backend/src/common/util/get-blur-image.ts: -------------------------------------------------------------------------------- 1 | import { getPlaiceholder } from 'plaiceholder'; 2 | 3 | export const getBlurImage = async (thumbnail: string) => { 4 | let blurUrl = ''; 5 | try { 6 | const { base64 } = await getPlaiceholder(thumbnail); 7 | blurUrl = base64; 8 | } catch (e) {} 9 | 10 | return blurUrl; 11 | }; 12 | -------------------------------------------------------------------------------- /backend/src/common/util/no-offset-page-request.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber, IsOptional, Max, Min } from 'class-validator'; 3 | import { Type } from 'class-transformer'; 4 | 5 | export class NoOffsetPageRequest { 6 | @IsOptional() 7 | @IsNumber() 8 | @Type(() => Number) 9 | @Min(1) 10 | @Max(30) 11 | @ApiProperty({ 12 | type: Number, 13 | example: 10, 14 | description: '가져올 데이터 수(default: 10)', 15 | minimum: 1, 16 | maximum: 30, 17 | required: false, 18 | }) 19 | limit = 10; 20 | 21 | @IsOptional() 22 | @IsNumber() 23 | @Type(() => Number) 24 | @Min(1) 25 | @ApiProperty({ 26 | type: Number, 27 | example: 50, 28 | description: '다음 아이디', 29 | required: false, 30 | }) 31 | nextId?: number; 32 | } 33 | -------------------------------------------------------------------------------- /backend/src/common/util/page-request.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional, Min } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Type } from 'class-transformer'; 4 | 5 | export class PageRequest { 6 | @IsOptional() 7 | @IsNumber() 8 | @Type(() => Number) 9 | @Min(1) 10 | @ApiProperty({ 11 | type: Number, 12 | example: 1, 13 | description: '페이지 번호', 14 | required: false, 15 | }) 16 | currentPage = 1; 17 | 18 | @IsOptional() 19 | @IsNumber() 20 | @Type(() => Number) 21 | @ApiProperty({ 22 | type: Number, 23 | example: 10, 24 | description: '페이지당 데이터 개수', 25 | required: false, 26 | }) 27 | countPerPage = 10; 28 | 29 | getLimit() { 30 | return this.countPerPage; 31 | } 32 | 33 | getOffset() { 34 | return this.countPerPage * (this.currentPage - 1); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from '@src/app.module'; 3 | import { AppConfigService } from '@src/common/config/app/config.service'; 4 | import { setNestApp } from '@src/setNestApp'; 5 | import { setSwagger } from '@src/setSwagger'; 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule); 9 | 10 | setNestApp(app); 11 | 12 | const appConfigService = app.get(AppConfigService); 13 | 14 | if (appConfigService.isDevelopment()) { 15 | setSwagger(app); 16 | } 17 | 18 | await app.listen(appConfigService.port); 19 | } 20 | 21 | bootstrap(); 22 | -------------------------------------------------------------------------------- /backend/src/sse.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Sse } from '@nestjs/common'; 2 | import { SseService } from '@common/module/sse/sse.service'; 3 | import { JwtAuth } from '@decorator/jwt-auth.decorator'; 4 | import { CurrentUser } from '@decorator/current-user.decorator'; 5 | import { User } from '@app/user/entity/user.entity'; 6 | 7 | @Controller('sse') 8 | export class SseController { 9 | constructor(private readonly sseService: SseService) {} 10 | 11 | @Sse() 12 | @JwtAuth() 13 | sse(@CurrentUser() user: User) { 14 | return this.sseService.subscribe(user); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleNameMapper": { 3 | "^@src/(.*)$": "/../src/$1", 4 | "^@common/(.*)$": "/../src/common/$1", 5 | "^@middleware/(.*)$": "/../src/common/middleware/$1", 6 | "^@filter/(.*)$": "/../src/common/filter/$1", 7 | "^@config/(.*)$": "/../src/common/config/$1", 8 | "^@decorator/(.*)$": "/../src/common/decorator/$1", 9 | "^@exception/(.*)$": "/../src/common/exception/$1", 10 | "^@app/(.*)$": "/../src/app/$1" 11 | }, 12 | "moduleFileExtensions": [ 13 | "js", 14 | "json", 15 | "ts" 16 | ], 17 | "rootDir": ".", 18 | "testEnvironment": "node", 19 | "testRegex": ".e2e-spec.ts$", 20 | "transform": { 21 | "^.+\\.(t|j)s$": "ts-jest" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/test/utils/jwt-test.utils.ts: -------------------------------------------------------------------------------- 1 | export const setCookie = (accessToken: string) => { 2 | return `access_token=${accessToken}`; 3 | }; 4 | -------------------------------------------------------------------------------- /backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.paths.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/tsconfig.paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@src/*": [ 6 | "src/*" 7 | ], 8 | "@common/*": [ 9 | "src/common/*" 10 | ], 11 | "@middleware/*": [ 12 | "src/common/middleware/*" 13 | ], 14 | "@filter/*": [ 15 | "src/common/filter/*" 16 | ], 17 | "@config/*": [ 18 | "src/common/config/*" 19 | ], 20 | "@decorator/*": [ 21 | "src/common/decorator/*" 22 | ], 23 | "@exception/*": [ 24 | "src/common/exception/*" 25 | ], 26 | "@app/*": [ 27 | "src/app/*" 28 | ] 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/charts 15 | **/docker-compose* 16 | **/compose* 17 | **/Dockerfile* 18 | **/node_modules 19 | **/npm-debug.log 20 | **/obj 21 | **/secrets.dev.yaml 22 | **/values.dev.yaml 23 | README.md 24 | .next 25 | node_modules/ -------------------------------------------------------------------------------- /frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | .next 2 | next-env.d.ts 3 | node_modules 4 | yarn.lock 5 | package-lock.json 6 | public 7 | next.config.js 8 | README.md 9 | Dockerfile 10 | .nvmrc 11 | .vscode 12 | .idea 13 | .lighthouserc.js 14 | .lighthouseci 15 | lhci_reports -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env* 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | .vscode 40 | .lighthouseci 41 | /lhci_reports 42 | 43 | # storybook 44 | .storybook-static 45 | build-storybook.log -------------------------------------------------------------------------------- /frontend/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | FRONTEND_GIT_DIFFS=$(git diff --cached --name-only | { grep "frontend" || true; }) 5 | 6 | if [ -z $FRONTEND_GIT_DIFFS ]; then 7 | exit 0 8 | fi 9 | 10 | echo '🏗️👷 Before commit, Checking format, linting and typing your project.' 11 | 12 | cd frontend 13 | 14 | # Check tsconfig standards 15 | echo '🔎 Check typing...' 16 | npm run check-types || 17 | ( 18 | echo '🤡😂❌🤡 Failed Type check. 🤡😂❌🤡 19 | Are you seriously trying to write that? Make the changes required above.' 20 | false; 21 | ) 22 | 23 | npx lint-staged 24 | -------------------------------------------------------------------------------- /frontend/.lighthouserc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ci: { 3 | collect: { 4 | startServerCommand: 'npm run start', 5 | url: ['http://localhost:3000'], 6 | numberOfRuns: 5, 7 | }, 8 | upload: { 9 | target: 'filesystem', 10 | outputDir: './lhci_reports', 11 | reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%', 12 | }, 13 | }, 14 | }; 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | next-env.d.ts 3 | node_modules 4 | yarn.lock 5 | package-lock.json 6 | public 7 | next.config.js 8 | README.md 9 | Dockerfile 10 | .nvmrc 11 | .vscode 12 | .idea 13 | .lighthouserc.js 14 | .lighthouseci 15 | lhci_reports -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "es5", 7 | "arrowParens": "always", 8 | "bracketSpacing": true, 9 | "endOfLine": "auto" 10 | } 11 | -------------------------------------------------------------------------------- /frontend/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-essentials', 8 | '@storybook/addon-interactions', 9 | 'storybook-addon-next-router', 10 | { 11 | name: 'storybook-addon-next', 12 | options: { 13 | nextConfigPath: path.resolve(__dirname, '../next.config.js'), 14 | }, 15 | }, 16 | ], 17 | framework: '@storybook/react', 18 | core: { 19 | builder: 'webpack5', 20 | }, 21 | features: { 22 | interactionDebugger: true, 23 | }, 24 | staticDirs: ['../public'], 25 | webpackFinal: async (config) => { 26 | const rules = config.module.rules; 27 | const fileLoaderRule = rules.find((rule) => rule.test.test('.svg')); 28 | fileLoaderRule.exclude = /\.svg$/; 29 | rules.push({ test: /\.svg$/, use: ['@svgr/webpack'] }); 30 | return config; 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /frontend/.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { RouterContext } from 'next/dist/shared/lib/router-context'; 3 | import CommonStyles from '@styles/CommonStyles'; 4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 5 | 6 | const ThemeWrapper = (props: { children: ReactNode }) => { 7 | return {props.children}; 8 | }; 9 | 10 | export const parameters = { 11 | layout: 'fullscreen', 12 | nextRouter: { 13 | Provider: RouterContext.Provider, 14 | }, 15 | }; 16 | 17 | const queryClient = new QueryClient(); 18 | 19 | export const decorators = [ 20 | (renderStory: Function) => ( 21 | 22 | {renderStory()} 23 | 24 | ), 25 | ]; 26 | -------------------------------------------------------------------------------- /frontend/.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = async ({ config }) => { 4 | config.resolve.alias = { 5 | ...config.resolve.alias, 6 | '@public': path.resolve(__dirname, '../public'), 7 | '@styles': path.resolve(__dirname, '../src/styles'), 8 | }; 9 | config.module.rules.push({ 10 | test: /\.(sass|scss)$/, 11 | use: ['resolve-url-loader'], 12 | include: path.resolve(__dirname, '../'), 13 | }); 14 | config.module.rules.push({ 15 | test: /\.(png|woff|woff2|eot|ttf|svg)$/, 16 | use: [ 17 | { 18 | loader: 'file-loader', 19 | options: { 20 | name: '[name].[ext]', 21 | }, 22 | }, 23 | ], 24 | include: path.resolve(__dirname + '../'), 25 | }); 26 | return config; 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # node 16.18.1 이미지 사용 2 | FROM node:16.18.1-alpine 3 | 4 | # 작업 디렉토리 설정 5 | WORKDIR /usr/src/app 6 | 7 | # package.json, package-lock.json 복사 8 | COPY package*.json ./ 9 | 10 | # 의존성 설치 11 | RUN npm install 12 | 13 | # 프로젝트의 모든 파일 복사 14 | COPY . . 15 | 16 | # 이미지 빌드시 환경 변수 주입 사용 17 | RUN touch .env 18 | RUN echo -e "API_URL=https://api.moyeomoyeo.com\\nNEXT_PUBLIC_API_URL=https://api.moyeomoyeo.com" > .env 19 | 20 | # Build NextJS app 21 | RUN npm run build 22 | 23 | # 3000 포트 개방 24 | EXPOSE 3000 -------------------------------------------------------------------------------- /frontend/Dockerfile.development: -------------------------------------------------------------------------------- 1 | FROM node:16.18.1-alpine 2 | WORKDIR /usr/src/app 3 | COPY package*.json ./ 4 | RUN npm install 5 | COPY . . -------------------------------------------------------------------------------- /frontend/dev/default/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | 4 | error_log /var/log/nginx/error.log notice; 5 | pid /var/run/nginx.pid; 6 | 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | 13 | http { 14 | proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=STATIC:10m inactive=7d use_temp_path=off; 15 | include /etc/nginx/mime.types; 16 | default_type application/octet-stream; 17 | 18 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 19 | '$status $body_bytes_sent "$http_referer" ' 20 | '"$http_user_agent" "$http_x_forwarded_for"'; 21 | 22 | access_log /var/log/nginx/access.log main; 23 | 24 | sendfile on; 25 | #tcp_nopush on; 26 | 27 | keepalive_timeout 65; 28 | 29 | # gzip 설정 30 | gzip on; 31 | gzip_proxied any; 32 | gzip_comp_level 4; 33 | gzip_types text/css application/javascript image/svg+xml; 34 | 35 | include /etc/nginx/conf.d/*.conf; 36 | } -------------------------------------------------------------------------------- /frontend/docker-compose.local.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | 3 | services: 4 | frontend: 5 | container_name: moyeo-nextjs 6 | image: moyeo-frontend-dev 7 | environment: 8 | NODE_ENV: development 9 | ports: 10 | - 3000:3000 11 | command: npm run dev 12 | volumes: 13 | - .:/usr/src/app 14 | - /usr/src/app/node_modules 15 | storybook: 16 | container_name: moyeo-storybook 17 | image: moyeo-frontend-dev 18 | environment: 19 | NODE_ENV: development 20 | ports: 21 | - 6006:6006 22 | command: npm run storybook 23 | volumes: 24 | - .:/usr/src/app 25 | - /usr/src/app/node_modules 26 | -------------------------------------------------------------------------------- /frontend/fonts.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.woff2'; 2 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | webpack(config) { 6 | config.module.rules.push({ 7 | test: /\.svg$/i, 8 | use: ["@svgr/webpack"] 9 | }); 10 | config.watchOptions = { 11 | poll: 500, 12 | aggregateTimeout: 300, 13 | } 14 | return config; 15 | }, 16 | images: { 17 | domains: ["avatars.githubusercontent.com", "kr.object.ncloudstorage.com"], 18 | }, 19 | } 20 | 21 | module.exports = nextConfig 22 | -------------------------------------------------------------------------------- /frontend/public/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/android-icon-144x144.png -------------------------------------------------------------------------------- /frontend/public/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/android-icon-192x192.png -------------------------------------------------------------------------------- /frontend/public/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/android-icon-36x36.png -------------------------------------------------------------------------------- /frontend/public/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/android-icon-48x48.png -------------------------------------------------------------------------------- /frontend/public/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/android-icon-72x72.png -------------------------------------------------------------------------------- /frontend/public/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/android-icon-96x96.png -------------------------------------------------------------------------------- /frontend/public/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/apple-icon-114x114.png -------------------------------------------------------------------------------- /frontend/public/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/apple-icon-120x120.png -------------------------------------------------------------------------------- /frontend/public/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/apple-icon-144x144.png -------------------------------------------------------------------------------- /frontend/public/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/apple-icon-152x152.png -------------------------------------------------------------------------------- /frontend/public/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/apple-icon-180x180.png -------------------------------------------------------------------------------- /frontend/public/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/apple-icon-57x57.png -------------------------------------------------------------------------------- /frontend/public/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/apple-icon-60x60.png -------------------------------------------------------------------------------- /frontend/public/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/apple-icon-72x72.png -------------------------------------------------------------------------------- /frontend/public/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/apple-icon-76x76.png -------------------------------------------------------------------------------- /frontend/public/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/apple-icon-precomposed.png -------------------------------------------------------------------------------- /frontend/public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/apple-icon.png -------------------------------------------------------------------------------- /frontend/public/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/avatar.jpg -------------------------------------------------------------------------------- /frontend/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /frontend/public/default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/default.jpg -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/favicon-96x96.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/fonts/NanumSquareNeo-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/fonts/NanumSquareNeo-Bold.woff2 -------------------------------------------------------------------------------- /frontend/public/fonts/NanumSquareNeo-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/fonts/NanumSquareNeo-ExtraBold.woff2 -------------------------------------------------------------------------------- /frontend/public/fonts/NanumSquareNeo-Heavy.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/fonts/NanumSquareNeo-Heavy.woff2 -------------------------------------------------------------------------------- /frontend/public/fonts/NanumSquareNeo-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/fonts/NanumSquareNeo-Light.woff2 -------------------------------------------------------------------------------- /frontend/public/fonts/NanumSquareNeo-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/fonts/NanumSquareNeo-Regular.woff2 -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "\/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "\/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "\/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /frontend/public/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/ms-icon-144x144.png -------------------------------------------------------------------------------- /frontend/public/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/ms-icon-150x150.png -------------------------------------------------------------------------------- /frontend/public/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/ms-icon-310x310.png -------------------------------------------------------------------------------- /frontend/public/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web13-moyeomoyeo/399ce792c9434ddc800d034523e86cdd607c4df6/frontend/public/ms-icon-70x70.png -------------------------------------------------------------------------------- /frontend/scripts/dev-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo $2 | docker login -u $1 $3 --password-stdin 4 | 5 | echo "docker has been logged in" 6 | 7 | cd frontend 8 | 9 | touch .env 10 | 11 | echo -e $4 > .env 12 | 13 | # docker down 14 | docker compose down --rmi all --remove-orphans 15 | 16 | docker compose up -d --build -------------------------------------------------------------------------------- /frontend/scripts/dev-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 에러를 만나도 무중단으로 명령어 실행 4 | set +e 5 | 6 | # 기존의 개발 컨테이너 중지 및 제거 7 | docker stop moyeo-nextjs 8 | docker rm moyeo-nextjs 9 | docker stop moyeo-storybook 10 | docker rm moyeo-storybook 11 | 12 | # 이미지 제거 13 | docker rmi moyeo-frontend-dev 14 | 15 | 16 | # 개발용 이미지 빌드 17 | docker build -t moyeo-frontend-dev -f Dockerfile.development . 18 | 19 | # 개발용 컨테이너 실행 20 | docker-compose -f docker-compose.local.yml up -d -------------------------------------------------------------------------------- /frontend/src/apis/test/getTestData.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const getTestData = async (nextId: number) => { 4 | return axios.get(`https://testServer/test`, { 5 | params: { limit: 5, nextId }, 6 | }); 7 | }; 8 | 9 | export default getTestData; 10 | -------------------------------------------------------------------------------- /frontend/src/apis/test/getTestGroupArticles.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { Category } from '@constants/category'; 4 | import { Location } from '@constants/location'; 5 | 6 | const getTestGroupArticles = async ( 7 | nextId: number, 8 | category: Category, 9 | location: Location, 10 | progress: boolean 11 | ) => { 12 | return axios.get(`https://testServer/group-articles`, { 13 | params: { category, location, progress, nextId, limit: 15 }, 14 | }); 15 | }; 16 | 17 | export default getTestGroupArticles; 18 | -------------------------------------------------------------------------------- /frontend/src/apis/test/getTestMyGroupArticles.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const getTestMyGroupArticles = async (nextId: number) => { 4 | return axios.get('https://testServer/group-articles/me', { 5 | params: { nextId, limit: 5 }, 6 | }); 7 | }; 8 | 9 | export default getTestMyGroupArticles; 10 | -------------------------------------------------------------------------------- /frontend/src/components/article/ArticleEditor/ArticleEditor.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import ArticleEditor from '.'; 4 | 5 | export default { 6 | title: 'Component/ArticleEditor', 7 | component: ArticleEditor, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = () => ; 11 | 12 | export const Default = Template.bind({}); 13 | -------------------------------------------------------------------------------- /frontend/src/components/article/Comment/Comment.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import Comment from '.'; 4 | 5 | export default { 6 | title: 'Component/Comment', 7 | component: Comment, 8 | } as ComponentMeta; 9 | 10 | const mockComment = { 11 | id: 1, 12 | authorId: 2, 13 | authorName: 'J999_김캠퍼', 14 | authorProfileImage: 'https://avatars.githubusercontent.com/u/90585081?v=4"', 15 | contents: 16 | '좋은 글 잘 읽었습니다!좋은 글 잘 읽었습니다좋은 글 잘 읽었습니다좋은 글 잘 읽었습니다좋은 글 잘 읽었습니다', 17 | createdAt: '2022-11-23T08:19:33.899Z', 18 | }; 19 | 20 | const Template: ComponentStory = (args) => ; 21 | 22 | export const Default = Template.bind({}); 23 | Default.args = { 24 | comment: mockComment, 25 | }; 26 | -------------------------------------------------------------------------------- /frontend/src/components/article/Comment/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | const CommentWrapper = styled.div` 4 | width: 100%; 5 | padding: 1.6rem; 6 | display: flex; 7 | flex-direction: column; 8 | `; 9 | 10 | const CommentHeader = styled.div` 11 | display: flex; 12 | align-items: center; 13 | justify-content: space-between; 14 | margin-bottom: 0.8rem; 15 | `; 16 | 17 | const CommentAuthor = styled.div` 18 | display: flex; 19 | align-items: center; 20 | gap: 0.8rem; 21 | cursor: pointer; 22 | `; 23 | 24 | const CommentUtils = styled.div` 25 | display: flex; 26 | align-items: center; 27 | gap: 0.8rem; 28 | `; 29 | 30 | const CommentUtilItem = styled.div` 31 | cursor: pointer; 32 | `; 33 | 34 | const CommentContent = styled.div` 35 | width: 100%; 36 | `; 37 | 38 | export { 39 | CommentWrapper, 40 | CommentHeader, 41 | CommentAuthor, 42 | CommentUtils, 43 | CommentUtilItem, 44 | CommentContent, 45 | }; 46 | -------------------------------------------------------------------------------- /frontend/src/components/article/CommentInput/CommentInput.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import Header from '@components/common/Header'; 4 | import RootTitle from '@components/common/Header/RootTitle'; 5 | import PageLayout from '@components/common/PageLayout'; 6 | import { CommentType } from '@typings/types'; 7 | 8 | import CommentInput from '.'; 9 | 10 | export default { 11 | title: 'Component/CommentInput', 12 | component: CommentInput, 13 | } as ComponentMeta; 14 | 15 | const Template: ComponentStory = (args) => ; 16 | const PageTemplate: ComponentStory = (args) => ( 17 | } />} 19 | footer={ {}} />} 20 | > 21 | ); 22 | 23 | export const Default = Template.bind({}); 24 | export const PageSticky = PageTemplate.bind({}); 25 | -------------------------------------------------------------------------------- /frontend/src/components/article/CommentInput/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | const CommentInputWrapper = styled.div` 4 | padding: 1.6rem; 5 | width: 100%; 6 | position: sticky; 7 | bottom: 0; 8 | background-color: ${({ theme }) => theme.white}; 9 | border-top: 1px solid ${({ theme }) => theme.colors.gray[2]}; 10 | `; 11 | 12 | export { CommentInputWrapper }; 13 | -------------------------------------------------------------------------------- /frontend/src/components/article/ImageThumbnail/ImageThumbnail.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import ImageThumbnail from '.'; 4 | 5 | export default { 6 | title: 'Component/ImageThumbnail', 7 | component: ImageThumbnail, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | 12 | export const NoImageURL = Template.bind({}); 13 | -------------------------------------------------------------------------------- /frontend/src/components/article/ParticipantsModal/ParticipantItem/ParticipantItem.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import { dummyParticipants } from '@constants/dummy'; 4 | 5 | import ParticipantItem from '.'; 6 | 7 | export default { 8 | title: 'Component/ParticipantItem', 9 | component: ParticipantItem, 10 | } as ComponentMeta; 11 | 12 | const Template: ComponentStory = (args) => ; 13 | 14 | export const _ParticipantItem = Template.bind({}); 15 | _ParticipantItem.args = { 16 | participant: dummyParticipants[0], 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/components/article/ParticipantsModal/ParticipantsModal.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import { dummyParticipants } from '@constants/dummy'; 4 | 5 | import ParticipantsModal from '.'; 6 | 7 | export default { 8 | title: 'Component/ParticipantsModal', 9 | component: ParticipantsModal, 10 | } as ComponentMeta; 11 | 12 | const Template: ComponentStory = (args) => ( 13 | 14 | ); 15 | 16 | export const Default = Template.bind({}); 17 | Default.args = { 18 | participants: dummyParticipants, 19 | open: true, 20 | onClose: () => {}, 21 | }; 22 | 23 | export const NoParticipants = Template.bind({}); 24 | NoParticipants.args = { 25 | participants: [], 26 | open: true, 27 | onClose: () => {}, 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/src/components/article/ParticipateButton/ParticipateButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import { ParticipateButtonStatus } from '@constants/participateButton'; 4 | 5 | import ParticipateButton from '.'; 6 | 7 | export default { 8 | title: 'Component/ParticipateButton', 9 | component: ParticipateButton, 10 | } as ComponentMeta; 11 | 12 | const Template: ComponentStory = (args) => ( 13 | 14 | ); 15 | 16 | export const ApplyButton = Template.bind({}); 17 | ApplyButton.args = { 18 | status: ParticipateButtonStatus.APPLY, 19 | }; 20 | 21 | export const CancelButton = Template.bind({}); 22 | CancelButton.args = { 23 | status: ParticipateButtonStatus.CANCEL, 24 | }; 25 | 26 | export const ClosedButton = Template.bind({}); 27 | ClosedButton.args = { 28 | status: ParticipateButtonStatus.CLOSED, 29 | }; 30 | 31 | export const LinkButton = Template.bind({}); 32 | LinkButton.args = { 33 | status: ParticipateButtonStatus.LINK, 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/components/article/ParticipateButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@mantine/core'; 2 | 3 | import ApplyButton from '@components/article/ParticipateButton/ApplyButton'; 4 | import CancelButton from '@components/article/ParticipateButton/CancelButton'; 5 | import ChatLinkButton from '@components/article/ParticipateButton/ChatLinkButton'; 6 | import { ParticipateButtonStatus } from '@constants/participateButton'; 7 | 8 | interface Props { 9 | status: ParticipateButtonStatus; 10 | groupArticleId: number; 11 | chatRoomLink?: string; 12 | } 13 | 14 | const ParticipateButton = ({ status, groupArticleId, chatRoomLink = '' }: Props) => { 15 | switch (status) { 16 | case ParticipateButtonStatus.APPLY: 17 | return ; 18 | case ParticipateButtonStatus.CANCEL: 19 | return ; 20 | case ParticipateButtonStatus.CLOSED: 21 | return ( 22 | 25 | ); 26 | case ParticipateButtonStatus.LINK: 27 | return ; 28 | } 29 | }; 30 | 31 | export default ParticipateButton; 32 | -------------------------------------------------------------------------------- /frontend/src/components/common/AlertModal/AlertModal.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import AlertModal from '.'; 4 | 5 | export default { 6 | title: 'Component/AlertModal', 7 | component: AlertModal, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | 12 | export const _AlertModal = Template.bind({}); 13 | _AlertModal.args = { 14 | open: true, 15 | onClose: () => {}, 16 | message: '테스트 문구입니다', 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/components/common/AlertModal/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { Modal } from '@mantine/core'; 3 | 4 | interface Props { 5 | message: string; 6 | open: boolean; 7 | onClose: () => void; 8 | } 9 | 10 | const AlertModal = ({ message, open, onClose }: Props) => { 11 | return ( 12 | <> 13 | {open && ( 14 | 15 | {message} 16 | 확인 17 | 18 | )} 19 | 20 | ); 21 | }; 22 | 23 | export default AlertModal; 24 | 25 | const StyledModal = styled(Modal)` 26 | & .mantine-Modal-body { 27 | display: flex; 28 | flex-direction: column; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | `; 33 | 34 | const ModalContent = styled.div` 35 | padding: 1.6rem; 36 | text-align: center; 37 | `; 38 | 39 | const OkButton = styled.div` 40 | padding: 1.6rem; 41 | color: ${({ theme }) => theme.colors.indigo[7]}; 42 | background-color: ${({ theme }) => theme.white}; 43 | border: none; 44 | &:hover { 45 | cursor: pointer; 46 | } 47 | `; 48 | -------------------------------------------------------------------------------- /frontend/src/components/common/ArticleListLoading/index.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from '@mantine/core'; 2 | 3 | import useDeferredResponse from '@hooks/useDeferredResponse'; 4 | 5 | const ArticleListLoading = () => { 6 | const isDeferred = useDeferredResponse(); 7 | 8 | if (!isDeferred) { 9 | return <>; 10 | } 11 | 12 | return ( 13 | <> 14 | {new Array(8).fill(0).map((_, index) => ( 15 | 16 | ))} 17 | 18 | ); 19 | }; 20 | 21 | export default ArticleListLoading; 22 | -------------------------------------------------------------------------------- /frontend/src/components/common/ArticleTag/ArticleTag.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import ArticleTag from '.'; 4 | 5 | export default { 6 | title: 'Component/ArticleTag', 7 | component: ArticleTag, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | 12 | export const _ArticleTag = Template.bind({}); 13 | _ArticleTag.args = { 14 | color: 'lime', 15 | content: '제주', 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/components/common/ArticleTag/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { Badge } from '@mantine/core'; 3 | 4 | interface Props { 5 | /** 6 | * 뱃지의 색상을 결정합니다. 존재하지 않는 색상을 입력할 경우, indigo로 설정됩니다. 7 | */ 8 | color: string; 9 | /** 10 | * 뱃지에 들어갈 내용이 입력됩니다. 11 | */ 12 | content: string; 13 | size?: 'sm' | 'lg'; 14 | } 15 | 16 | const ArticleTag = ({ color, content, size = 'sm' }: Props) => { 17 | return ( 18 | 19 | 20 | {content} 21 | 22 | 23 | ); 24 | }; 25 | 26 | const BadgeWrapper = styled.div` 27 | width: fit-content; 28 | `; 29 | 30 | export default ArticleTag; 31 | -------------------------------------------------------------------------------- /frontend/src/components/common/Avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, forwardRef } from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import Image from '@components/common/Image'; 6 | 7 | const AVATAR_SIZES = { 8 | sm: 26, 9 | md: 38, 10 | lg: 56, 11 | xl: 84, 12 | }; 13 | 14 | interface Props extends ComponentProps { 15 | size: keyof typeof AVATAR_SIZES; 16 | } 17 | 18 | const Avatar = forwardRef(({ size, ...rest }, ref) => { 19 | return ( 20 | 21 | 28 | 29 | ); 30 | }); 31 | 32 | Avatar.displayName = 'Avatar'; 33 | 34 | const AvatarWrapper = styled.div` 35 | font-size: 0; 36 | `; 37 | 38 | const AvatarImage = styled(Image)` 39 | border-radius: 50%; 40 | `; 41 | 42 | export default Avatar; 43 | -------------------------------------------------------------------------------- /frontend/src/components/common/ConfirmModal/ConfirmModal.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import ConfirmModal from '.'; 4 | 5 | export default { 6 | title: 'Component/ConfirmModal', 7 | component: ConfirmModal, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | 12 | export const _ConfirmModal = Template.bind({}); 13 | _ConfirmModal.args = { 14 | open: true, 15 | message: '삭제하시겠습니까?', 16 | onConfirmButtonClick: () => {}, 17 | onCancelButtonClick: () => {}, 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/components/common/DropDown/DropDown.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import { CategoryKr } from '@constants/category'; 4 | 5 | import DropDown from '.'; 6 | 7 | export default { 8 | title: 'Component/DropDown', 9 | component: DropDown, 10 | } as ComponentMeta; 11 | 12 | const Template: ComponentStory = (args) => ; 13 | 14 | export const Default = Template.bind({}); 15 | Default.args = { 16 | label: '카테고리', 17 | placeholder: '카테고리 선택하기', 18 | data: Object.entries(CategoryKr).map(([key, value]) => ({ label: value, value: key })), 19 | required: true, 20 | }; 21 | -------------------------------------------------------------------------------- /frontend/src/components/common/DropDown/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { Select, SelectProps } from '@mantine/core'; 3 | 4 | interface Props extends SelectProps {} 5 | 6 | const DropDown = (props: Props) => { 7 | return ; 8 | }; 9 | 10 | const StyledSelect = styled(Select)` 11 | width: 100%; 12 | & .mantine-Select-item { 13 | padding: 1.6rem; 14 | &[data-selected] { 15 | &, 16 | &:hover { 17 | background-color: ${({ theme }) => theme.colors.indigo[0]}; 18 | color: ${({ theme }) => theme.colors.indigo[7]}; 19 | } 20 | } 21 | } 22 | 23 | & .mantine-Select-label { 24 | padding-bottom: 0.4rem; 25 | } 26 | `; 27 | 28 | export default DropDown; 29 | -------------------------------------------------------------------------------- /frontend/src/components/common/EmptyMessage/EmptyMessage.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import EmptyMessage from '.'; 4 | 5 | export default { 6 | title: 'Component/EmptyMessage', 7 | component: EmptyMessage, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | 12 | export const _EmptyMessage = Template.bind({}); 13 | _EmptyMessage.args = { 14 | target: 'article', 15 | large: false, 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/components/common/EmptyMessage/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | import { IconMoodEmpty } from '@tabler/icons'; 4 | 5 | interface Props { 6 | target: 'article' | 'participant' | 'notification'; 7 | large?: boolean; 8 | } 9 | 10 | const targetKr = { 11 | article: '모임이', 12 | participant: '신청자가', 13 | notification: '알림이', 14 | }; 15 | 16 | const EmptyMessage = ({ target, large }: Props) => { 17 | const { 18 | colors: { gray }, 19 | } = useTheme(); 20 | 21 | return ( 22 | 23 | 24 | {targetKr[target]} 존재하지 않아요 25 | 26 | ); 27 | }; 28 | 29 | export default EmptyMessage; 30 | 31 | const MessageWrapper = styled.div` 32 | flex: 1; 33 | display: flex; 34 | flex-direction: column; 35 | gap: 1.6rem; 36 | justify-content: center; 37 | align-items: center; 38 | `; 39 | 40 | const Message = styled.div<{ large: boolean }>` 41 | font-size: ${(props) => (props.large ? '2rem' : '1.5rem')}; 42 | font-weight: 900; 43 | color: ${({ theme }) => theme.colors.gray[6]}; 44 | `; 45 | -------------------------------------------------------------------------------- /frontend/src/components/common/ErrorBoundary/AuthErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, PropsWithChildren, useState } from 'react'; 2 | 3 | import RedirectHomeModal from '@components/common/RedirectHomeModal'; 4 | import AuthError from '@utils/errors/AuthError'; 5 | 6 | interface Props extends PropsWithChildren {} 7 | 8 | interface State { 9 | error: Error; 10 | } 11 | 12 | class AuthErrorBoundary extends Component { 13 | constructor(props: Props) { 14 | super(props); 15 | this.state = { 16 | error: null, 17 | }; 18 | } 19 | 20 | static getDerivedStateFromError(error: Error) { 21 | if (error instanceof AuthError) { 22 | return { error }; 23 | } 24 | throw error; 25 | } 26 | 27 | render() { 28 | const { error } = this.state; 29 | const { children } = this.props; 30 | if (error) { 31 | return ; 32 | } 33 | return children; 34 | } 35 | } 36 | 37 | export default AuthErrorBoundary; 38 | 39 | const AuthErrors = () => { 40 | const [open, setOpen] = useState(true); 41 | return ( 42 | setOpen(false)} 45 | message="로그인이 필요한 서비스입니다." 46 | /> 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /frontend/src/components/common/ErrorBoundary/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, PropsWithChildren } from 'react'; 2 | 3 | import ErrorMessage from '@components/common/ErrorMessage'; 4 | 5 | interface Props extends PropsWithChildren {} 6 | 7 | interface State { 8 | error: Error; 9 | } 10 | 11 | class ErrorBoundary extends Component { 12 | constructor(props: Props) { 13 | super(props); 14 | this.state = { 15 | error: null, 16 | }; 17 | } 18 | 19 | static getDerivedStateFromError(error: Error) { 20 | return { error }; 21 | } 22 | 23 | render() { 24 | const { error } = this.state; 25 | const { children } = this.props; 26 | if (error) { 27 | return ; 28 | } 29 | return children; 30 | } 31 | } 32 | 33 | export default ErrorBoundary; 34 | 35 | const ErrorFallback = () => { 36 | return ( 37 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /frontend/src/components/common/ErrorMessage/ErrorMessage.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import { ERROR_MESSAGE } from '@constants/error'; 4 | 5 | import ErrorMessage from '.'; 6 | 7 | export default { 8 | title: 'Component/ErrorMessage', 9 | component: ErrorMessage, 10 | } as ComponentMeta; 11 | 12 | const Template: ComponentStory = (args) => ; 13 | 14 | export const _404 = Template.bind({}); 15 | _404.args = { 16 | errorCode: 404, 17 | title: ERROR_MESSAGE['404'].title, 18 | description: ERROR_MESSAGE['404'].description, 19 | subDescription: ERROR_MESSAGE['404'].subDescription, 20 | }; 21 | 22 | export const _500 = Template.bind({}); 23 | _500.args = { 24 | errorCode: 500, 25 | title: ERROR_MESSAGE['500'].title, 26 | description: ERROR_MESSAGE['500'].description, 27 | subDescription: ERROR_MESSAGE['500'].subDescription, 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/src/components/common/FloatingButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from 'react'; 2 | 3 | import { Menu } from '@mantine/core'; 4 | import { useClickOutside } from '@mantine/hooks'; 5 | 6 | import { FABWrapper, StyledIconPlus } from './styles'; 7 | 8 | /** 9 | * FloatingButton의 자체의 UI 로직만 정의한 컴포넌트 10 | */ 11 | 12 | interface Props { 13 | /** 14 | * 플로팅 버튼을 눌렀을 때 나오는 요소들을 넣는다. 15 | */ 16 | children?: ReactNode; 17 | } 18 | 19 | const FloatingButton = ({ children }: Props) => { 20 | const [opened, setOpened] = useState(false); 21 | const ref = useClickOutside(() => setOpened(false)); 22 | 23 | return ( 24 | 25 | 26 | setOpened((o) => !o)} 32 | ref={ref} 33 | > 34 | 35 | 36 | 37 | {children} 38 | 39 | ); 40 | }; 41 | 42 | export default FloatingButton; 43 | -------------------------------------------------------------------------------- /frontend/src/components/common/FloatingButton/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ActionIcon, ActionIconProps, createPolymorphicComponent } from '@mantine/core'; 3 | import { IconPlus } from '@tabler/icons'; 4 | 5 | import { transientOptions } from '@styles/utils'; 6 | 7 | // https://mantine.dev/styles/styled/#polymorphic-components 8 | const _FABWrapper = styled(ActionIcon)` 9 | position: fixed; 10 | bottom: 7.2rem; 11 | right: 1.6rem; 12 | z-index: 200; 13 | @media screen and (min-width: 600px) { 14 | right: calc(50vw - 300px + 1.6rem); 15 | } 16 | `; 17 | 18 | const FABWrapper = createPolymorphicComponent<'button', ActionIconProps>(_FABWrapper); 19 | 20 | interface StyledIconPlusProps { 21 | $opened: boolean; 22 | } 23 | 24 | const StyledIconPlus = styled(IconPlus, transientOptions)` 25 | transition: transform 0.2s ease-in-out; 26 | ${({ $opened }) => $opened && `transform: rotate(45deg);`} 27 | `; 28 | 29 | export { FABWrapper, StyledIconPlus }; 30 | -------------------------------------------------------------------------------- /frontend/src/components/common/FloatingUtilButton/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import { Menu, Text } from '@mantine/core'; 4 | import { IconArrowAutofitUp, IconPencil } from '@tabler/icons'; 5 | 6 | import FloatingButton from '@components/common/FloatingButton'; 7 | import useFetchMyInfo from '@hooks/queries/useFetchMyInfo'; 8 | 9 | /** 10 | * FloatingButton과 11 | * FloatingButton을 눌렀을 때 나오는 Item들 12 | * Item 별로 필요한 로직들을 정의한다 13 | */ 14 | 15 | const FloatingUtilButton = () => { 16 | const { data: myData } = useFetchMyInfo(); 17 | return ( 18 | 19 | } 22 | onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} 23 | > 24 | 25 | 상단으로 이동 26 | 27 | 28 | {myData && ( 29 | 30 | }> 31 | 32 | 게시글 작성 33 | 34 | 35 | 36 | )} 37 | 38 | ); 39 | }; 40 | 41 | export default FloatingUtilButton; 42 | -------------------------------------------------------------------------------- /frontend/src/components/common/GroupArticleCard/GroupArticleCard.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import { ArticleStatus } from '@constants/article'; 4 | import { dummyArticlePreview } from '@constants/dummy'; 5 | 6 | import GroupArticleCard from '.'; 7 | 8 | export default { 9 | title: 'Component/GroupArticleCard', 10 | component: GroupArticleCard, 11 | } as ComponentMeta; 12 | 13 | const Template: ComponentStory = (args) => ; 14 | 15 | export const NormalCard = Template.bind({}); 16 | NormalCard.args = { article: dummyArticlePreview }; 17 | 18 | export const LongTitleCard = Template.bind({}); 19 | LongTitleCard.args = { 20 | article: { ...dummyArticlePreview, title: '길어지는 제목입니다. 제목이 길어서 잘려요.' }, 21 | }; 22 | 23 | export const ClosedCard = Template.bind({}); 24 | ClosedCard.args = { 25 | article: { ...dummyArticlePreview, status: ArticleStatus.SUCCEED }, 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/components/common/HeadMeta/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | 3 | import { NextSeo } from 'next-seo'; 4 | 5 | interface Props { 6 | title?: string; 7 | description?: string; 8 | url?: string; 9 | image?: string; 10 | } 11 | 12 | const HeadMeta = ({ title, description, url, image }: Props) => { 13 | return ( 14 | <> 15 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default HeadMeta; 42 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/DetailTitle/DetailTitle.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import DetailTitle from '.'; 4 | 5 | export default { 6 | title: 'Component/Layout/Header/HeaderItems/DetailTitle', 7 | component: DetailTitle, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | 12 | export const _DetailTitle = Template.bind({}); 13 | _DetailTitle.args = { 14 | title: '모임게시판', 15 | subTitle: '다양한 소모임을 위한 게시판', 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/DetailTitle/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | 3 | import { ActionIcon, Text, Title } from '@mantine/core'; 4 | import { IconChevronLeft } from '@tabler/icons'; 5 | 6 | import { DetailTitleTextWrapper, DetailTitleWrapper } from './styles'; 7 | 8 | interface Props { 9 | title: string; 10 | subTitle: string; 11 | } 12 | 13 | const DetailTitle = ({ title, subTitle }: Props) => { 14 | const router = useRouter(); 15 | return ( 16 | 17 | router.back()}> 18 | 19 | 20 | 21 | {title} 22 | 23 | {subTitle} 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default DetailTitle; 31 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/DetailTitle/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | const DetailTitleWrapper = styled.div` 4 | display: flex; 5 | align-items: center; 6 | gap: 1.2rem; 7 | `; 8 | 9 | const DetailTitleTextWrapper = styled.div` 10 | display: flex; 11 | flex-direction: column; 12 | gap: 0.4rem; 13 | `; 14 | 15 | export { DetailTitleWrapper, DetailTitleTextWrapper }; 16 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/LoginButton/LoginButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import LoginButton from '.'; 4 | 5 | export default { 6 | title: 'Component/Layout/Header/HeaderItems/LoginButton', 7 | component: LoginButton, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | 12 | export const _LoginButton = Template.bind({}); 13 | _LoginButton.args = {}; 14 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/LoginButton/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import { Button } from '@mantine/core'; 4 | 5 | const LoginButton = () => { 6 | return ( 7 | 8 | 11 | 12 | ); 13 | }; 14 | 15 | export default LoginButton; 16 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/RootTitle/RootTitle.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import RootTitle from '.'; 4 | 5 | export default { 6 | title: 'Component/Layout/Header/HeaderItems/RootTitle', 7 | component: RootTitle, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | export const _RootTitle = Template.bind({}); 12 | _RootTitle.args = { 13 | title: '모임게시판!!!!', 14 | subTitle: '다양한 소모임을 위한 게시판', 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/RootTitle/index.tsx: -------------------------------------------------------------------------------- 1 | import { Text, Title } from '@mantine/core'; 2 | 3 | import { RootTitleWrapper } from '@components/common/Header/RootTitle/styles'; 4 | 5 | interface Props { 6 | title: string; 7 | subTitle: string; 8 | } 9 | 10 | const RootTitle = ({ title, subTitle }: Props) => { 11 | return ( 12 | 13 | {title} 14 | 15 | {subTitle} 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default RootTitle; 22 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/RootTitle/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | const RootTitleWrapper = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | gap: 0.4rem; 7 | `; 8 | 9 | export { RootTitleWrapper }; 10 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/UtilButton/UtilButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import { Menu, Text } from '@mantine/core'; 4 | 5 | import UtilButton from '.'; 6 | 7 | export default { 8 | title: 'Component/Layout/Header/HeaderItems/UtilButton', 9 | component: UtilButton, 10 | } as ComponentMeta; 11 | 12 | const Template: ComponentStory = (args) => ( 13 | 14 | 15 | 16 | 로그아웃 17 | 18 | 19 | 20 | 21 | 이름이 엄청엄청 긴거 22 | 23 | 24 | 25 | ); 26 | export const _UtilButton = Template.bind({}); 27 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/UtilButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import { ActionIcon, Menu } from '@mantine/core'; 4 | import { IconDotsVertical } from '@tabler/icons'; 5 | 6 | interface Props { 7 | /** 8 | * Menu.Items로 감싸진 컴포넌트들을 넣어줍니다. 9 | * 필요한 이벤트들은 Menu.Items에 바인딩하여 넘겨주어야 합니다. 10 | * Menu.Item의 p는 md로 고정되도록 해야 디자인 시안에 맞습니다. 11 | * 예시는 스토리북의 Header/DetailFull을 참고하세요. 12 | */ 13 | children: ReactNode; 14 | } 15 | 16 | const UtilButton = ({ children }: Props) => { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | {children} 25 | 26 | ); 27 | }; 28 | 29 | export default UtilButton; 30 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import { HeaderWrapper } from '@components/common/Header/styles'; 4 | 5 | interface Props { 6 | /** 7 | * 왼쪽에 위치할 자식 요소를 전달합니다. 8 | */ 9 | leftNode: ReactNode; 10 | /** 11 | * 오른쪽에 위치할 자식 요소를 전달합니다. 12 | */ 13 | rightNode?: ReactNode; 14 | } 15 | 16 | const Header = ({ leftNode, rightNode }: Props) => { 17 | return ( 18 | 19 | {leftNode} 20 | {rightNode} 21 | 22 | ); 23 | }; 24 | export default Header; 25 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | const HeaderWrapper = styled.header` 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | padding: 0 1.6rem; 8 | height: 6.4rem; 9 | background-color: ${({ theme }) => theme.white}; 10 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.1); 11 | `; 12 | 13 | export { HeaderWrapper }; 14 | -------------------------------------------------------------------------------- /frontend/src/components/common/Image/index.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-named-default 2 | import { ImageProps, default as NextImage } from 'next/image'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | interface Props extends ImageProps { 6 | defaultImgUrl?: string; 7 | className?: string; 8 | } 9 | 10 | const defaultImgPath = '/default.jpg'; 11 | 12 | const Image = ({ src, defaultImgUrl = defaultImgPath, ...rest }: Props) => { 13 | const [imgSrc, setImgSrc] = useState(src); 14 | 15 | useEffect(() => { 16 | setImgSrc(src); 17 | }, [src]); 18 | return setImgSrc(defaultImgPath)} />; 19 | }; 20 | 21 | export default Image; 22 | -------------------------------------------------------------------------------- /frontend/src/components/common/Joiner/Joiner.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import Joiner from '.'; 4 | 5 | export default { 6 | title: 'Component/Joiner', 7 | component: Joiner, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | 12 | export const _Joiner = Template.bind({}); 13 | _Joiner.args = { 14 | components: [
test1
,
test2
], 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/components/common/Joiner/index.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, ReactNode } from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | interface JoinerProps { 6 | components: ReactNode[]; 7 | before?: boolean; 8 | after?: boolean; 9 | } 10 | 11 | export default function Joiner({ components, before, after }: JoinerProps) { 12 | return ( 13 | <> 14 | {before && } 15 | {components.length > 0 && 16 | components.reduce((prev, curr) => ( 17 | <> 18 | {prev} 19 | 20 | {curr} 21 | 22 | ))} 23 | {after && } 24 | 25 | ); 26 | } 27 | 28 | const Separator = styled.div` 29 | height: 0.1rem; 30 | background-color: ${({ theme }) => theme.colors.gray[2]}; 31 | `; 32 | -------------------------------------------------------------------------------- /frontend/src/components/common/LoginRedirect/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | 4 | import useFetchMyInfo from '@hooks/queries/useFetchMyInfo'; 5 | 6 | const LoginRedirect = () => { 7 | const { data, isLoading } = useFetchMyInfo(); 8 | 9 | const router = useRouter(); 10 | 11 | useEffect(() => { 12 | const authRequiredPaths = ['/my', '/notification', '/article', '/user']; 13 | 14 | if (!isLoading && !data && authRequiredPaths.some((path) => router.pathname.includes(path))) { 15 | void router.push('/login'); 16 | } 17 | }, [data, isLoading, router]); 18 | 19 | return <>; 20 | }; 21 | 22 | export default LoginRedirect; 23 | -------------------------------------------------------------------------------- /frontend/src/components/common/Modals/index.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, FunctionComponent } from 'react'; 2 | 3 | import loadable from '@loadable/component'; 4 | 5 | import useModals from '@hooks/useModals'; 6 | 7 | const ConfirmModal = loadable(() => import('@components/common/ConfirmModal'), { ssr: false }); 8 | const ParticipantsModal = loadable(() => import('@components/article/ParticipantsModal'), { 9 | ssr: false, 10 | }); 11 | 12 | export const modals = { 13 | confirm: ConfirmModal as FunctionComponent>, 14 | participants: ParticipantsModal as FunctionComponent>, 15 | }; 16 | 17 | const Modals = () => { 18 | const { modals } = useModals(); 19 | 20 | return ( 21 | <> 22 | {modals.map(({ Component, props }, idx) => { 23 | return ; 24 | })} 25 | 26 | ); 27 | }; 28 | 29 | export default Modals; 30 | -------------------------------------------------------------------------------- /frontend/src/components/common/NavigationTab/NavigationTab.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import NavigationTab from '.'; 4 | 5 | export default { 6 | title: 'Component/Layout/NavigationTab', 7 | component: NavigationTab, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | 12 | export const _NavigationTab = Template.bind({}); 13 | -------------------------------------------------------------------------------- /frontend/src/components/common/NotificationLoading/NotificationLoading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from '@mantine/core'; 2 | 3 | import useDeferredResponse from '@hooks/useDeferredResponse'; 4 | 5 | const NotificationLoading = () => { 6 | const isDeferred = useDeferredResponse(); 7 | 8 | if (!isDeferred) { 9 | return <>; 10 | } 11 | 12 | return ( 13 | <> 14 | {new Array(10).fill(0).map((_, index) => ( 15 | 16 | ))} 17 | 18 | ); 19 | }; 20 | 21 | export default NotificationLoading; 22 | -------------------------------------------------------------------------------- /frontend/src/components/common/NotificationToast/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | 3 | import useNotificationEvent from '@hooks/useNotificationEvent'; 4 | import { showToast } from '@utils/toast'; 5 | 6 | const NotificationToast = () => { 7 | const { isReady, pathname } = useRouter(); 8 | 9 | useNotificationEvent({ 10 | onNotification: (e) => { 11 | showToast({ title: '알림 도착!', message: '알림 페이지에서 내용을 확인해보세요.' }); 12 | }, 13 | enabled: isReady && pathname !== '/notification', 14 | }); 15 | 16 | return <>; 17 | }; 18 | 19 | export default NotificationToast; 20 | -------------------------------------------------------------------------------- /frontend/src/components/common/Profile/Profile.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import { dummyUser } from '@constants/dummy'; 4 | 5 | import Profile from '.'; 6 | 7 | export default { 8 | title: 'Component/Profile ', 9 | component: Profile, 10 | } as ComponentMeta; 11 | 12 | const Template: ComponentStory = (args) => ; 13 | 14 | export const _Profile = Template.bind({}); 15 | _Profile.args = { user: dummyUser }; 16 | -------------------------------------------------------------------------------- /frontend/src/components/common/RouterTransition/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | 4 | import { 5 | NavigationProgress, 6 | completeNavigationProgress, 7 | startNavigationProgress, 8 | } from '@mantine/nprogress'; 9 | 10 | const RouterTransition = () => { 11 | const router = useRouter(); 12 | useEffect(() => { 13 | const handleStart = (url: string) => url !== router.asPath && startNavigationProgress(); 14 | const handleComplete = () => completeNavigationProgress(); 15 | 16 | router.events.on('routeChangeStart', handleStart); 17 | router.events.on('routeChangeComplete', handleComplete); 18 | router.events.on('routeChangeError', handleComplete); 19 | 20 | return () => { 21 | router.events.off('routeChangeStart', handleStart); 22 | router.events.off('routeChangeComplete', handleComplete); 23 | router.events.off('routeChangeError', handleComplete); 24 | }; 25 | }, [router.asPath, router.events]); 26 | 27 | return ; 28 | }; 29 | 30 | export default RouterTransition; 31 | -------------------------------------------------------------------------------- /frontend/src/components/common/ScrollHandler/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | 4 | import { useRecoilState } from 'recoil'; 5 | 6 | import { scrollYAtom } from '@recoil/atoms'; 7 | 8 | const ScrollHandler = () => { 9 | const router = useRouter(); 10 | const [scrollY, setScrollY] = useRecoilState(scrollYAtom); 11 | 12 | useEffect(() => { 13 | const saveScrollY = (url: string) => { 14 | if (url.startsWith('/article')) { 15 | setScrollY(window.scrollY); 16 | return; 17 | } 18 | if (url !== '/') { 19 | setScrollY(0); 20 | } 21 | }; 22 | 23 | const restoreScrollY = (url: string) => { 24 | if (url === '/') { 25 | window.scrollTo({ top: scrollY }); 26 | setScrollY(0); 27 | } 28 | }; 29 | 30 | router.events.on('routeChangeStart', saveScrollY); 31 | router.events.on('routeChangeComplete', restoreScrollY); 32 | return () => { 33 | router.events.off('routeChangeStart', saveScrollY); 34 | router.events.off('routeChangeComplete', restoreScrollY); 35 | }; 36 | }, [router.events, scrollY, setScrollY]); 37 | 38 | return <>; 39 | }; 40 | 41 | export default ScrollHandler; 42 | -------------------------------------------------------------------------------- /frontend/src/components/common/StatCounter/StatCounter.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import StatCounter from '.'; 4 | 5 | export default { 6 | title: 'Component/StatCounter', 7 | component: StatCounter, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | 12 | export const Like = Template.bind({}); 13 | Like.args = { 14 | variant: 'like', 15 | count: 100, 16 | }; 17 | 18 | export const Comment = Template.bind({}); 19 | Comment.args = { 20 | variant: 'comment', 21 | count: 80, 22 | }; 23 | 24 | export const Scrap = Template.bind({}); 25 | Scrap.args = { 26 | variant: 'scrap', 27 | count: 75, 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/src/components/common/TextInput/TextInput.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import TextInput from '.'; 4 | 5 | export default { 6 | title: 'Component/TextInput', 7 | component: TextInput, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ; 11 | 12 | export const Default = Template.bind({}); 13 | Default.args = { 14 | label: '제목', 15 | placeholder: '제목을 입력해주세요.', 16 | required: true, 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/components/common/TextInput/index.tsx: -------------------------------------------------------------------------------- 1 | import { TextInput as MantineTextInput, TextInputProps } from '@mantine/core'; 2 | 3 | interface Props extends TextInputProps {} 4 | 5 | const TextInput = (props: Props) => { 6 | return ; 7 | }; 8 | 9 | export default TextInput; 10 | -------------------------------------------------------------------------------- /frontend/src/components/login/GitLoginButton/GitLoginButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | 3 | import GitLoginButton from '.'; 4 | 5 | export default { 6 | title: 'Component/GitLoginButton', 7 | component: GitLoginButton, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = () => ; 11 | 12 | export const _GitLoginButton = Template.bind({}); 13 | -------------------------------------------------------------------------------- /frontend/src/components/login/GitLoginButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | import { IconBrandGithub } from '@tabler/icons'; 4 | 5 | const GitLoginButton = () => { 6 | const { white } = useTheme(); 7 | 8 | const handleLoginButtonClick = async () => { 9 | window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/v1/auth/github/login`; 10 | }; 11 | 12 | return ( 13 | 14 | 15 | Github로 로그인 16 | 17 | ); 18 | }; 19 | 20 | const LoginButton = styled.button` 21 | display: flex; 22 | gap: 0.8rem; 23 | width: 34.2rem; 24 | height: 5.6rem; 25 | justify-content: center; 26 | align-items: center; 27 | background-color: ${({ theme }) => theme.colors.dark[9]}; 28 | border: none; 29 | border-radius: 8px; 30 | &:hover { 31 | cursor: pointer; 32 | background-color: ${({ theme }) => theme.colors.dark[4]}; 33 | } 34 | `; 35 | 36 | const GithubLoginText = styled.span` 37 | color: ${({ theme }) => theme.white}; 38 | font-size: 1.6rem; 39 | font-weight: 800; 40 | `; 41 | 42 | export default GitLoginButton; 43 | -------------------------------------------------------------------------------- /frontend/src/components/notification/NotificationIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import { ActionIcon } from '@mantine/core'; 2 | import { IconCheck, IconMessageCircle2, IconX } from '@tabler/icons'; 3 | 4 | import { Notification } from '@constants/notification'; 5 | import { NotificationType } from '@typings/types'; 6 | 7 | interface Props { 8 | variant: NotificationType['type']; 9 | } 10 | 11 | const NotificationIcon = ({ variant }: Props) => { 12 | const iconScheme = 13 | variant === Notification.COMMENT_ADDED 14 | ? { color: 'indigo', icon: } 15 | : variant === Notification.GROUP_SUCCEED 16 | ? { 17 | color: 'cyan', 18 | icon: , 19 | } 20 | : { 21 | color: 'red', 22 | icon: , 23 | }; 24 | return ( 25 | 26 | {iconScheme.icon} 27 | 28 | ); 29 | }; 30 | 31 | export default NotificationIcon; 32 | -------------------------------------------------------------------------------- /frontend/src/constants/article.ts: -------------------------------------------------------------------------------- 1 | enum ArticleStatus { 2 | PROGRESS = 'PROGRESS', 3 | SUCCEED = 'SUCCEED', 4 | FAIL = 'FAIL', 5 | } 6 | 7 | enum ArticleStatusKr { 8 | PROGRESS = '모집중', 9 | SUCCEED = '모집완료', 10 | FAIL = '모집중단', 11 | } 12 | 13 | export { ArticleStatus, ArticleStatusKr }; 14 | -------------------------------------------------------------------------------- /frontend/src/constants/category.ts: -------------------------------------------------------------------------------- 1 | enum Category { 2 | STUDY = 'STUDY', 3 | PROJECT = 'PROJECT', 4 | COMPETITION = 'COMPETITION', 5 | MEAL = 'MEAL', 6 | ETC = 'ETC', 7 | } 8 | 9 | enum CategoryKr { 10 | STUDY = '스터디', 11 | PROJECT = '프로젝트', 12 | COMPETITION = '공모전', 13 | MEAL = '식사', 14 | ETC = '기타', 15 | } 16 | 17 | export { Category, CategoryKr }; 18 | -------------------------------------------------------------------------------- /frontend/src/constants/error.ts: -------------------------------------------------------------------------------- 1 | const ERROR_MESSAGE = { 2 | 404: { 3 | title: '저희의 빈틈을 찾으셨군요', 4 | description: '여기는 그저 빈 페이지 입니다.', 5 | subDescription: '홈 페이지로 돌려보내드릴게요.', 6 | }, 7 | 500: { 8 | title: '에러가 발생했어요', 9 | description: '저희가 뭘 잘못 만들었나봐요.', 10 | subDescription: '금방 해결해드릴게요.', 11 | }, 12 | }; 13 | 14 | export { ERROR_MESSAGE }; 15 | -------------------------------------------------------------------------------- /frontend/src/constants/location.ts: -------------------------------------------------------------------------------- 1 | enum Location { 2 | ONLINE = 'ONLINE', 3 | SEOUL = 'SEOUL', 4 | INCHEON = 'INCHEON', 5 | BUSAN = 'BUSAN', 6 | DAEGU = 'DAEGU', 7 | GWANGJU = 'GWANGJU', 8 | DAEJEON = 'DAEJEON', 9 | ULSAN = 'ULSAN', 10 | SEJONG = 'SEJONG', 11 | GYEONGGI = 'GYEONGGI', 12 | GANGWON = 'GANGWON', 13 | CHUNGBUK = 'CHUNGBUK', 14 | CHUNGNAM = 'CHUNGNAM', 15 | JEONBUK = 'JEONBUK', 16 | JEONNAM = 'JEONNAM', 17 | GYEONGBUK = 'GYEONGBUK', 18 | GYEONGNAM = 'GYEONGNAM', 19 | JEJU = 'JEJU', 20 | } 21 | 22 | enum LocationKr { 23 | ONLINE = '온라인', 24 | SEOUL = '서울', 25 | INCHEON = '인천', 26 | BUSAN = '부산', 27 | DAEGU = '대구', 28 | GWANGJU = '광주', 29 | DAEJEON = '대전', 30 | ULSAN = '울산', 31 | SEJONG = '세종', 32 | GYEONGGI = '경기', 33 | GANGWON = '강원', 34 | CHUNGBUK = '충북', 35 | CHUNGNAM = '충남', 36 | JEONBUK = '전북', 37 | JEONNAM = '전남', 38 | GYEONGBUK = '경북', 39 | GYEONGNAM = '경남', 40 | JEJU = '제주', 41 | } 42 | 43 | export { Location, LocationKr }; 44 | -------------------------------------------------------------------------------- /frontend/src/constants/notification.ts: -------------------------------------------------------------------------------- 1 | enum Notification { 2 | COMMENT_ADDED = 'COMMENT_ADDED', 3 | GROUP_SUCCEED = 'GROUP_SUCCEED', 4 | GROUP_FAILED = 'GROUP_FAILED', 5 | } 6 | 7 | export { Notification }; 8 | -------------------------------------------------------------------------------- /frontend/src/constants/pageTitle.ts: -------------------------------------------------------------------------------- 1 | const PAGE_TITLE = { 2 | ARTICLE: { 3 | title: '모임게시판', 4 | subTitle: '다양한 소모임을 위한 게시판', 5 | }, 6 | NOTIFICATION: { 7 | title: '알림', 8 | subTitle: '놓친 소식들은 없는지 확인해보세요', 9 | }, 10 | MY: { 11 | title: '마이페이지', 12 | subTitle: '프로필 수정 & 알림 설정', 13 | }, 14 | EDIT_MY: { 15 | title: '프로필 수정', 16 | subTitle: '자신의 프로필을 수정해보세요', 17 | }, 18 | PARTICIPATED_GROUP: { 19 | title: '내가 참여한 모임', 20 | subTitle: '내가 참여한 모임들을 확인해보세요', 21 | }, 22 | OWN_GROUP: { 23 | title: '내가 작성한 모임', 24 | subTitle: '내가 작성한 모임들을 확인해보세요', 25 | }, 26 | USER: { 27 | title: '프로필', 28 | subTitle: '프로필 페이지', 29 | }, 30 | }; 31 | 32 | export { PAGE_TITLE }; 33 | -------------------------------------------------------------------------------- /frontend/src/constants/participateButton.ts: -------------------------------------------------------------------------------- 1 | enum ParticipateButtonStatus { 2 | 'APPLY' = 'APPLY', 3 | 'CANCEL' = 'CANCEL', 4 | 'CLOSED' = 'CLOSED', 5 | 'LINK' = 'LINK', 6 | } 7 | 8 | export { ParticipateButtonStatus }; 9 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useAddComment.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query'; 2 | 3 | import useAuthMutation from '@hooks/useAuthMutation'; 4 | import { CommentInputType } from '@typings/types'; 5 | import { clientAxios } from '@utils/commonAxios'; 6 | 7 | const addComment = (commentInput: CommentInputType) => 8 | clientAxios.post('/v1/comments', { 9 | ...commentInput, 10 | }); 11 | 12 | const useAddComment = (articleId: number) => { 13 | const queryClient = useQueryClient(); 14 | return useAuthMutation(addComment, { 15 | onSuccess: async () => { 16 | await queryClient.invalidateQueries(['comments', articleId]); 17 | }, 18 | }); 19 | }; 20 | 21 | export default useAddComment; 22 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useApplyGroup.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query'; 2 | 3 | import useAuthMutation from '@hooks/useAuthMutation'; 4 | import { clientAxios } from '@utils/commonAxios'; 5 | 6 | const applyGroup = (groupArticleId: number) => 7 | clientAxios.post('/v1/group-applications', { groupArticleId }); 8 | 9 | const useApplyGroup = (groupArticleId: number) => { 10 | const queryClient = useQueryClient(); 11 | return useAuthMutation(applyGroup, { 12 | onSuccess: () => { 13 | void queryClient.invalidateQueries(['applicationStatus', groupArticleId]); 14 | void queryClient.invalidateQueries(['participants', groupArticleId]); 15 | }, 16 | }); 17 | }; 18 | 19 | export default useApplyGroup; 20 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useCancelApplication.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query'; 2 | 3 | import useAuthMutation from '@hooks/useAuthMutation'; 4 | import { clientAxios } from '@utils/commonAxios'; 5 | 6 | const cancelApplication = (groupArticleId: number) => 7 | clientAxios.post('/v1/group-applications/cancel', { groupArticleId }); 8 | 9 | const useCancelApplication = (groupArticleId: number) => { 10 | const queryClient = useQueryClient(); 11 | return useAuthMutation(cancelApplication, { 12 | onSuccess: () => { 13 | void queryClient.invalidateQueries(['applicationStatus', groupArticleId]); 14 | void queryClient.invalidateQueries(['participants', groupArticleId]); 15 | }, 16 | }); 17 | }; 18 | 19 | export default useCancelApplication; 20 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useCancelRecruitment.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 | 3 | import { clientAxios } from '@utils/commonAxios'; 4 | 5 | const cancelRecruitment = (articleId: number) => 6 | clientAxios.post(`/v1/group-articles/${articleId}/recruitment-cancel`); 7 | 8 | const useCancelRecruitment = () => { 9 | const queryClient = useQueryClient(); 10 | return useMutation(cancelRecruitment, { 11 | onSuccess: (data, variables, context) => { 12 | void queryClient.invalidateQueries(['article', variables]); 13 | }, 14 | }); 15 | }; 16 | 17 | export default useCancelRecruitment; 18 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useCompleteRecruitment.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 | 3 | import { clientAxios } from '@utils/commonAxios'; 4 | 5 | const completeRecruitment = (articleId: number) => 6 | clientAxios.post(`/v1/group-articles/${articleId}/recruitment-complete`); 7 | 8 | const useCompleteRecruitment = () => { 9 | const queryClient = useQueryClient(); 10 | return useMutation(completeRecruitment, { 11 | onSuccess: (data, variables, context) => { 12 | void queryClient.invalidateQueries(['article', variables]); 13 | }, 14 | }); 15 | }; 16 | 17 | export default useCompleteRecruitment; 18 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useDeleteArticle.ts: -------------------------------------------------------------------------------- 1 | import useAuthMutation from '@hooks/useAuthMutation'; 2 | import { clientAxios } from '@utils/commonAxios'; 3 | 4 | const deleteArticle = (articleId: number) => clientAxios.delete(`/v1/group-articles/${articleId}`); 5 | 6 | const useDeleteArticle = () => { 7 | return useAuthMutation(deleteArticle); 8 | }; 9 | 10 | export default useDeleteArticle; 11 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useDeleteComment.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query'; 2 | 3 | import useAuthMutation from '@hooks/useAuthMutation'; 4 | import { clientAxios } from '@utils/commonAxios'; 5 | 6 | const deleteComment = (commentId: number) => clientAxios.delete(`/v1/comments/${commentId}`); 7 | 8 | const useDeleteComment = (articleId: number) => { 9 | const queryClient = useQueryClient(); 10 | return useAuthMutation(deleteComment, { 11 | onSuccess: async () => { 12 | await queryClient.invalidateQueries(['comments', articleId]); 13 | }, 14 | }); 15 | }; 16 | 17 | export default useDeleteComment; 18 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useDeleteNotification.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query'; 2 | 3 | import useAuthMutation from '@hooks/useAuthMutation'; 4 | import { clientAxios } from '@utils/commonAxios'; 5 | 6 | const deleteNotification = (notificationId: number) => 7 | clientAxios.delete(`/v1/notifications/${notificationId}`); 8 | 9 | const useDeleteNotification = () => { 10 | const queryClient = useQueryClient(); 11 | return useAuthMutation(deleteNotification, { 12 | onSuccess: () => { 13 | void queryClient.invalidateQueries(['notifications']); 14 | }, 15 | }); 16 | }; 17 | 18 | export default useDeleteNotification; 19 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useEditMyArticle.ts: -------------------------------------------------------------------------------- 1 | import useAuthMutation from '@hooks/useAuthMutation'; 2 | import { ArticlePostInputType } from '@typings/types'; 3 | import { clientAxios } from '@utils/commonAxios'; 4 | 5 | const updateArticle = ({ 6 | articleId, 7 | articleInput, 8 | }: { 9 | articleId: number; 10 | articleInput: ArticlePostInputType; 11 | }) => { 12 | const { title, contents, thumbnail, chatUrl } = articleInput; 13 | return clientAxios.put(`/v1/group-articles/${articleId}`, { 14 | title, 15 | contents, 16 | thumbnail, 17 | chatUrl, 18 | }); 19 | }; 20 | 21 | const useEditMyArticle = () => { 22 | return useAuthMutation(updateArticle); 23 | }; 24 | 25 | export default useEditMyArticle; 26 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useEditMyProfile.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query'; 2 | 3 | import useAuthMutation from '@hooks/useAuthMutation'; 4 | import { UserType } from '@typings/types'; 5 | import { clientAxios } from '@utils/commonAxios'; 6 | 7 | const updateProfile = (userProfile: Omit) => 8 | clientAxios.put('/v1/my-info', userProfile); 9 | 10 | const useEditMyProfile = () => { 11 | const queryClient = useQueryClient(); 12 | return useAuthMutation(updateProfile, { 13 | onSuccess: async () => { 14 | await queryClient.invalidateQueries(['my']); 15 | }, 16 | }); 17 | }; 18 | 19 | export default useEditMyProfile; 20 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useFetchApplicationStatus.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | import useAuthQuery from '@hooks/useAuthQuery'; 4 | import { ApiResponse } from '@typings/types'; 5 | import { clientAxios } from '@utils/commonAxios'; 6 | 7 | interface DataType { 8 | isJoined: boolean; 9 | } 10 | 11 | const useFetchApplicationStatus = (groupArticleId: number) => { 12 | const { data } = useAuthQuery, AxiosError, boolean>( 13 | ['applicationStatus', groupArticleId], 14 | () => clientAxios.get(`/v1/group-applications/status`, { params: { groupArticleId } }), 15 | { 16 | select: (res) => res.data.data.isJoined, 17 | } 18 | ); 19 | 20 | return { data }; 21 | }; 22 | 23 | export default useFetchApplicationStatus; 24 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useFetchArticle.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosResponse } from 'axios'; 2 | 3 | import useAuthQuery from '@hooks/useAuthQuery'; 4 | import { ArticleType } from '@typings/types'; 5 | import { clientAxios } from '@utils/commonAxios'; 6 | 7 | interface ResponseType { 8 | status: string; 9 | message: string; 10 | data: ArticleType; 11 | } 12 | 13 | const getSpecificArticle = async (id: number) => { 14 | return clientAxios.get(`/v1/group-articles/${id}`); 15 | }; 16 | 17 | const useFetchArticle = (id: number) => { 18 | const { data, isLoading } = useAuthQuery, AxiosError, ArticleType>( 19 | ['article', id], 20 | () => getSpecificArticle(id), 21 | { 22 | select: (res) => res.data.data, 23 | } 24 | ); 25 | 26 | return { data, isLoading }; 27 | }; 28 | 29 | export default useFetchArticle; 30 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useFetchChatUrl.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | import useAuthQuery from '@hooks/useAuthQuery'; 4 | import { ApiResponse } from '@typings/types'; 5 | import { clientAxios } from '@utils/commonAxios'; 6 | 7 | const useFetchChatUrl = (groupArticleId: number, enabled: boolean) => { 8 | const { data } = useAuthQuery, AxiosError, string>( 9 | ['chatUrl', groupArticleId], 10 | () => clientAxios.get(`/v1/group-articles/${groupArticleId}/chat-url`), 11 | { 12 | select: (res) => res.data.data.chatUrl, 13 | enabled, 14 | } 15 | ); 16 | 17 | return { url: data }; 18 | }; 19 | 20 | export default useFetchChatUrl; 21 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useFetchMyArticle.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | import useAuthQuery from '@hooks/useAuthQuery'; 4 | import { ApiResponse, MyArticleType } from '@typings/types'; 5 | import { clientAxios } from '@utils/commonAxios'; 6 | 7 | const getMyArticle = async (id: number) => { 8 | return clientAxios.get(`/v1/my-group-articles/${id}`); 9 | }; 10 | 11 | const useFetchMyArticle = (id: number) => { 12 | const { data, isLoading } = useAuthQuery, AxiosError, MyArticleType>( 13 | ['article', 'my', id], 14 | () => getMyArticle(id), 15 | { 16 | select: (res) => res.data.data, 17 | } 18 | ); 19 | return { data, isLoading }; 20 | }; 21 | 22 | export default useFetchMyArticle; 23 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useFetchMyInfo.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | import useGeneralQuery from '@hooks/useGeneralQuery'; 4 | import { ApiResponse, UserType } from '@typings/types'; 5 | import { clientAxios } from '@utils/commonAxios'; 6 | 7 | /** 8 | * 로그인 한 유저의 유저정보를 반환 9 | */ 10 | 11 | const useFetchMyInfo = () => { 12 | const { data, isLoading } = useGeneralQuery, AxiosError, UserType>( 13 | ['my'], 14 | () => clientAxios.get('/v1/my-info'), 15 | { 16 | select: (res) => res.data.data, 17 | retry: false, 18 | staleTime: 1000 * 60 * 4, 19 | } 20 | ); 21 | 22 | return { data, isLoading }; 23 | }; 24 | 25 | export default useFetchMyInfo; 26 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useFetchParticipants.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | import useAuthQuery from '@hooks/useAuthQuery'; 4 | import { ApiResponse, ParticipantType } from '@typings/types'; 5 | import { clientAxios } from '@utils/commonAxios'; 6 | 7 | interface ParticipantsResponseType { 8 | id: number; 9 | user: ParticipantType; 10 | } 11 | 12 | const useFetchParticipants = (groupArticleId: number) => { 13 | const { data } = useAuthQuery< 14 | ApiResponse, 15 | AxiosError, 16 | ParticipantType[] 17 | >( 18 | ['participants', groupArticleId], 19 | () => clientAxios.get(`/v1/group-applications/participants`, { params: { groupArticleId } }), 20 | { 21 | select: (res) => res.data.data.map((participant) => participant.user), 22 | } 23 | ); 24 | 25 | return { data }; 26 | }; 27 | 28 | export default useFetchParticipants; 29 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useFetchProfile.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosResponse } from 'axios'; 2 | 3 | import useAuthQuery from '@hooks/useAuthQuery'; 4 | import { UserType } from '@typings/types'; 5 | import { clientAxios } from '@utils/commonAxios'; 6 | 7 | const getUserProfile = async (id: number) => { 8 | return clientAxios.get(`/v1/users/${id}`, { 9 | params: { id }, 10 | withCredentials: true, 11 | }); 12 | }; 13 | 14 | interface ResponseType { 15 | status: string; 16 | message: string; 17 | data: UserType; 18 | } 19 | 20 | const useFetchProfile = (id: number) => { 21 | const { data, isLoading, isFetching } = useAuthQuery< 22 | AxiosResponse, 23 | AxiosError, 24 | UserType 25 | >(['profile', id], () => getUserProfile(id), { 26 | select: (res) => res.data.data, 27 | }); 28 | 29 | return { profile: data, isLoading, isFetching }; 30 | }; 31 | 32 | export default useFetchProfile; 33 | -------------------------------------------------------------------------------- /frontend/src/hooks/queries/useLogout.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query'; 2 | 3 | import useAuthMutation from '@hooks/useAuthMutation'; 4 | import { clientAxios } from '@utils/commonAxios'; 5 | 6 | const postLogout = () => { 7 | return clientAxios.post('/v1/auth/logout'); 8 | }; 9 | 10 | const useLogout = () => { 11 | const queryClient = useQueryClient(); 12 | return useAuthMutation(postLogout, { 13 | onSuccess: async () => { 14 | await queryClient.resetQueries(['my']); 15 | }, 16 | }); 17 | }; 18 | 19 | export default useLogout; 20 | -------------------------------------------------------------------------------- /frontend/src/hooks/useAsyncError.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | import RequestError from '@utils/errors/RequestError'; 4 | 5 | const useAsyncError = () => { 6 | const [, setError] = useState(); 7 | return useCallback( 8 | (msg: string) => { 9 | setError(() => { 10 | throw new RequestError(msg); 11 | }); 12 | }, 13 | [setError] 14 | ); 15 | }; 16 | 17 | export default useAsyncError; 18 | -------------------------------------------------------------------------------- /frontend/src/hooks/useAuthMutation.ts: -------------------------------------------------------------------------------- 1 | import { MutationFunction } from '@tanstack/query-core'; 2 | import { useMutation } from '@tanstack/react-query'; 3 | import { UseMutationOptions } from '@tanstack/react-query/src/types'; 4 | import { AxiosError } from 'axios'; 5 | 6 | import AuthError from '@utils/errors/AuthError'; 7 | import RequestError from '@utils/errors/RequestError'; 8 | 9 | const useAuthMutation = < 10 | TData = unknown, 11 | TError = AxiosError, 12 | TVariables = unknown, 13 | TContext = unknown 14 | >( 15 | mutationFunc: MutationFunction, 16 | options?: Omit, 'mutationFn'> 17 | ) => { 18 | const { error, ...rest } = useMutation( 19 | mutationFunc, 20 | options 21 | ); 22 | 23 | if (error && error instanceof AxiosError) { 24 | if (error.response.status === 401) { 25 | throw new AuthError(); 26 | } 27 | throw new RequestError(error.response.data.message); 28 | } 29 | 30 | return { ...rest }; 31 | }; 32 | 33 | export default useAuthMutation; 34 | -------------------------------------------------------------------------------- /frontend/src/hooks/useClipboard.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useClipboard = () => { 4 | const [isCopied, setIsCopied] = useState(false); 5 | 6 | const doCopy = (text: string) => { 7 | void navigator.clipboard.writeText(text).then(() => { 8 | setIsCopied(true); 9 | }); 10 | }; 11 | 12 | return { isCopied, setIsCopied, doCopy }; 13 | }; 14 | 15 | export default useClipboard; 16 | -------------------------------------------------------------------------------- /frontend/src/hooks/useDeferredResponse.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const useDeferredResponse = () => { 4 | const [isDeferred, setIsDeferred] = useState(false); 5 | 6 | useEffect(() => { 7 | const timeout = setTimeout(() => { 8 | setIsDeferred(true); 9 | }, 200); 10 | return () => clearTimeout(timeout); 11 | }, []); 12 | 13 | return isDeferred; 14 | }; 15 | 16 | export default useDeferredResponse; 17 | -------------------------------------------------------------------------------- /frontend/src/hooks/useIntersect.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | 3 | const useIntersect = ( 4 | onIntersect: (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void, 5 | options?: IntersectionObserverInit 6 | ) => { 7 | const ref = useRef(null); 8 | const callback = useCallback( 9 | (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => { 10 | entries.forEach((entry) => { 11 | if (entry.isIntersecting) onIntersect(entry, observer); 12 | }); 13 | }, 14 | [onIntersect] 15 | ); 16 | 17 | useEffect(() => { 18 | if (!ref.current) return; 19 | const observer = new IntersectionObserver(callback, options); 20 | observer.observe(ref.current); 21 | return () => observer.disconnect(); 22 | }, [ref, options, callback]); 23 | 24 | return ref; 25 | }; 26 | 27 | export default useIntersect; 28 | -------------------------------------------------------------------------------- /frontend/src/hooks/useModals.ts: -------------------------------------------------------------------------------- 1 | import { ComponentProps, FunctionComponent, useCallback } from 'react'; 2 | 3 | import { useRecoilState } from 'recoil'; 4 | 5 | import { modalsAtom } from '@recoil/atoms'; 6 | 7 | const useModals = () => { 8 | const [modals, setModals] = useRecoilState(modalsAtom); 9 | 10 | const openModal = useCallback( 11 | >(Component: T, props: Omit, 'open'>) => { 12 | setModals((modals) => [...modals, { Component, props: { ...props, open: true } }]); 13 | }, 14 | [setModals] 15 | ); 16 | 17 | const closeModal = useCallback( 18 | >(Component: T) => { 19 | setModals((modals) => modals.filter((modal) => modal.Component !== Component)); 20 | }, 21 | [setModals] 22 | ); 23 | 24 | return { 25 | modals, 26 | openModal, 27 | closeModal, 28 | }; 29 | }; 30 | 31 | export default useModals; 32 | -------------------------------------------------------------------------------- /frontend/src/hooks/useNotificationEvent.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import useFetchMyInfo from '@hooks/queries/useFetchMyInfo'; 4 | 5 | interface Props { 6 | onNotification: (e: MessageEvent) => void; 7 | enabled?: boolean; 8 | } 9 | 10 | const useNotificationEvent = ({ onNotification, enabled = true }: Props) => { 11 | const { data: myData } = useFetchMyInfo(); 12 | useEffect(() => { 13 | if (!myData) return; 14 | let sse: EventSource | null = null; 15 | try { 16 | sse = new EventSource(`${process.env.NEXT_PUBLIC_API_URL}/v1/sse`, { 17 | withCredentials: true, 18 | }); 19 | 20 | sse.addEventListener('NOTIFICATION', (e) => { 21 | if (enabled) onNotification(e); 22 | }); 23 | 24 | sse.onerror = (event) => { 25 | sse.close(); 26 | }; 27 | } catch (err) { 28 | throw Error('Server Sent Event Error'); 29 | } 30 | return () => { 31 | if (sse) sse.close(); 32 | }; 33 | }, [myData, onNotification, enabled]); 34 | }; 35 | 36 | export default useNotificationEvent; 37 | -------------------------------------------------------------------------------- /frontend/src/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw'; 2 | 3 | import { handlers } from './handlers'; 4 | 5 | export const worker = setupWorker(...handlers); 6 | -------------------------------------------------------------------------------- /frontend/src/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | import { getGroupArticles } from '@mocks/resolvers/getGroupArticles'; 4 | import { postTest } from '@mocks/resolvers/postTest'; 5 | 6 | import { getMyInfo } from './resolvers/getMyInfo'; 7 | import { getTest } from './resolvers/test'; 8 | 9 | export const handlers = [ 10 | rest.get('https://testServer/test', getTest), 11 | rest.get('https://testServer/group-articles', getGroupArticles), 12 | rest.get('https://testServer/group-articles/me', getGroupArticles), 13 | rest.get('https://testServer/my-info', getMyInfo), 14 | rest.post('https://testServer/post-test', postTest), 15 | ]; 16 | -------------------------------------------------------------------------------- /frontend/src/mocks/index.ts: -------------------------------------------------------------------------------- 1 | const initMockApi = async () => { 2 | if (typeof window === 'undefined') { 3 | const { server } = await import('@mocks/server'); 4 | server.listen(); 5 | } else { 6 | const { worker } = await import('@mocks/browser'); 7 | await worker.start(); 8 | } 9 | }; 10 | 11 | export default initMockApi; 12 | -------------------------------------------------------------------------------- /frontend/src/mocks/resolvers/getGroupArticles.ts: -------------------------------------------------------------------------------- 1 | import { dummyArticlePreview } from '@constants/dummy'; 2 | 3 | export const getGroupArticles = (req: any, res: any, ctx: any) => { 4 | const { searchParams } = req.url; 5 | const limit = Number(searchParams.get('limit')); 6 | const nextId = Number(searchParams.get('nextId')); 7 | 8 | const totalCount = dummyArticlesPreview.length; 9 | const totalPages = Math.round(totalCount / limit); 10 | 11 | return res( 12 | ctx.status(200), 13 | ctx.json({ 14 | articles: dummyArticlesPreview.slice(limit * nextId, limit * nextId + limit), 15 | isLast: totalPages - 1 === nextId, 16 | currentId: nextId, 17 | }) 18 | ); 19 | }; 20 | 21 | const dummyArticlesPreview = Array.from({ length: 20 }) 22 | .fill(0) 23 | .map((_, index) => ({ ...dummyArticlePreview, id: index })); 24 | -------------------------------------------------------------------------------- /frontend/src/mocks/resolvers/getMyInfo.ts: -------------------------------------------------------------------------------- 1 | import { dummyUser } from '@constants/dummy'; 2 | 3 | export const getMyInfo = (req: any, res: any, ctx: any) => { 4 | return res(ctx.status(200), ctx.json({ ...dummyUser })); 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/mocks/resolvers/postTest.ts: -------------------------------------------------------------------------------- 1 | import { dummyUser } from '@constants/dummy'; 2 | 3 | export const postTest = (req: any, res: any, ctx: any) => { 4 | return res(ctx.status(401), ctx.json({ ...dummyUser })); 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/mocks/resolvers/test.ts: -------------------------------------------------------------------------------- 1 | export const getTest = (req: any, res: any, ctx: any) => { 2 | const { searchParams } = req.url; 3 | const limit = Number(searchParams.get('limit')); 4 | const nextId = Number(searchParams.get('nextId')); 5 | 6 | const totalCount = testData.length; 7 | const totalPages = Math.round(totalCount / limit); 8 | 9 | return res( 10 | ctx.status(200), 11 | ctx.json({ 12 | dataArr: testData.slice(limit * nextId, limit * nextId + limit), 13 | isLast: totalPages - 1 === nextId, 14 | currentId: nextId, 15 | }) 16 | ); 17 | }; 18 | 19 | const testData = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15']; 20 | -------------------------------------------------------------------------------- /frontend/src/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | 3 | import { handlers } from './handlers'; 4 | 5 | export const server = setupServer(...handlers); 6 | -------------------------------------------------------------------------------- /frontend/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import ErrorMessage from '@components/common/ErrorMessage'; 2 | import { ERROR_MESSAGE } from '@constants/error'; 3 | 4 | const Custom404 = () => { 5 | const { title, description, subDescription } = ERROR_MESSAGE['404']; 6 | 7 | return ( 8 | 14 | ); 15 | }; 16 | 17 | export default Custom404; 18 | -------------------------------------------------------------------------------- /frontend/src/pages/500.tsx: -------------------------------------------------------------------------------- 1 | import ErrorMessage from '@components/common/ErrorMessage'; 2 | import { ERROR_MESSAGE } from '@constants/error'; 3 | 4 | const Custom500 = () => { 5 | const { title, description, subDescription } = ERROR_MESSAGE['500']; 6 | 7 | return ( 8 | 14 | ); 15 | }; 16 | 17 | export default Custom500; 18 | -------------------------------------------------------------------------------- /frontend/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Head, Html, Main, NextScript } from 'next/document'; 2 | 3 | import { createGetInitialProps } from '@mantine/next'; 4 | 5 | import FaviconConfig from '@components/common/FaviconConfig'; 6 | import HeadMeta from '@components/common/HeadMeta'; 7 | 8 | const getInitialProps = createGetInitialProps(); 9 | 10 | export default class _Document extends Document { 11 | static getInitialProps = getInitialProps; 12 | 13 | render() { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/recoil/atoms.ts: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | 3 | import { atom } from 'recoil'; 4 | import { v4 as uuid } from 'uuid'; 5 | 6 | const categoryAtom = atom({ 7 | key: `categoryAtom/${uuid()}`, 8 | default: null, 9 | }); 10 | 11 | const locationAtom = atom({ 12 | key: `locationAtom/${uuid()}`, 13 | default: null, 14 | }); 15 | 16 | const progressCheckedAtom = atom({ 17 | key: `progressCheckedAtom/${uuid()}`, 18 | default: false, 19 | }); 20 | 21 | const scrollYAtom = atom({ 22 | key: `scrollYAtom/${uuid()}`, 23 | default: 0, 24 | }); 25 | 26 | const modalsAtom = atom; props: Object }>>({ 27 | key: `modalsAtom/${uuid()}`, 28 | default: [], 29 | }); 30 | 31 | export { categoryAtom, locationAtom, progressCheckedAtom, scrollYAtom, modalsAtom }; 32 | -------------------------------------------------------------------------------- /frontend/src/styles/CommonStyles.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import { MantineProvider } from '@mantine/core'; 4 | import { NotificationsProvider } from '@mantine/notifications'; 5 | 6 | import CustomFonts from '@styles/CustomFont'; 7 | import GlobalStyles from '@styles/GlobalStyles'; 8 | import theme from '@styles/theme'; 9 | 10 | interface Props { 11 | children: ReactNode; 12 | } 13 | 14 | const CommonStyles = ({ children }: Props) => ( 15 | 16 | 17 | 18 | {children} 19 | 20 | ); 21 | 22 | export default CommonStyles; 23 | -------------------------------------------------------------------------------- /frontend/src/styles/GlobalStyles.tsx: -------------------------------------------------------------------------------- 1 | import { Global } from '@mantine/core'; 2 | 3 | const GlobalStyle = () => { 4 | return ( 5 | 16 | ); 17 | }; 18 | 19 | export default GlobalStyle; 20 | -------------------------------------------------------------------------------- /frontend/src/styles/global.css: -------------------------------------------------------------------------------- 1 | #__next { 2 | display: flex; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/styles/theme.ts: -------------------------------------------------------------------------------- 1 | import { MantineThemeOverride } from '@mantine/core'; 2 | 3 | const theme: MantineThemeOverride = { 4 | colorScheme: 'light', 5 | focusRing: 'auto', 6 | defaultRadius: 'sm', 7 | primaryColor: 'indigo', 8 | defaultGradient: { 9 | from: 'indigo', 10 | to: 'cyan', 11 | deg: 45, 12 | }, 13 | loader: 'oval', 14 | cursorType: 'pointer', 15 | fontFamily: 'NanumSquareNeo, sans-serif', 16 | lineHeight: 1.2, 17 | fontSizes: { 18 | xs: 10, 19 | sm: 12, 20 | md: 14, 21 | lg: 16, 22 | xl: 20, 23 | }, 24 | spacing: { 25 | xs: 4, 26 | sm: 8, 27 | md: 12, 28 | lg: 16, 29 | xl: 20, 30 | }, 31 | 32 | headings: { 33 | fontFamily: 'NanumSquareNeo, sans-serif', 34 | fontWeight: 900, 35 | sizes: { 36 | h1: { fontSize: 32, lineHeight: 1.2 }, 37 | h2: { fontSize: 24, lineHeight: 1.2 }, 38 | h3: { fontSize: 20, lineHeight: 1.2 }, 39 | h4: { fontSize: 16, lineHeight: 1.2 }, 40 | h5: { fontSize: 14, lineHeight: 1.2 }, 41 | h6: { fontSize: 12, lineHeight: 1.2 }, 42 | }, 43 | }, 44 | }; 45 | 46 | export default theme; 47 | -------------------------------------------------------------------------------- /frontend/src/styles/utils.ts: -------------------------------------------------------------------------------- 1 | import { CreateStyled } from '@emotion/styled'; 2 | 3 | const transientOptions: Parameters[1] = { 4 | shouldForwardProp: (propName: string) => !propName.startsWith('$'), 5 | }; 6 | 7 | export { transientOptions }; 8 | -------------------------------------------------------------------------------- /frontend/src/typings/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | import React = require('react'); 3 | export const ReactComponent: React.FunctionComponent>; 4 | const src: string; 5 | export default src; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/typings/emotion.d.ts: -------------------------------------------------------------------------------- 1 | import '@emotion/react'; 2 | import type { MantineTheme } from '@mantine/core'; 3 | 4 | declare module '@emotion/react' { 5 | export interface Theme extends MantineTheme {} 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/utils/commonAxios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const clientAxios = axios.create({ 4 | baseURL: process.env.NEXT_PUBLIC_API_URL, 5 | withCredentials: true, 6 | }); 7 | 8 | const serverAxios = axios.create({ 9 | baseURL: process.env.API_URL, 10 | withCredentials: true, 11 | }); 12 | 13 | export { clientAxios, serverAxios }; 14 | -------------------------------------------------------------------------------- /frontend/src/utils/dateTime.ts: -------------------------------------------------------------------------------- 1 | import { format, register } from 'timeago.js'; 2 | import ko from 'timeago.js/lib/lang/ko'; 3 | 4 | register('ko', ko); 5 | 6 | export default function dateTimeFormat(date: string | Date) { 7 | return format(date, 'ko'); 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/utils/errors/AuthError.ts: -------------------------------------------------------------------------------- 1 | class AuthError extends Error { 2 | constructor() { 3 | super('권한이 없습니다'); 4 | } 5 | } 6 | 7 | export default AuthError; 8 | -------------------------------------------------------------------------------- /frontend/src/utils/errors/GetError.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | class GetError extends AxiosError {} 4 | 5 | export default GetError; 6 | -------------------------------------------------------------------------------- /frontend/src/utils/errors/RequestError.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | class RequestError extends AxiosError {} 4 | 5 | export default RequestError; 6 | -------------------------------------------------------------------------------- /frontend/src/utils/toast.tsx: -------------------------------------------------------------------------------- 1 | import { showNotification } from '@mantine/notifications'; 2 | import { IconCheck } from '@tabler/icons'; 3 | 4 | interface ToastProps { 5 | title: string; 6 | message: string; 7 | } 8 | 9 | const showToast = ({ title, message }: ToastProps) => 10 | showNotification({ 11 | title, 12 | message, 13 | color: 'indigo', 14 | icon: , 15 | autoClose: 2000, 16 | styles: (theme) => ({ 17 | root: { 18 | paddingTop: '1.6rem', 19 | paddingBottom: '1.6rem', 20 | }, 21 | title: { 22 | fontSize: theme.fontSizes.lg, 23 | fontWeight: 700, 24 | }, 25 | }), 26 | }); 27 | 28 | export { showToast }; 29 | --------------------------------------------------------------------------------