From 1305f5bc92c1c3d7964c6c4b4f337feb04766876 Mon Sep 17 00:00:00 2001 From: Michal Miszczyszyn Date: Sat, 11 Jun 2022 21:48:29 +0200 Subject: [PATCH 001/175] chore: migrate to Turborepo --- .circleci/config.yml | 84 - apps/www/.editorconfig => .editorconfig | 0 .eslintignore | 1 - .eslintrc | 18 - .eslintrc.js | 10 + .fossa.yml | 22 - .github/CODEOWNERS | 9 - .github/FUNDING.yml | 12 - .github/workflows/auto-approve-dependabot.yml | 14 - .github/workflows/codeql-analysis.yml | 54 - .github/workflows/deploy.yml | 49 - .github/workflows/test-PR.yml | 119 - .gitignore | 106 +- .nvmrc | 2 +- .vscode/settings.json | 44 - CONTRIBUTING.md | 2 +- apps/api/.editorconfig | 21 - apps/api/.env.dev | 13 - apps/api/.eslintrc | 15 - apps/api/.sequelizerc | 6 - apps/api/.version | 1 - apps/api/Dockerfile | 10 - apps/api/apiTypes.ts | 136 - apps/api/app.js | 4 - apps/api/benchmark.ts | 23 - apps/api/docker-compose.yml | 11 - apps/api/migrate.ts | 55 - apps/api/nodemon.json | 6 - apps/api/package.json | 118 - apps/api/src/config/database.js | 28 - apps/api/src/config/index.ts | 38 - apps/api/src/db.ts | 69 - apps/api/src/index.ts | 47 - .../src/migrations/20180814074541-initial.ts | 201 - .../migrations/20180924111648-initial-seed.ts | 66 - .../20190411134800-add-social-login.ts | 15 - .../migrations/20190411164500-add-session.ts | 32 - .../20190417172900-add-question-vote.ts | 28 - apps/api/src/models-consts.ts | 19 - apps/api/src/models/Question.ts | 205 - apps/api/src/models/QuestionCategory.ts | 24 - apps/api/src/models/QuestionLevel.ts | 24 - apps/api/src/models/QuestionStatus.ts | 24 - apps/api/src/models/QuestionVote.ts | 31 - apps/api/src/models/Session.ts | 57 - apps/api/src/models/User.ts | 80 - apps/api/src/models/UserRole.ts | 24 - .../modules/health-check/healthCheckRoutes.ts | 23 - .../modules/hello-world/helloWorldRoute.ts | 17 - .../question-votes/questionVotesRoutes.ts | 104 - .../question-votes/questionVotesSchemas.ts | 15 - .../questionRoutes.integration.test.ts | 113 - .../src/modules/questions/questionRoutes.ts | 278 - .../src/modules/questions/questionSchemas.ts | 77 - apps/api/src/plugins/auth/github.ts | 112 - apps/api/src/plugins/auth/index.ts | 262 - apps/api/src/plugins/auth/session.ts | 40 - apps/api/src/plugins/cls/cls.ts | 70 - apps/api/src/plugins/cls/context.ts | 45 - apps/api/src/server.ts | 276 - apps/api/src/shim.d.ts | 0 apps/api/src/tests/integrationTestsUtils.ts | 54 - apps/api/src/tests/utils.test.ts | 23 - apps/api/src/utils/utils.ts | 85 - apps/api/test/.mocharc.js | 6 - apps/api/test/setup-env.js | 7 - apps/api/tsconfig.json | 16 - apps/web/.eslintrc.js | 4 + apps/web/README.md | 30 + apps/web/next-env.d.ts | 5 + apps/web/next.config.js | 5 + apps/web/package.json | 26 + apps/web/pages/index.tsx | 10 + apps/web/tsconfig.json | 5 + apps/www/.babelrc | 4 - apps/www/.env | 6 - apps/www/.eslintrc | 9 - apps/www/app.js | 107 - apps/www/components/activeLink/ActiveLink.tsx | 129 - .../adminQuestions/AdminQuestions.tsx | 114 - .../animateProperty/AnimateProperty.tsx | 117 - apps/www/components/appLogo/AppLogo.tsx | 28 - .../components/appLogo/appLogo.module.scss | 26 - apps/www/components/container/Container.tsx | 14 - .../container/container.module.scss | 13 - .../errorBoundary/ErrorBoundary.tsx | 39 - apps/www/components/footer/AppFooter.tsx | 47 - .../components/footer/appFooter.module.scss | 50 - .../headers/ctaHeader/CtaHeader.tsx | 106 - .../headers/ctaHeader/ctaHeader.module.scss | 92 - .../navigationHeader/NavigationHeader.tsx | 101 - .../darkModeSwitcher/DarkModeSwitcher.tsx | 63 - .../darkModeSwitcher.module.scss | 42 - .../loginStatusLink/LoginStatusLink.tsx | 44 - .../navigationHeader.module.scss | 182 - apps/www/components/layout/AppSpinner.tsx | 40 - apps/www/components/layout/Layout.tsx | 45 - .../components/layout/appSpinner.module.scss | 46 - apps/www/components/layout/layout.module.scss | 10 - apps/www/components/loginForm/LoginForm.tsx | 63 - .../loginForm/loginForm.module.scss | 58 - .../components/markdownText/MarkdownText.tsx | 78 - .../AddQuestionConfirmationModal.tsx | 79 - .../addQuestionConfirmationModal.module.scss | 41 - .../addQuestionModal/AddQuestionModal.tsx | 170 - .../AddQuestionModalContent.tsx | 84 - .../AddQuestionModalFooter.tsx | 42 - .../addQuestionModal.module.scss | 74 - .../components/modals/appModals/AppModals.tsx | 89 - .../components/modals/baseModal/BaseModal.tsx | 103 - .../modals/baseModal/baseModal.module.scss | 166 - .../modals/baseModal/fixBodyService.ts | 61 - .../questionEditor/QuestionEditor.tsx | 205 - .../questionEditor/questionEditor.module.scss | 92 - .../questions/allQuestions/AllQuestions.tsx | 148 - .../allQuestions/allQuestions.module.scss | 34 - .../allQuestionsFooter/AllQuestionsFooter.tsx | 16 - .../allQuestionsFooter.module.scss | 19 - .../allQuestionsHeader/AllQuestionsHeader.tsx | 38 - .../allQuestionsHeader.module.scss | 9 - .../MobileActionButtons.tsx | 76 - .../mobileActionButtons.module.scss | 39 - .../questions/questionsList/QuestionsList.tsx | 66 - .../questionItem/QuestionItem.tsx | 377 - .../questionItem/QuestionVoting.tsx | 98 - .../questionItem/questionItem.module.scss | 277 - .../questionsList/questionsList.module.scss | 10 - .../QuestionsListLayout.tsx | 13 - .../questionsListLayout.module.scss | 15 - .../questionsSidebar/QuestionsSidebar.tsx | 40 - .../levelFilter/LevelFilter.tsx | 56 - .../levelFilter/levelFilter.module.scss | 143 - .../questionsSidebar.module.scss | 83 - .../technologyFilter/TechnologyFilter.tsx | 44 - .../technologyFilter.module.scss | 71 - .../components/questions/questionsUtils.ts | 8 - .../NoQuestionsSelectedInfo.tsx | 25 - .../selectedQuestions/SelectedQuestions.tsx | 84 - .../noQuestionsSelectedInfo.module.scss | 17 - .../selectedQuestions.module.scss | 49 - .../QuestionsPagination.tsx | 59 - .../questionsPagination.module.scss | 36 - apps/www/components/userAvatar/UserAvatar.tsx | 21 - .../userAvatar/userAvatar.module.scss | 5 - apps/www/constants/level.ts | 14 - apps/www/constants/technology-icon-items.ts | 31 - apps/www/contexts/UIContextProvider.tsx | 112 - apps/www/cypress.json | 3 - apps/www/cypress/fixtures/example.json | 5 - apps/www/cypress/integration/a11y.spec.js | 20 - apps/www/cypress/integration/basic.spec.js | 87 - apps/www/cypress/plugins/index.js | 21 - apps/www/cypress/support/commands.js | 25 - apps/www/cypress/support/index.js | 21 - apps/www/fast-check-arbitraries.ts | 77 - apps/www/iconmoon-selection.json | 453 - apps/www/iconmoon.json | 27337 ---------------- apps/www/jest.config.js | 12 - apps/www/jest.setup.js | 0 apps/www/next-env.d.ts | 23 - apps/www/next.config.js | 172 - apps/www/package.json | 97 - apps/www/pages/404.tsx | 6 - apps/www/pages/_app.tsx | 157 - apps/www/pages/_document.tsx | 119 - apps/www/pages/_error.tsx | 63 - apps/www/pages/about.tsx | 85 - apps/www/pages/admin.tsx | 37 - apps/www/pages/authors.tsx | 88 - apps/www/pages/index.scss | 13 - apps/www/pages/index.tsx | 11 - apps/www/pages/login.tsx | 32 - apps/www/pages/pages.module.scss | 5 - apps/www/pages/questions/[technology].tsx | 85 - apps/www/pages/questions/index.tsx | 10 - apps/www/pages/questions/p/[id].tsx | 68 - apps/www/pages/regulations.tsx | 329 - apps/www/pages/selected-questions.tsx | 19 - apps/www/pages/sitemap.xml.ts | 63 - apps/www/pages/staticPage.module.scss | 65 - apps/www/polyfills.js | 1 - apps/www/public/android-chrome-192x192.png | Bin 2359 -> 0 bytes apps/www/public/android-chrome-512x512.png | Bin 6097 -> 0 bytes apps/www/public/apple-touch-icon.png | Bin 1159 -> 0 bytes apps/www/public/browserconfig.xml | 9 - apps/www/public/favicon-16x16.png | Bin 438 -> 0 bytes apps/www/public/favicon-32x32.png | Bin 506 -> 0 bytes apps/www/public/favicon.ico | Bin 15086 -> 0 bytes apps/www/public/fonts/devicon.eot | Bin 5152 -> 0 bytes apps/www/public/fonts/devicon.svg | 1 - apps/www/public/fonts/devicon.ttf | Bin 4988 -> 0 bytes apps/www/public/fonts/devicon.woff | Bin 5064 -> 0 bytes apps/www/public/fonts/devicon.woff2 | Bin 2956 -> 0 bytes apps/www/public/images/action-icons/add.svg | 1 - apps/www/public/images/action-icons/add2.svg | 1 - .../images/action-icons/confirmation.svg | 1 - .../public/images/action-icons/download.svg | 1 - apps/www/public/images/action-icons/edit.svg | 1 - .../www/public/images/action-icons/filter.svg | 1 - apps/www/public/images/action-icons/menu.svg | 1 - .../public/images/action-icons/thumbs-up.svg | 1 - .../public/images/action-icons/warning.svg | 1 - apps/www/public/images/arrow-up.svg | 1 - apps/www/public/images/checkbox.svg | 1 - .../www/public/images/delete_forever_icon.svg | 1 - apps/www/public/images/github.svg | 1 - apps/www/public/images/michal_miszczyszyn.jpg | Bin 31660 -> 0 bytes apps/www/public/images/select-purple.svg | 1 - apps/www/public/images/select.svg | 1 - apps/www/public/images/typeofweb-logo.svg | 1 - apps/www/public/img/devfaq-cover-facebook.png | Bin 40214 -> 0 bytes apps/www/public/img/fefaq-cover-facebook.png | Bin 40214 -> 0 bytes apps/www/public/manifest.json | 18 - apps/www/public/mstile-144x144.png | Bin 1090 -> 0 bytes apps/www/public/mstile-150x150.png | Bin 1052 -> 0 bytes apps/www/public/robots.txt | 4 - apps/www/public/safari-pinned-tab.svg | 1 - apps/www/redux/actions.ts | 230 - apps/www/redux/reducers/auth.ts | 56 - apps/www/redux/reducers/index.ts | 29 - apps/www/redux/reducers/oneQuestion.ts | 28 - apps/www/redux/reducers/questions.ts | 110 - apps/www/redux/reducers/routeDetails.ts | 50 - apps/www/redux/reducers/selectedLevels.ts | 15 - apps/www/redux/reducers/selectedQuestions.ts | 39 - apps/www/redux/selectors/selectors.ts | 97 - apps/www/redux/store.ts | 21 - apps/www/redux/types.ts | 21 - apps/www/services/Api.ts | 267 - apps/www/styles/_buttons.scss | 121 - apps/www/styles/_devicon.scss | 100 - apps/www/styles/_icons.scss | 55 - apps/www/styles/_mixins.scss | 45 - apps/www/styles/_typography.scss | 38 - apps/www/styles/common.scss | 1 - apps/www/styles/global.scss | 121 - apps/www/styles/variables.scss | 159 - apps/www/tsconfig.json | 24 - apps/www/utils/analytics.ts | 44 - apps/www/utils/env.ts | 42 - apps/www/utils/hooks.tsx | 48 - apps/www/utils/redirect.spec.ts | 123 - apps/www/utils/redirect.ts | 86 - apps/www/utils/styles.ts | 11 - apps/www/utils/types.ts | 31 - dangerfile.ts | 249 - lerna.json | 6 - package.json | 69 +- packages/eslint-config-custom/index.js | 7 + packages/eslint-config-custom/package.json | 14 + packages/tsconfig/README.md | 3 + packages/tsconfig/base.json | 20 + packages/tsconfig/nextjs.json | 22 + packages/tsconfig/package.json | 11 + packages/tsconfig/react-library.json | 11 + packages/ui/Button.tsx | 4 + packages/ui/index.tsx | 2 + packages/ui/package.json | 19 + packages/ui/tsconfig.json | 5 + scripts/.eslintrc | 3 - scripts/circleGetBaseBranch.ts | 51 - scripts/deploy.sh | 5 - scripts/lighthouse.ts | 231 - scripts/sizeSnapshot/create.ts | 176 - scripts/ssh-script-deploy.sh | 100 - scripts/tsconfig.json | 9 - scripts/waitForVercel.ts | 69 - tsconfig.json | 29 - turbo.json | 14 + yarn.lock | 19071 +---------- 270 files changed, 2073 insertions(+), 58494 deletions(-) delete mode 100644 .circleci/config.yml rename apps/www/.editorconfig => .editorconfig (100%) delete mode 120000 .eslintignore delete mode 100644 .eslintrc create mode 100644 .eslintrc.js delete mode 100755 .fossa.yml delete mode 100644 .github/CODEOWNERS delete mode 100644 .github/FUNDING.yml delete mode 100644 .github/workflows/auto-approve-dependabot.yml delete mode 100644 .github/workflows/codeql-analysis.yml delete mode 100644 .github/workflows/deploy.yml delete mode 100644 .github/workflows/test-PR.yml delete mode 100644 .vscode/settings.json delete mode 100644 apps/api/.editorconfig delete mode 100644 apps/api/.env.dev delete mode 100644 apps/api/.eslintrc delete mode 100644 apps/api/.sequelizerc delete mode 100644 apps/api/.version delete mode 100644 apps/api/Dockerfile delete mode 100644 apps/api/apiTypes.ts delete mode 100644 apps/api/app.js delete mode 100644 apps/api/benchmark.ts delete mode 100644 apps/api/docker-compose.yml delete mode 100644 apps/api/migrate.ts delete mode 100644 apps/api/nodemon.json delete mode 100644 apps/api/package.json delete mode 100644 apps/api/src/config/database.js delete mode 100644 apps/api/src/config/index.ts delete mode 100644 apps/api/src/db.ts delete mode 100644 apps/api/src/index.ts delete mode 100644 apps/api/src/migrations/20180814074541-initial.ts delete mode 100644 apps/api/src/migrations/20180924111648-initial-seed.ts delete mode 100644 apps/api/src/migrations/20190411134800-add-social-login.ts delete mode 100644 apps/api/src/migrations/20190411164500-add-session.ts delete mode 100644 apps/api/src/migrations/20190417172900-add-question-vote.ts delete mode 100644 apps/api/src/models-consts.ts delete mode 100644 apps/api/src/models/Question.ts delete mode 100644 apps/api/src/models/QuestionCategory.ts delete mode 100644 apps/api/src/models/QuestionLevel.ts delete mode 100644 apps/api/src/models/QuestionStatus.ts delete mode 100644 apps/api/src/models/QuestionVote.ts delete mode 100644 apps/api/src/models/Session.ts delete mode 100644 apps/api/src/models/User.ts delete mode 100644 apps/api/src/models/UserRole.ts delete mode 100644 apps/api/src/modules/health-check/healthCheckRoutes.ts delete mode 100644 apps/api/src/modules/hello-world/helloWorldRoute.ts delete mode 100644 apps/api/src/modules/question-votes/questionVotesRoutes.ts delete mode 100644 apps/api/src/modules/question-votes/questionVotesSchemas.ts delete mode 100644 apps/api/src/modules/questions/questionRoutes.integration.test.ts delete mode 100644 apps/api/src/modules/questions/questionRoutes.ts delete mode 100644 apps/api/src/modules/questions/questionSchemas.ts delete mode 100644 apps/api/src/plugins/auth/github.ts delete mode 100644 apps/api/src/plugins/auth/index.ts delete mode 100644 apps/api/src/plugins/auth/session.ts delete mode 100644 apps/api/src/plugins/cls/cls.ts delete mode 100644 apps/api/src/plugins/cls/context.ts delete mode 100644 apps/api/src/server.ts delete mode 100644 apps/api/src/shim.d.ts delete mode 100644 apps/api/src/tests/integrationTestsUtils.ts delete mode 100644 apps/api/src/tests/utils.test.ts delete mode 100644 apps/api/src/utils/utils.ts delete mode 100644 apps/api/test/.mocharc.js delete mode 100644 apps/api/test/setup-env.js delete mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/.eslintrc.js create mode 100644 apps/web/README.md create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.js create mode 100644 apps/web/package.json create mode 100644 apps/web/pages/index.tsx create mode 100644 apps/web/tsconfig.json delete mode 100644 apps/www/.babelrc delete mode 100644 apps/www/.env delete mode 100644 apps/www/.eslintrc delete mode 100644 apps/www/app.js delete mode 100644 apps/www/components/activeLink/ActiveLink.tsx delete mode 100644 apps/www/components/adminQuestions/AdminQuestions.tsx delete mode 100644 apps/www/components/animateProperty/AnimateProperty.tsx delete mode 100644 apps/www/components/appLogo/AppLogo.tsx delete mode 100644 apps/www/components/appLogo/appLogo.module.scss delete mode 100644 apps/www/components/container/Container.tsx delete mode 100644 apps/www/components/container/container.module.scss delete mode 100644 apps/www/components/errorBoundary/ErrorBoundary.tsx delete mode 100644 apps/www/components/footer/AppFooter.tsx delete mode 100644 apps/www/components/footer/appFooter.module.scss delete mode 100644 apps/www/components/headers/ctaHeader/CtaHeader.tsx delete mode 100644 apps/www/components/headers/ctaHeader/ctaHeader.module.scss delete mode 100644 apps/www/components/headers/navigationHeader/NavigationHeader.tsx delete mode 100644 apps/www/components/headers/navigationHeader/darkModeSwitcher/DarkModeSwitcher.tsx delete mode 100644 apps/www/components/headers/navigationHeader/darkModeSwitcher/darkModeSwitcher.module.scss delete mode 100644 apps/www/components/headers/navigationHeader/loginStatusLink/LoginStatusLink.tsx delete mode 100644 apps/www/components/headers/navigationHeader/navigationHeader.module.scss delete mode 100644 apps/www/components/layout/AppSpinner.tsx delete mode 100644 apps/www/components/layout/Layout.tsx delete mode 100644 apps/www/components/layout/appSpinner.module.scss delete mode 100644 apps/www/components/layout/layout.module.scss delete mode 100644 apps/www/components/loginForm/LoginForm.tsx delete mode 100644 apps/www/components/loginForm/loginForm.module.scss delete mode 100644 apps/www/components/markdownText/MarkdownText.tsx delete mode 100644 apps/www/components/modals/addQuestionConfirmationModal/AddQuestionConfirmationModal.tsx delete mode 100644 apps/www/components/modals/addQuestionConfirmationModal/addQuestionConfirmationModal.module.scss delete mode 100644 apps/www/components/modals/addQuestionModal/AddQuestionModal.tsx delete mode 100644 apps/www/components/modals/addQuestionModal/AddQuestionModalContent.tsx delete mode 100644 apps/www/components/modals/addQuestionModal/AddQuestionModalFooter.tsx delete mode 100644 apps/www/components/modals/addQuestionModal/addQuestionModal.module.scss delete mode 100644 apps/www/components/modals/appModals/AppModals.tsx delete mode 100644 apps/www/components/modals/baseModal/BaseModal.tsx delete mode 100644 apps/www/components/modals/baseModal/baseModal.module.scss delete mode 100644 apps/www/components/modals/baseModal/fixBodyService.ts delete mode 100644 apps/www/components/questionEditor/QuestionEditor.tsx delete mode 100644 apps/www/components/questionEditor/questionEditor.module.scss delete mode 100644 apps/www/components/questions/allQuestions/AllQuestions.tsx delete mode 100644 apps/www/components/questions/allQuestions/allQuestions.module.scss delete mode 100644 apps/www/components/questions/allQuestions/allQuestionsFooter/AllQuestionsFooter.tsx delete mode 100644 apps/www/components/questions/allQuestions/allQuestionsFooter/allQuestionsFooter.module.scss delete mode 100644 apps/www/components/questions/allQuestions/allQuestionsHeader/AllQuestionsHeader.tsx delete mode 100644 apps/www/components/questions/allQuestions/allQuestionsHeader/allQuestionsHeader.module.scss delete mode 100644 apps/www/components/questions/mobileActionButtons/MobileActionButtons.tsx delete mode 100644 apps/www/components/questions/mobileActionButtons/mobileActionButtons.module.scss delete mode 100644 apps/www/components/questions/questionsList/QuestionsList.tsx delete mode 100644 apps/www/components/questions/questionsList/questionItem/QuestionItem.tsx delete mode 100644 apps/www/components/questions/questionsList/questionItem/QuestionVoting.tsx delete mode 100644 apps/www/components/questions/questionsList/questionItem/questionItem.module.scss delete mode 100644 apps/www/components/questions/questionsList/questionsList.module.scss delete mode 100644 apps/www/components/questions/questionsListLayout/QuestionsListLayout.tsx delete mode 100644 apps/www/components/questions/questionsListLayout/questionsListLayout.module.scss delete mode 100644 apps/www/components/questions/questionsSidebar/QuestionsSidebar.tsx delete mode 100644 apps/www/components/questions/questionsSidebar/levelFilter/LevelFilter.tsx delete mode 100644 apps/www/components/questions/questionsSidebar/levelFilter/levelFilter.module.scss delete mode 100644 apps/www/components/questions/questionsSidebar/questionsSidebar.module.scss delete mode 100644 apps/www/components/questions/questionsSidebar/technologyFilter/TechnologyFilter.tsx delete mode 100644 apps/www/components/questions/questionsSidebar/technologyFilter/technologyFilter.module.scss delete mode 100644 apps/www/components/questions/questionsUtils.ts delete mode 100644 apps/www/components/questions/selectedQuestions/NoQuestionsSelectedInfo.tsx delete mode 100644 apps/www/components/questions/selectedQuestions/SelectedQuestions.tsx delete mode 100644 apps/www/components/questions/selectedQuestions/noQuestionsSelectedInfo.module.scss delete mode 100644 apps/www/components/questions/selectedQuestions/selectedQuestions.module.scss delete mode 100644 apps/www/components/questionsPagination/QuestionsPagination.tsx delete mode 100644 apps/www/components/questionsPagination/questionsPagination.module.scss delete mode 100644 apps/www/components/userAvatar/UserAvatar.tsx delete mode 100644 apps/www/components/userAvatar/userAvatar.module.scss delete mode 100644 apps/www/constants/level.ts delete mode 100644 apps/www/constants/technology-icon-items.ts delete mode 100644 apps/www/contexts/UIContextProvider.tsx delete mode 100644 apps/www/cypress.json delete mode 100644 apps/www/cypress/fixtures/example.json delete mode 100644 apps/www/cypress/integration/a11y.spec.js delete mode 100644 apps/www/cypress/integration/basic.spec.js delete mode 100644 apps/www/cypress/plugins/index.js delete mode 100644 apps/www/cypress/support/commands.js delete mode 100644 apps/www/cypress/support/index.js delete mode 100644 apps/www/fast-check-arbitraries.ts delete mode 100755 apps/www/iconmoon-selection.json delete mode 100644 apps/www/iconmoon.json delete mode 100644 apps/www/jest.config.js delete mode 100644 apps/www/jest.setup.js delete mode 100644 apps/www/next-env.d.ts delete mode 100644 apps/www/next.config.js delete mode 100644 apps/www/package.json delete mode 100644 apps/www/pages/404.tsx delete mode 100644 apps/www/pages/_app.tsx delete mode 100644 apps/www/pages/_document.tsx delete mode 100644 apps/www/pages/_error.tsx delete mode 100644 apps/www/pages/about.tsx delete mode 100644 apps/www/pages/admin.tsx delete mode 100644 apps/www/pages/authors.tsx delete mode 100644 apps/www/pages/index.scss delete mode 100644 apps/www/pages/index.tsx delete mode 100644 apps/www/pages/login.tsx delete mode 100644 apps/www/pages/pages.module.scss delete mode 100644 apps/www/pages/questions/[technology].tsx delete mode 100644 apps/www/pages/questions/index.tsx delete mode 100644 apps/www/pages/questions/p/[id].tsx delete mode 100644 apps/www/pages/regulations.tsx delete mode 100644 apps/www/pages/selected-questions.tsx delete mode 100644 apps/www/pages/sitemap.xml.ts delete mode 100644 apps/www/pages/staticPage.module.scss delete mode 100644 apps/www/polyfills.js delete mode 100644 apps/www/public/android-chrome-192x192.png delete mode 100644 apps/www/public/android-chrome-512x512.png delete mode 100644 apps/www/public/apple-touch-icon.png delete mode 100644 apps/www/public/browserconfig.xml delete mode 100644 apps/www/public/favicon-16x16.png delete mode 100644 apps/www/public/favicon-32x32.png delete mode 100644 apps/www/public/favicon.ico delete mode 100755 apps/www/public/fonts/devicon.eot delete mode 100755 apps/www/public/fonts/devicon.svg delete mode 100755 apps/www/public/fonts/devicon.ttf delete mode 100755 apps/www/public/fonts/devicon.woff delete mode 100644 apps/www/public/fonts/devicon.woff2 delete mode 100644 apps/www/public/images/action-icons/add.svg delete mode 100644 apps/www/public/images/action-icons/add2.svg delete mode 100644 apps/www/public/images/action-icons/confirmation.svg delete mode 100644 apps/www/public/images/action-icons/download.svg delete mode 100644 apps/www/public/images/action-icons/edit.svg delete mode 100644 apps/www/public/images/action-icons/filter.svg delete mode 100644 apps/www/public/images/action-icons/menu.svg delete mode 100644 apps/www/public/images/action-icons/thumbs-up.svg delete mode 100644 apps/www/public/images/action-icons/warning.svg delete mode 100644 apps/www/public/images/arrow-up.svg delete mode 100644 apps/www/public/images/checkbox.svg delete mode 100644 apps/www/public/images/delete_forever_icon.svg delete mode 100644 apps/www/public/images/github.svg delete mode 100644 apps/www/public/images/michal_miszczyszyn.jpg delete mode 100644 apps/www/public/images/select-purple.svg delete mode 100644 apps/www/public/images/select.svg delete mode 100644 apps/www/public/images/typeofweb-logo.svg delete mode 100644 apps/www/public/img/devfaq-cover-facebook.png delete mode 100644 apps/www/public/img/fefaq-cover-facebook.png delete mode 100644 apps/www/public/manifest.json delete mode 100644 apps/www/public/mstile-144x144.png delete mode 100644 apps/www/public/mstile-150x150.png delete mode 100644 apps/www/public/robots.txt delete mode 100644 apps/www/public/safari-pinned-tab.svg delete mode 100644 apps/www/redux/actions.ts delete mode 100644 apps/www/redux/reducers/auth.ts delete mode 100644 apps/www/redux/reducers/index.ts delete mode 100644 apps/www/redux/reducers/oneQuestion.ts delete mode 100644 apps/www/redux/reducers/questions.ts delete mode 100644 apps/www/redux/reducers/routeDetails.ts delete mode 100644 apps/www/redux/reducers/selectedLevels.ts delete mode 100644 apps/www/redux/reducers/selectedQuestions.ts delete mode 100644 apps/www/redux/selectors/selectors.ts delete mode 100644 apps/www/redux/store.ts delete mode 100644 apps/www/redux/types.ts delete mode 100644 apps/www/services/Api.ts delete mode 100644 apps/www/styles/_buttons.scss delete mode 100644 apps/www/styles/_devicon.scss delete mode 100644 apps/www/styles/_icons.scss delete mode 100644 apps/www/styles/_mixins.scss delete mode 100644 apps/www/styles/_typography.scss delete mode 100644 apps/www/styles/common.scss delete mode 100644 apps/www/styles/global.scss delete mode 100644 apps/www/styles/variables.scss delete mode 100644 apps/www/tsconfig.json delete mode 100644 apps/www/utils/analytics.ts delete mode 100644 apps/www/utils/env.ts delete mode 100644 apps/www/utils/hooks.tsx delete mode 100644 apps/www/utils/redirect.spec.ts delete mode 100644 apps/www/utils/redirect.ts delete mode 100644 apps/www/utils/styles.ts delete mode 100644 apps/www/utils/types.ts delete mode 100644 dangerfile.ts delete mode 100644 lerna.json create mode 100644 packages/eslint-config-custom/index.js create mode 100644 packages/eslint-config-custom/package.json create mode 100644 packages/tsconfig/README.md create mode 100644 packages/tsconfig/base.json create mode 100644 packages/tsconfig/nextjs.json create mode 100644 packages/tsconfig/package.json create mode 100644 packages/tsconfig/react-library.json create mode 100644 packages/ui/Button.tsx create mode 100644 packages/ui/index.tsx create mode 100644 packages/ui/package.json create mode 100644 packages/ui/tsconfig.json delete mode 100644 scripts/.eslintrc delete mode 100644 scripts/circleGetBaseBranch.ts delete mode 100755 scripts/deploy.sh delete mode 100644 scripts/lighthouse.ts delete mode 100644 scripts/sizeSnapshot/create.ts delete mode 100755 scripts/ssh-script-deploy.sh delete mode 100644 scripts/tsconfig.json delete mode 100644 scripts/waitForVercel.ts delete mode 100644 tsconfig.json create mode 100644 turbo.json diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index c25ab495..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,84 +0,0 @@ -version: 2.1 -orbs: - node: circleci/node@1.1.6 - -jobs: - dangerfile: - docker: - - image: circleci/node:12-browsers - steps: - - run: | - if [ "$CIRCLE_BRANCH" = "develop" ] || [ "$CIRCLE_BRANCH" = "main" ]; then - circleci-agent step halt - fi - - - checkout - - restore_cache: - name: Restore Yarn Package Cache - keys: - - yarn-packages-{{ checksum "yarn.lock" }} - - yarn-packages- - - run: yarn install --frozen-lockfile - - run: yarn cache dir - - - run: yarn get-base-branch - - run: git status - - run: cat /tmp/.basebranch - - run: git fetch && git checkout $CIRCLE_BRANCH && git reset --hard origin/$CIRCLE_BRANCH - - run: git diff --name-only HEAD $(cat /tmp/.basebranch) - - when: - condition: git diff --name-only HEAD $(cat /tmp/.basebranch) | grep -q "apps/www" - steps: - - run: cp apps/www/.env apps/www/.env.staging - - run: cp apps/www/.env apps/www/.env.production - - run: yarn workspace www build > analyze.next - - run: rm apps/www/.env.staging - - run: rm apps/www/.env.production - - run: cat analyze.next - - run: yarn create-size && mv size-snapshot.json /tmp/current-size-snapshot.json - - run: rm analyze.next && rm -rf apps/www/.next - - - run: git checkout $(cat /tmp/.basebranch) && git reset --hard origin/$(cat /tmp/.basebranch) - - run: git status - - run: yarn install --frozen-lockfile - - run: cp apps/www/.env apps/www/.env.staging - - run: cp apps/www/.env apps/www/.env.production - - run: yarn workspace www build > analyze.next - - run: rm apps/www/.env.staging - - run: rm apps/www/.env.production - - run: cat analyze.next - - run: mv analyze.next /tmp/ - - - run: git checkout $CIRCLE_BRANCH && git reset --hard origin/$CIRCLE_BRANCH - - run: git status - - run: yarn install --frozen-lockfile - - run: cp apps/www/.env apps/www/.env.staging - - run: cp apps/www/.env apps/www/.env.production - - - run: mv /tmp/analyze.next ./ - - run: yarn create-size && mv size-snapshot.json previous-size-snapshot.json - - run: mv /tmp/current-size-snapshot.json ./ - - # - run: mkdir -p /tmp/lighthouse/ - - run: yarn danger ci - - - save_cache: - name: Save Yarn Package Cache - key: yarn-packages-{{ checksum "yarn.lock" }} - paths: - - ~/.cache/yarn - - /home/circleci/.cache/yarn/v6 - - - store_artifacts: - path: ./current-size-snapshot.json - # - store_artifacts: - # path: /tmp/lighthouse - -workflows: - dangerfile: - jobs: - - dangerfile: - filters: - branches: - ignore: - - /dependabot\/*/ diff --git a/apps/www/.editorconfig b/.editorconfig similarity index 100% rename from apps/www/.editorconfig rename to .editorconfig diff --git a/.eslintignore b/.eslintignore deleted file mode 120000 index 3e4e48b0..00000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -.gitignore \ No newline at end of file diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index a0a954dd..00000000 --- a/.eslintrc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "root": true, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "sourceType": "module" - }, - "plugins": [], - "extends": ["prettier", "plugin:import/typescript"], - "rules": { - "no-const-assign": "error", - "import/no-anonymous-default-export": "error", - "import/dynamic-import-chunkname": "error", - "import/order": ["error", { "newlines-between": "always", "alphabetize": { "order": "asc" } }], - "import/no-duplicates": "error", - "import/no-cycle": "error", - "@typescript-eslint/no-unused-vars": "off" - } -} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..5b999efa --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,10 @@ +module.exports = { + root: true, + // This tells ESLint to load the config from the package `eslint-config-custom` + extends: ["custom"], + settings: { + next: { + rootDir: ["apps/*/"], + }, + }, +}; diff --git a/.fossa.yml b/.fossa.yml deleted file mode 100755 index 546a93fd..00000000 --- a/.fossa.yml +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by FOSSA CLI (https://github.com/fossas/fossa-cli) -# Visit https://fossa.com to learn more - -version: 2 -cli: - server: https://app.fossa.com - fetcher: custom - project: git@github.com:typeofweb/devfaq.git -analyze: - modules: - - name: api - type: npm - target: apps/api - path: apps/api - - name: www - type: npm - target: apps/www - path: apps/www - - name: . - type: npm - target: . - path: . diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 62db841e..00000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,9 +0,0 @@ -apps/api/app.js @mmiszy -apps/api/src @mmiszy - -apps/www/app.js @mmiszy -apps/www/pages @mmiszy -apps/www/components @mmiszy -apps/www/public @mmiszy - -scripts @mmiszy diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index b3027101..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,12 +0,0 @@ -# These are supported funding model platforms - -github: [typeofweb] -patreon: # Replace with a single Patreon username -open_collective: typeofweb -ko_fi: # Replace with a single Ko-fi username -tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username -issuehunt: # Replace with a single IssueHunt username -otechie: # Replace with a single Otechie username -custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/auto-approve-dependabot.yml b/.github/workflows/auto-approve-dependabot.yml deleted file mode 100644 index 0c4d9b11..00000000 --- a/.github/workflows/auto-approve-dependabot.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Auto approve dependabot - -on: - pull_request: - branches: [develop] - -jobs: - auto-approve: - runs-on: ubuntu-latest - steps: - - uses: hmarr/auto-approve-action@v2.0.0 - if: github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]' - with: - github-token: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 7e8b529f..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: "CodeQL" - -on: - push: - branches: [develop, main] - pull_request: - # The branches below must be a subset of the branches above - branches: [develop] - schedule: - - cron: '0 19 * * 3' - -jobs: - analyse: - name: Analyse - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - # Override language selection by uncommenting this and choosing your languages - # with: - # languages: go, javascript, csharp, python, cpp, java - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 5ee86cd9..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Deploy to staging and production - -on: - push: - branches: [main, develop] - -jobs: - deploy: - runs-on: ubuntu-latest - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_LOG_LEVEL: debug - - steps: - - uses: actions/checkout@v2 - - - name: Setup SSH Keys and known_hosts - env: - SSH_AUTH_SOCK: /tmp/ssh_agent.sock - run: | - mkdir -p ~/.ssh - ssh-keyscan -H github.com >> ~/.ssh/known_hosts - ssh-keyscan -H s18.mydevil.net >> ~/.ssh/known_hosts - ssh-agent -a $SSH_AUTH_SOCK > /dev/null - ssh-add - <<< "${{ secrets.SSH_PRIVATE_KEY }}" - if [[ "${GITHUB_REF##*/}" == 'develop' ]]; then ENV="staging"; fi - if [[ "${GITHUB_REF##*/}" == 'main' ]]; then ENV="production"; fi - ssh typeofweb@s18.mydevil.net 'source ~/.bashrc && ssh-add ~/.ssh/github && bash -s' < ./scripts/ssh-script-deploy.sh $ENV - - - name: Create Sentry Release - run: | - if [[ "${GITHUB_REF##*/}" == 'develop' ]]; then ENV="staging"; fi - if [[ "${GITHUB_REF##*/}" == 'main' ]]; then ENV="production"; fi - - # Install Sentry CLI - curl -sL https://sentry.io/get-cli/ | bash - - # Create new Sentry release - export SENTRY_VERSION=$(sentry-cli releases propose-version) - - sentry-cli releases --org=typeofweb --project=devfaq-api new $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-api set-commits --auto $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-api finalize $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-api deploys $SENTRY_VERSION new -e $ENV - - sentry-cli releases --org=typeofweb --project=devfaq-www new $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-www set-commits --auto $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-www finalize $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-www deploys $SENTRY_VERSION new -e $ENV diff --git a/.github/workflows/test-PR.yml b/.github/workflows/test-PR.yml deleted file mode 100644 index caa8aa22..00000000 --- a/.github/workflows/test-PR.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Test and Build - -on: - pull_request: - branches: [develop, main] - -jobs: - test_www: - if: "! contains(toJSON(github.event.commits.*.message), '[skip-ci]')" - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 100 - - - uses: marceloprado/has-changed-path@master - id: changed-www - with: - paths: apps/www - - - name: Read .nvmrc - if: steps.changed-www.outputs.changed == 'true' - run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" - id: nvm - - name: Use Node.js - if: steps.changed-www.outputs.changed == 'true' - uses: actions/setup-node@v1 - with: - node-version: '${{ steps.nvm.outputs.NVMRC }}' - - - name: Get yarn cache directory path - if: steps.changed-www.outputs.changed == 'true' - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - name: Cache Node.js modules - if: steps.changed-www.outputs.changed == 'true' - uses: actions/cache@v1 - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - ${{ runner.OS }}- - - - run: cp apps/www/.env apps/www/.env.staging - - run: cp apps/www/.env apps/www/.env.production - - - name: Install dependencies - if: steps.changed-www.outputs.changed == 'true' - run: yarn workspace www install --frozen-lockfile - - - name: Run tests - if: steps.changed-www.outputs.changed == 'true' - run: yarn workspace www test - - - name: Run build - if: steps.changed-www.outputs.changed == 'true' - run: yarn workspace www build - - test_api: - if: "! contains(toJSON(github.event.commits.*.message), '[skip-ci]')" - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 100 - - - uses: marceloprado/has-changed-path@master - id: changed-api - with: - paths: apps/api - - - name: Setup PostgreSQL - if: steps.changed-api.outputs.changed == 'true' - uses: Harmon758/postgresql-action@v1.0.0 - with: - postgresql version: 12-alpine - postgresql db: database_development - postgresql user: postgres - postgresql password: -api2018 - - - name: Read .nvmrc - if: steps.changed-api.outputs.changed == 'true' - run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" - id: nvm - - name: Use Node.js - if: steps.changed-api.outputs.changed == 'true' - uses: actions/setup-node@v1 - with: - node-version: '${{ steps.nvm.outputs.NVMRC }}' - - name: Get yarn cache directory path - if: steps.changed-api.outputs.changed == 'true' - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - name: Cache Node.js modules - if: steps.changed-api.outputs.changed == 'true' - uses: actions/cache@v1 - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - ${{ runner.OS }}- - - - name: Install dependencies - if: steps.changed-api.outputs.changed == 'true' - run: yarn workspace api install --frozen-lockfile - - - name: Run tests - if: steps.changed-api.outputs.changed == 'true' - run: yarn workspace api test - - - name: Run build for dependabot - if: steps.changed-api.outputs.changed == 'true' && (github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]') - run: yarn workspace api build diff --git a/.gitignore b/.gitignore index d6c45f29..849425fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,91 +1,33 @@ -npm-debug.log -.next -out -previous-size-snapshot.json -current-size-snapshot.json -size-snapshot.json -analyze.next -.deployment-url -.basebranch -package-lock.json -spmdb/ -spmlogs/ -newrelic.js - +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# dependencies node_modules -.tmp -.idea -.DS_Store -.version -dist -.history - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* +.pnp +.pnp.js -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -junit -test-results.xml - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul +# testing coverage -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript +# next.js +.next/ +out/ +build -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env -.env.dev -apps/www/.env.staging -apps/www/.env.production +# misc +.DS_Store +*.pem -*.tsbuildinfo +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* -# cypress +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local -apps/www/cypress/screenshots -apps/www/cypress/videos +# turbo +.turbo diff --git a/.nvmrc b/.nvmrc index 48082f72..b6a7d89c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -12 +16 diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 65266697..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "editor.formatOnSave": true, - "editor.formatOnType": true, - "prettier.disableLanguages": ["json", "scss", "markdown"], - "[json]": { - "editor.formatOnSave": false, - "editor.formatOnType": false - }, - "[markdown]": { - "editor.formatOnSave": false, - "editor.formatOnType": false - }, - "[scss]": { - "editor.formatOnSave": false, - "editor.formatOnType": false - }, - "search.exclude": { - "**/node_modules": true, - "**/bower_components": true, - ".next": true, - "**/dist": true - }, - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.preferences.importModuleSpecifier": "relative", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": true - }, - "workbench.colorCustomizations": { - "titleBar.activeBackground": "#673ab7", - "titleBar.inactiveBackground": "#401886", - "titleBar.activeForeground": "#ffffff", - "titleBar.inactiveForeground": "#ffffff" - }, - "tslint.autoFixOnSave": true, - "files.exclude": { - "**/.git": true, - "**/.svn": true, - "**/.hg": true, - "**/CVS": true, - "**/.DS_Store": true, - "**/dist": true - }, - "prettier.configPath": "./.prettierrc" -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85c6dd10..0457d03f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ ## Introduction -DevFAQ is organised into a monorepo with lerna and yarn workspaces. You'll find frontend ([www](./apps/www)) and backend ([api](./apps/api)) in the [apps](./apps) directory. +DevFAQ is organised into a monorepo with Turborepo. You'll find frontend ([www](./apps/www)) and backend ([api](./apps/api)) in the [apps](./apps) directory. - Frontend is written in **Next.js (React) with TypeScript**. - Backend is a REST API, and uses **HapiJS, PostgreSQL, and TypeScript**. diff --git a/apps/api/.editorconfig b/apps/api/.editorconfig deleted file mode 100644 index c2cdfb8a..00000000 --- a/apps/api/.editorconfig +++ /dev/null @@ -1,21 +0,0 @@ -# EditorConfig helps developers define and maintain consistent -# coding styles between different editors and IDEs -# editorconfig.org - -root = true - - -[*] - -# Change these settings to your own preference -indent_style = space -indent_size = 2 - -# We recommend you to keep these unchanged -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.md] -trim_trailing_whitespace = false diff --git a/apps/api/.env.dev b/apps/api/.env.dev deleted file mode 100644 index 0eb50dbb..00000000 --- a/apps/api/.env.dev +++ /dev/null @@ -1,13 +0,0 @@ -PORT=3002 -DB_USERNAME=postgres -DB_PASSWORD=-api2018 -DB_NAME=database_development -DB_HOSTNAME=127.0.0.1 -SENTRY_DSN= - -COOKIE_DOMAIN="devfaq.localhost" -COOKIE_PASSWORD="Xj-#?B#f+1#agiD8QiQvh=RLhy;+Ybj|/+f#|KPH5bs20w^pN@X]q1" - -GITHUB_CLIENT_ID=e65b7b90cd7d2a85acd8 -GITHUB_CLIENT_SECRET=30087b1687598ce76ffa30ac5b6d3a45a7da9a17 -GITHUB_PASSWORD="g-X,-/O7oJ[EWVvE#*aK*!UKDS/zoudbEn!1T+`Ud|n(25EU/*gO::6QnffK+IZ`" diff --git a/apps/api/.eslintrc b/apps/api/.eslintrc deleted file mode 100644 index 545e94ec..00000000 --- a/apps/api/.eslintrc +++ /dev/null @@ -1,15 +0,0 @@ -{ - "root": false, - "extends": ["plugin:import/errors"], - "rules": { - "@typescript-eslint/no-unused-vars": "off" - }, - "overrides": [ - { - "files": ["src/models/*.ts"], - "rules": { - "import/no-cycle": "off" - } - } - ] -} diff --git a/apps/api/.sequelizerc b/apps/api/.sequelizerc deleted file mode 100644 index 7d09aaa1..00000000 --- a/apps/api/.sequelizerc +++ /dev/null @@ -1,6 +0,0 @@ -const path = require('path'); - -module.exports = { - config: path.resolve('src', 'config', 'database.js'), - 'models-path': path.resolve('src', 'models'), -}; diff --git a/apps/api/.version b/apps/api/.version deleted file mode 100644 index d00491fd..00000000 --- a/apps/api/.version +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile deleted file mode 100644 index 0aa3b858..00000000 --- a/apps/api/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM node:12-alpine -WORKDIR /app - -#copy all the app files -COPY . . - -RUN yarn install -RUN yarn run build - -CMD NODE_ENV=production yarn start diff --git a/apps/api/apiTypes.ts b/apps/api/apiTypes.ts deleted file mode 100644 index fe1835e0..00000000 --- a/apps/api/apiTypes.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * This file was auto-generated by swagger-to-ts. - * Do not make direct changes to the file. - */ - -export interface definitions { - Model1: { - id: number; - question: string; - _categoryId: 'html' | 'css' | 'js' | 'angular' | 'react' | 'git' | 'other'; - _levelId: 'junior' | 'mid' | 'senior'; - _statusId: 'accepted' | 'pending'; - acceptedAt?: string; - votesCount: number; - currentUserVotedOn?: boolean; - }; - data: definitions['Model1'][]; - meta: { total: number }; - Model2: { data: definitions['data']; meta?: definitions['meta'] }; - _user: { - id: number; - email: string; - createdAt: string; - updatedAt: string; - _roleId: string; - firstName?: string; - lastName?: string; - socialLogin?: string; - }; - Model3: { - keepMeSignedIn: boolean; - validUntil: string; - createdAt: string; - updatedAt: string; - version: number; - _userId: number; - _user: definitions['_user']; - }; - Model4: { data: definitions['Model3'] }; - Model5: { data: definitions['Model1'] }; - Model6: { _userId: number; _questionId: number }; - Model7: { data: definitions['Model6'] }; - Model8: { - question: string; - level: 'junior' | 'mid' | 'senior'; - category: 'html' | 'css' | 'js' | 'angular' | 'react' | 'git' | 'other'; - }; - Model9: { - question: string; - level: 'junior' | 'mid' | 'senior'; - category: 'html' | 'css' | 'js' | 'angular' | 'react' | 'git' | 'other'; - status: 'accepted' | 'pending'; - }; - - /** - * @summary Health check endpoint default Successful response - */ - getHealthCheckDefaultResponse: string; - - /** - * @summary Test endpoint default Successful response - */ - getHelloWorldDefaultResponse: string; - getQuestionsRequestQuery: { - category?: 'html' | 'css' | 'js' | 'angular' | 'react' | 'git' | 'other'; - status?: 'accepted' | 'pending'; - level?: ('junior' | 'mid' | 'senior')[]; - limit?: number; - offset?: number; - orderBy?: 'acceptedAt' | 'level' | 'votesCount'; - order?: 'asc' | 'desc'; - }; - - /** - * @summary Returns questions 200 Successful response - */ - getQuestions200Response: definitions['Model2']; - postQuestionsRequestBody: definitions['Model8']; - - /** - * @description When user is not an admin, it won't publish the question - * @summary Creates a question 200 Successful response - */ - postQuestions200Response: definitions['Model5']; - - getOauthGithubDefaultResponse: string; - - postOauthGithubDefaultResponse: string; - - getOauthMe200Response: definitions['Model4']; - getQuestionsIdRequestPathParams: { - id: number; - }; - - /** - * @summary Returns one question 200 Successful response - */ - getQuestionsId200Response: definitions['Model5']; - patchQuestionsIdRequestPathParams: { - id: number; - }; - patchQuestionsIdRequestBody: definitions['Model9']; - - /** - * @summary Updates a question 200 Successful response - */ - patchQuestionsId200Response: definitions['Model5']; - deleteQuestionsIdRequestPathParams: { - id: number; - }; - - /** - * @summary Deletes one question default Successful response - */ - deleteQuestionsIdDefaultResponse: string; - postQuestionVotesRequestQuery: { - _userId: number; - _questionId: number; - }; - - /** - * @summary Votes on a question 200 Successful response - */ - postQuestionVotes200Response: definitions['Model7']; - deleteQuestionVotesRequestQuery: { - _userId: number; - _questionId: number; - }; - - /** - * @summary Votes on a question default Successful response - */ - deleteQuestionVotesDefaultResponse: string; - - postOauthLogoutDefaultResponse: string; -} diff --git a/apps/api/app.js b/apps/api/app.js deleted file mode 100644 index cf1a2178..00000000 --- a/apps/api/app.js +++ /dev/null @@ -1,4 +0,0 @@ -require('newrelic'); - -// MyDevil Hack -require('./dist/src/index.js'); diff --git a/apps/api/benchmark.ts b/apps/api/benchmark.ts deleted file mode 100644 index 33d99e71..00000000 --- a/apps/api/benchmark.ts +++ /dev/null @@ -1,23 +0,0 @@ -// tslint:disable-next-line: no-implicit-dependencies -import Autocannon from 'autocannon'; - -const benchmark = async () => { - // tslint:disable-next-line: no-magic-numbers - for (let mutableI = 0; mutableI < 10; ++mutableI) { - const result = await Autocannon({ - url: 'http://localhost:3002/questions', - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - console.log(`${mutableI}: ${result.requests.mean} requests per second`); - } - process.exit(); -}; - -benchmark().catch((err) => { - console.error(err); - process.exit(); -}); diff --git a/apps/api/docker-compose.yml b/apps/api/docker-compose.yml deleted file mode 100644 index d7e2d500..00000000 --- a/apps/api/docker-compose.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: '2' - -services: - devfaq_db: - image: postgres:12-alpine - ports: - - '5432:5432' - environment: - POSTGRES_USER: 'postgres' - POSTGRES_DB: 'database_development' - POSTGRES_PASSWORD: '-api2018' diff --git a/apps/api/migrate.ts b/apps/api/migrate.ts deleted file mode 100644 index fd4aa224..00000000 --- a/apps/api/migrate.ts +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env ts-node-script - -import Path from 'path'; - -import { Sequelize } from 'sequelize'; -// tslint:disable-next-line: no-implicit-dependencies -import { Umzug, SequelizeStorage, Migration } from 'umzug'; - -import { sequelizeConfig } from './src/db'; - -const sequelize = new Sequelize({ ...sequelizeConfig, logging: undefined }); - -const storageTableName = { - migration: { modelName: 'SequelizeMeta', path: './src/migrations' }, - seeder: { modelName: 'SequelizeData', path: './src/seeders' }, -} as const; - -const getUmzug = (type: keyof typeof storageTableName) => { - return new Umzug({ - logging: console.info, - migrations: { - path: storageTableName[type].path, - pattern: /\.ts$/, - params: [sequelize.getQueryInterface(), Sequelize], - nameFormatter(path) { - // ignore file extension to make it compatible with older .js migrations - return Path.basename(path, Path.extname(path)); - }, - }, - storage: new SequelizeStorage({ - sequelize, - modelName: storageTableName[type].modelName, - }), - }); -}; - -const execute = async (fn: () => Promise, msg: string) => { - fn() - .then((result) => { - console.log( - msg, - result.map((r) => r?.file ?? r) - ); - process.exit(); - }) - .catch((err) => { - console.error(err); - process.exit(1); - }); -}; - -export const seedUp = () => execute(() => getUmzug('seeder').up(), 'Executed seeds:'); -export const seedDown = () => execute(() => getUmzug('seeder').down(), 'Reverted seeds:'); -export const migrateUp = () => execute(() => getUmzug('migration').up(), 'Executed migrations:'); -export const migrateDown = () => execute(() => getUmzug('migration').down(), 'Reverted migration:'); diff --git a/apps/api/nodemon.json b/apps/api/nodemon.json deleted file mode 100644 index 4c4f4c25..00000000 --- a/apps/api/nodemon.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "watch": ["dist/src"], - "ext": "js", - "ignore": [".git", "node_modules", "dist/src/**/*.test.*"], - "exec": "node ./dist/src/index.js" -} diff --git a/apps/api/package.json b/apps/api/package.json deleted file mode 100644 index 895a0461..00000000 --- a/apps/api/package.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "name": "api", - "version": "5.3.0", - "author": "Michał Miszczyszyn - Type of Web (https://typeofweb.com/)", - "license": "AGPL-3.0-only", - "private": true, - "engines": { - "node": "12.x.x" - }, - "keywords": [], - "main": "dist/src/index.js", - "scripts": { - "start:db": "docker-compose up", - "dev": "concurrently --kill-others-on-fail 'yarn start:db' 'yarn dev_'", - "dev_": "wait-on tcp:5432 --interval 5000 && yarn db:migrate:up && ts-node-dev src/index.ts", - "test": "cross-env NODE_ENV=test ENV=test yarn test:all", - "test:integration": "cross-env NODE_ENV=test ENV=test yarn test:_integration", - "test:integration:single": "cross-env NODE_ENV=test ENV=test yarn test:prepare:integration && yarn mocha", - "test:unit": "cross-env NODE_ENV=test ENV=test yarn mocha src/**/*.test.ts --exclude src/**/*.integration.test.ts", - "test:unit:single": "cross-env NODE_ENV=test ENV=test yarn mocha", - "mocha": "cross-env ENV=test mocha --config test/.mocharc.js", - "eslint": "eslint . --ext .js,.jsx,.ts,.tsx --fix", - "tsc": "tsc --noEmit -p tsconfig.json", - "db:seed:up": "ts-node --transpile-only -e 'require(`./migrate.ts`).seedUp()'", - "db:seed:down": "ts-node --transpile-only -e 'require(`./migrate.ts`).seedDown()'", - "db:migrate:up": "ts-node --transpile-only -e 'require(`./migrate.ts`).migrateUp()'", - "db:migrate:down": "ts-node --transpile-only -e 'require(`./migrate.ts`).migrateDown()'", - "//1": "/*****************************************************************************", - "//2": "* Rest of those commands are used internally and should not be used directly!", - "//3": "*****************************************************************************/", - "db:test:create": "cross-env NODE_ENV=test ENV=test ts-node --transpile-only ./node_modules/.bin/sequelize --config='./src/config/database.js' db:create || true", - "db:test:drop": "cross-env NODE_ENV=test ENV=test ts-node --transpile-only ./node_modules/.bin/sequelize --config='./src/config/database.js' db:drop || true", - "test:all": "concurrently --kill-others-on-fail --names *typescript,*****eslint,*tests:unit,integration --prefix-colors blue.inverse,blue,yellow,green 'yarn tsc' 'yarn eslint' 'yarn test:unit' 'yarn test:integration'", - "test:prepare:integration": "yarn db:test:drop && yarn db:test:create && yarn db:migrate:up", - "test:_integration": "yarn test:prepare:integration && yarn mocha src/**/*.integration.test.ts", - "test:ci": "cross-env NODE_ENV=test ENV=test yarn mocha:ci", - "mocha:ci": "yarn test:prepare:integration && cross-env mocha 'src/**/*.test.ts'", - "clean": "rm -rf dist", - "build": "yarn clean && tsc && rsync -av --exclude='*.ts' src/** dist/src/", - "generate-api-types": "./node_modules/@manifoldco/swagger-to-ts/pkg/bin/cli.js http://localhost:3002/swagger.json --output apiTypes.ts --prettier-config .prettierrc", - "postinstall": "cd ./node_modules/@manifoldco/swagger-to-ts && yarn && yarn build" - }, - "dependencies": { - "@hapi/bell": "12.0.1", - "@hapi/boom": "9.1.0", - "@hapi/cookie": "11.0.1", - "@hapi/hapi": "19.1.1", - "@hapi/inert": "6.0.1", - "@hapi/joi": "17.1.1", - "@hapi/vision": "6.0.0", - "@sentry/node": "5.17.0", - "cls-hooked": "4.2.2", - "cls-proxify": "1.0.1", - "dotenv": "8.2.0", - "faker": "4.1.0", - "google-auth-library": "6.0.2", - "hapi-swagger": "13.0.2", - "lodash": "4.17.15", - "moment": "2.27.0", - "nanoid": "3.1.10", - "newrelic": "6.10.0", - "node-fetch": "2.6.0", - "pg": "8.2.1", - "pg-hstore": "2.3.3", - "reflect-metadata": "0.1.13", - "sequelize": "4.44.4", - "sequelize-typescript": "0.6.11", - "uuid": "8.1.0" - }, - "devDependencies": { - "@manifoldco/swagger-to-ts": "github:mmiszy/swagger-to-ts#develop", - "@types/autocannon": "4.1.0", - "@types/chai": "4.2.11", - "@types/chai-as-promised": "7.1.2", - "@types/chai-datetime": "0.0.33", - "@types/cls-hooked": "4.3.0", - "@types/dotenv": "8.2.0", - "@types/faker": "4.1.12", - "@types/hapi-pino": "8.0.0", - "@types/hapi__bell": "11.0.0", - "@types/hapi__boom": "9.0.1", - "@types/hapi__cookie": "10.1.0", - "@types/hapi__hapi": "19.0.3", - "@types/hapi__inert": "5.2.0", - "@types/hapi__joi": "17.1.2", - "@types/hapi__vision": "5.5.1", - "@types/inert": "5.1.2", - "@types/lodash": "4.14.155", - "@types/mocha": "7.0.2", - "@types/nanoid": "2.1.0", - "@types/node": "14.0.13", - "@types/node-fetch": "2.5.7", - "@types/sequelize": "4.28.9", - "@types/sinon": "9.0.4", - "@types/sinon-chai": "3.2.4", - "@types/uuid": "8.0.0", - "@types/vision": "5.3.7", - "autocannon": "5.0.1", - "chai": "4.2.0", - "chai-as-promised": "7.1.1", - "chai-datetime": "1.6.0", - "concurrently": "5.2.0", - "cross-env": "7.0.2", - "eslint": "7.5.0", - "mocha": "8.0.1", - "nodemon": "2.0.4", - "prettier": "2.0.5", - "pretty-quick": "2.0.1", - "sequelize-cli": "5.5.1", - "sinon": "9.0.2", - "sinon-chai": "3.5.0", - "ts-node": "8.10.2", - "ts-node-dev": "1.0.0-pre.49", - "typescript": "3.9.5", - "umzug": "3.0.0-beta.5", - "wait-on": "5.0.1" - } -} diff --git a/apps/api/src/config/database.js b/apps/api/src/config/database.js deleted file mode 100644 index b2b55ec5..00000000 --- a/apps/api/src/config/database.js +++ /dev/null @@ -1,28 +0,0 @@ -const dotenv = require('dotenv'); - -const { getConfig } = require('./index'); - -if (getConfig('NODE_ENV') !== 'production') { - dotenv.config({ path: '.env.dev' }); -} else { - dotenv.config(); -} - -const config = { - username: getConfig('DB_USERNAME'), - password: getConfig('DB_PASSWORD'), - database: getConfig('DB_NAME'), - host: getConfig('DB_HOSTNAME'), - dialect: 'postgres', -}; - -module.exports = { - development: config, - test: { - ...config, - database: 'database_test', - host: '127.0.0.1', - }, - production: config, - staging: config, -}; diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts deleted file mode 100644 index 6e851966..00000000 --- a/apps/api/src/config/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import Fs from 'fs'; - -export function getConfig(name: 'ENV'): 'production' | 'staging' | 'development' | 'test'; -export function getConfig(name: 'NODE_ENV'): 'production' | 'development'; -export function getConfig(name: string): string; -export function getConfig(name: string): string { - const val = process.env[name]; - - switch (name) { - case 'NODE_ENV': - return val || 'development'; - case 'ENV': - return val || 'development'; - case 'PORT': - return val || '3009'; - case 'AWS_ACCESS_KEY_ID': - case 'AWS_SECRET_ACCESS_KEY': - case 'SENTRY_DSN': - case 'HARVEST_API_AUTH_TOKEN': - case 'HARVEST_API_USER_AGENT': - case 'GITHUB_CLIENT_ID': - case 'GITHUB_CLIENT_SECRET': - return val || ''; - case 'VERSION': - return Fs.existsSync('.version') ? Fs.readFileSync('.version', 'utf-8').trim() : 'dev'; - case 'SENTRY_VERSION': - return getConfig('VERSION').split(':').pop() || ''; - } - - if (!val) { - throw new Error(`Cannot find environmental variable: ${name}`); - } - - return val; -} - -export const isProd = () => getConfig('ENV') === 'production'; -export const isStaging = () => getConfig('ENV') === 'staging'; diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts deleted file mode 100644 index 038d9a5c..00000000 --- a/apps/api/src/db.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Sequelize, Model } from 'sequelize-typescript'; - -import { getConfig } from './config'; -import { SentryCLS, getContext, USER_CONTEXT_KEY } from './plugins/cls/context'; - -// tslint:disable-next-line:no-var-requires -const config = require('./config/database.js'); - -export interface AnyModel extends Model {} -export type RawModel = Pick> & { - id: number; -}; - -export const sequelizeConfig = { - ...config[getConfig('ENV')], - pool: { - max: 5, - min: 0, - acquire: 30000, - idle: 10000, - }, - // http://docs.sequelizejs.com/manual/tutorial/querying.html#operators - operatorsAliases: false, - // native: true, - logging: - getConfig('NODE_ENV') !== 'production' - ? // tslint:disable-next-line:no-any - (sql: string, model?: Model) => { - SentryCLS.addBreadcrumb({ - category: 'SQL', - message: sql, - level: SentryCLS.Severity.Info, - data: { - model: model?.toJSON?.() || undefined, - context: getContext(USER_CONTEXT_KEY), - }, - }); - } - : (sql: string, _model: unknown) => { - if (getConfig('ENV') !== 'test') { - console.log( - [ - getContext(USER_CONTEXT_KEY)?.currentRequestID, - 'user: ' + getContext(USER_CONTEXT_KEY)?.userEmail, - sql, - ].join('\t') - ); - } - }, -}; - -export const sequelize = new Sequelize(sequelizeConfig); - -export const initDb = async () => { - await sequelize.addModels([__dirname + '/models']); - await sequelize.authenticate(); - - console.log('Connection to the database has been established successfully.'); - - return sequelize; -}; - -export function getAllModels() { - return (sequelize._ as unknown) as Sequelize['models']; -} - -export function getModelByName(name: string) { - return getAllModels()[name]; -} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts deleted file mode 100644 index f14cde91..00000000 --- a/apps/api/src/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import dotenv from 'dotenv'; - -import { getConfig } from './config'; -import { initDb } from './db'; -import { SentryCLS } from './plugins/cls/context'; -import { getServerWithPlugins } from './server'; -import { handleException } from './utils/utils'; - -if (getConfig('NODE_ENV') !== 'production') { - dotenv.config({ path: '.env.dev' }); -} else { - dotenv.config(); -} - -if (!getConfig('SENTRY_DSN')) { - console.warn('SENTRY_DSN is missing. No errors will be reported!'); -} else { - SentryCLS.init({ - debug: false, - dsn: getConfig('SENTRY_DSN'), - environment: getConfig('ENV'), - release: getConfig('SENTRY_VERSION'), - }); -} - -// tslint:disable-next-line:no-floating-promises -(async () => { - try { - await initDb(); - const devfaqServer = await getServerWithPlugins(); - await devfaqServer.start(); - - console.info('Server running at:', devfaqServer.info.uri); - } catch (err) { - handleException(err, SentryCLS.Severity.Fatal); - - const client = SentryCLS.getCurrentHub().getClient(); - if (client) { - client - // tslint:disable-next-line:no-magic-numbers - .close(2000) - .then(() => process.exit(1)); - } else { - process.exit(1); - } - } -})(); diff --git a/apps/api/src/migrations/20180814074541-initial.ts b/apps/api/src/migrations/20180814074541-initial.ts deleted file mode 100644 index 792ffdd8..00000000 --- a/apps/api/src/migrations/20180814074541-initial.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { QueryInterface, SequelizeStatic } from 'sequelize'; - -module.exports = { - async up(queryInterface: QueryInterface, Sequelize: SequelizeStatic) { - return queryInterface.sequelize.transaction(async (_t) => { - await queryInterface.createTable('QuestionCategory', { - id: { - type: Sequelize.TEXT, - primaryKey: true, - allowNull: false, - unique: true, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - - await queryInterface.createTable('QuestionLevel', { - id: { - type: Sequelize.TEXT, - primaryKey: true, - allowNull: false, - unique: true, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - - await queryInterface.createTable('QuestionStatus', { - id: { - type: Sequelize.TEXT, - primaryKey: true, - allowNull: false, - unique: true, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - - await queryInterface.createTable('UserRoleType', { - id: { - type: Sequelize.TEXT, - primaryKey: true, - allowNull: false, - unique: true, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - - await queryInterface.createTable('User', { - id: { - type: Sequelize.INTEGER, - primaryKey: true, - autoIncrement: true, - allowNull: false, - }, - email: { - type: Sequelize.TEXT, - allowNull: false, - unique: true, - }, - firstName: { - type: Sequelize.TEXT, - allowNull: true, - }, - lastName: { - type: Sequelize.TEXT, - allowNull: true, - }, - _roleId: { - type: Sequelize.TEXT, - allowNull: false, - defaultValue: 'user', - references: { - model: 'UserRoleType', - key: 'id', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - - await queryInterface.createTable('Question', { - id: { - type: Sequelize.INTEGER, - primaryKey: true, - autoIncrement: true, - allowNull: false, - }, - question: { - type: Sequelize.TEXT, - allowNull: false, - unique: true, - }, - acceptedAt: { - type: Sequelize.DATE, - allowNull: true, - }, - _categoryId: { - type: Sequelize.TEXT, - allowNull: false, - references: { - model: 'QuestionCategory', - key: 'id', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - _levelId: { - type: Sequelize.TEXT, - allowNull: false, - references: { - model: 'QuestionLevel', - key: 'id', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - _statusId: { - type: Sequelize.TEXT, - allowNull: false, - defaultValue: 'pending', - references: { - model: 'QuestionStatus', - key: 'id', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - }); - }, - - async down(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - await queryInterface.dropAllTables(); - }, -}; diff --git a/apps/api/src/migrations/20180924111648-initial-seed.ts b/apps/api/src/migrations/20180924111648-initial-seed.ts deleted file mode 100644 index 8d988e43..00000000 --- a/apps/api/src/migrations/20180924111648-initial-seed.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { QueryInterface, SequelizeStatic } from 'sequelize'; - -enum USER_ROLE { - USER = 'user', - ADMIN = 'admin', -} - -enum QUESTION_CATEGORY { - HTML = 'html', - CSS = 'css', - JS = 'js', - ANGULAR = 'angular', - REACT = 'react', - GIT = 'git', - OTHER = 'other', -} - -enum QUESTION_LEVEL { - JUNIOR = 'junior', - MID = 'mid', - SENIOR = 'senior', -} - -enum QUESTION_STATUS { - ACCEPTED = 'accepted', - PENDING = 'pending', -} - -// tslint:disable-next-line:no-any -function toEntities(enumerable: any): Array<{ id: string }> { - return Object.values(enumerable).map((t) => ({ - id: t, - createdAt: new Date(), - updatedAt: new Date(), - })); -} - -module.exports = { - async up(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - const userRoles = toEntities(USER_ROLE); - const questionCategories = toEntities(QUESTION_CATEGORY); - const questionLevels = toEntities(QUESTION_LEVEL); - const questionStatuses = toEntities(QUESTION_STATUS); - - return Promise.all([ - queryInterface.bulkInsert('UserRoleType', userRoles), - queryInterface.bulkInsert('QuestionCategory', questionCategories), - queryInterface.bulkInsert('QuestionLevel', questionLevels), - queryInterface.bulkInsert('QuestionStatus', questionStatuses), - ]); - }, - - async down(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - const userRoles = toEntities(USER_ROLE); - const questionCategories = toEntities(QUESTION_CATEGORY); - const questionLevels = toEntities(QUESTION_LEVEL); - const questionStatuses = toEntities(QUESTION_STATUS); - - return Promise.all([ - queryInterface.bulkDelete('UserRoleType', userRoles), - queryInterface.bulkDelete('QuestionCategory', questionCategories), - queryInterface.bulkDelete('QuestionLevel', questionLevels), - queryInterface.bulkDelete('QuestionStatus', questionStatuses), - ]); - }, -}; diff --git a/apps/api/src/migrations/20190411134800-add-social-login.ts b/apps/api/src/migrations/20190411134800-add-social-login.ts deleted file mode 100644 index e53e6fcc..00000000 --- a/apps/api/src/migrations/20190411134800-add-social-login.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { QueryInterface, SequelizeStatic } from 'sequelize'; - -module.exports = { - async up(queryInterface: QueryInterface, Sequelize: SequelizeStatic) { - return queryInterface.addColumn('User', 'socialLogin', { - type: Sequelize.JSONB, - allowNull: false, - defaultValue: {}, - }); - }, - - async down(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - return queryInterface.removeColumn('User', 'socialLogin'); - }, -}; diff --git a/apps/api/src/migrations/20190411164500-add-session.ts b/apps/api/src/migrations/20190411164500-add-session.ts deleted file mode 100644 index b99a8311..00000000 --- a/apps/api/src/migrations/20190411164500-add-session.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { QueryInterface, SequelizeStatic } from 'sequelize'; - -module.exports = { - async up(queryInterface: QueryInterface, Sequelize: SequelizeStatic) { - return queryInterface.createTable('Session', { - id: { primaryKey: true, type: Sequelize.STRING, allowNull: false, unique: true }, - keepMeSignedIn: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, - validUntil: { type: Sequelize.DATE, allowNull: false }, - _userId: { - type: Sequelize.INTEGER, - allowNull: false, - references: { model: 'User', key: 'id' }, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - }, - - async down(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - return queryInterface.dropTable('Session'); - }, -}; diff --git a/apps/api/src/migrations/20190417172900-add-question-vote.ts b/apps/api/src/migrations/20190417172900-add-question-vote.ts deleted file mode 100644 index 2e53eb74..00000000 --- a/apps/api/src/migrations/20190417172900-add-question-vote.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { QueryInterface, SequelizeStatic } from 'sequelize'; - -module.exports = { - async up(queryInterface: QueryInterface, Sequelize: SequelizeStatic) { - return queryInterface.createTable('QuestionVote', { - _userId: { - primaryKey: true, - type: Sequelize.INTEGER, - references: { model: 'User', key: 'id' }, - allowNull: false, - }, - _questionId: { - primaryKey: true, - type: Sequelize.INTEGER, - references: { model: 'Question', key: 'id' }, - allowNull: false, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - }); - }, - - async down(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - return queryInterface.dropTable('QuestionVote'); - }, -}; diff --git a/apps/api/src/models-consts.ts b/apps/api/src/models-consts.ts deleted file mode 100644 index ffee7018..00000000 --- a/apps/api/src/models-consts.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const userRoles = ['user', 'admin'] as const; -export type UserRoleUnion = typeof userRoles[number]; - -export const questionCategories = [ - 'html', - 'css', - 'js', - 'angular', - 'react', - 'git', - 'other', -] as const; -export type QuestionCategoryUnion = typeof questionCategories[number]; - -export const questionLevels = ['junior', 'mid', 'senior'] as const; -export type QuestionLevelUnion = typeof questionLevels[number]; - -export const questionStatuses = ['accepted', 'pending'] as const; -export type QuestionStatusUnion = typeof questionStatuses[number]; diff --git a/apps/api/src/models/Question.ts b/apps/api/src/models/Question.ts deleted file mode 100644 index 9b296266..00000000 --- a/apps/api/src/models/Question.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { isArray } from 'util'; - -import { - Table, - Column, - Model, - DataType, - Unique, - ForeignKey, - AllowNull, - Default, - BeforeUpdate, - BeforeCreate, - BelongsToMany, - Scopes, - IFindOptions, - Sequelize, -} from 'sequelize-typescript'; - -import { sequelize } from '../db'; -import { QuestionLevelUnion, QuestionCategoryUnion, QuestionStatusUnion } from '../models-consts'; - -import { QuestionCategory } from './QuestionCategory'; -import { QuestionLevel } from './QuestionLevel'; -import { QuestionStatus } from './QuestionStatus'; -import { QuestionVote } from './QuestionVote'; -import { User } from './User'; - -function getQuestionsOrderQuery(orders: Array<[string, 'DESC' | 'ASC'] | [string]>): string { - if (!orders || !orders.length) { - return `"Question"."id" ASC`; - } - - return orders - .filter((o) => o.length > 0) - .filter(([colName]) => { - return colName in Question.rawAttributes; - }) - .map((o) => { - const [colName, order = ''] = o; - if (colName === 'votesCount') { - return `"votesCount" ${order}`.trim(); - } - - return `"Question"."${colName}" ${order}`.trim(); - }) - .join(',\n'); -} - -function getQuestionsWhereQuery( - where: { [P in keyof Question]?: number | string | boolean | number[] | string[] | boolean[] } -): string { - return Object.entries(where) - .map(([key, val]) => { - if (isArray(val)) { - return `"Question"."${key}" IN (:${key})`; - } - return `"Question"."${key}" = :${key}`; - }) - .join(' AND '); -} - -@Scopes({ - withVotes() { - return { - include: [ - { - model: User, - as: '_votes', - attributes: ['id'], - }, - ], - }; - }, -}) -@Table({ version: true, timestamps: true }) -export class Question extends Model { - @BeforeUpdate - @BeforeCreate - static setAcceptedAt(instance: Question) { - if (!instance.acceptedAt && instance._statusId === 'accepted') { - instance.acceptedAt = new Date(); - } - if (instance.acceptedAt && instance._statusId === 'pending') { - instance.acceptedAt = null; - } - } - - static async findAllWithVotes( - { limit, offset, order, where }: IFindOptions, - userId?: User['id'] - ): Promise { - // tslint:disable-next-line:no-any - const orders = order as any; - // tslint:disable-next-line:no-any - const whereQuery = getQuestionsWhereQuery(where as any); - - const didUserVoteOnQuery = userId - ? `COALESCE( (SELECT true FROM "QuestionVote" WHERE "_questionId" = "Question"."id" AND "_userId" = :userId), false)` - : `false`; - - return sequelize.query( - ` - SELECT - ${didUserVoteOnQuery} as "didUserVoteOn", - "Question"."id", - "Question"."question", - "Question"."_categoryId", - "Question"."_levelId", - "Question"."_statusId", - "Question"."acceptedAt", - count("_votes"."id") as "votesCount" - FROM "Question" - LEFT OUTER JOIN ( - "QuestionVote" INNER JOIN "User" AS "_votes" ON "_votes"."id" = "QuestionVote"."_userId" - ) ON "Question"."id" = "QuestionVote"."_questionId" - - ${whereQuery ? `WHERE ${whereQuery}` : ''} - - GROUP BY "Question".id - ORDER BY ${getQuestionsOrderQuery(orders)} - ${limit ? 'LIMIT :limit' : ''} - ${offset ? 'OFFSET :offset' : ''} - ; - `, - { - type: Sequelize.QueryTypes.SELECT, - nest: true, - // tslint:disable-next-line:no-any - replacements: { limit, offset, userId, ...(where as any) }, - // tslint:disable-next-line:no-any - model: Question as any, - } - ); - } - - static async didUserVoteOn(user: User, question: Question): Promise { - const vote = await QuestionVote.findOne({ - where: { - _userId: user.id, - _questionId: question.id, - }, - }); - - return Boolean(vote); - } - - @Unique - @AllowNull(false) - @Column(DataType.TEXT) - question!: string; - - @Unique - @AllowNull(true) - @Column(DataType.DATE) - acceptedAt?: Date | null; - - @ForeignKey(() => QuestionCategory) - @AllowNull(false) - @Column(DataType.STRING) - _categoryId!: QuestionCategoryUnion; - - @ForeignKey(() => QuestionLevel) - @AllowNull(false) - @Column(DataType.STRING) - _levelId!: QuestionLevelUnion; - - @ForeignKey(() => QuestionStatus) - @Default('pending') - @AllowNull(false) - @Column(DataType.STRING) - _statusId!: QuestionStatusUnion; - - @BelongsToMany(() => User, { - through: () => QuestionVote, - foreignKey: '_questionId', - otherKey: '_userId', - as: '_votes', - }) - _votes?: Array; - - @Column({ - type: new DataType.VIRTUAL(DataType.BOOLEAN), - get() { - return this.getDataValue('didUserVoteOn') || false; - }, - }) - didUserVoteOn?: boolean; - - @Column({ - type: new DataType.VIRTUAL(DataType.INTEGER), - get() { - if (this.getDataValue('votesCount')) { - return this.getDataValue('votesCount'); - } - - const votes = this.getDataValue('_votes') as Question['_votes']; - if (!votes) { - throw new Error('Include _votes if you need votesCount!'); - } - return votes.length; - }, - }) - votesCount!: number; -} diff --git a/apps/api/src/models/QuestionCategory.ts b/apps/api/src/models/QuestionCategory.ts deleted file mode 100644 index 7fac85a6..00000000 --- a/apps/api/src/models/QuestionCategory.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - Unique, - AllowNull, - HasMany, - PrimaryKey, -} from 'sequelize-typescript'; - -import { Question } from './Question'; - -@Table({ version: true, timestamps: true }) -export class QuestionCategory extends Model { - @Unique - @AllowNull(false) - @PrimaryKey - @Column(DataType.TEXT) - readonly id!: string; - - @HasMany(() => Question, '_categoryId') - _questions?: Question[]; -} diff --git a/apps/api/src/models/QuestionLevel.ts b/apps/api/src/models/QuestionLevel.ts deleted file mode 100644 index cf74f449..00000000 --- a/apps/api/src/models/QuestionLevel.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - Unique, - AllowNull, - HasMany, - PrimaryKey, -} from 'sequelize-typescript'; - -import { Question } from './Question'; - -@Table({ version: true, timestamps: true }) -export class QuestionLevel extends Model { - @Unique - @AllowNull(false) - @PrimaryKey - @Column(DataType.TEXT) - readonly id!: string; - - @HasMany(() => Question, '_levelId') - _questions?: Question[]; -} diff --git a/apps/api/src/models/QuestionStatus.ts b/apps/api/src/models/QuestionStatus.ts deleted file mode 100644 index 1a85522a..00000000 --- a/apps/api/src/models/QuestionStatus.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - Unique, - AllowNull, - HasMany, - PrimaryKey, -} from 'sequelize-typescript'; - -import { Question } from './Question'; - -@Table({ version: true, timestamps: true }) -export class QuestionStatus extends Model { - @Unique - @AllowNull(false) - @PrimaryKey - @Column(DataType.TEXT) - readonly id!: string; - - @HasMany(() => Question, '_statusId') - _questions?: Question[]; -} diff --git a/apps/api/src/models/QuestionVote.ts b/apps/api/src/models/QuestionVote.ts deleted file mode 100644 index f7ed2417..00000000 --- a/apps/api/src/models/QuestionVote.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - ForeignKey, - AllowNull, - BelongsTo, -} from 'sequelize-typescript'; - -import { Question } from './Question'; -import { User } from './User'; - -@Table({ timestamps: true, updatedAt: false }) -export class QuestionVote extends Model { - @ForeignKey(() => User) - @AllowNull(false) - @Column({ type: DataType.INTEGER, primaryKey: true }) - _userId!: number; - - @ForeignKey(() => Question) - @AllowNull(false) - @Column({ type: DataType.INTEGER, primaryKey: true }) - _questionId!: number; - - @BelongsTo(() => User, '_userId') - _user?: User; - - @BelongsTo(() => Question, '_questionId') - _question?: Question; -} diff --git a/apps/api/src/models/Session.ts b/apps/api/src/models/Session.ts deleted file mode 100644 index 82644c82..00000000 --- a/apps/api/src/models/Session.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { nanoid } from 'nanoid'; -import { - Table, - Column, - Model, - DataType, - AllowNull, - PrimaryKey, - Sequelize, - ForeignKey, - BelongsTo, -} from 'sequelize-typescript'; - -import { User } from './User'; - -@Table({ version: true, timestamps: true }) -export class Session extends Model { - @Column - readonly createdAt!: Date; - - @Column - readonly updatedAt!: Date; - - @Column - readonly version!: number; - - // tslint:disable-next-line:no-magic-numbers - @PrimaryKey - @AllowNull(false) - @Column({ - type: Sequelize.STRING, - defaultValue() { - const TOKEN_LENGTH = 36; - return nanoid(TOKEN_LENGTH); - }, - }) - readonly id!: string; - - @AllowNull(false) - @Column({ - type: DataType.BOOLEAN, - defaultValue: false, - }) - keepMeSignedIn!: boolean; - - @AllowNull(false) - @Column(DataType.DATE) - validUntil!: Date; - - @ForeignKey(() => User) - @AllowNull(false) - @Column(DataType.INTEGER) - _userId!: number; - - @BelongsTo(() => User, '_userId') - _user?: User; -} diff --git a/apps/api/src/models/User.ts b/apps/api/src/models/User.ts deleted file mode 100644 index 9cb253f1..00000000 --- a/apps/api/src/models/User.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - Unique, - DefaultScope, - ForeignKey, - AllowNull, - Default, - BelongsTo, - IFindOptions, - Scopes, - BelongsToMany, -} from 'sequelize-typescript'; - -import { UserRoleUnion } from '../models-consts'; - -import { Question } from './Question'; -import { QuestionVote } from './QuestionVote'; -import { UserRole } from './UserRole'; - -function withSensitiveData(): IFindOptions { - return { - attributes: ['createdAt', 'updatedAt', 'version', 'socialLogin'], - }; -} - -@DefaultScope({ - attributes: ['id', 'email', 'firstName', 'lastName', '_roleId'], -}) -@Scopes({ - withSensitiveData, -}) -@Table({ version: true, timestamps: true }) -export class User extends Model { - readonly id!: number; - readonly createdAt!: Date; - readonly updatedAt!: Date; - readonly version!: number; - - @Unique - @AllowNull(false) - @Column(DataType.TEXT) - email!: string; - - @AllowNull(true) - @Column(DataType.TEXT) - firstName?: string | null; - - @AllowNull(true) - @Column(DataType.TEXT) - lastName?: string | null; - - @AllowNull(false) - @Default({}) - @Column(DataType.JSONB) - socialLogin!: {}; - - @AllowNull(true) - @Column(DataType.TEXT) - avatarUrl?: string | null; - - @ForeignKey(() => UserRole) - @Default('user') - @AllowNull(false) - @Column(DataType.STRING) - _roleId!: UserRoleUnion; - - @BelongsTo(() => UserRole, '_roleId') - _role?: UserRole; - - @BelongsToMany(() => Question, { - through: () => QuestionVote, - foreignKey: '_userId', - otherKey: '_questionId', - as: '_votedOn', - }) - _votedOn?: Array; -} diff --git a/apps/api/src/models/UserRole.ts b/apps/api/src/models/UserRole.ts deleted file mode 100644 index ec92da6f..00000000 --- a/apps/api/src/models/UserRole.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - Unique, - AllowNull, - HasMany, - PrimaryKey, -} from 'sequelize-typescript'; - -import { User } from './User'; - -@Table({ version: true, timestamps: true }) -export class UserRole extends Model { - @Unique - @AllowNull(false) - @PrimaryKey - @Column(DataType.TEXT) - readonly id!: string; - - @HasMany(() => User, '_roleId') - _users?: User[]; -} diff --git a/apps/api/src/modules/health-check/healthCheckRoutes.ts b/apps/api/src/modules/health-check/healthCheckRoutes.ts deleted file mode 100644 index 1e44ba5c..00000000 --- a/apps/api/src/modules/health-check/healthCheckRoutes.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Server } from '@hapi/hapi'; - -import { getConfig } from '../../config'; - -export const healthCheckRoute = { - init(server: Server) { - return server.route({ - method: 'GET', - path: '/health-check', - options: { - description: 'Health check endpoint', - tags: ['api'], - auth: false, - }, - handler() { - return { - ENV: getConfig('ENV'), - SENTRY_VERSION: getConfig('SENTRY_VERSION'), - }; - }, - }); - }, -}; diff --git a/apps/api/src/modules/hello-world/helloWorldRoute.ts b/apps/api/src/modules/hello-world/helloWorldRoute.ts deleted file mode 100644 index 03307165..00000000 --- a/apps/api/src/modules/hello-world/helloWorldRoute.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Server } from '@hapi/hapi'; - -export const helloWorldRoute = { - init(server: Server) { - return server.route({ - method: 'GET', - path: '/helloWorld', - options: { - description: 'Test endpoint', - tags: ['api'], - }, - handler() { - return 'Hello, world!'; - }, - }); - }, -}; diff --git a/apps/api/src/modules/question-votes/questionVotesRoutes.ts b/apps/api/src/modules/question-votes/questionVotesRoutes.ts deleted file mode 100644 index 8dbaf28e..00000000 --- a/apps/api/src/modules/question-votes/questionVotesRoutes.ts +++ /dev/null @@ -1,104 +0,0 @@ -import Boom from '@hapi/boom'; -import { Server } from '@hapi/hapi'; - -import { definitions } from '../../../apiTypes'; -import { Question } from '../../models/Question'; -import { QuestionVote } from '../../models/QuestionVote'; -import { User } from '../../models/User'; - -import { - CreateQuestionVoteRequestSchema, - CreateQuestionVoteResponseSchema, -} from './questionVotesSchemas'; - -export const questionVotesRoutes = { - async init(server: Server) { - await server.route({ - method: 'POST', - path: '/question-votes', - options: { - auth: { - mode: 'required', - access: { - scope: ['admin', 'user-{query._userId}'], - }, - }, - tags: ['api', 'questions', 'votes'], - validate: CreateQuestionVoteRequestSchema, - description: 'Votes on a question', - response: { - schema: CreateQuestionVoteResponseSchema, - }, - }, - async handler(request): Promise { - const { - _userId, - _questionId, - } = (request.query as unknown) as definitions['postQuestionVotesRequestQuery']; - - const question = await Question.findByPk(_questionId, { attributes: ['id'] }); - if (!question) { - throw Boom.badRequest(`Question with id=${_questionId} doesn't exist!`); - } - - const user = await User.findByPk(_userId, { attributes: ['id'] }); - if (!user) { - throw Boom.badRequest(`User with id=${_userId} doesn't exist!`); - } - - const [questionVote] = await QuestionVote.findOrCreate({ - raw: true, - where: { - _userId, - _questionId, - }, - defaults: { - _userId, - _questionId, - }, - }); - - return { - data: { - _userId: questionVote._userId, - _questionId: questionVote._questionId, - }, - }; - }, - }); - - await server.route({ - method: 'DELETE', - path: '/question-votes', - options: { - auth: { - mode: 'required', - access: { - scope: ['admin', 'user-{query._userId}'], - }, - }, - tags: ['api', 'questions', 'votes'], - validate: CreateQuestionVoteRequestSchema, - description: 'Votes on a question', - response: { - emptyStatusCode: 204, - }, - }, - async handler(request) { - const { - _userId, - _questionId, - } = (request.query as unknown) as definitions['deleteQuestionVotesRequestQuery']; - - await QuestionVote.destroy({ - where: { - _userId, - _questionId, - }, - }); - - return null; - }, - }); - }, -}; diff --git a/apps/api/src/modules/question-votes/questionVotesSchemas.ts b/apps/api/src/modules/question-votes/questionVotesSchemas.ts deleted file mode 100644 index 85aa76bc..00000000 --- a/apps/api/src/modules/question-votes/questionVotesSchemas.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Joi from '@hapi/joi'; - -export const CreateQuestionVoteRequestSchema = { - query: Joi.object({ - _userId: Joi.number().integer().required(), - _questionId: Joi.number().integer().required(), - }).required(), -}; - -export const CreateQuestionVoteResponseSchema = Joi.object({ - data: Joi.object({ - _userId: Joi.number().integer().required(), - _questionId: Joi.number().integer().required(), - }).required(), -}); diff --git a/apps/api/src/modules/questions/questionRoutes.integration.test.ts b/apps/api/src/modules/questions/questionRoutes.integration.test.ts deleted file mode 100644 index 0fbac86c..00000000 --- a/apps/api/src/modules/questions/questionRoutes.integration.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Server } from '@hapi/hapi'; -import { expect } from 'chai'; -import faker = require('faker'); -import { uniqBy } from 'lodash'; - -import { questionCategories, questionLevels } from '../../models-consts'; -import { Question } from '../../models/Question'; -import { getServerWithPlugins } from '../../server'; -import { generateQuestions } from '../../tests/integrationTestsUtils'; - -describe('helloWorldRoute', async () => { - let devfaqServer: Server; - - beforeEach(async () => { - devfaqServer = await getServerWithPlugins(); - }); - - it('should allow empty response', async () => { - const res = await devfaqServer.inject({ - method: 'GET', - url: '/questions', - }); - - expect(res.result).to.eql({ data: [], meta: { total: 0 } }); - }); - - const assertAreAllAccepted = >(result: T) => { - const areAllAccepted = result.every((item) => item._statusId === 'accepted' && item.acceptedAt); - expect(areAllAccepted).to.eql(true); - }; - - const assertAreAllPending = >(result: T) => { - const areAllPending = result.every((item) => item._statusId === 'pending' && !item.acceptedAt); - expect(areAllPending).to.eql(true); - }; - - const assertAreAllUnique = >(result: T) => { - const areAllUnique = uniqBy(result, 'id').length === result.length; - expect(areAllUnique).to.eql(true); - }; - - const assertAllHaveValidCategory = >(category: any, result: T) => { - const allHaveValidCategory = result.every((item) => item._categoryId === category); - expect(allHaveValidCategory, category).to.eql(true); - }; - - const assertAllHaveValidLevel = >(level: any, result: T) => { - const allHaveValidLevel = result.every((item) => item._levelId === level); - expect(allHaveValidLevel, level).to.eql(true); - }; - - describe('GET /questions', async () => { - it('should return all questions in query', async () => { - await generateQuestions(20); - - const res = await devfaqServer.inject({ - method: 'GET', - url: '/questions', - }); - - const result = res.result as any; - expect(result).to.be.an('object'); - - assertAreAllAccepted(result!.data); - assertAreAllUnique(result!.data); - }); - - it('should return questions matching the query', async () => { - await generateQuestions(20); - - for (let i = 0; i < 5; ++i) { - const category = faker.random.arrayElement(questionCategories); - const level = faker.random.arrayElement(questionLevels); - - const res = await devfaqServer.inject({ - method: 'GET', - url: `/questions?category=${category}&level=${level}`, - }); - const result = res.result as any; - - assertAllHaveValidCategory(category, result!.data); - assertAllHaveValidLevel(level, result!.data); - assertAreAllAccepted(result!.data); - assertAreAllUnique(result!.data); - } - }); - }); - - describe('POST /questions', async () => { - it('should create questions with status=pending', async () => { - for (let i = 0; i < 20; ++i) { - const category = faker.random.arrayElement(questionCategories); - const level = faker.random.arrayElement(questionLevels); - - const payload = { - question: faker.lorem.sentence(), - category, - level, - }; - - await devfaqServer.inject({ - method: 'POST', - url: `/questions`, - payload, - }); - } - - const questions = await Question.findAll({ raw: true }); - assertAreAllPending(questions); - assertAreAllUnique(questions); - }); - }); -}); diff --git a/apps/api/src/modules/questions/questionRoutes.ts b/apps/api/src/modules/questions/questionRoutes.ts deleted file mode 100644 index 428e71ab..00000000 --- a/apps/api/src/modules/questions/questionRoutes.ts +++ /dev/null @@ -1,278 +0,0 @@ -import Boom from '@hapi/boom'; -import Hapi from '@hapi/hapi'; -import { IFindOptions } from 'sequelize-typescript'; - -import { definitions } from '../../../apiTypes'; -import { Question } from '../../models/Question'; -import { isAdmin, getCurrentUser } from '../../utils/utils'; - -import { - GetQuestionsRequestSchema, - GetQuestionsResponseSchema, - CreateQuestionRequestSchema, - CreateQuestionResponseSchema, - GetOneQuestionRequestSchema, - GetOneQuestionResponseSchema, - UpdateQuestionRequestSchema, - UpdateQuestionResponseSchema, -} from './questionSchemas'; - -function columnNameFromQuery( - orderBy: NonNullable -): string { - switch (orderBy) { - case 'level': - return '_levelId'; - default: - return orderBy; - } -} - -function getOrderFromQuery(request: Hapi.Request): IFindOptions['order'] { - const { order, orderBy } = request.query as definitions['getQuestionsRequestQuery']; - if (!order || !orderBy) { - return undefined; - } - - return [[columnNameFromQuery(orderBy), order], ['id']]; -} - -export const questionsRoutes = { - async init(server: Hapi.Server) { - await server.route({ - method: 'GET', - path: '/questions', - options: { - auth: { mode: 'try' }, - tags: ['api', 'questions'], - validate: GetQuestionsRequestSchema, - description: 'Returns questions', - response: { - schema: GetQuestionsResponseSchema, - }, - }, - async handler(request): Promise { - const { - category, - level, - status, - limit, - offset, - } = request.query as definitions['getQuestionsRequestQuery']; - const currentUser = getCurrentUser(request); - - const where = { - ...(category && { _categoryId: category }), - ...(level && { _levelId: level }), - ...(status && isAdmin(request) ? { _statusId: status } : { _statusId: 'accepted' }), - }; - - const total = await Question.count({ - where, - }); - - const order = getOrderFromQuery(request); - - const questions = await Question.findAllWithVotes( - { - where, - limit, - offset, - ...(order && { order }), - subQuery: false, - }, - currentUser && currentUser.id - ); - - const data = questions.map((q) => { - return { - id: q.id, - question: q.question, - _categoryId: q._categoryId, - _levelId: q._levelId, - _statusId: q._statusId, - acceptedAt: q.acceptedAt?.toISOString(), - votesCount: q.votesCount, - currentUserVotedOn: q.didUserVoteOn, - }; - }); - - return { data, meta: { total } }; - }, - }); - - await server.route({ - method: 'POST', - path: '/questions', - options: { - auth: { mode: 'try' }, - tags: ['api', 'questions'], - validate: CreateQuestionRequestSchema, - description: 'Creates a question', - notes: `When user is not an admin, it won't publish the question`, - response: { - schema: CreateQuestionResponseSchema, - }, - }, - async handler(request): Promise { - const { - question, - level, - category, - } = request.payload as definitions['postQuestionsRequestBody']; - - const newQuestion = await Question.create({ - question, - _levelId: level, - _categoryId: category, - _statusId: 'pending', - }); - - const data = { - id: newQuestion.id, - question: newQuestion.question, - _categoryId: newQuestion._categoryId, - _levelId: newQuestion._levelId, - _statusId: newQuestion._statusId, - acceptedAt: newQuestion.acceptedAt?.toISOString(), - currentUserVotedOn: false, - votesCount: 0, - }; - - return { data }; - }, - }); - - await server.route({ - method: 'PATCH', - path: '/questions/{id}', - options: { - auth: { - mode: 'required', - scope: ['admin'], - }, - tags: ['api', 'questions'], - validate: UpdateQuestionRequestSchema, - description: 'Updates a question', - response: { - schema: UpdateQuestionResponseSchema, - }, - }, - async handler(request): Promise { - const { - id, - } = (request.params as unknown) as definitions['patchQuestionsIdRequestPathParams']; - - const q = await Question.scope('withVotes').findByPk(id); - - if (!q) { - throw Boom.notFound(); - } - - const { - question, - level, - category, - status, - } = request.payload as definitions['patchQuestionsIdRequestBody']; - - const currentUser = getCurrentUser(request); - - q.question = question; - q._levelId = level; - q._categoryId = category; - q._statusId = status; - - await q.save(); - - const data = { - id: q.id, - question: q.question, - _categoryId: q._categoryId, - _levelId: q._levelId, - _statusId: q._statusId, - acceptedAt: q.acceptedAt?.toISOString(), - currentUserVotedOn: currentUser ? await Question.didUserVoteOn(currentUser, q) : false, - votesCount: q.votesCount, - }; - - return { data }; - }, - }); - - await server.route({ - method: 'GET', - path: '/questions/{id}', - options: { - auth: { mode: 'try' }, - tags: ['api', 'questions'], - validate: GetOneQuestionRequestSchema, - description: 'Returns one question', - response: { - schema: GetOneQuestionResponseSchema, - }, - }, - async handler(request): Promise { - const { - id, - } = (request.params as unknown) as definitions['getQuestionsIdRequestPathParams']; - - const question = await Question.scope('withVotes').findOne({ - where: { - id, - _statusId: 'accepted', - }, - }); - - if (!question) { - throw Boom.notFound(); - } - - const currentUser = getCurrentUser(request); - - const data = { - id: question.id, - question: question.question, - _categoryId: question._categoryId, - _levelId: question._levelId, - _statusId: question._statusId, - acceptedAt: question.acceptedAt?.toISOString(), - currentUserVotedOn: currentUser - ? await Question.didUserVoteOn(currentUser, question) - : false, - votesCount: question.votesCount, - }; - - return { data }; - }, - }); - - await server.route({ - method: 'DELETE', - path: '/questions/{id}', - options: { - auth: { - mode: 'required', - access: { - scope: ['admin'], - }, - }, - tags: ['api', 'questions'], - validate: GetOneQuestionRequestSchema, - description: 'Deletes one question', - response: { - emptyStatusCode: 204, - }, - }, - async handler(request) { - const { id } = request.params; - - await Question.destroy({ - where: { id }, - }); - - return null; - }, - }); - }, -}; diff --git a/apps/api/src/modules/questions/questionSchemas.ts b/apps/api/src/modules/questions/questionSchemas.ts deleted file mode 100644 index fa883c05..00000000 --- a/apps/api/src/modules/questions/questionSchemas.ts +++ /dev/null @@ -1,77 +0,0 @@ -import Joi from '@hapi/joi'; - -import { questionCategories, questionStatuses, questionLevels } from '../../models-consts'; - -export const QuestionCategorySchema = Joi.string().valid(...questionCategories); - -export const QuestionStatusSchema = Joi.string().valid(...questionStatuses); - -export const QuestionLevelSchema = Joi.string().valid(...questionLevels); - -export const QuestionSchema = Joi.object({ - id: Joi.number().integer().required(), - question: Joi.string().required(), - _categoryId: QuestionCategorySchema.required(), - _levelId: QuestionLevelSchema.required(), - _statusId: QuestionStatusSchema.required(), - acceptedAt: Joi.date().allow(null), -}); - -export const GetQuestionsRequestSchema = { - query: Joi.object({ - category: QuestionCategorySchema, - status: QuestionStatusSchema, - level: Joi.array().items(QuestionLevelSchema).single().optional(), - limit: Joi.number().integer().optional(), - offset: Joi.number().integer().optional(), - orderBy: Joi.string().valid('acceptedAt', 'level', 'votesCount'), - order: Joi.string().valid('asc', 'desc'), - }).required(), -}; - -export const QuestionResponseSchema = QuestionSchema.keys({ - votesCount: Joi.number().integer().required(), - currentUserVotedOn: Joi.bool(), -}); - -export const GetQuestionsResponseSchema = Joi.object({ - data: Joi.array().items(QuestionResponseSchema).required(), - meta: Joi.object({ - total: Joi.number().required(), - }).optional(), -}); - -export const GetOneQuestionRequestSchema = { - params: Joi.object({ - id: Joi.number().integer().required(), - }).required(), -}; - -export const GetOneQuestionResponseSchema = Joi.object({ - data: QuestionResponseSchema.required(), -}).required(); - -export const CreateQuestionRequestPayloadSchema = Joi.object({ - question: Joi.string().required(), - level: QuestionLevelSchema.required(), - category: QuestionCategorySchema.required(), -}); - -export const CreateQuestionRequestSchema = { - payload: CreateQuestionRequestPayloadSchema.required(), -}; - -export const CreateQuestionResponseSchema = Joi.object({ - data: QuestionResponseSchema.required(), -}).required(); - -export const UpdateQuestionRequestSchema = { - params: Joi.object({ - id: Joi.number().integer().required(), - }).required(), - payload: CreateQuestionRequestPayloadSchema.keys({ - status: QuestionStatusSchema.required(), - }).required(), -}; - -export const UpdateQuestionResponseSchema = CreateQuestionResponseSchema; diff --git a/apps/api/src/plugins/auth/github.ts b/apps/api/src/plugins/auth/github.ts deleted file mode 100644 index 436e8506..00000000 --- a/apps/api/src/plugins/auth/github.ts +++ /dev/null @@ -1,112 +0,0 @@ -import Bell from '@hapi/bell'; -import Boom from '@hapi/boom'; -import Hapi from '@hapi/hapi'; -import fetch from 'node-fetch'; - -import type { AuthProviderOptions } from '.'; - -export interface GitHubAuthPluginConfig { - githubClientId: string; - githubClientSecret: string; - githubPassword: string; - isProduction: boolean; -} - -interface GitHubCredentials { - token: string; - profile: { - id: number; - username: string; - displayName: string; - email: string | null; - raw: unknown; - }; -} - -const getNames = (credentials: GitHubCredentials): { firstName: string; lastName: string } => { - if (!credentials.profile || !credentials.profile.displayName) { - return { firstName: '', lastName: '' }; - } - - const [firstName, ...rest] = credentials.profile.displayName.split(' '); - return { - firstName, - lastName: rest.join(' '), - }; -}; - -const GitHubAuthPlugin: Hapi.Plugin = { - multiple: false, - name: 'DEVFAQ-API GitHub Auth Plugin', - version: '1.0.0', - async register(server, options) { - const bellOptions: Bell.BellOptions = { - provider: 'github', - password: options.githubPassword, - clientId: options.githubClientId, - clientSecret: options.githubClientSecret, - isSecure: options.isProduction, - forceHttps: options.isProduction, - }; - await server.auth.strategy('github', 'bell', bellOptions); - - await server.route({ - method: ['GET', 'POST'], - path: '/github', - options: { - auth: { - mode: 'try', - strategy: 'github', - }, - tags: ['api', 'oauth', 'github'], - }, - async handler(request, h) { - if (!request.auth.isAuthenticated) { - return request.auth.error.message; - } - - const gitHubCredentials = (request.auth.credentials as unknown) as GitHubCredentials; - - const token = gitHubCredentials.token; - - const res = await fetch('https://api.github.com/user/emails', { - headers: { - Authorization: `token ${token}`, - }, - }); - - if (!res.ok) { - throw Boom.serverUnavailable('GitHub responded with an error!'); - } - - const emails = (await res.json()) as Array<{ - email: string; - primary: boolean; - verified: boolean; - visibility: unknown; - }>; - const primaryEmail = emails.find((e) => e.primary && e.verified); - - if (!primaryEmail) { - throw Boom.unauthorized('Your primary email is not verified!'); - } - - const { firstName, lastName } = getNames(gitHubCredentials); - - return options.next( - { - serviceName: 'github', - externalServiceId: gitHubCredentials.profile.id, - email: primaryEmail.email, - firstName, - lastName, - }, - request, - h - ); - }, - }); - }, -}; - -export default GitHubAuthPlugin; diff --git a/apps/api/src/plugins/auth/index.ts b/apps/api/src/plugins/auth/index.ts deleted file mode 100644 index af5a6486..00000000 --- a/apps/api/src/plugins/auth/index.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { isString } from 'util'; - -import Bell from '@hapi/bell'; -import Boom from '@hapi/boom'; -import HapiAuthCookie from '@hapi/cookie'; -import Hapi from '@hapi/hapi'; -import Joi from '@hapi/joi'; -import { Op } from 'sequelize'; - -import { Session } from '../../models/Session'; -import { User } from '../../models/User'; - -import GitHubAuthPlugin from './github'; -import type { GitHubAuthPluginConfig } from './github'; -import { createNewSession, getNewSessionValidUntil } from './session'; - -declare module '@hapi/hapi' { - interface AuthCredentials { - session: Session; - } -} - -declare module '@hapi/boom' { - export function isBoom(err: any, statusCode?: number): err is Boom; -} - -interface RequiredOptions { - cookieDomain: string; - isProduction: boolean; - cookiePassword: string; -} - -type ProviderOptions = GitHubAuthPluginConfig | {}; -type AuthPluginOptions = RequiredOptions & ProviderOptions; - -interface AuthUserData { - serviceName: Bell.Provider; - externalServiceId: number | string; - email: string; - firstName?: string; - lastName?: string; -} - -type AuthProviderNext = ( - data: AuthUserData, - // tslint:disable-next-line:no-any - request: Hapi.Request, - h: Hapi.ResponseToolkit -) => Hapi.Lifecycle.ReturnValue; - -export interface AuthProviderOptions { - next: AuthProviderNext; -} - -const meAuthSchema = Joi.object({ - keepMeSignedIn: Joi.boolean().required(), - validUntil: Joi.date().required(), - createdAt: Joi.date().required(), - updatedAt: Joi.date().required(), - version: Joi.number().required(), - _userId: Joi.number().required(), - _user: Joi.object({ - id: Joi.number().required(), - email: Joi.string().required(), - createdAt: Joi.date().required(), - updatedAt: Joi.date().required(), - _roleId: Joi.string().required(), - firstName: Joi.string().allow('', null), - lastName: Joi.string().allow('', null), - // socialLogin: Joi.object({ - // github: Joi.alternatives(Joi.string(), Joi.number().integer()), - // }).allow(null), - socialLogin: Joi.any(), - }).required(), -}); - -async function maybeUpdateSessionValidity(session: Session) { - const validUntil = session.validUntil; - const newValidUntil = getNewSessionValidUntil(session.keepMeSignedIn); - - // tslint:disable-next-line:no-magic-numbers - const ONE_MINUTE = 1000 * 60; - if (newValidUntil.getTime() - validUntil.getTime() <= ONE_MINUTE) { - return; // update at most after 1 minute - } - - session.validUntil = newValidUntil; - await session.save(); -} - -const findOrCreateAccountFor = async ({ - serviceName, - externalServiceId, - email, - firstName, - lastName, -}: AuthUserData): Promise => { - const userWithSocialLogin = await User.findOne({ - where: { - socialLogin: { - [serviceName]: { - [Op.eq]: externalServiceId, - }, - }, - }, - }); - - if (userWithSocialLogin) { - return userWithSocialLogin; - } else { - const userWithEmail = await User.findOne({ - where: { - email, - }, - }); - if (userWithEmail) { - // @todo merge accounts - throw Boom.conflict('User with provided email already exists!'); - } else { - const user = await User.create({ - email, - socialLogin: { [serviceName]: externalServiceId }, - firstName, - lastName, - }); - - return user; - } - } -}; - -const next: AuthProviderNext = async (authData, request, _h) => { - const user = await findOrCreateAccountFor(authData); - const session = await createNewSession(user, false); - - request.cookieAuth.set({ id: session.id }); - - return ` - - - - - `.trim(); -}; - -const AuthPlugin: Hapi.Plugin = { - multiple: false, - name: 'DEVFAQ-API Auth Plugin', - version: '1.0.0', - async register(server, options) { - await server.register(Bell); - await server.register(HapiAuthCookie); - - const cookieOptions: HapiAuthCookie.Options = { - cookie: { - name: 'session', - password: options.cookiePassword, - // tslint:disable-next-line:no-magic-numbers - ttl: 365 * 24 * 60 * 60 * 1000, - encoding: 'iron' as 'iron', - isSecure: options.isProduction, - isHttpOnly: true, - clearInvalid: true, - strictHeader: true, - isSameSite: 'Lax' as 'Lax', - domain: options.cookieDomain, - path: '/', - }, - async validateFunc(request, session: { id?: string | number } | undefined) { - if (!session || !session.id) { - return { valid: false }; - } - - const sessionModel = await Session.findOne({ - where: { - id: session.id, - validUntil: { - [Op.gte]: new Date(), - }, - }, - include: [User.scope(['defaultScope', 'withSensitiveData'])], - }); - - if (!sessionModel) { - request?.cookieAuth.clear(); - return { valid: false }; - } - - await maybeUpdateSessionValidity(sessionModel); - - const roleId = sessionModel._user && sessionModel._user._roleId; - const userId = sessionModel._user && sessionModel._user.id; - const scope = ['user', `user-${userId}`, roleId].filter(isString); - - return { valid: true, credentials: { session: sessionModel, scope } }; - }, - }; - await server.auth.strategy('session', 'cookie', cookieOptions); - await server.auth.default('session'); - - if ('githubClientId' in options && options.githubClientId && options.githubClientSecret) { - const githubOptions: GitHubAuthPluginConfig & AuthProviderOptions = { - ...options, - next, - }; - await server.register({ - plugin: GitHubAuthPlugin, - options: githubOptions, - }); - } - - await server.route({ - method: 'POST', - path: '/logout', - options: { - tags: ['api', 'oauth'], - auth: { - mode: 'try', - strategy: 'session', - }, - }, - async handler(request) { - request.cookieAuth.clear(); - if (request.auth.credentials && request.auth.credentials.session) { - await Session.destroy({ - where: { - id: request.auth.credentials.session.id, - }, - }); - } - - return null; - }, - }); - - await server.route({ - method: 'GET', - path: '/me', - options: { - tags: ['api', 'oauth'], - auth: { - mode: 'try', - strategy: 'session', - }, - response: { - schema: Joi.object({ - data: meAuthSchema.required().allow(null), - }).required(), - }, - }, - async handler(request) { - if (request.auth.credentials && request.auth.credentials.session) { - return { data: request.auth.credentials.session.toJSON() }; - } - - return { data: null }; - }, - }); - }, -}; - -export default AuthPlugin; diff --git a/apps/api/src/plugins/auth/session.ts b/apps/api/src/plugins/auth/session.ts deleted file mode 100644 index 6189d79f..00000000 --- a/apps/api/src/plugins/auth/session.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Op } from 'sequelize'; - -import { Session } from '../../models/Session'; -import { User } from '../../models/User'; - -export function getNewSessionValidUntil(keepMeSignedIn: boolean): Date { - const validUntil = new Date(); - if (keepMeSignedIn) { - // tslint:disable-next-line:no-magic-numbers - validUntil.setHours(validUntil.getHours() + 24 * 7); - } else { - // tslint:disable-next-line:no-magic-numbers - validUntil.setHours(validUntil.getHours() + 2); - } - - return validUntil; -} - -export async function createNewSession(user: User, keepMeSignedIn = false) { - await Session.destroy({ - where: { - validUntil: { - [Op.lt]: new Date(), - }, - }, - }); - - const session = await Session.create({ - validUntil: getNewSessionValidUntil(keepMeSignedIn), - keepMeSignedIn, - _userId: user.id, - }); - - // @todo - // await user.update('lastLoginAt', new Date()); - - return session.reload({ - include: [{ model: User }], - }); -} diff --git a/apps/api/src/plugins/cls/cls.ts b/apps/api/src/plugins/cls/cls.ts deleted file mode 100644 index 96abec8e..00000000 --- a/apps/api/src/plugins/cls/cls.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Plugin } from '@hapi/hapi'; -import * as Sentry from '@sentry/node'; -import { setClsProxyValue } from 'cls-proxify'; -import uuid from 'uuid'; - -import { - contextNs, - setContext, - getContext, - updateContext, - USER_CONTEXT_KEY, - SENTRY_BREADCRUMBS_KEY, - SENTRY_KEY, -} from './context'; - -declare module '@hapi/hapi' { - interface PluginProperties { - cls: { - setContext: typeof setContext; - getContext: typeof getContext; - updateContext: typeof updateContext; - }; - } -} - -export const cls: Plugin<{}> = { - multiple: false, - name: 'cls', - version: '1.0.0', - - async register(server, _options) { - server.expose('setContext', setContext); - server.expose('getContext', getContext); - server.expose('updateContext', updateContext); - - server.ext('onRequest', (request, h) => { - contextNs.bindEmitter(request.raw.req); - contextNs.bindEmitter(request.raw.res); - return contextNs.runPromise(async () => { - request.server.plugins.cls.setContext(USER_CONTEXT_KEY, { currentRequestID: uuid.v4() }); - setClsProxyValue(SENTRY_KEY, { - addBreadcrumb(breadcrumb: Sentry.Breadcrumb) { - const breadcrumbs = getContext(SENTRY_BREADCRUMBS_KEY) || []; - breadcrumbs.push(breadcrumb); - setContext(SENTRY_BREADCRUMBS_KEY, breadcrumbs); - }, - Severity: Sentry.Severity, - withScope: Sentry.withScope, - captureException: Sentry.captureException, - getCurrentHub: Sentry.getCurrentHub, - }); - return h.continue; - }); - }); - - server.ext('onPostAuth', (request, h) => { - if (request.auth.credentials.session._user?.email) { - updateContext(USER_CONTEXT_KEY, { - userEmail: request.auth.credentials.session._user?.email, - }); - } - return h.continue; - }); - - server.events.on('response', () => { - // cleanup - setContext(SENTRY_BREADCRUMBS_KEY, undefined); - }); - }, -}; diff --git a/apps/api/src/plugins/cls/context.ts b/apps/api/src/plugins/cls/context.ts deleted file mode 100644 index e70727a5..00000000 --- a/apps/api/src/plugins/cls/context.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { clsProxifyNamespace, clsProxify } from 'cls-proxify'; - -export const USER_CONTEXT_KEY = 'USER_CONTEXT'; -type USER_CONTEXT_KEY = typeof USER_CONTEXT_KEY; -export const SENTRY_BREADCRUMBS_KEY = 'SENTRY_BREADCRUMBS'; -type SENTRY_BREADCRUMBS_KEY = typeof SENTRY_BREADCRUMBS_KEY; -export const contextNs = clsProxifyNamespace; - -export const SENTRY_KEY = 'SENTRY_KEY'; -export const SentryCLS = clsProxify(SENTRY_KEY, Sentry); - -interface ContextData { - currentRequestID?: string; - userEmail?: string; - key?: string; -} - -type KeyToValue = { - [SENTRY_BREADCRUMBS_KEY]: Sentry.Breadcrumb[] | undefined; - [USER_CONTEXT_KEY]: ContextData | undefined; -}; - -export function setContext( - name: N, - values: KeyToValue[N] -): KeyToValue[N] { - return contextNs.active ? contextNs.set(name, values) : undefined; -} - -export function updateContext( - name: N, - updates: KeyToValue[N] -): KeyToValue[N] { - const context = getContext(name) ?? setContext(name, updates); - if (!context) { - return undefined; - } - Object.assign(context, updates); - return context; -} - -export function getContext(name: N): KeyToValue[N] { - return contextNs.active ? contextNs.get(name) : undefined; -} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts deleted file mode 100644 index 8a30ad9f..00000000 --- a/apps/api/src/server.ts +++ /dev/null @@ -1,276 +0,0 @@ -import * as fs from 'fs'; - -import Boom from '@hapi/boom'; -import Hapi from '@hapi/hapi'; -import Inert from '@hapi/inert'; -import Joi from '@hapi/joi'; -import Vision from '@hapi/vision'; -import HapiSwagger from 'hapi-swagger'; - -import pkg from '../package.json'; - -import { getConfig, isProd, isStaging } from './config'; -import { healthCheckRoute } from './modules/health-check/healthCheckRoutes'; -import { helloWorldRoute } from './modules/hello-world/helloWorldRoute'; -import { questionVotesRoutes } from './modules/question-votes/questionVotesRoutes'; -import { questionsRoutes } from './modules/questions/questionRoutes'; -import AuthPlugin from './plugins/auth'; -import { SentryCLS } from './plugins/cls/context'; -import { handleException, routeToLabel } from './utils/utils'; - -const getServer = () => { - return new Hapi.Server({ - host: '0.0.0.0', - port: getConfig('PORT'), - routes: { - cors: { - origin: ['*'], - credentials: true, - }, - response: { - modify: true, - options: { - allowUnknown: false, - convert: true, - stripUnknown: { objects: true }, - }, - }, - validate: { - async failAction(_request, _h, err) { - if (isProd()) { - // In prod, log a limited error message and throw the default Bad Request error. - handleException(err, SentryCLS.Severity.Warning); - - throw Boom.badRequest(`Invalid request payload input`); - } else { - handleException(err, SentryCLS.Severity.Warning); - throw err; - } - }, - }, - }, - }); -}; - -export async function getServerWithPlugins() { - const server = getServer(); - server.validator(Joi); - - if (process.env.ENV !== 'test') { - /** - * @description Automatically generate labels for all endpoints with validation for the purpose of automatic types generation on the front-end - */ - const allLabels = new Map(); - server.events.on('route', (route) => { - if (route.path.startsWith('/swaggerui') || route.path.startsWith('/documentation')) { - return; // skip - } - - if (!route.settings.response?.schema) { - if (!isProd() && getConfig('ENV') !== 'test') { - console.warn(`Route without a response schema: ${route.method} ${route.path}`); - } - return; - } - - if (!Joi.isSchema(route.settings.response?.schema)) { - if (!isProd() && getConfig('ENV') !== 'test') { - console.warn(`Route response schema is not a Joi schema: ${route.method} ${route.path}`); - } - return; - } - - // tslint:disable-next-line: no-any - const maybeALabel = (route.settings.response.schema as any)._flags?.label; - if (maybeALabel) { - if (!isProd() && getConfig('ENV') !== 'test') { - console.log( - `Skipping route ${route.method} ${route.path} because it already has a response schema label: ${maybeALabel}` - ); - } - allLabels.set(maybeALabel, true); - return; - } - - const label = routeToLabel(route) + 'Response'; - - if (allLabels.has(label)) { - throw new Error(`Duplicate label: ${label} for ${route.method} ${route.path}`); - } - allLabels.set(label, true); - - // route.settings.response.schema = (route.settings.response.schema as Joi.Schema).label(label); - }); - } - - server.events.on({ name: 'request', channels: 'error' }, (request, event, _tags) => { - const baseUrl = `${server.info.protocol}://${request.info.host}`; - - SentryCLS.withScope((scope) => { - scope.setExtra('timestamp', request.info.received); - scope.setExtra('remoteAddress', request.info.remoteAddress); - - // const user = - // request.auth && - // request.auth.credentials && - // request.auth.credentials.session && - // request.auth.credentials.session._user; - // if (user) { - // scope.setUser({ - // id: user.id, - // username: user.email, - // email: user.email, - // json: user.toJSON(), - // }); - // } - - const extraData = { - method: request.method, - query_string: request.query, - headers: request.headers, - cookies: request.state, - url: baseUrl + request.path, - data: ['get', 'head'].includes(request.method) ? '' : request.payload, - }; - - scope.setExtra('request', extraData); - - handleException(event.error); - }); - }); - - const swaggerOptions: HapiSwagger.RegisterOptions = { - info: { - title: `${pkg.name} Documentation`, - version: - getConfig('ENV') + '-' + pkg.version + '-' + fs.readFileSync('.version', 'utf-8').trim(), - }, - auth: false, - }; - - await server.register([ - { plugin: Inert }, - { plugin: Vision }, - { - plugin: HapiSwagger, - options: swaggerOptions, - }, - ]); - - await server.register( - { - plugin: AuthPlugin, - options: { - cookieDomain: getConfig('COOKIE_DOMAIN'), - isProduction: isProd() || isStaging(), - cookiePassword: getConfig('COOKIE_PASSWORD'), - githubClientId: getConfig('GITHUB_CLIENT_ID'), - githubClientSecret: getConfig('GITHUB_CLIENT_SECRET'), - githubPassword: getConfig('GITHUB_PASSWORD'), - }, - }, - { - routes: { - prefix: '/oauth', - }, - } - ); - - await helloWorldRoute.init(server); - await healthCheckRoute.init(server); - await questionsRoutes.init(server); - await questionVotesRoutes.init(server); - - type CspReport = { - 'blocked-uri': string; - 'document-uri': string; - 'original-policy': string; - referrer: string; - 'violated-directive': string; - 'column-number'?: number; - 'line-number'?: number; - 'source-file': string; - }; - - server.route({ - path: '/csp', - method: 'POST', - options: { - tags: ['api'], - auth: { - mode: 'try', - }, - payload: { - override: 'application/json', - }, - }, - handler(request, h) { - if (typeof request.payload !== 'object' || !('csp-report' in request.payload)) { - return null; - } - const cspReport = request.payload as { - 'csp-report': CspReport; - }; - - SentryCLS.withScope((scope) => { - const { - info: { host, received, remoteAddress }, - method, - query, - headers, - state, - path, - auth, - } = request; - const baseUrl = `${server.info.protocol}://${host}`; - scope.setExtra('timestamp', received); - scope.setExtra('remoteAddress', remoteAddress); - - const user = auth?.credentials?.session?._user; - if (user) { - scope.setUser({ - id: String(user.id), - username: user.email, - email: user.email, - json: user.toJSON(), - }); - } - - const extraData = { - method, - query_string: query, - headers, - cookies: state, - url: baseUrl + path, - data: cspReport['csp-report'], - }; - - scope.setExtra('request', extraData); - - handleException(new Error('CSP Error')); - }); - - return null; - }, - }); - - await server.route({ - method: 'GET', - path: '/', - options: { - auth: { - mode: 'try', - strategy: 'session', - }, - }, - handler(request) { - if (request.auth.isAuthenticated) { - return request.auth.credentials; - } - - return `

