├── .env.development ├── .env.production ├── .github └── workflows │ ├── dev.yml │ └── main.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── asset-license.md ├── config ├── env.js ├── jest │ ├── cssTransform.js │ └── fileTransform.js ├── modules.js ├── paths.js ├── pnpTs.js ├── webpack.config.js ├── webpack.config.server.js ├── webpack.config.serverless.js └── webpackDevServer.config.js ├── deploy-hook ├── .gitignore ├── handler.js ├── package.json ├── serverless.yml └── yarn.lock ├── deploy.config.json ├── docker └── redis │ ├── Dockerfile │ └── docker-compose.yml ├── package.json ├── public ├── favicon.ico ├── favicons │ ├── apple-icon-152x152.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon-96x96.png ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── scripts ├── activate ├── activate.dev ├── build.js ├── build.server.js ├── keepChunks.js ├── start.js └── test.js ├── serverless.yml ├── src ├── App.css ├── App.tsx ├── GlobalStyles.ts ├── components │ ├── auth │ │ ├── AuthEmailForm.tsx │ │ ├── AuthEmailSuccess.tsx │ │ ├── AuthForm.tsx │ │ ├── AuthModal.tsx │ │ ├── AuthSocialButton.tsx │ │ ├── AuthSocialButtonGroup.tsx │ │ └── __tests__ │ │ │ ├── AuthEmailSuccess.test.tsx │ │ │ ├── AuthForm.test.tsx │ │ │ └── __snapshots__ │ │ │ └── AuthForm.test.tsx.snap │ ├── base │ │ ├── BodyTransition.tsx │ │ ├── ConditionalBackground.tsx │ │ ├── FloatingHeader.tsx │ │ ├── Header.tsx │ │ ├── HeaderLogo.tsx │ │ ├── HeaderUserIcon.tsx │ │ ├── HeaderUserMenu.tsx │ │ ├── HeaderUserMenuItem.tsx │ │ ├── PageTemplate.tsx │ │ ├── ThemeToggleButton.tsx │ │ ├── __tests__ │ │ │ ├── HeaderLogo.test.tsx │ │ │ ├── HeaderUserIcon.test.tsx │ │ │ └── __snapshots__ │ │ │ │ └── HeaderUserIcon.test.tsx.snap │ │ └── hooks │ │ │ ├── useHeader.ts │ │ │ ├── useThemeEffect.ts │ │ │ └── useToggleTheme.ts │ ├── common │ │ ├── AdFeed.tsx │ │ ├── Button.tsx │ │ ├── DragDropUpload.tsx │ │ ├── EditRemoveGroup.tsx │ │ ├── FlatPostCard.tsx │ │ ├── FlatPostCardList.tsx │ │ ├── FollowButton.tsx │ │ ├── HideScroll.tsx │ │ ├── HorizontalTab.tsx │ │ ├── LabelInput.tsx │ │ ├── MarkdownEditor.tsx │ │ ├── MarkdownRender.tsx │ │ ├── OpaqueLayer.tsx │ │ ├── PaginateWithScroll.tsx │ │ ├── PasteUpload.tsx │ │ ├── PlainLink.tsx │ │ ├── PlainNavLink.tsx │ │ ├── PopupBase.tsx │ │ ├── PopupOKCancel.tsx │ │ ├── PostCard.tsx │ │ ├── PostCardGrid.tsx │ │ ├── PostLink.tsx │ │ ├── PrivatePostLabel.tsx │ │ ├── RatioImage.tsx │ │ ├── RequireLogin.tsx │ │ ├── RoundButton.tsx │ │ ├── ScrollingPagination.tsx │ │ ├── SelectableList.tsx │ │ ├── SetupAdFeed.tsx │ │ ├── Skeleton.tsx │ │ ├── SkeletonTexts.tsx │ │ ├── SorterButton.tsx │ │ ├── SpinnerBlock.tsx │ │ ├── StatusCode.tsx │ │ ├── Sticky.tsx │ │ ├── TagItem.tsx │ │ ├── TagList.tsx │ │ ├── ToggleSwitch.tsx │ │ ├── Typography.tsx │ │ ├── UserProfile.tsx │ │ ├── VLink.tsx │ │ ├── __tests__ │ │ │ ├── PopupOKCancel.test.tsx │ │ │ ├── PostLink.test.tsx │ │ │ ├── RequireLogin.test.tsx │ │ │ ├── SelectableList.test.tsx │ │ │ ├── SorterButton.test.tsx │ │ │ ├── UserProfile.test.tsx │ │ │ └── __snapshots__ │ │ │ │ └── PopupOKCancel.test.tsx.snap │ │ ├── atom-one-dark.css │ │ └── atom-one-light.css │ ├── error │ │ ├── ChunkErrorScreen.tsx │ │ ├── CrashErrorScreen.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── ErrorScreenTemplate.tsx │ │ ├── NetworkErrorScreen.tsx │ │ └── NotFoundError.tsx │ ├── home │ │ ├── HomeLayout.tsx │ │ ├── HomeMobileHeadExtra.tsx │ │ ├── HomeNoticeWidget.tsx │ │ ├── HomeRightFooter.tsx │ │ ├── HomeSidebar.tsx │ │ ├── HomeTab.tsx │ │ ├── HomeTagWidget.tsx │ │ ├── HomeWidget.tsx │ │ ├── TimeframePicker.tsx │ │ ├── hooks │ │ │ └── useTimeframe.ts │ │ └── utils │ │ │ └── timeframeMap.ts │ ├── main │ │ ├── MainPageTemplate.tsx │ │ ├── MainResponsive.tsx │ │ └── MainTemplate.tsx │ ├── policy │ │ ├── PolicyViewer.tsx │ │ └── policyData.ts │ ├── post │ │ ├── JobPositions.tsx │ │ ├── LinkedPostItem.tsx │ │ ├── LinkedPostList.tsx │ │ ├── MobileLikeButton.tsx │ │ ├── PostBanner.tsx │ │ ├── PostCommentItem.tsx │ │ ├── PostCommentsList.tsx │ │ ├── PostCommentsTemplate.tsx │ │ ├── PostCommentsWrite.tsx │ │ ├── PostContent.tsx │ │ ├── PostCustomBanner.tsx │ │ ├── PostHead.tsx │ │ ├── PostHtmlContent.tsx │ │ ├── PostLikeShareButtons.tsx │ │ ├── PostReplies.tsx │ │ ├── PostSeriesInfo.tsx │ │ ├── PostSkeleton.tsx │ │ ├── PostTags.tsx │ │ ├── PostTemplate.tsx │ │ ├── PostToc.tsx │ │ ├── PostViewerProvider.tsx │ │ └── __tests__ │ │ │ ├── LinkedPostItem.test.tsx │ │ │ ├── LinkedPostList.test.tsx │ │ │ ├── MobileLikeButton.test.tsx │ │ │ ├── PostCommentItem.test.tsx │ │ │ ├── PostCommentList.test.tsx │ │ │ ├── PostCommentsTemplate.test.tsx │ │ │ ├── PostCommentsWrite.test.tsx │ │ │ ├── PostContent.test.tsx │ │ │ ├── PostHead.test.tsx │ │ │ ├── PostLikeShareButtons.test.tsx │ │ │ ├── PostReplies.test.tsx │ │ │ ├── PostSeriesInfo.test.tsx │ │ │ └── PostsTags.test.tsx │ ├── postStats │ │ └── PostStats.tsx │ ├── readingList │ │ └── ReadingListTab.tsx │ ├── register │ │ ├── RegisterForm.tsx │ │ ├── RegisterTemplate.tsx │ │ └── __tests__ │ │ │ ├── RegisterForm.test.tsx │ │ │ ├── RegisterTemplate.test.tsx │ │ │ └── __snapshots__ │ │ │ ├── RegisterForm.test.tsx.snap │ │ │ └── RegisterTemplate.test.tsx.snap │ ├── saves │ │ ├── SavedPostItem.tsx │ │ ├── SavedPosts.tsx │ │ ├── SavesTemplate.tsx │ │ └── hooks │ │ │ └── useSavedPosts.ts │ ├── search │ │ ├── SearchInput.tsx │ │ ├── SearchResultInfo.tsx │ │ ├── SearchTemplate.tsx │ │ └── __tests__ │ │ │ └── SearchInput.test.tsx │ ├── setting │ │ ├── SettingEditButton.tsx │ │ ├── SettingEmailRow.tsx │ │ ├── SettingEmailRulesRow.tsx │ │ ├── SettingEmailSuccess.tsx │ │ ├── SettingInput.tsx │ │ ├── SettingRow.tsx │ │ ├── SettingRows.tsx │ │ ├── SettingSocialInfoRow.tsx │ │ ├── SettingTitleRow.tsx │ │ ├── SettingUnregisterRow.tsx │ │ ├── SettingUserProfile.tsx │ │ └── __tests__ │ │ │ └── SettingUserProfile.test.tsx │ ├── tags │ │ ├── DetailedTagItem.tsx │ │ ├── DetailedTagList.tsx │ │ └── TagDetail.tsx │ ├── user-integrate │ │ └── UserIntegrateTemplate.tsx │ ├── velog │ │ ├── DragSample.tsx │ │ ├── DraggableSeriesPostList.tsx │ │ ├── SeriesActionButtons.tsx │ │ ├── SeriesItem.tsx │ │ ├── SeriesList.tsx │ │ ├── SeriesPostItem.tsx │ │ ├── SeriesPostList.tsx │ │ ├── SeriesPostsTemplate.tsx │ │ ├── SeriesSorterAligner.tsx │ │ ├── SideArea.tsx │ │ ├── UserTagHorizontalList.tsx │ │ ├── UserTagVerticalList.tsx │ │ ├── UserTags.tsx │ │ ├── VelogAboutContent.tsx │ │ ├── VelogAboutEdit.tsx │ │ ├── VelogAboutRightButton.tsx │ │ ├── VelogPageTemplate.tsx │ │ ├── VelogResponsive.tsx │ │ ├── VelogSearchInput.tsx │ │ ├── VelogTab.tsx │ │ ├── __tests__ │ │ │ ├── SeriesActionButtons.test.tsx │ │ │ ├── SeriesItem.test.tsx │ │ │ ├── SeriesList.test.tsx │ │ │ ├── SeriesPostItem.test.tsx │ │ │ ├── SeriesPostList.test.tsx │ │ │ ├── SeriesPostsTemplate.test.tsx │ │ │ └── VelogAboutRightButton.test.tsx │ │ └── hooks │ │ │ └── useUserTags.ts │ └── write │ │ ├── AddLink.tsx │ │ ├── AskChangeEditor.tsx │ │ ├── EditorPanes.tsx │ │ ├── MarkdownPreview.tsx │ │ ├── PublishActionButtons.tsx │ │ ├── PublishPreview.tsx │ │ ├── PublishPrivacySetting.tsx │ │ ├── PublishScreenTemplate.tsx │ │ ├── PublishSection.tsx │ │ ├── PublishSeriesConfigButtons.tsx │ │ ├── PublishSeriesConfigTemplate.tsx │ │ ├── PublishSeriesCreate.tsx │ │ ├── PublishSeriesSection.tsx │ │ ├── PublishURLSetting.tsx │ │ ├── QuillEditor.tsx.temp │ │ ├── TagInput.tsx │ │ ├── TitleTextarea.tsx │ │ ├── Toolbar.tsx │ │ ├── WriteFooter.tsx │ │ ├── WriteMarkdownEditor.tsx │ │ └── __tests__ │ │ ├── AskChangeEditor.test.tsx │ │ ├── EditorPanes.test.tsx │ │ ├── MarkdownEditor.test.tsx │ │ ├── MarkdownPreview.test.tsx │ │ ├── PublishActionButtons.test.tsx │ │ ├── PublishPreview.test.tsx │ │ ├── PublishPrivacySetting.test.tsx │ │ ├── PublishScreenTemplate.test.tsx │ │ ├── PublishSection.test.tsx │ │ ├── PublishSeriesConfigButtons.test.tsx │ │ ├── PublishSeriesCreate.test.tsx │ │ ├── PublishSeriesSection.test.tsx │ │ ├── PublishURLSetting.test.tsx │ │ ├── QuillEditor.test.tsx.temp │ │ ├── TagInput.test.tsx │ │ ├── Toolbar.test.tsx │ │ ├── WriteFooter.test.tsx │ │ └── __snapshots__ │ │ ├── AskChangeEditor.test.tsx.snap │ │ ├── MarkdownEditor.test.tsx.snap │ │ ├── MarkdownPreview.test.tsx.snap │ │ ├── PublishActionButtons.test.tsx.snap │ │ ├── PublishPreview.test.tsx.snap │ │ ├── PublishPrivacySetting.test.tsx.snap │ │ ├── PublishScreenTemplate.test.tsx.snap │ │ ├── PublishSection.test.tsx.snap │ │ ├── PublishURLSetting.test.tsx.snap │ │ ├── TagInput.test.tsx.snap │ │ ├── Toolbar.test.tsx.snap │ │ └── WriteFooter.test.tsx.snap ├── containers │ ├── auth │ │ ├── AuthModalContainer.tsx │ │ └── __tests__ │ │ │ ├── AuthModalContainer.test.tsx │ │ │ └── __snapshots__ │ │ │ └── AuthModalContainer.test.tsx.snap │ ├── base │ │ ├── CommonPopup.tsx │ │ ├── Core.tsx │ │ └── hooks │ │ │ ├── useCrisp.tsx │ │ │ └── useUserLoader.tsx │ ├── etc │ │ ├── EmailChange.tsx │ │ └── EmailLogin.tsx │ ├── home │ │ ├── MainNoticeWidgetContainer.tsx │ │ └── MainTagWidgetContainer.tsx │ ├── post │ │ ├── HorizontalAd.tsx │ │ ├── HorizontalBanner.tsx │ │ ├── PostComments.tsx │ │ ├── PostCommentsWriteContainer.tsx │ │ ├── PostEditComment.tsx │ │ ├── PostRepliesContainer.tsx │ │ ├── PostViewer.tsx │ │ ├── RelatedPost.tsx │ │ ├── RelatedPostAd.tsx │ │ ├── RelatedPostsForGuest.tsx │ │ └── __tests__ │ │ │ ├── PostComments.test.tsx │ │ │ ├── PostCommentsWriteContainer.test.tsx │ │ │ └── PostViewer.test.tsx │ ├── register │ │ └── RegisterFormContainer.tsx │ ├── search │ │ ├── LargeSearchInput.tsx │ │ └── SearchResult.tsx │ ├── setting │ │ ├── SettingRowsContainer.tsx │ │ ├── SettingUserProfileContainer.tsx │ │ └── hooks │ │ │ ├── useChangeEmail.ts │ │ │ ├── useUnregister.ts │ │ │ ├── useUpdateEmailRules.ts │ │ │ ├── useUpdateSocialInfo.ts │ │ │ ├── useUpdateThumbnail.ts │ │ │ ├── useUserProfile.ts │ │ │ └── useVelogConfig.ts │ ├── tags │ │ ├── DetailedTagListContainer.tsx │ │ └── TagDetailContainer.tsx │ ├── velog │ │ ├── SeriesListContainer.tsx │ │ ├── SeriesPosts.tsx │ │ ├── UserPosts.tsx │ │ ├── UserProfileContainer.tsx │ │ ├── UserSearchedPosts.tsx │ │ ├── VelogAbout.tsx │ │ ├── VelogPageFallback.tsx │ │ ├── VelogSearchInputContainer.tsx │ │ ├── __tests__ │ │ │ ├── SeriesListContainer.test.tsx │ │ │ ├── SeriesPosts.test.tsx │ │ │ └── UserProfileContainer.test.tsx │ │ └── hooks │ │ │ └── useApplyVelogConfig.ts │ └── write │ │ ├── ActiveEditor.tsx │ │ ├── EditorPanesContainer.tsx │ │ ├── MarkdownEditorContainer.tsx │ │ ├── MarkdownPreviewContainer.tsx │ │ ├── PublishActionButtonsContainer.tsx │ │ ├── PublishCaptcha.tsx │ │ ├── PublishCaptchaContainer.tsx │ │ ├── PublishPreviewContainer.tsx │ │ ├── PublishPrivacySettingContainer.tsx │ │ ├── PublishScreen.tsx │ │ ├── PublishSeriesConfig.tsx │ │ ├── PublishSeriesList.tsx │ │ ├── PublishSeriesSectionContainer.tsx │ │ ├── PublishSettings.tsx │ │ ├── PublishURLSettingContainer.tsx │ │ ├── QuillEditorContainer.tsx.temp │ │ ├── TagInputContainer.tsx │ │ ├── __tests__ │ │ ├── ActiveEditor.test.tsx │ │ ├── MarkdownEditorContainer.test.tsx │ │ ├── PublishPreviewContainer.test.tsx │ │ ├── PublishPrivacySettingContainer.test.tsx │ │ ├── PublishScreen.test.tsx │ │ ├── PublishSeriesSectionContainer.test.tsx │ │ ├── PublishURLSettingContainer.test.tsx │ │ ├── QuillEditorContainer.test.tsx.temp │ │ ├── TagInputContainer.test.tsx │ │ └── __snapshots__ │ │ │ ├── ActiveEditor.test.tsx.snap │ │ │ └── TagInputContainer.test.tsx.snap │ │ └── hooks │ │ └── useSaveHotkey.ts ├── index.css ├── index.server.ts ├── index.tsx ├── lib │ ├── __tests__ │ │ └── utils.test.ts │ ├── api │ │ ├── apiClient.ts │ │ ├── auth.ts │ │ ├── files.ts │ │ └── jobs.ts │ ├── convertToMarkdown.ts.temp │ ├── crisp.ts │ ├── detectIOS.ts │ ├── graphql │ │ ├── UncachedApolloContext.tsx │ │ ├── __data__ │ │ │ ├── post.data.ts │ │ │ ├── series.data.ts │ │ │ └── user.data.ts │ │ ├── ad.ts │ │ ├── client.ts │ │ ├── notification.ts │ │ ├── post.ts │ │ ├── series.ts │ │ ├── tags.ts │ │ ├── types.d.ts │ │ └── user.ts │ ├── gtag.ts │ ├── heading.ts │ ├── hooks │ │ ├── useBoolean.tsx │ │ ├── useCFUpload.ts │ │ ├── useDidMount.ts │ │ ├── useInput.tsx │ │ ├── useInputs.tsx │ │ ├── useMounted.ts │ │ ├── useNotFound.ts │ │ ├── usePopup.tsx │ │ ├── usePrefetchPost.ts │ │ ├── usePreserveScroll.ts │ │ ├── useRequest.tsx │ │ ├── useRequireLogin.ts │ │ ├── useS3Upload.tsx │ │ ├── useScrollPagination.ts │ │ ├── useTheme.ts │ │ ├── useToggle.tsx │ │ ├── useTurnstile.tsx │ │ ├── useUpload.tsx │ │ └── useUser.tsx │ ├── jazzbar │ │ ├── Jazzbar.css │ │ ├── Jazzbar.tsx │ │ ├── JazzbarContext.tsx │ │ ├── index.tsx │ │ └── useJazzbar.tsx │ ├── katexWhitelist.ts │ ├── optimizeImage.ts │ ├── quill │ │ └── markdownShortcuts │ │ │ ├── formats │ │ │ └── hr.js │ │ │ └── index.js │ ├── remark │ │ ├── embedPlugin.ts │ │ └── prismPlugin.js │ ├── renderWithApollo.tsx │ ├── renderWithProviders.tsx │ ├── renderWithRedux.tsx │ ├── replacedModule.ts │ ├── share.ts │ ├── storage.ts │ ├── styles │ │ ├── media.ts │ │ ├── palette.ts │ │ ├── postStyles.ts │ │ ├── prismThemes.ts │ │ ├── responsive.ts │ │ ├── themes.ts │ │ ├── transitions.ts │ │ ├── utils.ts │ │ └── zIndexes.ts │ ├── utils.ts │ └── waitUntil.ts ├── logo copy.svg ├── logo.svg ├── modules │ ├── __tests__ │ │ ├── core.test.ts │ │ ├── header.test.ts │ │ ├── post.test.ts │ │ └── write.test.ts │ ├── core │ │ ├── actions.ts │ │ ├── index.ts │ │ ├── reducer.ts │ │ └── types.ts │ ├── darkMode.ts │ ├── error.ts │ ├── header.ts │ ├── home.ts │ ├── index.ts │ ├── post.ts │ ├── scroll.ts │ └── write.ts ├── pages │ ├── EmailChangePage.tsx │ ├── EmailLoginPage.tsx │ ├── NotFoundPage.tsx │ ├── PolicyPage.tsx │ ├── PostStatsPage.tsx │ ├── RegisterPage.tsx │ ├── SavesPage.tsx │ ├── SearchPage.tsx │ ├── SettingPage.tsx │ ├── SuccessPage.tsx │ ├── UserIntegratePage.tsx │ ├── WritePage.tsx │ ├── home │ │ ├── HomePage.tsx │ │ ├── RecentPostsPage.tsx │ │ ├── TrendingPostsPage.tsx │ │ └── hooks │ │ │ ├── useRecentPosts.ts │ │ │ └── useTrendingPosts.ts │ ├── readingList │ │ ├── ReadingListPage.tsx │ │ └── hooks │ │ │ └── useReadingList.ts │ ├── tags │ │ ├── TagDetailPage.tsx │ │ ├── TagListPage.tsx │ │ └── TagsPage.tsx │ └── velog │ │ ├── PostPage.tsx │ │ ├── SeriesPage.tsx │ │ ├── UserPage.tsx │ │ ├── VelogPage.tsx │ │ └── tabs │ │ ├── AboutTab.tsx │ │ ├── SeriesTab.tsx │ │ └── UserPostsTab.tsx ├── react-app-env.d.ts ├── server │ ├── CacheManager.ts │ ├── Html.tsx │ ├── checkCacheRule.ts │ ├── rateLimitMiddleware.ts │ ├── serverRender.tsx │ └── ssrMiddleware.ts ├── serverless.ts ├── serviceWorker.ts ├── setupTests.ts ├── static │ ├── images │ │ ├── empty-thumbnail.svg │ │ ├── index.ts │ │ ├── pluto-welcome.png │ │ ├── series-thumbnail.svg │ │ ├── undraw_blank_canvas_3rbb.svg │ │ ├── undraw_bug_fixing_oc7a.svg │ │ ├── undraw_empty.svg │ │ ├── undraw_joyride_hnno.svg │ │ ├── undraw_login_v483.svg │ │ ├── undraw_page_not_found_su7k.svg │ │ ├── undraw_searching.svg │ │ ├── undraw_server_down_s4lk.svg │ │ ├── undraw_update_uxn2.svg │ │ └── user-thumbnail.png │ └── svg │ │ ├── icon-add-list.svg │ │ ├── icon-check.svg │ │ ├── icon-clip.svg │ │ ├── icon-email.svg │ │ ├── icon-facebook-square.svg │ │ ├── icon-facebook.svg │ │ ├── icon-github.svg │ │ ├── icon-globe.svg │ │ ├── icon-google.svg │ │ ├── icon-like.svg │ │ ├── icon-lock.svg │ │ ├── icon-minus-box.svg │ │ ├── icon-moon.svg │ │ ├── icon-notification.svg │ │ ├── icon-plus-box.svg │ │ ├── icon-search-2.svg │ │ ├── icon-search-3.svg │ │ ├── icon-search.svg │ │ ├── icon-share-2.svg │ │ ├── icon-share.svg │ │ ├── icon-sun.svg │ │ ├── icon-twitter.svg │ │ ├── image-series.svg │ │ ├── index.ts │ │ ├── logo.svg │ │ ├── vector-image.svg │ │ └── velog-icon.svg ├── types │ ├── missingTypes.d.ts │ └── window.d.ts └── typography.css ├── tsconfig.json └── yarn.lock /.env.development: -------------------------------------------------------------------------------- 1 | PUBLIC_URL=/ 2 | 3 | REACT_APP_CLIENT_V3_HOST=http://localhost:3001 4 | REACT_APP_API_HOST=http://localhost:5002/ 5 | 6 | REACT_APP_GRAPHQL_HOST=http://localhost:5002/ 7 | REACT_APP_GRAPHQL_HOST_NOCDN=https://v2.velog.io/ -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | PUBLIC_URL=/ 2 | 3 | REACT_APP_CLIENT_V3_HOST=http://localhost:3001 4 | REACT_APP_API_HOST=http://localhost:5002/ 5 | 6 | REACT_APP_GRAPHQL_HOST=https://v2cdn.velog.io/ 7 | REACT_APP_GRAPHQL_HOST_NOCDN=https://v2.velog.io/ -------------------------------------------------------------------------------- /.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 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | /dist 26 | 27 | # package directories 28 | jspm_packages 29 | 30 | # Serverless directories 31 | .serverless 32 | .webpack 33 | 34 | # ignore setting 35 | .idea 36 | .vscode 37 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 80, 8 | "parser": "typescript" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## velog-client 2 | 3 | > Velog is a blog platform for developers. It provides compfy markdown editor with syntax highlighter enabled. Currently, this service only supports Korean language. 4 | 5 | Website link: https://velog.io/ 6 | 7 | Backend project of service is at another Repo - [velog-backend](https://github.com/velopert/velog-server) 8 | 9 | ### Project Stack 10 | 11 | - React 12 | - React Router 13 | - TypeScript 14 | - Redux 15 | - Apollo GraphQL 16 | - Styled Components 17 | - Remark 18 | - Codemirror 19 | - Serverless Framework 20 | - AWS Lambda 21 | -------------------------------------------------------------------------------- /asset-license.md: -------------------------------------------------------------------------------- 1 | 1. **[Image Icon](https://www.flaticon.com/free-icon/picture_149092) (WriteScreen)**: 2 | Icons made by [Smashicons](https://www.flaticon.com/authors/smashicons) from [www.flaticon.com](https://www.flaticon.com/) is licensed by [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/) 3 | 4 |
Icons made by SimpleIcon from www.flaticon.com is licensed by CC 3.0 BY
5 | 6 |
Icons made by SimpleIcon from www.flaticon.com is licensed by CC 3.0 BY
7 | 8 | 9 | Heart Icon: https://iconmonstr.com/favorite-8-svg/ 10 | Clip Icon: https://iconmonstr.com/paperclip-2-svg/ 11 | Check Icon: https://iconmonstr.com/check-mark-1-svg/ -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return 'module.exports = {};'; 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return 'cssTransform'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /config/pnpTs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { resolveModuleName } = require('ts-pnp'); 4 | 5 | exports.resolveModuleName = ( 6 | typescript, 7 | moduleName, 8 | containingFile, 9 | compilerOptions, 10 | resolutionHost 11 | ) => { 12 | return resolveModuleName( 13 | moduleName, 14 | containingFile, 15 | compilerOptions, 16 | resolutionHost, 17 | typescript.resolveModuleName 18 | ); 19 | }; 20 | 21 | exports.resolveTypeReferenceDirective = ( 22 | typescript, 23 | moduleName, 24 | containingFile, 25 | compilerOptions, 26 | resolutionHost 27 | ) => { 28 | return resolveModuleName( 29 | moduleName, 30 | containingFile, 31 | compilerOptions, 32 | resolutionHost, 33 | typescript.resolveTypeReferenceDirective 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /deploy-hook/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless -------------------------------------------------------------------------------- /deploy-hook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netlify-completion-hook", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "axios": "^0.19.1", 8 | "ioredis": "^4.14.1" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /deploy-hook/serverless.yml: -------------------------------------------------------------------------------- 1 | service: velog-v2-frontend-deploy-hook 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs12.x 6 | region: ap-northeast-2 7 | stage: prod 8 | 9 | environment: 10 | GITHUB_TOKEN: ${ssm:/velog-v2/github-token} 11 | SSR_DEPLOY_TOKEN: ${ssm:/velog-v2/ssr-deploy-token} 12 | REDIS_HOST: ${ssm:/velog-v2/redis-host} 13 | 14 | vpc: 15 | securityGroupIds: 16 | - sg-007a5a395dbdcef1f 17 | - sg-f588c99d 18 | - sg-0fd14591289bb3212 19 | subnetIds: 20 | - subnet-02c6112f71f5148c7 21 | - subnet-0ebc43e6ab298c646 22 | 23 | functions: 24 | webhook: 25 | handler: handler.webhook 26 | events: 27 | - http: 28 | path: / 29 | method: POST 30 | 31 | -------------------------------------------------------------------------------- /deploy.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "ssr", 5 | "script": "./dist/server.js", 6 | "instances": 2, 7 | "exec_mode": "cluster", 8 | "env": { 9 | "NODE_PATH": "src" 10 | } 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /docker/redis/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM bitnami/redis:5.0 2 | ENV ALLOW_EMPTY_PASSWORD=yes 3 | -------------------------------------------------------------------------------- /docker/redis/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | redis: 5 | image: 'bitnami/redis:5.0' 6 | environment: 7 | # ALLOW_EMPTY_PASSWORD is recommended only for development. 8 | - ALLOW_EMPTY_PASSWORD=yes 9 | ports: 10 | - '6379:6379' 11 | volumes: 12 | - 'redis_data:/bitnami/redis/data' 13 | 14 | volumes: 15 | redis_data: 16 | driver: local 17 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/velog-client/65e23035d9e65efac1c266d6744fd7bc16fda632/public/favicon.ico -------------------------------------------------------------------------------- /public/favicons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/velog-client/65e23035d9e65efac1c266d6744fd7bc16fda632/public/favicons/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/velog-client/65e23035d9e65efac1c266d6744fd7bc16fda632/public/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/velog-client/65e23035d9e65efac1c266d6744fd7bc16fda632/public/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/velog-client/65e23035d9e65efac1c266d6744fd7bc16fda632/public/favicons/favicon-96x96.png -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/velog-client/65e23035d9e65efac1c266d6744fd7bc16fda632/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/velog-client/65e23035d9e65efac1c266d6744fd7bc16fda632/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /scripts/activate: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export PUBLIC_URL=https://static.velog.io/ 3 | export REACT_APP_API_HOST=https://v2.velog.io/ 4 | export S3_BUCKET=static.velog.io -------------------------------------------------------------------------------- /scripts/activate.dev: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export PUBLIC_URL=https://d3v0gm8v6v8olv.cloudfront.net/ 3 | export REACT_APP_API_HOST=https://v2dev.velog.io/ 4 | export STAGE=true 5 | export S3_BUCKET=dev.static.velog.io -------------------------------------------------------------------------------- /scripts/build.server.js: -------------------------------------------------------------------------------- 1 | // Do this as the first thing so that any code reading it knows the right env. 2 | process.env.BABEL_ENV = 'production'; 3 | process.env.NODE_ENV = 'production'; 4 | process.env.REACT_APP_SSR = 'enabled'; 5 | 6 | // Makes the script crash on unhandled rejections instead of silently 7 | // ignoring them. In the future, promise rejections that are not handled will 8 | // terminate the Node.js process with a non-zero exit code. 9 | process.on('unhandledRejection', err => { 10 | throw err; 11 | }); 12 | 13 | // Ensure environment variables are read. 14 | require('../config/env'); 15 | const fs = require('fs-extra'); 16 | const webpack = require('webpack'); 17 | const config = require('../config/webpack.config.server'); 18 | const paths = require('../config/paths'); 19 | 20 | function build() { 21 | const compiler = webpack({ ...config }); 22 | fs.emptyDirSync(paths.ssrBuild); 23 | return new Promise((resolve, reject) => { 24 | compiler.run((err, stats) => { 25 | if (err) { 26 | console.log(err); 27 | return; 28 | } 29 | }); 30 | }); 31 | } 32 | 33 | build(); 34 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/GlobalStyles.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | import { themedPalette, themes } from './lib/styles/themes'; 3 | 4 | const GlobalStyles = createGlobalStyle` 5 | body { 6 | margin: 0; 7 | padding: 0; 8 | font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple SD Gothic Neo", "Malgun Gothic", "맑은 고딕", 나눔고딕, "Nanum Gothic", "Noto Sans KR", "Noto Sans CJK KR", arial, 돋움, Dotum, Tahoma, Geneva, sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | color: ${themedPalette.text1}; 12 | box-sizing: border-box; 13 | 14 | } 15 | 16 | * { 17 | box-sizing: inherit; 18 | } 19 | 20 | code { 21 | font-family: 'Fira Mono', source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 22 | monospace; 23 | } 24 | 25 | input, button, textarea { 26 | font-family: inherit; 27 | } 28 | 29 | html, body, #root { 30 | height: 100%; 31 | } 32 | 33 | body { 34 | ${themes.light} 35 | } 36 | 37 | @media (prefers-color-scheme: dark) { 38 | body { 39 | ${themes.dark} 40 | } 41 | } 42 | 43 | body[data-theme='light'] { 44 | ${themes.light}; 45 | } 46 | 47 | body[data-theme='dark'] { 48 | ${themes.dark}; 49 | } 50 | 51 | `; 52 | 53 | export default GlobalStyles; 54 | -------------------------------------------------------------------------------- /src/components/auth/AuthSocialButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import AuthSocialButton from './AuthSocialButton'; 4 | 5 | const AuthSocialButtonGroupBlock = styled.div` 6 | display: flex; 7 | justify-content: space-around; 8 | margin-top: 1.5rem; 9 | `; 10 | 11 | const AuthSocialButtonGroup = ({ 12 | currentPath, 13 | isIntegrate, 14 | integrateState, 15 | }: { 16 | currentPath: string; 17 | isIntegrate?: boolean; 18 | integrateState?: string; 19 | }) => { 20 | return ( 21 | 22 | 29 | 36 | 43 | 44 | ); 45 | }; 46 | 47 | export default AuthSocialButtonGroup; 48 | -------------------------------------------------------------------------------- /src/components/auth/__tests__/AuthEmailSuccess.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import AuthEmailSuccess, { AuthEmailSuccessProps } from '../AuthEmailSuccess'; 4 | 5 | describe('AuthEmailSuccess', () => { 6 | const setup = (props: Partial = {}) => { 7 | const initialProps: AuthEmailSuccessProps = { 8 | registered: false, 9 | }; 10 | return render(); 11 | }; 12 | it('renders properly', () => { 13 | setup(); 14 | }); 15 | 16 | it('registered is true', () => { 17 | const { getByText } = setup({ 18 | registered: true, 19 | }); 20 | getByText(/로그인 링크가/); 21 | }); 22 | 23 | it('registered is false', () => { 24 | const { getByText } = setup({ 25 | registered: false, 26 | }); 27 | getByText(/회원가입 링크가/); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/base/BodyTransition.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useEffect, useState } from 'react'; 3 | import { createGlobalStyle } from 'styled-components'; 4 | 5 | const Styles = createGlobalStyle` 6 | body { 7 | transition: background 0.125s ease-in; 8 | } 9 | `; 10 | 11 | function BodyTransition() { 12 | const [enabled, setEnabled] = useState(false); 13 | useEffect(() => { 14 | setTimeout(() => { 15 | setEnabled(true); 16 | }, 500); 17 | }, []); 18 | 19 | if (!enabled) return null; 20 | return ; 21 | } 22 | 23 | export default BodyTransition; 24 | -------------------------------------------------------------------------------- /src/components/base/ConditionalBackground.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMemo } from 'react'; 3 | import { matchPath, useLocation } from 'react-router-dom'; 4 | import { createGlobalStyle } from 'styled-components'; 5 | import { themedPalette } from '../../lib/styles/themes'; 6 | 7 | interface Props {} 8 | 9 | const GrayBackground = createGlobalStyle` 10 | body { 11 | background: ${themedPalette.bg_page1}; 12 | } 13 | `; 14 | 15 | const WhiteBackground = createGlobalStyle` 16 | body { 17 | background: ${themedPalette.bg_page2}; 18 | } 19 | `; 20 | 21 | /** 22 | * Bacgkround should be gray on following paths 23 | * - / 24 | * - /recent 25 | * - /lists 26 | */ 27 | function ConditionalBackground(props: Props) { 28 | const location = useLocation(); 29 | 30 | const isGray = useMemo( 31 | () => 32 | [{ path: '/', exact: true }, '/recent', '/lists'].some((path) => 33 | matchPath(location.pathname, path), 34 | ), 35 | [location], 36 | ); 37 | 38 | return isGray ? : ; 39 | } 40 | 41 | export default ConditionalBackground; 42 | -------------------------------------------------------------------------------- /src/components/base/PageTemplate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import Header from './Header'; 4 | import FloatingHeader from './FloatingHeader'; 5 | 6 | const PageTemplateBlock = styled.div``; 7 | 8 | interface PageTemplateProps { 9 | hideHeader?: boolean; 10 | style?: React.CSSProperties; 11 | className?: string; 12 | } 13 | 14 | const PageTemplate: React.FC = ({ 15 | hideHeader, 16 | style, 17 | className, 18 | children, 19 | }) => { 20 | return ( 21 | 22 | {!hideHeader && ( 23 | <> 24 |
25 | 26 | 27 | )} 28 | {children} 29 | 30 | ); 31 | }; 32 | 33 | export default PageTemplate; 34 | -------------------------------------------------------------------------------- /src/components/base/__tests__/HeaderUserIcon.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import HeaderUserIcon, { HeaderUserIconProps } from '../HeaderUserIcon'; 4 | 5 | describe('HeaderUserIcon', () => { 6 | const setup = (props: Partial = {}) => { 7 | const initialProps: HeaderUserIconProps = { 8 | user: { 9 | id: '', 10 | username: 'tester', 11 | profile: { 12 | thumbnail: '', 13 | display_name: 'tester kim', 14 | }, 15 | }, 16 | }; 17 | const utils = render(); 18 | return utils; 19 | }; 20 | it('renders properly', () => { 21 | setup(); 22 | }); 23 | it('matches snapshot', () => { 24 | const { container } = setup(); 25 | expect(container).toMatchSnapshot(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/components/base/__tests__/__snapshots__/HeaderUserIcon.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`HeaderUserIcon matches snapshot 1`] = ` 4 |
5 |
8 | thumbnail 12 | 21 | 24 | 25 |
26 |
27 | `; 28 | -------------------------------------------------------------------------------- /src/components/base/hooks/useHeader.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from 'react-redux'; 2 | import { useCallback } from 'react'; 3 | import { showAuthModal } from '../../../modules/core'; 4 | import { RootState } from '../../../modules'; 5 | import { logout } from '../../../lib/api/auth'; 6 | import storage from '../../../lib/storage'; 7 | import { useMutation } from '@apollo/react-hooks'; 8 | import { LOGOUT } from '../../../lib/graphql/user'; 9 | 10 | export default function useHeader() { 11 | const dispatch = useDispatch(); 12 | const user = useSelector((state: RootState) => state.core.user); 13 | const customHeader = useSelector((state: RootState) => state.header); 14 | const [graphqlLogout] = useMutation(LOGOUT); 15 | 16 | const onLoginClick = useCallback(() => { 17 | dispatch(showAuthModal('LOGIN')); 18 | }, [dispatch]); 19 | 20 | const onLogout = useCallback(async () => { 21 | try { 22 | await Promise.all([logout(), graphqlLogout()]); 23 | } catch {} 24 | storage.removeItem('CURRENT_USER'); 25 | window.location.href = '/'; 26 | }, [graphqlLogout]); 27 | 28 | return { user, onLoginClick, onLogout, customHeader }; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/base/hooks/useThemeEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { RootState } from '../../../modules'; 4 | import darkMode from '../../../modules/darkMode'; 5 | 6 | export function useThemeEffect() { 7 | const dispatch = useDispatch(); 8 | const theme = useSelector((state: RootState) => state.darkMode.theme); 9 | 10 | useEffect(() => { 11 | const systemPrefersDark = window.matchMedia( 12 | '(prefers-color-scheme: dark)', 13 | ).matches; 14 | dispatch( 15 | darkMode.actions.setSystemTheme(systemPrefersDark ? 'dark' : 'light'), 16 | ); 17 | }, [dispatch]); 18 | 19 | useEffect(() => { 20 | if (theme !== 'default') { 21 | document.body.dataset.theme = theme; 22 | } 23 | }, [theme]); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/base/hooks/useToggleTheme.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux'; 2 | import { useTheme } from '../../../lib/hooks/useTheme'; 3 | import storage from '../../../lib/storage'; 4 | import darkMode from '../../../modules/darkMode'; 5 | 6 | export function useToggleTheme() { 7 | const dispatch = useDispatch(); 8 | const theme = useTheme(); 9 | 10 | const saveToStorage = (value: 'light' | 'dark') => { 11 | storage.setItem('theme', value); // For CSR 12 | // save to cookie 13 | document.cookie = `theme=${value}; path=/;`; // For SSR 14 | }; 15 | 16 | const toggle = () => { 17 | if (!theme) return; 18 | if (theme === 'dark') { 19 | dispatch(darkMode.actions.enableLightMode()); 20 | saveToStorage('light'); 21 | } else { 22 | dispatch(darkMode.actions.enableDarkMode()); 23 | saveToStorage('dark'); 24 | } 25 | }; 26 | 27 | return [theme, toggle] as const; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/common/EditRemoveGroup.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { themedPalette } from '../../lib/styles/themes'; 3 | 4 | const EditRemoveGroup = styled.div` 5 | button { 6 | padding: 0; 7 | outline: none; 8 | border: none; 9 | background: none; 10 | font-size: inherit; 11 | cursor: pointer; 12 | color: ${themedPalette.text3}; 13 | &:hover { 14 | color: ${themedPalette.text1}; 15 | } 16 | } 17 | button + button { 18 | margin-left: 0.5rem; 19 | } 20 | `; 21 | 22 | export default EditRemoveGroup; 23 | -------------------------------------------------------------------------------- /src/components/common/FlatPostCardList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import PostCard, { PostCardSkeleton } from './FlatPostCard'; 4 | import { PartialPost } from '../../lib/graphql/post'; 5 | import { themedPalette } from '../../lib/styles/themes'; 6 | 7 | const PostCardListBlock = styled.div``; 8 | 9 | interface PostCardListProps { 10 | posts: PartialPost[]; 11 | hideUser?: boolean; 12 | } 13 | 14 | const PostCardList: React.FC = ({ posts, hideUser }) => { 15 | return ( 16 | 17 | {posts.map((post) => ( 18 | 19 | ))} 20 | 21 | ); 22 | }; 23 | 24 | export type PostCardListSkeletonProps = { 25 | hideUser?: boolean; 26 | forLoading?: boolean; 27 | }; 28 | 29 | export function PostCardListSkeleton({ 30 | hideUser, 31 | forLoading, 32 | }: PostCardListSkeletonProps) { 33 | return ( 34 | 35 | {forLoading && } 36 | {Array.from({ length: forLoading ? 1 : 3 }).map((_, i) => ( 37 | 38 | ))} 39 | 40 | ); 41 | } 42 | 43 | const Separator = styled.div` 44 | border-top: 1px solid ${themedPalette.border4}; 45 | `; 46 | 47 | export default PostCardList; 48 | -------------------------------------------------------------------------------- /src/components/common/HideScroll.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface HideScrollProps {} 4 | 5 | const { useLayoutEffect } = React; 6 | 7 | /** 8 | * Hides body scrollbar on mount 9 | * Revert on unmount 10 | */ 11 | const HideScroll: React.FC = props => { 12 | useLayoutEffect(() => { 13 | const { overflowX, overflowY } = getComputedStyle(window.document.body); 14 | const prevStyles = { 15 | overflowX, 16 | overflowY, 17 | }; 18 | window.document.body.style.overflowX = 'hidden'; 19 | window.document.body.style.overflowY = 'hidden'; 20 | return () => { 21 | window.document.body.style.overflowX = prevStyles.overflowX; 22 | window.document.body.style.overflowY = prevStyles.overflowY; 23 | }; 24 | }, []); 25 | return null; 26 | }; 27 | 28 | export default HideScroll; 29 | -------------------------------------------------------------------------------- /src/components/common/PaginateWithScroll.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useCallback, useRef } from 'react'; 2 | import { getScrollBottom } from '../../lib/utils'; 3 | 4 | export interface PaginateWithScrollProps { 5 | cursor: string | null; 6 | onLoadMore: (cursor: string) => any; 7 | } 8 | 9 | const PaginateWithScroll: React.FC = ({ 10 | cursor, 11 | onLoadMore, 12 | }) => { 13 | const lastCursor = useRef(null); 14 | 15 | const loadMore = useCallback(() => { 16 | if (!cursor) return; 17 | if (cursor === lastCursor.current) return; 18 | onLoadMore(cursor); 19 | lastCursor.current = cursor; 20 | }, [cursor, onLoadMore]); 21 | 22 | const onScroll = useCallback(() => { 23 | const scrollBottom = getScrollBottom(); 24 | if (scrollBottom < 768) { 25 | loadMore(); 26 | } 27 | }, [loadMore]); 28 | 29 | useEffect(() => { 30 | console.log('register scroll event'); 31 | window.addEventListener('scroll', onScroll); 32 | return () => { 33 | console.log('unregister scroll event'); 34 | window.removeEventListener('scroll', onScroll); 35 | }; 36 | }, [onScroll]); 37 | return null; 38 | }; 39 | 40 | export default PaginateWithScroll; 41 | -------------------------------------------------------------------------------- /src/components/common/PasteUpload.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | export interface PasteUploadProps { 4 | onUpload: (file: File) => any; 5 | } 6 | 7 | const PasteUpload: React.FC = ({ onUpload }) => { 8 | useEffect(() => { 9 | const onPaste: EventListener = e => { 10 | const { clipboardData } = e as ClipboardEvent; 11 | if (!clipboardData) return; 12 | 13 | const { items } = clipboardData; 14 | if (items.length === 0) return; 15 | 16 | const itemsArray = (() => { 17 | const array = []; 18 | for (let i = 0; i < items.length; i++) { 19 | array.push(items[i]); 20 | } 21 | return array; 22 | })(); 23 | 24 | const fileItem = itemsArray.filter(item => item.kind === 'file')[0]; 25 | if (!fileItem || !fileItem.getAsFile) return; 26 | const file = fileItem.getAsFile(); 27 | if (!file) return; 28 | onUpload(file); 29 | e.preventDefault(); 30 | }; 31 | window.addEventListener('paste', onPaste); 32 | return () => { 33 | window.removeEventListener('paste', onPaste); 34 | }; 35 | }, [onUpload]); 36 | return null; 37 | }; 38 | 39 | export default PasteUpload; 40 | -------------------------------------------------------------------------------- /src/components/common/PlainLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLProps } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | type PlainLinkProps = HTMLProps & { 5 | to: string; 6 | }; 7 | 8 | /** 9 | * Needed when StyledLink has a custom props 10 | */ 11 | const PlainLink: React.FC = ({ 12 | to, 13 | className, 14 | children, 15 | onClick, 16 | onMouseOver, 17 | onMouseEnter, 18 | }) => { 19 | return ( 20 | 27 | {children} 28 | 29 | ); 30 | }; 31 | 32 | export default PlainLink; 33 | -------------------------------------------------------------------------------- /src/components/common/PlainNavLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLProps, CSSProperties } from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | type PlainNavLinkProps = HTMLProps & { 5 | to: string; 6 | activeClassName?: string; 7 | activeStyle?: CSSProperties; 8 | exact?: boolean; 9 | }; 10 | 11 | /** 12 | * Needed when StyledLink has a custom props 13 | */ 14 | const PlainNavLink: React.FC = ({ 15 | to, 16 | activeClassName, 17 | activeStyle, 18 | className, 19 | children, 20 | onClick, 21 | exact, 22 | }) => { 23 | return ( 24 | 32 | {children} 33 | 34 | ); 35 | }; 36 | 37 | export default PlainNavLink; 38 | -------------------------------------------------------------------------------- /src/components/common/PostLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | import PlainLink from './PlainLink'; 4 | import { useApolloClient } from '@apollo/react-hooks'; 5 | import { READ_POST } from '../../lib/graphql/post'; 6 | 7 | const PostLinkBlock = styled(PlainLink)``; 8 | 9 | export interface PostLinkProps { 10 | className?: string; 11 | username: string; 12 | urlSlug: string; 13 | prefetch?: boolean; 14 | children?: ReactNode; 15 | } 16 | 17 | const PostLink: React.FC = ({ 18 | username, 19 | urlSlug, 20 | prefetch = true, 21 | children, 22 | className, 23 | }) => { 24 | const to = `/@${username}/${urlSlug}`; 25 | 26 | const client = useApolloClient(); 27 | const onPrefetch = useCallback(() => { 28 | if (!prefetch) { 29 | return; 30 | } 31 | client.query({ 32 | query: READ_POST, 33 | variables: { 34 | username, 35 | url_slug: urlSlug, 36 | }, 37 | }); 38 | }, [prefetch, client, username, urlSlug]); 39 | 40 | return ( 41 | 42 | {children} 43 | 44 | ); 45 | }; 46 | 47 | export default PostLink; 48 | -------------------------------------------------------------------------------- /src/components/common/PrivatePostLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import palette from '../../lib/styles/palette'; 4 | import { LockIcon } from '../../static/svg'; 5 | 6 | export type PrivatePostLabelProps = {}; 7 | 8 | function PrivatePostLabel(props: PrivatePostLabelProps) { 9 | return ( 10 | 11 | 비공개 12 | 13 | ); 14 | } 15 | 16 | const Block = styled.div` 17 | background: ${palette.gray8}; 18 | color: white; 19 | line-height: 1; 20 | padding-left: 0.5rem; 21 | padding-right: 0.5rem; 22 | padding-top: 0.25rem; 23 | padding-bottom: 0.25rem; 24 | border-radius: 4px; 25 | font-weight: bold; 26 | font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 27 | 'Apple SD Gothic Neo', arial, 나눔고딕, 'Nanum Gothic', 돋움, Dotum, Tahoma, 28 | Geneva, sans-serif; 29 | display: inline-flex; 30 | align-items: center; 31 | svg { 32 | margin-right: 0.5rem; 33 | width: 0.875rem; 34 | height: 0.875rem; 35 | } 36 | `; 37 | 38 | export default PrivatePostLabel; 39 | -------------------------------------------------------------------------------- /src/components/common/RatioImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const RatioImageBlock = styled.div` 5 | width: 100%; 6 | position: relative; 7 | img { 8 | position: absolute; 9 | top: 0; 10 | left: 0; 11 | width: 100%; 12 | height: 100%; 13 | display: block; 14 | object-fit: cover; 15 | } 16 | `; 17 | 18 | export interface RatioImageProps { 19 | widthRatio: number; 20 | heightRatio: number; 21 | src: string; 22 | alt?: string; 23 | className?: string; 24 | } 25 | 26 | const RatioImage: React.FC = ({ 27 | widthRatio, 28 | heightRatio, 29 | src, 30 | alt, 31 | className, 32 | }) => { 33 | const paddingTop = `${(heightRatio / widthRatio) * 100}%`; 34 | 35 | return ( 36 | 42 | {alt} 43 | 44 | ); 45 | }; 46 | 47 | export default RatioImage; 48 | -------------------------------------------------------------------------------- /src/components/common/SkeletonTexts.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Skeleton from './Skeleton'; 3 | 4 | type SkeletonTextsProps = { 5 | wordLengths: number[]; 6 | useFlex?: boolean; 7 | }; 8 | 9 | function SkeletonTexts({ wordLengths, useFlex }: SkeletonTextsProps) { 10 | return ( 11 | <> 12 | {wordLengths.map((length, index) => { 13 | const props = { 14 | [useFlex ? 'flex' : 'width']: useFlex ? length : `${length}rem`, 15 | }; 16 | return ; 17 | })} 18 | 19 | ); 20 | } 21 | 22 | export default SkeletonTexts; 23 | -------------------------------------------------------------------------------- /src/components/common/StatusCode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route } from 'react-router-dom'; 3 | 4 | export type StatusCodeProps = { 5 | statusCode: number; 6 | }; 7 | 8 | function StatusCode({ statusCode }: StatusCodeProps) { 9 | return ( 10 | { 12 | if (staticContext) { 13 | staticContext.statusCode = statusCode; 14 | } 15 | return null; 16 | }} 17 | /> 18 | ); 19 | } 20 | 21 | export default StatusCode; 22 | -------------------------------------------------------------------------------- /src/components/common/TagList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import TagItem from './TagItem'; 4 | import media from '../../lib/styles/media'; 5 | 6 | export interface TagListProps { 7 | link?: boolean; 8 | tags: string[]; 9 | className?: string; 10 | } 11 | 12 | const TagList = ({ link, tags, className }: TagListProps) => { 13 | return ( 14 | 15 | {tags.map(tag => ( 16 | 17 | {tag} 18 | 19 | ))} 20 | 21 | ); 22 | }; 23 | 24 | const TagListBlock = styled.div` 25 | margin-top: 0.875rem; 26 | margin-bottom: -0.875rem; 27 | min-height: 0.875rem; 28 | ${media.small} { 29 | margin-top: 0.5rem; 30 | margin-bottom: -0.5rem; 31 | min-height: 0.5rem; 32 | } 33 | `; 34 | 35 | export default TagList; 36 | -------------------------------------------------------------------------------- /src/components/common/VLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | type Props = { 5 | to: string; 6 | className?: string; 7 | children?: React.ReactNode; 8 | style?: React.CSSProperties; 9 | onClick?: (event: React.MouseEvent) => void; 10 | }; 11 | 12 | function VLink({ to, children, className = '', style, onClick }: Props) { 13 | const url = `${process.env.REACT_APP_CLIENT_V3_HOST}${to}`; 14 | 15 | const handleClick = (event: React.MouseEvent) => { 16 | if (!onClick) return; 17 | onClick(event); 18 | }; 19 | 20 | return ( 21 | handleClick(event)} 26 | > 27 | {children} 28 | 29 | ); 30 | } 31 | 32 | const Link = styled.a` 33 | color: inherit; 34 | text-decoration: none; 35 | `; 36 | 37 | export default VLink; 38 | -------------------------------------------------------------------------------- /src/components/common/__tests__/RequireLogin.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import RequireLogin, { RequireLoginProps } from '../RequireLogin'; 4 | import renderWithRedux from '../../../lib/renderWithRedux'; 5 | 6 | describe('RequireLogin', () => { 7 | const setup = (props: Partial = {}) => { 8 | const initialProps: RequireLoginProps = {}; 9 | const utils = renderWithRedux( 10 | , 11 | ); 12 | return { 13 | ...utils, 14 | }; 15 | }; 16 | it('renders properly', () => { 17 | setup(); 18 | }); 19 | it('calls requireLogin on button click', () => { 20 | const utils = setup(); 21 | const button = utils.getByText('로그인'); 22 | fireEvent.click(button); 23 | expect(utils.store.getState().core.auth.visible).toBe(true); 24 | }); 25 | it('has no margin when hasMargin props is omitted', () => { 26 | const utils = setup(); 27 | expect(utils.container.firstChild).not.toHaveStyle('margin-top: 10rem'); 28 | }); 29 | it('has margin when hasMargin is true', () => { 30 | const utils = setup({ hasMargin: true }); 31 | expect(utils.container.firstChild).toHaveStyle('margin-top: 10rem'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/components/common/__tests__/__snapshots__/PopupOKCancel.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`PopupOKCancel matches snapshot 1`] = ` 4 |
5 |
8 |
11 |
14 |
17 |

18 | 제목 19 |

20 |
23 | 내용 24 |
25 |
28 | 34 |
35 |
36 |
37 |
38 |
39 | `; 40 | -------------------------------------------------------------------------------- /src/components/error/CrashErrorScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ErrorScreenTemplate from './ErrorScreenTemplate'; 3 | import { undrawBugFixing } from '../../static/images'; 4 | import { useHistory } from 'react-router-dom'; 5 | 6 | export type CrashErrorScreenProps = { 7 | onResolve: () => void; 8 | }; 9 | 10 | function CrashErrorScreen({ onResolve }: CrashErrorScreenProps) { 11 | const history = useHistory(); 12 | return ( 13 | { 18 | history.push('/'); 19 | onResolve(); 20 | }} 21 | /> 22 | ); 23 | } 24 | 25 | export default CrashErrorScreen; 26 | -------------------------------------------------------------------------------- /src/components/error/NetworkErrorScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ErrorScreenTemplate from './ErrorScreenTemplate'; 3 | import { undrawServerDown } from '../../static/images'; 4 | 5 | export type NetworkErrorScreenProps = {}; 6 | 7 | function NetworkErrorScreen(props: NetworkErrorScreenProps) { 8 | return ( 9 | window.location.reload()} 14 | /> 15 | ); 16 | } 17 | 18 | export default NetworkErrorScreen; 19 | -------------------------------------------------------------------------------- /src/components/home/HomeLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | export type HomeLayoutProps = { 5 | main: React.ReactNode; 6 | }; 7 | 8 | function HomeLayout({ main }: HomeLayoutProps) { 9 | return ( 10 | 11 |
{main}
12 |
13 | ); 14 | } 15 | 16 | const Block = styled.div` 17 | display: flex; 18 | margin-top: 2rem; 19 | `; 20 | const Main = styled.main` 21 | flex: 1; 22 | `; 23 | 24 | export default HomeLayout; 25 | -------------------------------------------------------------------------------- /src/components/home/HomeSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import MainNoticeWidgetContainer from '../../containers/home/MainNoticeWidgetContainer'; 4 | import MainTagWidgetContainer from '../../containers/home/MainTagWidgetContainer'; 5 | import HomeRightFooter from './HomeRightFooter'; 6 | import Sticky from '../common/Sticky'; 7 | import { mediaQuery } from '../../lib/styles/media'; 8 | 9 | export type HomeSidebarProps = {}; 10 | 11 | function HomeSidebar(props: HomeSidebarProps) { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | const Block = styled.div` 24 | width: 16rem; 25 | ${mediaQuery(1440)} { 26 | width: 12rem; 27 | } 28 | `; 29 | 30 | export default HomeSidebar; 31 | -------------------------------------------------------------------------------- /src/components/home/HomeWidget.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { themedPalette } from '../../lib/styles/themes'; 4 | import palette from '../../lib/styles/palette'; 5 | 6 | export type HomeWidgetProps = { 7 | title: string; 8 | children: React.ReactNode; 9 | className?: string; 10 | }; 11 | 12 | function HomeWidget({ title, children, className }: HomeWidgetProps) { 13 | return ( 14 | 15 |

{title}

16 | {children} 17 |
18 | ); 19 | } 20 | 21 | const MainWidgetBlock = styled.section` 22 | h4 { 23 | line-height: 1.5; 24 | font-size: 0.875rem; 25 | padding-bottom: 0.5rem; 26 | border-bottom: 1px solid ${themedPalette.border4}; 27 | margin-top: 0; 28 | margin-bottom: 1rem; 29 | font-weight: bold; 30 | } 31 | 32 | & + & { 33 | margin-top: 4rem; 34 | } 35 | `; 36 | 37 | export default HomeWidget; 38 | -------------------------------------------------------------------------------- /src/components/home/hooks/useTimeframe.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from 'react-redux'; 2 | import { useMemo } from 'react'; 3 | import { bindActionCreators } from 'redux'; 4 | import home from '../../../modules/home'; 5 | import { RootState } from '../../../modules'; 6 | 7 | export function useTimeframe() { 8 | const dispatch = useDispatch(); 9 | const actions = useMemo( 10 | () => bindActionCreators(home.actions, dispatch), 11 | [dispatch], 12 | ); 13 | const timeframe = useSelector((state: RootState) => state.home.timeframe); 14 | 15 | return [timeframe, actions.choose] as const; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/home/utils/timeframeMap.ts: -------------------------------------------------------------------------------- 1 | import { Timeframe } from '../../../modules/home'; 2 | 3 | export const timeframeMap: Record = { 4 | day: '오늘', 5 | week: '이번 주', 6 | month: '이번 달', 7 | year: '올해', 8 | }; 9 | 10 | export const timeframes = Object.entries(timeframeMap) as [Timeframe, string][]; 11 | -------------------------------------------------------------------------------- /src/components/main/MainPageTemplate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MainTemplate from './MainTemplate'; 3 | import Header from '../base/Header'; 4 | import FloatingHeader from '../base/FloatingHeader'; 5 | 6 | export type MainPageTemplateProps = { 7 | children?: React.ReactNode; 8 | }; 9 | 10 | function MainPageTemplate({ children }: MainPageTemplateProps) { 11 | return ( 12 | 13 |
14 | 15 | {children} 16 | 17 | ); 18 | } 19 | 20 | export default MainPageTemplate; 21 | -------------------------------------------------------------------------------- /src/components/main/MainResponsive.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { mediaQuery } from '../../lib/styles/media'; 4 | 5 | export type MainResponsiveProps = { 6 | className?: string; 7 | children: React.ReactNode; 8 | }; 9 | 10 | function MainResponsive({ className, children }: MainResponsiveProps) { 11 | return {children}; 12 | } 13 | 14 | const Block = styled.div` 15 | width: 1728px; 16 | margin-left: auto; 17 | margin-right: auto; 18 | ${mediaQuery(1919)} { 19 | width: 1376px; 20 | } 21 | ${mediaQuery(1440)} { 22 | width: 1024px; 23 | } 24 | ${mediaQuery(1056)} { 25 | width: calc(100% - 2rem); 26 | } 27 | `; 28 | 29 | export default MainResponsive; 30 | -------------------------------------------------------------------------------- /src/components/main/MainTemplate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | export type MainTemplateProps = { 5 | children: React.ReactNode; 6 | }; 7 | 8 | function MainTemplate({ children }: MainTemplateProps) { 9 | return ( 10 | <> 11 | {children} 12 | 13 | ); 14 | } 15 | 16 | const Block = styled.div``; 17 | 18 | export default MainTemplate; 19 | -------------------------------------------------------------------------------- /src/components/policy/PolicyViewer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import data from './policyData'; 3 | import MarkdownRender from '../common/MarkdownRender'; 4 | import styled from 'styled-components'; 5 | import { Helmet } from 'react-helmet-async'; 6 | 7 | export type PolicyViewerProps = { 8 | type: 'privacy' | 'terms'; 9 | }; 10 | 11 | function PolicyViewer({ type }: PolicyViewerProps) { 12 | const title = type === 'privacy' ? '개인정보 취급 방침' : '이용약관'; 13 | 14 | return ( 15 | 16 | 17 | {`${title} - velog`} 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | const Block = styled.div` 26 | margin-top: 3rem; 27 | `; 28 | 29 | export default PolicyViewer; 30 | -------------------------------------------------------------------------------- /src/components/post/LinkedPostList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import LinkedPost from './LinkedPostItem'; 4 | import { LinkedPosts } from '../../lib/graphql/post'; 5 | import VelogResponsive from '../velog/VelogResponsive'; 6 | import media from '../../lib/styles/media'; 7 | 8 | const LinkedPostsBlock = styled(VelogResponsive)` 9 | margin-top: 3rem; 10 | display: flex; 11 | ${media.small} { 12 | flex-direction: column-reverse; 13 | padding-left: 1rem; 14 | padding-right: 1rem; 15 | } 16 | `; 17 | const Wrapper = styled.div` 18 | min-width: 0; 19 | flex: 1; 20 | & + & { 21 | margin-left: 3rem; 22 | } 23 | 24 | ${media.small} { 25 | flex: initial; 26 | width: 100%; 27 | & + & { 28 | margin-left: 0; 29 | margin-bottom: 1.5rem; 30 | } 31 | } 32 | `; 33 | 34 | export interface LinkedPostListProps { 35 | linkedPosts: LinkedPosts; 36 | } 37 | 38 | const LinkedPostList: React.FC = ({ linkedPosts }) => { 39 | return ( 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default LinkedPostList; 52 | -------------------------------------------------------------------------------- /src/components/post/PostBanner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HorizontalBanner from '../../containers/post/HorizontalBanner'; 3 | import PostCustomBanner from './PostCustomBanner'; 4 | 5 | interface PostBannerProps { 6 | isDisplayAd?: boolean; 7 | customAd: { image: string; url: string } | null; 8 | } 9 | 10 | const PostBanner: React.FC = ({ isDisplayAd, customAd }) => { 11 | if (customAd) { 12 | return ; 13 | } 14 | return ; 15 | }; 16 | 17 | export default PostBanner; 18 | -------------------------------------------------------------------------------- /src/components/post/PostCommentsList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Comment } from '../../lib/graphql/post'; 4 | import PostCommentItem from './PostCommentItem'; 5 | 6 | const PostCommentsListBlock = styled.div``; 7 | 8 | export interface PostCommentsListProps { 9 | comments: Comment[]; 10 | currentUserId: null | string; 11 | onRemove: (id: string) => any; 12 | ownPost: boolean; 13 | } 14 | 15 | const PostCommentsList: React.FC = ({ 16 | comments, 17 | currentUserId, 18 | onRemove, 19 | ownPost, 20 | }) => { 21 | return ( 22 | 23 | {comments 24 | .filter((comment) => !!comment.user?.profile) 25 | .map((comment) => ( 26 | 33 | ))} 34 | 35 | ); 36 | }; 37 | 38 | export default PostCommentsList; 39 | -------------------------------------------------------------------------------- /src/components/post/PostCommentsTemplate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import VelogResponsive from '../velog/VelogResponsive'; 4 | import { themedPalette } from '../../lib/styles/themes'; 5 | import media from '../../lib/styles/media'; 6 | 7 | const PostCommentsTemplateBlock = styled(VelogResponsive)` 8 | margin-top: 3rem; 9 | color: ${themedPalette.text1}; 10 | h4 { 11 | font-size: 1.125rem; 12 | line-height: 1.5; 13 | font-weight: 600; 14 | margin-bottom: 1rem; 15 | } 16 | ${media.small} { 17 | padding-left: 1rem; 18 | padding-right: 1rem; 19 | } 20 | `; 21 | 22 | const Contents = styled.div``; 23 | 24 | export interface PostCommentsTemplateProps { 25 | count: number; 26 | } 27 | 28 | const PostCommentsTemplate: React.FC = ({ 29 | count, 30 | children, 31 | }) => { 32 | return ( 33 | 34 |

{count}개의 댓글

35 | {children} 36 |
37 | ); 38 | }; 39 | 40 | export default PostCommentsTemplate; 41 | -------------------------------------------------------------------------------- /src/components/post/PostCustomBanner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import VelogResponsive from '../velog/VelogResponsive'; 4 | import gtag from '../../lib/gtag'; 5 | import media from '../../lib/styles/media'; 6 | 7 | interface PostCustomBannerProps { 8 | image: string; 9 | url: string; 10 | } 11 | 12 | const onClick = () => { 13 | gtag('event', 'ads_banner_click'); 14 | }; 15 | 16 | const PostCustomBanner: React.FC = ({ image, url }) => { 17 | return ( 18 | 19 | 20 | post-custom-banner 21 | 22 | 23 | ); 24 | }; 25 | 26 | const PostCustomBannerBlock = styled(VelogResponsive)` 27 | max-width: 100%; 28 | height: auto; 29 | margin-top: 1rem; 30 | 31 | ${media.small} { 32 | margin-top: 0.5rem; 33 | } 34 | 35 | img { 36 | display: block; 37 | width: 100%; 38 | object-fit: contain; 39 | } 40 | `; 41 | 42 | export default PostCustomBanner; 43 | -------------------------------------------------------------------------------- /src/components/post/PostHtmlContent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import Typography from '../common/Typography'; 4 | 5 | const PostHtmlContentBlock = styled.div``; 6 | 7 | export interface PostHtmlContentProps { 8 | html: string; 9 | } 10 | 11 | const PostHtmlContent: React.FC = ({ html }) => { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default PostHtmlContent; 20 | -------------------------------------------------------------------------------- /src/components/post/PostTags.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Link } from 'react-router-dom'; 4 | import { themedPalette } from '../../lib/styles/themes'; 5 | import palette from '../../lib/styles/palette'; 6 | 7 | const PostTagsBlock = styled.div` 8 | margin-top: 0.875rem; 9 | margin-bottom: -0.875rem; 10 | min-height: 0.875rem; 11 | `; 12 | 13 | const Tag = styled(Link)` 14 | margin-bottom: 0.875rem; 15 | background: ${themedPalette.bg_element2}; 16 | padding-left: 1rem; 17 | padding-right: 1rem; 18 | height: 2rem; 19 | border-radius: 1rem; 20 | display: inline-flex; 21 | align-items: center; 22 | margin-right: 0.875rem; 23 | color: ${themedPalette.primary1}; 24 | text-decoration: none; 25 | font-weight: 500; 26 | &:hover { 27 | background: ${themedPalette.bg_element2}; 28 | } 29 | font-size: 0.875rem; 30 | `; 31 | 32 | export interface PostTagsProps { 33 | tags: string[]; 34 | } 35 | 36 | const PostTags: React.FC = ({ tags }) => { 37 | return ( 38 | 39 | {tags.map((tag) => ( 40 | 41 | {tag} 42 | 43 | ))} 44 | 45 | ); 46 | }; 47 | 48 | export default PostTags; 49 | -------------------------------------------------------------------------------- /src/components/post/PostTemplate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import PageTemplate from '../base/PageTemplate'; 4 | 5 | const PostTemplateBlock = styled(PageTemplate)``; 6 | 7 | interface PostTemplateProps {} 8 | 9 | const PostTemplate: React.FC = ({ children }) => { 10 | return {children}; 11 | }; 12 | 13 | export default PostTemplate; 14 | -------------------------------------------------------------------------------- /src/components/post/__tests__/PostCommentsTemplate.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import PostCommentsTemplate, { 4 | PostCommentsTemplateProps, 5 | } from '../PostCommentsTemplate'; 6 | 7 | describe('PostCommentsTemplate', () => { 8 | const setup = (props: Partial = {}) => { 9 | const initialProps: PostCommentsTemplateProps = { 10 | count: 9, 11 | }; 12 | const utils = render( 13 | 14 | contents 15 | , 16 | ); 17 | return { 18 | ...utils, 19 | }; 20 | }; 21 | it('shows comments count and children', () => { 22 | const { getByText } = setup(); 23 | getByText('9개의 댓글'); 24 | getByText('contents'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/post/__tests__/PostContent.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import PostContent, { PostContentProps } from '../PostContent'; 4 | import PostViewerProvider from '../PostViewerProvider'; 5 | import { HelmetProvider } from 'react-helmet-async'; 6 | 7 | describe('PostContent', () => { 8 | const setup = (props: Partial = {}) => { 9 | const initialProps: PostContentProps = { 10 | isMarkdown: true, 11 | body: '# Hello World!\n안녕하세요.', 12 | }; 13 | const utils = render( 14 | 15 | {}}> 16 | 17 | 18 | , 19 | ); 20 | return { 21 | ...utils, 22 | }; 23 | }; 24 | it('renders markdown post', () => { 25 | const { getByText } = setup(); 26 | getByText('Hello World!'); 27 | getByText('안녕하세요.'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/post/__tests__/PostLikeShareButtons.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import PostLikeShareButtons, { 4 | PostLikeShareButtonsProps, 5 | } from '../PostLikeShareButtons'; 6 | 7 | describe('PostLikeShareButtons', () => { 8 | const setup = (props: Partial = {}) => { 9 | const initialProps: PostLikeShareButtonsProps = { 10 | onLikeToggle: () => {}, 11 | onShareClick: () => {}, 12 | likes: 0, 13 | liked: false, 14 | postId: 'id', 15 | }; 16 | const utils = render(); 17 | return { 18 | ...utils, 19 | }; 20 | }; 21 | it('renders properly', () => { 22 | setup(); 23 | }); 24 | it('renders likes', () => { 25 | const { getByText } = setup({ 26 | likes: 123, 27 | }); 28 | getByText('123'); 29 | }); 30 | it('calls onLikeToggle', () => { 31 | const onLikeToggle = jest.fn(); 32 | const { getByTestId } = setup({ 33 | onLikeToggle, 34 | }); 35 | const likeButton = getByTestId('like'); 36 | fireEvent.click(likeButton); 37 | expect(onLikeToggle).toBeCalled(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/post/__tests__/PostsTags.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import PostTags, { PostTagsProps } from '../PostTags'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | 6 | describe('PostTags', () => { 7 | const setup = (props: Partial = {}) => { 8 | const initialProps: PostTagsProps = { 9 | tags: ['태그1', '태그2'], 10 | }; 11 | const utils = render( 12 | 13 | 14 | , 15 | ); 16 | return { 17 | ...utils, 18 | }; 19 | }; 20 | it('renders tags', () => { 21 | const { getByText } = setup(); 22 | const tag1 = getByText('태그1'); 23 | expect(tag1).toHaveAttribute('href', '/tags/태그1'); 24 | getByText('태그2'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/readingList/ReadingListTab.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HorizontalTab from '../common/HorizontalTab'; 3 | 4 | export type ReadingListTabProps = { 5 | type: 'liked' | 'read'; 6 | }; 7 | 8 | function ReadingListTab({ type }: ReadingListTabProps) { 9 | return ( 10 | 11 | 16 | 21 | 22 | ); 23 | } 24 | 25 | export default ReadingListTab; 26 | -------------------------------------------------------------------------------- /src/components/register/RegisterTemplate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import { themedPalette } from '../../lib/styles/themes'; 4 | import media from '../../lib/styles/media'; 5 | 6 | const RegisterTemplateBlock = styled.div` 7 | width: 768px; 8 | margin: 0 auto; 9 | margin-top: 100px; 10 | line-height: 1.5; 11 | h1 { 12 | font-size: 4rem; 13 | color: ${themedPalette.text1}; 14 | font-weight: bolder; 15 | margin: 0; 16 | } 17 | .description { 18 | font-size: 1.5rem; 19 | color: ${themedPalette.text1}; 20 | } 21 | 22 | ${media.small} { 23 | width: 100%; 24 | padding-left: 1rem; 25 | padding-right: 1rem; 26 | margin-top: 4rem; 27 | h1 { 28 | font-size: 3rem; 29 | } 30 | .description { 31 | font-size: 1rem; 32 | } 33 | } 34 | `; 35 | 36 | export interface RegisterTemplateProps {} 37 | 38 | const RegisterTemplate: React.FC = ({ children }) => { 39 | return ( 40 | 41 |

환영합니다!

42 |
기본 회원 정보를 등록해주세요.
43 |
{children}
44 |
45 | ); 46 | }; 47 | 48 | export default RegisterTemplate; 49 | -------------------------------------------------------------------------------- /src/components/register/__tests__/RegisterTemplate.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import RegisterTemplate, { RegisterTemplateProps } from '../RegisterTemplate'; 4 | 5 | describe('RegisterTemplate', () => { 6 | const setup = (props: Partial = {}) => { 7 | const initialProps: RegisterTemplateProps = { 8 | registered: false, 9 | }; 10 | return render(); 11 | }; 12 | it('renders properly', () => { 13 | setup(); 14 | }); 15 | it('matches snapshot', () => { 16 | const { container } = setup(); 17 | expect(container).toMatchSnapshot(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/register/__tests__/__snapshots__/RegisterTemplate.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`RegisterTemplate matches snapshot 1`] = ` 4 |
5 |
8 |

9 | 환영합니다! 10 |

11 |
14 | 기본 회원 정보를 등록해주세요. 15 |
16 |
19 |
20 |
21 | `; 22 | -------------------------------------------------------------------------------- /src/components/saves/SavesTemplate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import VelogResponsive from '../velog/VelogResponsive'; 3 | import styled from 'styled-components'; 4 | import PageTemplate from '../base/PageTemplate'; 5 | import { themedPalette } from '../../lib/styles/themes'; 6 | import media from '../../lib/styles/media'; 7 | 8 | export interface SavesTemplateProps { 9 | children: React.ReactNode; 10 | } 11 | 12 | const StyledVelogResponsive = styled(VelogResponsive)` 13 | margin-top: 5rem; 14 | & > h1 { 15 | line-height: 1.5; 16 | font-size: 3rem; 17 | margin-top: 0; 18 | margin-bottom: 3rem; 19 | color: ${themedPalette.text1}; 20 | } 21 | 22 | ${media.medium} { 23 | padding-left: 1rem; 24 | padding-right: 1rem; 25 | } 26 | ${media.small} { 27 | width: 100%; 28 | margin-top: 2rem; 29 | & > h1 { 30 | font-size: 2.5rem; 31 | margin-bottom: 1.5rem; 32 | } 33 | } 34 | `; 35 | 36 | function SavesTemplate({ children }: SavesTemplateProps) { 37 | return ( 38 | 39 | 40 |

임시 글 목록

41 | {children} 42 |
43 |
44 | ); 45 | } 46 | 47 | export default SavesTemplate; 48 | -------------------------------------------------------------------------------- /src/components/search/SearchResultInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { themedPalette } from '../../lib/styles/themes'; 3 | import styled from 'styled-components'; 4 | import media from '../../lib/styles/media'; 5 | 6 | const Info = styled.p` 7 | margin-bottom: 4rem; 8 | font-size: 1.125rem; 9 | line-height: 1.5; 10 | ${media.small} { 11 | font-size: 1rem; 12 | margin-bottom: 1rem; 13 | } 14 | color: ${themedPalette.text2}; 15 | b { 16 | color: ${themedPalette.text1}; 17 | } 18 | `; 19 | export interface SearchResultInfoProps { 20 | count: number; 21 | className?: string; 22 | } 23 | 24 | function SearchResultInfo({ count, className }: SearchResultInfoProps) { 25 | if (count === 0) { 26 | return 검색 결과가 없습니다.; 27 | } 28 | return ( 29 | 30 | 총 {count}개의 포스트를 찾았습니다. 31 | 32 | ); 33 | } 34 | 35 | export default SearchResultInfo; 36 | -------------------------------------------------------------------------------- /src/components/search/SearchTemplate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PageTemplate from '../base/PageTemplate'; 3 | import VelogResponsive from '../velog/VelogResponsive'; 4 | import styled from 'styled-components'; 5 | import media from '../../lib/styles/media'; 6 | 7 | const StyledVelogResponsive = styled(VelogResponsive)` 8 | margin-top: 3.5rem; 9 | ${media.medium} { 10 | padding-left: 1rem; 11 | padding-right: 1rem; 12 | margin-top: 2rem; 13 | } 14 | ${media.small} { 15 | margin-top: 0.5rem; 16 | } 17 | `; 18 | 19 | export interface SearchTemplateProps { 20 | children: React.ReactNode; 21 | } 22 | 23 | function SearchTemplate({ children }: SearchTemplateProps) { 24 | return ( 25 | 26 | {children} 27 | 28 | ); 29 | } 30 | 31 | export default SearchTemplate; 32 | -------------------------------------------------------------------------------- /src/components/search/__tests__/SearchInput.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import SearchInput, { SearchInputProps } from '../SearchInput'; 4 | 5 | describe('SearchInput', () => { 6 | const setup = (props: Partial = {}) => { 7 | const initialProps: SearchInputProps = { 8 | onSearch: () => {}, 9 | }; 10 | const utils = render(); 11 | return { 12 | ...utils, 13 | }; 14 | }; 15 | 16 | it('calls onSearch', () => { 17 | const onSearch = jest.fn(); 18 | const { getByPlaceholderText } = setup({ onSearch }); 19 | const input = getByPlaceholderText('검색어를 입력하세요'); 20 | fireEvent.change(input, { 21 | target: { 22 | value: 'Hello World', 23 | }, 24 | }); 25 | expect(input).toHaveAttribute('value', 'Hello World'); 26 | fireEvent.keyPress(input, { 27 | key: 'Enter', 28 | code: 13, 29 | charCode: 13, 30 | }); 31 | expect(onSearch).toBeCalledWith('Hello World'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/components/setting/SettingEditButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { themedPalette } from '../../lib/styles/themes'; 4 | import palette from '../../lib/styles/palette'; 5 | 6 | export type SettingEditButtonProps = { 7 | onClick?: () => void; 8 | customText: string; 9 | }; 10 | 11 | function SettingEditButton({ onClick, customText }: SettingEditButtonProps) { 12 | return {customText}; 13 | } 14 | 15 | SettingEditButton.defaultProps = { 16 | customText: '수정', 17 | }; 18 | 19 | const StyledButton = styled.button` 20 | outline: none; 21 | padding: 0; 22 | border: none; 23 | display: inline; 24 | font-size: 1rem; 25 | line-height: 1.5; 26 | color: ${themedPalette.primary1}; 27 | text-decoration: underline; 28 | background: none; 29 | cursor: pointer; 30 | &:hover, 31 | &:focus { 32 | color: ${palette.teal4}; 33 | } 34 | `; 35 | 36 | export default SettingEditButton; 37 | -------------------------------------------------------------------------------- /src/components/setting/SettingEmailSuccess.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { MdCheck } from 'react-icons/md'; 3 | import styled from 'styled-components'; 4 | import palette from '../../lib/styles/palette'; 5 | 6 | function SettingEmailSuccess() { 7 | return ( 8 | 9 | 10 |
11 | 메일이 전송되었습니다. 받은 편지함을 확인하세요. 12 |
13 |
14 | ); 15 | } 16 | 17 | const SettingEmailSuccessBlock = styled.div` 18 | display: flex; 19 | align-items: center; 20 | padding-left: 0.75rem; 21 | padding-right: 0.75rem; 22 | color: ${palette.teal6}; 23 | white-space: pre; 24 | .icon { 25 | margin-right: 10px; 26 | } 27 | .description { 28 | font-size: 0.875rem; 29 | text-align: center; 30 | } 31 | `; 32 | 33 | export default SettingEmailSuccess; 34 | -------------------------------------------------------------------------------- /src/components/setting/SettingInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLProps } from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | import { themedPalette } from '../../lib/styles/themes'; 4 | 5 | export type SettingInputProps = { 6 | fullWidth?: boolean; 7 | onChange: (e: React.ChangeEvent) => void; 8 | } & Omit, 'ref' | 'as' | 'onChange'>; 9 | 10 | function SettingInput(props: SettingInputProps) { 11 | return ; 12 | } 13 | 14 | const StyledInput = styled.input<{ fullWidth?: boolean }>` 15 | display: block; 16 | border: 1px solid ${themedPalette.border3}; 17 | background: ${themedPalette.bg_element1}; 18 | padding: 0.5rem; 19 | color: ${themedPalette.text2}; 20 | font-size: 1rem; 21 | line-height: 1rem; 22 | outline: none; 23 | border-radius: 4px; 24 | &:focus { 25 | border: 1px solid ${themedPalette.border1}; 26 | } 27 | ${(props) => 28 | props.fullWidth && 29 | css` 30 | width: 100%; 31 | `} 32 | `; 33 | 34 | export default SettingInput; 35 | -------------------------------------------------------------------------------- /src/components/setting/SettingUnregisterRow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Button from '../common/Button'; 3 | import SettingRow from './SettingRow'; 4 | import PopupOKCancel from '../common/PopupOKCancel'; 5 | 6 | export type SettingUnregisterRowProps = { 7 | onUnregister: () => void; 8 | }; 9 | 10 | function SettingUnregisterRow({ onUnregister }: SettingUnregisterRowProps) { 11 | const [ask, setAsk] = useState(false); 12 | return ( 13 | <> 14 | 18 | 21 | 22 | setAsk(false)} 26 | onConfirm={onUnregister} 27 | > 28 | 정말로 탈퇴 하시겠습니까? 29 | 30 | 31 | ); 32 | } 33 | 34 | export default SettingUnregisterRow; 35 | -------------------------------------------------------------------------------- /src/components/velog/SeriesActionButtons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import EditRemoveGroup from '../common/EditRemoveGroup'; 4 | import Button from '../common/Button'; 5 | 6 | const SeriesActionButtonsBlock = styled.div` 7 | display: flex; 8 | justify-content: flex-end; 9 | height: 2rem; 10 | align-items: center; 11 | `; 12 | 13 | export interface SeriesActionButtonsProps { 14 | onEdit: () => void; 15 | onApply: () => void; 16 | editing: boolean; 17 | onRemove: () => void; 18 | } 19 | 20 | const SeriesActionButtons = ({ 21 | onEdit, 22 | onApply, 23 | onRemove, 24 | editing, 25 | }: SeriesActionButtonsProps) => { 26 | return ( 27 | 28 | {editing ? ( 29 | 30 | ) : ( 31 | 32 | 33 | 34 | 35 | )} 36 | 37 | ); 38 | }; 39 | 40 | export default SeriesActionButtons; 41 | -------------------------------------------------------------------------------- /src/components/velog/SeriesSorterAligner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const SeriesSorterAlignerBlock = styled.div` 5 | display: flex; 6 | justify-content: flex-end; 7 | margin-top: 1rem; 8 | `; 9 | 10 | export interface SeriesSorterAlignerProps {} 11 | 12 | const SeriesSorterAligner: React.FC = ({ 13 | children, 14 | }) => { 15 | return {children}; 16 | }; 17 | 18 | export default SeriesSorterAligner; 19 | -------------------------------------------------------------------------------- /src/components/velog/SideArea.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import media from '../../lib/styles/media'; 4 | 5 | export type SideAreaProps = { 6 | children: React.ReactNode; 7 | }; 8 | 9 | function SideArea({ children }: SideAreaProps) { 10 | return ( 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | 17 | const Wrapper = styled.div` 18 | position: relative; 19 | `; 20 | 21 | const Block = styled.div` 22 | position: absolute; 23 | 24 | width: 11.5rem; 25 | left: -13.5rem; 26 | 27 | ${media.large} { 28 | display: none; 29 | } 30 | `; 31 | 32 | export default SideArea; 33 | -------------------------------------------------------------------------------- /src/components/velog/UserTags.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import UserTagVerticalList from './UserTagVerticalList'; 4 | import useUserTags from './hooks/useUserTags'; 5 | import UserTagHorizontalList from './UserTagHorizontalList'; 6 | 7 | export type UserTagsProps = { 8 | username: string; 9 | tag: string | null; 10 | }; 11 | 12 | function UserTags({ username, tag }: UserTagsProps) { 13 | const { data, loading } = useUserTags(username); 14 | if (!data || loading) return null; 15 | 16 | return ( 17 | <> 18 | 24 | 30 | 31 | ); 32 | } 33 | 34 | export default UserTags; 35 | -------------------------------------------------------------------------------- /src/components/velog/VelogAboutEdit.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import MarkdownEditor from '../common/MarkdownEditor'; 4 | 5 | const VelogAboutEditBlock = styled.div``; 6 | 7 | export interface VelogAboutEditProps { 8 | onChangeMarkdown: (markdown: string) => void; 9 | initialMarkdown: string; 10 | } 11 | 12 | const VelogAboutEdit = ({ 13 | onChangeMarkdown, 14 | initialMarkdown, 15 | }: VelogAboutEditProps) => { 16 | return ( 17 | 18 | 22 | 23 | ); 24 | }; 25 | 26 | export default VelogAboutEdit; 27 | -------------------------------------------------------------------------------- /src/components/velog/VelogAboutRightButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Button from '../common/Button'; 4 | 5 | const VelogAboutRightButtonBlock = styled.div` 6 | display: flex; 7 | margin-bottom: 1.5rem; 8 | justify-content: flex-end; 9 | `; 10 | 11 | export interface VelogAboutRightButtonProps { 12 | edit: boolean; 13 | onClick: () => void; 14 | } 15 | 16 | const VelogAboutRightButton = ({ 17 | edit, 18 | onClick, 19 | }: VelogAboutRightButtonProps) => { 20 | return ( 21 | 22 | 25 | 26 | ); 27 | }; 28 | 29 | export default VelogAboutRightButton; 30 | -------------------------------------------------------------------------------- /src/components/velog/VelogResponsive.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import media from '../../lib/styles/media'; 4 | 5 | const VelogResponsiveBlock = styled.div` 6 | width: 768px; 7 | margin-left: auto; 8 | margin-right: auto; 9 | ${media.small} { 10 | width: 100%; 11 | } 12 | `; 13 | 14 | export interface VelogResponsiveProps { 15 | className?: string; 16 | style?: React.CSSProperties; 17 | onClick?: () => void; 18 | } 19 | 20 | const VelogResponsive: React.FC = ({ 21 | children, 22 | className, 23 | style, 24 | onClick, 25 | }) => { 26 | return ( 27 | 33 | ); 34 | }; 35 | 36 | export default VelogResponsive; 37 | -------------------------------------------------------------------------------- /src/components/velog/VelogSearchInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import SearchInput from '../search/SearchInput'; 4 | 5 | const Section = styled.section` 6 | display: flex; 7 | justify-content: flex-end; 8 | margin-bottom: 2rem; 9 | `; 10 | 11 | export interface VelogSearchInputProps { 12 | onSearch: (keyword: string) => void; 13 | initial: string; 14 | } 15 | 16 | function VelogSearchInput(props: VelogSearchInputProps) { 17 | return ( 18 |
19 | 20 |
21 | ); 22 | } 23 | 24 | export default VelogSearchInput; 25 | -------------------------------------------------------------------------------- /src/components/velog/__tests__/SeriesItem.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import SeriesItem, { SeriesItemProps } from '../SeriesItem'; 4 | import { formatDate } from '../../../lib/utils'; 5 | import { MemoryRouter } from 'react-router'; 6 | 7 | describe('SeriesItem', () => { 8 | const setup = (props: Partial = {}) => { 9 | const initialProps: SeriesItemProps = { 10 | thumbnail: null, 11 | name: '시리즈 이름', 12 | postsCount: 5, 13 | lastUpdate: '2019-08-21T14:27:30.629Z', 14 | username: 'velopert', 15 | urlSlug: 'sample-url-slug', 16 | }; 17 | const utils = render( 18 | 19 | 20 | , 21 | ); 22 | return { 23 | ...utils, 24 | }; 25 | }; 26 | it('renders properly', () => { 27 | const { getByText } = setup(); 28 | getByText('시리즈 이름'); 29 | getByText('5개의 포스트'); 30 | getByText(`마지막 업데이트 ${formatDate('2019-08-21T14:27:30.629Z')}`); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/velog/__tests__/SeriesList.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import SeriesList, { SeriesListProps } from '../SeriesList'; 4 | import { userSeriesListData } from '../../../lib/graphql/__data__/user.data'; 5 | import { MemoryRouter } from 'react-router'; 6 | 7 | describe('SeriesList', () => { 8 | const setup = (props: Partial = {}) => { 9 | const initialProps: SeriesListProps = { 10 | username: 'velopert', 11 | list: userSeriesListData.user.series_list, 12 | }; 13 | const utils = render( 14 | 15 | 16 | , 17 | ); 18 | return { 19 | ...utils, 20 | }; 21 | }; 22 | it('renders properly', () => { 23 | const { getByText } = setup(); 24 | getByText('sample-series'); 25 | getByText('ㅋㅋㅋㅋㅋ'); 26 | getByText('ㅅㄹㅈ'); 27 | getByText('zxcvxcvxcvzxcv'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/velog/__tests__/SeriesPostItem.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { fireEvent } from '@testing-library/react'; 3 | import SeriesPostItem, { SeriesPostItemProps } from '../SeriesPostItem'; 4 | import { seriesData } from '../../../lib/graphql/__data__/series.data'; 5 | import renderWithProviders from '../../../lib/renderWithProviders'; 6 | 7 | const [{ post: firstPost }] = seriesData.series.series_posts; 8 | 9 | describe('SeriesPostItem', () => { 10 | const setup = (props: Partial = {}) => { 11 | const initialProps: SeriesPostItemProps = { 12 | date: firstPost.released_at, 13 | description: firstPost.short_description, 14 | thumbnail: firstPost.thumbnail, 15 | title: firstPost.title, 16 | urlSlug: firstPost.url_slug, 17 | username: 'velopert', 18 | index: 1, 19 | }; 20 | const utils = renderWithProviders( 21 | , 22 | ); 23 | return { 24 | ...utils, 25 | }; 26 | }; 27 | it('renders properly', () => { 28 | const { getByText, history } = setup(); 29 | const a = getByText(firstPost.title) as HTMLAnchorElement; 30 | fireEvent.click(a); 31 | expect(history.location.pathname).toBe('/@velopert/redux-or-mobx'); 32 | getByText(/리액트 생태계에서 사용되는/); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/components/velog/__tests__/SeriesPostsTemplate.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import SeriesPostsTemplate, { 4 | SeriesPostsTemplateProps, 5 | } from '../SeriesPostsTemplate'; 6 | 7 | describe('SeriesPostsTemplate', () => { 8 | const setup = (props: Partial = {}) => { 9 | const initialProps: SeriesPostsTemplateProps = { 10 | name: '시리즈 이름', 11 | }; 12 | const utils = render(); 13 | return { 14 | ...utils, 15 | }; 16 | }; 17 | it('renders properly', () => { 18 | const { getByText } = setup(); 19 | getByText('시리즈 이름'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/velog/__tests__/VelogAboutRightButton.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import VelogAboutRightButton, { 4 | VelogAboutRightButtonProps, 5 | } from '../VelogAboutRightButton'; 6 | 7 | describe('VelogAboutRightButton', () => { 8 | const setup = (props: Partial = {}) => { 9 | const initialProps: VelogAboutRightButtonProps = { 10 | edit: false, 11 | onClick: () => {}, 12 | }; 13 | const utils = render( 14 | , 15 | ); 16 | return { 17 | ...utils, 18 | }; 19 | }; 20 | it('renders properly', () => { 21 | const { getByText } = setup(); 22 | getByText('수정하기'); 23 | }); 24 | it('calls onClick', () => { 25 | const onClick = jest.fn(); 26 | const { getByText } = setup({ 27 | onClick, 28 | }); 29 | const button = getByText('수정하기'); 30 | fireEvent.click(button); 31 | expect(onClick).toBeCalled(); 32 | }); 33 | it('shows save button when edit=true', () => { 34 | const { getByText } = setup({ 35 | edit: true, 36 | }); 37 | getByText('저장하기'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/velog/hooks/useUserTags.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/react-hooks'; 2 | import { GET_USER_TAGS, GetUserTagsResponse } from '../../../lib/graphql/tags'; 3 | 4 | export default function useUserTags(username: string) { 5 | const { data, loading } = useQuery(GET_USER_TAGS, { 6 | variables: { 7 | username, 8 | }, 9 | fetchPolicy: 'network-only', 10 | }); 11 | 12 | return { 13 | data: data 14 | ? { tags: data.userTags.tags, postsCount: data.userTags.posts_count } 15 | : null, 16 | loading, 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/write/AskChangeEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PopupOKCancel from '../common/PopupOKCancel'; 3 | import { WriteMode } from '../../modules/write'; 4 | 5 | export interface AskChangeEditorProps { 6 | visible: boolean; 7 | onCancel: () => void; 8 | onConfirm: () => void; 9 | convertTo: WriteMode; 10 | } 11 | 12 | const AskChangeEditor: React.FC = ({ 13 | visible, 14 | onCancel, 15 | onConfirm, 16 | convertTo, 17 | }) => { 18 | if (convertTo === WriteMode.MARKDOWN) { 19 | return ( 20 | 26 | 에디터 모드를 전환하시겠습니까? 27 | {/*
28 | 확인을 누르시면 지금까지 입력하신 모든 내용이 마크다운 포맷으료 29 | 변환됩니다. 30 |
31 |
32 |
33 | 주의: 변환 후 코드블록의 언어 타입을 수동으로 입력해주셔야 34 | 합니다. 35 |
*/} 36 |
37 | ); 38 | } 39 | return ( 40 | 46 | 에디터 모드를 전환하시겠습니까? 47 | 48 | ); 49 | }; 50 | 51 | export default AskChangeEditor; 52 | -------------------------------------------------------------------------------- /src/components/write/PublishActionButtons.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import Button from '../common/Button'; 4 | import media from '../../lib/styles/media'; 5 | 6 | const PublishActionButtonsBlock = styled.div` 7 | display: flex; 8 | justify-content: flex-end; 9 | margin-top: 0.5rem; 10 | ${media.custom(767)} { 11 | margin-top: 2rem; 12 | } 13 | `; 14 | 15 | export interface PublishActionButtonsProps { 16 | onCancel: () => void; 17 | onPublish: () => void; 18 | edit: boolean; 19 | isLoading: boolean; 20 | } 21 | 22 | const PublishActionButtons: React.FC = ({ 23 | onCancel, 24 | onPublish, 25 | edit, 26 | isLoading, 27 | }) => { 28 | return ( 29 | 30 | 38 | 46 | 47 | ); 48 | }; 49 | 50 | export default PublishActionButtons; 51 | -------------------------------------------------------------------------------- /src/components/write/PublishSection.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import { themedPalette } from '../../lib/styles/themes'; 4 | 5 | const PublishSectionBlock = styled.section` 6 | & > h3 { 7 | font-size: 1.3125rem; 8 | color: ${themedPalette.text1}; 9 | line-height: 1.5; 10 | margin-bottom: 0.5rem; 11 | margin-top: 0; 12 | } 13 | .contents { 14 | } 15 | 16 | & + & { 17 | margin-top: 1.5rem; 18 | } 19 | `; 20 | 21 | export interface PublishSectionProps extends React.HTMLProps { 22 | title: string; 23 | children: React.ReactNode; 24 | } 25 | 26 | const PublishSection: React.FC = ({ 27 | title, 28 | children, 29 | ...rest 30 | }) => { 31 | const htmlProps = rest as any; 32 | return ( 33 | 34 |

{title}

35 |
{children}
36 |
37 | ); 38 | }; 39 | 40 | export default PublishSection; 41 | -------------------------------------------------------------------------------- /src/components/write/PublishSeriesConfigButtons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Button from '../common/Button'; 4 | 5 | const PublishSeriesConfigButtonsBlock = styled.div` 6 | margin-top: 1rem; 7 | padding-top: 1px; 8 | display: flex; 9 | justify-content: flex-end; 10 | `; 11 | 12 | export interface PublishSeriesConfigButtonsProps { 13 | onCancel: () => any; 14 | onConfirm: () => any; 15 | disableConfirm: boolean; 16 | } 17 | 18 | const PublishSeriesConfigButtons: React.FC = ({ 19 | onCancel, 20 | onConfirm, 21 | disableConfirm, 22 | }) => { 23 | return ( 24 | 25 | 28 | 31 | 32 | ); 33 | }; 34 | 35 | export default PublishSeriesConfigButtons; 36 | -------------------------------------------------------------------------------- /src/components/write/PublishSeriesConfigTemplate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import PublishSection from './PublishSection'; 4 | 5 | export interface PublishSeriesConfigTemplateProps { 6 | buttons: React.ReactNode; 7 | } 8 | 9 | const StyledPublishSection = styled(PublishSection)` 10 | display: flex; 11 | flex: 1; 12 | flex-direction: column; 13 | .contents { 14 | display: flex; 15 | flex-direction: column; 16 | flex: 1; 17 | } 18 | `; 19 | 20 | const SeriesBlock = styled.div` 21 | display: flex; 22 | width: 100%; 23 | flex: 1; 24 | flex-direction: column; 25 | border-radius: 2px; 26 | box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.03); 27 | overflow: hidden; 28 | `; 29 | 30 | const PublishSeriesConfigTemplate: React.FC< 31 | PublishSeriesConfigTemplateProps 32 | > = ({ children, buttons }) => { 33 | return ( 34 | 35 | {children} 36 | {buttons} 37 | 38 | ); 39 | }; 40 | 41 | export default PublishSeriesConfigTemplate; 42 | -------------------------------------------------------------------------------- /src/components/write/TitleTextarea.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | import TextareaAutosize from 'react-textarea-autosize'; 3 | import { themedPalette } from '../../lib/styles/themes'; 4 | import { mediaQuery } from '../../lib/styles/media'; 5 | 6 | const style = css` 7 | background: transparent; 8 | display: block; 9 | padding: 0; 10 | font-size: 2.75rem; 11 | ${mediaQuery(767)} { 12 | font-size: 1.8rem; 13 | } 14 | width: 100%; 15 | resize: none; 16 | line-height: 1.5; 17 | outline: none; 18 | border: none; 19 | font-weight: bold; 20 | color: ${themedPalette.text1}; 21 | &::placeholder { 22 | color: ${themedPalette.text3}; 23 | } 24 | `; 25 | 26 | export const TitleTextareaForSSR = styled.textarea` 27 | ${style} 28 | `; 29 | 30 | const TitleTextarea = styled(TextareaAutosize)` 31 | ${style} 32 | `; 33 | 34 | export default TitleTextarea; 35 | -------------------------------------------------------------------------------- /src/components/write/__tests__/MarkdownPreview.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import MarkdownPreview, { MarkdownPreviewProps } from '../MarkdownPreview'; 4 | import { HelmetProvider } from 'react-helmet-async'; 5 | 6 | describe('MarkdownPreview', () => { 7 | const setup = (props: Partial = {}) => { 8 | const initialProps: MarkdownPreviewProps = { 9 | markdown: '', 10 | title: '', 11 | }; 12 | const utils = render( 13 | 14 | 15 | , 16 | ); 17 | return { 18 | ...utils, 19 | }; 20 | }; 21 | it('renders properly', () => { 22 | setup(); 23 | }); 24 | it('matches snapshot', () => { 25 | const { container } = setup(); 26 | expect(container).toMatchSnapshot(); 27 | }); 28 | it('renders markdown', () => { 29 | const utils = setup({ 30 | markdown: '## hello world', 31 | }); 32 | const element = utils.getByText('hello world'); 33 | expect(element.tagName).toBe('H2'); 34 | }); 35 | it('renders title', () => { 36 | const utils = setup({ 37 | title: 'this is the title', 38 | }); 39 | utils.getByText('this is the title'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/write/__tests__/PublishScreenTemplate.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import PublishScreenTemplate, { 4 | PublishScreenTemplateProps, 5 | } from '../PublishScreenTemplate'; 6 | 7 | describe('PublishScreenTemplate', () => { 8 | const setup = (props: Partial = {}) => { 9 | const initialProps: PublishScreenTemplateProps = { 10 | visible: true, 11 | left: 'left', 12 | right: 'right', 13 | }; 14 | const utils = render( 15 | , 16 | ); 17 | return { 18 | ...utils, 19 | }; 20 | }; 21 | it('renders properly', () => { 22 | setup(); 23 | }); 24 | it('matches snapshot', () => { 25 | const { container } = setup(); 26 | expect(container).toMatchSnapshot(); 27 | }); 28 | it('is not visible when visible is false', () => { 29 | const utils = setup({ visible: false }); 30 | expect(utils.queryByText('left')).toBeFalsy(); 31 | }); 32 | it('shows left and right', () => { 33 | const utils = setup(); 34 | utils.getByText('left'); 35 | utils.getByText('right'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/write/__tests__/PublishSection.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import PublishSection, { PublishSectionProps } from '../PublishSection'; 4 | 5 | describe('PublishSection', () => { 6 | const setup = (props: Partial = {}) => { 7 | const initialProps: PublishSectionProps = { 8 | title: '섹션 제목', 9 | children: '섹션 내용', 10 | }; 11 | const utils = render(); 12 | return { 13 | ...utils, 14 | }; 15 | }; 16 | it('renders properly', () => { 17 | setup(); 18 | }); 19 | it('matches snapshot', () => { 20 | const { container } = setup(); 21 | expect(container).toMatchSnapshot(); 22 | }); 23 | it('renders title and children', () => { 24 | const utils = setup(); 25 | utils.getByText('섹션 제목'); 26 | utils.getByText('섹션 내용'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/write/__tests__/PublishURLSetting.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import PublishURLSetting, { 4 | PublishURLSettingProps, 5 | } from '../PublishURLSetting'; 6 | 7 | describe('PublishURLSetting', () => { 8 | const setup = (props: Partial = {}) => { 9 | const initialProps: PublishURLSettingProps = { 10 | username: 'velog', 11 | urlSlug: 'url-slug', 12 | onChangeUrlSlug: () => {}, 13 | }; 14 | const utils = render(); 15 | return { 16 | ...utils, 17 | }; 18 | }; 19 | it('renders properly', () => { 20 | setup(); 21 | }); 22 | it('matches snapshot', () => { 23 | const { container } = setup(); 24 | expect(container).toMatchSnapshot(); 25 | }); 26 | it('shows username props', () => { 27 | const utils = setup(); 28 | utils.getByText('/@velog/'); 29 | }); 30 | it('urlSlug is working properly', () => { 31 | const onChangeUrlSlug = jest.fn(); 32 | const utils = setup({ 33 | onChangeUrlSlug, 34 | }); 35 | const input = utils.getByDisplayValue('url-slug'); 36 | fireEvent.change(input, { target: { value: 'hello-world' } }); 37 | expect(onChangeUrlSlug).toBeCalledWith('hello-world'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/write/__tests__/__snapshots__/AskChangeEditor.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`AskChangeEditor matches snapshot 1`] = ` 4 |
5 |
8 |
11 |
14 |
17 |

18 | 마크다운 에디터로 전환 19 |

20 |
23 | 에디터 모드를 전환하시겠습니까? 24 |
25 |
28 | 34 | 40 |
41 |
42 |
43 |
44 |
45 | `; 46 | -------------------------------------------------------------------------------- /src/components/write/__tests__/__snapshots__/MarkdownPreview.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`MarkdownPreview matches snapshot 1`] = ` 4 |
5 |
9 |

12 |
15 |
18 |
19 |
20 |

21 | `; 22 | -------------------------------------------------------------------------------- /src/components/write/__tests__/__snapshots__/PublishActionButtons.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`PublishActionButtons matches snapshot 1`] = ` 4 |
5 |
8 | 15 | 22 |
23 |
24 | `; 25 | -------------------------------------------------------------------------------- /src/components/write/__tests__/__snapshots__/PublishPreview.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`PublishPreview matches snapshot 1`] = ` 4 |
5 |
8 |

9 | 포스트 미리보기 10 |

11 |
14 |
17 |
20 |
23 | 24 | vector-image.svg 25 | 26 | 31 |
32 |
33 |
34 |
37 |

38 | 타이틀 39 |

40 |