├── .env
├── .eslintrc.js
├── .github
├── ISSUE_TEMPLATE
│ ├── баг-репорт.md
│ └── предложить-фичу.md
└── workflows
│ ├── cd.yml
│ └── ci.yml
├── .gitignore
├── .husky
├── .gitignore
└── pre-commit
├── .lintstagedrc
├── .prettierignore
├── .prettierrc
├── CONTRIBUTING.md
├── LICENCE
├── README.md
├── jsconfig.json
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── manifest.json
└── robots.txt
└── src
├── __tests__
└── compact-mode.test.jsx
├── app
├── App.jsx
├── GlobalProvider.jsx
├── GlobalStyles.js
├── LazyPlaceholder.jsx
├── Title
│ ├── Title.jsx
│ └── Title.test.jsx
├── constants.js
├── store.js
└── theme.js
├── assets
├── animation-back-to-top.css
├── bootstrap-placeholder.css
├── sprite.svg
└── toast-ui-iqa-theme.css
├── common
├── context
│ └── Auth
│ │ ├── AuthProvider.jsx
│ │ ├── index.jsx
│ │ └── useAuth.jsx
├── hooks
│ ├── useOnScroll.js
│ └── useQueryString.js
└── utils
│ ├── title.js
│ ├── truncateLongText.js
│ └── url.js
├── components
├── FavoritePopoverContent.jsx
├── icons
│ ├── ArrowAvatar.jsx
│ ├── CheckIcon.jsx
│ ├── ChevronUpIcon.jsx
│ ├── CloseIcon.jsx
│ ├── CloseMenuIcon.jsx
│ ├── CommentsIcon.jsx
│ ├── DeleteIcon.jsx
│ ├── EmptyFolderIcon.jsx
│ ├── ErrorIcon.jsx
│ ├── FavoritesIcon.jsx
│ ├── FavoritesInIcon.jsx
│ ├── GitHubIcons.jsx
│ ├── HelpIcon.jsx
│ ├── IconIcon.jsx
│ ├── InfoIcon.jsx
│ ├── LogoIcon.jsx
│ ├── LogoNoColorIcon.jsx
│ ├── LongLogoIcon.jsx
│ ├── MenuIcon.jsx
│ ├── NoIcon.jsx
│ ├── PlusIcon.jsx
│ ├── QuestionViewsIcon.jsx
│ ├── RestoreIcon.jsx
│ ├── SaveIcon.jsx
│ ├── SearchIcon.jsx
│ ├── SpinnerIcon.jsx
│ ├── SuccessIcon.jsx
│ ├── WarningIcon.jsx
│ └── YesIcon.jsx
└── layout
│ ├── Footer.jsx
│ ├── Paper
│ ├── Paper.test.jsx
│ └── index.jsx
│ ├── ScrollToTop.jsx
│ └── header
│ ├── AdaptiveMenu.jsx
│ ├── AnimatedSearch.jsx
│ ├── Header.jsx
│ ├── Logo.jsx
│ ├── PopoverContent.jsx
│ ├── Search.jsx
│ └── header-menu
│ ├── HeaderMenu.jsx
│ ├── LinkToDeleted.jsx
│ ├── LinkToFavorites.jsx
│ └── LinkToProfilePage.jsx
├── features
├── application
│ └── applicationSlice.js
├── comments
│ ├── AddComment.jsx
│ ├── CommentItem.jsx
│ ├── CommentView.jsx
│ ├── CommentsList.jsx
│ ├── CommentsOfQuestion.jsx
│ ├── CommentsPlaceholder.jsx
│ ├── comment-actions
│ │ ├── CommentsActions.jsx
│ │ └── DeleteAction.jsx
│ └── commentsSlice.js
├── profile
│ ├── ProfileUser.jsx
│ ├── UserFullnameForm.jsx
│ └── profileSlice.js
├── questions
│ ├── create-question
│ │ └── CreateQuestionPage.jsx
│ ├── question-page
│ │ ├── QuestionPage.jsx
│ │ ├── QuestionPageContent.jsx
│ │ ├── QuestionPageHeader.jsx
│ │ └── QuestionPagePlaceholder.jsx
│ ├── questions-list
│ │ ├── QuestionBlock.jsx
│ │ ├── QuestionContent.jsx
│ │ ├── QuestionEmptyFolder.jsx
│ │ ├── QuestionHeader.jsx
│ │ ├── QuestionsList.jsx
│ │ ├── QuestionsListMapper.jsx
│ │ ├── QuestionsListPlaceholder.jsx
│ │ └── question-actions
│ │ │ ├── CommentsAction.jsx
│ │ │ ├── DeleteAction.jsx
│ │ │ ├── FavoriteAction.jsx
│ │ │ ├── FavoriteIconSwitcher.jsx
│ │ │ ├── FavoritePopover.jsx
│ │ │ ├── QuestionViews.jsx
│ │ │ ├── QuestionsActions.jsx
│ │ │ ├── RestoreAction.jsx
│ │ │ └── TheQuestionAction.jsx
│ └── questionsSlice.js
└── search
│ └── searchQuestionSlice.js
├── index.jsx
├── index.test.jsx
└── pages
└── HelpPage.jsx
/.env:
--------------------------------------------------------------------------------
1 | REACT_APP_FEATURE_SEARCH=on
2 | REACT_APP_FEATURE_ADD_QUESTION=on
3 | REACT_APP_FEATURE_FAVORITES=on
4 | REACT_APP_FEATURE_DELETE_QUESTION=on
5 | REACT_APP_FEATURE_TAGS=on
6 | REACT_APP_FEATURE_COMMENTARIES=on
7 | REACT_APP_FEATURE_RATING=on
8 | REACT_APP_FEATURE_LIKE_COMMENT=on
9 | REACT_APP_FEATURE_DELETE_COMMENT=on
10 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['react-app', 'airbnb', 'prettier', 'plugin:eslint-plugin/recommended'],
3 | settings: {
4 | 'import/resolver': {
5 | node: {
6 | moduleDirectory: ['node_modules', 'src/'],
7 | },
8 | },
9 | },
10 | rules: {
11 | // опция для проверки на абсолютный импорт
12 | // выкидывает ошибку если испорт начинается с '../'
13 | 'no-restricted-imports': [
14 | 'error',
15 | {
16 | patterns: ['../../*'],
17 | },
18 | ],
19 | // 'no-unused-vars': 'off',
20 |
21 | // делаем error, чтобы зачищать консоли, если где-то консоль важна,
22 | // то правило нужно отключить на строке
23 | 'no-console': 'error',
24 |
25 | // правило не нужно т.к. CRA настроен на автоимпорт реакта
26 | 'react/react-in-jsx-scope': 'off',
27 |
28 | // todo нужно узнать что лучше off или error (по умолчанию error)
29 | 'import/prefer-default-export': 'off',
30 |
31 | // иначе компонента выглядят уродски
32 | 'arrow-body-style': 'off',
33 |
34 | // styled-components часто требует пробрасывания всех пропсов
35 | 'react/jsx-props-no-spreading': 'off',
36 |
37 | // отключаем из-за использования immer в редьюсерах
38 | // https://redux-toolkit.js.org/usage/immer-reducers#linting-state-mutations
39 | 'no-param-reassign': ['error', { props: true, ignorePropertyModificationsFor: ['state'] }],
40 |
41 | 'no-underscore-dangle': ['error', { allow: ['_id'] }],
42 |
43 | // свойство появилось в cra@5
44 | 'react/function-component-definition': 'off',
45 | },
46 | overrides: [
47 | {
48 | files: ['**/*.stories.*'],
49 | rules: {
50 | 'import/no-anonymous-default-export': 'off',
51 | 'react/jsx-props-no-spreading': 'off',
52 | },
53 | },
54 | ],
55 | };
56 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/баг-репорт.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Баг-репорт
3 | about: Сообщить о найденном баге
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 | ---
8 |
9 | ## Описание бага
10 |
11 | Коротко, но в то же время понятно опиши в чём заключается суть бага.
12 |
13 | ## Как можно увидеть этот баг?
14 |
15 | Выполнить следующие шаги:
16 |
17 | 1. Зайти на сайт
18 | 2. Кликнуть на ...
19 | 3. Сделать ...
20 | 4. Увидеть ошибку
21 |
22 | ## Что должно было случиться?
23 |
24 | Опиши что ты ожидал увидеть вместо ошибки выполнив эти шаги
25 |
26 | ## Скриншоты
27 |
28 | Приложи скриншоты, если это возможно.
29 |
30 | ## Дополнительное описание
31 |
32 | Свободное поле для подробного описания
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/предложить-фичу.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Предложить фичу
3 | about: Предложи свою идею для улучшения
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 | ---
8 |
9 | ## Какую фичу ты предлагаешь?
10 |
11 | Небольшое описание фичи
12 |
13 | ## Какую пользу принесет эта фича?
14 |
15 | Дай короткое, но понятное описание о пользе этой фичи
16 |
17 | ## Дополнительное описание
18 |
19 | Опиши фичи подробнее в деталях. Если есть возможность, то приложи скриншоты или схему реализации фичи.
20 |
--------------------------------------------------------------------------------
/.github/workflows/cd.yml:
--------------------------------------------------------------------------------
1 | name: Stage CD
2 |
3 | on:
4 | push:
5 | branches: [main]
6 |
7 | jobs:
8 | build:
9 | # The type of runner that the job will run on
10 | runs-on: ubuntu-latest
11 |
12 | # Steps represent a sequence of tasks that will be executed as part of the job
13 | steps:
14 | - name: Deploy using ssh
15 | uses: appleboy/ssh-action@master
16 | with:
17 | host: ${{ secrets.HOST }}
18 | username: ${{ secrets.USERNAME }}
19 | password: ${{ secrets.PASSWORD }}
20 | port: 22
21 | script_stop: true
22 | script: |
23 | cd /root/stage/iqa-frontend
24 | git pull origin main
25 | git status
26 | npm install
27 | pm2 stop frontend
28 | npm run build
29 | pm2 start frontend
30 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Stage CI
2 |
3 | env:
4 | REACT_APP_FEATURE_SEARCH: on
5 | REACT_APP_FEATURE_ADD_QUESTION: on
6 | REACT_APP_FEATURE_FAVORITES: on
7 | REACT_APP_FEATURE_DELETE_QUESTION: on
8 | REACT_APP_FEATURE_TAGS: on
9 | REACT_APP_FEATURE_COMMENTARIES: on
10 | REACT_APP_FEATURE_RATING: on
11 | DEBUG_PRINT_LIMIT: 9999999
12 |
13 | on:
14 | push:
15 | branches: [main]
16 | pull_request:
17 | branches: [main]
18 |
19 | jobs:
20 | check:
21 | runs-on: ubuntu-latest
22 |
23 | steps:
24 | - uses: actions/checkout@v3
25 |
26 | - name: Use Node.js 16.x
27 | uses: actions/setup-node@v3
28 | with:
29 | node-version: '16.x'
30 | - run: npm ci
31 | - run: npm run check
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .vscode
3 | .DS_Store
4 | build
5 | storybook-static
6 |
7 | # Файл взят с https://github.com/github/gitignore/blob/master/Node.gitignore
8 |
9 | # Logs
10 | logs
11 | *.log
12 | npm-debug.log*
13 | yarn-debug.log*
14 | yarn-error.log*
15 | lerna-debug.log*
16 | .pnpm-debug.log*
17 |
18 | # Diagnostic reports (https://nodejs.org/api/report.html)
19 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
20 |
21 | # Runtime data
22 | pids
23 | *.pid
24 | *.seed
25 | *.pid.lock
26 |
27 | # Directory for instrumented libs generated by jscoverage/JSCover
28 | lib-cov
29 |
30 | # Coverage directory used by tools like istanbul
31 | coverage
32 | *.lcov
33 |
34 | # nyc test coverage
35 | .nyc_output
36 |
37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
38 | .grunt
39 |
40 | # Bower dependency directory (https://bower.io/)
41 | bower_components
42 |
43 | # node-waf configuration
44 | .lock-wscript
45 |
46 | # Compiled binary addons (https://nodejs.org/api/addons.html)
47 | build/Release
48 |
49 | # Dependency directories
50 | node_modules/
51 | jspm_packages/
52 |
53 | # Snowpack dependency directory (https://snowpack.dev/)
54 | web_modules/
55 |
56 | # TypeScript cache
57 | *.tsbuildinfo
58 |
59 | # Optional npm cache directory
60 | .npm
61 |
62 | # Optional eslint cache
63 | .eslintcache
64 |
65 | # Microbundle cache
66 | .rpt2_cache/
67 | .rts2_cache_cjs/
68 | .rts2_cache_es/
69 | .rts2_cache_umd/
70 |
71 | # Optional REPL history
72 | .node_repl_history
73 |
74 | # Output of 'npm pack'
75 | *.tgz
76 |
77 | # Yarn Integrity file
78 | .yarn-integrity
79 |
80 | # parcel-bundler cache (https://parceljs.org/)
81 | .cache
82 | .parcel-cache
83 |
84 | # Next.js build output
85 | .next
86 | out
87 |
88 | # Nuxt.js build / generate output
89 | .nuxt
90 | dist
91 |
92 | # Gatsby files
93 | .cache/
94 | # Comment in the public line in if your project uses Gatsby and not Next.js
95 | # https://nextjs.org/blog/next-9-1#public-directory-support
96 | # public
97 |
98 | # vuepress build output
99 | .vuepress/dist
100 |
101 | # Serverless directories
102 | .serverless/
103 |
104 | # FuseBox cache
105 | .fusebox/
106 |
107 | # DynamoDB Local files
108 | .dynamodb/
109 |
110 | # TernJS port file
111 | .tern-port
112 |
113 | # Stores VSCode versions used for testing VSCode extensions
114 | .vscode-test
115 |
116 | # yarn v2
117 | .yarn/cache
118 | .yarn/unplugged
119 | .yarn/build-state.yml
120 | .yarn/install-state.gz
121 | .pnp.*
122 |
--------------------------------------------------------------------------------
/.husky/.gitignore:
--------------------------------------------------------------------------------
1 | _
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/.lintstagedrc:
--------------------------------------------------------------------------------
1 | {
2 | "*{.json,.jsx,.js,.css,.html,.yml}": "prettier --check",
3 | "*{.jsx,.js}": "eslint --report-unused-disable-directives --max-warnings 0",
4 | "*": "npm run test:ci ./src"
5 | }
6 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": true,
4 | "printWidth": 100,
5 | "trailingComma": "es5",
6 | "tabWidth": 2,
7 | "endOfLine": "auto",
8 | "quoteProps": "as-needed",
9 | "jsxSingleQuote": false,
10 | "bracketSameLine": false
11 | }
12 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Участие в проекте
2 |
3 | Любой желающий может внести свой вклад в развитие данного проекта.
4 |
5 | Помощь может заключаться не только в непосредственной разработке, но и в поиске ошибок, исправлении опечаток, предложении новых идей, которые сделают проект лучше.
6 |
7 | ## Как предложить свою идею или указать на найденный баг?
8 |
9 | Открой раздел [Issues](https://github.com/intocode/iqa-frontend/issues). Все текущие и будущие задачи обсуждаются здесь. Если в списке открытых вопросов нет твоей идеи, то смело создавай новый Issue с предложением.
10 |
11 | ## Как начать выполнять задачу?
12 |
13 | Открой раздел [Issues](https://github.com/intocode/iqa-frontend/issues). Выбери задачу, над которой хотел бы поработать. Внимательно ознакомься с описанием. Если остались вопросы, то задавай их в обсуждении Issue.
14 |
15 | Убедись, что задача тебе полностью понятна и только после этого приступай к её реализации. Если задача связана с написанием кода, то подробности выполнения такой задачи указаны в разделе ниже.
16 |
17 | ## Технические требования
18 |
19 | Ко всем выполняемым задачам есть определенные требования. Они довольно простые:
20 |
21 | **Один Pull Request должен закрывать только одну задачу.** Нельзя включать в Pull Request никакие изменения, которые напрямую не относятся к теме пуллреквеста, даже если это просто исправление опечатки в тексте.
22 |
23 | **Pull Request должен закрывать решаемую задачу полностью.** Нельзя закрывать только часть задачи, другую часть оставив на другой Pull Request. Если задача требует разбивки на более мелкие задачи, то это нужно обсудить в Issue этой задачи и в случае необходимости создать новые Issue для подзадач.
24 |
25 | **Весь код должен идти с комментариями.** Разрешается не комментировать участки кода, которые можно однозначно понять в рамках своего контекста. Комментарии не должны содержать грамматические ошибки.
26 |
27 | _Пример 1:_
28 |
29 | ```javascript
30 | useEffect(() => {
31 | dispatch(fetchUsers());
32 | }, [dispatch]);
33 | ```
34 |
35 | В данном случае можно недвусмысленно понять что делает этот код, поэтому его можно не комментировать.
36 |
37 | ---
38 |
39 | _Пример 2:_
40 |
41 | ```javascript
42 | // создаем переменную стейта, чтобы сделать поле ввода логина управляемым компонентом
43 | const [text, setText] = useState(null);
44 | ```
45 |
46 | В этом случае код нужно было прокомментировать, потому что переменная `text` может использоваться как угодно и нужно вносить ясность в момент её создания.
47 |
48 | ---
49 |
50 | _Пример 3:_
51 |
52 | ```javascript
53 | const [loginFieldValue, setLoginFieldValue] = useState('');
54 | ```
55 |
56 | Данной код выполяет ту же задачу, но за счет хорошего нейминга переменных можно избежать неоднозначности, поэтому дополнительный комментарий не требуется.
57 |
58 | ## Как приступить к разработке
59 |
60 | ### Подготовка git
61 |
62 | **1. Сделай fork текущего репозитория**.
63 |
64 | **2. Склонируй свой fork на рабочий компьютер**:
65 |
66 | ```shell
67 | git clone https://github.com/твой-логин/iqa-frontend.git
68 | ```
69 |
70 | **3. Добавь головной сервер `upstream`**:
71 |
72 | ```shell
73 | git remote add upstream https://github.com/intocode/iqa-frontend.git
74 | ```
75 |
76 | **4. Создай новую ветку под выполняемую задачу**:
77 |
78 | ```shell
79 | git switch -c my-feature
80 | ```
81 |
82 | ### Запуск тестов
83 |
84 | После выполнения задачи запусти линтеры и тесты командой `npm run check`. Если какой-то тест не проходит, то внеси исправления в свой код.
85 |
86 | ### Выгрузка изменений
87 |
88 | **Если задача будет решена и все тесты проходят** выгрузи свою работу и открыть новый Pull Request:
89 |
90 | ```shell
91 | git add измененный-файл.js
92 | git commit -m "Хорошее описание коммита"
93 | git push origin my-feature
94 | ```
95 |
96 | После этого нужно перейти в свой репозиторий на GitHub и открыть Pull Request. Подробнее об этом [в документации GitHub](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork).
97 |
98 | ### Проверка на конфликты
99 |
100 | Если выполнение задачи заняло много времени, то возможно репозиторий в это время был обновлен. Необходимо сгрузить себе изменения и замёржить их. Для этого:
101 |
102 | **а) Сделай коммит своей работы:**
103 |
104 | ```shell
105 | git add file1.js file2.js
106 | git commit -m "Описание выполненной работы"
107 | ```
108 |
109 | **б) Перейди на ветку `main`:**
110 |
111 | ```shell
112 | git switch main
113 | ```
114 |
115 | **в) Подтяни изменения с головной ветки:**
116 |
117 | ```shell
118 | git pull upstream main
119 | ```
120 |
121 | **г) Перейди на рабочую ветку и сделай мёрж:**
122 |
123 | ```shell
124 | git switch my-feature
125 | git merge main
126 | ```
127 |
128 | **д) Если есть конфликты, то исправь их.** Если не знаешь как это делать, то прочитай какой-нибудь материал на эту тему. Например [этот](https://www.atlassian.com/ru/git/tutorials/using-branches/merge-conflicts), [этот](https://stackoverflow.com/questions/161813/how-do-i-resolve-merge-conflicts-in-a-git-repository) или [вот этот](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/resolving-a-merge-conflict-using-the-command-line).
129 |
130 | После этого можно делать `push` и открывать Pull Request.
131 |
132 | ## Принятие Pull Request
133 |
134 | Открытый Pull Request должен пройти код ревью как минимум двух участников проекта. Однако, в некоторых случаях мы можем принять изменения не дожидаясь двух подтверждений.
135 |
136 | Всё обсуждение пулл реквеста должно вестись на его странице в комментариях, чтобы оно было доступно всем участникам разработки.
137 |
--------------------------------------------------------------------------------
/LICENCE:
--------------------------------------------------------------------------------
1 | Copyright 2021 (c) intocode
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # iqa-frontend
2 |
3 | [](https://github.com/intocode/iqa-frontend)
4 |
5 | Репозиторий содержит фронтенд проекта iqa-application. Backend находится в [отдельном репозитории](https://github.com/intocode/iqa-backend).
6 |
7 | Группа для обсуждения в Discord: [discord.gg/3B8pF2fYw7](https://discord.gg/3B8pF2fYw7).
8 |
9 | ## Начало работы
10 |
11 | ### Установка
12 |
13 | ```shell
14 | git clone https://github.com/intocode/iqa-frontend.git
15 | cd iqa-frontend
16 | npm install
17 | ```
18 |
19 | ### Запуск в режиме разработки
20 |
21 | ```shell
22 | npm start
23 | ```
24 |
25 | Установка backend-части приложения не требуется. Все запросы будут направлены на доступный в сети stage-сервер.
26 |
27 | **ВНИМАНИЕ!** Установка для участников разработки немного отличается от вышеуказанной. Подробнее читай в файле [CONTRIBUTING.md](./CONTRIBUTING.md).
28 |
29 | ## Участие в разработке
30 |
31 | Любой желающий может внести свой вклад в развитие данного проекта.
32 |
33 | Помощь может заключаться не только в непосредственной разработке, но и в поиске ошибок, исправлении опечаток, предложении новых идей, которые сделают проект лучше.
34 |
35 | Подробности участия в проекте можно прочитать в файле [CONTRIBUTING.md](./CONTRIBUTING.md).
36 |
37 | ## Стек
38 |
39 | - JavaScript, ES6, ES7
40 | - React 17 (FC), create-react-app, prop-types
41 | - Redux, Redux Toolkit, Redux Persist
42 | - antd, styled-components, bootstrap-grid.css
43 | - axios, dayjs, react-transition-group
44 | - Jest, ESLint, Prettier, lint-staged
45 |
46 | 🤘🏼 Необязательно знать все инструменты, чтобы начать работу с проектом.
47 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src"
4 | },
5 | "include": ["src"]
6 | }
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "license": "MIT",
6 | "dependencies": {
7 | "@ant-design/icons": "^4.7.0",
8 | "@reduxjs/toolkit": "^1.8.2",
9 | "@toast-ui/editor": "^3.2.0",
10 | "@toast-ui/react-editor": "^3.2.0",
11 | "antd": "^4.23.0",
12 | "axios": "^0.27.2",
13 | "bootstrap": "^5.1.3",
14 | "dayjs": "^1.11.3",
15 | "prop-types": "^15.8.1",
16 | "react": "^17.0.2",
17 | "react-dom": "^17.0.2",
18 | "react-redux": "^7.2.6",
19 | "react-router-dom": "^5.3.0",
20 | "react-scripts": "5.0.1",
21 | "react-transition-group": "^4.4.2",
22 | "redux-persist": "^6.0.0",
23 | "source-map-explorer": "^2.5.3",
24 | "styled-components": "^5.3.3"
25 | },
26 | "scripts": {
27 | "check": "npm run prettier:check && npm run lint && npm run test:ci && cross-env CI=true npm run build",
28 | "start": "react-scripts start",
29 | "build": "cross-env GENERATE_SOURCEMAP=false react-scripts build",
30 | "test": "react-scripts test",
31 | "test:ci": "cross-env CI=true react-scripts test",
32 | "eject": "react-scripts eject",
33 | "fix": "npm run lint:fix && npm run prettier",
34 | "lint": "npm run lint:js",
35 | "lint:fix": "eslint ./src --ext .js,.jsx --fix",
36 | "lint:js": "eslint ./src --ext .js,.jsx --report-unused-disable-directives --max-warnings 0",
37 | "storybook": "start-storybook -p 6006",
38 | "build-storybook": "build-storybook",
39 | "prepare": "husky install",
40 | "prettier:check": "prettier . --check",
41 | "prettier": "prettier . --write",
42 | "analyze": "npm run analyze:build && source-map-explorer 'build/static/js/*.js'",
43 | "analyze:build": "GENERATE_SOURCEMAP=true react-scripts build"
44 | },
45 | "browserslist": {
46 | "production": [
47 | "> 1%, IE 10",
48 | "not dead",
49 | "not op_mini all"
50 | ],
51 | "development": [
52 | "last 1 chrome version",
53 | "last 1 firefox version",
54 | "last 1 safari version"
55 | ]
56 | },
57 | "devDependencies": {
58 | "@testing-library/jest-dom": "^5.16.2",
59 | "@testing-library/react": "^12.1.4",
60 | "@types/jest": "^27.4.1",
61 | "@types/react-dom": "^17.0.13",
62 | "@types/react-router-dom": "^5.3.3",
63 | "@types/styled-components": "^5.1.24",
64 | "cross-env": "^7.0.3",
65 | "eslint-config-airbnb": "^19.0.4",
66 | "eslint-config-prettier": "^8.5.0",
67 | "eslint-plugin-eslint-plugin": "^5.0.6",
68 | "husky": "^8.0.3",
69 | "lint-staged": "^13.1.2",
70 | "prettier": "^2.7.1"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/intocode/iqa-frontend/c15005a3631948125e09f8ec5216f5612dfe4a74/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | iqa: помощь в прохождении собеседований
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | You need to enable JavaScript to run this app.
36 |
37 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "start_url": ".",
5 | "display": "standalone",
6 | "theme_color": "#000000",
7 | "background_color": "#ffffff"
8 | }
9 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/__tests__/compact-mode.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import axios from 'axios';
3 | import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4 | import dayjs from 'dayjs';
5 | import 'dayjs/locale/ru';
6 | import relativeTime from 'dayjs/plugin/relativeTime';
7 | import calendar from 'dayjs/plugin/calendar';
8 | import { App } from 'app/App';
9 | import { BASE_API_URL, LS_TOKEN_KEY } from 'app/constants';
10 | import '@testing-library/jest-dom';
11 | import { GlobalProvider } from 'app/GlobalProvider';
12 |
13 | // import 'bootstrap/dist/css/bootstrap-grid.min.css';
14 |
15 | axios.defaults.baseURL = BASE_API_URL;
16 | axios.defaults.headers.authorization = `Bearer ${localStorage.getItem(LS_TOKEN_KEY)}`;
17 |
18 | dayjs.extend(relativeTime);
19 | dayjs.extend(calendar);
20 | dayjs.locale('ru');
21 |
22 | describe('Header rendering', () => {
23 | it('renders header', async () => {
24 | render(
25 |
26 |
27 |
28 | );
29 |
30 | // дожидаемся подгрузки вопросов
31 | await waitFor(() => expect(screen.getAllByTestId('question-block')[0]).toBeInTheDocument(), {
32 | timeout: 7000,
33 | });
34 |
35 | // кликаем на "Компактный вид"
36 | fireEvent.click(screen.getByTestId('compact-mode-label'));
37 |
38 | // проверяем остались ли ненужные элементы
39 | expect(screen.queryAllByTestId('not-for-compact')).toBeInstanceOf(Array);
40 | expect(screen.queryAllByTestId('not-for-compact').length).toBe(0);
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/app/App.jsx:
--------------------------------------------------------------------------------
1 | import { useDispatch } from 'react-redux';
2 | import { Route, Switch } from 'react-router-dom';
3 | import { useEffect, lazy, Suspense } from 'react';
4 | import { useAuth } from 'common/context/Auth/useAuth';
5 | import { Header } from 'components/layout/header/Header';
6 | import { fetchProfile } from 'features/profile/profileSlice';
7 | import { Footer } from 'components/layout/Footer';
8 | import UserFullnameForm from 'features/profile/UserFullnameForm';
9 | import { LazyPlaceholder } from './LazyPlaceholder';
10 |
11 | const QuestionPage = lazy(() => import('features/questions/question-page/QuestionPage'));
12 | const CreateQuestion = lazy(() => import('features/questions/create-question/CreateQuestionPage'));
13 | const QuestionsList = lazy(() => import('features/questions/questions-list/QuestionsList'));
14 |
15 | const ProfileUser = lazy(() => import('features/profile/ProfileUser'));
16 | const HelpPage = lazy(() => import('pages/HelpPage'));
17 |
18 | const routes = [
19 | {
20 | key: 10,
21 | component: QuestionsList,
22 | path: '/',
23 | exact: true,
24 | },
25 | {
26 | key: 20,
27 | component: CreateQuestion,
28 | path: '/create',
29 | },
30 | {
31 | key: 30,
32 | component: QuestionPage,
33 | path: '/question/:id',
34 | },
35 | {
36 | key: 40,
37 | component: ProfileUser,
38 | path: '/profile',
39 | },
40 | {
41 | key: 50,
42 | component: HelpPage,
43 | path: '/help',
44 | },
45 | ];
46 |
47 | export const App = () => {
48 | const { token } = useAuth();
49 |
50 | const dispatch = useDispatch();
51 |
52 | useEffect(() => {
53 | if (token) {
54 | dispatch(fetchProfile());
55 | }
56 | }, [dispatch, token]);
57 |
58 | return (
59 | <>
60 |
61 |
62 | }>
63 |
64 | {routes.map((route) => (
65 |
66 | ))}
67 |
68 |
69 |
70 | >
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/src/app/GlobalProvider.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ThemeProvider } from 'styled-components';
3 | import { Provider as ReduxProvider } from 'react-redux';
4 | import { PersistGate } from 'redux-persist/integration/react';
5 | import { BrowserRouter } from 'react-router-dom';
6 | import PropTypes from 'prop-types';
7 | import { AuthProvider } from 'common/context/Auth/AuthProvider';
8 | import { theme } from './theme';
9 | import { GlobalStyles } from './GlobalStyles';
10 | import { store, persistor } from './store';
11 |
12 | export const GlobalProvider = ({ children }) => {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {children}
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | GlobalProvider.propTypes = {
32 | children: PropTypes.node.isRequired,
33 | };
34 |
--------------------------------------------------------------------------------
/src/app/GlobalStyles.js:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components';
2 | import { theme } from './theme';
3 |
4 | export const GlobalStyles = createGlobalStyle`
5 | * {
6 | box-sizing: border-box;
7 | margin: 0;
8 | text-rendering: geometricPrecision;
9 | }
10 |
11 | html, body {
12 | font-family: 'Roboto', sans-serif;
13 | background-color: #F5F5F5;
14 | }
15 |
16 | html {
17 | height: 100%;
18 | }
19 |
20 | body {
21 | position: relative;
22 | min-height: 100%;
23 | padding-bottom: 60px;
24 | height: auto;
25 | }
26 |
27 | .container {
28 | max-width: 844px;
29 | }
30 |
31 | a, a:visited {
32 | color: ${theme.colors.primary.main};
33 | text-decoration: none;
34 | }
35 | `;
36 |
--------------------------------------------------------------------------------
/src/app/LazyPlaceholder.jsx:
--------------------------------------------------------------------------------
1 | import { Tag } from 'antd';
2 | import styled from 'styled-components';
3 | import 'assets/bootstrap-placeholder.css';
4 |
5 | const StyledPlaceholder = styled.div`
6 | max-width: 820px;
7 | background-color: white;
8 | margin: auto;
9 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
10 | `;
11 |
12 | const Placeholder = () => {
13 | return (
14 |
15 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
34 |
35 | );
36 | };
37 |
38 | export const LazyPlaceholder = () => {
39 | const placeholders = new Array(3).fill(null);
40 | return (
41 | <>
42 | {placeholders.map((_, idx) => (
43 | // eslint-disable-next-line react/no-array-index-key
44 |
45 | ))}
46 | >
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/src/app/Title/Title.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | export const Title = ({ children }) => {
5 | useEffect(() => {
6 | const title = document.querySelector('title');
7 |
8 | if (title) {
9 | title.textContent = children;
10 | } else {
11 | const node = document.createElement('title', { textContent: children });
12 | document.head.append(node);
13 | }
14 | }, [children]);
15 |
16 | return null;
17 | };
18 |
19 | Title.defaultProps = {
20 | children: '',
21 | };
22 |
23 | Title.propTypes = {
24 | children: PropTypes.string,
25 | };
26 |
--------------------------------------------------------------------------------
/src/app/Title/Title.test.jsx:
--------------------------------------------------------------------------------
1 | import { render, waitFor } from '@testing-library/react';
2 | import '@testing-library/jest-dom';
3 | import { Title } from './Title';
4 |
5 | describe('Title', () => {
6 | it('must renders without crash and return null', async () => {
7 | const { container } = render( );
8 | expect(container).toBeEmptyDOMElement();
9 | });
10 |
11 | it('must add title tag to document', async () => {
12 | const title = 'lorem ipsum';
13 | const { container } = render({title} );
14 | await waitFor(() => expect(document.head.title).toBeDefined());
15 | expect(document.querySelector('title').textContent).toBe(title);
16 | expect(container).toBeEmptyDOMElement();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/app/constants.js:
--------------------------------------------------------------------------------
1 | import { getBaseUrl } from 'common/utils/url';
2 | import { theme } from './theme';
3 |
4 | const lsPrefix = 'iqa_';
5 |
6 | export const BASE_API_URL = getBaseUrl();
7 |
8 | export const LS_TOKEN_KEY = `${lsPrefix}accessToken`;
9 |
10 | export const AUTHORIZE_SERVICE_URL = `${BASE_API_URL}auth/github`;
11 |
12 | export const QUESTIONS_PER_PAGE = 5;
13 |
14 | export const AVAILABLE_THEME_COLORS = Object.keys(theme.colors);
15 | export const DEFAULT_COLOR = theme.defaultColor;
16 | export const SCROLL_TO_TOP_SHOW = 500;
17 |
18 | export const TAG_MAX_LENGTH = 15;
19 |
20 | export const MAX_NUMBER_OF_TAGS = 5;
21 |
22 | export const MAX_LAST_COMMENT_LENGTH = 140;
23 |
--------------------------------------------------------------------------------
/src/app/store.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import {
3 | persistStore,
4 | persistReducer,
5 | FLUSH,
6 | REHYDRATE,
7 | PAUSE,
8 | PERSIST,
9 | PURGE,
10 | REGISTER,
11 | } from 'redux-persist';
12 | import storage from 'redux-persist/lib/storage';
13 | import profile from 'features/profile/profileSlice';
14 | import questions from 'features/questions/questionsSlice';
15 | import comments from 'features/comments/commentsSlice';
16 | import application from 'features/application/applicationSlice';
17 | import questionsSearch from 'features/search/searchQuestionSlice';
18 |
19 | const persistConfig = {
20 | key: 'root',
21 | storage,
22 | };
23 |
24 | const persistedReducer = persistReducer(persistConfig, application);
25 |
26 | export const store = configureStore({
27 | reducer: {
28 | questionsSearch,
29 | profile,
30 | questions,
31 | comments,
32 | application: persistedReducer,
33 | },
34 | middleware: (getDefaultMiddleware) =>
35 | getDefaultMiddleware({
36 | serializableCheck: {
37 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
38 | },
39 | }),
40 | });
41 |
42 | export const persistor = persistStore(store);
43 |
--------------------------------------------------------------------------------
/src/app/theme.js:
--------------------------------------------------------------------------------
1 | export const theme = {
2 | defaultColor: 'primary',
3 |
4 | colors: {
5 | primary: {
6 | text: 'white',
7 | main: '#409EFF',
8 | addition: '#ECF5FF',
9 | },
10 | success: {
11 | text: 'white',
12 | main: '#67C23A',
13 | addition: '#f0f9eb',
14 | },
15 | danger: {
16 | text: 'white',
17 | main: '#DC3545',
18 | addition: '#fef0f0',
19 | },
20 | gray: {
21 | text: 'white',
22 | main: '#909399',
23 | addition: '#f4f4f5',
24 | },
25 | warning: {
26 | text: 'white',
27 | main: '#E6A23C',
28 | addition: '#fdf6ec',
29 | },
30 | },
31 |
32 | palette: {
33 | blue: {
34 | 400: 'lightblue',
35 | 900: 'blue',
36 | },
37 | },
38 | media: {
39 | phone: '(max-width:425px)',
40 | },
41 | };
42 |
--------------------------------------------------------------------------------
/src/assets/animation-back-to-top.css:
--------------------------------------------------------------------------------
1 | .alert-enter {
2 | opacity: 0;
3 | transform: scale(0.9);
4 | }
5 | .alert-enter-active {
6 | opacity: 1;
7 | transform: translateX(0);
8 | transition: opacity 300ms, transform 300ms;
9 | }
10 | .alert-exit {
11 | opacity: 1;
12 | }
13 | .alert-exit-active {
14 | opacity: 0;
15 | transform: scale(0.9);
16 | transition: opacity 300ms, transform 300ms;
17 | }
18 |
--------------------------------------------------------------------------------
/src/assets/bootstrap-placeholder.css:
--------------------------------------------------------------------------------
1 | .placeholder {
2 | display: inline-block;
3 | min-height: 1em;
4 | vertical-align: middle;
5 | cursor: wait;
6 | background-color: currentColor;
7 | opacity: 0.5;
8 | }
9 | .placeholder.btn::before {
10 | display: inline-block;
11 | content: '';
12 | }
13 |
14 | .placeholder-xs {
15 | min-height: 0.6em;
16 | }
17 |
18 | .placeholder-sm {
19 | min-height: 0.8em;
20 | }
21 |
22 | .placeholder-lg {
23 | min-height: 1.2em;
24 | }
25 |
26 | .placeholder-glow .placeholder {
27 | -webkit-animation: placeholder-glow 2s ease-in-out infinite;
28 | animation: placeholder-glow 2s ease-in-out infinite;
29 | }
30 |
31 | @-webkit-keyframes placeholder-glow {
32 | 50% {
33 | opacity: 0.2;
34 | }
35 | }
36 |
37 | @keyframes placeholder-glow {
38 | 50% {
39 | opacity: 0.2;
40 | }
41 | }
42 | .placeholder-wave {
43 | -webkit-mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%);
44 | mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%);
45 | -webkit-mask-size: 200% 100%;
46 | mask-size: 200% 100%;
47 | -webkit-animation: placeholder-wave 2s linear infinite;
48 | animation: placeholder-wave 2s linear infinite;
49 | }
50 |
51 | @-webkit-keyframes placeholder-wave {
52 | 100% {
53 | -webkit-mask-position: -200% 0%;
54 | mask-position: -200% 0%;
55 | }
56 | }
57 |
58 | @keyframes placeholder-wave {
59 | 100% {
60 | -webkit-mask-position: -200% 0%;
61 | mask-position: -200% 0%;
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/assets/toast-ui-iqa-theme.css:
--------------------------------------------------------------------------------
1 | .toastui-editor-iqa .toastui-editor-contents {
2 | font-size: 1rem;
3 | }
4 |
5 | .toastui-editor-iqa pre {
6 | border-radius: 4px;
7 | background-color: #e2e7e8;
8 | margin-top: 1rem;
9 | }
10 |
--------------------------------------------------------------------------------
/src/common/context/Auth/AuthProvider.jsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useMemo, useState } from 'react';
2 | import PropTypes from 'prop-types';
3 | import axios from 'axios';
4 | import { LS_TOKEN_KEY } from 'app/constants';
5 | import { AuthContext } from './index';
6 |
7 | export const AuthProvider = ({ children }) => {
8 | const [token, setToken] = useState(() => localStorage.getItem(LS_TOKEN_KEY));
9 |
10 | const setAuthToken = useCallback((newToken) => {
11 | setToken(() => {
12 | if (newToken) {
13 | axios.defaults.headers.authorization = `Bearer ${newToken}`;
14 | localStorage.setItem(LS_TOKEN_KEY, newToken);
15 | } else {
16 | axios.defaults.headers.authorization = ``;
17 | localStorage.removeItem(LS_TOKEN_KEY);
18 | }
19 |
20 | return newToken;
21 | });
22 | }, []);
23 |
24 | // перехватчик axios на случай, если слетит авторизация
25 | axios.interceptors.response.use(null, (error) => {
26 | if (error.response?.status === 401) {
27 | setToken(null);
28 | }
29 |
30 | return Promise.reject(error);
31 | });
32 |
33 | const authValue = useMemo(() => ({ token, setAuthToken }), [token, setAuthToken]);
34 |
35 | return {children} ;
36 | };
37 |
38 | AuthProvider.propTypes = {
39 | children: PropTypes.node.isRequired,
40 | };
41 |
--------------------------------------------------------------------------------
/src/common/context/Auth/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const AuthContext = React.createContext(null);
4 |
--------------------------------------------------------------------------------
/src/common/context/Auth/useAuth.jsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useContext, useState } from 'react';
2 | import { AUTHORIZE_SERVICE_URL } from 'app/constants';
3 | import { AuthContext } from './index';
4 |
5 | export const useAuth = () => {
6 | const { token, setAuthToken } = useContext(AuthContext);
7 | const [isLoggingIn, setIsLoggingIn] = useState(false);
8 |
9 | const executeLoggingInProcess = useCallback(() => {
10 | setIsLoggingIn(true);
11 |
12 | const windowMessageListener = (message) => {
13 | if (message.data.app === 'iqa') {
14 | const { accessToken, error } = message.data;
15 |
16 | if (error) {
17 | // todo обработать ошибку в ui
18 | // eslint-disable-next-line no-console
19 | console.error(error);
20 | } else {
21 | setAuthToken(accessToken);
22 | }
23 |
24 | window.removeEventListener('message', windowMessageListener);
25 | }
26 | };
27 |
28 | window.addEventListener('message', windowMessageListener);
29 |
30 | window.open(AUTHORIZE_SERVICE_URL);
31 | }, [setAuthToken]);
32 |
33 | const logout = useCallback(() => {
34 | setAuthToken('');
35 | }, [setAuthToken]);
36 |
37 | return { token, logout, executeLoggingInProcess, isLoggingIn };
38 | };
39 |
--------------------------------------------------------------------------------
/src/common/hooks/useOnScroll.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | export const useOnScroll = (scrollHandler) => {
4 | useEffect(() => {
5 | document.addEventListener('scroll', scrollHandler);
6 |
7 | return () => {
8 | document.removeEventListener('scroll', scrollHandler);
9 | };
10 | }, [scrollHandler]);
11 | };
12 |
--------------------------------------------------------------------------------
/src/common/hooks/useQueryString.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useLocation } from 'react-router-dom';
3 |
4 | export const useQueryString = () => {
5 | const { search } = useLocation();
6 |
7 | return React.useMemo(() => new URLSearchParams(search), [search]);
8 | };
9 |
--------------------------------------------------------------------------------
/src/common/utils/title.js:
--------------------------------------------------------------------------------
1 | export const generateTitle = (deletedOnly, savedOnly) => {
2 | if (savedOnly) {
3 | return 'Сохраненные вопросы';
4 | }
5 | if (deletedOnly) {
6 | return 'Корзина';
7 | }
8 | return 'Все вопросы';
9 | };
10 |
--------------------------------------------------------------------------------
/src/common/utils/truncateLongText.js:
--------------------------------------------------------------------------------
1 | import { MAX_LAST_COMMENT_LENGTH } from 'app/constants';
2 |
3 | export const truncateLongText = (value) => {
4 | let str = value;
5 | if (str.length > MAX_LAST_COMMENT_LENGTH) {
6 | str = str.slice(0, MAX_LAST_COMMENT_LENGTH);
7 | const lastSpaceIndex = str.lastIndexOf(' ');
8 | str = str.slice(0, lastSpaceIndex > -1 ? lastSpaceIndex : MAX_LAST_COMMENT_LENGTH);
9 | str += '...';
10 | }
11 | return str;
12 | };
13 |
--------------------------------------------------------------------------------
/src/common/utils/url.js:
--------------------------------------------------------------------------------
1 | export const getBaseUrl = () => {
2 | const PRODUCTION = process.env.NODE_ENV === 'production';
3 | const STAGING = !!process.env.REACT_APP_STAGING;
4 |
5 | let baseUrl = 'https://iqa-server.intocode.ru/';
6 |
7 | if (!PRODUCTION || STAGING) {
8 | baseUrl = 'https://iqa-stage-backend.intocode.ru/';
9 | }
10 |
11 | return baseUrl;
12 | };
13 |
--------------------------------------------------------------------------------
/src/components/FavoritePopoverContent.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { useAuth } from 'common/context/Auth/useAuth';
4 | import styled from 'styled-components';
5 | import PropTypes from 'prop-types';
6 |
7 | const StyledPopoverBlock = styled.div`
8 | width: 250px;
9 | text-align: center;
10 | `;
11 |
12 | const FavoritePopoverContent = ({ text }) => {
13 | const { executeLoggingInProcess } = useAuth();
14 |
15 | return (
16 |
17 |
18 | Авторизуйся,
19 | {' '}
20 | {text}
21 |
22 | );
23 | };
24 |
25 | export default FavoritePopoverContent;
26 |
27 | FavoritePopoverContent.propTypes = {
28 | text: PropTypes.node.isRequired,
29 | };
30 |
--------------------------------------------------------------------------------
/src/components/icons/ArrowAvatar.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const ArrowAvatar = () => {
4 | return (
5 |
13 |
14 |
15 | );
16 | };
17 |
18 | export default ArrowAvatar;
19 |
--------------------------------------------------------------------------------
/src/components/icons/CheckIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function CheckIcon() {
4 | return (
5 |
19 | );
20 | }
21 |
22 | export default CheckIcon;
23 |
--------------------------------------------------------------------------------
/src/components/icons/ChevronUpIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function ChevronUpIcon() {
4 | return (
5 |
6 |
10 |
11 | );
12 | }
13 |
14 | export default ChevronUpIcon;
15 |
--------------------------------------------------------------------------------
/src/components/icons/CloseIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function CloseIcon() {
4 | return (
5 |
12 |
13 |
14 | );
15 | }
16 |
17 | export default CloseIcon;
18 |
--------------------------------------------------------------------------------
/src/components/icons/CloseMenuIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function CloseMenuIcon() {
4 | return (
5 |
27 | );
28 | }
29 |
30 | export default CloseMenuIcon;
31 |
--------------------------------------------------------------------------------
/src/components/icons/CommentsIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function CommentsIcon() {
4 | return (
5 |
13 |
17 |
18 | );
19 | }
20 |
21 | export default CommentsIcon;
22 |
--------------------------------------------------------------------------------
/src/components/icons/DeleteIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function DeleteIcon() {
4 | return (
5 |
6 |
7 |
11 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
27 | export default DeleteIcon;
28 |
--------------------------------------------------------------------------------
/src/components/icons/EmptyFolderIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const EmptyFolderIcon = () => {
4 | return (
5 |
12 |
16 |
20 |
21 | );
22 | };
23 |
24 | export default EmptyFolderIcon;
25 |
--------------------------------------------------------------------------------
/src/components/icons/ErrorIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function ErrorIcon() {
4 | return (
5 |
6 |
13 |
17 |
24 |
31 |
32 |
33 | );
34 | }
35 |
36 | export default ErrorIcon;
37 |
--------------------------------------------------------------------------------
/src/components/icons/FavoritesIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { theme } from 'app/theme';
3 |
4 | function FavoritesIcon() {
5 | return (
6 |
15 |
19 |
20 | );
21 | }
22 |
23 | export default FavoritesIcon;
24 |
--------------------------------------------------------------------------------
/src/components/icons/FavoritesInIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { theme } from 'app/theme';
3 |
4 | function FavoritesInIcon() {
5 | return (
6 |
15 |
19 |
20 | );
21 | }
22 |
23 | export default FavoritesInIcon;
24 |
--------------------------------------------------------------------------------
/src/components/icons/GitHubIcons.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function GitHubIcons() {
4 | return (
5 |
10 | );
11 | }
12 |
13 | export default GitHubIcons;
14 |
--------------------------------------------------------------------------------
/src/components/icons/HelpIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const HelpIcon = () => {
4 | return (
5 |
6 |
7 |
11 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default HelpIcon;
26 |
--------------------------------------------------------------------------------
/src/components/icons/IconIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function IconIcon() {
4 | return (
5 |
6 |
13 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | export default IconIcon;
26 |
--------------------------------------------------------------------------------
/src/components/icons/InfoIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function InfoIcon() {
4 | return (
5 |
6 |
13 |
17 |
24 |
31 |
32 |
33 | );
34 | }
35 |
36 | export default InfoIcon;
37 |
--------------------------------------------------------------------------------
/src/components/icons/LogoIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function LogoIcon() {
4 | return (
5 |
6 |
13 |
14 |
15 |
16 |
17 |
21 |
25 |
29 |
30 |
31 | );
32 | }
33 |
34 | export default LogoIcon;
35 |
--------------------------------------------------------------------------------
/src/components/icons/LogoNoColorIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function LogoNoColorIcon() {
4 | return (
5 |
6 |
13 |
14 |
15 |
16 |
17 |
21 |
22 |
23 | );
24 | }
25 |
26 | export default LogoNoColorIcon;
27 |
--------------------------------------------------------------------------------
/src/components/icons/LongLogoIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function LongLogoIcon() {
4 | return (
5 |
6 |
13 |
14 |
15 |
16 |
17 |
21 |
25 |
29 |
30 |
31 | );
32 | }
33 |
34 | export default LongLogoIcon;
35 |
--------------------------------------------------------------------------------
/src/components/icons/MenuIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function MenuIcon() {
4 | return (
5 |
21 | );
22 | }
23 |
24 | export default MenuIcon;
25 |
--------------------------------------------------------------------------------
/src/components/icons/NoIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function NoIcon() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
16 |
20 |
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | export default NoIcon;
31 |
--------------------------------------------------------------------------------
/src/components/icons/PlusIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function PlusIcon() {
4 | return (
5 |
17 | );
18 | }
19 |
20 | export default PlusIcon;
21 |
--------------------------------------------------------------------------------
/src/components/icons/QuestionViewsIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const QuestionViewsIcon = () => {
4 | return (
5 |
13 |
14 |
15 |
16 | );
17 | };
18 |
19 | export default QuestionViewsIcon;
20 |
--------------------------------------------------------------------------------
/src/components/icons/RestoreIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const RestoreIcon = () => {
4 | return (
5 |
6 |
7 |
11 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default RestoreIcon;
26 |
--------------------------------------------------------------------------------
/src/components/icons/SaveIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function SaveIcon() {
4 | return (
5 |
6 |
13 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | export default SaveIcon;
26 |
--------------------------------------------------------------------------------
/src/components/icons/SearchIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function SearchIcon() {
4 | return (
5 |
21 | );
22 | }
23 |
24 | export default SearchIcon;
25 |
--------------------------------------------------------------------------------
/src/components/icons/SpinnerIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | const StyledLoading = styled.div`
5 | display: inline-block;
6 | margin-right: 8px;
7 | position: relative;
8 | width: 14px;
9 | height: 16px;
10 | & > div {
11 | box-sizing: border-box;
12 | display: block;
13 | position: absolute;
14 | width: 14px;
15 | height: 14px;
16 | border: 2px solid #e6a23c;
17 | border-radius: 50%;
18 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
19 | border-color: #e6a23c transparent transparent transparent;
20 | }
21 | & > div:nth-child(1) {
22 | animation-delay: -0.45s;
23 | }
24 | & > div:nth-child(2) {
25 | animation-delay: -0.3s;
26 | }
27 | & > div:nth-child(3) {
28 | animation-delay: -0.15s;
29 | }
30 | @keyframes lds-ring {
31 | 0% {
32 | transform: rotate(0deg);
33 | }
34 | 100% {
35 | transform: rotate(360deg);
36 | }
37 | }
38 | `;
39 |
40 | function SpinnerIcon() {
41 | return (
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | export default SpinnerIcon;
52 |
--------------------------------------------------------------------------------
/src/components/icons/SuccessIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function SuccessIcon() {
4 | return (
5 |
26 | );
27 | }
28 |
29 | export default SuccessIcon;
30 |
--------------------------------------------------------------------------------
/src/components/icons/WarningIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function WarningIcon() {
4 | return (
5 |
6 |
13 |
17 |
24 |
31 |
32 |
33 | );
34 | }
35 |
36 | export default WarningIcon;
37 |
--------------------------------------------------------------------------------
/src/components/icons/YesIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function YesIcon() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
16 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | export default YesIcon;
27 |
--------------------------------------------------------------------------------
/src/components/layout/Footer.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import styled from 'styled-components';
3 | import { Typography } from 'antd';
4 | import LogoNoColorIcon from 'components/icons/LogoNoColorIcon';
5 |
6 | const StyledFooter = styled.div`
7 | display: flex;
8 | align-items: end;
9 | justify-content: space-between;
10 | position: absolute;
11 | right: 0;
12 | left: 0;
13 | bottom: 0;
14 |
15 | .footer_text {
16 | font-size: 14px;
17 | color: #828282;
18 | }
19 |
20 | .footer_link {
21 | color: #4f4f4f;
22 | font-size: 16px;
23 | }
24 | `;
25 |
26 | export const Footer = () => {
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | Interview Questions And Answers Application
39 | Intocode, 2016-2022
40 |
41 |
42 |
50 |
51 |
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/src/components/layout/Paper/Paper.test.jsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { Paper } from 'components/layout/Paper';
3 | import '@testing-library/jest-dom';
4 |
5 | const children = 'lorem ipsum';
6 |
7 | const PaperTest = (props) => ;
8 |
9 | describe('Paper', () => {
10 | it('should render the Paper', () => {
11 | const component = render({children} );
12 | const { tagName } = component.container;
13 | expect(tagName.toLowerCase()).toEqual('div');
14 | });
15 |
16 | it('should render the Paper component', () => {
17 | render({children} );
18 | expect(screen.getByText('lorem ipsum')).toBeInTheDocument();
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/components/layout/Paper/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import PropTypes from 'prop-types';
4 |
5 | const StyledPaper = styled.div`
6 | background-color: #fff;
7 | padding: 15px;
8 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
9 | border-radius: 4px;
10 | `;
11 |
12 | export const Paper = ({ children, ...props }) => {children} ;
13 |
14 | Paper.propTypes = {
15 | children: PropTypes.node.isRequired,
16 | };
17 |
--------------------------------------------------------------------------------
/src/components/layout/ScrollToTop.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import styled from 'styled-components';
3 | import { CSSTransition } from 'react-transition-group';
4 | import { SCROLL_TO_TOP_SHOW } from 'app/constants';
5 | import ChevronUpIcon from 'components/icons/ChevronUpIcon';
6 | import 'assets/animation-back-to-top.css';
7 |
8 | const StyledScroll = styled.div`
9 | display: inline-block;
10 | width: 45px;
11 | height: 45px;
12 | background-color: ${(props) => props.theme.colors.primary.main};
13 | border-radius: 100%;
14 | padding: 8px;
15 | cursor: pointer;
16 | position: fixed;
17 | bottom: 60px;
18 | right: 20px;
19 | box-shadow: 0 0 10px ${(props) => props.theme.colors.primary.main};
20 | `;
21 |
22 | export const ScrollToTop = () => {
23 | const [windowScroll, setWindowScroll] = useState(window.pageYOffset);
24 | const [showBackToTop, setShowBackToTop] = useState(false);
25 |
26 | const scrollToTop = () => {
27 | window.scroll({ top: 0, left: 0, behavior: 'smooth' });
28 | };
29 |
30 | useEffect(() => {
31 | const handleScroll = () => {
32 | setWindowScroll(window.pageYOffset);
33 | };
34 |
35 | window.addEventListener('scroll', handleScroll);
36 |
37 | return () => window.removeEventListener('scroll', handleScroll);
38 | }, []);
39 |
40 | useEffect(() => {
41 | if (windowScroll > SCROLL_TO_TOP_SHOW) {
42 | setShowBackToTop(true);
43 | } else setShowBackToTop(false);
44 | }, [windowScroll]);
45 |
46 | return (
47 |
48 |
49 |
50 |
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/src/components/layout/header/AdaptiveMenu.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useMemo } from 'react';
2 | import { Link, useLocation } from 'react-router-dom';
3 | import PropTypes from 'prop-types';
4 | import styled from 'styled-components';
5 | import { Button } from 'antd';
6 | import { useAuth } from 'common/context/Auth/useAuth';
7 | import { useSelector } from 'react-redux';
8 | import { selectProfile } from 'features/profile/profileSlice';
9 |
10 | const StyledMenu = styled.ul`
11 | list-style: none;
12 | position: absolute;
13 | width: 75%;
14 | min-height: 100vh;
15 | top: 80px;
16 | background-color: #f8f9fa;
17 | box-shadow: 200px -4px 0px -5px rgba(0, 0, 0, 0.31);
18 | z-index: 1;
19 |
20 | li {
21 | padding: 15px 0;
22 | }
23 | `;
24 |
25 | const StyledAvatar = styled.div`
26 | & > img {
27 | width: 50px;
28 | height: 50px;
29 | border-radius: 50%;
30 | cursor: pointer;
31 | }
32 | `;
33 |
34 | const Line = styled.hr`
35 | margin-top: 10px;
36 | width: 90%;
37 | border: 0;
38 | height: 1px;
39 | background: #333;
40 | background-image: linear-gradient(to right, #ccc, #333, #ccc);
41 | `;
42 |
43 | const AdaptiveMenu = ({ toggleMobileMenu, mobileMenu }) => {
44 | const location = useLocation();
45 | const { token, executeLoggingInProcess, logout } = useAuth();
46 | const profile = useSelector(selectProfile);
47 |
48 | useEffect(() => {
49 | if (mobileMenu) {
50 | toggleMobileMenu();
51 | }
52 | }, [location.key]); //eslint-disable-line
53 |
54 | const menuItems = useMemo(() => {
55 | return [
56 | {
57 | id: 1,
58 | protected: true,
59 | jsx: (
60 | <>
61 |
62 |
63 |
64 |
65 |
66 |
Профиль @{profile.name}
67 |
68 |
69 |
70 | >
71 | ),
72 | },
73 | {
74 | id: 2,
75 | protected: true,
76 | jsx: (
77 |
78 | Избранные
79 |
80 | ),
81 | },
82 | {
83 | id: 3,
84 | protected: true,
85 | jsx: Корзина,
86 | },
87 | {
88 | id: 4,
89 | protected: true,
90 | jsx: (
91 |
92 | Выйти
93 |
94 | ),
95 | },
96 | {
97 | id: 5,
98 | guest: true,
99 | jsx: (
100 |
101 | Login with GitHub
102 |
103 | ),
104 | },
105 | ].filter((item) => {
106 | if (item.protected) {
107 | return !!token;
108 | }
109 |
110 | if (item.guest) {
111 | return !token;
112 | }
113 |
114 | return true;
115 | });
116 | }, [executeLoggingInProcess, logout, profile, token]);
117 |
118 | if (!mobileMenu) return null;
119 |
120 | return (
121 |
122 | {menuItems.map((item) => (
123 |
124 | {item.jsx}
125 |
126 | ))}
127 |
128 | );
129 | };
130 |
131 | AdaptiveMenu.propTypes = {
132 | toggleMobileMenu: PropTypes.func.isRequired,
133 | mobileMenu: PropTypes.bool.isRequired,
134 | };
135 |
136 | export default AdaptiveMenu;
137 |
--------------------------------------------------------------------------------
/src/components/layout/header/AnimatedSearch.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { SwitchTransition, CSSTransition } from 'react-transition-group';
3 | import styled from 'styled-components';
4 | import SearchIcon from 'components/icons/SearchIcon';
5 | import Search from './Search';
6 |
7 | const StyledAnimatedSearch = styled.div`
8 | display: flex;
9 | align-items: center;
10 |
11 | .search-icon {
12 | width: 18px;
13 | height: 18px;
14 | display: flex;
15 | align-items: center;
16 | cursor: pointer;
17 | margin-left: 10px;
18 | }
19 |
20 | .search-form {
21 | transform: translateX(5%);
22 | transition: 0.2s;
23 | margin-right: 20px;
24 | }
25 |
26 | .fade-enter {
27 | opacity: 0;
28 | }
29 |
30 | .fade-enter-active {
31 | opacity: 1;
32 | transform: translateX(-10%);
33 | transition: opacity 100ms, transform 10ms;
34 | }
35 |
36 | .fade-exit {
37 | opacity: 1;
38 | }
39 |
40 | .fade-exit-active {
41 | opacity: 0;
42 | transition: opacity 100ms, transform 10ms;
43 | }
44 |
45 | @media (max-width: 586px) {
46 | display: none;
47 | }
48 | `;
49 |
50 | const AnimatedSearch = () => {
51 | const [openSearch, setOpenSearch] = useState(true);
52 |
53 | return (
54 |
55 |
56 | node.addEventListener('transitionend', done, false)}
59 | classNames="fade"
60 | >
61 | {openSearch ? (
62 | setOpenSearch(false)}>
63 |
64 |
65 | ) : (
66 | setOpenSearch(true)}>
67 |
68 |
69 | )}
70 |
71 |
72 |
73 | );
74 | };
75 |
76 | export default AnimatedSearch;
77 |
--------------------------------------------------------------------------------
/src/components/layout/header/Header.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import { useEffect, useState } from 'react';
3 | import styled from 'styled-components';
4 | import { useSelector } from 'react-redux';
5 | import { Button, Popover } from 'antd';
6 | import { selectProfile } from 'features/profile/profileSlice';
7 | import { useAuth } from 'common/context/Auth/useAuth';
8 | import ArrowAvatar from 'components/icons/ArrowAvatar';
9 | import CloseMenuIcon from 'components/icons/CloseMenuIcon';
10 | import MenuIcon from 'components/icons/MenuIcon';
11 | import HelpIcon from 'components/icons/HelpIcon';
12 | import AdaptiveMenu from './AdaptiveMenu';
13 | import Search from './Search';
14 | import { Logo } from './Logo';
15 | import PopoverContent from './PopoverContent';
16 |
17 | const StyledHeader = styled.div`
18 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
19 | background-color: white;
20 | min-height: 80px;
21 | display: flex;
22 | align-items: center;
23 |
24 | .header_link {
25 | text-decoration: none;
26 | }
27 |
28 | .menu-icon {
29 | position: absolute;
30 | }
31 |
32 | @media ${({ theme }) => theme.media.phone} {
33 | .container {
34 | padding: 24px 20px;
35 | }
36 | }
37 | `;
38 |
39 | const StyledAvatar = styled.div`
40 | display: flex;
41 | align-items: center;
42 |
43 | & > img {
44 | width: 36px;
45 | height: 36px;
46 | border-radius: 50%;
47 | cursor: pointer;
48 | }
49 |
50 | .downArrow,
51 | .upArrow {
52 | color: ${({ theme }) => theme.colors.gray.main};
53 | cursor: pointer;
54 | margin-left: 5px;
55 | transform: rotate(180deg);
56 | }
57 | .upArrow {
58 | transform: rotate(0deg);
59 | }
60 | `;
61 |
62 | export const Header = () => {
63 | // todo: рефакторить мобильную версию. Возможно нужен вынос в хук или в контекст
64 |
65 | const { token, executeLoggingInProcess } = useAuth();
66 |
67 | const [mobileMenu, setMobileMenu] = useState(false);
68 |
69 | const [openMenuProfile, setOpenMenuProfile] = useState(false);
70 |
71 | // todo: зачем это нужно?
72 | const [windowWidth, setWindowWidth] = useState(window.innerWidth);
73 |
74 | const profile = useSelector(selectProfile);
75 |
76 | useEffect(() => {
77 | const handleResize = () => {
78 | setWindowWidth(window.innerWidth);
79 | };
80 |
81 | window.addEventListener('resize', handleResize);
82 |
83 | return () => {
84 | window.removeEventListener('resize', handleResize);
85 | };
86 | }, []);
87 |
88 | const handleToggleMenu = () => {
89 | if (!mobileMenu) {
90 | setMobileMenu(!mobileMenu);
91 | document.body.style.overflowY = 'clip';
92 | } else {
93 | setMobileMenu(!mobileMenu);
94 | document.body.style.overflowY = 'visible';
95 | }
96 | };
97 | const handleOpenMenuProfile = (isOpen) => {
98 | setOpenMenuProfile(isOpen);
99 | };
100 |
101 | // todo: рефакторить
102 | const iconMenuAndClose = !mobileMenu ? : ;
103 |
104 | const { REACT_APP_FEATURE_ADD_QUESTION } = process.env;
105 |
106 | return (
107 |
108 |
109 |
110 |
111 |
112 |
113 | {iconMenuAndClose}
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 | {token && (
125 |
126 | {REACT_APP_FEATURE_ADD_QUESTION && (
127 |
128 | Добавить вопрос
129 |
130 | )}
131 |
132 | )}
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 | {token ? (
141 |
142 |
148 |
149 |
150 |
153 |
154 |
155 |
156 | ) : (
157 |
158 |
159 | Login with GitHub
160 |
161 |
162 | )}
163 |
164 |
165 |
166 |
167 | );
168 | };
169 |
--------------------------------------------------------------------------------
/src/components/layout/header/Logo.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import LogoIcon from 'components/icons/LogoIcon';
3 | import LogoNoColorIcon from 'components/icons/LogoNoColorIcon';
4 | import LongLogoIcon from 'components/icons/LongLogoIcon';
5 |
6 | export const Logo = ({ noColor, long }) => {
7 | let logo = ;
8 |
9 | if (noColor) {
10 | logo = ;
11 | }
12 | if (long) {
13 | logo = ;
14 | }
15 |
16 | return logo;
17 | };
18 |
19 | Logo.propTypes = {
20 | noColor: PropTypes.bool,
21 | long: PropTypes.bool,
22 | };
23 |
24 | Logo.defaultProps = {
25 | noColor: false,
26 | long: false,
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/layout/header/PopoverContent.jsx:
--------------------------------------------------------------------------------
1 | import { Divider, Typography } from 'antd';
2 | import React from 'react';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import styled from 'styled-components';
5 | import { Link } from 'react-router-dom';
6 | import { useAuth } from 'common/context/Auth/useAuth';
7 | import { resetProfile, selectProfile } from 'features/profile/profileSlice';
8 | import { LinkToDeleted } from './header-menu/LinkToDeleted';
9 | import { LinkToFavorites } from './header-menu/LinkToFavorites';
10 | import LinkToProfilePage from './header-menu/LinkToProfilePage';
11 |
12 | const StyledMenuProfile = styled.div`
13 | line-height: 1.7;
14 | margin: 10px 0;
15 | `;
16 |
17 | const StyledMenuList = styled.ul`
18 | list-style: none;
19 | padding: 0;
20 | `;
21 |
22 | const PopoverContent = () => {
23 | const { logout } = useAuth();
24 |
25 | const dispatch = useDispatch();
26 |
27 | const profile = useSelector(selectProfile);
28 |
29 | const handleClick = () => {
30 | dispatch(resetProfile());
31 | logout();
32 | };
33 |
34 | return (
35 |
36 |
@{profile.name}
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
59 | Выйти
60 |
61 |
62 |
63 | );
64 | };
65 |
66 | export default PopoverContent;
67 |
--------------------------------------------------------------------------------
/src/components/layout/header/Search.jsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useEffect, useState } from 'react';
2 | import styled from 'styled-components';
3 | import { useSelector, useDispatch } from 'react-redux';
4 | import { Link, useLocation } from 'react-router-dom';
5 | import { Input } from 'antd';
6 | import { selectQuestionsSearch, fetchQuestionsSearch } from 'features/search/searchQuestionSlice';
7 | import SearchIcon from 'components/icons/SearchIcon';
8 |
9 | const StyledSearch = styled.div`
10 | width: 300px;
11 | margin-left: 20px;
12 | position: relative;
13 |
14 | .questions {
15 | margin-top: 5px;
16 | position: absolute;
17 | width: 100%;
18 | border-radius: 3px;
19 | background-color: #ffffff;
20 | box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
21 | z-index: 100;
22 | }
23 |
24 | input {
25 | border-color: #909399;
26 | height: 40px;
27 | }
28 |
29 | input::placeholder {
30 | font-size: 14px;
31 | }
32 |
33 | .question {
34 | border-bottom: 1px solid #9fa0a1;
35 | padding: 15px;
36 | }
37 |
38 | .question:hover {
39 | background-color: #ecf5ff;
40 | }
41 |
42 | .question-tittle {
43 | color: #000;
44 | font-weight: bold;
45 | }
46 | .question-text {
47 | color: #6c757d;
48 | font-size: 14px;
49 | }
50 |
51 | .search-icon {
52 | position: absolute;
53 | top: 11px;
54 | right: 7px;
55 | }
56 | `;
57 |
58 | const Search = () => {
59 | const location = useLocation();
60 | const dispatch = useDispatch();
61 | const search = useSelector(selectQuestionsSearch);
62 | const [question, setQuestion] = useState('');
63 | const [examination, setExamination] = useState(false);
64 |
65 | const ref = useRef(null);
66 |
67 | const handleSearch = (e) => {
68 | setQuestion(e.target.value);
69 | };
70 |
71 | useEffect(() => {
72 | if (question) {
73 | dispatch(fetchQuestionsSearch(question));
74 | setExamination(true);
75 | }
76 | }, [question, dispatch]);
77 |
78 | useEffect(() => {
79 | setExamination(false);
80 | setQuestion('');
81 | }, [location.key]);
82 |
83 | const { REACT_APP_FEATURE_SEARCH } = process.env;
84 |
85 | return (
86 |
87 | {REACT_APP_FEATURE_SEARCH && (
88 | <>
89 | handleSearch(e)}
91 | value={question}
92 | placeholder="Поиск..."
93 | ref={ref}
94 | />
95 |
96 |
97 | >
98 | )}
99 | {examination && (
100 |
101 | {search.map((item) => (
102 |
103 |
104 | {item.question}
105 |
106 |
{item.comment.substr(0, 30)}...
107 |
108 | ))}
109 |
110 | )}
111 |
112 | );
113 | };
114 |
115 | export default Search;
116 |
--------------------------------------------------------------------------------
/src/components/layout/header/header-menu/HeaderMenu.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import styled from 'styled-components';
3 | import { Typography } from 'antd';
4 |
5 | const StyledUl = styled.ul`
6 | list-style: none;
7 | padding: 0;
8 |
9 | li {
10 | margin-left: 2rem;
11 | padding: 0;
12 | display: inline-block;
13 | }
14 | `;
15 |
16 | export const HeaderMenu = () => {
17 | return (
18 |
19 |
20 |
21 |
22 | Главная
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/src/components/layout/header/header-menu/LinkToDeleted.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import { Typography } from 'antd';
3 | import { useAuth } from 'common/context/Auth/useAuth';
4 |
5 | export const LinkToDeleted = () => {
6 | const { token } = useAuth();
7 |
8 | const { REACT_APP_FEATURE_DELETE_QUESTION } = process.env;
9 |
10 | if (!REACT_APP_FEATURE_DELETE_QUESTION || !token) return null;
11 |
12 | return (
13 |
14 | Корзина
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/src/components/layout/header/header-menu/LinkToFavorites.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import { Typography } from 'antd';
3 | import { useAuth } from 'common/context/Auth/useAuth';
4 |
5 | export const LinkToFavorites = () => {
6 | const { token } = useAuth();
7 |
8 | const { REACT_APP_FEATURE_FAVORITES } = process.env;
9 |
10 | if (!REACT_APP_FEATURE_FAVORITES || !token) return null;
11 |
12 | return (
13 |
14 | Сохраненные
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/src/components/layout/header/header-menu/LinkToProfilePage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { Typography } from 'antd';
4 |
5 | const LinkToProfilePage = () => {
6 | return (
7 |
8 | Профиль
9 |
10 | );
11 | };
12 |
13 | export default LinkToProfilePage;
14 |
--------------------------------------------------------------------------------
/src/features/application/applicationSlice.js:
--------------------------------------------------------------------------------
1 | import { createSelector, createSlice } from '@reduxjs/toolkit';
2 |
3 | const applicationSlice = createSlice({
4 | name: 'application',
5 | initialState: {
6 | isCompactMode: false,
7 | },
8 | reducers: {
9 | toggleIsCompactMode: (state) => {
10 | state.isCompactMode = !state.isCompactMode;
11 | },
12 | },
13 | });
14 | const selectIsCompactModeState = (state) => state.application;
15 |
16 | export const selectIsCompactModeToogle = createSelector(
17 | selectIsCompactModeState,
18 | (state) => state.isCompactMode
19 | );
20 |
21 | export const { toggleIsCompactMode } = applicationSlice.actions;
22 |
23 | export default applicationSlice.reducer;
24 |
--------------------------------------------------------------------------------
/src/features/comments/AddComment.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from 'react';
2 | import { Editor } from '@toast-ui/react-editor';
3 | import { useParams } from 'react-router-dom';
4 | import { useDispatch, useSelector } from 'react-redux';
5 | import styled from 'styled-components';
6 | import { Button } from 'antd';
7 | import { selectProfile } from 'features/profile/profileSlice';
8 | import { addComment, selectCommentsAdding } from './commentsSlice';
9 |
10 | const StyledAvatar = styled.img`
11 | width: 48px;
12 | border-radius: 50%;
13 | `;
14 |
15 | const AddComment = () => {
16 | const dispatch = useDispatch();
17 |
18 | const { id } = useParams();
19 | const editorRef = useRef();
20 |
21 | const profile = useSelector(selectProfile);
22 | const commentAdding = useSelector(selectCommentsAdding);
23 |
24 | const [text, setText] = useState('');
25 |
26 | const handleChange = () => {
27 | const instance = editorRef.current.getInstance();
28 | setText(instance.getMarkdown());
29 | };
30 |
31 | const handleAddComment = () => {
32 | dispatch(addComment({ text, id })).then(() => {
33 | setText('');
34 | editorRef.current.getInstance().reset();
35 | });
36 | };
37 |
38 | const toolbarItems = [
39 | ['heading', 'bold', 'italic', 'strike'],
40 | ['hr', 'quote', 'code', 'codeblock'],
41 | ];
42 |
43 | return (
44 |
45 |
46 |
47 |
48 |
49 |
50 |
64 |
70 | Опубликовать
71 |
72 |
73 |
74 |
75 | );
76 | };
77 |
78 | export default AddComment;
79 |
--------------------------------------------------------------------------------
/src/features/comments/CommentItem.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { useSelector } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { commentsSelectors } from './commentsSlice';
5 | import { CommentView } from './CommentView';
6 |
7 | const StyledCommentBlock = styled.div`
8 | &:hover .delete {
9 | opacity: 1;
10 | }
11 | `;
12 |
13 | export const CommentItem = ({ commentId }) => {
14 | const comment = useSelector((state) => commentsSelectors.selectById(state, commentId));
15 | return (
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | CommentItem.propTypes = {
23 | commentId: PropTypes.string.isRequired,
24 | };
25 |
--------------------------------------------------------------------------------
/src/features/comments/CommentView.jsx:
--------------------------------------------------------------------------------
1 | import { Viewer } from '@toast-ui/react-editor';
2 | import { useAuth } from 'common/context/Auth/useAuth';
3 | import PropTypes from 'prop-types';
4 | import styled from 'styled-components';
5 | import React, { useState } from 'react';
6 | import dayjs from 'dayjs';
7 | import { HeartFilled, HeartOutlined } from '@ant-design/icons';
8 | import { useDispatch, useSelector } from 'react-redux';
9 | import { selectProfile } from 'features/profile/profileSlice';
10 | import FavoritePopoverContent from 'components/FavoritePopoverContent';
11 | import { Popover } from 'antd';
12 | import { truncateLongText } from 'common/utils/truncateLongText';
13 | import { CommentsActions } from './comment-actions/CommentsActions';
14 | import { likeCommentById, unlikeCommentById } from './commentsSlice';
15 |
16 | const StyledWrapper = styled.div`
17 | background-color: #f5f5f5;
18 | padding: 15px;
19 | border-radius: 4px;
20 | margin-bottom: 25px;
21 | & > div {
22 | margin-bottom: 0 !important;
23 | }
24 | `;
25 |
26 | const StyledProfile = styled.div`
27 | display: flex;
28 | align-items: center;
29 | & > img {
30 | width: 36px;
31 | height: 36px;
32 | border-radius: 24px;
33 | margin-right: 10px;
34 | }
35 | `;
36 |
37 | const StyledCommentLikes = styled.div`
38 | display: flex;
39 | align-items: center;
40 |
41 | & > button {
42 | border: none;
43 | background: none;
44 | cursor: pointer;
45 | margin: 0;
46 | padding: 0;
47 | }
48 |
49 | & > span {
50 | margin-left: 0.3125rem;
51 | color: #ff4646;
52 | font-weight: 400;
53 | font-size: 0.875rem;
54 | }
55 | `;
56 |
57 | const StyledTime = styled.span`
58 | padding-left: 1rem;
59 | font-size: 12px;
60 | color: ${({ theme }) => theme.colors.gray.main};
61 |
62 | &::before {
63 | content: '•';
64 | padding-right: 1rem;
65 | }
66 | `;
67 |
68 | const StyledCommentActions = styled.div`
69 | display: flex;
70 | align-items: center;
71 | `;
72 |
73 | const StyledPopoverBlock = styled.div`
74 | position: relative;
75 | `;
76 |
77 | const StyledPopoverChildren = styled.div`
78 | position: absolute;
79 | right: 47px;
80 | `;
81 |
82 | export const CommentView = ({ comment, lastComment }) => {
83 | const Wrapper = lastComment ? StyledWrapper : React.Fragment;
84 |
85 | const [isAuthorizePopoverEnable, setIsAuthorizePopoverEnable] = useState(false);
86 |
87 | const dispatch = useDispatch();
88 | const { token } = useAuth();
89 |
90 | const { REACT_APP_FEATURE_LIKE_COMMENT } = process.env;
91 |
92 | const profile = useSelector(selectProfile);
93 | const commentLikes = comment.likes || 0;
94 | const commentId = comment._id;
95 | const userId = profile._id;
96 |
97 | const handleToggleLike = () => {
98 | if (token) {
99 | if (!commentLikes.includes(userId)) {
100 | dispatch(likeCommentById({ commentId, userId }));
101 | } else {
102 | dispatch(unlikeCommentById({ commentId, userId }));
103 | }
104 | } else {
105 | setIsAuthorizePopoverEnable(true);
106 | }
107 | };
108 |
109 | const handleOpenPopover = () => {
110 | setIsAuthorizePopoverEnable(!isAuthorizePopoverEnable);
111 | };
112 |
113 | return (
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | {comment.author?.name}
124 | {dayjs(comment.createdAt).fromNow()}
125 |
126 |
130 | {!lastComment && (
131 |
132 |
133 | {REACT_APP_FEATURE_LIKE_COMMENT && (
134 |
135 |
140 | {commentLikes.includes(userId) ? (
141 |
142 | ) : (
143 |
144 | )}
145 |
146 | {commentLikes.length}
147 |
148 |
149 |
156 | }
157 | />
158 |
159 |
160 |
161 | )}
162 |
163 | )}
164 |
165 |
166 |
167 | );
168 | };
169 |
170 | CommentView.propTypes = {
171 | comment: PropTypes.shape({
172 | author: PropTypes.shape({
173 | avatar: PropTypes.shape({
174 | full: PropTypes.string,
175 | thumbnail: PropTypes.string,
176 | }),
177 | avatarUrl: PropTypes.string,
178 | name: PropTypes.string,
179 | _id: PropTypes.string,
180 | }),
181 | createdAt: PropTypes.string,
182 | questionId: PropTypes.string,
183 | text: PropTypes.string,
184 | likes: PropTypes.arrayOf(PropTypes.string),
185 | updatedAt: PropTypes.string,
186 | _id: PropTypes.string,
187 | }).isRequired,
188 | lastComment: PropTypes.bool,
189 | };
190 |
191 | CommentView.defaultProps = {
192 | lastComment: false,
193 | };
194 |
--------------------------------------------------------------------------------
/src/features/comments/CommentsList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector } from 'react-redux';
3 | import { commentsSelectors } from './commentsSlice';
4 | import { CommentItem } from './CommentItem';
5 |
6 | const CommentsList = () => {
7 | const commentIds = useSelector(commentsSelectors.selectIds);
8 |
9 | return (
10 | <>
11 | {commentIds.map((commentId) => {
12 | return ;
13 | })}
14 | >
15 | );
16 | };
17 |
18 | export default CommentsList;
19 |
--------------------------------------------------------------------------------
/src/features/comments/CommentsOfQuestion.jsx:
--------------------------------------------------------------------------------
1 | import React, { lazy, useEffect, useRef } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { useParams } from 'react-router-dom';
4 | import { useAuth } from 'common/context/Auth/useAuth';
5 | import {
6 | commentsSelectors,
7 | fetchComments,
8 | resetComments,
9 | selectCommentsLoading,
10 | } from './commentsSlice';
11 | import CommentsList from './CommentsList';
12 | import { CommentsPlaceholder } from './CommentsPlaceholder';
13 |
14 | const AddComment = lazy(() => import('./AddComment'));
15 |
16 | const CommentsOfQuestion = () => {
17 | const dispatch = useDispatch();
18 |
19 | const { token } = useAuth();
20 | const { id } = useParams();
21 |
22 | const commentIds = useSelector(commentsSelectors.selectIds);
23 | const fetching = useSelector(selectCommentsLoading);
24 |
25 | const ref = useRef(null);
26 |
27 | // todo refactor
28 | const { hash } = window.location;
29 | if (hash !== '') {
30 | if (ref.current) ref.current.scrollIntoView();
31 | }
32 |
33 | useEffect(() => {
34 | dispatch(fetchComments(id));
35 |
36 | return () => {
37 | dispatch(resetComments());
38 | };
39 | }, [dispatch, id]);
40 |
41 | return (
42 |
43 |
44 | {commentIds.length ? `Комментарии (${commentIds.length})` : 'Нет комментариев'}
45 |
46 |
47 | {token &&
}
48 |
49 | {fetching ?
:
}
50 |
51 | );
52 | };
53 |
54 | export default CommentsOfQuestion;
55 |
--------------------------------------------------------------------------------
/src/features/comments/CommentsPlaceholder.jsx:
--------------------------------------------------------------------------------
1 | import 'assets/bootstrap-placeholder.css';
2 | import { Paper } from 'components/layout/Paper';
3 |
4 | const Placeholder = () => {
5 | return (
6 |
7 |
10 |
14 |
15 | );
16 | };
17 |
18 | export const CommentsPlaceholder = () => {
19 | const placeholders = new Array(4).fill(null);
20 | return (
21 | <>
22 | {placeholders.map((_, idx) => (
23 | // eslint-disable-next-line react/no-array-index-key
24 |
25 | ))}
26 | >
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/src/features/comments/comment-actions/CommentsActions.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { DeleteAction } from './DeleteAction';
4 |
5 | export const CommentsActions = ({ commentId }) => {
6 | return (
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | CommentsActions.propTypes = {
14 | commentId: PropTypes.string.isRequired,
15 | };
16 |
--------------------------------------------------------------------------------
/src/features/comments/comment-actions/DeleteAction.jsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { useAuth } from 'common/context/Auth/useAuth';
4 | import { selectProfile } from 'features/profile/profileSlice';
5 | import PropTypes from 'prop-types';
6 | import styled from 'styled-components';
7 | import DeleteIcon from 'components/icons/DeleteIcon';
8 | import SpinnerIcon from 'components/icons/SpinnerIcon';
9 | import {
10 | commentsSelectors,
11 | removeCommentById,
12 | selectCommentDeliting,
13 | } from 'features/comments/commentsSlice';
14 |
15 | const StyledDelete = styled.div`
16 | cursor: pointer;
17 | height: 17px;
18 | margin-right: 13px;
19 | `;
20 |
21 | export const DeleteAction = ({ commentId }) => {
22 | const { token } = useAuth();
23 | const dispatch = useDispatch();
24 |
25 | const { REACT_APP_FEATURE_DELETE_COMMENT } = process.env;
26 |
27 | const profile = useSelector(selectProfile);
28 | const deletingComments = useSelector(selectCommentDeliting);
29 | const comment = useSelector((state) => commentsSelectors.selectById(state, commentId));
30 |
31 | const handleToggleDelete = () => {
32 | dispatch(removeCommentById({ questionId: comment.questionId, commentId }));
33 | };
34 |
35 | const isDeliting = useMemo(
36 | () => deletingComments.find((id) => id.commentId === comment._id),
37 | [deletingComments, comment]
38 | );
39 |
40 | if (!REACT_APP_FEATURE_DELETE_COMMENT || !token || !profile.isAdmin) return null;
41 |
42 | return (
43 |
44 |
45 | {isDeliting ? : }
46 |
47 |
48 | );
49 | };
50 |
51 | DeleteAction.propTypes = {
52 | commentId: PropTypes.string.isRequired,
53 | };
54 |
--------------------------------------------------------------------------------
/src/features/comments/commentsSlice.js:
--------------------------------------------------------------------------------
1 | import {
2 | createAsyncThunk,
3 | createEntityAdapter,
4 | createSelector,
5 | createSlice,
6 | } from '@reduxjs/toolkit';
7 | import axios from 'axios';
8 |
9 | export const fetchComments = createAsyncThunk('comments/fetch', async (questionId, thunkAPI) => {
10 | try {
11 | const response = await axios.get(`/questions/${questionId}/comments`);
12 |
13 | return response.data.items;
14 | } catch (e) {
15 | return thunkAPI.rejectWithValue(e.message);
16 | }
17 | });
18 |
19 | export const addComment = createAsyncThunk('comments/add', async ({ id, text }, thunkAPI) => {
20 | try {
21 | const response = await axios.post(`/questions/${id}/comments`, { text });
22 |
23 | return response.data;
24 | } catch (error) {
25 | return thunkAPI.rejectWithValue(error.response.data);
26 | }
27 | });
28 |
29 | export const removeCommentById = createAsyncThunk(
30 | 'comments/remove/byId',
31 | async ({ questionId, commentId }, thunkAPI) => {
32 | try {
33 | await axios.delete(`/questions/${questionId}/comments/${commentId}`);
34 |
35 | return { comment: commentId };
36 | } catch (e) {
37 | return thunkAPI.rejectWithValue(e.message);
38 | }
39 | }
40 | );
41 |
42 | export const likeCommentById = createAsyncThunk(
43 | 'likes/add',
44 | async ({ commentId, userId }, thunkAPI) => {
45 | try {
46 | const response = await axios.post(`/comments/${commentId}/like`, userId);
47 |
48 | return response.data;
49 | } catch (error) {
50 | return thunkAPI.rejectWithValue(error.response.data);
51 | }
52 | }
53 | );
54 |
55 | export const unlikeCommentById = createAsyncThunk(
56 | 'likes/remove',
57 | async ({ commentId, userId }, thunkAPI) => {
58 | try {
59 | const response = await axios.delete(`/comments/${commentId}/like`, userId);
60 |
61 | return response.data;
62 | } catch (error) {
63 | return thunkAPI.rejectWithValue(error.response.data);
64 | }
65 | }
66 | );
67 |
68 | const commentsAdapter = createEntityAdapter({
69 | selectId: (entity) => entity._id,
70 | });
71 |
72 | const initialState = commentsAdapter.getInitialState({
73 | fetching: false,
74 | adding: false,
75 | deletingCommentIds: [],
76 | likedCommentsIds: [],
77 | });
78 |
79 | const commentsSlice = createSlice({
80 | name: 'comments',
81 | initialState,
82 |
83 | reducers: {
84 | resetComments: (state) => {
85 | commentsAdapter.removeAll(state);
86 | },
87 | },
88 |
89 | extraReducers: {
90 | [fetchComments.pending]: (state) => {
91 | state.fetching = true;
92 | },
93 |
94 | [fetchComments.fulfilled]: (state, action) => {
95 | commentsAdapter.upsertMany(state, action.payload);
96 | state.fetching = false;
97 | },
98 |
99 | [addComment.pending]: (state) => {
100 | state.adding = true;
101 | },
102 |
103 | [addComment.fulfilled]: (state, action) => {
104 | commentsAdapter.addOne(state, action.payload);
105 |
106 | state.adding = false;
107 | },
108 |
109 | [removeCommentById.pending]: (state, action) => {
110 | state.deletingCommentIds.push(action.meta.arg);
111 | },
112 |
113 | [removeCommentById.fulfilled]: (state, action) => {
114 | commentsAdapter.removeOne(state, action.meta.arg.commentId);
115 | },
116 |
117 | [likeCommentById.pending]: (state, action) => {
118 | const { commentId, userId } = action.meta.arg;
119 |
120 | state.likedCommentsIds.push(commentId);
121 |
122 | // stop preloader
123 | state.likedCommentsIds = state.likedCommentsIds.filter((id) => id !== commentId);
124 |
125 | const { selectById } = commentsAdapter.getSelectors();
126 |
127 | commentsAdapter.updateOne(state, {
128 | id: commentId,
129 | changes: { likes: [...selectById(state, commentId).likes, userId] },
130 | });
131 | },
132 |
133 | [unlikeCommentById.pending]: (state, action) => {
134 | const { commentId, userId } = action.meta.arg;
135 |
136 | const { selectById } = commentsAdapter.getSelectors();
137 |
138 | commentsAdapter.updateOne(state, {
139 | id: commentId,
140 | changes: {
141 | likes: selectById(state, commentId).likes.filter((id) => id !== userId),
142 | },
143 | });
144 | },
145 | },
146 | });
147 |
148 | const selectCommentsState = (state) => state.comments;
149 |
150 | export const commentsSelectors = commentsAdapter.getSelectors(selectCommentsState);
151 |
152 | export const selectComments = createSelector(selectCommentsState, (state) => state.comments);
153 |
154 | export const selectCommentsAdding = createSelector(selectCommentsState, (state) => state.adding);
155 |
156 | export const selectCommentsError = createSelector(selectCommentsState, (state) => state.error);
157 |
158 | export const selectCommentsLoading = createSelector(selectCommentsState, (state) => state.loading);
159 |
160 | export const selectCommentDeliting = createSelector(
161 | selectCommentsState,
162 | (state) => state.deletingCommentIds
163 | );
164 |
165 | export const selectCommentLiked = createSelector(
166 | selectCommentsState,
167 | (state) => state.likedCommentsIds
168 | );
169 |
170 | export const selectCommentsSuccess = createSelector(selectCommentsState, (state) => state.success);
171 |
172 | export const { resetComments } = commentsSlice.actions;
173 |
174 | export default commentsSlice.reducer;
175 |
--------------------------------------------------------------------------------
/src/features/profile/ProfileUser.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import dayjs from 'dayjs';
4 | import { useSelector } from 'react-redux';
5 | import { Paper } from 'components/layout/Paper';
6 | import { theme } from 'app/theme';
7 | import { selectProfile } from './profileSlice';
8 |
9 | const ProfileUser = () => {
10 | const profile = useSelector(selectProfile);
11 |
12 | const StyledAvatar = styled.div`
13 | & > img {
14 | width: 200px;
15 | height: 200px;
16 | border-radius: 50%;
17 | cursor: pointer;
18 | }
19 | @media screen and (max-width: 576px) {
20 | & > img {
21 | margin: auto;
22 | border-radius: 50%;
23 | }
24 | }
25 | `;
26 |
27 | const StyledPageUser = styled.div`
28 | .pageUser {
29 | margin-left: 0px;
30 | }
31 | .registration-date {
32 | color: ${theme.colors.gray.main};
33 | font-size: 12px;
34 | }
35 | .nameUser {
36 | font-size: 22px;
37 | }
38 | .userName {
39 | font-size: 20px;
40 | font-weight: 400;
41 | margin-top: 20px;
42 | }
43 | .userEmail {
44 | margin-top: 20px;
45 | font-size: 20px;
46 | }
47 | .userData {
48 | margin: 20px 0 26px 20px;
49 | }
50 | @media screen and (min-width: 576px) {
51 | .pageUser {
52 | margin-left: -20px;
53 | }
54 | }
55 | `;
56 |
57 | return (
58 |
59 |
60 |
61 |
62 |
Профиль @{profile.name}
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | Зарегистрирован {dayjs(profile.createdAt).fromNow()}
72 |
73 |
74 | {profile.fullName ? profile.fullName : `User ${profile._id}`}
75 |
76 |
{profile.email}
77 |
78 |
79 |
80 |
81 |
82 |
83 | );
84 | };
85 |
86 | export default ProfileUser;
87 |
--------------------------------------------------------------------------------
/src/features/profile/UserFullnameForm.jsx:
--------------------------------------------------------------------------------
1 | import { Button, Input, Modal } from 'antd';
2 | import React, { useState } from 'react';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { selectProfile, updateProfile } from 'features/profile/profileSlice';
5 | import styled from 'styled-components';
6 | import { useAuth } from 'common/context/Auth/useAuth';
7 |
8 | const StyledModalHeading = styled.h2`
9 | text-align: center;
10 | `;
11 | const StyledButtonBlock = styled.div`
12 | width: 110px;
13 | margin: auto;
14 | `;
15 |
16 | const UserFullnameForm = () => {
17 | const profile = useSelector(selectProfile);
18 |
19 | const { token } = useAuth();
20 |
21 | const dispatch = useDispatch();
22 |
23 | const [userFullName, setUserFullName] = useState('');
24 | const [userEmail, setUserEmail] = useState('');
25 |
26 | const id = profile._id;
27 |
28 | const handleSubmit = () => {
29 | dispatch(updateProfile({ id, userFullName, userEmail }));
30 | };
31 |
32 | const isModalOpened = !profile.fullName || !profile.email;
33 |
34 | if (!token || profile.loading) return null;
35 |
36 | return (
37 |
38 |
65 |
66 | );
67 | };
68 |
69 | export default UserFullnameForm;
70 |
--------------------------------------------------------------------------------
/src/features/profile/profileSlice.js:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, createSlice, createSelector } from '@reduxjs/toolkit';
2 | import axios from 'axios';
3 | import {
4 | addQuestionToFavorites,
5 | deleteQuestionFromFavorites,
6 | } from 'features/questions/questionsSlice';
7 |
8 | export const fetchProfile = createAsyncThunk('profile/fetch', async (_, thunkAPI) => {
9 | try {
10 | const response = await axios.get('/user/profile');
11 |
12 | return response.data;
13 | } catch (e) {
14 | return thunkAPI.rejectWithValue(e.message);
15 | }
16 | });
17 |
18 | export const updateProfile = createAsyncThunk(
19 | 'profile/patch',
20 | async ({ id, userFullName, userEmail }, thunkAPI) => {
21 | try {
22 | const response = await axios.patch(`/user/profile/${id}`, {
23 | fullName: userFullName,
24 | email: userEmail,
25 | });
26 | return response.data;
27 | } catch (e) {
28 | return thunkAPI.rejectWithValue(e.message);
29 | }
30 | }
31 | );
32 |
33 | const profileSlice = createSlice({
34 | name: 'profile',
35 | initialState: {
36 | loading: false,
37 | _id: null,
38 | name: null,
39 | fullName: null,
40 | avatar: {},
41 | questionIdsThatUserFavorite: [],
42 | isAdmin: false,
43 | createdAt: null,
44 | email: null,
45 | // добавить остальные поля по мере необходимости
46 | },
47 |
48 | extraReducers: {
49 | [fetchProfile.pending]: (state) => {
50 | state.loading = true;
51 | },
52 |
53 | [fetchProfile.fulfilled]: (state, { payload }) => {
54 | state.loading = false;
55 | state._id = payload._id;
56 | state.name = payload.name;
57 | state.fullName = payload.fullName;
58 | state.avatar = payload.avatar;
59 | state.questionIdsThatUserFavorite = payload.questionIdsThatUserFavorite;
60 | state.isAdmin = payload.isAdmin;
61 | state.createdAt = payload.createdAt;
62 | state.email = payload.email;
63 | },
64 |
65 | [addQuestionToFavorites.pending]: (state, action) => {
66 | state.questionIdsThatUserFavorite.push(action.meta.arg.questionId);
67 | },
68 |
69 | [deleteQuestionFromFavorites.pending]: (state, action) => {
70 | state.questionIdsThatUserFavorite = state.questionIdsThatUserFavorite.filter(
71 | (id) => id !== action.meta.arg.questionId
72 | );
73 | },
74 |
75 | [updateProfile.fulfilled]: (state, action) => {
76 | state.fullName = action.payload.fullName;
77 | state.email = action.payload.email;
78 | },
79 | },
80 | reducers: {
81 | resetProfile: (state) => {
82 | state.loading = false;
83 | state._id = null;
84 | state.name = null;
85 | state.fullName = null;
86 | state.avatar = {};
87 | state.questionIdsThatUserFavorite = [];
88 | state.isAdmin = false;
89 | state.createdAt = null;
90 | },
91 | },
92 | });
93 |
94 | const selectProfileState = (state) => state.profile;
95 |
96 | export const { resetProfile } = profileSlice.actions;
97 |
98 | export const selectProfile = createSelector(selectProfileState, (state) => state);
99 |
100 | export const selectProfileLoading = createSelector(selectProfileState, (state) => state.loading);
101 |
102 | export const selectQuestionIdsThatUserFavorite = createSelector(
103 | selectProfile,
104 | (state) => state.questionIdsThatUserFavorite
105 | );
106 |
107 | export default profileSlice.reducer;
108 |
--------------------------------------------------------------------------------
/src/features/questions/create-question/CreateQuestionPage.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback, useEffect, useRef } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { Link, Redirect, useHistory } from 'react-router-dom';
4 | import styled from 'styled-components';
5 | import { Editor } from '@toast-ui/react-editor';
6 | import { PlusOutlined } from '@ant-design/icons';
7 | import { Button, Tag, Input, Alert, Typography, notification } from 'antd';
8 | import { Title } from 'app/Title/Title';
9 | import { Paper } from 'components/layout/Paper';
10 | import { addQuestion } from 'features/questions/questionsSlice';
11 | import { selectProfile } from 'features/profile/profileSlice';
12 | import { useAuth } from 'common/context/Auth/useAuth';
13 | import { MAX_NUMBER_OF_TAGS, TAG_MAX_LENGTH } from 'app/constants';
14 | import PropTypes from 'prop-types';
15 |
16 | const StyledQuestionWrapper = styled.div`
17 | & .new-tag {
18 | display: flex;
19 | align-items: center;
20 | background: #f8f9fa;
21 | border: 1px solid #e4e7ed;
22 | border-radius: 4px;
23 | max-width: 85px;
24 | font-weight: 500;
25 | padding: 6px 8px;
26 | color: #606266;
27 | font-size: 12px;
28 | line-height: 14px;
29 | cursor: pointer;
30 |
31 | & svg {
32 | margin-right: 11px;
33 | }
34 |
35 | & span {
36 | white-space: nowrap;
37 | padding: 0;
38 | }
39 |
40 | & input {
41 | font-size: 12px;
42 | line-height: 14px;
43 | padding: 0;
44 | max-width: 45px;
45 | border: none;
46 | background-color: transparent;
47 |
48 | &:focus {
49 | outline: none;
50 | }
51 | }
52 | }
53 | `;
54 |
55 | const StyledAvatar = styled.div`
56 | img {
57 | width: 36px;
58 | height: 36px;
59 | border-radius: 1.5rem;
60 | margin-right: 0.5rem;
61 | }
62 | `;
63 |
64 | const StyledInputBlock = styled.div`
65 | width: 77px;
66 | margin-top: 5px;
67 | `;
68 |
69 | const StyledTagBlock = styled.div`
70 | height: fit-content;
71 | margin-top: 5px;
72 | `;
73 |
74 | const CancelButtonWrapper = styled.span`
75 | cursor: pointer;
76 | `;
77 |
78 | const CancelLinkLabel = ({ navigate, href, children }) => {
79 | return (
80 |
81 | navigate(href)}>
82 | {children}
83 |
84 |
85 | );
86 | };
87 |
88 | CancelLinkLabel.propTypes = {
89 | navigate: PropTypes.func.isRequired,
90 | href: PropTypes.string.isRequired,
91 | children: PropTypes.node.isRequired,
92 | };
93 |
94 | const CreateQuestion = () => {
95 | const dispatch = useDispatch();
96 | const history = useHistory();
97 | const editorRef = useRef();
98 |
99 | const profile = useSelector(selectProfile);
100 |
101 | const { token } = useAuth();
102 |
103 | const [question, setQuestion] = useState('');
104 | const [fullDescription, setFullDescription] = useState('');
105 | const [tagValue, setTagValue] = useState('');
106 |
107 | const [tags, setTags] = useState([]);
108 |
109 | const [tagEditMode, setTagEditMode] = useState(false);
110 | const [tooManyQuestions, setTooManyQuestions] = useState(false);
111 |
112 | const addTag = () => {
113 | const tagRegex = /^(?!^[.-])[a-z0-9]+(?:[-.][a-z0-9]+)*$/i;
114 | if (tagRegex.test(tagValue) && !tags.includes(tagValue)) {
115 | setTags([...tags, tagValue]);
116 | setTagValue('');
117 | setTagEditMode(false);
118 | }
119 | };
120 |
121 | const removeTag = (tag) => {
122 | setTags(tags.filter((t) => t !== tag));
123 | };
124 |
125 | const callbackRef = useCallback((event) => {
126 | if (event) {
127 | event.focus();
128 | }
129 | }, []);
130 |
131 | const handleKeyPress = (event) => {
132 | if (event.key === 'Enter') {
133 | addTag(tagValue);
134 | }
135 | };
136 |
137 | const handleCreate = () => {
138 | dispatch(
139 | addQuestion({
140 | question,
141 | fullDescription,
142 | tags,
143 | userId: profile._id,
144 | })
145 | )
146 | .unwrap()
147 | .then(() => {
148 | notification.success({
149 | message: 'Вопрос успешно опубликован',
150 | });
151 | history.push('/');
152 | setQuestion('');
153 | setFullDescription('');
154 | editorRef.current.getInstance().reset();
155 | });
156 | };
157 |
158 | const handleChange = () => {
159 | const instance = editorRef.current.getInstance();
160 | setFullDescription(instance.getMarkdown());
161 | };
162 |
163 | const handleChangeTag = (e) => {
164 | const limitedValue = e.target.value.substring(0, TAG_MAX_LENGTH);
165 | setTagValue(limitedValue);
166 | };
167 |
168 | useEffect(() => {
169 | if (/\?[^?]+\?/.test(question)) {
170 | setTooManyQuestions(true);
171 | } else {
172 | setTooManyQuestions(false);
173 | }
174 | }, [question]);
175 |
176 | // если не авторизован, то кидаем на главную
177 | if (!token) return ;
178 |
179 | const { REACT_APP_FEATURE_TAGS } = process.env;
180 |
181 | return (
182 | <>
183 | iqa: добавить вопрос
184 |
185 |
186 |
187 |
Добавление вопроса
188 |
189 | Вернуться назад
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
{profile.name}
200 |
201 |
202 | {tooManyQuestions && (
203 |
208 | )}
209 |
210 |
211 | Как звучит вопрос?*
212 |
213 |
214 | setQuestion(e.target.value)}
216 | value={question}
217 | placeholder="Формулировка вопроса..."
218 | autoFocus
219 | />
220 |
221 |
222 |
Дополнительный комментарий
223 |
240 |
241 | {REACT_APP_FEATURE_TAGS && (
242 | <>
243 |
244 | Теги*
245 |
246 |
247 |
248 | {tags.map((tag) => (
249 |
250 | removeTag(tag)}>
251 | {tag}
252 |
253 |
254 | ))}
255 |
256 | {!tagEditMode && tags.length < MAX_NUMBER_OF_TAGS && (
257 |
258 | setTagEditMode(true)}>
259 | New Tag
260 |
261 |
262 | )}
263 | {tagEditMode && (
264 |
265 | setTagEditMode(false)}
273 | />
274 |
275 | )}
276 |
277 | >
278 | )}
279 |
280 |
281 |
282 |
283 |
288 | Опубликовать
289 |
290 |
291 |
292 |
293 | Отмена
294 |
295 |
296 |
297 |
298 |
299 |
300 | >
301 | );
302 | };
303 |
304 | export default CreateQuestion;
305 |
--------------------------------------------------------------------------------
/src/features/questions/question-page/QuestionPage.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { useParams } from 'react-router-dom';
4 | import {
5 | fetchQuestionById,
6 | selectOpenedQuestion,
7 | selectQuestionsFetching,
8 | } from 'features/questions/questionsSlice';
9 | import CommentsOfQuestion from 'features/comments/CommentsOfQuestion';
10 | import { Paper } from 'components/layout/Paper';
11 | import { QuestionPagePlaceholder } from './QuestionPagePlaceholder';
12 | import { QuestionPageHeader } from './QuestionPageHeader';
13 | import { QuestionPageContent } from './QuestionPageContent';
14 |
15 | const { REACT_APP_FEATURE_COMMENTARIES } = process.env;
16 |
17 | const QuestionPage = () => {
18 | const dispatch = useDispatch();
19 |
20 | const { id } = useParams();
21 |
22 | const fetching = useSelector(selectQuestionsFetching);
23 | const question = useSelector(selectOpenedQuestion);
24 |
25 | useEffect(() => dispatch(fetchQuestionById(id)), [dispatch, id]);
26 |
27 | return (
28 |
29 |
30 |
31 |
32 | {fetching || !question ? : }
33 |
34 | {REACT_APP_FEATURE_COMMENTARIES && }
35 |
36 |
37 | );
38 | };
39 |
40 | export default QuestionPage;
41 |
--------------------------------------------------------------------------------
/src/features/questions/question-page/QuestionPageContent.jsx:
--------------------------------------------------------------------------------
1 | import { Viewer } from '@toast-ui/react-editor';
2 | import { Tag, Divider } from 'antd';
3 | import dayjs from 'dayjs';
4 | import { useSelector } from 'react-redux';
5 | import styled from 'styled-components';
6 | import { selectOpenedQuestion } from 'features/questions/questionsSlice';
7 |
8 | const StyledAvatar = styled.div`
9 | display: flex;
10 | align-items: center;
11 |
12 | & > img {
13 | width: 36px;
14 | height: 36px;
15 | margin-right: 10px;
16 | border-radius: 24px;
17 | }
18 |
19 | & > div {
20 | color: #909399;
21 | font-size: 12px;
22 | margin-left: 10px;
23 | }
24 | `;
25 |
26 | const StyledFullDescription = styled.div`
27 | .toastui-editor-contents {
28 | font-size: 16px;
29 | }
30 | `;
31 |
32 | export const QuestionPageContent = () => {
33 | const { REACT_APP_FEATURE_TAGS } = process.env;
34 |
35 | const question = useSelector(selectOpenedQuestion);
36 |
37 | return (
38 | <>
39 |
40 |
41 |
42 | {question.author.name}
43 | добавлено {dayjs(question?.createdAt).fromNow()}
44 |
45 | {REACT_APP_FEATURE_TAGS && (
46 |
47 | {question.tags.map((tag) => (
48 |
49 | {tag}
50 |
51 | ))}
52 |
53 | )}
54 |
55 |
56 | {question.question}
57 |
58 |
59 |
60 |
61 |
62 | >
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/src/features/questions/question-page/QuestionPageHeader.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { Link } from 'react-router-dom';
3 | import { Typography } from 'antd';
4 | import { Title } from 'app/Title/Title';
5 | import { selectOpenedQuestion } from 'features/questions/questionsSlice';
6 |
7 | export const QuestionPageHeader = () => {
8 | const question = useSelector(selectOpenedQuestion);
9 |
10 | return (
11 | <>
12 | {`iqa: ${question?.question}`}
13 |
14 |
15 |
16 |
Обсуждение вопроса
17 |
18 | Вернуться назад
19 |
20 |
21 |
22 | >
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/src/features/questions/question-page/QuestionPagePlaceholder.jsx:
--------------------------------------------------------------------------------
1 | import { Tag } from 'antd';
2 | import 'assets/bootstrap-placeholder.css';
3 | import { Paper } from 'components/layout/Paper';
4 |
5 | export const QuestionPagePlaceholder = () => {
6 | return (
7 |
8 |
14 |
18 |
24 |
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/QuestionBlock.jsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import PropTypes from 'prop-types';
3 | import React from 'react';
4 | import { useSelector } from 'react-redux';
5 | import { Divider } from 'antd';
6 | import { Paper } from 'components/layout/Paper';
7 | import { selectIsCompactModeToogle } from 'features/application/applicationSlice';
8 | import { questionSelectors } from 'features/questions/questionsSlice';
9 | import { CommentView } from 'features/comments/CommentView';
10 | import { QuestionHeader } from './QuestionHeader';
11 | import { QuestionContent } from './QuestionContent';
12 | import { QuestionsActions } from './question-actions/QuestionsActions';
13 |
14 | const StyledQuestionBlock = styled.div`
15 | opacity: ${(props) => (props.deleted ? 0.3 : 1)};
16 | transition: all 0.5s;
17 | `;
18 |
19 | export const QuestionBlock = ({ questionId }) => {
20 | const isCompactMode = useSelector(selectIsCompactModeToogle);
21 | const question = useSelector((state) => questionSelectors.selectById(state, questionId));
22 |
23 | const QuestionWrapper = isCompactMode ? React.Fragment : Paper;
24 |
25 | return (
26 |
27 |
28 | {isCompactMode || }
29 |
30 |
31 |
32 | {!isCompactMode && question.lastComment && (
33 |
34 | )}
35 |
36 |
37 | {isCompactMode && }
38 |
39 |
40 | );
41 | };
42 |
43 | QuestionBlock.propTypes = {
44 | questionId: PropTypes.string.isRequired,
45 | };
46 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/QuestionContent.jsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Link } from 'react-router-dom';
3 | import React from 'react';
4 | import PropTypes from 'prop-types';
5 | import { useSelector } from 'react-redux';
6 | import { Typography } from 'antd';
7 | import { selectIsCompactModeToogle } from 'features/application/applicationSlice';
8 | import { questionSelectors } from 'features/questions/questionsSlice';
9 |
10 | const StyledLink = styled(Link)`
11 | text-decoration: none;
12 | color: #000 !important;
13 | `;
14 |
15 | export const QuestionContent = ({ questionId }) => {
16 | const isCompactMode = useSelector(selectIsCompactModeToogle);
17 |
18 | const question = useSelector((state) => questionSelectors.selectById(state, questionId));
19 | return (
20 |
21 | {isCompactMode ? (
22 |
23 | {question.question}
24 |
25 | ) : (
26 |
27 | {question.question}
28 |
29 | )}
30 |
31 | );
32 | };
33 |
34 | QuestionContent.propTypes = {
35 | questionId: PropTypes.string.isRequired,
36 | };
37 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/QuestionEmptyFolder.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import EmptyFolderIcon from 'components/icons/EmptyFolderIcon';
4 | import { theme } from 'app/theme';
5 |
6 | const StaledWrapper = styled.div`
7 | margin-top: 40px;
8 | .noEntry {
9 | color: ${theme.colors.gray.main};
10 | font-size: 22px;
11 | font-weight: 400;
12 | }
13 | .inner {
14 | text-align: center;
15 | margin: auto;
16 | }
17 | `;
18 |
19 | const QuestionEmptyFolder = () => {
20 | return (
21 |
22 |
23 |
24 |
В данном разделе нет записей
25 |
26 |
27 | );
28 | };
29 |
30 | export default QuestionEmptyFolder;
31 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/QuestionHeader.jsx:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 | import styled from 'styled-components';
3 | import PropTypes from 'prop-types';
4 | import React from 'react';
5 | import { Tag, Typography } from 'antd';
6 | import { useSelector } from 'react-redux';
7 | import { questionSelectors } from 'features/questions/questionsSlice';
8 |
9 | const StyledTag = styled.div`
10 | display: flex;
11 | & > div {
12 | margin-right: 10px;
13 | }
14 | & > div:last-child {
15 | margin-right: 0;
16 | }
17 | `;
18 |
19 | const StyledHeader = styled.div`
20 | & img.avatar {
21 | width: 36px;
22 | border-radius: 50%;
23 | }
24 | `;
25 |
26 | export const QuestionHeader = ({ questionId }) => {
27 | const question = useSelector((state) => questionSelectors.selectById(state, questionId));
28 | const { REACT_APP_FEATURE_TAGS } = process.env;
29 |
30 | const { Text } = Typography;
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
{question.author.name}
39 |
добавлен {dayjs(question.createdAt).fromNow()}
40 |
41 |
42 |
43 | {REACT_APP_FEATURE_TAGS && question.tags.map((tag) => {tag} )}
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | QuestionHeader.propTypes = {
51 | questionId: PropTypes.string.isRequired,
52 | };
53 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/QuestionsList.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useCallback } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { Button, Spin, Switch } from 'antd';
4 | import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
5 | import styled from 'styled-components';
6 | import { Title } from 'app/Title/Title';
7 | import {
8 | fetchQuestions,
9 | fetchNextPartOfQuestions,
10 | selectQuestionsFetching,
11 | resetQuestionsList,
12 | questionSelectors,
13 | } from 'features/questions/questionsSlice';
14 | import { Paper } from 'components/layout/Paper';
15 | import {
16 | selectIsCompactModeToogle,
17 | toggleIsCompactMode,
18 | } from 'features/application/applicationSlice';
19 | import { useOnScroll } from 'common/hooks/useOnScroll';
20 | import { generateTitle } from 'common/utils/title';
21 | import { useQueryString } from 'common/hooks/useQueryString';
22 | import { Link } from 'react-router-dom';
23 | import QuestionsListMapper from './QuestionsListMapper';
24 | import QuestionEmptyFolder from './QuestionEmptyFolder';
25 |
26 | const StyledSwitchBlock = styled.span`
27 | display: none;
28 |
29 | @media screen and (min-width: 576px) {
30 | color: #409eff;
31 | cursor: pointer;
32 | display: inline;
33 | }
34 | `;
35 |
36 | const QuestionsList = () => {
37 | const dispatch = useDispatch();
38 |
39 | const queryString = useQueryString();
40 | const favoritesOnly = queryString.get('favoritesOnly');
41 | const deletedOnly = queryString.get('deletedOnly');
42 |
43 | const fetching = useSelector(selectQuestionsFetching);
44 | const isCompactMode = useSelector(selectIsCompactModeToogle);
45 |
46 | const questionsIds = useSelector(questionSelectors.selectIds);
47 |
48 | const scrollHandler = useCallback(
49 | (e) => {
50 | // граница, по мере придвижения к которой делается скролл
51 | const scrollBorder = 100;
52 |
53 | const { documentElement } = e.target;
54 | const position =
55 | documentElement.scrollHeight - (documentElement.scrollTop + window.innerHeight);
56 |
57 | if (position < scrollBorder) {
58 | dispatch(fetchNextPartOfQuestions({ favoritesOnly, deletedOnly }));
59 | }
60 | },
61 | [deletedOnly, dispatch, favoritesOnly]
62 | );
63 |
64 | useOnScroll(scrollHandler);
65 |
66 | useEffect(() => {
67 | /**
68 | * Если переключились между категорией вывода вопросов, то нужно сбросить
69 | * отступы из пагинации, чтобы загрузка началась с нуля и очистить массив вопросов.
70 | * Поэтому в зависимостях переменные, на которые нужно триггериться
71 | */
72 | dispatch(resetQuestionsList());
73 | }, [deletedOnly, dispatch, favoritesOnly]);
74 |
75 | useEffect(() => {
76 | dispatch(fetchQuestions({ favoritesOnly, deletedOnly }));
77 | }, [deletedOnly, dispatch, favoritesOnly]);
78 |
79 | const QuestionWrapper = isCompactMode ? Paper : React.Fragment;
80 | const generatedTitle = generateTitle(deletedOnly, favoritesOnly);
81 |
82 | const antIcon = (
83 |
89 | );
90 |
91 | const handleClickSwitch = () => {
92 | dispatch(toggleIsCompactMode());
93 | };
94 |
95 | return (
96 | <>
97 | {`iqa: ${generatedTitle}`}
98 |
99 |
100 |
101 |
{generatedTitle}
102 |
103 | } />
104 |
105 |
106 |
107 |
108 |
114 | Компактный вид
115 |
116 |
117 |
118 | {!questionsIds.length && !fetching ? (
119 |
120 | ) : (
121 |
122 |
123 |
124 | )}
125 | {fetching && (
126 |
127 |
128 |
129 | )}
130 |
131 | >
132 | );
133 | };
134 |
135 | export default QuestionsList;
136 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/QuestionsListMapper.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { questionSelectors } from 'features/questions/questionsSlice';
3 | import { QuestionBlock } from './QuestionBlock';
4 |
5 | const QuestionsListMapper = () => {
6 | const questionsIds = useSelector(questionSelectors.selectIds);
7 |
8 | return questionsIds.map((id) => );
9 | };
10 |
11 | export default QuestionsListMapper;
12 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/QuestionsListPlaceholder.jsx:
--------------------------------------------------------------------------------
1 | import { Tag } from 'antd';
2 | import 'assets/bootstrap-placeholder.css';
3 | import { Paper } from 'components/layout/Paper';
4 |
5 | const Placeholder = () => {
6 | return (
7 |
8 |
14 |
18 |
22 |
23 | );
24 | };
25 |
26 | export const QuestionsListPlaceholder = () => {
27 | const placeholders = new Array(4).fill(null);
28 | return (
29 | <>
30 | {placeholders.map((_, idx) => (
31 | // eslint-disable-next-line react/no-array-index-key
32 |
33 | ))}
34 | >
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/question-actions/CommentsAction.jsx:
--------------------------------------------------------------------------------
1 | import { useHistory } from 'react-router-dom';
2 | import PropTypes from 'prop-types';
3 | import { useSelector } from 'react-redux';
4 | import CommentsIcon from 'components/icons/CommentsIcon';
5 | import { questionSelectors } from 'features/questions/questionsSlice';
6 | import { theme } from 'app/theme';
7 | import { TheQuestionAction } from './TheQuestionAction';
8 |
9 | export const CommentsAction = ({ questionId }) => {
10 | const { REACT_APP_FEATURE_COMMENTARIES } = process.env;
11 |
12 | const history = useHistory();
13 |
14 | const question = useSelector((state) => questionSelectors.selectById(state, questionId));
15 |
16 | const handleOpenComments = () => {
17 | history.push(`/question/${question._id}#scroll`);
18 | };
19 |
20 | if (!REACT_APP_FEATURE_COMMENTARIES) return null;
21 |
22 | return (
23 | }
25 | onClick={handleOpenComments}
26 | color={theme.colors.primary.main}
27 | >
28 | {question.commentsCount > 0 ? question.commentsCount : 'Обсуждение'}
29 |
30 | );
31 | };
32 |
33 | CommentsAction.propTypes = {
34 | questionId: PropTypes.string.isRequired,
35 | };
36 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/question-actions/DeleteAction.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { useMemo } from 'react';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import styled from 'styled-components';
5 | import { theme } from 'app/theme';
6 | import { useAuth } from 'common/context/Auth/useAuth';
7 | import DeleteIcon from 'components/icons/DeleteIcon';
8 | import { selectProfile } from 'features/profile/profileSlice';
9 | import SpinnerIcon from 'components/icons/SpinnerIcon';
10 | import {
11 | questionSelectors,
12 | removeQuestionById,
13 | selectDeletingQuestions,
14 | } from 'features/questions/questionsSlice';
15 | import { TheQuestionAction } from './TheQuestionAction';
16 |
17 | const StyledDelete = styled.div`
18 | color: ${(props) => (props.deleted ? theme.colors.primary.main : props.theme.colors.danger.main)};
19 | font-weight: 400;
20 | font-size: 14px;
21 | cursor: pointer;
22 | `;
23 |
24 | export const DeleteAction = ({ questionId }) => {
25 | const { token } = useAuth();
26 | const dispatch = useDispatch();
27 |
28 | const { REACT_APP_FEATURE_DELETE_QUESTION } = process.env;
29 |
30 | const profile = useSelector(selectProfile);
31 | const deletingQuestions = useSelector(selectDeletingQuestions);
32 |
33 | const question = useSelector((state) => questionSelectors.selectById(state, questionId));
34 |
35 | const handleDelete = () => {
36 | dispatch(removeQuestionById(question._id));
37 | };
38 |
39 | const isDeliting = useMemo(
40 | () => deletingQuestions.find((id) => id === question._id),
41 | [deletingQuestions, question]
42 | );
43 |
44 | if (!REACT_APP_FEATURE_DELETE_QUESTION || !token || !profile.isAdmin || question.deleted) {
45 | return null;
46 | }
47 |
48 | const DeletingIcon = (
49 | {isDeliting ? : }
50 | );
51 |
52 | return (
53 |
58 | );
59 | };
60 |
61 | DeleteAction.propTypes = {
62 | questionId: PropTypes.string.isRequired,
63 | };
64 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/question-actions/FavoriteAction.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { theme } from 'app/theme';
5 | import { useAuth } from 'common/context/Auth/useAuth';
6 | import { selectProfile } from 'features/profile/profileSlice';
7 | import {
8 | addQuestionToFavorites,
9 | deleteQuestionFromFavorites,
10 | questionSelectors,
11 | } from 'features/questions/questionsSlice';
12 | import FavoriteIconSwitcher from './FavoriteIconSwitcher';
13 | import { TheQuestionAction } from './TheQuestionAction';
14 | import { FavoritePopover } from './FavoritePopover';
15 |
16 | export const FavoriteAction = ({ questionId }) => {
17 | const { token } = useAuth();
18 | const dispatch = useDispatch();
19 |
20 | const { REACT_APP_FEATURE_FAVORITES } = process.env;
21 |
22 | const profile = useSelector(selectProfile);
23 |
24 | const question = useSelector((state) => questionSelectors.selectById(state, questionId));
25 |
26 | const handleToggleFavorite = () => {
27 | if (token) {
28 | if (question.usersThatFavoriteIt.includes(profile._id)) {
29 | dispatch(
30 | deleteQuestionFromFavorites({
31 | questionId: question._id,
32 | userId: profile._id,
33 | })
34 | );
35 | } else {
36 | dispatch(
37 | addQuestionToFavorites({
38 | questionId: question._id,
39 | userId: profile._id,
40 | })
41 | );
42 | }
43 | } else {
44 | // todo сделать окно запроса авторизации
45 | }
46 | };
47 |
48 | if (!REACT_APP_FEATURE_FAVORITES) return null;
49 |
50 | return (
51 |
52 | }
54 | onClick={handleToggleFavorite}
55 | color={theme.colors.danger.main}
56 | >
57 | {question.usersThatFavoriteIt.length}
58 |
59 |
60 | );
61 | };
62 |
63 | FavoriteAction.propTypes = {
64 | questionId: PropTypes.string.isRequired,
65 | };
66 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/question-actions/FavoriteIconSwitcher.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { useSelector } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { selectProfile } from 'features/profile/profileSlice';
5 | import { questionSelectors } from 'features/questions/questionsSlice';
6 | import favoritesIcon from 'assets/sprite.svg';
7 |
8 | const FavoritesIconAnimation = styled.div`
9 | cursor: pointer;
10 | height: 49px;
11 | width: 49px;
12 | margin: -20px -15px;
13 | background-image: url(${favoritesIcon});
14 | background-position: right;
15 | background-size: 2900%;
16 | animation: star-burst 0.4s steps(27) 1;
17 |
18 | @keyframes star-burst {
19 | from {
20 | background-position: left;
21 | }
22 | to {
23 | background-position: right;
24 | }
25 | }
26 | `;
27 | const StyledFavoritesIcon = styled.div`
28 | cursor: pointer;
29 | height: 49px;
30 | width: 49px;
31 | margin: -20px -15px;
32 | background-image: url(${favoritesIcon});
33 | background-position: left;
34 | background-size: 2700%;
35 | `;
36 |
37 | const FavoriteIconSwitcher = ({ questionId }) => {
38 | const profile = useSelector(selectProfile);
39 |
40 | const question = useSelector((state) => questionSelectors.selectById(state, questionId));
41 |
42 | if (question.usersThatFavoriteIt.includes(profile._id)) {
43 | return ;
44 | }
45 |
46 | return ;
47 | };
48 |
49 | FavoriteIconSwitcher.propTypes = {
50 | questionId: PropTypes.PropTypes.string.isRequired,
51 | };
52 |
53 | export default FavoriteIconSwitcher;
54 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/question-actions/FavoritePopover.jsx:
--------------------------------------------------------------------------------
1 | import { Popover } from 'antd';
2 | import { useAuth } from 'common/context/Auth/useAuth';
3 | import PropTypes from 'prop-types';
4 | import FavoritePopoverContent from 'components/FavoritePopoverContent';
5 |
6 | export const FavoritePopover = ({ children }) => {
7 | const { token } = useAuth();
8 |
9 | if (!token) {
10 | return (
11 | }
15 | >
16 | {children}
17 |
18 | );
19 | }
20 |
21 | return children;
22 | };
23 |
24 | FavoritePopover.propTypes = {
25 | children: PropTypes.node.isRequired,
26 | };
27 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/question-actions/QuestionViews.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector } from 'react-redux';
3 | import PropTypes from 'prop-types';
4 | import QuestionViewsIcon from 'components/icons/QuestionViewsIcon';
5 | import { questionSelectors } from 'features/questions/questionsSlice';
6 | import { theme } from 'app/theme';
7 | import { TheQuestionAction } from './TheQuestionAction';
8 |
9 | const QuestionViews = ({ questionId }) => {
10 | const question = useSelector((state) => questionSelectors.selectById(state, questionId));
11 |
12 | return (
13 | } color={theme.colors.gray.main}>
14 | {question.views}
15 |
16 | );
17 | };
18 |
19 | QuestionViews.propTypes = {
20 | questionId: PropTypes.string.isRequired,
21 | };
22 |
23 | export default QuestionViews;
24 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/question-actions/QuestionsActions.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 |
5 | import { FavoriteAction } from './FavoriteAction';
6 | import { CommentsAction } from './CommentsAction';
7 | import { DeleteAction } from './DeleteAction';
8 | import QuestionViews from './QuestionViews';
9 | import { RestoreAction } from './RestoreAction';
10 |
11 | const QuestionsActionsWrapper = styled.div`
12 | margin-top: -10px;
13 | & div[role='button'] {
14 | cursor: pointer;
15 | }
16 | `;
17 |
18 | export const QuestionsActions = ({ questionId }) => {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | QuestionsActions.propTypes = {
33 | questionId: PropTypes.string.isRequired,
34 | };
35 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/question-actions/RestoreAction.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { useMemo } from 'react';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import styled from 'styled-components';
5 | import { theme } from 'app/theme';
6 | import { useAuth } from 'common/context/Auth/useAuth';
7 | import RestoreIcon from 'components/icons/RestoreIcon';
8 | import SpinnerIcon from 'components/icons/SpinnerIcon';
9 | import { selectProfile } from 'features/profile/profileSlice';
10 | import {
11 | questionSelectors,
12 | restoreQuestionById,
13 | selectRestoringQuestions,
14 | } from 'features/questions/questionsSlice';
15 | import { TheQuestionAction } from './TheQuestionAction';
16 |
17 | const StyledDelete = styled.div`
18 | color: ${(props) => (props.deleted ? theme.colors.primary.main : props.theme.colors.danger.main)};
19 | font-weight: 400;
20 | font-size: 14px;
21 | cursor: pointer;
22 | `;
23 |
24 | export const RestoreAction = ({ questionId }) => {
25 | const { token } = useAuth();
26 | const dispatch = useDispatch();
27 |
28 | const { REACT_APP_FEATURE_DELETE_QUESTION } = process.env;
29 |
30 | const profile = useSelector(selectProfile);
31 | const restoringQuestions = useSelector(selectRestoringQuestions);
32 |
33 | const question = useSelector((state) => questionSelectors.selectById(state, questionId));
34 |
35 | const handleRestore = () => {
36 | dispatch(restoreQuestionById(question._id));
37 | };
38 |
39 | const isRestoring = useMemo(
40 | () => restoringQuestions.find((id) => id === question._id),
41 | [restoringQuestions, question]
42 | );
43 |
44 | if (!REACT_APP_FEATURE_DELETE_QUESTION || !token || !profile.isAdmin || !question.deleted) {
45 | return null;
46 | }
47 |
48 | const DeletingIcon = (
49 |
50 | {isRestoring ? : }
51 |
52 | );
53 |
54 | return (
55 |
60 | );
61 | };
62 |
63 | RestoreAction.propTypes = {
64 | questionId: PropTypes.string.isRequired,
65 | };
66 |
--------------------------------------------------------------------------------
/src/features/questions/questions-list/question-actions/TheQuestionAction.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import styled from 'styled-components';
3 |
4 | const StyledCaption = styled.div`
5 | color: ${(props) => props.color};
6 | margin-left: 3px;
7 | `;
8 |
9 | export const TheQuestionAction = ({ icon, children, onClick, color }) => {
10 | return (
11 |
12 |
13 | {icon}
14 | {children}
15 |
16 |
17 | );
18 | };
19 |
20 | TheQuestionAction.propTypes = {
21 | icon: PropTypes.node.isRequired,
22 | children: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
23 | onClick: PropTypes.func,
24 | color: PropTypes.string,
25 | };
26 |
27 | TheQuestionAction.defaultProps = {
28 | color: 'inherit',
29 | onClick: () => {},
30 | children: '',
31 | };
32 |
--------------------------------------------------------------------------------
/src/features/questions/questionsSlice.js:
--------------------------------------------------------------------------------
1 | import {
2 | createAction,
3 | createAsyncThunk,
4 | createEntityAdapter,
5 | createSelector,
6 | createSlice,
7 | } from '@reduxjs/toolkit';
8 | import axios from 'axios';
9 | import { QUESTIONS_PER_PAGE } from 'app/constants';
10 |
11 | const incrementPaginationOffset = createAction('questions/pagination/next');
12 | export const resetQuestionsList = createAction('questions/reset');
13 |
14 | export const fetchQuestions = createAsyncThunk(
15 | 'questions/fetch',
16 | async ({ deletedOnly, favoritesOnly }, thunkAPI) => {
17 | const { questions } = thunkAPI.getState();
18 | const { pagination } = questions;
19 |
20 | let queryString = `limit=${pagination.limit}&offset=${pagination.offset}`;
21 |
22 | if (deletedOnly) {
23 | queryString += '&deletedOnly=true';
24 | }
25 |
26 | if (favoritesOnly) {
27 | queryString += '&favoritesOnly=true';
28 | }
29 |
30 | try {
31 | const response = await axios.get(`/questions?${queryString}`);
32 |
33 | return response.data;
34 | } catch (e) {
35 | return thunkAPI.rejectWithValue(e.message);
36 | }
37 | },
38 | {
39 | condition: (_, { getState }) => {
40 | const { questions } = getState();
41 |
42 | if (questions.fetching) {
43 | // уже идет загрузка, отменяем санк..
44 | return false;
45 | }
46 |
47 | return true;
48 | },
49 | }
50 | );
51 |
52 | export const fetchNextPartOfQuestions = createAsyncThunk(
53 | 'questions/fetch/nextPart',
54 | async ({ deletedOnly, favoritesOnly }, { dispatch }) => {
55 | dispatch(incrementPaginationOffset());
56 | dispatch(fetchQuestions({ favoritesOnly, deletedOnly }));
57 | },
58 | {
59 | condition: (_, { getState }) => {
60 | const { questions } = getState();
61 |
62 | // отменяем санк если уже идет загрузка
63 | if (questions.fetching) {
64 | return false;
65 | }
66 |
67 | // отменяем санк, если данных для загрузки больше нет
68 |
69 | const { limit, offset, totalQuestions } = questions.pagination;
70 |
71 | const nextOffset = limit + offset;
72 |
73 | if (nextOffset >= totalQuestions) {
74 | return false;
75 | }
76 |
77 | return true;
78 | },
79 | }
80 | );
81 |
82 | export const fetchQuestionById = createAsyncThunk('questions/fetch/byId', async (id, thunkAPI) => {
83 | try {
84 | const response = await axios.get(`/questions/${id}`);
85 |
86 | return response.data;
87 | } catch (e) {
88 | return thunkAPI.rejectWithValue(e.message);
89 | }
90 | });
91 |
92 | // todo: добработать, исправить
93 | export const addQuestion = createAsyncThunk('add', async (data, thunkAPI) => {
94 | try {
95 | const response = await axios.post('/questions', data);
96 |
97 | return response.data;
98 | } catch (error) {
99 | return thunkAPI.rejectWithValue(error.response.data);
100 | }
101 | });
102 |
103 | export const removeQuestionById = createAsyncThunk(
104 | 'questions/remove/byId',
105 | async (id, thunkAPI) => {
106 | try {
107 | await axios.delete(`/questions/${id}`);
108 |
109 | return { questionId: id };
110 | } catch (e) {
111 | return thunkAPI.rejectWithValue(e.message);
112 | }
113 | }
114 | );
115 |
116 | export const restoreQuestionById = createAsyncThunk(
117 | 'questions/restore/byId',
118 | async (id, thunkAPI) => {
119 | try {
120 | await axios.patch(`/questions/${id}/restore`);
121 |
122 | return { questionId: id };
123 | } catch (e) {
124 | return thunkAPI.rejectWithValue(e.message);
125 | }
126 | }
127 | );
128 |
129 | export const addQuestionToFavorites = createAsyncThunk(
130 | 'questions/favorites/add',
131 | async ({ questionId, userId }, { rejectWithValue }) => {
132 | try {
133 | await axios.post(`/questions/${questionId}/favorites`);
134 | return { questionId, userId };
135 | } catch (error) {
136 | return rejectWithValue(error.message);
137 | }
138 | }
139 | );
140 |
141 | export const deleteQuestionFromFavorites = createAsyncThunk(
142 | 'questions/favorite/delete',
143 | async ({ questionId, userId }, thunkAPI) => {
144 | try {
145 | await axios.delete(`/questions/${questionId}/favorites`);
146 |
147 | return { questionId, userId };
148 | } catch (error) {
149 | return thunkAPI.rejectWithValue(error.message);
150 | }
151 | }
152 | );
153 |
154 | export const questionsAdapter = createEntityAdapter({
155 | selectId: (entity) => entity._id,
156 | });
157 |
158 | const initialState = questionsAdapter.getInitialState({
159 | pagination: {
160 | limit: QUESTIONS_PER_PAGE,
161 | offset: 0,
162 | totalQuestions: 0,
163 | },
164 | openedQuestion: null,
165 | fetching: false,
166 | error: null,
167 | deletingQuestionIds: [],
168 | restoringQuestionIds: [],
169 | favoritingQuestionIds: [],
170 | });
171 |
172 | const questionsSlice = createSlice({
173 | name: 'questions',
174 | initialState,
175 |
176 | extraReducers: {
177 | [incrementPaginationOffset]: (state) => {
178 | state.pagination.offset += state.pagination.limit;
179 | },
180 |
181 | [resetQuestionsList]: (state) => {
182 | questionsAdapter.removeAll(state);
183 |
184 | state.pagination.offset = 0;
185 | state.pagination.totalQuestions = 0;
186 | },
187 |
188 | [fetchQuestions.pending]: (state) => {
189 | state.fetching = true;
190 | },
191 |
192 | [fetchQuestions.fulfilled]: (state, action) => {
193 | state.fetching = false;
194 | state.pagination.totalQuestions = action.payload.total;
195 | questionsAdapter.upsertMany(state, action.payload.items);
196 | },
197 |
198 | [fetchQuestionById.pending]: (state) => {
199 | state.fetching = true;
200 | },
201 |
202 | [fetchQuestionById.fulfilled]: (state, action) => {
203 | state.fetching = false;
204 | state.openedQuestion = action.payload;
205 | },
206 |
207 | [removeQuestionById.pending]: (state, action) => {
208 | state.deletingQuestionIds.push(action.meta.arg);
209 | },
210 |
211 | [removeQuestionById.fulfilled]: (state, action) => {
212 | // stop preloader
213 | state.deletingQuestionIds = state.deletingQuestionIds.filter(
214 | (id) => id !== action.payload.questionId
215 | );
216 |
217 | questionsAdapter.updateOne(state, {
218 | id: action.payload.questionId,
219 | changes: { deleted: true },
220 | });
221 | },
222 |
223 | [restoreQuestionById.pending]: (state, action) => {
224 | state.restoringQuestionIds.push(action.meta.arg);
225 | },
226 |
227 | [restoreQuestionById.fulfilled]: (state, action) => {
228 | // stop preloader
229 | state.restoringQuestionIds = state.restoringQuestionIds.filter(
230 | (id) => id !== action.payload.questionId
231 | );
232 |
233 | questionsAdapter.updateOne(state, {
234 | id: action.payload.questionId,
235 | changes: { deleted: false },
236 | });
237 | },
238 |
239 | [addQuestion.pending]: (state) => {
240 | state.fetching = true;
241 | },
242 |
243 | [addQuestion.fulfilled]: (state, action) => {
244 | questionsAdapter.addOne(state, action.payload.question);
245 | state.error = null;
246 | state.fetching = false;
247 | },
248 |
249 | [addQuestion.rejected]: (state, action) => {
250 | state.error = JSON.stringify(action.payload.errors);
251 | state.fetching = false;
252 | },
253 |
254 | [addQuestionToFavorites.pending]: (state, action) => {
255 | state.favoritingQuestionIds.push(action.meta.arg.questionId);
256 |
257 | // код ниже обычно бывает в fulfilled, однако он здесь из-за
258 | // эффекта анимации при клике на звезду, она сразу должна становиться выделенной
259 |
260 | // stop preloader
261 | state.favoritingQuestionIds = state.favoritingQuestionIds.filter(
262 | (id) => id !== action.meta.arg.questionId
263 | );
264 |
265 | state.entities[action.meta.arg.questionId].usersThatFavoriteIt.push(action.meta.arg.userId);
266 | },
267 |
268 | [deleteQuestionFromFavorites.pending]: (state, action) => {
269 | state.favoritingQuestionIds.push(action.meta.arg.questionId);
270 |
271 | // читай коммент к кейсу выше
272 |
273 | // stop preloader
274 | state.favoritingQuestionIds = state.favoritingQuestionIds.filter(
275 | (id) => id !== action.meta.arg.questionId
276 | );
277 |
278 | state.entities[action.meta.arg.questionId].usersThatFavoriteIt = state.entities[
279 | action.meta.arg.questionId
280 | ].usersThatFavoriteIt.filter((id) => id !== action.meta.arg.userId);
281 | },
282 | },
283 | });
284 |
285 | const selectQuestionsState = (state) => state.questions;
286 |
287 | export const questionSelectors = questionsAdapter.getSelectors((state) => state.questions);
288 |
289 | // todo refactor
290 | export const selectQuestionById = (id) =>
291 | createSelector([selectQuestionsState], (state) => {
292 | if (state.questions.length !== 0) {
293 | return state.questions.find((question) => question._id === id);
294 | }
295 | return state.openedQuestion;
296 | });
297 |
298 | export const selectQuestionsFetching = createSelector(
299 | selectQuestionsState,
300 | (state) => state.fetching
301 | );
302 |
303 | export const selectAllQuestionsList = createSelector(
304 | selectQuestionsState,
305 | (state) => state.questions
306 | );
307 |
308 | export const selectQuestionsPagination = createSelector(
309 | selectQuestionsState,
310 | (state) => state.pagination
311 | );
312 |
313 | export const selectOpenedQuestion = createSelector(
314 | selectQuestionsState,
315 | (state) => state.openedQuestion
316 | );
317 |
318 | export const selectDeletingQuestions = createSelector(
319 | selectQuestionsState,
320 | (state) => state.deletingQuestionIds
321 | );
322 |
323 | export const selectRestoringQuestions = createSelector(
324 | selectQuestionsState,
325 | (state) => state.restoringQuestionIds
326 | );
327 |
328 | export const selectIsFavoritingQuestion = (questionId) =>
329 | createSelector(selectQuestionsState, (state) =>
330 | state.favoritingQuestionIds.find((id) => id === questionId)
331 | );
332 |
333 | export default questionsSlice.reducer;
334 |
--------------------------------------------------------------------------------
/src/features/search/searchQuestionSlice.js:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit';
2 | import axios from 'axios';
3 |
4 | export const fetchQuestionsSearch = createAsyncThunk(
5 | 'question/search',
6 | async (question, thunkAPI) => {
7 | try {
8 | const response = await axios.get(`/questions?search=${question}`);
9 |
10 | return response.data;
11 | } catch (e) {
12 | return thunkAPI.rejectWithValue(e.message);
13 | }
14 | }
15 | );
16 |
17 | const questionsSearchSlice = createSlice({
18 | name: 'questionsSearch',
19 | initialState: {
20 | questionsSearch: [],
21 | openedQuestion: null,
22 | loading: false,
23 | error: '',
24 | success: false,
25 | },
26 | reducers: {
27 | resetStatus: (state) => {
28 | state.error = '';
29 | },
30 | resetSuccess: (state) => {
31 | state.success = false;
32 | },
33 | },
34 | extraReducers: {
35 | [fetchQuestionsSearch.pending]: (state) => {
36 | state.loading = true;
37 | state.questionsSearch = [];
38 | },
39 | [fetchQuestionsSearch.fulfilled]: (state, action) => {
40 | state.loading = false;
41 | state.questionsSearch = action.payload;
42 | },
43 | },
44 | });
45 |
46 | const selectQuestionsSearchState = (state) => state;
47 |
48 | export const selectQuestionsLoading = createSelector(
49 | selectQuestionsSearchState,
50 | (state) => state.loading
51 | );
52 |
53 | export const selectQuestionsSuccess = createSelector(
54 | selectQuestionsSearchState,
55 | (state) => state.success
56 | );
57 |
58 | export const selectQuestionsError = createSelector(
59 | selectQuestionsSearchState,
60 | (state) => state.error
61 | );
62 |
63 | export const selectQuestionsSearch = createSelector(
64 | selectQuestionsSearchState,
65 | (state) => state.questionsSearch.questionsSearch
66 | );
67 |
68 | export const { resetStatus, resetSuccess } = questionsSearchSlice.actions;
69 |
70 | export default questionsSearchSlice.reducer;
71 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import axios from 'axios';
4 | import dayjs from 'dayjs';
5 | import 'dayjs/locale/ru';
6 | import relativeTime from 'dayjs/plugin/relativeTime';
7 | import calendar from 'dayjs/plugin/calendar';
8 | import { App } from './app/App';
9 | import { BASE_API_URL, LS_TOKEN_KEY } from './app/constants';
10 |
11 | import '@toast-ui/editor/dist/toastui-editor.css';
12 | import './assets/toast-ui-iqa-theme.css';
13 | import 'bootstrap/dist/css/bootstrap-grid.min.css';
14 | import 'antd/dist/antd.min.css';
15 | import { GlobalProvider } from './app/GlobalProvider';
16 |
17 | axios.defaults.baseURL = BASE_API_URL;
18 | axios.defaults.headers.authorization = `Bearer ${localStorage.getItem(LS_TOKEN_KEY)}`;
19 |
20 | dayjs.extend(relativeTime);
21 | dayjs.extend(calendar);
22 | dayjs.locale('ru');
23 |
24 | ReactDOM.render(
25 |
26 |
27 | ,
28 | document.getElementById('root')
29 | );
30 |
31 | // fake
32 |
--------------------------------------------------------------------------------
/src/index.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import axios from 'axios';
3 | import { render, waitFor } from '@testing-library/react';
4 | import { App } from './app/App';
5 | import { BASE_API_URL, LS_TOKEN_KEY } from './app/constants';
6 | import '@testing-library/jest-dom';
7 | import { GlobalProvider } from './app/GlobalProvider';
8 |
9 | // import 'bootstrap/dist/css/bootstrap-grid.min.css';
10 |
11 | axios.defaults.baseURL = BASE_API_URL;
12 | axios.defaults.headers.authorization = `Bearer ${localStorage.getItem(LS_TOKEN_KEY)}`;
13 |
14 | describe('App rendering', () => {
15 | it('renders without crash', async () => {
16 | const { container } = render(
17 |
18 |
19 |
20 | );
21 |
22 | await waitFor(() => {
23 | expect(container.querySelector('.placeholder')).toBeInTheDocument();
24 | });
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/src/pages/HelpPage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Paper } from 'components/layout/Paper';
3 | import { Title } from 'app/Title/Title';
4 |
5 | const HelpPage = () => {
6 | return (
7 |
8 |
О проекте IQA
9 |
10 | О проекте IQA
11 |
12 | Back when tech enthusiasts realised the power of what would be known as the internet, two
13 | popular browsers came into existence - the Netscape Navigator & following its success,
14 | (drumrolls) Microsoft’s Internet explorer. This was even before the W3C standards came
15 | into picture which would eventually standardise how code would run across different
16 | browsers. Hence, you can imagine that, given the popularity of these browsers, websites
17 | were written in two versions - one for the Navigator, and the other one for IE, which
18 | might sound a little redundant now, but was the norm back then. However, after the W3C
19 | standards were created and browsers started adhering to them, developers encountered a new
20 | problem.
21 |
22 |
23 | The problem now was that the legacy code started to break. Hence, a possible solution to
24 | this was that the sites were now made in two versions - a Standard version (the one which
25 | we mentioned earlier) which was W3C standards compliant and hence would run across
26 | different browsers and a Quirks version which supported the legacy code.
27 |
28 |
29 | Now how do browsers identify which mode it needs to use? Well, just add a valid DOCTYPE
30 | declaration in the first line of the HTML file, to instruct the browser to run the code in
31 | Standard mode. Anything other than that will trigger the Quirks mode in IE9 or older. This
32 | is exactly what <!DOCTYPE html> does HTML5 onwards. If you fail to add this line to
33 | your HTML file, the browser would interpret this as an instruction to run your code in
34 | Quirks mode, and you could end up getting inconsistent results across different browsers.
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default HelpPage;
42 |
--------------------------------------------------------------------------------