Stay awhile and listen.

You're not logged in.

`; - }, - }); - - return server; -} diff --git a/apps/api/src/shim.d.ts b/apps/api/src/shim.d.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/tests/integrationTestsUtils.ts b/apps/api/src/tests/integrationTestsUtils.ts deleted file mode 100644 index 62533821..00000000 --- a/apps/api/src/tests/integrationTestsUtils.ts +++ /dev/null @@ -1,54 +0,0 @@ -import faker from 'faker'; - -import { initDb, sequelize, getAllModels } from '../db'; -import { questionCategories, questionLevels, questionStatuses } from '../models-consts'; -import { Question } from '../models/Question'; - -before(async () => { - await initDb(); - await sequelize.sync({ match: /_test$/, logging: false }); - await clearDB(); -}); - -afterEach(async () => { - await clearDB(); -}); - -async function clearDB() { - const TRUNCATE_BLACKLIST = [ - 'sequelize', - 'Sequelize', - 'SequelizeMeta', - 'QuestionCategory', - 'QuestionLevel', - 'QuestionStatus', - 'UserRole', - ]; - - const keys = Object.keys(getAllModels()).filter((key) => !TRUNCATE_BLACKLIST.includes(key)); - return Promise.all( - keys.map(async (key) => { - await sequelize.query(`TRUNCATE TABLE "${key}" RESTART IDENTITY CASCADE;`, { - raw: true, - }); - }) - ); -} - -export async function generateQuestions(num: number): Promise { - return Promise.all( - Array.from({ length: num }).map(async (_i) => { - const _categoryId = faker.random.arrayElement(questionCategories); - const _levelId = faker.random.arrayElement(questionLevels); - const _statusId = faker.random.arrayElement(questionStatuses); - - return Question.create({ - question: faker.lorem.sentence(), - acceptedAt: faker.date.past(), - _categoryId, - _levelId, - _statusId, - }); - }) - ); -} diff --git a/apps/api/src/tests/utils.test.ts b/apps/api/src/tests/utils.test.ts deleted file mode 100644 index 840d1767..00000000 --- a/apps/api/src/tests/utils.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect } from 'chai'; - -import { defaultToAny } from '../utils/utils'; - -describe('utils', () => { - describe('defaultToAny', () => { - it('should use first value if provided', () => { - expect(defaultToAny(1)).to.eql(1); - }); - - it('should use second value if first is undefined', () => { - expect(defaultToAny(undefined, 'aaa')).to.eql('aaa'); - }); - - it('should use second value if first is null', () => { - expect(defaultToAny(null, 'bbb')).to.eql('bbb'); - }); - - it('should use next values if previous are undefined or null', () => { - expect(defaultToAny(undefined, null, 123)).to.eql(123); - }); - }); -}); diff --git a/apps/api/src/utils/utils.ts b/apps/api/src/utils/utils.ts deleted file mode 100644 index df3f6825..00000000 --- a/apps/api/src/utils/utils.ts +++ /dev/null @@ -1,85 +0,0 @@ -import Hapi, { RequestRoute } from '@hapi/hapi'; -import type { Severity } from '@sentry/node'; -import { isUndefined, omitBy, upperFirst, camelCase } from 'lodash'; -import moment from 'moment'; -import { Model } from 'sequelize-typescript'; - -import { getConfig } from '../config'; -import { User } from '../models/User'; -import { SentryCLS } from '../plugins/cls/context'; - -type Nil = T | undefined | null; -export function defaultToAny(v1: T): T; -export function defaultToAny(v1: Nil, v2: T): T; -export function defaultToAny(v1: Nil, v2: boolean): boolean; -export function defaultToAny(v1: Nil, v2: Nil, v3: T): T; -export function defaultToAny(v1: Nil, v2: Nil, v3: boolean): boolean; -export function defaultToAny(...defaults: Array>) { - return defaults.reduce((prev, next) => { - if (prev === undefined || prev === null || Number.isNaN((prev as unknown) as number)) { - return next; - } - return prev; - }); -} - -// tslint:disable-next-line:no-any -export function handleException(err: any, level?: Severity) { - if (!getConfig('SENTRY_DSN')) { - return console.error(err, level); - } - - if (level) { - SentryCLS.withScope((scope) => { - scope.setLevel(level); - SentryCLS.captureException(err); - }); - } else { - SentryCLS.captureException(err); - } -} - -// tslint:disable-next-line:no-any -export const isEmptyES6 = (obj: any): obj is {} => { - return ( - obj && - Object.getOwnPropertyNames(obj).length === 0 && - Object.getOwnPropertySymbols(obj).length === 0 - ); -}; - -export const omitUndefined = (obj: T): Partial => - omitBy(obj, isUndefined) as Partial; - -export function getNewSessionValidUntil(keepMeSignedIn: boolean): Date { - const now = moment(); - - // tslint:disable-next-line:no-magic-numbers - return keepMeSignedIn ? now.add(7, 'days').toDate() : now.add(2, 'hours').toDate(); -} - -export const getCurrentUser = (request: Hapi.Request): User | undefined => { - return ( - request && - request.auth && - request.auth.credentials && - request.auth.credentials.session && - request.auth.credentials.session._user - ); -}; - -export const isAdmin = (request: Hapi.Request): boolean => { - const user = getCurrentUser(request); - return Boolean(user && user._roleId === 'admin'); -}; - -// tslint:disable-next-line:no-any -export const arrayToJSON = >(entities: Array>) => { - return entities.map((entity) => entity.toJSON()); -}; - -export const routeToLabel = ({ method, path }: RequestRoute): string => { - const prefix = method.toLowerCase(); - const label = upperFirst(camelCase(path)); - return prefix + label; -}; diff --git a/apps/api/test/.mocharc.js b/apps/api/test/.mocharc.js deleted file mode 100644 index 01d4d5f6..00000000 --- a/apps/api/test/.mocharc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - require: ['test/setup-env.js', 'source-map-support/register'], - timeout: 600000, - bail: true, - exit: true, -}; diff --git a/apps/api/test/setup-env.js b/apps/api/test/setup-env.js deleted file mode 100644 index 51214bf0..00000000 --- a/apps/api/test/setup-env.js +++ /dev/null @@ -1,7 +0,0 @@ -var chai = require('chai'); -chai.use(require('sinon-chai')); - -require('ts-node').register({ - project: './tsconfig.json', - transpileOnly: true, -}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json deleted file mode 100644 index 20b16b05..00000000 --- a/apps/api/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "baseUrl": ".", - "emitDecoratorMetadata": true, - "esModuleInterop": true, - "experimentalDecorators": true, - "lib": ["es2017"], - "module": "commonjs", - "outDir": "dist", - "rootDir": ".", - "target": "es2017", - "typeRoots": ["./node_modules/@types", "./typings"] - }, - "exclude": ["dist"] -} diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js new file mode 100644 index 00000000..c8df6075 --- /dev/null +++ b/apps/web/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ["custom"], +}; diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 00000000..1d67ece7 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,30 @@ +## Getting Started + +First, run the development server: + +```bash +yarn dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. + +[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. + +The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/web/next.config.js b/apps/web/next.config.js new file mode 100644 index 00000000..53e0a424 --- /dev/null +++ b/apps/web/next.config.js @@ -0,0 +1,5 @@ +const withTM = require("next-transpile-modules")(["ui"]); + +module.exports = withTM({ + reactStrictMode: true, +}); diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000..e1c04c32 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "web", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "12.0.8", + "react": "17.0.2", + "react-dom": "17.0.2", + "ui": "*" + }, + "devDependencies": { + "eslint": "7.32.0", + "eslint-config-custom": "*", + "next-transpile-modules": "9.0.0", + "tsconfig": "*", + "@types/node": "^17.0.12", + "@types/react": "17.0.37", + "typescript": "^4.5.3" + } +} diff --git a/apps/web/pages/index.tsx b/apps/web/pages/index.tsx new file mode 100644 index 00000000..6ec0887c --- /dev/null +++ b/apps/web/pages/index.tsx @@ -0,0 +1,10 @@ +import { Button } from "ui"; + +export default function Web() { + return ( +
+

Web

+
+ ); +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 00000000..a355365b --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/nextjs.json", + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/apps/www/.babelrc b/apps/www/.babelrc deleted file mode 100644 index ed9d4f3d..00000000 --- a/apps/www/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "plugins": ["lodash"], - "presets": ["next/babel"] -} diff --git a/apps/www/.env b/apps/www/.env deleted file mode 100644 index 573ac39d..00000000 --- a/apps/www/.env +++ /dev/null @@ -1,6 +0,0 @@ -API_URL="http://api.devfaq.localhost:3002" -# API_URL="https://staging-api.devfaq.pl" -GA_TRACKING_ID="" -ABSOLUTE_URL="http://app.devfaq.localhost:3000" -SENTRY_DSN= -VERSION=123 diff --git a/apps/www/.eslintrc b/apps/www/.eslintrc deleted file mode 100644 index 57ef60d6..00000000 --- a/apps/www/.eslintrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "root": false, - "extends": ["react-app"], - "rules": { - "react-hooks/exhaustive-deps": ["error", { "additionalHooks": "useCustomCompareMemo" }], - "jsx-a11y/anchor-is-valid": "off", - "@typescript-eslint/no-unused-vars": "off" - } -} diff --git a/apps/www/app.js b/apps/www/app.js deleted file mode 100644 index 1a4b2bfe..00000000 --- a/apps/www/app.js +++ /dev/null @@ -1,107 +0,0 @@ -/* eslint-disable import/order */ -const { Dsn } = require('@sentry/utils'); - -const newrelic = require('newrelic'); - -const { v4 } = require('uuid'); - -// MyDevil.net specific -function loadDotEnv() { - const fs = require('fs'); - const version = fs.readFileSync('.version', 'utf-8').trim(); - process.env.ENV = version.split(':').shift(); - process.env.VERSION = version; - process.env.SENTRY_VERSION = version.split(':').pop(); - console.log('process.env.ENV', process.env.ENV, process.env.NODE_ENV); - require('dotenv').config({ - path: `.env.${process.env.ENV}`, - }); -} -loadDotEnv(); - -const { projectId, user } = (process.env.SENTRY_DSN && new Dsn(process.env.SENTRY_DSN)) || {}; -const apiUrl = process.env.API_URL; -const cspReportEndpoint = - projectId && user - ? `https://o125101.ingest.sentry.io/api/${projectId}/security/?sentry_key=${user}&sentry_environment=${process.env.ENV}&sentry_release=${process.env.SENTRY_VERSION}` - : ''; - -const Sentry = require('@sentry/node'); -const isDev = process.env.NODE_ENV !== 'production'; -Sentry.init({ - dsn: process.env.SENTRY_DSN, - debug: isDev, - environment: process.env.ENV, - release: process.env.SENTRY_VERSION, -}); - -const Url = require('url'); - -const cookieParser = require('cookie-parser'); -const express = require('express'); -const helmet = require('helmet'); -const next = require('next'); - -const app = next({ dev: false }); - -const handle = app.getRequestHandler(); - -app - .prepare() - .then(() => { - loadDotEnv(); - const server = express(); - server.use(Sentry.Handlers.requestHandler()); - server.use(Sentry.Handlers.errorHandler()); - server.use(helmet()); - - server.use((req, res, next) => { - res.locals.nonce = v4(); - - if (cspReportEndpoint) { - res.setHeader( - 'Report-To', - JSON.stringify({ - group: 'csp-group', - max_age: 10886400, - endpoints: [{ url: cspReportEndpoint }], - }) - ); - } - helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - connectSrc: ["'self'", apiUrl, 'https://*.sentry.io', 'https://sentry.io'], - styleSrc: ["'self'", 'https://fonts.googleapis.com'], - scriptSrc: [(_req, res) => `'nonce-${res.locals.nonce}'`, `'strict-dynamic'`], - fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'], - imgSrc: [ - "'self'", - 'https://www.google-analytics.com', - 'https://*.githubusercontent.com', - ], - ...(cspReportEndpoint && { reportUri: cspReportEndpoint }), - reportTo: `csp-group`, - }, - reportOnly: true, - }, - })(req, res, next); - }); - server.use(cookieParser()); - - server.get('*', (req, res) => { - const parsedUrl = Url.parse(req.url, true); - const { pathname } = parsedUrl; - newrelic.setTransactionName(pathname); - - return handle(req, res); - }); - - const port = process.env.PORT || '3000'; - server.listen(port, () => console.log(`Server listening at localhost:${port}`)); - }) - .catch((err) => { - console.error(err); - process.exit(1); - }); diff --git a/apps/www/components/activeLink/ActiveLink.tsx b/apps/www/components/activeLink/ActiveLink.tsx deleted file mode 100644 index b3238266..00000000 --- a/apps/www/components/activeLink/ActiveLink.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import classNames from 'classnames'; -import invariant from 'invariant'; -import Link, { LinkProps } from 'next/link'; -import React, { memo } from 'react'; -import { connect } from 'react-redux'; - -import type { AppState } from '../../redux/reducers/index'; -import { hrefQueryToAsPath } from '../../utils/redirect'; -import type { RouteDetails } from '../../utils/types'; - -interface ActiveLinkOwnProps { - activeClassName: string; - exact?: boolean; - disabledWhenActive?: boolean; - onClick?: React.MouseEventHandler; - children: React.ReactElement; - query?: Record; -} - -type ActiveLinkComponentProps = Omit & ActiveLinkOwnProps; - -const ActiveLinkComponent: React.FC< - ActiveLinkComponentProps & ReturnType -> = memo( - ({ - isMatch, - activeClassName, - disabledWhenActive, - query, - as, - children, - href, - replace, - scroll, - shallow, - passHref, - prefetch, - }) => { - const conditionallyAddClassToChild = ( - shouldAddActiveClass: boolean, - activeClassName: string, - child: React.ReactElement - ): React.ReactElement => { - if (!shouldAddActiveClass) { - return child; - } - const modifiedChild = React.cloneElement(child, { - ...child.props, - className: classNames(child.props.className, { [activeClassName]: shouldAddActiveClass }), - }); - return modifiedChild; - }; - - invariant(activeClassName != null, 'activeClassName is required!'); - - const child = React.Children.only(children); - const newChild = conditionallyAddClassToChild(isMatch, activeClassName, child); - - // if (isMatch && this.props.disabledWhenActive) { - // return
{newChild}
; - // } - - return ( - - {newChild} - - ); - } -); - -const checkForMatch = ( - routeDetails: RouteDetails, - { - href, - query, - as, - exact, - }: { - href: ActiveLinkComponentProps['href']; - query: ActiveLinkComponentProps['query']; - as: string; - exact?: boolean; - } -): boolean => { - const isExactMatch = as === routeDetails.asPath; - - if (isExactMatch || exact) { - return isExactMatch; - } - - const asNoQuery = hrefQueryToAsPath(href, query, true); - if (asNoQuery.as === routeDetails.asPath) { - return true; - } - - if (routeDetails.asPath && routeDetails.asPath.startsWith(asNoQuery.as)) { - return true; - } - - return false; -}; - -const mapStateToProps = (state: AppState, ownProps: ActiveLinkComponentProps) => { - const { as, href } = hrefQueryToAsPath(ownProps.href, ownProps.query); - - const isMatch = checkForMatch(state.routeDetails.current, { - as, - href: ownProps.href, - query: ownProps.query, - exact: ownProps.exact, - }); - - return { - isMatch, - as, - href, - }; -}; - -const ActiveLink = connect(mapStateToProps)(ActiveLinkComponent); -export default ActiveLink; diff --git a/apps/www/components/adminQuestions/AdminQuestions.tsx b/apps/www/components/adminQuestions/AdminQuestions.tsx deleted file mode 100644 index 852c2f61..00000000 --- a/apps/www/components/adminQuestions/AdminQuestions.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useState, useEffect, memo, useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; - -import spinnerStyles from '../../components/layout/appSpinner.module.scss'; -import { TechnologyKey } from '../../constants/technology-icon-items'; -import { useUIContext } from '../../contexts/UIContextProvider'; -import { ActionCreators } from '../../redux/actions'; -import { Question } from '../../redux/reducers/questions'; -import { Container } from '../container/Container'; -import { CommonModalProps } from '../modals/baseModal/BaseModal'; -import QuestionsList from '../questions/questionsList/QuestionsList'; -import questionListStyles from '../questions/questionsList/questionsList.module.scss'; -import noQuestionsStyles from '../questions/selectedQuestions/noQuestionsSelectedInfo.module.scss'; - -const EmptyAdminQuestions = memo(({ questions }: { questions?: Question[] }) => { - if (!questions?.length) { - return null; - } - return ( -
- -

Nie ma żadnych pytań do zaakceptowania!

-
-
- ); -}); - -const AdminQuestions = memo(() => { - const { openEditQuestionModal } = useUIContext(); - const [status, setStatus] = useState<'pending' | 'accepted'>('pending'); - const [technology] = useState(undefined); - - const dispatch = useDispatch(); - - const questions = useSelector((state) => state.questions); - const selectedLevels = useSelector((state) => state.selectedLevels); - - const refetchQuestions = useCallback(() => { - dispatch( - ActionCreators.fetchQuestionsForAdmin({ - technology, - selectedLevels, - status, - }) - ); - }, [dispatch, selectedLevels, status, technology]); - - useEffect(() => { - refetchQuestions(); - }, [refetchQuestions]); - - const onEditFinished: CommonModalProps['onClose'] = useCallback( - (e) => { - if (e?.reason === 'submit') { - refetchQuestions(); - } - }, - [refetchQuestions] - ); - - const toggleQuestion = useCallback( - (questionId: Question['id']) => { - dispatch(ActionCreators.deleteQuestionForAdmin(questionId)); - }, - [dispatch] - ); - - const editQuestion = useCallback( - (questionId: Question['id']) => { - if (!questions.data) { - return; - } - - const question = questions.data.data.find((q) => q.id === questionId); - if (!question) { - return; - } - - openEditQuestionModal(question, onEditFinished); - }, - [onEditFinished, openEditQuestionModal, questions.data] - ); - - const updateStatus: React.ChangeEventHandler = useCallback((e) => { - setStatus(e.currentTarget.value as 'pending' | 'accepted'); - }, []); - - if (!questions || questions.isLoading) { - return null; - } - - return ( - <> - - {questions.isLoading &&
} - - - - ); -}); -export default AdminQuestions; diff --git a/apps/www/components/animateProperty/AnimateProperty.tsx b/apps/www/components/animateProperty/AnimateProperty.tsx deleted file mode 100644 index eb830745..00000000 --- a/apps/www/components/animateProperty/AnimateProperty.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { memo, useCallback, useMemo, PropsWithChildren } from 'react'; -import { Transition } from 'react-transition-group'; - -import { updateStyles } from '../../utils/styles'; -import type { Nil } from '../../utils/types'; - -interface AnimateHeightProps { - in?: boolean; - enterTime: number; - exitTime: number; - nodeRef: React.RefObject; -} - -export const AnimateHeight = memo>( - ({ enterTime, exitTime, in: isIn, children, nodeRef }) => { - const reflow = useCallback((el: Nil): void => { - // @ts-ignore - // reading scrollTop causes reflow of an element - const _ignore = el?.scrollTop; - return; - }, []); - - const isBrowser = !process || !!process.browser; - - const timeout = useMemo(() => ({ enter: enterTime, exit: exitTime }), [enterTime, exitTime]); - - const onExit = useCallback(() => { - updateStyles(nodeRef.current, { - willChange: 'height, opacity', - height: nodeRef.current?.scrollHeight + 'px', - opacity: '1', - }); - reflow(nodeRef.current); - }, [nodeRef, reflow]); - - const onExiting = useCallback(() => { - updateStyles(nodeRef.current, { - height: '0', - opacity: '0', - minHeight: '0', - transition: `height ${exitTime}ms, opacity ${exitTime}ms`, - }); - }, [exitTime, nodeRef]); - - const onExited = useCallback(() => { - updateStyles(nodeRef.current, { - height: '', - opacity: '', - transition: '', - minHeight: '', - willChange: '', - }); - }, [nodeRef]); - - const onEnter = useCallback( - (isAppearing: boolean) => { - updateStyles(nodeRef.current, { - willChange: 'height, opacity', - height: '0', - opacity: '0', - minHeight: '0', - }); - reflow(nodeRef.current); - }, - [nodeRef, reflow] - ); - - const onEntering = useCallback( - (isAppearing: boolean) => { - updateStyles(nodeRef.current, { - height: nodeRef.current?.scrollHeight + 'px', - opacity: '1', - transition: `height ${enterTime}ms, opacity ${enterTime}ms`, - }); - }, - [enterTime, nodeRef] - ); - - const onEntered = useCallback( - (isAppearing: boolean) => { - updateStyles(nodeRef.current, { - height: '', - opacity: '', - transition: '', - minHeight: '', - willChange: '', - }); - }, - [nodeRef] - ); - - const addEndListener = useCallback( - (done: () => void) => { - nodeRef.current?.addEventListener('transitionend', done, { once: true, passive: true }); - }, - [nodeRef] - ); - - return ( - - {() => children} - - ); - } -); diff --git a/apps/www/components/appLogo/AppLogo.tsx b/apps/www/components/appLogo/AppLogo.tsx deleted file mode 100644 index 34e32d43..00000000 --- a/apps/www/components/appLogo/AppLogo.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; - -import styles from './appLogo.module.scss'; - -const AppLogo = ({ fill = '#ffffff' }) => { - return ( - - - - - - - - - - - - ); -}; - -export default AppLogo; diff --git a/apps/www/components/appLogo/appLogo.module.scss b/apps/www/components/appLogo/appLogo.module.scss deleted file mode 100644 index 25339ba5..00000000 --- a/apps/www/components/appLogo/appLogo.module.scss +++ /dev/null @@ -1,26 +0,0 @@ -@import 'common'; - -.app-logo { - display: block; - width: 100px; - height: 40px; - @include mediaquery('gt-sm') { - width: 140px; - height: var(--navigation-header-height); - } -} - -.scaling-svg-container { - position: relative; - height: 100%; - width: 100%; - padding: 0; - display: block; -} -.scaling-svg { - position: absolute; - height: 100%; - width: 100%; - left: 0; - top: 0; -} diff --git a/apps/www/components/container/Container.tsx b/apps/www/components/container/Container.tsx deleted file mode 100644 index 25e14e50..00000000 --- a/apps/www/components/container/Container.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import classNames from 'classnames'; -import React, { memo } from 'react'; - -import styles from './container.module.scss'; - -/** - * @default as="div" - */ -export const Container: React.FC<{ - className?: string; - as?: 'div' | 'main' | 'section' | 'article' | 'footer' | 'header'; -}> = memo(({ className, children, as: As = 'div' }) => { - return {children}; -}); diff --git a/apps/www/components/container/container.module.scss b/apps/www/components/container/container.module.scss deleted file mode 100644 index c29e7a52..00000000 --- a/apps/www/components/container/container.module.scss +++ /dev/null @@ -1,13 +0,0 @@ -@import 'common'; - -.container { - width: 100%; - box-sizing: border-box; - margin: 0 auto; - max-width: var(--container-small-width); - padding: 0 var(--container-small-padding); - @include mediaquery('gt-sm') { - max-width: var(--container-large-width); - padding: 0 var(--container-large-padding); - } -} diff --git a/apps/www/components/errorBoundary/ErrorBoundary.tsx b/apps/www/components/errorBoundary/ErrorBoundary.tsx deleted file mode 100644 index 238a2dcc..00000000 --- a/apps/www/components/errorBoundary/ErrorBoundary.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import * as Sentry from '@sentry/browser'; -import React from 'react'; - -export class ErrorBoundary extends React.Component { - state: { error: Error | null } = { - error: null, - }; - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo & Record) { - if (!this.state.error) { - this.setState({ error }); - } - Sentry.configureScope((scope) => { - Object.keys(errorInfo).forEach((key) => { - scope.setExtra(key, errorInfo[key]); - }); - Sentry.captureException(error); - }); - } - - onReportFeedbackClick = () => { - Sentry.showReportDialog(); - }; - - render() { - if (this.state.error) { - return ( - <> -

Something went wrong…

- - - ); - } else { - return this.props.children; - } - } -} diff --git a/apps/www/components/footer/AppFooter.tsx b/apps/www/components/footer/AppFooter.tsx deleted file mode 100644 index fffd4b45..00000000 --- a/apps/www/components/footer/AppFooter.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import classNames from 'classnames'; -import React from 'react'; - -import env from '../../utils/env'; -import ActiveLink from '../activeLink/ActiveLink'; -import { Container } from '../container/Container'; - -import styles from './appFooter.module.scss'; - -export const AppFooter = () => { - const version = env.VERSION; - return ( -
- - - -
- ); -}; diff --git a/apps/www/components/footer/appFooter.module.scss b/apps/www/components/footer/appFooter.module.scss deleted file mode 100644 index 3dc80e74..00000000 --- a/apps/www/components/footer/appFooter.module.scss +++ /dev/null @@ -1,50 +0,0 @@ -@import 'common'; -.footer-container { - margin-top: auto; - display: block; - height: var(--footer-height); - background-color: var(--purple-branding); - z-index: 30; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-end; - @include mediaquery('gt-sm') { - } - .footer-navigation { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - @include mediaquery('gt-sm') { - justify-content: flex-end; - } - &--links { - list-style-type: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: row; - margin-left: -30px; - @include mediaquery('gt-sm') { - margin-left: 0; - } - &--item { - a { - color: var(--white-text); - font-size: 1.4rem; - text-decoration: none; - display: block; - margin-left: 30px; - text-align: center; - @include mediaquery('gt-sm') { - text-align-last: left; - } - } - &:first-child a { - margin-left: 0; - } - } - } - } -} diff --git a/apps/www/components/headers/ctaHeader/CtaHeader.tsx b/apps/www/components/headers/ctaHeader/CtaHeader.tsx deleted file mode 100644 index 5e11ca2f..00000000 --- a/apps/www/components/headers/ctaHeader/CtaHeader.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import classNames from 'classnames'; -import React, { memo } from 'react'; -import { connect } from 'react-redux'; - -import { useUIContext } from '../../../contexts/UIContextProvider'; -import { AppState } from '../../../redux/reducers/index'; -import { - getAreAnyQuestionSelected, - getDownloadUrl, - getIsAdmin, -} from '../../../redux/selectors/selectors'; -import ActiveLink from '../../activeLink/ActiveLink'; -import { Container } from '../../container/Container'; - -import styles from './ctaHeader.module.scss'; - -export const CtaHeaderComponent: React.FC> = memo( - ({ areAnyQuestionSelected, isAdmin }) => { - const { openAddQuestionModal } = useUIContext(); - const onDownloadClick: React.MouseEventHandler = (_event) => { - reportEvent('Pobierz plik PDF'); - // @todo open DownloadSuccessModal - // @todo this.analyticsService.reportPdfDownload(this.selectedQuestionsService.getSelectedIds()); - }; - - const onOpenAddQuestionModalClick: React.MouseEventHandler = (_event) => { - reportEvent('Dodaj pytanie'); - openAddQuestionModal(); - }; - - const reportEvent = (action: string) => { - globalReportEvent(action, 'Menu'); - }; - return ( - - ); - } -); - -const mapStateToProps = (state: AppState) => { - return { - areAnyQuestionSelected: getAreAnyQuestionSelected(state), - downloadUrl: getDownloadUrl(state), - isAdmin: getIsAdmin(state), - }; -}; - -export const CtaHeader = connect(mapStateToProps)(CtaHeaderComponent); diff --git a/apps/www/components/headers/ctaHeader/ctaHeader.module.scss b/apps/www/components/headers/ctaHeader/ctaHeader.module.scss deleted file mode 100644 index 8efcf15a..00000000 --- a/apps/www/components/headers/ctaHeader/ctaHeader.module.scss +++ /dev/null @@ -1,92 +0,0 @@ -@import 'common'; -.cta-header { - background-color: var(--purple-branding); - position: sticky; - top: 0; - z-index: 2; - a { - color: var(--white-text); - text-decoration: none; - &:hover { - color: var(--white-text-dark); - } - } -} - -.app-header--cta { - height: var(--sticky-header-height); - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - overflow: hidden; -} - -.app-tabs { - display: flex; - flex-direction: row; - padding-bottom: 2px; - box-sizing: border-box; - width: 100%; - @include mediaquery('gt-sm') { - width: auto; - } - &--tab { - display: block; - padding: 0; - flex: 1; - height: 50px; - box-sizing: border-box; - border-bottom: 2px solid transparent; - display: flex; - justify-content: center; - align-items: center; - text-align: center; - margin-left: 15px; - font-size: 1.5rem; - white-space: nowrap; - position: relative; - @include mediaquery('gt-sm') { - padding: 0 29px; - flex: initial; - } - &:first-child { - margin-left: 0; - } - &.active { - border-color: var(--white-text); - font-weight: 700; - } - &.active:hover { - border-color: currentColor; - } - &::after { - content: ''; - display: inline-block; - border-radius: 50%; - width: 6px; - height: 6px; - margin-left: 6px; - transition: background-color 0.2s; - transform: translateY(-50%); // right: 15px; - } - &.has-notification { - &::after { - background-color: var(--orange-notification); - } - } - } -} - -.call-to-action-buttons { - display: none; - @include mediaquery('gt-sm') { - display: block; - } - .round-button { - margin-left: 30px; - &:first-child { - margin-left: 0; - } - } -} diff --git a/apps/www/components/headers/navigationHeader/NavigationHeader.tsx b/apps/www/components/headers/navigationHeader/NavigationHeader.tsx deleted file mode 100644 index 94681c92..00000000 --- a/apps/www/components/headers/navigationHeader/NavigationHeader.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import classNames from 'classnames'; -import dynamic from 'next/dynamic'; -import React, { useState } from 'react'; - -import ActiveLink from '../../activeLink/ActiveLink'; -import AppLogo from '../../appLogo/AppLogo'; -import { Container } from '../../container/Container'; - -import DarkModeSwitcher from './darkModeSwitcher/DarkModeSwitcher'; -import LoginStatusLink from './loginStatusLink/LoginStatusLink'; -import styles from './navigationHeader.module.scss'; - -const DynamicDarkModeSwitcher = dynamic(() => import('./darkModeSwitcher/DarkModeSwitcher'), { - ssr: false, -}); - -export const NavigationHeader = () => { - const [open, toggle] = useState(false); - - const toggleMenu = () => { - toggle(!open); - }; - - const closeMenu = () => { - toggle(false); - }; - - const onAboutClick = () => { - closeMenu(); - reportEvent('Jak korzystać'); - }; - - const onAuthorsClick = () => { - closeMenu(); - reportEvent('Autorzy'); - }; - - const onLoginClick = () => { - closeMenu(); - reportEvent('Zaloguj'); - }; - - const reportEvent = (action: string) => { - globalReportEvent(action, 'Menu'); - }; - - return ( -
- - - -

- DevFAQ.pl - -

-
-
- -
-
- ); -}; diff --git a/apps/www/components/headers/navigationHeader/darkModeSwitcher/DarkModeSwitcher.tsx b/apps/www/components/headers/navigationHeader/darkModeSwitcher/DarkModeSwitcher.tsx deleted file mode 100644 index 63749c9a..00000000 --- a/apps/www/components/headers/navigationHeader/darkModeSwitcher/DarkModeSwitcher.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; - -import styles from './darkModeSwitcher.module.scss'; - -type ColorMode = 'light' | 'dark'; - -export function initializeColorMode() { - try { - setColorModeClass(document.querySelector('html')!, getBrowserColorMode()); - } catch {} -} - -export function getBrowserColorMode() { - const persistedColorPreference = window.localStorage.getItem('color-mode'); - - if (persistedColorPreference === 'dark' || persistedColorPreference === 'light') { - return persistedColorPreference; - } - - const mql = window.matchMedia('(prefers-color-scheme: dark)'); - const hasMediaQueryPreference = typeof mql.matches === 'boolean'; - if (hasMediaQueryPreference) { - return mql.matches ? 'dark' : 'light'; - } - - return 'light'; -} - -export function setColorModeClass(target: HTMLElement, colorMode: ColorMode) { - if (colorMode === 'dark') { - target?.classList.add('theme-dark'); - } else { - target?.classList.remove('theme-dark'); - } -} - -const DarkModeSwitcher = React.memo(() => { - const [colorMode, setColorMode] = useState(() => getBrowserColorMode()); - - useEffect(() => { - const html = document.querySelector('html')!; - setColorModeClass(html, colorMode); - }, [colorMode]); - - const handleColorModeChange = useCallback>((e) => { - const mode = e.target.checked ? 'dark' : 'light'; - setColorMode(mode); - window.localStorage.setItem('color-mode', mode); - }, []); - - return ( - - ); -}); - -export default DarkModeSwitcher; diff --git a/apps/www/components/headers/navigationHeader/darkModeSwitcher/darkModeSwitcher.module.scss b/apps/www/components/headers/navigationHeader/darkModeSwitcher/darkModeSwitcher.module.scss deleted file mode 100644 index abaa22d9..00000000 --- a/apps/www/components/headers/navigationHeader/darkModeSwitcher/darkModeSwitcher.module.scss +++ /dev/null @@ -1,42 +0,0 @@ -@import 'common'; - -.switch { - @include mediaquery('ls-md') { - margin: 30px auto; - } - position: relative; - display: block; - width: 40px; - height: 22px; - input { - cursor: pointer; - position: absolute; - left: 0; - height: 100%; - width: 100%; - appearance: none; - &::before { - content: ''; - position: absolute; - background: var(--purple-dark); - border-radius: 10px; - height: 100%; - width: 100%; - } - &::after { - content: ''; - transition: 0.3s; - width: 16px; - height: 16px; - position: absolute; - border-radius: 100%; - background: var(--white-text); - top: 3px; - left: 3px; - } - &:checked::after { - left: calc(100% - 3px); - transform: translateX(-100%); - } - } -} diff --git a/apps/www/components/headers/navigationHeader/loginStatusLink/LoginStatusLink.tsx b/apps/www/components/headers/navigationHeader/loginStatusLink/LoginStatusLink.tsx deleted file mode 100644 index 46eb40f5..00000000 --- a/apps/www/components/headers/navigationHeader/loginStatusLink/LoginStatusLink.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useRouter } from 'next/router'; -import React from 'react'; -import { connect } from 'react-redux'; - -import { AppState } from '../../../../redux/reducers'; -import { getLoggedInUser } from '../../../../redux/selectors/selectors'; -import { getPreviousPathFromHrefQuery } from '../../../../utils/redirect'; -import ActiveLink from '../../../activeLink/ActiveLink'; -import UserAvatar from '../../../userAvatar/UserAvatar'; -import navigationHeaderStyles from '../navigationHeader.module.scss'; - -type LoginStatusLinkProps = ReturnType; - -const LoginStatusLinkComponent: React.FC any }> = ({ - user, - onLoginClick, - route, -}) => { - if (user) { - return ; - } - - const previousPath = getPreviousPathFromHrefQuery(route.pathname, route.query); - - return ( - - Zaloguj - - ); -}; - -const mapStateToProps = (state: AppState) => { - return { - route: state.routeDetails.current, - user: getLoggedInUser(state), - }; -}; - -const LoginStatusLink = connect(mapStateToProps)(LoginStatusLinkComponent); -export default LoginStatusLink; diff --git a/apps/www/components/headers/navigationHeader/navigationHeader.module.scss b/apps/www/components/headers/navigationHeader/navigationHeader.module.scss deleted file mode 100644 index 15d0f238..00000000 --- a/apps/www/components/headers/navigationHeader/navigationHeader.module.scss +++ /dev/null @@ -1,182 +0,0 @@ -@import 'common'; - -.navigation-header { - background-color: var(--purple-branding); - @include mediaquery('gt-sm') { - overflow: hidden; - height: var(--navigation-header-height); - } - a { - color: var(--white-text); - text-decoration: none; - &:hover { - color: var(--white-text-dark); - } - } - h1 { - margin: 0; - } -} - -.app-header--main { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - box-sizing: border-box; - position: relative; - color: var(--white-text); - &.open { - a { - position: relative; - z-index: calc(var(--z-index-modal) + 1); - } - } - @include mediaquery('gt-sm') { - &::after { - content: ''; - background: var(--purple-border-lighter); - height: 1px; - width: 100vw; - position: absolute; - bottom: 0; - left: 0; - @media screen and (min-width: var(--container-large-width)) { - left: calc((var(--container-large-width) - 100vw) / 2); - } - } - } -} - -.main-nav { - margin-left: auto; - display: block; - ul { - display: none; - } - .menu-button { - $bar-height: 4px; - appearance: none; - border: 0; - background: transparent; - padding: 0; - margin: 0; - position: absolute; - top: 10px; - right: 15px; - color: var(--white-text); - background-position: 50% 50%; - background-repeat: no-repeat; - font-size: 6rem; - line-height: 25px; - width: #{(60 / 9) * $bar-height}; - height: #{(45 / 9) * $bar-height}; - transform: rotate(0deg); - transition: transform 0.5s ease-in-out; - cursor: pointer; - span { - display: block; - position: absolute; - height: $bar-height; - width: 100%; - background: white; - border-radius: $bar-height; - opacity: 1; - left: 0; - transform: rotate(0deg); - transition: 0.25s ease-in-out; - &:nth-child(1) { - top: 0px; - } - &:nth-child(2), - &:nth-child(3) { - top: #{$bar-height * 2}; - } - &:nth-child(4) { - top: #{$bar-height * 4}; - } - } - &.open { - span { - &:nth-child(1) { - top: #{$bar-height * 2}; - width: 0%; - left: 50%; - } - &:nth-child(2) { - transform: rotate(45deg); - } - &:nth-child(3) { - transform: rotate(-45deg); - } - &:nth-child(4) { - top: #{$bar-height * 2}; - width: 0%; - left: 50%; - } - } - } - &:focus { - outline: 0; - } - } - ul { - list-style-type: none; - margin: 0; - padding: 0; - display: none; - } - a { - text-transform: uppercase; - height: 100%; - display: inline-flex; - justify-content: center; - align-items: center; - &.active { - border-bottom: 1px solid white; - } - } - &.open { - display: block; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: var(--purple-branding); - z-index: var(--z-index-modal); - ul { - display: flex; - flex-direction: column; - text-align: center; - margin-top: 70px; - li:last-child { - order: -1; - } - } - a { - font-size: 2rem; - margin: 30px 0; - display: block; - } - } - @include mediaquery('gt-sm') { - .menu-button { - display: none; - } - ul { - display: flex; - flex-direction: row; - align-items: center; - } - li { - &:first-child { - margin-left: 0; - } - &:last-child { - width: 30px; - } - margin-left: 30px; - } - } -} diff --git a/apps/www/components/layout/AppSpinner.tsx b/apps/www/components/layout/AppSpinner.tsx deleted file mode 100644 index 6e350f30..00000000 --- a/apps/www/components/layout/AppSpinner.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React, { useState, useRef, useEffect, memo, useCallback } from 'react'; -import { useSelector } from 'react-redux'; - -import styles from './appSpinner.module.scss'; - -const SUSPENSE_TIME = 150; - -export const AppSpinner = memo(() => { - const [show, setShow] = useState(false); - const timerId = useRef(); - - const isLoading = useSelector((state) => state.routeDetails.isTransitioning); - - const stopTimer = useCallback(() => { - window.clearTimeout(timerId.current); - timerId.current = undefined; - }, []); - - const startTimer = useCallback(() => { - if (isLoading) { - if (!timerId.current && !show) { - timerId.current = window.setTimeout(() => { - setShow(true); - }, SUSPENSE_TIME); - } - } else { - stopTimer(); - if (show) { - setShow(false); - } - } - }, [isLoading, show, stopTimer]); - - useEffect(() => { - startTimer(); - return stopTimer; - }, [startTimer, stopTimer]); - - return show ?
: null; -}); diff --git a/apps/www/components/layout/Layout.tsx b/apps/www/components/layout/Layout.tsx deleted file mode 100644 index b495e0d1..00000000 --- a/apps/www/components/layout/Layout.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import Head from 'next/head'; -import { useRouter } from 'next/router'; -import React, { memo } from 'react'; - -import env from '../../utils/env'; -import { AppFooter } from '../footer/AppFooter'; -import { CtaHeader } from '../headers/ctaHeader/CtaHeader'; -import { NavigationHeader } from '../headers/navigationHeader/NavigationHeader'; - -import { AppSpinner } from './AppSpinner'; -import styles from './layout.module.scss'; - -interface LayoutProps { - title?: string; - description?: string; -} -const Layout: React.FC = memo( - ({ - title = 'Front-end Frequently Asked Questions', - description = 'DevFAQ.pl — największa baza pytań z programowania tworzona przez społeczność. DevFAQ.pl jest serwisem internetowym służącym do udostępniania i wymiany pytań rekrutacyjnych na stanowiska developerów.', - children, - }) => { - const router = useRouter(); - return ( - <> - - DevFAQ.pl • {title} - - - - - -
- - - -
{children}
- -
- - ); - } -); - -export default Layout; diff --git a/apps/www/components/layout/appSpinner.module.scss b/apps/www/components/layout/appSpinner.module.scss deleted file mode 100644 index 06cd871f..00000000 --- a/apps/www/components/layout/appSpinner.module.scss +++ /dev/null @@ -1,46 +0,0 @@ -@import 'common'; - -.spinner { - color: var(--purple-branding-text); - font-size: 90px; - text-indent: -9999em; - overflow: hidden; - width: 1em; - height: 1em; - border-radius: 50%; - animation: load6 1.7s infinite ease, round 1.7s infinite ease; - position: fixed; - left: 50%; - top: calc(15% + 155px); - transform: translate3d(-50%, -50%, 0.00001px); - z-index: var(--z-index-spinner); -} - -@keyframes load6 { - 0% { - box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, - 0 -0.83em 0 -0.477em; - } - 5%, - 95% { - box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, - 0 -0.83em 0 -0.477em; - } - 10%, - 59% { - box-shadow: 0 -0.83em 0 -0.4em, -0.087em -0.825em 0 -0.42em, -0.173em -0.812em 0 -0.44em, - -0.256em -0.789em 0 -0.46em, -0.297em -0.775em 0 -0.477em; - } - 20% { - box-shadow: 0 -0.83em 0 -0.4em, -0.338em -0.758em 0 -0.42em, -0.555em -0.617em 0 -0.44em, - -0.671em -0.488em 0 -0.46em, -0.749em -0.34em 0 -0.477em; - } - 38% { - box-shadow: 0 -0.83em 0 -0.4em, -0.377em -0.74em 0 -0.42em, -0.645em -0.522em 0 -0.44em, - -0.775em -0.297em 0 -0.46em, -0.82em -0.09em 0 -0.477em; - } - 100% { - box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, - 0 -0.83em 0 -0.477em; - } -} diff --git a/apps/www/components/layout/layout.module.scss b/apps/www/components/layout/layout.module.scss deleted file mode 100644 index 659f5ea8..00000000 --- a/apps/www/components/layout/layout.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -.app-root { - display: flex; - flex-direction: column; - min-height: 100%; -} - -.app-content { - background-color: var(--grey-backround); - flex: 1; -} diff --git a/apps/www/components/loginForm/LoginForm.tsx b/apps/www/components/loginForm/LoginForm.tsx deleted file mode 100644 index 654935e9..00000000 --- a/apps/www/components/loginForm/LoginForm.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { useEffect, useCallback, memo } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; - -import { ActionCreators } from '../../redux/actions'; -import { getLoggedInUser, getPreviousPath } from '../../redux/selectors/selectors'; -import { redirect, getHrefQueryFromPreviousPath } from '../../utils/redirect'; -import ActiveLink from '../activeLink/ActiveLink'; -import AppLogo from '../appLogo/AppLogo'; - -import styles from './loginForm.module.scss'; - -const defaultPath = { href: '/', query: {} } as const; - -export const LoginForm = memo(() => { - const auth = useSelector((state) => state.auth); - const user = useSelector((state) => getLoggedInUser(state)); - const previousPath = useSelector((state) => getHrefQueryFromPreviousPath(getPreviousPath(state))); - const isTransitioning = useSelector((state) => state.routeDetails.isTransitioning); - const dispatch = useDispatch(); - - const reportEvent = useCallback((action: string) => { - globalReportEvent(action, 'Logowanie'); - }, []); - - const logInWithGithub = useCallback(() => { - reportEvent('Zaloguj się przez GitHuba'); - dispatch(ActionCreators.logInWithGitHub()); - }, [dispatch, reportEvent]); - - useEffect(() => { - if (user && !isTransitioning) { - if (previousPath) { - redirect(previousPath.href, previousPath.query); - } else { - redirect('/'); - } - } - }, [user, isTransitioning, previousPath]); - - const route = previousPath || defaultPath; - return ( -
-
- - {auth.error &&

{auth.error.message}

} -

Stwórz konto już dzisiaj i korzystaj z dodatkowych funkcji serwisu DevFAQ!

- - -
-
- ); -}); diff --git a/apps/www/components/loginForm/loginForm.module.scss b/apps/www/components/loginForm/loginForm.module.scss deleted file mode 100644 index 4265fd04..00000000 --- a/apps/www/components/loginForm/loginForm.module.scss +++ /dev/null @@ -1,58 +0,0 @@ -@import 'common'; - -.login-overlay { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - margin: 0; - z-index: var(--z-index-modal); - background: var(--purple-branding); -} - -.login-container { - max-width: 520px; - padding: 20vmin 30px 30px 30px; - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - box-sizing: border-box; - margin: 0 auto; - - .app-logo { - width: 100%; - max-width: 220px; - } - - p { - color: #fff; - font-size: 2rem; - text-align: center; - margin: 3rem 0 4rem 0; - } - - footer { - margin-top: 5rem; - a { - color: #fff; - } - } -} - -.login-with-github { - color: #333; - white-space: nowrap; - text-align: center; - vertical-align: middle; - cursor: pointer; - font-size: 1.6rem; - border: 2px solid rgb(217, 217, 217); - border-radius: 10px; - background: url(/images/github.svg) 12px 50% / 25px no-repeat #fff; - padding: 0 0 0 20px; - width: 265px; - box-sizing: border-box; - height: 45px; -} diff --git a/apps/www/components/markdownText/MarkdownText.tsx b/apps/www/components/markdownText/MarkdownText.tsx deleted file mode 100644 index 38c96180..00000000 --- a/apps/www/components/markdownText/MarkdownText.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import commonmark from 'commonmark'; -import React from 'react'; -import xss from 'xss'; - -interface MarkdownTextProps { - className?: string; - value: string; -} - -const reader = new commonmark.Parser(); -const writer = new commonmark.HtmlRenderer(); - -export function getHtmlFromMarkdown(markdown: string): string { - const parsed = reader.parse(markdown); - const rendered = writer.render(parsed); - return xss.filterXSS(rendered, { - onIgnoreTagAttr(tag, name, value, _isWhiteAttr) { - if (tag !== 'code' && tag !== 'pre') { - return; - } - if (name !== 'class') { - return; - } - return `${name}="${xss.escapeAttrValue(value)}"`; - }, - }); -} - -export function highlightSyntax(el: Element): void { - import(/* webpackChunkName: "prismjs" */ 'prismjs') - .then((Prism) => { - Prism.highlightAllUnder(el); - }) - .catch((err) => { - console.error(err); - }); -} - -export default class MarkdownText extends React.Component { - markdownRef = React.createRef(); - - getHtmlFromMarkdown(markdown: string): string { - return getHtmlFromMarkdown(markdown.replace(/\r\n\n/gi, '\n')); - } - - shouldComponentUpdate(nextProps: MarkdownTextProps): boolean { - return this.props.value !== nextProps.value; - } - - componentDidMount() { - this.highlight(); - } - - componentDidUpdate() { - this.highlight(); - } - - highlight() { - if (!this.markdownRef.current) { - return; - } - if (typeof window === 'undefined') { - return; - } - - highlightSyntax(this.markdownRef.current); - } - - render() { - return ( -
- ); - } -} diff --git a/apps/www/components/modals/addQuestionConfirmationModal/AddQuestionConfirmationModal.tsx b/apps/www/components/modals/addQuestionConfirmationModal/AddQuestionConfirmationModal.tsx deleted file mode 100644 index 98fb868d..00000000 --- a/apps/www/components/modals/addQuestionConfirmationModal/AddQuestionConfirmationModal.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import classNames from 'classnames'; -import React, { memo, useCallback, forwardRef } from 'react'; - -import { useDidMount } from '../../../utils/hooks'; -import { BaseModal, CommonModalProps } from '../baseModal/BaseModal'; - -import styles from './addQuestionConfirmationModal.module.scss'; - -const reportEvent = (action: string) => globalReportEvent(action, 'Przesłane nowe pytanie warstwa'); - -export const AddQuestionConfirmationModal = memo( - forwardRef(({ onClose }, ref) => { - useDidMount(() => { - reportEvent('Wyświetlenie'); - }); - - const handleClose: CommonModalProps['onClose'] = useCallback( - (arg) => { - if (arg.reason === 'ok') { - reportEvent('OK'); - } else { - reportEvent('Zamknij'); - } - - onClose(arg); - }, - [onClose] - ); - - const close: React.MouseEventHandler = useCallback( - (e) => { - handleClose({ event: e, reason: 'ok' }); - }, - [handleClose] - ); - - const renderContent = useCallback(() => { - return ( -
-

- Jeszcze momencik… a Twoje pytanie pojawi się na liście dostępnych pytań. Najpierw musimy - rzucić na nie okiem i zatwierdzić. -
W międzyczasie zajrzyj na bloga ❤️ -

- - -
- ); - }, [close]); - - return ( - - ); - }) -); diff --git a/apps/www/components/modals/addQuestionConfirmationModal/addQuestionConfirmationModal.module.scss b/apps/www/components/modals/addQuestionConfirmationModal/addQuestionConfirmationModal.module.scss deleted file mode 100644 index 2a8febee..00000000 --- a/apps/www/components/modals/addQuestionConfirmationModal/addQuestionConfirmationModal.module.scss +++ /dev/null @@ -1,41 +0,0 @@ -@import 'common'; -.add-question-confirmation-modal { - .round-button { - font-size: 1.6rem; - } - p { - margin-top: 5vh; - padding: 0 8px; - } - .logos { - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - margin-top: 25px; - margin-bottom: 25px; - } - .logos a { - border-radius: 9px; - box-shadow: var(--box-shadow) 0 1px 4px 0; - height: 130px; - width: 130px; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.2s; - box-sizing: border-box; - &:hover { - background-color: rgb(250, 250, 250); - } - @include mediaquery('gt-sm') { - a:last-child img { - max-width: 115px; - } - } - img { - max-height: 115px; - max-width: 100%; - } - } -} diff --git a/apps/www/components/modals/addQuestionModal/AddQuestionModal.tsx b/apps/www/components/modals/addQuestionModal/AddQuestionModal.tsx deleted file mode 100644 index e0d98a81..00000000 --- a/apps/www/components/modals/addQuestionModal/AddQuestionModal.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { isEqual } from 'lodash'; -import React, { forwardRef, memo, useCallback, useEffect, useState } from 'react'; - -import type { LevelKey } from '../../../constants/level'; -import type { TechnologyKey } from '../../../constants/technology-icon-items'; -import { useUIContext } from '../../../contexts/UIContextProvider'; -import { Question } from '../../../redux/reducers/questions'; -import { Api } from '../../../services/Api'; -import { useDidMount, useRenderProp } from '../../../utils/hooks'; -import { BaseModal, CommonModalProps } from '../baseModal/BaseModal'; - -import { AddQuestionModalContent } from './AddQuestionModalContent'; -import { AddQuestionModalFooter } from './AddQuestionModalFooter'; -import styles from './addQuestionModal.module.scss'; - -const reportEvent = (action: string) => globalReportEvent(action, 'Nowe pytanie warstwa'); - -interface AddQuestionModalOwnProps { - originalQuestion?: Question; -} - -type AddQuestionModalProps = AddQuestionModalOwnProps & CommonModalProps; - -export const AddQuestionModal = memo( - forwardRef(({ onClose, originalQuestion }, ref) => { - const { openAddQuestionConfirmationModal } = useUIContext(); - const [editedQuestion, setEditedQuestion] = useState(); - const [questionText, setQuestionText] = useState(''); - const [level, setLevel] = useState(); - const [technology, setTechnology] = useState(); - const [isLoading, setIsLoading] = useState(false); - const [valid, setValid] = useState(false); - - const isValid = useCallback(() => Boolean(level && technology && questionText.trim()), [ - level, - questionText, - technology, - ]); - - const handleClose: CommonModalProps['onClose'] = useCallback( - (args) => { - if (args.reason === 'cancel') { - reportEvent('Anuluj'); - } else if (args.reason === 'submit') { - reportEvent('Dodaj pytanie'); - } else { - reportEvent('Zamknij'); - } - return onClose(args); - }, - [onClose] - ); - - const onCancelClick: React.MouseEventHandler = useCallback( - (e) => { - handleClose({ reason: 'cancel', event: e }); - }, - [handleClose] - ); - - const handleChangeTechnology: React.ChangeEventHandler = useCallback((e) => { - const value = e.currentTarget.value as TechnologyKey; - setTechnology(value); - }, []); - - const handleChangeLevel: React.ChangeEventHandler = useCallback((e) => { - const value = e.currentTarget.value as LevelKey; - setLevel(value); - }, []); - - const handleChangeQuestionText = useCallback((text: string) => { - setQuestionText(text); - }, []); - - const handleSubmit: React.MouseEventHandler = useCallback(() => { - if (!isValid()) { - return; - } - - setIsLoading(true); - - // remove `| undefined` because `isValid()` determines we're good to go - const body = { - question: questionText!, - level: level!, - category: technology!, - }; - - if (originalQuestion) { - return Api.updateQuestion(originalQuestion.id, { - ...body, - status: 'accepted', - }) - .then(() => { - onClose({ reason: 'submit' }); - }) - .finally(() => setIsLoading(false)); - } else { - return Api.createQuestion(body) - .then(() => { - onClose({ reason: 'submit' }); - openAddQuestionConfirmationModal(); - }) - .finally(() => setIsLoading(false)); - } - }, [ - isValid, - level, - onClose, - openAddQuestionConfirmationModal, - originalQuestion, - questionText, - technology, - ]); - - const validate = useCallback(() => { - setValid(isValid()); - }, [isValid]); - - const renderContent = useRenderProp(AddQuestionModalContent, { - handleChangeLevel, - handleChangeQuestionText, - handleChangeTechnology, - level, - originalQuestion, - questionText, - technology, - }); - - const renderFooter = useRenderProp(AddQuestionModalFooter, { - handleSubmit, - isLoading, - onCancelClick, - valid, - }); - - useEffect(() => { - validate(); - }); - - useDidMount(() => { - reportEvent('Wyświetlenie'); - setValid(isValid()); - }); - - useEffect(() => { - if (!originalQuestion || isEqual(originalQuestion, editedQuestion)) { - return; - } - setEditedQuestion(originalQuestion); - setLevel(originalQuestion._levelId); - setTechnology(originalQuestion._categoryId); - setQuestionText(originalQuestion.question); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [originalQuestion]); - - return ( - - ); - }) -); diff --git a/apps/www/components/modals/addQuestionModal/AddQuestionModalContent.tsx b/apps/www/components/modals/addQuestionModal/AddQuestionModalContent.tsx deleted file mode 100644 index 382201e9..00000000 --- a/apps/www/components/modals/addQuestionModal/AddQuestionModalContent.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import classNames from 'classnames'; -import React, { memo } from 'react'; - -import { LevelKey, levelsWithLabels } from '../../../constants/level'; -import { TechnologyKey, technologyIconItems } from '../../../constants/technology-icon-items'; -import { Question } from '../../../redux/reducers/questions'; -import { QuestionEditor } from '../../questionEditor/QuestionEditor'; -import modalStyles from '../baseModal/baseModal.module.scss'; - -import styles from './addQuestionModal.module.scss'; - -type AddQuestionModalContentProps = { - originalQuestion?: Question; - handleChangeTechnology: React.ChangeEventHandler; - handleChangeLevel: React.ChangeEventHandler; - handleChangeQuestionText: (text: string) => void; - technology?: TechnologyKey; - level?: LevelKey; - questionText: string; -}; - -export const AddQuestionModalContent = memo( - ({ - originalQuestion, - level, - technology, - questionText, - handleChangeTechnology, - handleChangeLevel, - handleChangeQuestionText, - }) => { - return ( -
-

- {originalQuestion ? 'Edytuj pytanie' : 'Nowe pytanie'} -

-
e.preventDefault()}> -
-
- - -
- - -
-
-
- ); - } -); diff --git a/apps/www/components/modals/addQuestionModal/AddQuestionModalFooter.tsx b/apps/www/components/modals/addQuestionModal/AddQuestionModalFooter.tsx deleted file mode 100644 index 133b4bda..00000000 --- a/apps/www/components/modals/addQuestionModal/AddQuestionModalFooter.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import classNames from 'classnames'; -import React, { memo } from 'react'; - -import { Question } from '../../../redux/reducers/questions'; - -import styles from './addQuestionModal.module.scss'; - -type AddQuestionModalFooterProps = { - isLoading: boolean; - valid: boolean; - originalQuestion?: Question; - handleSubmit: React.MouseEventHandler; - onCancelClick: React.MouseEventHandler; -}; - -export const AddQuestionModalFooter = memo( - ({ isLoading, valid, originalQuestion, handleSubmit, onCancelClick }) => { - return ( -
- - -
- ); - } -); diff --git a/apps/www/components/modals/addQuestionModal/addQuestionModal.module.scss b/apps/www/components/modals/addQuestionModal/addQuestionModal.module.scss deleted file mode 100644 index 0b50a6a7..00000000 --- a/apps/www/components/modals/addQuestionModal/addQuestionModal.module.scss +++ /dev/null @@ -1,74 +0,0 @@ -@import 'common'; - -.app-select { - width: 240px; - max-width: 100%; - margin: 0; - color: var(--purple-branding-text); - font-size: 1.3rem; - box-sizing: border-box; - appearance: none; - border: none; - cursor: pointer; - padding: 9px 25px 9px 4px; - background-image: url(/images/select-purple.svg); - background-size: 25px; - background-repeat: no-repeat; - background-position: 100% 50%; - background-color: transparent; - border-bottom: 1px solid var(--purple-branding-text); - border-radius: 0; - text-transform: capitalize; - transition: background-color 0.1s, color 0.1s, box-shadow 0.1s; - &:focus { - outline: 0; - box-shadow: var(--purple-branding-text) 0 0 10px; - } - &:hover { - background-color: rgba(var(--purple-branding), 0.05); - } - option { - color: initial; - } -} - -.add-question-modal { - .app-question-form { - display: flex; - flex-direction: column; - padding-bottom: 15px; - text-align: left; - @include mediaquery('gt-sm') { - padding-bottom: 30px; - } - & > * { - width: 100%; - } - &--options-container { - display: flex; - margin: 0 0 30px 0; - flex-direction: column; - align-items: stretch; - @include mediaquery('gt-sm') { - margin: 40px 0 30px 0; - flex-direction: row; - justify-content: center; - } - } - &--level { - margin-top: 15px; - } - @include mediaquery('gt-sm') { - &--technology { - margin-right: 2.5%; - } - &--level { - margin-top: 0; - margin-left: 2.5%; - } - } - } - .branding-button-inverse { - border-color: transparent; - } -} diff --git a/apps/www/components/modals/appModals/AppModals.tsx b/apps/www/components/modals/appModals/AppModals.tsx deleted file mode 100644 index ec9bd6fa..00000000 --- a/apps/www/components/modals/appModals/AppModals.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { memo, useCallback, useRef } from 'react'; -import { CSSTransition } from 'react-transition-group'; - -import { useUIContext } from '../../../contexts/UIContextProvider'; -import { AddQuestionConfirmationModal } from '../addQuestionConfirmationModal/AddQuestionConfirmationModal'; -import { AddQuestionModal } from '../addQuestionModal/AddQuestionModal'; -import { CommonModalProps } from '../baseModal/BaseModal'; -import styles from '../baseModal/baseModal.module.scss'; - -const timeout = 200; - -export const AppModals = memo(() => { - const addQuestionModalRef = useRef(null); - const addQuestionConfirmationModalRef = useRef(null); - - const { - addQuestionModalState, - closeAddQuestionModal, - isAddQuestionConfirmationModalOpen, - closeEditQuestionModal, - closeAddQuestionConfirmationModal, - } = useUIContext(); - - const closeQuestionModal: CommonModalProps['onClose'] = useCallback( - (args) => { - if (addQuestionModalState.onClose) { - addQuestionModalState.onClose(args); - } - if (addQuestionModalState.data) { - closeEditQuestionModal(); - } else { - closeAddQuestionModal(); - } - }, - [addQuestionModalState, closeAddQuestionModal, closeEditQuestionModal] - ); - - const closeConfirmationModal: CommonModalProps['onClose'] = useCallback( - (_args) => { - closeAddQuestionConfirmationModal(); - }, - [closeAddQuestionConfirmationModal] - ); - - return ( - <> - - - - - - - - - ); -}); diff --git a/apps/www/components/modals/baseModal/BaseModal.tsx b/apps/www/components/modals/baseModal/BaseModal.tsx deleted file mode 100644 index d12209d7..00000000 --- a/apps/www/components/modals/baseModal/BaseModal.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import classNames from 'classnames'; -import React, { memo, useRef, useEffect, useCallback, forwardRef } from 'react'; - -import styles from './baseModal.module.scss'; -import { FixBodyService } from './fixBodyService'; - -export type ModalType = 'warning' | 'confirmation' | 'thumbs-up' | 'add'; - -export interface CommonModalProps { - onClose(arg: { reason?: string; event?: React.SyntheticEvent }): any; -} - -type BaseModalOwnProps = CommonModalProps & { - closable?: boolean; - type?: ModalType; - className: string; - 'aria-labelledby'?: string; - 'aria-describedby'?: string; - renderContent?(): React.ReactNode; - renderFooter?(): React.ReactNode; -}; - -const fixBodyService = new FixBodyService(); - -const findFirstFocusableChild = (el: HTMLElement) => { - return el.querySelector('input, select, textarea, button, [tabindex]'); -}; - -const elementIsFocusable = (el: Node | null): el is HTMLElement => { - return el ? 'focus' in el : false; -}; - -export const BaseModal = memo( - forwardRef( - ( - { - closable = false, - type = undefined, - renderContent = () => null, - renderFooter = () => null, - onClose, - className, - 'aria-labelledby': ariaLabelledby, - 'aria-describedby': ariaLescribedby, - }, - ref - ) => { - const contentRef = useRef(null); - const previousFocusedElement = useRef(null); - - const close: React.MouseEventHandler = useCallback( - (e) => onClose({ event: e }), - [onClose] - ); - - useEffect(() => { - fixBodyService.fixBody(); - previousFocusedElement.current = document.activeElement; - - const firstFocusable = contentRef.current && findFirstFocusableChild(contentRef.current); - if (elementIsFocusable(firstFocusable)) { - firstFocusable.focus(); - } - - return () => { - fixBodyService.unfixBody(); - if (elementIsFocusable(previousFocusedElement.current)) { - previousFocusedElement.current.focus(); - } - }; - }, []); - - return ( -
-
- {closable && ( -
- -
- )} -
-
-
- {renderContent()} -
-
{renderFooter()}
-
-
-
- ); - } - ) -); diff --git a/apps/www/components/modals/baseModal/baseModal.module.scss b/apps/www/components/modals/baseModal/baseModal.module.scss deleted file mode 100644 index 5fbf7986..00000000 --- a/apps/www/components/modals/baseModal/baseModal.module.scss +++ /dev/null @@ -1,166 +0,0 @@ -@import 'common'; - -.fade-enter, -.fade-exit { - will-change: opacity, transition; -} - -.fade-enter-active, -.fade-exit-active { - transition: opacity 200ms; -} - -.fade-enter { - opacity: 0; -} - -.fade-enter-active { - opacity: 1; -} - -.fade-exit { - opacity: 1; -} - -.fade-exit-active { - opacity: 0; -} - -.fade-enter, -.fade-exit, -.fade-enter-done { - display: block !important; -} - -.app-modal-container { - @include hardwareAcceleration(); - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - background-color: rgba(var(--white-text), 0.9); - z-index: var(--z-index-modal-container); - display: none; -} - -.app-modal { - @include hardwareAcceleration(); - position: absolute; - left: 0; - top: 0; - max-width: 100%; - width: 100%; - -webkit-overflow-scrolling: touch; - height: 100%; - @include mediaquery('gt-sm') { - top: 10%; - left: 50%; - transform: translate3d(-50%, 0, 0); - width: 768px; - height: 80%; - } - .action-icon { - margin-bottom: 0px; - @include mediaquery('gt-sm') { - margin-bottom: 15px; - } - } - &--header { - position: absolute; - top: 0; - right: 0; - z-index: calc(var(--z-index-modal) + 1); - } - &--close { - position: absolute; - right: 25px; - top: 15px; - appearance: none; - border: 0; - background: transparent; - color: var(--purple-branding-light); - cursor: pointer; - transition: color 0.2s, background-color 0.2s; - border-radius: 50%; - width: 31px; - height: 31px; - line-height: 29px; - font-size: 4rem; - text-align: center; - padding: 0 0 1px 0; - transition: background-color 0.1s, color 0.1s, box-shadow 0.1s; - &:hover { - background-color: var(--purple-branding); - color: var(--white-text); - } - &:focus { - outline: 0; - box-shadow: var(--purple-branding) 0 0 10px; - } - } - &--content { - -webkit-overflow-scrolling: touch; - max-height: 100%; - max-width: 100%; - width: 100%; - background-color: var(--white); - z-index: var(--z-index-modal); - box-shadow: var(--box-shadow) 0 2px 6px 1px; - box-sizing: border-box; - overflow-y: auto; - position: absolute; - height: 100%; - padding: 35px 15px; - @include mediaquery('gt-sm') { - height: auto; - padding: 45px 90px; - } - } - &--body { - font-size: 1.6rem; - color: var(--purple-branding-text); - line-height: 2; - text-align: center; - } - &--footer { - margin-bottom: 40px; - .branding-button-inverse { - box-shadow: var(--grey-text-secondary) 0 2px 4px; - } - > * { - display: flex; - flex-direction: column; - align-items: center; - > button { - margin-bottom: 10px; - width: 85%; - } - @include mediaquery('gt-sm') { - flex-direction: row-reverse; - justify-content: flex-start; - align-items: center; - > button { - margin-bottom: 0; - width: auto; - &:first-child { - margin-left: 15px; - } - } - } - } - } - &--title { - text-transform: uppercase; - font-size: 2rem; - font-weight: 700; - color: var(--purple-branding-text); - margin-top: 0; - } - .app-select { - width: 100%; - @include mediaquery('gt-sm') { - width: 240px; - } - } -} diff --git a/apps/www/components/modals/baseModal/fixBodyService.ts b/apps/www/components/modals/baseModal/fixBodyService.ts deleted file mode 100644 index 6f90872d..00000000 --- a/apps/www/components/modals/baseModal/fixBodyService.ts +++ /dev/null @@ -1,61 +0,0 @@ -export class FixBodyService { - private windowOffsetY = 0; - private scrollbarWidth = 0; - - constructor() { - if (typeof window === 'undefined') { - return; - } - - this.scrollbarWidth = this.getScrollbarWidth(); - } - - fixBody() { - if (typeof window === 'undefined') { - return; - } - - this.windowOffsetY = window.pageYOffset; - document.body.classList.add('not-scrollable'); - document.body.style.top = `-${this.windowOffsetY}px`; - document.body.style.paddingRight = `${this.scrollbarWidth}px`; - document.body.style.marginLeft = `-${this.scrollbarWidth / 2}px`; - } - - unfixBody() { - if (typeof window === 'undefined') { - return; - } - - document.body.classList.remove('not-scrollable'); - document.body.style.top = ''; - document.body.style.paddingRight = ''; - document.body.style.marginLeft = ''; - requestAnimationFrame(() => window.scrollTo(0, this.windowOffsetY)); - } - - private getScrollbarWidth() { - const outer = document.createElement('div'); - outer.style.visibility = 'hidden'; - outer.style.width = '100px'; - // outer.style.msOverflowStyle = 'scrollbar'; // needed for WinJS apps - - document.body.appendChild(outer); - - const widthNoScroll = outer.offsetWidth; - // force scrollbars - outer.style.overflow = 'scroll'; - - // add innerdiv - const inner = document.createElement('div'); - inner.style.width = '100%'; - outer.appendChild(inner); - - const widthWithScroll = inner.offsetWidth; - - // remove divs - outer.parentNode!.removeChild(outer); - - return widthNoScroll - widthWithScroll; - } -} diff --git a/apps/www/components/questionEditor/QuestionEditor.tsx b/apps/www/components/questionEditor/QuestionEditor.tsx deleted file mode 100644 index 3a4d284f..00000000 --- a/apps/www/components/questionEditor/QuestionEditor.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import classNames from 'classnames'; -import React, { useRef, useState, memo, useCallback, useLayoutEffect } from 'react'; - -import { getHtmlFromMarkdown, highlightSyntax } from '../markdownText/MarkdownText'; - -import styles from './questionEditor.module.scss'; - -interface QuestionEditorProps { - id?: string; - label?: string; - value: string; - onChange(text: string): any; -} - -const actions = { - BOLD: { open: '**', close: '**' }, - ITALIC: { open: '_', close: '_' }, - HEADING: { open: '# ', close: '' }, - CODEBLOCK: { open: '```javascript\n', close: '\n```' }, - UL: { open: '* ', close: '' }, - OL: { open: '1. ', close: '' }, -} as const; -type Actions = keyof typeof actions; - -const autoresize = (el: HTMLTextAreaElement) => { - el.style.height = 'auto'; - el.style.height = el.scrollHeight + 'px'; - el.scrollTop = el.scrollHeight; - window.scrollTo(window.scrollX, el.scrollTop + el.scrollHeight); -}; - -const autofocus = ( - el: HTMLTextAreaElement, - selectionStart = el.selectionStart, - selectionEnd = el.selectionEnd -) => { - el.focus(); - el.setSelectionRange(selectionStart, selectionEnd); -}; - -const addTokensToTextarea = ( - el: HTMLTextAreaElement, - tokens: typeof actions[keyof typeof actions], - onChange: QuestionEditorProps['onChange'] -): void => { - const { selectionStart, selectionEnd, value } = el; - const { open, close } = tokens; - - let newValue = value; - newValue = newValue.substring(0, selectionEnd) + close + newValue.substr(selectionEnd); - newValue = newValue.substring(0, selectionStart) + open + newValue.substr(selectionStart); - - onChange(newValue); - el.value = newValue; - autofocus(el, selectionStart + open.length, selectionEnd + open.length); -}; - -export const QuestionEditor = memo(({ onChange, value }) => { - const [isPreview, setIsPreview] = useState(false); - const textAreaRef = useRef(null); - const previewRef = useRef(null); - const cursorRef = useRef({ selectionStart: 0, selectionEnd: 0 }); - - const handleTextChange = useCallback(() => { - if (!textAreaRef.current) { - return; - } - onChange(textAreaRef.current.value || ''); - }, [onChange]); - - const handleAction = useCallback( - (action: Actions): React.MouseEventHandler => (e) => { - e.preventDefault(); - - const el = textAreaRef.current; - if (!el) { - return; - } - - const tokens = actions[action]; - addTokensToTextarea(el, tokens, onChange); - handleTextChange(); - }, - [handleTextChange, onChange] - ); - - const togglePreview: React.MouseEventHandler = useCallback((e) => { - e.preventDefault(); - if (textAreaRef.current) { - cursorRef.current = { - selectionStart: textAreaRef.current.selectionStart, - selectionEnd: textAreaRef.current.selectionEnd, - }; - } - setIsPreview((isPreview) => !isPreview); - }, []); - - const handleEditorClick = useCallback(() => { - if (!textAreaRef.current) { - return; - } - // clicking anywhere in the editor should focus the textarea - autofocus(textAreaRef.current); - }, []); - - useLayoutEffect(() => { - if (isPreview && previewRef.current) { - highlightSyntax(previewRef.current); - } - if (!isPreview && textAreaRef.current) { - // restore previous cursor position - autofocus( - textAreaRef.current, - cursorRef.current.selectionStart, - cursorRef.current.selectionEnd - ); - } - }, [isPreview]); - - useLayoutEffect(() => { - if (!isPreview && textAreaRef.current) { - autoresize(textAreaRef.current); - } - }, [value, isPreview]); - - return ( -
-
-
-
- {!isPreview && ( - +
+ + +
+ + + ); +}; diff --git a/apps/app/src/components/AppModals.tsx b/apps/app/src/components/AppModals.tsx new file mode 100644 index 00000000..fbf7804a --- /dev/null +++ b/apps/app/src/components/AppModals.tsx @@ -0,0 +1,23 @@ +"use client"; + +import type { ComponentProps, ComponentType } from "react"; +import { useModalContext } from "../providers/ModalProvider"; +import type { Modal } from "../providers/ModalProvider"; +import { AddQuestionModal } from "./AddQuestionModal"; +import { BaseModal } from "./BaseModal"; + +const modals: Record>> = { + AddQuestionModal, +}; + +export const AppModals = () => { + const { openedModal, closeModal } = useModalContext(); + + return ( + <> + {Object.entries(modals).map(([type, Modal]) => ( + + ))} + + ); +}; diff --git a/apps/app/src/components/BaseModal.tsx b/apps/app/src/components/BaseModal.tsx new file mode 100644 index 00000000..463473ad --- /dev/null +++ b/apps/app/src/components/BaseModal.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { ReactNode, useEffect } from "react"; +import { Transition } from "@headlessui/react"; +import { lockScroll, unlockScroll } from "../utils/pageScroll"; +import { CloseButton } from "./CloseButton/CloseButton"; + +type BaseModalProps = Readonly<{ + isOpen: boolean; + onClose: () => void; + children?: ReactNode; +}>; + +export const BaseModal = ({ isOpen, onClose, children }: BaseModalProps) => { + useEffect(() => { + if (isOpen) { + lockScroll(); + } + }, [isOpen]); + + return ( + +
{ + // stop propagation to avoid triggering `onClick` on the backdrop behind the modal + event.stopPropagation(); + }} + > + + {children} +
+
+ ); +}; diff --git a/apps/app/src/components/Button/Button.tsx b/apps/app/src/components/Button/Button.tsx index 9aa683fb..ce604f81 100644 --- a/apps/app/src/components/Button/Button.tsx +++ b/apps/app/src/components/Button/Button.tsx @@ -19,7 +19,7 @@ type ButtonProps = Readonly<{ export const Button = ({ variant, className, ...props }: ButtonProps) => ( +); diff --git a/apps/app/src/components/CtaHeader/AddQuestionButton.tsx b/apps/app/src/components/CtaHeader/AddQuestionButton.tsx new file mode 100644 index 00000000..bee9b494 --- /dev/null +++ b/apps/app/src/components/CtaHeader/AddQuestionButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useModalContext } from "../../providers/ModalProvider"; +import { Button } from "../Button/Button"; + +export const AddQuestionButton = () => { + const { openModal } = useModalContext(); + + return ( + <> + + + ); +}; diff --git a/apps/app/src/components/CtaHeader.tsx b/apps/app/src/components/CtaHeader/CtaHeader.tsx similarity index 76% rename from apps/app/src/components/CtaHeader.tsx rename to apps/app/src/components/CtaHeader/CtaHeader.tsx index e869df9c..b7a31eae 100644 --- a/apps/app/src/components/CtaHeader.tsx +++ b/apps/app/src/components/CtaHeader/CtaHeader.tsx @@ -1,7 +1,7 @@ import { ReactNode } from "react"; -import { ActiveLink } from "./ActiveLink"; -import { Button } from "./Button/Button"; -import { Container } from "./Container"; +import { ActiveLink } from "../ActiveLink"; +import { Container } from "../Container"; +import { AddQuestionButton } from "./AddQuestionButton"; type CtaHeaderActiveLinkProps = Readonly<{ href: string; @@ -23,9 +23,7 @@ export const CtaHeader = () => ( Lista pytań Wybrane pytania - +
); diff --git a/apps/app/src/components/Select/Select.stories.tsx b/apps/app/src/components/Select/Select.stories.tsx new file mode 100644 index 00000000..95be4282 --- /dev/null +++ b/apps/app/src/components/Select/Select.stories.tsx @@ -0,0 +1,22 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { Select } from "./Select"; + +const meta: Meta = { + title: "Select", + component: Select, + args: { + children: ( + <> + + + + + ), + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/app/src/components/Select/Select.tsx b/apps/app/src/components/Select/Select.tsx new file mode 100644 index 00000000..44ab9d05 --- /dev/null +++ b/apps/app/src/components/Select/Select.tsx @@ -0,0 +1,12 @@ +import { twMerge } from "tailwind-merge"; +import type { SelectHTMLAttributes } from "react"; + +export const Select = ({ className, ...props }: SelectHTMLAttributes) => ( + { } }, [theme]); - return ( - - {children} - + const value = useMemo( + () => ({ + theme, + changeTheme, + }), + [theme], ); + + return {children}; }; export { useThemeContext, ThemeProvider }; From e5d4f9a3b7d5dba88c38501c8eb3bdec10933906 Mon Sep 17 00:00:00 2001 From: Adrian Polak Date: Fri, 9 Dec 2022 10:17:30 +0100 Subject: [PATCH 085/175] feat(app): add modal dark mode (#395) --- apps/app/src/components/AddQuestionModal.tsx | 11 ++++++++--- apps/app/src/components/BaseModal.tsx | 2 +- apps/app/src/components/CloseButton/CloseButton.tsx | 2 +- apps/app/src/components/Select/Select.tsx | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/AddQuestionModal.tsx b/apps/app/src/components/AddQuestionModal.tsx index 63b6d99d..d1787077 100644 --- a/apps/app/src/components/AddQuestionModal.tsx +++ b/apps/app/src/components/AddQuestionModal.tsx @@ -16,7 +16,9 @@ export const AddQuestionModal = (props: ComponentProps) => { return ( -

Nowe pytanie

+

+ Nowe pytanie +

- +
diff --git a/apps/app/src/components/QuestionsSidebar/LevelFilter/LevelFilter.tsx b/apps/app/src/components/QuestionsSidebar/LevelFilter/LevelFilter.tsx index 39197bbb..7892e76e 100644 --- a/apps/app/src/components/QuestionsSidebar/LevelFilter/LevelFilter.tsx +++ b/apps/app/src/components/QuestionsSidebar/LevelFilter/LevelFilter.tsx @@ -1,19 +1,31 @@ +"use client"; + +import { useQuestionsLevels } from "../../../hooks/useQuestionsLevels"; import { QuestionsSidebarSection } from "../QuestionsSidebarSection"; +import { levels } from "../../../lib/level"; import { LevelButton } from "./LevelButton"; export const LevelFilter = () => { + const { queryLevels, addLevel, removeLevel } = useQuestionsLevels(); + return (
- - Junior - - - Mid - - - Senior - + {levels.map((level) => { + const isActive = Boolean(queryLevels?.includes(level)); + const handleClick = isActive ? removeLevel : addLevel; + + return ( + handleClick(level)} + > + {level} + + ); + })}
); diff --git a/apps/app/src/hooks/useQuestionsLevels.ts b/apps/app/src/hooks/useQuestionsLevels.ts new file mode 100644 index 00000000..3fa1fdd3 --- /dev/null +++ b/apps/app/src/hooks/useQuestionsLevels.ts @@ -0,0 +1,25 @@ +import { useSearchParams } from "next/navigation"; +import { parseQueryLevels, Level } from "../lib/level"; +import { useDevFAQRouter } from "./useDevFAQRouter"; + +export const useQuestionsLevels = () => { + const searchParams = useSearchParams(); + const { mergeQueryParams } = useDevFAQRouter(); + + const queryLevel = searchParams.get("level"); + const queryLevels = parseQueryLevels(queryLevel); + + const addLevel = (level: Level) => { + mergeQueryParams({ level: [...(queryLevels || []), level].join(",") }); + }; + + const removeLevel = (level: Level) => { + if (queryLevels) { + const newQueryLevels = queryLevels.filter((l) => l !== level); + + mergeQueryParams({ level: newQueryLevels.join(",") }); + } + }; + + return { queryLevels, addLevel, removeLevel }; +}; diff --git a/apps/app/src/hooks/useQuestionsOrderBy.ts b/apps/app/src/hooks/useQuestionsOrderBy.ts index c708f3b8..f2f5d4cd 100644 --- a/apps/app/src/hooks/useQuestionsOrderBy.ts +++ b/apps/app/src/hooks/useQuestionsOrderBy.ts @@ -1,5 +1,5 @@ import { useSearchParams } from "next/navigation"; -import { validateSortByQuery, DEFAULT_SORT_BY_QUERY } from "../lib/order"; +import { parseQuerySortBy, DEFAULT_SORT_BY_QUERY } from "../lib/order"; import { useDevFAQRouter } from "./useDevFAQRouter"; export const useQuestionsOrderBy = () => { @@ -9,7 +9,7 @@ export const useQuestionsOrderBy = () => { const sortBy = searchParams.get("sortBy") || DEFAULT_SORT_BY_QUERY; const setSortByFromString = (sortBy: string) => { - if (validateSortByQuery(sortBy)) { + if (parseQuerySortBy(sortBy)) { mergeQueryParams({ sortBy }); } }; diff --git a/apps/app/src/lib/level.ts b/apps/app/src/lib/level.ts new file mode 100644 index 00000000..2b197692 --- /dev/null +++ b/apps/app/src/lib/level.ts @@ -0,0 +1,19 @@ +import { QueryParam } from "../types"; + +export const levels = ["junior", "mid", "senior"] as const; + +export type Level = typeof levels[number]; + +export const parseQueryLevels = (query?: QueryParam | null) => { + if (typeof query !== "string") { + return null; + } + + const splittedQuery = query.split(","); + + if (!splittedQuery.every((level) => levels.includes(level))) { + return null; + } + + return splittedQuery as Level[]; +}; diff --git a/apps/app/src/lib/order.ts b/apps/app/src/lib/order.ts index 8c9d68d7..b622ad27 100644 --- a/apps/app/src/lib/order.ts +++ b/apps/app/src/lib/order.ts @@ -1,31 +1,31 @@ -const orderBy = ["acceptedAt", "level", "votesCount"] as const; -const order = ["asc", "desc"] as const; +import { QueryParam } from "../types"; -type OrderBy = typeof orderBy[number]; -type Order = typeof order[number]; +const ordersBy = ["acceptedAt", "level", "votesCount"] as const; +const orders = ["asc", "desc"] as const; export const DEFAULT_SORT_BY_QUERY = "acceptedAt*desc"; - -export const validateOrderBy = (data: string): data is OrderBy => { - return orderBy.includes(data); +export const sortByLabels: Record<`${OrderBy}*${Order}`, string> = { + "acceptedAt*asc": "od najstarszych", + "acceptedAt*desc": "od najnowszych", + "level*asc": "od najprostszych", + "level*desc": "od najtrudniejszych", + "votesCount*asc": "od najmniej popularnych", + "votesCount*desc": "od najpopularniejszych", }; -export const validateOrder = (data: string): data is Order => { - return order.includes(data); -}; +type OrderBy = typeof ordersBy[number]; +type Order = typeof orders[number]; -export const validateSortByQuery = (query?: string): query is `${OrderBy}*${Order}` => { - const [orderBy, order] = query?.split("*") || []; +export const parseQuerySortBy = (query: QueryParam) => { + if (typeof query !== "string") { + return null; + } - return Boolean(orderBy && order && validateOrderBy(orderBy) && validateOrder(order)); -}; + const [orderBy, order] = query.split("*"); -export const getQuerySortBy = (query?: string) => { - if (!validateSortByQuery(query)) { + if (!orderBy || !order || !ordersBy.includes(orderBy) || !orders.includes(order)) { return null; } - const [orderBy, order] = query.split("*") as [OrderBy, Order]; - return { orderBy, order }; }; diff --git a/apps/app/src/types.ts b/apps/app/src/types.ts index d1c9bda1..d64e34f0 100644 --- a/apps/app/src/types.ts +++ b/apps/app/src/types.ts @@ -2,3 +2,13 @@ import { paths } from "openapi-types"; export type UserData = paths["/auth/me"]["get"]["responses"][200]["content"]["application/json"]["data"]; + +export type Params = { + readonly [K in T]: string; +}; + +export type QueryParam = string | readonly string[] | undefined; + +export type SearchParams = { + readonly [K in T]?: QueryParam; +}; From 9602215ae60f87d8e7acb4e9365cf39856c3be52 Mon Sep 17 00:00:00 2001 From: Adrian Polak Date: Thu, 15 Dec 2022 00:43:56 +0100 Subject: [PATCH 101/175] Fix Internal Server Error when ordering by level (#417) * fix: orderBy level Internal Server Error * Fix the bug, improve typesafety * Add comment Co-authored-by: Michal Miszczyszyn --- apps/api/modules/questions/questions.params.ts | 7 ++++--- apps/api/utils.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 apps/api/utils.ts diff --git a/apps/api/modules/questions/questions.params.ts b/apps/api/modules/questions/questions.params.ts index e5e338a7..9a04ff8b 100644 --- a/apps/api/modules/questions/questions.params.ts +++ b/apps/api/modules/questions/questions.params.ts @@ -1,4 +1,5 @@ import { Prisma } from "@prisma/client"; +import { kv } from "../../utils"; import { GetQuestionsQuery } from "./questions.schemas"; export const getQuestionsPrismaParams = ( @@ -24,9 +25,9 @@ export const getQuestionsPrismaParams = ( _count: order, }, } - : { - [orderBy]: order, - }), + : orderBy === "level" + ? { levelId: order } + : kv(orderBy, order)), }, }), } satisfies Prisma.QuestionFindManyArgs; diff --git a/apps/api/utils.ts b/apps/api/utils.ts new file mode 100644 index 00000000..502cff92 --- /dev/null +++ b/apps/api/utils.ts @@ -0,0 +1,10 @@ +/** + * Add typesafety to computed properties that are unions + * @param k union of keys `"a" | "b"` + * @param v value + * @returns a union of `{ [k]: v }` distributed over `k` but typed correctly + * @see https://tsplay.dev/m0bxDw + */ +export function kv(k: K, v: V): { [P in K]: { [Q in P]: V } }[K] { + return { [k]: v } as never; +} From 5a20d5346730b39786782462a32689130420238c Mon Sep 17 00:00:00 2001 From: Kacper Polak <41890821+xStrixU@users.noreply.github.com> Date: Thu, 15 Dec 2022 12:35:39 +0100 Subject: [PATCH 102/175] feat(app): add questions voting (#412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): add questions voting * feat(app): add questions voting filtration * refactor(app): refactor question voting * refactor(app): move QuestionVoting logic to hook Co-authored-by: Michał Miszczyszyn --- .../api/modules/questions/questions.params.ts | 2 +- .../questions/[technology]/[page]/page.tsx | 14 ++++---- .../QuestionItem/QuestionItem.stories.ts | 2 -- .../components/QuestionItem/QuestionItem.tsx | 15 ++++++--- .../QuestionItem/QuestionVoting.tsx | 32 +++++++++++++++++-- apps/app/src/hooks/useDevFAQRouter.ts | 12 ++++++- apps/app/src/hooks/useGetQuestionVotes.ts | 24 ++++++++++++++ apps/app/src/hooks/useQuestionVoting.ts | 9 ++++++ apps/app/src/services/questions.service.ts | 6 ++++ apps/app/src/types.ts | 6 ++++ 10 files changed, 105 insertions(+), 17 deletions(-) create mode 100644 apps/app/src/hooks/useGetQuestionVotes.ts create mode 100644 apps/app/src/hooks/useQuestionVoting.ts diff --git a/apps/api/modules/questions/questions.params.ts b/apps/api/modules/questions/questions.params.ts index 9a04ff8b..37a35336 100644 --- a/apps/api/modules/questions/questions.params.ts +++ b/apps/api/modules/questions/questions.params.ts @@ -1,5 +1,5 @@ import { Prisma } from "@prisma/client"; -import { kv } from "../../utils"; +import { kv } from "../../utils.js"; import { GetQuestionsQuery } from "./questions.schemas"; export const getQuestionsPrismaParams = ( diff --git a/apps/app/src/app/(main-layout)/questions/[technology]/[page]/page.tsx b/apps/app/src/app/(main-layout)/questions/[technology]/[page]/page.tsx index 0ae9224f..60c92306 100644 --- a/apps/app/src/app/(main-layout)/questions/[technology]/[page]/page.tsx +++ b/apps/app/src/app/(main-layout)/questions/[technology]/[page]/page.tsx @@ -7,7 +7,7 @@ import { DEFAULT_SORT_BY_QUERY, parseQuerySortBy } from "../../../../../lib/orde import { parseQueryLevels } from "../../../../../lib/level"; import { technologies } from "../../../../../lib/technologies"; import { getAllQuestions } from "../../../../../services/questions.service"; -import { Params, SearchParams } from "../../../../../types"; +import { Params, QuestionFilter, SearchParams } from "../../../../../types"; export default async function QuestionsPage({ params, @@ -24,26 +24,28 @@ export default async function QuestionsPage({ return redirect("/"); } - const { data } = await getAllQuestions({ + const questionFilter: QuestionFilter = { category: params.technology, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, orderBy: sortBy?.orderBy, order: sortBy?.order, level: levels?.join(","), - }); + }; + + const { data } = await getAllQuestions(questionFilter); return (
- {data.data.map(({ id, question, _levelId, acceptedAt, votesCount }) => ( + {data.data.map(({ id, question, _levelId, acceptedAt }) => ( ))} diff --git a/apps/app/src/components/QuestionItem/QuestionItem.stories.ts b/apps/app/src/components/QuestionItem/QuestionItem.stories.ts index 1371b804..1c302e4b 100644 --- a/apps/app/src/components/QuestionItem/QuestionItem.stories.ts +++ b/apps/app/src/components/QuestionItem/QuestionItem.stories.ts @@ -7,8 +7,6 @@ const meta: Meta = { args: { title: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", creationDate: new Date(), - votes: 1, - voted: true, }, }; diff --git a/apps/app/src/components/QuestionItem/QuestionItem.tsx b/apps/app/src/components/QuestionItem/QuestionItem.tsx index 9cff2e08..30099470 100644 --- a/apps/app/src/components/QuestionItem/QuestionItem.tsx +++ b/apps/app/src/components/QuestionItem/QuestionItem.tsx @@ -1,20 +1,27 @@ import Link from "next/link"; import { format } from "../../utils/intl"; +import { QuestionFilter } from "../../types"; import { QuestionLevel } from "./QuestionLevel"; import { QuestionVoting } from "./QuestionVoting"; import type { Level } from "./QuestionLevel"; type QuestionItemProps = Readonly<{ + id: number; title: string; - votes: number; - voted: boolean; level: Level; creationDate: Date; + questionFilter: QuestionFilter; }>; -export const QuestionItem = ({ title, votes, voted, level, creationDate }: QuestionItemProps) => ( +export const QuestionItem = ({ + id, + title, + level, + creationDate, + questionFilter, +}: QuestionItemProps) => (
- +

{title}

diff --git a/apps/app/src/components/QuestionItem/QuestionVoting.tsx b/apps/app/src/components/QuestionItem/QuestionVoting.tsx index 10691db3..19775649 100644 --- a/apps/app/src/components/QuestionItem/QuestionVoting.tsx +++ b/apps/app/src/components/QuestionItem/QuestionVoting.tsx @@ -1,14 +1,39 @@ +"use client"; + import { twMerge } from "tailwind-merge"; +import { useDevFAQRouter } from "../../hooks/useDevFAQRouter"; import { pluralize } from "../../utils/intl"; +import { QuestionFilter } from "../../types"; +import { useQuestionVoting } from "../../hooks/useQuestionVoting"; +import { useGetQuestionVotes } from "../../hooks/useGetQuestionVotes"; type QuestionVotingProps = Readonly<{ - votes: number; - voted: boolean; + questionId: number; + questionFilter: QuestionFilter; }>; const votesPluralize = pluralize("głos", "głosy", "głosów"); -export const QuestionVoting = ({ votes, voted }: QuestionVotingProps) => { +export const QuestionVoting = ({ questionId, questionFilter }: QuestionVotingProps) => { + const { votes, voted, refetch } = useGetQuestionVotes({ questionId, questionFilter }); + const { upvote, downvote } = useQuestionVoting(); + const { requireLoggedIn } = useDevFAQRouter(); + + const handleClick = () => { + const mutation = !voted ? upvote : downvote; + + mutation.mutate( + { + id: questionId, + }, + { + onSuccess: () => { + void refetch(); + }, + }, + ); + }; + return ( +
+ + ); +}; diff --git a/apps/app/src/components/AddQuestionModal.tsx b/apps/app/src/components/AddQuestionModal.tsx deleted file mode 100644 index 415203e2..00000000 --- a/apps/app/src/components/AddQuestionModal.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import { ComponentProps } from "react"; -import type { FormEvent } from "react"; -import { useUIContext } from "../providers/UIProvider"; -import { BaseModal } from "./BaseModal"; -import { Button } from "./Button/Button"; -import { Select } from "./Select/Select"; - -export const AddQuestionModal = (props: ComponentProps) => { - const { closeModal } = useUIContext(); - - const handleFormSubmit = (event: FormEvent) => { - event.preventDefault(); - }; - - return ( - -

- Nowe pytanie -

- -
- - -
- -
- - -
- -
- ); -}; diff --git a/apps/app/src/components/AddQuestionModal/AddQuestionModal.tsx b/apps/app/src/components/AddQuestionModal/AddQuestionModal.tsx new file mode 100644 index 00000000..f80f3d71 --- /dev/null +++ b/apps/app/src/components/AddQuestionModal/AddQuestionModal.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { ChangeEvent, ComponentProps, useState } from "react"; +import type { FormEvent } from "react"; +import { twMerge } from "tailwind-merge"; +import { useUIContext } from "../../providers/UIProvider"; +import { technologiesLabel, Technology, validateTechnology } from "../../lib/technologies"; +import { Level, levels, validateLevel } from "../../lib/level"; +import { BaseModal } from "../BaseModal"; +import { Button } from "../Button/Button"; +import { Select } from "../Select/Select"; +import { useCreateQuestion } from "../../hooks/useCreateQuestion"; +import { QuestionEditor } from "./QuestionEditor"; + +type SelectDataState = Readonly<{ + technology?: Technology; + level?: Level; +}>; + +export const AddQuestionModal = (props: ComponentProps) => { + const [selectData, setSelectData] = useState({}); + const [content, setContent] = useState(""); + const [isError, setIsError] = useState(false); + + const { openModal, closeModal } = useUIContext(); + const { createQuestionMutation } = useCreateQuestion(); + + const disabled = + !selectData.technology || + !selectData.level || + content.length === 0 || + createQuestionMutation.isLoading; + + const handleFormSubmit = (event: FormEvent) => { + const { technology, level } = selectData; + + event.preventDefault(); + + if (technology && level) { + createQuestionMutation.mutate( + { + category: technology, + question: content, + level, + }, + { + onSuccess: () => { + setSelectData({}); + setContent(""); + openModal("AddQuestionConfirmationModal"); + }, + onError: () => setIsError(true), + }, + ); + } + }; + + const handleSelectChange = + (key: keyof SelectDataState, validator: (value: string) => boolean) => + (event: ChangeEvent) => { + const { value } = event.target; + + event.preventDefault(); + + if (validator(value)) { + setSelectData((prev) => ({ ...prev, [key]: value })); + } + }; + + return ( + +

+ Nowe pytanie +

+
+
+ + +
+ +
+ + +
+

+ ⚠️ Wystąpił nieoczekiwany błąd przy dodawaniu pytania. Spróbuj ponownie, a jeśli problem + będzie się powtarzał,{" "} + + skontaktuj się z administracją. + +

+ +
+ ); +}; diff --git a/apps/app/src/components/AddQuestionModal/QuestionEditor.tsx b/apps/app/src/components/AddQuestionModal/QuestionEditor.tsx new file mode 100644 index 00000000..0f2da457 --- /dev/null +++ b/apps/app/src/components/AddQuestionModal/QuestionEditor.tsx @@ -0,0 +1,45 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const WysiwygEditor = dynamic( + () => + import(/* webpackChunkName: "WysiwygEditor" */ "../WysiwygEditor").then( + (mod) => mod.WysiwygEditor, + ), + { + ssr: false, + }, +); + +const options = { + maxHeight: "300px", + toolbar: [ + "bold", + "italic", + "heading", + "|", + "code", + "unordered-list", + "ordered-list", + "|", + "preview", + ], + status: false, +} as const; + +type QuestionEditorProps = Readonly<{ + value: string; + onChange: (value: string) => void; +}>; + +export const QuestionEditor = ({ value, onChange }: QuestionEditorProps) => ( +
+ +
+); diff --git a/apps/app/src/components/AppModals.tsx b/apps/app/src/components/AppModals.tsx index 118b4a3e..ab050599 100644 --- a/apps/app/src/components/AppModals.tsx +++ b/apps/app/src/components/AppModals.tsx @@ -3,11 +3,13 @@ import type { ComponentProps, ComponentType } from "react"; import { useUIContext } from "../providers/UIProvider"; import type { Modal } from "../providers/UIProvider"; -import { AddQuestionModal } from "./AddQuestionModal"; +import { AddQuestionModal } from "./AddQuestionModal/AddQuestionModal"; +import { AddQuestionConfirmationModal } from "./AddQuestionConfirmationModal"; import { BaseModal } from "./BaseModal"; const modals: Record>> = { AddQuestionModal, + AddQuestionConfirmationModal, }; export const AppModals = () => { diff --git a/apps/app/src/components/BaseModal.tsx b/apps/app/src/components/BaseModal.tsx index 1ec24040..d67a0214 100644 --- a/apps/app/src/components/BaseModal.tsx +++ b/apps/app/src/components/BaseModal.tsx @@ -3,6 +3,7 @@ import { ReactNode, useEffect } from "react"; import { Transition } from "@headlessui/react"; import { lockScroll, unlockScroll } from "../utils/pageScroll"; +import { useUIContext } from "../providers/UIProvider"; import { CloseButton } from "./CloseButton/CloseButton"; type BaseModalProps = Readonly<{ @@ -12,6 +13,8 @@ type BaseModalProps = Readonly<{ }>; export const BaseModal = ({ isOpen, onClose, children }: BaseModalProps) => { + const { openedModal } = useUIContext(); + useEffect(() => { if (isOpen) { lockScroll(); @@ -20,7 +23,7 @@ export const BaseModal = ({ isOpen, onClose, children }: BaseModalProps) => { return ( { leave="transition-opacity duration-100" leaveFrom="opacity-100" leaveTo="opacity-0" - afterLeave={unlockScroll} + afterLeave={() => { + if (!openedModal) { + unlockScroll(); + } + }} > -
{ - // stop propagation to avoid triggering `onClick` on the backdrop behind the modal - event.stopPropagation(); - }} - > - - {children} +
+
{ + // stop propagation to avoid triggering `onClick` on the backdrop behind the modal + event.stopPropagation(); + }} + > + + {children} +
); diff --git a/apps/app/src/components/Button/Button.tsx b/apps/app/src/components/Button/Button.tsx index ce604f81..04302c85 100644 --- a/apps/app/src/components/Button/Button.tsx +++ b/apps/app/src/components/Button/Button.tsx @@ -6,7 +6,7 @@ const variants = { branding: "text-violet-700 dark:text-neutral-200 border-violet-700 dark:border-neutral-200 bg-transparent hover:bg-violet-50 hover:dark:bg-violet-900 focus:shadow-[0_0_10px] focus:shadow-primary", brandingInverse: - "text-white border-white bg-primary hover:bg-violet-200 hover:dark:bg-violet-700 focus:shadow-[0_0_10px] focus:shadow-white", + "border-white bg-primary text-white transition-opacity hover:bg-violet-200 focus:shadow-[0_0_10px] focus:shadow-white disabled:opacity-50 hover:dark:bg-violet-700", alternative: "text-white border-white bg-yellow-branding hover:bg-yellow-branding-dark focus:shadow-[0_0_10px] focus:shadow-yellow-branding", }; diff --git a/apps/app/src/components/WysiwygEditor.tsx b/apps/app/src/components/WysiwygEditor.tsx new file mode 100644 index 00000000..9006aebf --- /dev/null +++ b/apps/app/src/components/WysiwygEditor.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import EasyMDE from "easymde"; + +import "easymde/dist/easymde.min.css"; + +type EditorProps = Readonly<{ + value: string; + label?: string; + options?: EasyMDE.Options; + onChange: (value: string) => void; +}>; + +export const WysiwygEditor = ({ value, label, options, onChange }: EditorProps) => { + const textAreaRef = useRef(null); + const editorRef = useRef(null); + + useEffect(() => { + if (textAreaRef.current && !editorRef.current) { + const easyMDE = new EasyMDE({ + ...options, + element: textAreaRef.current, + initialValue: value, + }); + + easyMDE.codemirror.on("change", () => { + onChange(easyMDE.value()); + }); + + editorRef.current = easyMDE; + } + + return () => { + editorRef.current?.cleanup(); + }; + }, [value, onChange, options]); + + return