├── .dockerignore ├── .env ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── deploy.yml │ └── node.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc.json ├── Dockerfile ├── LICENSE ├── README.md ├── __tests__ └── api.test.ts ├── app ├── (auth and errors) │ ├── [...not-found] │ │ └── page.tsx │ ├── auth │ │ └── page.tsx │ ├── invites │ │ ├── [path] │ │ │ └── page.tsx │ │ └── error │ │ │ └── page.tsx │ ├── layout.tsx │ └── not-found.tsx ├── (root) │ ├── global.scss │ ├── layout.tsx │ ├── page.tsx │ └── quest │ │ ├── [id] │ │ ├── edit │ │ │ ├── about │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── leaderboard │ │ │ │ ├── LeaderboardTabClient.tsx │ │ │ │ └── page.tsx │ │ │ ├── logs │ │ │ │ ├── LogsTabClient.tsx │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ ├── tasks │ │ │ │ └── page.tsx │ │ │ └── teams │ │ │ │ ├── TeamsTabClient.tsx │ │ │ │ └── page.tsx │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ └── play │ │ │ └── page.tsx │ │ └── create │ │ └── page.tsx ├── api │ ├── __mocks__ │ │ ├── Quest.mock.ts │ │ ├── Task.mock.ts │ │ └── User.mock.ts │ ├── api.ts │ ├── auth │ │ └── [...nextauth] │ │ │ ├── auth.ts │ │ │ └── route.ts │ ├── client │ │ ├── client.ts │ │ └── constants.ts │ ├── custom-errors.ts │ ├── settings.ts │ └── uploadToS3.ts ├── favicon.ico ├── main.scss ├── metadata.ts ├── robots.ts ├── sitemap.ts └── types │ ├── json-data.ts │ ├── quest-interfaces.ts │ └── user-interfaces.ts ├── components ├── AuthForm │ ├── AuthForm.scss │ ├── AuthForm.tsx │ └── AuthForm.types.ts ├── Background │ ├── Background.scss │ ├── Background.tsx │ └── Background.types.ts ├── Body │ ├── Body.scss │ └── Body.tsx ├── ContentWrapper │ ├── ContentWrapper.scss │ ├── ContentWrapper.tsx │ └── ContentWrapper.types.ts ├── CustomCountdown │ └── CustomCountdown.tsx ├── CustomModal │ ├── CustomModal.scss │ └── CustomModal.tsx ├── ExitButton │ ├── ExitButton.scss │ └── ExitButton.tsx ├── Footer │ ├── Footer.scss │ └── Footer.tsx ├── Header │ ├── Header.scss │ ├── Header.tsx │ ├── HeaderAvatar │ │ ├── HeaderAvatar.scss │ │ └── HeaderAvatar.tsx │ └── _index.scss ├── Logotype │ ├── Logotype.scss │ └── Logotype.tsx ├── NextAuthProvider │ ├── NextAuthProvider.tsx │ └── SessionRefetchEvents.tsx ├── Profile │ ├── AvatarStub │ │ └── AvatarStub.tsx │ ├── EditProfile │ │ ├── EditAvatar │ │ │ └── EditAvatar.tsx │ │ ├── EditName │ │ │ └── EditName.tsx │ │ ├── EditPassword │ │ │ └── EditPassword.tsx │ │ ├── EditProfile.helpers.ts │ │ ├── EditProfile.scss │ │ └── EditProfile.tsx │ ├── Profile.scss │ ├── Profile.tsx │ └── _index.scss ├── Quest │ ├── EditQuest │ │ ├── EditQuest.scss │ │ ├── EditQuest.tsx │ │ ├── QuestEditor │ │ │ ├── QuestEditor.scss │ │ │ └── QuestEditor.tsx │ │ └── QuestPreview │ │ │ ├── QuestPreview.scss │ │ │ └── QuestPreview.tsx │ ├── Quest.helpers.tsx │ ├── Quest.scss │ ├── Quest.tsx │ ├── QuestAdminPanel │ │ ├── QuestAdminPanel.scss │ │ └── QuestAdminPanel.tsx │ ├── QuestAllTeams │ │ ├── QuestAllTeams.scss │ │ └── QuestAllTeams.tsx │ ├── QuestDescription │ │ ├── QuestDescription.scss │ │ └── QuestDescription.tsx │ ├── QuestHeader │ │ ├── QuestHeader.helpers.tsx │ │ ├── QuestHeader.scss │ │ └── QuestHeader.tsx │ ├── QuestParticipantsWrapper │ │ └── QuestParticipantsWrapper.tsx │ ├── QuestResults │ │ ├── QuestResults.scss │ │ └── QuestResults.tsx │ ├── QuestTeam │ │ ├── CreateTeam │ │ │ ├── CreateTeam.scss │ │ │ └── CreateTeam.tsx │ │ ├── InviteModal │ │ │ ├── InviteModal.scss │ │ │ └── InviteModal.tsx │ │ ├── QuestTeam.scss │ │ └── QuestTeam.tsx │ └── _index.scss ├── QuestAdmin │ ├── DeprecatedQuestAdmin.tsx │ ├── Leaderboard │ │ ├── EditPenalty │ │ │ └── EditPenalty.tsx │ │ ├── Leaderboard.scss │ │ └── Leaderboard.tsx │ ├── Logs │ │ ├── Filters │ │ │ ├── Filters.scss │ │ │ └── Filters.tsx │ │ ├── InfoAlert │ │ │ ├── InfoAlert.scss │ │ │ └── InfoAlert.tsx │ │ ├── Logs.scss │ │ └── Logs.tsx │ ├── QuestAdmin.helpers.ts │ ├── QuestAdmin.scss │ ├── QuestAdminTabs.tsx │ ├── Teams │ │ ├── Teams.scss │ │ └── Teams.tsx │ └── _index.scss ├── QuestTabs │ ├── QuestCard │ │ ├── QuestCard.scss │ │ └── QuestCard.tsx │ ├── QuestCardsList │ │ └── QuestCardsList.tsx │ ├── QuestTabs.helpers.tsx │ ├── QuestTabs.scss │ ├── QuestTabs.server.tsx │ ├── QuestTabs.tsx │ ├── _index.scss │ └── questTabsSelectTheme.tsx ├── Tasks │ ├── Brief │ │ ├── Brief.scss │ │ ├── Brief.tsx │ │ └── BriefEditButtons │ │ │ └── BriefEditButtons.tsx │ ├── ContextProvider │ │ └── ContextProvider.tsx │ ├── PlayPageContent │ │ ├── PlayPageContent.scss │ │ └── PlayPageContent.tsx │ ├── Task │ │ ├── EditTask │ │ │ ├── EditTask.scss │ │ │ ├── EditTask.tsx │ │ │ ├── HintsForm │ │ │ │ └── HintsForm.tsx │ │ │ └── TaskEditButtons │ │ │ │ └── TaskEditButtons.tsx │ │ ├── Task.helpers.tsx │ │ ├── Task.scss │ │ └── Task.tsx │ ├── TaskGroup │ │ ├── EditTaskGroup │ │ │ ├── EditTaskGroup.scss │ │ │ └── EditTaskGroup.tsx │ │ ├── TaskGroup.scss │ │ ├── TaskGroup.tsx │ │ └── TaskGroupExtra │ │ │ └── TaskGroupExtra.tsx │ ├── Tasks.scss │ ├── Tasks.tsx │ └── _index.scss ├── ThemeChanger │ ├── ThemeChanger.scss │ └── ThemeChanger.tsx └── _component-dir.scss ├── globals ├── _colors.scss ├── _index.scss ├── _theme.scss └── _variables.scss ├── infra └── k8s │ └── questspace │ ├── frontend-service.yaml │ ├── questspace-frontend.yaml │ └── questspace-secret.example.yaml ├── jest.config.js ├── jest.setup.js ├── lib ├── Manrope.woff2 ├── RobotoFlex.woff2 ├── fonts.ts ├── theme │ └── themeConfig.tsx └── utils │ ├── modalTypes.ts │ └── utils.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── Questspace-Background-Dark.svg ├── Questspace-Background-Light.svg ├── Questspace-Background.webp ├── Questspace-Icon.svg └── Questspace-Text.svg ├── tsconfig.json └── types └── next-auth.d.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | .git 4 | Dockerfile 5 | LICENSE 6 | .dockerignore 7 | .gitignore 8 | README.md -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NEXTAUTH_URL='https://questspace.fun' 2 | PORT=':3000' 3 | 4 | NEXTAUTH_SECRET='megagigasecret' 5 | GOOGLE_CLIENT_ID='example' 6 | GOOGLE_CLIENT_SECRET='example' 7 | 8 | AWS_ACCESS_KEY_ID='access-key-id' 9 | AWS_SECRET_KEY_ID='secret-key-id' 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | .next/* 3 | *.png 4 | *.svg 5 | *.json 6 | *.xml 7 | *.md 8 | # NOTE: we are using .js-files only for configs, so no need for linters 9 | *.js 10 | public 11 | lib/utils 12 | next-env.d.ts 13 | # next.config.js 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "promise", 4 | "@typescript-eslint" 5 | ], 6 | "extends": [ 7 | "eslint:recommended", 8 | "airbnb", 9 | "airbnb-typescript", 10 | "airbnb/hooks", 11 | "next/core-web-vitals", 12 | "plugin:promise/recommended", 13 | "plugin:@typescript-eslint/recommended-type-checked", 14 | "plugin:@typescript-eslint/stylistic-type-checked", 15 | "prettier" 16 | ], 17 | "parserOptions": { 18 | "project": "./tsconfig.json" 19 | }, 20 | "rules": { 21 | "eslint/prefer-promise-reject-errors": "off", 22 | "@typescript-eslint/no-unused-vars": "error", 23 | "@typescript-eslint/no-explicit-any": "error", 24 | "import/no-named-as-default": "off", 25 | "react/jsx-curly-brace-presence": "off", 26 | "jsx-a11y/no-noninteractive-tabindex": "off", 27 | "react/prop-types": "off", 28 | "react/require-default-props": "off", 29 | "react/jsx-props-no-spreading": "off", 30 | "@typescript-eslint/prefer-nullish-coalescing": "warn", 31 | "@typescript-eslint/no-misused-promises": [ 32 | "error", 33 | { 34 | "checksVoidReturn": { 35 | "arguments": false, 36 | "attributes": false 37 | } 38 | } 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Frontend 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | IMAGE_NAME: frontend 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Login to Yandex Cloud Container Registry 16 | id: login-cr 17 | uses: yc-actions/yc-cr-login@v1 18 | with: 19 | yc-sa-json-credentials: ${{ secrets.CI_REGISTRY_KEY }} 20 | 21 | - name: Build, tag, and push image to Yandex Cloud Container Registry 22 | run: | 23 | docker build -t ${{ secrets.CI_REGISTRY }}/${{ env.IMAGE_NAME }}:0${{ github.run_number }} . 24 | docker push ${{ secrets.CI_REGISTRY }}/${{ env.IMAGE_NAME }}:0${{ github.run_number }} 25 | deploy: 26 | runs-on: ubuntu-latest 27 | container: gcr.io/cloud-builders/kubectl:latest 28 | needs: build 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Update deployment image 33 | run: | 34 | kubectl config set-cluster k8s --server="${{ secrets.KUBE_URL }}" --insecure-skip-tls-verify=true 35 | kubectl config set-credentials admin --token="${{ secrets.KUBE_TOKEN }}" 36 | kubectl config set-context default --cluster=k8s --user=admin 37 | kubectl config use-context default 38 | sed -i "s,__VERSION__,${{ secrets.CI_REGISTRY }}/${{ env.IMAGE_NAME }}:0${{ github.run_number }}," ./infra/k8s/questspace/questspace-frontend.yaml 39 | kubectl apply -f ./infra/k8s/questspace/questspace-frontend.yaml 40 | -------------------------------------------------------------------------------- /.github/workflows/node.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | paths-ignore: 7 | - .github/** 8 | - infra/** 9 | pull_request: 10 | branches: [ "main" ] 11 | paths-ignore: 12 | - .github/** 13 | - infra/** 14 | workflow_dispatch: 15 | 16 | jobs: 17 | 18 | lint: 19 | runs-on: ubuntu-22.04 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version-file: package.json 26 | cache: npm 27 | 28 | - run: npm install eslint@8.57.0 29 | 30 | - name: Run ESLint 31 | run: npx eslint . 32 | --config .eslintrc.json 33 | --ext .js,.jsx,.ts,.tsx 34 | 35 | test: 36 | runs-on: ubuntu-22.04 37 | steps: 38 | - uses: actions/checkout@v4 39 | 40 | - uses: actions/setup-node@v4 41 | with: 42 | node-version-file: package.json 43 | cache: npm 44 | 45 | - run: npm ci 46 | 47 | - run: npm test 48 | 49 | build: 50 | runs-on: ubuntu-22.04 51 | steps: 52 | - uses: actions/checkout@v4 53 | 54 | - uses: actions/setup-node@v4 55 | with: 56 | node-version-file: package.json 57 | cache: npm 58 | 59 | - run: npm ci 60 | 61 | - run: npm run build 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | .idea 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # sass 39 | *.css 40 | *.css.map 41 | 42 | 43 | certificates 44 | /server.js 45 | 46 | infra/k8s/**/*-secret.yaml 47 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | engine-strict=true 3 | node-version=21.7.2 4 | save-exact=true 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 21.7.2 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "bracketSameLine": false, 6 | "jsxSingleQuote": false, 7 | "printWidth": 80, 8 | "proseWrap": "preserve", 9 | "quoteProps": "as-needed", 10 | "semi": true, 11 | "singleQuote": true, 12 | "trailingComma": "all", 13 | "tabWidth": 4, 14 | "useTabs": false 15 | } 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21.6.2-alpine AS base 2 | 3 | # Install dependencies only when needed 4 | FROM base AS deps 5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 6 | RUN apk add --no-cache libc6-compat 7 | WORKDIR /app 8 | 9 | # Install dependencies based on the preferred package manager 10 | COPY package.json package-lock.json* ./ 11 | RUN npm ci 12 | 13 | 14 | # Rebuild the source code only when needed 15 | FROM base AS builder 16 | WORKDIR /app 17 | COPY --from=deps /app/node_modules ./node_modules 18 | COPY . . 19 | 20 | RUN npm run build 21 | 22 | # Production image, copy all the files and run next 23 | FROM base AS runner 24 | WORKDIR /app 25 | 26 | ENV NODE_ENV production 27 | 28 | RUN addgroup --system --gid 1001 nodejs 29 | RUN adduser --system --uid 1001 nextjs 30 | 31 | COPY --from=builder /app/public ./public 32 | 33 | # Set the correct permission for prerender cache 34 | RUN mkdir .next 35 | RUN chown nextjs:nodejs .next 36 | 37 | # Automatically leverage output traces to reduce image size 38 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 39 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 40 | 41 | USER nextjs 42 | 43 | EXPOSE 3000 44 | 45 | ENV PORT 3000 46 | ENV HOSTNAME "0.0.0.0" 47 | 48 | CMD ["node", "server.js"] 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Questspace-v2 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # questspace-frontend 2 | 3 | 4 | 5 | Движок для создания и проведения квестов 6 | 7 | **[questspace.fun](https://questspace.fun)** 8 | 9 | ## Возможности 10 | 11 | - Каталог квестов 12 | - Игровой режим с задачами и подсказками 13 | - Команды с инвайтами 14 | - Дашборд с рейтингом команд 15 | - Создание/редактирование квестов и задач 16 | - Авторизация через логин/пароль или Google OAuth 17 | 18 |
19 | 20 | 21 | | Страница квеста | Каталог квестов | 22 | |:---------------:|:---------------:| 23 | | | | 24 | 25 | 26 | | Редактирование задачи | Редактирование квеста | Лидерборд | 27 | |:---------------------:|:---------------------:|:---------:| 28 | | | | | 29 | 30 | 31 | 32 | 33 | ## Запуск 34 | 35 | 1. Установить [Node.js](https://nodejs.org/en/download/) >= 21.7.2 или через [nvm](https://github.com/nvm-sh/nvm) 36 | 37 | 2. Установить зависимости 38 | 39 | ```sh 40 | npm i 41 | ``` 42 | 43 | 3. Запустить Next.js 44 | 45 | ```sh 46 | npm run dev 47 | ``` 48 | -------------------------------------------------------------------------------- /__tests__/api.test.ts: -------------------------------------------------------------------------------- 1 | import { enableFetchMocks } from 'jest-fetch-mock'; 2 | import { authSignIn, getFilteredQuests } from '@/app/api/api'; 3 | import { ISignIn, ISignInResponse } from '@/app/types/user-interfaces'; 4 | import { Forbidden, HttpError } from 'http-errors'; 5 | import { IFilteredQuestsResponse } from '@/app/types/quest-interfaces'; 6 | import questMock from '@/app/api/__mocks__/Quest.mock'; 7 | 8 | enableFetchMocks(); 9 | 10 | describe('authSignInTests', () => { 11 | const testCredentials: ISignIn = { 12 | username: 'clown', 13 | password: '12345' 14 | }; 15 | 16 | const testResponse: ISignInResponse = { 17 | access_token: 'token', 18 | user: { 19 | id: '1', 20 | username: 'clown', 21 | avatar_url: 'someUrl' 22 | } 23 | }; 24 | 25 | beforeEach(() => { 26 | fetchMock.resetMocks(); 27 | }); 28 | 29 | it('Valid credentials', async () => { 30 | fetchMock.mockResponse(JSON.stringify(testResponse)); 31 | const data = await authSignIn(testCredentials) as ISignInResponse; 32 | expect(data).toStrictEqual(testResponse); 33 | }); 34 | 35 | // NOTE(svayp11): Skip broken test 36 | test.skip('Invalid credentials', async () => { 37 | fetchMock.mockReject(new Forbidden('Forbidden')); 38 | const data = await authSignIn(testCredentials) as HttpError; 39 | expect(data).toBe(new Forbidden('Forbidden')); 40 | }); 41 | }); 42 | 43 | describe('getFilteredQuests', () => { 44 | const testResponse: IFilteredQuestsResponse = { 45 | all: { 46 | next_page_id: '1', 47 | quests: [ 48 | questMock 49 | ] 50 | } 51 | }; 52 | 53 | beforeEach(() => { 54 | fetchMock.resetMocks(); 55 | }); 56 | 57 | // NOTE(svayp11): Skip broken test 58 | test.skip('AllQuests', async () => { 59 | fetchMock.mockResponse(JSON.stringify(testResponse)); 60 | const data = await getFilteredQuests(['all']) as IFilteredQuestsResponse; 61 | expect(data).toStrictEqual(testResponse); 62 | }); 63 | }); -------------------------------------------------------------------------------- /app/(auth and errors)/[...not-found]/page.tsx: -------------------------------------------------------------------------------- 1 | import {notFound} from "next/navigation" 2 | 3 | export default function NotFoundCatchAll() { 4 | notFound() 5 | } 6 | -------------------------------------------------------------------------------- /app/(auth and errors)/auth/page.tsx: -------------------------------------------------------------------------------- 1 | import AuthForm from '@/components/AuthForm/AuthForm'; 2 | import { getServerSession } from 'next-auth'; 3 | import { redirect } from 'next/navigation'; 4 | import { FRONTEND_URL } from '@/app/api/client/constants'; 5 | import { Metadata } from 'next'; 6 | 7 | export const metadata: Metadata = { 8 | title: { 9 | default: 'Авторизация', 10 | template: `%s | Квестспейс` 11 | } 12 | }; 13 | 14 | export default async function Auth() { 15 | const session = await getServerSession(); 16 | if (session && session.user) { 17 | redirect(FRONTEND_URL); 18 | } 19 | 20 | return ( 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/(auth and errors)/invites/[path]/page.tsx: -------------------------------------------------------------------------------- 1 | import { IGetQuestResponse } from '@/app/types/quest-interfaces'; 2 | import { notFound, redirect } from 'next/navigation'; 3 | import { getServerSession } from 'next-auth'; 4 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 5 | import { getQuestByTeamInvite, joinTeam } from '@/app/api/api'; 6 | import { ITeam } from '@/app/types/user-interfaces'; 7 | 8 | export default async function InvitePage({params}: {params: {path: string}}) { 9 | const session = await getServerSession(authOptions); 10 | if (!session?.accessToken) { 11 | const [id] = params.path.split('/'); 12 | const redirectParams = new URLSearchParams({route: 'invites', id}); 13 | redirect(`/auth?${redirectParams.toString()}`); 14 | } 15 | 16 | const data = await getQuestByTeamInvite(params.path, session?.accessToken) as IGetQuestResponse; 17 | if (data) { 18 | const questId = data.quest?.id; 19 | const team = await joinTeam(params.path, session.accessToken) as ITeam; 20 | if (team) { 21 | redirect(`/quest/${questId}`); 22 | } else { 23 | redirect('/invites/error'); 24 | } 25 | } else { 26 | notFound(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/(auth and errors)/invites/error/page.tsx: -------------------------------------------------------------------------------- 1 | import Background from '@/components/Background/Background'; 2 | import ContentWrapper from '@/components/ContentWrapper/ContentWrapper'; 3 | import { Button } from 'antd'; 4 | import Link from 'next/link'; 5 | 6 | export default function InviteErrorPage() { 7 | return ( 8 | <> 9 | 10 |
11 | 12 |

Упс...

13 |

Кажется, в этой команде закончились места 😢

14 | 15 | 16 | 17 |
18 |
19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/(auth and errors)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AntdRegistry } from '@ant-design/nextjs-registry'; 3 | import { manrope, robotoFlex } from '@/lib/fonts'; 4 | import { Metadata } from 'next'; 5 | import { getServerSession } from 'next-auth'; 6 | import NextAuthProvider from '@/components/NextAuthProvider/NextAuthProvider'; 7 | import { ConfigProvider } from 'antd'; 8 | import theme from '@/lib/theme/themeConfig'; 9 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 10 | import Background from '@/components/Background/Background'; 11 | import mainMetadata from '@/app/metadata'; 12 | import { ThemeProvider } from 'next-themes'; 13 | 14 | import '../(root)/global.scss'; 15 | import '../main.scss'; 16 | 17 | 18 | export const metadata: Metadata = mainMetadata; 19 | 20 | export default async function RootLayout({ children }: React.PropsWithChildren) { 21 | const session = await getServerSession(authOptions); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 | 32 | {children} 33 |
34 |
35 |
36 |
37 |
38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/(auth and errors)/not-found.tsx: -------------------------------------------------------------------------------- 1 | import ContentWrapper from '@/components/ContentWrapper/ContentWrapper'; 2 | import { Button } from 'antd'; 3 | import Link from 'next/link'; 4 | import { Metadata } from 'next'; 5 | import { FRONTEND_URL } from '@/app/api/client/constants'; 6 | 7 | export const metadata: Metadata = { 8 | metadataBase: new URL(FRONTEND_URL), 9 | title: { 10 | default: 'Квест не найден', 11 | template: `%s | Квестспейс` 12 | }, 13 | openGraph: { 14 | description: 'Квест, который вы ищете, не существует', 15 | images: [''] 16 | }, 17 | }; 18 | 19 | export default function NotFound() { 20 | return ( 21 |
22 | 23 |

404

24 |

Мы как-то не рассчитывали, что квест зайдет настолько далеко...🤔

25 | 26 | 27 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/(root)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AntdRegistry } from '@ant-design/nextjs-registry'; 3 | import { manrope, robotoFlex } from '@/lib/fonts'; 4 | import { Metadata } from 'next'; 5 | import { getServerSession } from 'next-auth'; 6 | import NextAuthProvider from '@/components/NextAuthProvider/NextAuthProvider'; 7 | import { ConfigProvider } from 'antd'; 8 | import theme from '@/lib/theme/themeConfig'; 9 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 10 | import Header from '@/components/Header/Header'; 11 | import Body from '@/components/Body/Body'; 12 | import dynamic from 'next/dynamic'; 13 | import mainMetadata from '@/app/metadata'; 14 | import { ThemeProvider } from 'next-themes'; 15 | 16 | import './global.scss'; 17 | import '../main.scss'; 18 | 19 | 20 | export const metadata: Metadata = mainMetadata; 21 | 22 | const DynamicFooter = dynamic(() => import('@/components/Footer/Footer'), { 23 | ssr: true, 24 | }) 25 | 26 | export default async function RootLayout({ children }: React.PropsWithChildren) { 27 | const session = await getServerSession(authOptions); 28 | const isAuthorized = Boolean(session?.user); 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 |
39 | 40 | {children} 41 | 42 | 43 |
44 |
45 |
46 |
47 |
48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/(root)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Spin } from 'antd'; 2 | import dynamic from 'next/dynamic'; 3 | import { getServerSession } from 'next-auth'; 4 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 5 | import getBackendQuests from '@/components/QuestTabs/QuestTabs.server'; 6 | 7 | const DynamicQuestTabs = dynamic(() => import('@/components/QuestTabs/QuestTabs'), { 8 | ssr: false, 9 | }) 10 | 11 | const DynamicProfile = dynamic(() => import('@/components/Profile/Profile'), { 12 | ssr: false, 13 | loading: () => 14 | }) 15 | 16 | 17 | async function HomePage() { 18 | const fetchedData = await getBackendQuests('all'); 19 | const fetchedAllQuests = fetchedData?.quests; 20 | const nextPageId = fetchedData?.next_page_id; 21 | const session = await getServerSession(authOptions); 22 | 23 | const isAuthorized = Boolean(session?.user); 24 | 25 | return ( 26 | <> 27 | {isAuthorized && } 28 | 29 | 30 | ); 31 | } 32 | 33 | export default HomePage; 34 | -------------------------------------------------------------------------------- /app/(root)/quest/[id]/edit/about/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import EditQuest from '@/components/Quest/EditQuest/EditQuest'; 4 | import { useTasksContext } from '@/components/Tasks/ContextProvider/ContextProvider'; 5 | import QuestAdminTabs from '@/components/QuestAdmin/QuestAdminTabs'; 6 | 7 | export default function AboutTab() { 8 | const { data: contextData, updater: setContextData } = useTasksContext()!; 9 | 10 | return ( 11 | 12 | 13 | 14 | ) 15 | } -------------------------------------------------------------------------------- /app/(root)/quest/[id]/edit/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { getServerSession } from 'next-auth'; 3 | import { notFound, redirect } from 'next/navigation'; 4 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 5 | import { getTaskGroupsAdmin } from '@/app/api/api'; 6 | import { ITaskGroupsAdminResponse } from '@/app/types/quest-interfaces'; 7 | import ContextProvider from '@/components/Tasks/ContextProvider/ContextProvider'; 8 | 9 | export const dynamic = 'force-dynamic'; 10 | 11 | 12 | export default async function QuestAdminLayout({ 13 | children, 14 | params, 15 | }: { 16 | children: ReactNode; 17 | params: { id: string }; 18 | }) { 19 | const session = await getServerSession(authOptions); 20 | 21 | if (!session || !session.user) { 22 | redirect('/auth'); 23 | } 24 | 25 | const questData = await getTaskGroupsAdmin(params.id, session.accessToken) as ITaskGroupsAdminResponse; 26 | 27 | if (!questData) { 28 | notFound(); 29 | } 30 | 31 | const isCreator = questData.quest.creator.id === session.user.id; 32 | 33 | if (!isCreator) { 34 | notFound(); 35 | } 36 | 37 | return ( 38 | 39 | {children} 40 | 41 | ); 42 | } -------------------------------------------------------------------------------- /app/(root)/quest/[id]/edit/leaderboard/LeaderboardTabClient.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTasksContext } from '@/components/Tasks/ContextProvider/ContextProvider'; 4 | import Leaderboard from '@/components/QuestAdmin/Leaderboard/Leaderboard'; 5 | import { IAdminLeaderboardResponse } from '@/app/types/quest-interfaces'; 6 | import { finishQuest } from '@/app/api/api'; 7 | import { useSession } from 'next-auth/react'; 8 | import QuestAdminTabs from '@/components/QuestAdmin/QuestAdminTabs'; 9 | import { Button } from 'antd'; 10 | import classNames from 'classnames'; 11 | import { NotificationOutlined } from '@ant-design/icons'; 12 | 13 | interface LeaderboardTabClientProps { 14 | initialLeaderboard: IAdminLeaderboardResponse; 15 | } 16 | 17 | export default function LeaderboardTabClient({ 18 | initialLeaderboard, 19 | 20 | }: LeaderboardTabClientProps) { 21 | const { data: contextData } = useTasksContext()!; 22 | const { data: session } = useSession(); 23 | 24 | const publishResults = () => finishQuest(contextData.quest.id, session?.accessToken); 25 | const publishResultsButton = 26 | ; 29 | 30 | return ( 31 | 32 | 36 | 37 | ); 38 | } -------------------------------------------------------------------------------- /app/(root)/quest/[id]/edit/leaderboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { getLeaderboardAdmin } from '@/app/api/api'; 2 | import { IAdminLeaderboardResponse } from '@/app/types/quest-interfaces'; 3 | import { getServerSession } from 'next-auth'; 4 | import { unstable_noStore as noStore } from 'next/cache'; 5 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 6 | import LeaderboardTabClient from './LeaderboardTabClient'; 7 | 8 | export default async function LeaderboardTab({ params }: { params: { id: string } }) { 9 | const session = await getServerSession(authOptions); 10 | 11 | noStore(); 12 | const leaderboardData = await getLeaderboardAdmin( 13 | params.id, 14 | session?.accessToken 15 | ) as IAdminLeaderboardResponse; 16 | 17 | return ( 18 | 21 | ); 22 | } -------------------------------------------------------------------------------- /app/(root)/quest/[id]/edit/logs/LogsTabClient.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTasksContext } from '@/components/Tasks/ContextProvider/ContextProvider'; 4 | import Logs from '@/components/QuestAdmin/Logs/Logs'; 5 | import { IPaginatedAnswerLogs } from '@/app/types/quest-interfaces'; 6 | import React, { useState, useEffect } from 'react'; 7 | import QuestAdminTabs from '@/components/QuestAdmin/QuestAdminTabs'; 8 | import { ITeam } from '@/app/types/user-interfaces'; 9 | 10 | interface LogsTabClientProps { 11 | initialTeams: ITeam[]; 12 | initialLogs: IPaginatedAnswerLogs; 13 | questId: string; 14 | } 15 | 16 | export default function LogsTabClient({ initialTeams, initialLogs, questId }: LogsTabClientProps) { 17 | const { data: contextData, updater: setContextData } = useTasksContext()!; 18 | const [isInfoAlertHidden, setIsInfoAlertHidden] = useState(false); 19 | 20 | // Инициализация состояний предзагруженными данными 21 | useEffect(() => { 22 | setContextData(prevState => ({ 23 | ...prevState, 24 | teams: initialTeams, 25 | quest: { ...prevState.quest, id: questId } 26 | })); 27 | }, [initialTeams, questId, setContextData]); 28 | 29 | return ( 30 | 31 | 37 | 38 | ); 39 | } -------------------------------------------------------------------------------- /app/(root)/quest/[id]/edit/logs/page.tsx: -------------------------------------------------------------------------------- 1 | import { getPaginatedAnswerLogs, getQuestTeams } from '@/app/api/api'; 2 | import { IGetAllTeamsResponse, IPaginatedAnswerLogs, IPaginatedAnswerLogsParams } from '@/app/types/quest-interfaces'; 3 | import { getServerSession } from 'next-auth'; 4 | import { unstable_noStore as noStore } from 'next/cache'; 5 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 6 | import { LOGS_PAGE_SIZE } from '@/components/QuestAdmin/Logs/Logs'; 7 | import LogsTabClient from './LogsTabClient'; 8 | 9 | export default async function LogsTab({ params }: { params: { id: string } }) { 10 | const session = await getServerSession(authOptions); 11 | 12 | const paramsForLogs: IPaginatedAnswerLogsParams = { 13 | desc: true, 14 | page_size: LOGS_PAGE_SIZE // Используем вашу константу 15 | }; 16 | 17 | noStore(); 18 | const [logsData, teamsData] = await Promise.all([ 19 | getPaginatedAnswerLogs(params.id, session?.accessToken, paramsForLogs) as Promise, 20 | getQuestTeams(params.id) as Promise 21 | ]); 22 | 23 | return ( 24 | 29 | ); 30 | } -------------------------------------------------------------------------------- /app/(root)/quest/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import AboutTab from './about/page'; 2 | 3 | export default function EditQuestPage() { 4 | return ( 5 | 6 | ); 7 | } -------------------------------------------------------------------------------- /app/(root)/quest/[id]/edit/tasks/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTasksContext } from '@/components/Tasks/ContextProvider/ContextProvider'; 4 | import QuestAdminTabs from '@/components/QuestAdmin/QuestAdminTabs'; 5 | import { TasksMode } from '@/components/Tasks/Task/Task.helpers'; 6 | import Tasks from '@/components/Tasks/Tasks'; 7 | import React, { useState } from 'react'; 8 | import { Button } from 'antd'; 9 | import classNames from 'classnames'; 10 | import { PlusOutlined } from '@ant-design/icons'; 11 | import { TaskGroupModalProps } from '@/components/Tasks/TaskGroup/EditTaskGroup/EditTaskGroup'; 12 | 13 | export default function TasksTab() { 14 | const { data: contextData } = useTasksContext()!; 15 | const [isOpenModal, setIsOpenModal] = useState(false); 16 | const [EditTaskGroupComponent, setEditTaskGroupComponent] = useState | null>(null); 17 | 18 | const addTaskGroup = async () => { 19 | if (!EditTaskGroupComponent) { 20 | const DynamicEditTaskGroup = (await import('@/components/Tasks/TaskGroup/EditTaskGroup/EditTaskGroup')).default; 21 | setEditTaskGroupComponent(() => DynamicEditTaskGroup); 22 | } 23 | setIsOpenModal(true); 24 | }; 25 | 26 | const addTaskGroupButton = 27 | ; 30 | 31 | return ( 32 | 33 | 34 | {EditTaskGroupComponent && isOpenModal && ( 35 | 40 | )} 41 | 42 | ) 43 | } -------------------------------------------------------------------------------- /app/(root)/quest/[id]/edit/teams/TeamsTabClient.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTasksContext } from '@/components/Tasks/ContextProvider/ContextProvider'; 4 | import QuestAdminTabs from '@/components/QuestAdmin/QuestAdminTabs'; 5 | import Teams from '@/components/QuestAdmin/Teams/Teams'; 6 | import React, { useEffect } from 'react'; 7 | import { ITeam } from '@/app/types/user-interfaces'; 8 | 9 | interface TeamsTabClientProps { 10 | initialTeams: ITeam[]; 11 | questId: string; 12 | } 13 | 14 | export default function TeamsTabClient({ initialTeams, questId }: TeamsTabClientProps) { 15 | const { data: contextData, updater: setContextData } = useTasksContext()!; 16 | 17 | useEffect(() => { 18 | setContextData(prev => ({ 19 | ...prev, 20 | teams: initialTeams, 21 | quest: { ...prev.quest, id: questId } 22 | })); 23 | }, [initialTeams, questId, setContextData]); 24 | 25 | return ( 26 | 27 | 32 | 33 | ); 34 | } -------------------------------------------------------------------------------- /app/(root)/quest/[id]/edit/teams/page.tsx: -------------------------------------------------------------------------------- 1 | import { getQuestTeams } from '@/app/api/api'; 2 | import { IGetAllTeamsResponse } from '@/app/types/quest-interfaces'; 3 | import { unstable_noStore as noStore } from 'next/cache'; 4 | import TeamsTabClient from './TeamsTabClient'; 5 | 6 | 7 | export default async function TeamsTab({ params }: { params: { id: string } }) { 8 | noStore(); 9 | const teamsData = await getQuestTeams(params.id) as IGetAllTeamsResponse; 10 | 11 | return ( 12 | 16 | ); 17 | } -------------------------------------------------------------------------------- /app/(root)/quest/[id]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import ContentWrapper from '@/components/ContentWrapper/ContentWrapper'; 2 | import Link from 'next/link'; 3 | import { Button } from 'antd'; 4 | import Background from '@/components/Background/Background'; 5 | import React from 'react'; 6 | import { Metadata } from 'next'; 7 | import { FRONTEND_URL } from '@/app/api/client/constants'; 8 | 9 | export const metadata: Metadata = { 10 | metadataBase: new URL(FRONTEND_URL), 11 | title: { 12 | default: 'Квест не найден', 13 | template: `%s | Квестспейс` 14 | }, 15 | openGraph: { 16 | description: 'Квест, который вы ищете, не существует', 17 | images: [''] 18 | }, 19 | }; 20 | 21 | export default function NotFound() { 22 | return ( 23 | <> 24 | 25 |
26 | 27 |

404

28 |

Мы как-то не рассчитывали, что квест зайдет настолько далеко...🤔

29 | 30 | 31 | 32 |
33 |
34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/(root)/quest/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getServerSession } from 'next-auth'; 3 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 4 | import { notFound } from 'next/navigation'; 5 | import { IGetQuestResponse } from '@/app/types/quest-interfaces'; 6 | import { getQuestById } from '@/app/api/api'; 7 | import QuestMainPage from '@/components/Quest/Quest'; 8 | 9 | 10 | // eslint-disable-next-line consistent-return 11 | export async function generateMetadata({params}: {params: {id: string}}) { 12 | try { 13 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 14 | const response = await getQuestById(params.id); 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 17 | if (response?.length <= 0) { 18 | notFound(); 19 | } 20 | 21 | const data = (response as IGetQuestResponse).quest; 22 | 23 | return { 24 | title: data.name, 25 | openGraph: { 26 | title: data.name, 27 | description: data.description, 28 | images: [data.media_link] 29 | } 30 | } 31 | } catch (error) { 32 | notFound(); 33 | } 34 | } 35 | 36 | export default async function QuestPage({params}: {params: {id: string}}) { 37 | const session = await getServerSession(authOptions); 38 | const questData = await getQuestById(params.id, session?.accessToken) 39 | .then(res => res as IGetQuestResponse) 40 | .catch(err => { 41 | throw err; 42 | }) 43 | 44 | if (!questData) { 45 | notFound(); 46 | } 47 | 48 | const isCreator = (session && session.user.id === questData.quest.creator.id) ?? false; 49 | 50 | return ( 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /app/(root)/quest/[id]/play/page.tsx: -------------------------------------------------------------------------------- 1 | import { getServerSession } from 'next-auth'; 2 | import React from 'react'; 3 | import { notFound, redirect } from 'next/navigation'; 4 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 5 | import { IQuestTaskGroupsResponse } from '@/app/types/quest-interfaces'; 6 | import PlayPageContent from '@/components/Tasks/PlayPageContent/PlayPageContent'; 7 | import { getTaskGroupsPlayMode } from '@/app/api/api'; 8 | import ContextProvider from '@/components/Tasks/ContextProvider/ContextProvider'; 9 | 10 | 11 | export default async function PlayQuestPage({params}: {params: {id: string}}) { 12 | const session = await getServerSession(authOptions); 13 | 14 | const questData = await getTaskGroupsPlayMode(params.id, session?.accessToken) as IQuestTaskGroupsResponse; 15 | const hasNoBrief = questData?.quest?.status === 'REGISTRATION_DONE' && (!questData?.quest?.has_brief || !questData?.quest?.brief); 16 | 17 | if (!questData || hasNoBrief || questData.error) { 18 | notFound(); 19 | } 20 | 21 | if (!session || !session.user) { 22 | redirect('/auth'); 23 | } 24 | 25 | return ( 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/(root)/quest/create/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { redirect } from 'next/navigation'; 3 | import { getServerSession } from 'next-auth'; 4 | import dynamic from 'next/dynamic'; 5 | import { Spin } from 'antd'; 6 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 7 | 8 | const DynamicCreateQuest = dynamic(() => import('@/components/Quest/EditQuest/EditQuest'), { 9 | ssr: false, 10 | loading: () => 11 | }) 12 | 13 | export default async function CreateQuestPage() { 14 | const session = await getServerSession(authOptions); 15 | 16 | if (!session || !session.user) { 17 | redirect('/auth'); 18 | } 19 | 20 | return ( 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/api/__mocks__/Quest.mock.ts: -------------------------------------------------------------------------------- 1 | import { IQuest } from '@/app/types/quest-interfaces'; 2 | import userMock from '@/app/api/__mocks__/User.mock'; 3 | import { QuestStatus } from '@/components/Quest/Quest.helpers'; 4 | 5 | const questMock: IQuest = { 6 | access: "public", 7 | creator: userMock, 8 | description: "Объехал весь Екатеринбург во время видеомарафона? Пора рассмотреть все детали этого города! Городской квест дпмм возвращается уже в эти выходные! \n" + 9 | "\n" + 10 | "**Старт**: 19 сентября, 15:00 (!) перенесли дату, чтобы не мокнуть под дождем в воскресенье \n" + 11 | "**Где**: Заоперный \n" + 12 | "\n" + 13 | "**Телеграм-канал квеста:** [t.me/questdpmm2020](https://t.me/questdpmm2020)", 14 | finish_time: "2021-02-18T21:54:42.123Z", 15 | id: "b5ee72a3-54dd-c4b8-551c-4bdc0204cedb", 16 | max_team_cap: 5, 17 | media_link: "https://api.dicebear.com/7.x/thumbs/svg?seed=591f6fe1-d6cd-479b-a327-35f6b12a08f4", 18 | name: "Городской квест ДПММ", 19 | registration_deadline: "2021-02-11T21:54:42.123Z", 20 | start_time: "2021-02-18T11:54:42.123Z", 21 | status: QuestStatus.StatusWaitResults, 22 | quest_type: 'ASSAULT', 23 | feedback_link: '', 24 | } 25 | 26 | export default questMock; 27 | -------------------------------------------------------------------------------- /app/api/__mocks__/Task.mock.ts: -------------------------------------------------------------------------------- 1 | import { ITask, ITaskGroup } from '@/app/types/quest-interfaces'; 2 | 3 | export const taskMock1: ITask = { 4 | correct_answers: [ 5 | "string" 6 | ], 7 | hints_full: [ 8 | { 9 | taken: false, 10 | text: "string" 11 | }, 12 | { 13 | taken: false, 14 | text: "str" 15 | } 16 | ], 17 | id: "string", 18 | media_links: ["https://api.dicebear.com/7.x/thumbs/svg?seed=591f6fe1-d6cd-479b-a327-35f6b12a08fc"], 19 | name: "Канатная дорога", 20 | order_idx: 0, 21 | pub_time: "string", 22 | question: "В Нижнем Новгороде над Волгой в нулевые протянули канатную дорогу. Аналогичная конструкция, если мы прикроем глаза как младенцы, появилась в Екатеринбурге значительно раньше и кабель также прокинут над рекой. Что смертельно опасно делать в зоне нашей канатной дороги согласно табличке, расположенной на одной из опор?", 23 | reward: 300, 24 | verification: "auto" 25 | } 26 | 27 | export const taskMock2: ITask = { 28 | correct_answers: [ 29 | "строка", "возможно" 30 | ], 31 | hints_full: [ 32 | { 33 | taken: false, 34 | text: "string" 35 | }, 36 | { 37 | taken: false, 38 | text: "string2" 39 | }, 40 | { 41 | taken: false, 42 | text: "string3" 43 | } 44 | ], 45 | id: "string1337", 46 | media_links: [""], 47 | name: "Кто-то рождается, кто-то умирает", 48 | order_idx: 1, 49 | pub_time: "string", 50 | question: "На табличке церкви есть опечатка — кто-то пропустил букву и получилось рожество. Ошибка, возможно была фатальной — иначе как объяснить граффити с годами жизни, расположенное рядом. Назовите код, расположенный на перпендикулярной граффити поверхности", 51 | reward: 100, 52 | verification: "auto" 53 | } 54 | 55 | export const taskGroupMock: ITaskGroup = { 56 | id: "string", 57 | name: "Уралмаш", 58 | order_idx: 0, 59 | pub_time: "string", 60 | tasks: [taskMock1, taskMock2] 61 | } 62 | -------------------------------------------------------------------------------- /app/api/__mocks__/User.mock.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from '@/app/types/user-interfaces'; 2 | 3 | const userMock: IUser = { 4 | username: 'prikotletka', 5 | id: '1337abc', 6 | avatar_url: 'https://api.dicebear.com/7.x/thumbs/svg?seed=591f6fe1-d6cd-479b-a327-35f6b12a08fc' 7 | } 8 | 9 | export default userMock; 10 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 4 | 5 | type CombineRequest = Request & NextApiRequest; 6 | type CombineResponse = Response & NextApiResponse; 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 9 | const handler = (req: CombineRequest, res: CombineResponse) => NextAuth(req, res, authOptions); 10 | 11 | export {handler as GET, handler as POST}; 12 | -------------------------------------------------------------------------------- /app/api/client/constants.ts: -------------------------------------------------------------------------------- 1 | export const BACKEND_URL = 'https://api.questspace.fun'; 2 | export const FRONTEND_URL = process.env.NODE_ENV === 'development' ? 'https://test.questspace.fun:3000' : 'https://questspace.fun'; 3 | export const ALLOWED_USERS_ID = [ 4 | '1e6984c6-515a-4342-a8d8-de098e621e7c', 5 | '85ce207f-0688-423a-8d7e-6f25b7d78e95', 6 | '31ba03d1-39e1-4d6a-b8a8-9dbf6cc6bed7', 7 | 'c465da31-dea8-4602-8581-0a7b4524909f' 8 | ]; 9 | export const RELEASED_FEATURE = process.env.NODE_ENV === 'development'; 10 | -------------------------------------------------------------------------------- /app/api/custom-errors.ts: -------------------------------------------------------------------------------- 1 | class HttpError extends Error { 2 | statusCode: number; 3 | 4 | message: string; 5 | 6 | constructor(statusCode: number, message?: string) { 7 | super(message ?? 'An unspecified HTTP error occurred'); 8 | this.statusCode = statusCode; 9 | this.message = message ?? 'An unspecified HTTP error occurred'; 10 | } 11 | } 12 | 13 | export default HttpError; -------------------------------------------------------------------------------- /app/api/settings.ts: -------------------------------------------------------------------------------- 1 | import { BACKEND_URL, FRONTEND_URL } from '@/app/api/client/constants'; 2 | 3 | const API_URL = 4 | process.env.NODE_ENV === 'development' ? 'http://localhost:8080' : BACKEND_URL; 5 | 6 | export const localHeaders = new Headers([['Origin', FRONTEND_URL]]); 7 | 8 | export default API_URL; 9 | -------------------------------------------------------------------------------- /app/api/uploadToS3.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; 4 | 5 | export default async function uploadToS3( 6 | key: string, 7 | fileType: string, 8 | body: FormData, 9 | ) { 10 | const file = body.get('file') as Blob; 11 | const arrayBuffer = await file.arrayBuffer(); 12 | const buffer = Buffer.from(arrayBuffer); 13 | 14 | const bucketName = 'questspace-img'; 15 | const s3Client = new S3Client({ 16 | region: 'ru-central1', 17 | endpoint: 'https://storage.yandexcloud.net', 18 | requestChecksumCalculation: 'WHEN_REQUIRED', 19 | forcePathStyle: true, 20 | credentials: { 21 | accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '', 22 | secretAccessKey: process.env.AWS_SECRET_KEY_ID ?? '', 23 | } 24 | }); 25 | 26 | const params = { 27 | Bucket: bucketName, 28 | Key: key, 29 | Body: buffer, 30 | ContentType: fileType, 31 | }; 32 | 33 | const command = new PutObjectCommand(params); 34 | try { 35 | await s3Client.send(command); 36 | return `https://storage.yandexcloud.net/${bucketName}/${key}`; 37 | } catch (err) { 38 | throw new Error('An error occurred during image upload'); 39 | } 40 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Questspace-v2/questspace-frontend/24ba50f57ebd02b895409f32b5eea97c0fe8d4b5/app/favicon.ico -------------------------------------------------------------------------------- /app/main.scss: -------------------------------------------------------------------------------- 1 | @use "../components/component-dir"; 2 | @use '../globals' as *; 3 | 4 | * { 5 | -webkit-tap-highlight-color: transparent; 6 | } 7 | 8 | *:focus-visible { 9 | outline: 4px solid #91d5ff; 10 | transition: outline-offset 0s, outline 0s; 11 | border-radius: 2px; 12 | outline-offset: 1px; 13 | opacity: unset; 14 | } 15 | 16 | .off-screen { 17 | position: absolute; 18 | left: -99999rem; 19 | } 20 | 21 | .ant-btn.ant-btn-default.ant-btn-dangerous { 22 | border-color: var(--stroke-secondary); 23 | } 24 | 25 | .light-description { 26 | color: var(--text-secondary); 27 | margin: 0; 28 | } 29 | 30 | .line-break { 31 | user-select: text; 32 | overflow-wrap: break-word; 33 | } 34 | 35 | .ant-radio-wrapper .ant-radio { 36 | --ant-radio-radio-color: var(--text-blue); 37 | 38 | .ant-radio-inner { 39 | background-color: transparent; 40 | border-color: var(--stroke-secondary); 41 | 42 | } 43 | 44 | &.ant-radio-checked .ant-radio-inner { 45 | background-color: transparent; 46 | border-color: var(--text-blue); 47 | } 48 | } 49 | 50 | // потому что меня искренне заебало моргание при смене темы. решает 90% морганий. 51 | [class^="ant"] { 52 | transition: unset !important; 53 | * { 54 | transition: unset !important; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/metadata.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | import { FRONTEND_URL } from '@/app/api/client/constants'; 3 | 4 | const mainMetadata: Metadata = { 5 | metadataBase: new URL(FRONTEND_URL), 6 | keywords: ['Квестспейс', 'Квест спейс', 'Questspace', 'Quest space', 'Квест', 'Матмех', 'Мат-мех'], 7 | title: { 8 | default: 'Квестспейс', 9 | template: `%s | Квестспейс` 10 | }, 11 | description: 'Квестспейс — движок для городских квестов. Проводите квесты в городе, а сервис возьмет на себя прием ответов и подсчет баллов.', 12 | openGraph: { 13 | description: 'Квестспейс — движок для городских квестов. Проводите квесты в городе, а сервис возьмет на себя прием ответов и подсчет баллов.', 14 | images: [''] 15 | }, 16 | }; 17 | 18 | export default mainMetadata; 19 | -------------------------------------------------------------------------------- /app/robots.ts: -------------------------------------------------------------------------------- 1 | import {MetadataRoute} from 'next'; 2 | import { FRONTEND_URL } from '@/app/api/client/constants'; 3 | 4 | export default function robots(): MetadataRoute.Robots { 5 | return { 6 | rules: { 7 | userAgent: '*', 8 | allow: ['/', '/auth', '/quest/'], 9 | disallow: [] 10 | }, 11 | sitemap: `${FRONTEND_URL}/sitemap.xml` 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { FRONTEND_URL } from '@/app/api/client/constants'; 2 | import getBackendQuests from '@/components/QuestTabs/QuestTabs.server'; 3 | import { IQuest } from '@/app/types/quest-interfaces'; 4 | 5 | 6 | export default async function sitemap() { 7 | let nextPageId: string | undefined = ''; 8 | const questIds: string[] = []; 9 | while (nextPageId !== undefined) { 10 | // eslint-disable-next-line no-await-in-loop 11 | const data = await getBackendQuests('all', nextPageId, '50'); 12 | questIds.push(...(data?.quests ?? []).map((quest: IQuest) => quest.id) ?? []); 13 | 14 | nextPageId = data?.next_page_id; 15 | } 16 | 17 | const questsSitemap = questIds.map((id: string) => ({ 18 | url:`${FRONTEND_URL}/quest/${id}`, 19 | lastModified: new Date(), 20 | priority: 0.5 21 | })) 22 | 23 | return [ 24 | { 25 | url: FRONTEND_URL, 26 | lastModified: new Date(), 27 | priority: 1 28 | }, 29 | { 30 | url: `${FRONTEND_URL}/auth`, 31 | lastModified: new Date(), 32 | priority: 0.8 33 | }, 34 | ...questsSitemap 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /app/types/json-data.ts: -------------------------------------------------------------------------------- 1 | export type Data = Record 2 | 3 | export type JSONValue = 4 | | string 5 | | number 6 | | boolean 7 | | { [x: string]: JSONValue } 8 | | JSONValue[]; 9 | -------------------------------------------------------------------------------- /app/types/user-interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | avatar_url: string, 3 | id: string, 4 | username: string, 5 | } 6 | 7 | export interface IUserUpdate { 8 | avatar_url?: string, 9 | username?: string 10 | } 11 | 12 | export interface IUserUpdateResponse { 13 | user: IUser, 14 | access_token: string 15 | } 16 | 17 | export interface IPasswordUpdate { 18 | old_password: string, 19 | new_password: string 20 | } 21 | 22 | export interface ISignIn { 23 | username: string, 24 | password: string 25 | } 26 | 27 | export interface ISignInResponse { 28 | user: IUser, 29 | access_token: string 30 | } 31 | 32 | export interface IUserCreate extends ISignIn { 33 | avatar_url?: string 34 | } 35 | 36 | export interface ITeam { 37 | captain: IUser, 38 | id: string, 39 | invite_link: string, 40 | members: IUser[], 41 | name: string, 42 | score: number, 43 | registration_status?: 'ON_CONSIDERATION' | 'ACCEPTED' 44 | } 45 | -------------------------------------------------------------------------------- /components/AuthForm/AuthForm.scss: -------------------------------------------------------------------------------- 1 | .page-auth { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 16px; 5 | box-sizing: content-box; 6 | height: 100vh; 7 | justify-content: center; 8 | align-items: center; 9 | } 10 | 11 | .content__wrapper.page-auth__content-wrapper { 12 | flex-direction: column; 13 | max-width: 320px; 14 | width: 320px; 15 | box-sizing: border-box; 16 | padding: 32px 24px 24px 24px; 17 | 18 | input:-webkit-autofill, 19 | input:-webkit-autofill:focus { 20 | transition: background-color 0s 600000s, color 0s 600000s !important; 21 | } 22 | } 23 | 24 | .content__wrapper.page-auth__content-wrapper:has(.page-auth__change-button) { 25 | padding: 0; 26 | } 27 | 28 | .auth-form__header { 29 | display: flex; 30 | flex-direction: column; 31 | gap: 14px; 32 | user-select: none; 33 | } 34 | 35 | .auth-form__title { 36 | margin: 0; 37 | } 38 | 39 | .auth-form__body { 40 | display: flex; 41 | flex-direction: column; 42 | gap: 8px; 43 | 44 | &:first-of-type { 45 | padding-top: 24px; 46 | } 47 | 48 | .error-message_unauthorized { 49 | color: var(--text-red); 50 | font-size: 12px; 51 | font-weight: bold; 52 | margin: 0; 53 | line-height: 14px; 54 | } 55 | 56 | .ant-form-item:first-of-type { 57 | padding-top: 8px; 58 | } 59 | 60 | .error-message_unauthorized + .ant-form-item { 61 | padding: 0; 62 | } 63 | 64 | .ant-form-item { 65 | margin: 0; 66 | 67 | &.auth-form__submit-button { 68 | padding: 4px 0 0; 69 | } 70 | 71 | &.auth-form__google-button { 72 | padding: 16px 0 0; 73 | 74 | .ant-btn { 75 | display: flex; 76 | align-items: center; 77 | justify-content: center; 78 | gap: 10px; 79 | 80 | .anticon+span { 81 | margin-inline-start: 0; 82 | } 83 | } 84 | } 85 | } 86 | 87 | .ant-input-affix-wrapper { 88 | border-radius: 2px; 89 | 90 | svg { 91 | fill: var(--text-default); 92 | } 93 | } 94 | 95 | .ant-input-status-error { 96 | svg { 97 | fill: var(--text-default); 98 | } 99 | } 100 | } 101 | 102 | .ant-btn.page-auth__change-button { 103 | display: flex; 104 | align-items: center; 105 | justify-content: center; 106 | max-width: 320px; 107 | } 108 | -------------------------------------------------------------------------------- /components/AuthForm/AuthForm.types.ts: -------------------------------------------------------------------------------- 1 | export enum Auth { 2 | LOGIN = 'login', 3 | SIGNUP = 'sign-up', 4 | } 5 | 6 | export type AuthFormTypes = Auth.LOGIN | Auth.SIGNUP; 7 | export interface TitleDictionary { 8 | pageHeader: string; 9 | formTitle: string; 10 | submitButton: string; 11 | changeFormButton: string; 12 | } 13 | export const LoginDictionary : TitleDictionary = { 14 | pageHeader: 'Вход в\u00A0Квестспейс', 15 | formTitle: 'Вход', 16 | submitButton: 'Войти', 17 | changeFormButton: 'Зарегистрироваться' 18 | } 19 | 20 | export const SignupDictionary : TitleDictionary = { 21 | pageHeader: 'Регистрация', 22 | formTitle: 'Регистрация', 23 | submitButton: 'Зарегистрироваться', 24 | changeFormButton: 'У меня уже есть учетная запись' 25 | } 26 | -------------------------------------------------------------------------------- /components/Background/Background.scss: -------------------------------------------------------------------------------- 1 | .background__wrapper { 2 | position: absolute; 3 | display: flex; 4 | width: 100%; 5 | height: 100%; 6 | justify-content: center; 7 | flex-grow: 0; 8 | flex-shrink: 0; 9 | overflow: hidden; 10 | } 11 | 12 | .background__wrapper >img { 13 | user-drag: none; 14 | user-select: none; 15 | -moz-user-select: none; 16 | -webkit-user-drag: none; 17 | -webkit-user-select: none; 18 | -ms-user-select: none; 19 | } 20 | 21 | .background_footer { 22 | height: inherit; 23 | width: 100%; 24 | } 25 | 26 | html[data-theme="dark"] { 27 | .background_footer, .background__wrapper { 28 | >img { 29 | content: url('/Questspace-Background-Dark.svg'); 30 | } 31 | 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /components/Background/Background.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { BackgroundProps } from '@/components/Background/Background.types'; 3 | 4 | export default function Background({ type, className = '' }: BackgroundProps) { 5 | switch (type) { 6 | case 'page': 7 | return ( 8 |
9 | {'Красивая 20 |
21 | ); 22 | case 'footer': 23 | return ( 24 |
25 | {'Красивая 37 |
38 | ); 39 | default: 40 | return null; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /components/Background/Background.types.ts: -------------------------------------------------------------------------------- 1 | export interface BackgroundProps { 2 | type: 'page' | 'footer'; 3 | className?: string; 4 | } 5 | -------------------------------------------------------------------------------- /components/Body/Body.scss: -------------------------------------------------------------------------------- 1 | .page-body { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | width: 100%; 6 | height: auto; 7 | margin: 0; 8 | gap: 16px; 9 | flex-grow: 1; 10 | 11 | .ant-spin { 12 | display: flex; 13 | width: 100%; 14 | height: auto; 15 | flex-grow: 1; 16 | align-items: center; 17 | justify-content: center; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /components/Body/Body.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Body({ children }: React.PropsWithChildren) { 4 | return
{children}
; 5 | } 6 | -------------------------------------------------------------------------------- /components/ContentWrapper/ContentWrapper.scss: -------------------------------------------------------------------------------- 1 | @use '../../globals' as *; 2 | 3 | .content__wrapper { 4 | position: relative; 5 | display: flex; 6 | flex-direction: column; 7 | background: var(--background-primary); 8 | max-width: $max-items-width; 9 | width: 100%; 10 | height: auto; 11 | box-sizing: border-box; 12 | border-radius: 16px; 13 | 14 | padding-left: $side-margins-32; 15 | padding-right: $side-margins-32; 16 | 17 | transition: all var(--ant-motion-duration-mid) var(--ant-motion-ease-in); 18 | } 19 | 20 | .content__wrapper.not-found__content-wrapper, .content__wrapper.invites-error__content-wrapper { 21 | display: flex; 22 | flex-direction: column; 23 | align-items: flex-start; 24 | padding: 32px 24px 24px; 25 | gap: 20px; 26 | width: 378px; 27 | 28 | background: var(--background-primary); 29 | box-shadow: 0 16px 32px rgba(0, 0, 0, 0.04); 30 | border-radius: 16px; 31 | 32 | p { 33 | font-size: 24px; 34 | margin: 0; 35 | } 36 | } 37 | 38 | .content__wrapper.invites-error__content-wrapper { 39 | .roboto-flex-header { 40 | font-size: 107px; 41 | color: var(--text-blue); 42 | } 43 | } 44 | 45 | @media (max-width: $m-breakpoint-639) { 46 | .content__wrapper { 47 | padding-left: $side-margins-24; 48 | padding-right: $side-margins-24; 49 | } 50 | } 51 | 52 | @media (max-width: $s-breakpoint-525) { 53 | .content__wrapper { 54 | padding-left: $side-margins-16; 55 | padding-right: $side-margins-16; 56 | } 57 | 58 | .content__wrapper.not-found__content-wrapper, .content__wrapper.invites-error__content-wrapper { 59 | width: 320px; 60 | 61 | p { 62 | font-size: 20px; 63 | } 64 | } 65 | 66 | .content__wrapper.invites-error__content-wrapper { 67 | .roboto-flex-header { 68 | font-size: 90px; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /components/ContentWrapper/ContentWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React from 'react'; 4 | import { ContentWrapperProps } from './ContentWrapper.types'; 5 | 6 | export default function ContentWrapper({ 7 | children, 8 | className = '', 9 | style = {} 10 | }: ContentWrapperProps) { 11 | return
{children}
; 12 | } 13 | -------------------------------------------------------------------------------- /components/ContentWrapper/ContentWrapper.types.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, ReactNode } from 'react'; 2 | 3 | export interface ContentWrapperProps { 4 | className?: string; 5 | children: ReactNode; 6 | style?: CSSProperties; 7 | } 8 | -------------------------------------------------------------------------------- /components/CustomCountdown/CustomCountdown.tsx: -------------------------------------------------------------------------------- 1 | import Countdown from 'react-countdown'; 2 | import { ComponentProps } from 'react'; 3 | 4 | export default function CustomCountdown(props: ComponentProps) { 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /components/CustomModal/CustomModal.scss: -------------------------------------------------------------------------------- 1 | @use "../../globals" as *; 2 | 3 | .custom-modal, .img-crop-modal { 4 | &.ant-modal .ant-modal-content { 5 | padding: 32px 32px 40px 32px; 6 | 7 | .ant-modal-close { 8 | width: 24px; 9 | height: 24px; 10 | 11 | & svg { 12 | width: 24px; 13 | height: 24px; 14 | } 15 | } 16 | } 17 | 18 | &-header { 19 | margin: 0 0 24px; 20 | font-size: $small-font-size; 21 | } 22 | 23 | &-header-large { 24 | margin: 0 0 24px; 25 | font-size: $medium-font-size; 26 | 27 | @media (max-width: $xl-breakpoint-1279) { 28 | font-size: $small-font-size; 29 | } 30 | } 31 | 32 | .ant-form-item { 33 | margin: 0; 34 | } 35 | 36 | :not(.ant-col, .ant-row) >.ant-form-item:not(.ant-form-item:first-child) { 37 | margin-top: 16px; 38 | } 39 | } 40 | 41 | @media (max-width: $m-breakpoint-639) { 42 | .ant-modal-root .ant-modal.custom-modal { 43 | margin: 0; 44 | max-width: unset; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /components/CustomModal/CustomModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from 'antd'; 2 | import { ComponentProps, useMemo } from 'react'; 3 | import classNames from 'classnames'; 4 | import { getCenter } from '@/lib/utils/utils'; 5 | 6 | export const customModalClassname = 'custom-modal'; 7 | 8 | export default function CustomModal ({children, ...props}: ComponentProps) { 9 | const { clientWidth, clientHeight } = typeof document !== "undefined" ? document.body : { clientWidth: 0, clientHeight: 0 }; 10 | const centerPosition = useMemo(() => getCenter(clientWidth, clientHeight), [clientWidth, clientHeight]); 11 | return ( 12 | 17 | {children} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components/ExitButton/ExitButton.scss: -------------------------------------------------------------------------------- 1 | .ant-btn.exit__button { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | gap: 10px; 6 | 7 | &.ant-btn >.anticon+span { 8 | margin-inline-start: 1px; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /components/ExitButton/ExitButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from 'antd'; 2 | import { LogoutOutlined } from '@ant-design/icons'; 3 | import React from 'react'; 4 | import { signOut } from 'next-auth/react'; 5 | 6 | 7 | interface ExitButtonProps { 8 | block?: boolean; 9 | } 10 | 11 | export default function ExitButton(props: ExitButtonProps) { 12 | const { block } = props; 13 | 14 | // должен чиститься state и совершаться signOut 15 | const handleClick = async () => { 16 | await signOut(); 17 | } 18 | 19 | return ( 20 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /components/Footer/Footer.scss: -------------------------------------------------------------------------------- 1 | @use '../../globals' as *; 2 | 3 | $footer-height-lg: 256px; 4 | $footer-height-md: 192px; 5 | 6 | .page-footer__wrapper { 7 | display: flex; 8 | width: 100%; 9 | height: $footer-height-lg; 10 | box-sizing: content-box; 11 | overflow: hidden; 12 | margin: 32px 0 0 0; 13 | flex-shrink: 0; 14 | flex-grow: 0; 15 | } 16 | 17 | .page-footer { 18 | position: absolute; 19 | display: flex; 20 | justify-content: center; 21 | width: 100%; 22 | height: $footer-height-lg; 23 | box-sizing: border-box; 24 | flex-shrink: 0; 25 | flex-grow: 0; 26 | } 27 | 28 | .page-footer__items { 29 | width: 100%; 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | gap: 32px; 34 | margin: 0 $side-margins-24; 35 | flex-shrink: 0; 36 | flex-grow: 0; 37 | transition: ease gap .5s; 38 | } 39 | 40 | .footer-text { 41 | font-family: "Manrope", sans-serif; 42 | font-style: normal; 43 | font-size: 16px; 44 | font-weight: 700; 45 | color: var(--text-secondary); 46 | line-height: 24px; 47 | margin: 0; 48 | padding: 0; 49 | -webkit-user-select: none; 50 | -khtml-user-select: none; 51 | -moz-user-select: none; 52 | -o-user-select: none; 53 | user-select: none; 54 | 55 | html[data-theme="dark"] & { 56 | color: var(--text-default); 57 | } 58 | } 59 | 60 | @media (max-width: $xl-breakpoint-1279) { 61 | .page-footer__wrapper { 62 | height: $footer-height-md; 63 | } 64 | 65 | .page-footer { 66 | height: $footer-height-md; 67 | justify-content: flex-start; 68 | } 69 | 70 | .page-footer__items { 71 | flex-direction: column; 72 | align-items: flex-start; 73 | gap: 8px; 74 | flex-shrink: unset; 75 | transition: ease gap .5s; 76 | } 77 | 78 | .footer-logo { 79 | width: 245px; 80 | height: auto; 81 | } 82 | } 83 | 84 | @media (min-width: 1920px) { 85 | .footer-background { 86 | object-fit: cover; 87 | width: 100%; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /components/Footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Logotype from '@/components/Logotype/Logotype'; 2 | import Background from '@/components/Background/Background'; 3 | 4 | 5 | export default function Footer() { 6 | return ( 7 |
8 | 9 |
10 |
11 | 16 |
17 | from mathmech with ❤️ 18 |
19 | since 2020 20 |
21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/Header/Header.scss: -------------------------------------------------------------------------------- 1 | @use '../../globals' as *; 2 | 3 | .page-header { 4 | position: sticky; 5 | transition: top 0.3s ease-in-out; 6 | top: 0; 7 | z-index: 1000; 8 | display: flex; 9 | justify-content: center; 10 | width: 100%; 11 | height: 48px; 12 | background-color: var(--background-primary); 13 | box-sizing: content-box; 14 | 15 | &.page-header__hidden { 16 | top: -48px; 17 | } 18 | } 19 | 20 | .page-header__items { 21 | max-width: 1240px; 22 | width: 100%; 23 | display: flex; 24 | justify-content: space-between; 25 | align-items: center; 26 | } 27 | 28 | @media (max-width: $xl-breakpoint-1279) { 29 | .page-header__items { 30 | margin: 0 16px 0 24px; 31 | } 32 | } 33 | 34 | @media (max-width: $m-breakpoint-639) { 35 | .page-header__items { 36 | margin: 0 0 0 16px; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import HeaderAvatar from '@/components/Header/HeaderAvatar/HeaderAvatar'; 4 | import Logotype from '@/components/Logotype/Logotype'; 5 | import { CSSProperties, useEffect, useRef, useState } from 'react'; 6 | import Link from 'next/link'; 7 | import { Button } from 'antd'; 8 | import { getRedirectParams } from '@/lib/utils/utils'; 9 | 10 | 11 | const pointerCursor: CSSProperties = { 12 | cursor: 'pointer', 13 | }; 14 | 15 | interface HeaderProps { 16 | isAuthorized: boolean 17 | } 18 | 19 | export default function Header({isAuthorized} : HeaderProps) { 20 | const redirectParams = getRedirectParams(); 21 | const isValidRedirect = redirectParams.get('route') === 'quest'; 22 | const [lastScrollY, setLastScrollY] = useState(0); 23 | const headerRef = useRef(null); 24 | const [headerHeight, setHeaderHeight] = useState(0); 25 | 26 | useEffect(() => { 27 | if (headerRef.current) { 28 | setHeaderHeight(headerRef.current.offsetHeight); 29 | } 30 | }, []); 31 | 32 | useEffect(() => { 33 | const handleScroll = () => { 34 | const currentScrollY = window.scrollY; 35 | 36 | // Определяем, скроллим вниз или вверх 37 | const scrollingDown = currentScrollY > lastScrollY; 38 | 39 | // Показываем хэдер если: 40 | // 1. Скроллим вверх 41 | // 2. Мы в самом верху страницы 42 | // 3. Мы еще не проскроллили высоту хэдера 43 | if (!scrollingDown || currentScrollY < headerHeight || currentScrollY <= 0) { 44 | headerRef?.current?.classList.remove('page-header__hidden'); 45 | } else { 46 | // Скрываем только если скроллим вниз И проскроллили больше чем высота хэдера 47 | headerRef?.current?.classList.add('page-header__hidden'); 48 | } 49 | 50 | setLastScrollY(currentScrollY); 51 | }; 52 | 53 | window.addEventListener('scroll', handleScroll, { passive: true }); 54 | 55 | return () => { 56 | window.removeEventListener('scroll', handleScroll); 57 | }; 58 | }, [lastScrollY, headerHeight]); 59 | 60 | return ( 61 |
62 |
63 | 64 | 65 | 66 | {isAuthorized ? 67 | 68 | : 69 | 70 | 71 | 72 | } 73 |
74 |
75 | ); 76 | } -------------------------------------------------------------------------------- /components/Header/HeaderAvatar/HeaderAvatar.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .header-avatar__dropdown.ant-dropdown .ant-dropdown-menu { 4 | padding: 4px 0; 5 | } 6 | 7 | .header-avatar__frame { 8 | cursor: pointer; 9 | } 10 | 11 | .header-avatar__button { 12 | display: flex; 13 | flex: 50px; 14 | align-items: center; 15 | height: 48px; 16 | width: auto; 17 | padding: 0 8px; 18 | gap: 4px; 19 | transition: background-color .2s ease; 20 | cursor: pointer; 21 | border: none; 22 | background-color: transparent; 23 | } 24 | 25 | .header-avatar__button:focus { 26 | border: none; 27 | } 28 | 29 | .header-avatar__image { 30 | user-select: none; 31 | } 32 | 33 | .header-dropdown_open { 34 | background-color: var(--background-secondary); 35 | transition: background-color .2s ease; 36 | } 37 | -------------------------------------------------------------------------------- /components/Header/HeaderAvatar/HeaderAvatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useState } from 'react'; 4 | import { Dropdown, MenuProps } from 'antd'; 5 | import { DownOutlined } from '@ant-design/icons'; 6 | import Image from 'next/image'; 7 | import { signOut, useSession } from 'next-auth/react'; 8 | import Link from 'next/link'; 9 | import ThemeChanger from '@/components/ThemeChanger/ThemeChanger'; 10 | import AvatarStub from '@/components/Profile/AvatarStub/AvatarStub'; 11 | 12 | 13 | export default function HeaderAvatar() { 14 | const {data: session} = useSession(); 15 | const {image: avatarUrl} = session?.user ?? {}; 16 | const [open, setOpen] = useState(false); 17 | const openClassName: string = open ? 'header-dropdown_open' : ''; 18 | 19 | const handleMenuClick: MenuProps['onClick'] = () => { 20 | setOpen(false); 21 | }; 22 | 23 | const handleOpenChange = (flag: boolean) => { 24 | setOpen(flag); 25 | }; 26 | 27 | const items: MenuProps['items'] = [ 28 | { 29 | label: Мой профиль, 30 | key: '1', 31 | }, 32 | { 33 | label: { 35 | event.preventDefault(); 36 | await signOut()} 37 | } style={{color: 'var(--text-red)'}}>Выйти, 38 | key: '2', 39 | }, 40 | { 41 | type: 'divider', 42 | }, 43 | { 44 | key: '3', 45 | label: , 46 | }, 47 | ]; 48 | 49 | return ( 50 |
51 | 62 | 78 | 79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /components/Header/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "Header"; 2 | @forward "HeaderAvatar/HeaderAvatar"; 3 | -------------------------------------------------------------------------------- /components/Logotype/Logotype.scss: -------------------------------------------------------------------------------- 1 | @use '../../globals' as *; 2 | 3 | html[data-theme="dark"] .logotype { 4 | &_text { 5 | filter: grayscale(100%) brightness(200%); 6 | } 7 | 8 | &_icon { 9 | filter: invert(100%) grayscale(100%) brightness(300%); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /components/Logotype/Logotype.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { CSSProperties } from 'react'; 3 | import classNames from 'classnames'; 4 | 5 | interface LogotypeProps { 6 | className?: string; 7 | style?: CSSProperties; 8 | width: number; 9 | type: 'icon' | 'text'; 10 | } 11 | export default function Logotype(props: LogotypeProps) { 12 | const {width, 13 | type, 14 | style = {}, 15 | className = ''} = props; 16 | 17 | if (type === 'text') { 18 | const aspectRatio = 809/104; 19 | return ( 20 | {'Текстовый 34 | ); 35 | } 36 | 37 | if (type === 'icon') { 38 | return ( 39 | {'Иконка-логотип 52 | ); 53 | } 54 | 55 | return null; 56 | } 57 | -------------------------------------------------------------------------------- /components/NextAuthProvider/NextAuthProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SessionProvider } from 'next-auth/react'; 4 | import { Session } from 'next-auth'; 5 | import React, { ReactNode } from 'react'; 6 | import SessionRefetchEvents from '@/components/NextAuthProvider/SessionRefetchEvents'; 7 | 8 | export default function NextAuthProvider({children, session}: {children: ReactNode, session: Session | null}) { 9 | return ( 10 | 15 | 16 | {children} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /components/NextAuthProvider/SessionRefetchEvents.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { getSession, useSession } from 'next-auth/react'; 5 | 6 | export default function SessionRefetchEvents() { 7 | const {update} = useSession(); 8 | 9 | useEffect(() => { 10 | const refetchSession = async () => { 11 | // console.log("Refetching session due to focus or reconnect..."); 12 | const session = await getSession(); 13 | if (update) { 14 | await update(session); 15 | } 16 | 17 | 18 | // console.log(session); 19 | }; 20 | 21 | window.addEventListener("online", refetchSession); 22 | window.addEventListener("focus", refetchSession); 23 | 24 | return () => { 25 | window.removeEventListener("online", refetchSession); 26 | window.removeEventListener("focus", refetchSession); 27 | }; 28 | }, []); 29 | 30 | return null; 31 | } 32 | -------------------------------------------------------------------------------- /components/Profile/AvatarStub/AvatarStub.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | interface Props { 4 | width: number; 5 | height: number; 6 | }; 7 | 8 | export default function AvatarStub({ width, height }: Props) { 9 | return ( 10 |
14 | ); 15 | } -------------------------------------------------------------------------------- /components/Profile/EditProfile/EditAvatar/EditAvatar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { message, Upload } from 'antd'; 3 | import ImgCrop from 'antd-img-crop'; 4 | import { updateUser } from '@/app/api/api'; 5 | import { ModalEnum, SubModalProps } from '@/components/Profile/EditProfile/EditProfile.helpers'; 6 | import useBreakpoint from 'antd/es/grid/hooks/useBreakpoint'; 7 | import client from '@/app/api/client/client'; 8 | import { UploadRequestOption } from 'rc-upload/lib/interface'; 9 | import { useSession } from 'next-auth/react'; 10 | import { RcFile } from 'antd/es/upload'; 11 | import { getCenter, uid } from '@/lib/utils/utils'; 12 | import { IUserUpdateResponse } from '@/app/types/user-interfaces'; 13 | 14 | export default function EditAvatar({children, setCurrentModal}: SubModalProps) { 15 | const [messageApi, contextHolder] = message.useMessage(); 16 | const {clientWidth, clientHeight} = document.body; 17 | const centerPosition = useMemo(() => getCenter(clientWidth, clientHeight), [clientWidth, clientHeight]); 18 | const {data, update} = useSession(); 19 | const {id} = data?.user ?? {}; 20 | const {accessToken} = data ?? {}; 21 | const { xs } = useBreakpoint(); 22 | 23 | const error = () => { 24 | // eslint-disable-next-line no-void 25 | void messageApi.open({ 26 | type: 'error', 27 | content: 'Неправильный формат', 28 | }); 29 | }; 30 | 31 | const networkError = () => { 32 | // eslint-disable-next-line no-void 33 | void messageApi.open({ 34 | type: 'error', 35 | content: 'Обновите страницу', 36 | }); 37 | }; 38 | 39 | const avatarError = () => { 40 | // eslint-disable-next-line no-void 41 | void messageApi.open({ 42 | type: 'error', 43 | content: 'Произошла ошибка при загрузке медиа', 44 | }); 45 | }; 46 | 47 | const handleEditAvatarClose = () => { 48 | setCurrentModal!(ModalEnum.EDIT_PROFILE) 49 | }; 50 | 51 | const beforeCrop = (file: RcFile) => { 52 | const fileType = (file as File).type; 53 | if (!fileType.startsWith('image/')) { 54 | error(); 55 | throw new Error('Неправильный формат'); 56 | } 57 | 58 | setCurrentModal!(ModalEnum.EDIT_AVATAR); 59 | }; 60 | 61 | const customRequest = async ({file}: UploadRequestOption) => { 62 | if (!id || !accessToken) { 63 | networkError(); 64 | return; 65 | } 66 | 67 | const fileType = (file as File).type; 68 | if (!fileType.startsWith('image/')) { 69 | return; 70 | } 71 | 72 | const key = `users/${uid()}`; 73 | 74 | try { 75 | await client.handleS3Request(key, fileType, file); 76 | const resp = await updateUser( 77 | id, 78 | {avatar_url: `https://storage.yandexcloud.net/questspace-img/${key}`}, 79 | accessToken 80 | ) as IUserUpdateResponse; 81 | await update({image: resp.user.avatar_url, accessToken: resp.access_token}); 82 | } catch (err) { 83 | avatarError(); 84 | } 85 | } 86 | 87 | return ( 88 | <> 89 | {contextHolder} 90 | 99 | 106 | {children} 107 | 108 | 109 | 110 | ); 111 | 112 | } 113 | -------------------------------------------------------------------------------- /components/Profile/EditProfile/EditName/EditName.tsx: -------------------------------------------------------------------------------- 1 | import { ModalEnum, SubModalProps } from '@/components/Profile/EditProfile/EditProfile.helpers'; 2 | import { Button, Form, Input, message } from 'antd'; 3 | import React, { useState } from 'react'; 4 | import useBreakpoint from 'antd/es/grid/hooks/useBreakpoint'; 5 | import { updateUser } from '@/app/api/api'; 6 | import { useSession } from 'next-auth/react'; 7 | import { IUserUpdateResponse } from '@/app/types/user-interfaces'; 8 | import {ValidationStatus} from '@/lib/utils/modalTypes'; 9 | import CustomModal, { customModalClassname } from '@/components/CustomModal/CustomModal'; 10 | import classNames from 'classnames'; 11 | 12 | export default function EditName({currentModal, setCurrentModal}: SubModalProps) { 13 | const [messageApi, contextHolder] = message.useMessage(); 14 | const [form] = Form.useForm(); 15 | const { xs } = useBreakpoint(); 16 | const {data, update} = useSession(); 17 | const {id} = data?.user ?? {}; 18 | const {accessToken} = data ?? {}; 19 | 20 | const [errorMsg, setErrorMsg] = useState(''); 21 | const [validationStatus, setValidationStatus] = useState('success'); 22 | 23 | const handleError = (msg = 'Логин уже занят') => { 24 | setValidationStatus('error'); 25 | setErrorMsg(msg); 26 | }; 27 | 28 | const networkError = () => { 29 | // eslint-disable-next-line no-void 30 | void messageApi.open({ 31 | type: 'error', 32 | content: 'Обновите страницу', 33 | }); 34 | }; 35 | 36 | const handleSubmit = async () => { 37 | form.validateFields().catch(err => {throw err}); 38 | 39 | if (!id || !accessToken) { 40 | networkError(); 41 | return; 42 | } 43 | 44 | const username = (form.getFieldValue('username') as string).trim(); 45 | if (!username) { 46 | handleError('Логин не может быть пустым'); 47 | return; 48 | } 49 | const resp = await updateUser(id, { username }, accessToken) 50 | .then(response => response as IUserUpdateResponse) 51 | .catch((error) => { 52 | throw error; 53 | }); 54 | if (resp) { 55 | await update({name: resp.user.username, accessToken: resp.access_token}).then(() => setCurrentModal!(ModalEnum.EDIT_PROFILE)); 56 | } else { 57 | handleError(); 58 | } 59 | }; 60 | 61 | const onCancel = () => { 62 | form.resetFields(); 63 | setValidationStatus('success'); 64 | setErrorMsg(''); 65 | setCurrentModal!(ModalEnum.EDIT_PROFILE); 66 | }; 67 | 68 | const handleFieldChange = () => { 69 | setValidationStatus('success'); 70 | setErrorMsg(''); 71 | }; 72 | 73 | return ( 74 | Изменить 81 | логин} 82 | footer={null} 83 | > 84 | {contextHolder} 85 |
86 | 90 | 94 | 95 | 96 | 97 | 98 |
99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /components/Profile/EditProfile/EditPassword/EditPassword.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | 3 | import { ModalEnum, SubModalProps } from '@/components/Profile/EditProfile/EditProfile.helpers'; 4 | import { Button, Form, Input, message } from 'antd'; 5 | import React, { useState } from 'react'; 6 | import useBreakpoint from 'antd/es/grid/hooks/useBreakpoint'; 7 | import { updatePassword } from '@/app/api/api'; 8 | import { IUser } from '@/app/types/user-interfaces'; 9 | import { useSession } from 'next-auth/react'; 10 | import {ValidationStatus} from '@/lib/utils/modalTypes'; 11 | import CustomModal from '@/components/CustomModal/CustomModal'; 12 | 13 | export default function EditPassword({currentModal, setCurrentModal}: SubModalProps) { 14 | const [messageApi, contextHolder] = message.useMessage(); 15 | const [form] = Form.useForm(); 16 | const { xs } = useBreakpoint(); 17 | const {data} = useSession(); 18 | const {id} = data?.user ?? {}; 19 | const {accessToken} = data ?? {}; 20 | 21 | const [errorMsg, setErrorMsg] = useState(''); 22 | const [validationStatus, setValidationStatus] = useState('success'); 23 | 24 | 25 | const networkError = () => { 26 | // eslint-disable-next-line no-void 27 | void messageApi.open({ 28 | type: 'error', 29 | content: 'Обновите страницу', 30 | }); 31 | }; 32 | 33 | const handleError = (msg = 'Проверьте, правильно ли введен старый пароль') => { 34 | setErrorMsg(msg); 35 | setValidationStatus('error'); 36 | }; 37 | 38 | const handleSubmit = async () => { 39 | form.validateFields().catch(err => {throw err}); 40 | 41 | if (!id || !accessToken) { 42 | networkError(); 43 | return; 44 | } 45 | 46 | const oldPassword = form.getFieldValue('oldPassword') as string; 47 | const newPassword = form.getFieldValue('password') as string; 48 | const resp = await updatePassword( 49 | id, 50 | {old_password: oldPassword, new_password: newPassword}, 51 | accessToken 52 | ) 53 | .then(response => response as IUser) 54 | .catch((error) => { 55 | handleError(); 56 | throw error; 57 | }); 58 | if (resp) { 59 | setCurrentModal!(ModalEnum.EDIT_PROFILE); 60 | } else { 61 | handleError(); 62 | } 63 | } 64 | 65 | return ( 66 | setCurrentModal!(ModalEnum.EDIT_PROFILE)} 70 | width={xs ? '100%' : 400} 71 | centered 72 | title={

Изменить пароль

} 73 | footer={null} 74 | > 75 | {contextHolder} 76 |
77 | 78 | 79 | 80 | 81 | 82 | 83 | ({ 88 | validator(_, value) { 89 | const pswValue = getFieldValue('password') as string; 90 | if ((!value && !pswValue)|| pswValue === value) { 91 | return Promise.resolve(); 92 | } 93 | return Promise.reject(new Error('Пароли не совпадают')); 94 | }, 95 | }) 96 | ]} 97 | > 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /components/Profile/EditProfile/EditProfile.helpers.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const enum ModalEnum { 4 | EDIT_PROFILE, 5 | EDIT_AVATAR, 6 | EDIT_NAME, 7 | EDIT_PASSWORD 8 | } 9 | 10 | export type ModalType = ModalEnum | null; 11 | 12 | export interface SubModalProps { 13 | children?: JSX.Element, 14 | setCurrentModal?: React.Dispatch>, 15 | currentModal?: ModalType, 16 | } 17 | -------------------------------------------------------------------------------- /components/Profile/EditProfile/EditProfile.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .ant-btn.edit-profile__button { 4 | display: flex; 5 | align-items: center; 6 | gap: 10px; 7 | 8 | &.ant-btn >.anticon+span { 9 | margin-inline-start: 0; 10 | } 11 | } 12 | 13 | .img-crop-modal { 14 | .ant-modal-header { 15 | display: none; 16 | } 17 | 18 | .ant-modal-body { 19 | margin-top: 24px; 20 | } 21 | } 22 | 23 | .edit-profile__avatar { 24 | display: flex; 25 | flex-direction: column; 26 | width: min-content; 27 | 28 | .ant-upload-wrapper .ant-upload-select { 29 | display: flex; 30 | width: 100%; 31 | justify-content: center; 32 | } 33 | } 34 | 35 | .edit-profile-header { 36 | margin: 0 0 24px; 37 | 38 | &.responsive-header-h2 { 39 | font-size: 32px; 40 | } 41 | } 42 | 43 | .edit-profile-subheader { 44 | padding: 16px 0 0; 45 | margin: 0; 46 | font-size: 20px; 47 | font-weight: 500; 48 | color: var(--text-default); 49 | } 50 | 51 | .edit-profile-paragraph { 52 | margin: 0; 53 | color: var(--text-default); 54 | } 55 | 56 | .edit-profile__change-button { 57 | &.ant-btn { 58 | padding: 0; 59 | } 60 | 61 | span { 62 | color: var(--text-blue); 63 | } 64 | } 65 | 66 | @media (max-width: $m-breakpoint-639) { 67 | .ant-modal-root .ant-modal.edit-profile__modal { 68 | margin: 0; 69 | max-width: unset; 70 | } 71 | 72 | .edit-profile-header.responsive-header-h2 { 73 | font-size: 24px; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /components/Profile/EditProfile/EditProfile.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from 'antd'; 4 | import React, { useEffect, useState } from 'react'; 5 | import { useSession } from 'next-auth/react'; 6 | import Image from 'next/image'; 7 | import { EditOutlined } from '@ant-design/icons'; 8 | import ExitButton from '@/components/ExitButton/ExitButton'; 9 | import useBreakpoint from 'antd/es/grid/hooks/useBreakpoint'; 10 | import { ModalEnum, ModalType } from '@/components/Profile/EditProfile/EditProfile.helpers'; 11 | import EditAvatar from '@/components/Profile/EditProfile/EditAvatar/EditAvatar'; 12 | import EditName from '@/components/Profile/EditProfile/EditName/EditName'; 13 | import EditPassword from '@/components/Profile/EditProfile/EditPassword/EditPassword'; 14 | import CustomModal from '@/components/CustomModal/CustomModal'; 15 | import AvatarStub from '../AvatarStub/AvatarStub'; 16 | 17 | 18 | export default function EditProfile() { 19 | const {data: session} = useSession(); 20 | const {name: username, image: avatarUrl} = session?.user ?? {}; 21 | const isOAuth = session?.isOAuthProvider; 22 | const { xs } = useBreakpoint(); 23 | const [currentModal, setCurrentModal] = useState(null); 24 | 25 | useEffect(() => {}, [session]); 26 | 27 | const showModal = () => { 28 | setCurrentModal(ModalEnum.EDIT_PROFILE); 29 | }; 30 | 31 | const handleCancel = () => { 32 | if (currentModal === null) { 33 | return; 34 | } 35 | 36 | if (currentModal === ModalEnum.EDIT_PROFILE) { 37 | setCurrentModal(null); 38 | return; 39 | } 40 | 41 | if (currentModal !== ModalEnum.EDIT_AVATAR) { 42 | setCurrentModal(ModalEnum.EDIT_PROFILE); 43 | } 44 | }; 45 | return ( 46 | <> 47 | 56 | } 63 | centered 64 | title={

66 | Редактирование
профиля 67 |

} 68 | 69 | > 70 | 71 |
72 | { 73 | avatarUrl ? 74 | {'avatar'} : 82 | 83 | } 84 | 85 | 91 | 92 |
93 |

Логин

94 |

{username ?? 'Аноним'}

95 | 98 |

Пароль

99 | {isOAuth ? 100 | Учетная запись привязана к аккаунту Google, для авторизации не нужен пароль : 101 | 104 | } 105 |
106 | 107 | 108 | 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /components/Profile/Profile.scss: -------------------------------------------------------------------------------- 1 | @use "../../globals" as *; 2 | 3 | .profile__content-wrapper { 4 | display: flex; 5 | flex-direction: row; 6 | width: 100%; 7 | margin: 24px 0; 8 | gap: 32px; 9 | } 10 | 11 | .profile-information { 12 | display: flex; 13 | flex-direction: column; 14 | justify-content: center; 15 | gap: 16px; 16 | 17 | & h1 { 18 | word-break: break-word; 19 | } 20 | } 21 | 22 | .profile-information__buttons { 23 | display: flex; 24 | flex-direction: row; 25 | justify-content: flex-start; 26 | flex-wrap: wrap; 27 | gap: 8px; 28 | } 29 | 30 | .avatar__image { 31 | user-select: none; 32 | } 33 | 34 | @media (max-width: $m-breakpoint-639) { 35 | .avatar__image { 36 | width: 96px; 37 | height: 96px; 38 | } 39 | 40 | .profile__content-wrapper { 41 | flex-direction: column; 42 | gap: 8px; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /components/Profile/Profile.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import ContentWrapper from '@/components/ContentWrapper/ContentWrapper'; 4 | 5 | import useBreakpoint from 'antd/es/grid/hooks/useBreakpoint'; 6 | import EditProfile from '@/components/Profile/EditProfile/EditProfile'; 7 | import ExitButton from '@/components/ExitButton/ExitButton'; 8 | import { useSession } from 'next-auth/react'; 9 | import Image from 'next/image'; 10 | import AvatarStub from './AvatarStub/AvatarStub'; 11 | 12 | export default function Profile() { 13 | const {data: session} = useSession(); 14 | const {name: username, image: avatarUrl} = session?.user ?? {}; 15 | const greetings = `Привет, ${username ? `@${username}` : 'Аноним'}!`; 16 | const { xs } = useBreakpoint(); 17 | 18 | return ( 19 | 20 |
21 | { 22 | avatarUrl ? 23 | {'avatar'} : 31 | 32 | } 33 |
34 |

{greetings}

35 |
36 | 37 | 38 |
39 |
40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/Profile/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "Profile"; 2 | @forward "EditProfile/EditProfile"; 3 | 4 | -------------------------------------------------------------------------------- /components/Quest/EditQuest/EditQuest.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .edit-quest__content-wrapper { 4 | padding-top: 24px; 5 | padding-bottom: 24px; 6 | gap: 32px; 7 | 8 | .edit-quest__body__content { 9 | display: flex; 10 | flex-direction: row; 11 | justify-content: center; 12 | gap: 32px; 13 | } 14 | 15 | .content__separator { 16 | min-width: 1px; 17 | width: 1px; 18 | height: auto; 19 | background-color: var(--stroke-secondary); 20 | } 21 | } 22 | 23 | .edit-quest__content-wrapper section { 24 | width: calc((100% - 65px) / 2); 25 | } 26 | 27 | .edit-quest__header__content { 28 | display: flex; 29 | flex-direction: column; 30 | gap: 8px; 31 | } 32 | 33 | @media (max-width: $m-breakpoint-639) { 34 | .edit-quest__content-wrapper { 35 | gap: 16px; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /components/Quest/EditQuest/QuestPreview/QuestPreview.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../globals" as *; 2 | 3 | .quest-preview__wrapper { 4 | display: flex; 5 | flex-direction: column; 6 | gap: 16px; 7 | padding-bottom: 16px; 8 | 9 | .quest-preview__information { 10 | display: flex; 11 | column-gap: 24px; 12 | row-gap: 4px; 13 | flex-wrap: wrap; 14 | color: var(--text-default); 15 | 16 | .information__block { 17 | display: flex; 18 | gap: 8px; 19 | } 20 | } 21 | 22 | .quest-preview__about { 23 | color: var(--text-default); 24 | } 25 | } 26 | 27 | .quest-preview__wrapper p { 28 | margin: 0; 29 | } 30 | 31 | .quest-preview__information .information__block img { 32 | align-self: center; 33 | } 34 | 35 | .quest-preview_default { 36 | height: 100%; 37 | min-height: 320px; 38 | display: flex; 39 | align-items: center; 40 | justify-content: center; 41 | color: var(--text-default); 42 | } 43 | 44 | .quest-preview_default p { 45 | margin: 0; 46 | width: 168px; 47 | text-align: center; 48 | } 49 | 50 | .quest-image__container { 51 | width: 100%; 52 | height: auto; 53 | display: flex; 54 | align-items: center; 55 | overflow: hidden; 56 | border-radius: 16px; 57 | } 58 | 59 | .quest-image__image { 60 | color: transparent; 61 | max-width: 100%; 62 | object-fit: contain; 63 | height: auto; 64 | } 65 | 66 | @media (max-width: $s-breakpoint-525) { 67 | .quest-preview__wrapper { 68 | .quest-preview__information { 69 | flex-direction: column; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /components/Quest/EditQuest/QuestPreview/QuestPreview.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { UploadFile } from 'antd'; 4 | import dayjs from 'dayjs'; 5 | import { useMemo } from 'react'; 6 | import { useSession } from 'next-auth/react'; 7 | import { QuestAboutForm } from '@/components/Quest/EditQuest/QuestEditor/QuestEditor'; 8 | import QuestHeader, { QuestHeaderProps } from '@/components/Quest/QuestHeader/QuestHeader'; 9 | import QuestDescription from '@/components/Quest/QuestDescription/QuestDescription'; 10 | 11 | dayjs.locale('ru') 12 | 13 | interface QuestEditorProps { 14 | form: QuestAboutForm, 15 | file: UploadFile, 16 | previousImage?: string 17 | } 18 | 19 | export default function QuestPreview({form, file, previousImage}: QuestEditorProps) { 20 | let image = useMemo(()=> file ? URL.createObjectURL(file.originFileObj as Blob) : '', [file]); 21 | const creator = useSession().data?.user; 22 | 23 | if (!form && !image || (!(image || form.image || form.name || form.description || form.startTime || form.finishTime))) { 24 | return ( 25 |
26 |

Здесь появится предпросмотр квеста

27 |
28 | ); 29 | } 30 | 31 | if (form.image && !file) { 32 | image = form.image; 33 | } 34 | 35 | const {name, description, startTime, finishTime, maxTeamCap, questType} = form; 36 | const {name: username, image: avatarUrl, id: creatorId} = creator!; 37 | const props: QuestHeaderProps = { 38 | name, 39 | description, 40 | start_time: startTime, 41 | creator: { 42 | avatar_url: avatarUrl!, 43 | username: username!, 44 | id: creatorId 45 | }, 46 | id: '', 47 | status: '', 48 | registration_deadline: '', 49 | media_link: image.trim().length > 0 ? image : previousImage!, 50 | finish_time: finishTime, 51 | access: 'public', 52 | quest_type: questType, 53 | max_team_cap: maxTeamCap ?? 0, 54 | feedback_link: '', 55 | }; 56 | 57 | return ( 58 |
59 | 60 | 61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /components/Quest/Quest.scss: -------------------------------------------------------------------------------- 1 | @use "../../globals" as *; 2 | 3 | .quest-page__content-wrapper { 4 | padding: 24px $side-margins-32; 5 | gap: 16px; 6 | } 7 | -------------------------------------------------------------------------------- /components/Quest/Quest.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IGetQuestResponse } from '@/app/types/quest-interfaces'; 3 | import QuestHeader from '@/components/Quest/QuestHeader/QuestHeader'; 4 | import QuestDescription from '@/components/Quest/QuestDescription/QuestDescription'; 5 | import QuestAdminPanel from '@/components/Quest/QuestAdminPanel/QuestAdminPanel'; 6 | import QuestParticipantsWrapper from '@/components/Quest/QuestParticipantsWrapper/QuestParticipantsWrapper'; 7 | 8 | 9 | export default function QuestMainPage({props, isCreator}: {props: IGetQuestResponse, isCreator: boolean}) { 10 | const {quest, team, leaderboard, all_teams: allTeams} = props; 11 | 12 | return ( 13 | <> 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components/Quest/QuestAdminPanel/QuestAdminPanel.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | 4 | .content__wrapper.quest-page__admin-panel { 5 | background-color: var(--background-blue-secondary); 6 | display: flex; 7 | flex-wrap: wrap; 8 | flex-direction: row; 9 | padding: 24px 17px 24px 32px; 10 | align-items: center; 11 | justify-content: space-between; 12 | 13 | & p { 14 | margin: 0; 15 | } 16 | 17 | @media screen and (max-width: $l-breakpoint-959) { 18 | padding: 24px 9px 24px 24px; 19 | } 20 | 21 | @media screen and (max-width: $xm-breakpoint-799) { 22 | padding: 24px; 23 | 24 | & button span { 25 | position: relative; 26 | left: -15px; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /components/Quest/QuestAdminPanel/QuestAdminPanel.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname } from 'next/navigation'; 4 | import ContentWrapper from '@/components/ContentWrapper/ContentWrapper'; 5 | import Link from 'next/link'; 6 | import { Button } from 'antd'; 7 | import { EditOutlined } from '@ant-design/icons'; 8 | import React from 'react'; 9 | 10 | export default function QuestAdminPanel({isCreator} : {isCreator: boolean}) { 11 | const currentPath = usePathname(); 12 | 13 | if (isCreator) { 14 | return ( 15 | 16 |

Сейчас вы смотрите на квест как обычный пользователь Квестспейса

17 | 18 | 19 | 20 | 21 |
22 | ); 23 | } 24 | 25 | return null; 26 | } 27 | -------------------------------------------------------------------------------- /components/Quest/QuestAllTeams/QuestAllTeams.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .quest-page__all-teams-table .ant-table-content { 4 | column-count: 3; 5 | 6 | @media screen and (max-width: $l-breakpoint-959) { 7 | column-count: 2; 8 | } 9 | 10 | @media screen and (max-width: $s-breakpoint-525) { 11 | column-count: 1; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /components/Quest/QuestAllTeams/QuestAllTeams.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ITeam } from '@/app/types/user-interfaces'; 4 | import { Table, TableColumnsType } from 'antd'; 5 | import React from 'react'; 6 | 7 | export default function QuestAllTeams({allTeams, currentTeam} : {allTeams?: ITeam[], currentTeam?: ITeam}) { 8 | const columns: TableColumnsType = [ 9 | { 10 | dataIndex: 'index', 11 | key: 'index', 12 | render: (_, record, index) => `${index + 1}.`, 13 | align: 'right', 14 | width: 36 15 | }, 16 | { 17 | dataIndex: 'name', 18 | key: 'name', 19 | render: (_, record) => { 20 | if (record.id === currentTeam?.name) { 21 | return {record.name}; 22 | } 23 | return record.name; 24 | }, 25 | }, 26 | ] 27 | 28 | if (!allTeams) { 29 | return null; 30 | } 31 | 32 | return ( 33 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/Quest/QuestDescription/QuestDescription.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .quest-page__description { 4 | & p { 5 | margin: 0; 6 | color: var(--text-default); 7 | } 8 | 9 | & a:not(a:visited) { 10 | color: var(--text-blue); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /components/Quest/QuestDescription/QuestDescription.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useMemo } from 'react'; 4 | import { parseToMarkdown } from '@/lib/utils/utils'; 5 | import ContentWrapper from '@/components/ContentWrapper/ContentWrapper'; 6 | import { Skeleton } from 'antd'; 7 | import Markdown from 'react-markdown'; 8 | import remarkGfm from 'remark-gfm'; 9 | import classNames from 'classnames'; 10 | 11 | interface QuestDescriptionProps { 12 | description?: string; 13 | mode: 'page' | 'edit' 14 | } 15 | 16 | export default function QuestDescription({ description, mode}: QuestDescriptionProps) { 17 | const afterParse = useMemo(() => parseToMarkdown(description), [description]); 18 | 19 | if (mode === 'page') { 20 | return ( 21 | 22 |

О квесте

23 | 24 | {afterParse?.toString()} 25 | 26 |
27 | ); 28 | } 29 | 30 | if (mode === 'edit') { 31 | return ( 32 | <> 33 | {description &&

О квесте

} 34 | {description} 35 | 36 | ); 37 | } 38 | 39 | return null; 40 | } 41 | -------------------------------------------------------------------------------- /components/Quest/QuestHeader/QuestHeader.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .quest-header__wrapper { 4 | padding: 0 !important; 5 | border-radius: 16px; 6 | background: transparent; 7 | 8 | .ant-card:not(.ant-card-bordered) { 9 | box-shadow: none; 10 | background: transparent; 11 | } 12 | 13 | .ant-card { 14 | border-radius: 16px; 15 | font-size: 14px; 16 | line-height: 22px; 17 | font-weight: 500; 18 | 19 | .ant-card-cover { 20 | background-color: var(--background-primary); 21 | width: 100%; 22 | height: auto; 23 | display: flex; 24 | align-items: center; 25 | overflow: hidden; 26 | border-radius: 16px 16px 0 0; 27 | } 28 | 29 | .ant-card-cover img { 30 | border-radius: 16px 16px 0 0; 31 | } 32 | 33 | .ant-card-body { 34 | display: flex; 35 | flex-direction: row; 36 | justify-content: space-between; 37 | column-gap: 24px; 38 | padding: 24px 32px; 39 | background: var(--background-primary); 40 | border-radius: 0 0 16px 16px; 41 | } 42 | 43 | .ant-card-body:after, .ant-card-body:before { 44 | display: none; 45 | } 46 | } 47 | 48 | .quest-preview__information { 49 | display: flex; 50 | gap: 24px; 51 | padding-top: 12px; 52 | text-wrap: nowrap; 53 | color: var(--text-default); 54 | 55 | .information__block { 56 | display: flex; 57 | gap: 8px; 58 | 59 | & img { 60 | align-self: center; 61 | } 62 | } 63 | } 64 | 65 | .quest-header { 66 | .quest-header__name { 67 | margin: 0; 68 | color: var(--text-default); 69 | word-break: break-word; 70 | } 71 | 72 | & p { 73 | margin: 0; 74 | } 75 | } 76 | 77 | .quest-header__interactive { 78 | display: flex; 79 | flex-direction: column; 80 | text-align: center; 81 | gap: 4px; 82 | justify-content: flex-end; 83 | 84 | .ant-btn { 85 | transition: none; 86 | } 87 | 88 | & button { 89 | width: 240px; 90 | } 91 | 92 | & p { 93 | width: 240px; 94 | color: var(--text-default); 95 | } 96 | } 97 | 98 | .quest-header__interactive button.already-have-team { 99 | color: var(--text-green); 100 | 101 | svg { 102 | fill: var(--icon-outlined-green); 103 | } 104 | } 105 | 106 | @media screen and (max-width: $xm-breakpoint-799) { 107 | .ant-card .ant-card-body { 108 | flex-direction: column; 109 | 110 | .quest-header__interactive { 111 | padding-top: 16px; 112 | 113 | & button { 114 | width: 100%; 115 | } 116 | 117 | & p { 118 | width: 100%; 119 | } 120 | } 121 | } 122 | 123 | .quest-header { 124 | .quest-preview__information { 125 | flex-direction: column; 126 | gap: 4px; 127 | padding-top: 16px; 128 | } 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /components/Quest/QuestParticipantsWrapper/QuestParticipantsWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { QuestStatus } from '@/components/Quest/Quest.helpers'; 2 | import { IFinalLeaderboard } from '@/app/types/quest-interfaces'; 3 | import { ITeam } from '@/app/types/user-interfaces'; 4 | import ContentWrapper from '@/components/ContentWrapper/ContentWrapper'; 5 | import QuestResults from '@/components/Quest/QuestResults/QuestResults'; 6 | import React from 'react'; 7 | import QuestTeam from '@/components/Quest/QuestTeam/QuestTeam'; 8 | import QuestAllTeams from '@/components/Quest/QuestAllTeams/QuestAllTeams'; 9 | import { getServerSession } from 'next-auth'; 10 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 11 | 12 | interface QuestParticipantsWrapperProps { 13 | status: QuestStatus | string, 14 | leaderboard?: IFinalLeaderboard, 15 | team?: ITeam, 16 | allTeams?: ITeam[], 17 | } 18 | 19 | export default async function QuestParticipantsWrapper({ status, leaderboard, team, allTeams }: QuestParticipantsWrapperProps) { 20 | const questIsFinished = status as QuestStatus === QuestStatus.StatusFinished; 21 | const session = await getServerSession(authOptions); 22 | 23 | if (!team && ((allTeams ?? []).length < 1 ?? (leaderboard?.rows ?? []).length < 1)) { 24 | return null; 25 | } 26 | 27 | return ( 28 | 29 |

{questIsFinished ? 'Результаты квеста' : 'Участники квеста'}

30 | 31 | {questIsFinished ? 32 | : 33 | 34 | } 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /components/Quest/QuestResults/QuestResults.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .results__title { 4 | font-weight: 400; 5 | font-size: 16px; 6 | line-height: 24px; 7 | margin: 0; 8 | } 9 | 10 | .results__table .ant-table-content { 11 | column-count: 2; 12 | 13 | @media screen and (max-width: $xm-breakpoint-799) { 14 | column-count: 1; 15 | } 16 | 17 | & .ant-table-row .results__team-index.ant-table-cell { 18 | padding-right: 0; 19 | } 20 | 21 | & .ant-table-row .results__team-place.ant-table-cell { 22 | padding-right: 2px; 23 | padding-left: 0; 24 | } 25 | } 26 | 27 | .results__content_waiting { 28 | display: flex; 29 | flex-direction: column; 30 | align-items: center; 31 | gap: 8px; 32 | 33 | .anticon-clock-circle > svg{ 34 | width: 112px; 35 | height: 112px; 36 | padding: 16px; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /components/Quest/QuestResults/QuestResults.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { QuestStatus } from '@/components/Quest/Quest.helpers'; 4 | import { IFinalLeaderboard, IFinalLeaderboardRow } from '@/app/types/quest-interfaces'; 5 | import { TrophyFilled } from '@ant-design/icons'; 6 | import { Table } from 'antd'; 7 | import Column from 'antd/lib/table/Column'; 8 | import React from 'react'; 9 | 10 | export default function QuestResults({ status, leaderboard }: { status: QuestStatus | string, leaderboard?: IFinalLeaderboard }) { 11 | const statusQuest = status as QuestStatus; 12 | 13 | if (!leaderboard?.rows) { 14 | return null; 15 | } 16 | 17 | if (statusQuest === QuestStatus.StatusFinished) { 18 | return ( 19 | leaderboard && ( 20 |
21 | `${index + 1}.`} 26 | align={'right'} 27 | /> 28 | 29 | record.score}/> 30 | { 34 | if (index + 1 === 1) { 35 | return 36 | } 37 | if (index + 1 === 2) { 38 | return 39 | } 40 | if (index + 1 === 3) { 41 | return 42 | } 43 | 44 | return null; 45 | }} 46 | align={'right'} 47 | width={16} 48 | /> 49 |
50 | ) 51 | ) 52 | } 53 | 54 | return null; 55 | } 56 | -------------------------------------------------------------------------------- /components/Quest/QuestTeam/CreateTeam/CreateTeam.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../globals/index" as *; 2 | 3 | .create-team-modal__content { 4 | padding: 32px 32px 40px 32px !important; 5 | 6 | .custom-modal-header-large { 7 | margin-bottom: 16px; 8 | } 9 | .ant-form-item { 10 | margin: 0; 11 | } 12 | 13 | .ant-modal-title h2 { 14 | font-size: $medium-font-size; 15 | } 16 | 17 | .ant-modal-body { 18 | display: flex; 19 | flex-direction: column; 20 | } 21 | 22 | .create-team-content__span { 23 | color: var(--text-default); 24 | font-size: 16px; 25 | } 26 | 27 | .create-team-content__span + .ant-form { 28 | padding-top: 8px; 29 | } 30 | } 31 | 32 | .ant-modal-root .ant-modal.create-team-modal { 33 | margin: 0; 34 | max-width: unset; 35 | } 36 | -------------------------------------------------------------------------------- /components/Quest/QuestTeam/InviteModal/InviteModal.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../globals/index" as *; 2 | 3 | .invite-modal__content { 4 | padding: 32px 32px 40px 32px !important; 5 | 6 | .ant-input-suffix { 7 | color: var(--stroke-secondary); 8 | transition-behavior: normal; 9 | transition-delay: 0s; 10 | transition-duration: 0.2s; 11 | transition-property: all; 12 | transition-timing-function: ease; 13 | } 14 | 15 | .ant-input-affix-wrapper-readonly { 16 | &:hover, &:focus, &:focus-within { 17 | .ant-input-suffix { 18 | color: var(--text-blue); 19 | } 20 | } 21 | } 22 | 23 | .custom-modal-header-large { 24 | margin-bottom: 16px; 25 | color: var(--text-default); 26 | } 27 | 28 | .custom-modal-header_success { 29 | color: var(--text-green); 30 | } 31 | 32 | .ant-form-item { 33 | margin: 0; 34 | } 35 | 36 | .ant-modal-body { 37 | display: flex; 38 | flex-direction: column; 39 | gap: 8px; 40 | } 41 | 42 | .invite-content__span { 43 | font-size: 16px; 44 | color: var(--text-default); 45 | } 46 | } 47 | 48 | .ant-modal-root .ant-modal.invite-modal { 49 | margin: 0; 50 | max-width: unset; 51 | 52 | .roboto-flex-header { 53 | color: var(--text-green); 54 | } 55 | } 56 | 57 | .invite-link__wrapper { 58 | display: flex; 59 | padding: 0; 60 | gap: 4px; 61 | align-items: baseline; 62 | 63 | .invite-link__link, .invite-link__text { 64 | border: none; 65 | height: auto; 66 | padding: 0; 67 | margin: 0; 68 | font-weight: 500; 69 | color: var(--text-default); 70 | } 71 | 72 | .invite-link__link { 73 | max-width: 100%; 74 | display: flex; 75 | align-items: center; 76 | font-size: 16px; 77 | color: var(--text-blue); 78 | } 79 | 80 | .invite-link__link span { 81 | white-space: nowrap; 82 | overflow: hidden; 83 | text-overflow: ellipsis; 84 | } 85 | } 86 | 87 | @media (max-width: 799px) { 88 | .invite-link__wrapper { 89 | flex-direction: column; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /components/Quest/QuestTeam/InviteModal/InviteModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React from 'react'; 4 | import { Input, message } from 'antd'; 5 | import useBreakpoint from 'antd/es/grid/hooks/useBreakpoint'; 6 | import { CopyOutlined } from '@ant-design/icons'; 7 | import {ModalProps, TeamModal} from '@/lib/utils/modalTypes'; 8 | import CustomModal, { customModalClassname } from '@/components/CustomModal/CustomModal'; 9 | import classNames from 'classnames'; 10 | 11 | export default function InviteModal({inviteLink, currentModal, setCurrentModal, registrationType = 'AUTO'}: ModalProps) { 12 | const { xs } = useBreakpoint(); 13 | const [messageApi, contextHolder] = message.useMessage(); 14 | 15 | const onCancel = () => { 16 | setCurrentModal!(null); 17 | }; 18 | 19 | const success = () => { 20 | // eslint-disable-next-line no-void 21 | void messageApi.open({ 22 | type: 'success', 23 | content: 'Скопировано!', 24 | }); 25 | }; 26 | 27 | if (registrationType === 'AUTO') { 28 | return ( 29 | 42 | Команда зарегистрирована 43 | 44 | } 45 | footer={null} 46 | > 47 | Пригласите друзей в свою команду 48 | {contextHolder} 49 | { 55 | navigator.clipboard.writeText(inviteLink!).then(() => success()).catch(err => {throw err}); 56 | }}/>} 57 | /> 58 | 59 | ); 60 | } 61 | 62 | return ( 63 | Заявка 71 | отправлена} 72 | footer={null} 73 | > 74 | Отследить статус заявки можно на странице квеста в разделе твоя команда 75 | А пока можно пригласить друзей в свою команду: 76 | {contextHolder} 77 | { 83 | navigator.clipboard.writeText(inviteLink!).then(() => success()).catch(err => { 84 | throw err 85 | }); 86 | }} />} 87 | /> 88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /components/Quest/QuestTeam/QuestTeam.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .quest-team__collapse { 4 | &.ant-collapse .ant-collapse-item .ant-collapse-header { 5 | padding: 0; 6 | align-items: center; 7 | 8 | & .ant-collapse-arrow>svg { 9 | width: 20px; 10 | height: 20px; 11 | } 12 | } 13 | 14 | &.ant-collapse>.ant-collapse-item >.ant-collapse-header .ant-collapse-expand-icon { 15 | padding-right: 8px; 16 | align-self: baseline; 17 | } 18 | 19 | &.ant-collapse .ant-collapse-item { 20 | display: flex; 21 | flex-direction: column; 22 | gap: 16px; 23 | padding: 16px; 24 | border-radius: 8px; 25 | border: 1px solid var(--stroke-secondary); 26 | 27 | &:last-child { 28 | border-radius: 8px; 29 | } 30 | } 31 | 32 | &.ant-collapse.ant-collapse-ghost .ant-collapse-content .ant-collapse-content-box { 33 | display: flex; 34 | flex-direction: column; 35 | gap: 16px; 36 | padding: 0 0 0 28px; 37 | padding-block: 0; 38 | 39 | @media screen and (max-width: $m-breakpoint-639) { 40 | padding: 0; 41 | } 42 | } 43 | 44 | .ant-collapse-extra { 45 | margin-top: -18px; 46 | } 47 | 48 | .quest-team__header-wrapper { 49 | display: flex; 50 | flex-wrap: wrap; 51 | gap: 4px 16px; 52 | align-items: center; 53 | } 54 | 55 | .quest-team__name { 56 | font-size: 18px; 57 | line-height: 17px; 58 | } 59 | 60 | .quest-team__status { 61 | display: flex; 62 | gap: 6px; 63 | flex-wrap: nowrap; 64 | font-size: 14px; 65 | 66 | &_accepted { 67 | color: var(--text-green); 68 | } 69 | 70 | &_on-consideration { 71 | color: var(--text-secondary); 72 | } 73 | } 74 | 75 | .exit-team__large-screen { 76 | top: 8px; 77 | } 78 | 79 | .exit-team__small-screen { 80 | display: none; 81 | } 82 | 83 | @media screen and (max-width: $xm-breakpoint-799) { 84 | .exit-team__large-screen { 85 | display: none; 86 | } 87 | 88 | .exit-team__small-screen { 89 | display: block; 90 | } 91 | } 92 | 93 | & .quest-team__members .team-member__wrapper { 94 | @media screen and (max-width: $m-breakpoint-639) { 95 | width: 100%; 96 | max-width: 100%; 97 | } 98 | } 99 | } 100 | 101 | .quest-team__block { 102 | display: flex; 103 | flex-direction: column; 104 | padding: 16px; 105 | gap: 16px; 106 | border: 1px solid var(--stroke-secondary); 107 | border-radius: 8px; 108 | 109 | .quest-team__header-wrapper { 110 | display: flex; 111 | justify-content: space-between; 112 | gap: 16px; 113 | flex-wrap: nowrap; 114 | } 115 | 116 | .quest-team__extra_footer { 117 | display: none; 118 | width: 100%; 119 | } 120 | 121 | @media screen and (max-width: $m-breakpoint-639) { 122 | .quest-team__extra_header { 123 | display: none; 124 | } 125 | 126 | .quest-team__extra_footer { 127 | display: unset; 128 | } 129 | } 130 | 131 | .quest-team__name { 132 | font-size: 20px; 133 | line-height: 28px; 134 | margin: 0; 135 | word-break: break-word; 136 | } 137 | } 138 | 139 | .quest-team__members { 140 | display: flex; 141 | flex-wrap: wrap; 142 | gap: 16px 32px; 143 | 144 | .team-member__wrapper { 145 | display: flex; 146 | column-gap: 8px; 147 | width: 256px; 148 | max-width: 256px; 149 | align-items: center; 150 | font-weight: 500; 151 | } 152 | 153 | .team-member__name { 154 | word-break: break-word; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /components/Quest/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'Quest'; 2 | 3 | @forward 'EditQuest/EditQuest'; 4 | @forward 'EditQuest/QuestPreview/QuestPreview'; 5 | @forward 'EditQuest/QuestEditor/QuestEditor'; 6 | 7 | @forward "QuestAdminPanel/QuestAdminPanel"; 8 | @forward "QuestAllTeams/QuestAllTeams"; 9 | @forward "QuestDescription/QuestDescription"; 10 | @forward "QuestHeader/QuestHeader"; 11 | @forward "QuestResults/QuestResults"; 12 | @forward "QuestTeam/QuestTeam"; 13 | @forward 'QuestTeam/CreateTeam/CreateTeam'; 14 | @forward 'QuestTeam/InviteModal/InviteModal'; 15 | -------------------------------------------------------------------------------- /components/QuestAdmin/Leaderboard/Leaderboard.scss: -------------------------------------------------------------------------------- 1 | .content__wrapper.leaderboard__content-wrapper { 2 | max-width: 100vw; 3 | padding: 0; 4 | background-color: transparent; 5 | } 6 | 7 | .leaderboard__wrapper { 8 | max-width: 100%; 9 | 10 | .ant-table-wrapper .ant-table.ant-table-bordered >.ant-table-container { 11 | border-inline-start: none; 12 | border-top: none; 13 | } 14 | } 15 | 16 | .ant-table-wrapper .ant-table-tbody>tr>td.ant-table-cell.leaderboard__penalty { 17 | padding: 16px 32px 16px 16px; 18 | } 19 | 20 | .leaderboard__edit-penalty { 21 | font-size: 16px; 22 | background: transparent; 23 | border: none; 24 | position: absolute; 25 | top: calc(50% - 8px); 26 | right: 8px; 27 | opacity: 0.45; 28 | padding: 0 0 0 8px; 29 | display: inline-flex; 30 | cursor: pointer; 31 | 32 | svg { 33 | fill: var(--text-default); 34 | } 35 | 36 | &:hover { 37 | opacity: 1; 38 | } 39 | } 40 | 41 | .edit-penalty__description { 42 | display: flex; 43 | flex-direction: column; 44 | font-size: 16px; 45 | margin-bottom: 16px; 46 | 47 | .team-name { 48 | color: var(--text-default); 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /components/QuestAdmin/Leaderboard/Leaderboard.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IAdminLeaderboardResponse, 3 | IAdminLeaderboardResult, 4 | IAdminTaskGroup, 5 | } from '@/app/types/quest-interfaces'; 6 | import { Table, TableColumnsType } from 'antd'; 7 | import useBreakpoint from 'antd/es/grid/hooks/useBreakpoint'; 8 | import ContentWrapper from '@/components/ContentWrapper/ContentWrapper'; 9 | import EditPenalty from '@/components/QuestAdmin/Leaderboard/EditPenalty/EditPenalty'; 10 | import {useEffect, useState} from 'react'; 11 | import {getLeaderboardAdmin} from '@/app/api/api'; 12 | import {useSession} from 'next-auth/react'; 13 | 14 | export default function Leaderboard({teams, questId}: {teams: IAdminLeaderboardResponse, questId: string}) { 15 | const {xs} = useBreakpoint(); 16 | const [shouldUpdatePenalty, setShouldUpdatePenalty] = useState(false); 17 | const {data: session} = useSession(); 18 | const [leaderboardContent, setLeaderboardContent] = useState(teams); 19 | 20 | useEffect(() => { 21 | if (shouldUpdatePenalty) { 22 | const fetchTable = async () => { 23 | const result = 24 | await getLeaderboardAdmin(questId, session?.accessToken) as IAdminLeaderboardResponse; 25 | setLeaderboardContent(result); 26 | }; 27 | 28 | fetchTable() 29 | .catch(err => { 30 | throw err; 31 | }); 32 | 33 | setShouldUpdatePenalty(false); 34 | } 35 | }, [session, questId, shouldUpdatePenalty]); 36 | 37 | if (!leaderboardContent.results?.length) { 38 | return null; 39 | } 40 | 41 | const columns: TableColumnsType = [ 42 | { 43 | title: 'Имя участника', 44 | dataIndex: 'team_name', 45 | key: 'team_name', 46 | fixed: !xs ? 'left' : false 47 | }, 48 | { 49 | title: 'Сумма', 50 | dataIndex: 'total_score', 51 | key: 'total_score', 52 | fixed: !xs ? 'left' : false 53 | }, 54 | { 55 | title: 'Баллы', 56 | dataIndex: 'task_score', 57 | key: 'task_score', 58 | }, 59 | { 60 | title: 'Бонус', 61 | dataIndex: 'penalty', 62 | key: 'penalty', 63 | className: 'leaderboard__penalty', 64 | render: (_, record) => ( 65 | <> 66 | {-1 * (record as IAdminLeaderboardResult).penalty} 67 | 72 | 73 | ) 74 | }, 75 | ...(leaderboardContent.task_groups ?? []).map((group, group_index) => ({ 76 | title: group.name, 77 | key: group.id, 78 | children: group.tasks?.map((task, task_index) => ({ 79 | title: task.name, 80 | dataIndex: task.id, 81 | key: `task_${group_index}_${task_index}_score`, 82 | render: (_: string, record: IAdminLeaderboardResult) => 83 | {record[`task_${group_index}_${task_index}_score`]} 84 | })) 85 | })) 86 | ] 87 | 88 | return ( 89 | 90 |
91 | 92 | 93 | 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /components/QuestAdmin/Logs/Filters/Filters.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../globals" as *; 2 | 3 | .answer-logs__filters { 4 | width: 100%; 5 | display: flex; 6 | gap: 8px; 7 | align-items: center; 8 | font-family: $font-manrope; 9 | padding: 16px 0; 10 | 11 | .ant-select.answer-logs__filter { 12 | .ant-select-selector { 13 | border-radius: 2px; 14 | width: 200px; 15 | min-width: 200px; 16 | } 17 | } 18 | } 19 | 20 | .answer-logs__filters-title { 21 | font-weight: 700; 22 | font-size: 14px; 23 | line-height: 22px; 24 | width: 144px; 25 | } 26 | 27 | @media screen and (max-width: $xl-breakpoint-1279) { 28 | .answer-logs__filters-title { 29 | width: auto; 30 | } 31 | 32 | .answer-logs__filters { 33 | .ant-select.ant-select-outlined.answer-logs__filter { 34 | width: 25%; 35 | .ant-select-selector { 36 | min-width: unset; 37 | width: 100%; 38 | } 39 | } 40 | } 41 | } 42 | 43 | @media screen and (max-width: $xm-breakpoint-799) { 44 | .answer-logs__filters { 45 | flex-direction: column; 46 | align-items: flex-start; 47 | width: 100%; 48 | .ant-select.ant-select-outlined.answer-logs__filter { 49 | width: 100%; 50 | .ant-select-selector { 51 | width: 100%; 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /components/QuestAdmin/Logs/Filters/Filters.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, Select } from 'antd'; 2 | import { Dispatch, SetStateAction } from 'react'; 3 | 4 | export interface Option { 5 | value: string; 6 | label: string; 7 | } 8 | 9 | export interface FilterSelectOptions { 10 | groups: Option[]; 11 | tasks: Option[]; 12 | teams: Option[]; 13 | users: Option[]; 14 | accepted_only: boolean; 15 | } 16 | 17 | export interface SelectedFiltersState { 18 | group?: string; 19 | task?: string; 20 | team?: string; 21 | user?: string; 22 | accepted_only?: boolean; 23 | } 24 | 25 | interface FiltersProps { 26 | options: FilterSelectOptions; 27 | setSelectedFilters: Dispatch>; 28 | } 29 | 30 | export default function Filters({ options, setSelectedFilters }: FiltersProps) { 31 | const onFilterChange = (filterName: keyof SelectedFiltersState, value: string | boolean) => { 32 | setSelectedFilters(prevState => ({ 33 | ...prevState, 34 | [filterName]: value, 35 | })); 36 | }; 37 | 38 | return ( 39 |
40 | Фильтры 41 | 61 | (optionA?.label ?? '').toLowerCase().localeCompare((optionB?.label ?? '').toLowerCase()) 62 | } 63 | options={options?.tasks} 64 | className='answer-logs__filter' 65 | onChange={(value: string) => onFilterChange('task', value)} 66 | /> 67 | 87 | (optionA?.label ?? '').toLowerCase().localeCompare((optionB?.label ?? '').toLowerCase()) 88 | } 89 | options={options?.users} 90 | className='answer-logs__filter' 91 | onChange={(value: string) => onFilterChange('user', value)} 92 | /> 93 | onFilterChange('accepted_only', e.target.checked)} 97 | > 98 | Только правильные 99 | 100 |
101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /components/QuestAdmin/Logs/InfoAlert/InfoAlert.scss: -------------------------------------------------------------------------------- 1 | .ant-alert.ant-alert-info.ant-alert-with-description { 2 | padding: 9px 16px; 3 | margin: 16px 0 0; 4 | font-size: 14px; 5 | line-height: 22px; 6 | display: flex; 7 | align-items: center; 8 | border-radius: 2px; 9 | } 10 | 11 | .anticon.anticon-info-circle.ant-alert-icon { 12 | width: 18px; 13 | height: 18px; 14 | } -------------------------------------------------------------------------------- /components/QuestAdmin/Logs/InfoAlert/InfoAlert.tsx: -------------------------------------------------------------------------------- 1 | import { Alert } from 'antd'; 2 | import { Dispatch, SetStateAction } from 'react'; 3 | 4 | interface InfoAlertProps { 5 | setIsInfoAlertHidden: Dispatch>; 6 | } 7 | 8 | export default function InfoAlert({ setIsInfoAlertHidden}: InfoAlertProps) { 9 | const handleClose = () => { 10 | setIsInfoAlertHidden(true); 11 | }; 12 | 13 | return ( 14 | 21 | ) 22 | } -------------------------------------------------------------------------------- /components/QuestAdmin/Logs/Logs.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .logs-table__table.ant-table-wrapper { 4 | max-width: 1250px; 5 | width: 100%; 6 | font-family: $font-manrope; 7 | 8 | .ant-table-thead tr th { 9 | font-size: 12px; 10 | transition: unset !important; 11 | color: var(--text-secondary); 12 | line-height: 22px; 13 | font-weight: 400; 14 | padding: 5px 8px 5px 0; 15 | } 16 | 17 | .ant-table-thead tr th::before { 18 | display: none; 19 | } 20 | 21 | .logs-table__answer.accepted { 22 | color: var(--text-green); 23 | } 24 | 25 | .logs-table__answer.rejected { 26 | color: var(--text-red); 27 | } 28 | 29 | .logs-table__score.accepted { 30 | color: var(--text-green); 31 | } 32 | 33 | .logs-table__score.rejected { 34 | color: var(--text-secondary); 35 | } 36 | } 37 | 38 | .logs-table__table { 39 | .ant-table-tbody > tr:last-child > td { 40 | border: none 41 | } 42 | 43 | .ant-table-tbody > tr > td.ant-table-cell { 44 | padding: 5px 8px 5px 0; 45 | max-width: 200px; 46 | text-overflow: ellipsis; 47 | white-space: nowrap; 48 | overflow: hidden; 49 | } 50 | } 51 | 52 | .ant-tooltip.ant-tooltip-placement-top { 53 | .ant-tooltip-content { 54 | .ant-tooltip-inner { 55 | font-family: $font-manrope; 56 | font-weight: 200; 57 | font-size: 14px; 58 | line-height: 22px; 59 | } 60 | } 61 | } 62 | 63 | .empty__logs-not-found.ant-empty { 64 | display: flex; 65 | flex-direction: column; 66 | justify-content: center; 67 | align-items: center; 68 | padding: 48px 0; 69 | color: var(--text-default); 70 | 71 | .ant-empty-image { 72 | font-size: 48px; 73 | opacity: 0.5; 74 | height: auto; 75 | margin: 0; 76 | } 77 | 78 | .ant-empty-description { 79 | color: var(--text-default); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /components/QuestAdmin/QuestAdmin.helpers.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export const enum SelectAdminTabs { 3 | ABOUT = 'about', 4 | TASKS = 'tasks', 5 | LOGS = 'logs', 6 | TEAMS = 'teams', 7 | LEADERBOARD = 'leaderboard', 8 | } 9 | -------------------------------------------------------------------------------- /components/QuestAdmin/QuestAdmin.scss: -------------------------------------------------------------------------------- 1 | @use "../../globals" as *; 2 | 3 | .admin-page__content { 4 | display: contents; 5 | 6 | .task__wrapper { 7 | flex: 1; 8 | max-width: calc(100% - 242px); 9 | 10 | @media screen and (max-width: $s-breakpoint-525) { 11 | max-width: 100%; 12 | } 13 | } 14 | } 15 | 16 | .quest-admin__content-wrapper { 17 | padding-top: 24px; 18 | 19 | .quest-admin__header__content { 20 | display: flex; 21 | flex-direction: column; 22 | gap: 8px; 23 | } 24 | 25 | &:has(.quest-admin__header__content > .quest-admin__extra-button) { 26 | @media screen and (max-width: $xm-breakpoint-799) { 27 | padding-bottom: 16px; 28 | } 29 | } 30 | } 31 | 32 | .quest-admin__header__content > .quest-admin__extra-button { 33 | display: none; 34 | margin-top: 8px; 35 | } 36 | 37 | .quest-admin__tabs.ant-tabs { 38 | .ant-tabs-nav { 39 | margin-bottom: 0; 40 | } 41 | } 42 | 43 | .quest-admin__upper-wrapper { 44 | display: flex; 45 | justify-content: space-between; 46 | 47 | .delete-quest__button.ant-btn { 48 | display: flex; 49 | align-items: center; 50 | gap: 10px; 51 | } 52 | 53 | 54 | .delete-quest__button.ant-btn .anticon+span { 55 | margin-inline-start: 0; 56 | } 57 | } 58 | 59 | @media screen and (max-width: $xm-breakpoint-799) { 60 | .delete-quest__button.ant-btn { 61 | gap: 8px; 62 | 63 | & span:not(.anticon) { 64 | display: none; 65 | } 66 | } 67 | 68 | .quest-admin__tabs .ant-tabs-extra-content { 69 | display: none; 70 | } 71 | 72 | .quest-admin__header__content .quest-admin__extra-button { 73 | display: block; 74 | } 75 | 76 | 77 | } 78 | -------------------------------------------------------------------------------- /components/QuestAdmin/Teams/Teams.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .empty__teams-not-found.ant-empty { 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | padding: 48px 0; 9 | color: var(--text-default); 10 | 11 | .ant-empty-image { 12 | font-size: 48px; 13 | opacity: 0.5; 14 | height: auto; 15 | margin: 0; 16 | } 17 | 18 | .ant-empty-description { 19 | color: var(--text-default); 20 | } 21 | } 22 | 23 | .teams__content-wrapper { 24 | padding-top: 24px; 25 | padding-bottom: 24px; 26 | } 27 | 28 | .teams__wrapper { 29 | display: flex; 30 | flex-direction: column; 31 | gap: 16px; 32 | 33 | .approved-teams__wrapper { 34 | display: flex; 35 | flex-direction: column; 36 | gap: 16px; 37 | } 38 | 39 | .approved-teams__header { 40 | font-family: $font-manrope; 41 | font-size: 24px; 42 | line-height: 32px; 43 | font-weight: 700; 44 | margin: 0; 45 | } 46 | 47 | .requested-teams__wrapper { 48 | display: flex; 49 | flex-direction: column; 50 | gap: 16px; 51 | } 52 | 53 | .ant-collapse>.ant-collapse-item:not(.ant-collapse-item-active) { 54 | border-radius: 0; 55 | border-bottom: 1px solid var(--stroke-secondary); 56 | padding-bottom: 16px; 57 | } 58 | 59 | .ant-collapse>.ant-collapse-item >.ant-collapse-header 60 | { 61 | .ant-collapse-expand-icon span { 62 | font-size: 24px; 63 | } 64 | 65 | &.requested-teams__collapse-header { 66 | padding: 0; 67 | align-items: center; 68 | flex-wrap: wrap; 69 | } 70 | 71 | @media screen and (max-width: $m-breakpoint-639) { 72 | .ant-collapse-extra { 73 | width: 100%; 74 | padding: 8px 0 0; 75 | } 76 | } 77 | 78 | & .requested-teams__header { 79 | font-family: $font-manrope; 80 | padding: 0; 81 | font-size: 24px; 82 | line-height: 32px; 83 | font-weight: 700; 84 | margin: 0; 85 | } 86 | } 87 | 88 | .ant-collapse >.ant-collapse-item >.ant-collapse-content >.ant-collapse-content-box { 89 | display: flex; 90 | flex-direction: column; 91 | gap: 16px; 92 | padding: 16px 0 0; 93 | } 94 | 95 | .requested-team__extra { 96 | display: flex; 97 | gap: 8px; 98 | } 99 | } 100 | 101 | 102 | 103 | @media screen and (max-width: $xm-breakpoint-799) { 104 | .teams__wrapper { 105 | .approved-teams__header { 106 | font-size: 20px; 107 | line-height: 28px; 108 | } 109 | 110 | .ant-collapse > .ant-collapse-item > .ant-collapse-header { 111 | .ant-collapse-expand-icon span { 112 | font-size: 20px; 113 | } 114 | 115 | .requested-teams__header { 116 | font-size: 20px; 117 | line-height: 28px; 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /components/QuestAdmin/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "QuestAdmin"; 2 | @forward "Leaderboard/Leaderboard"; 3 | @forward "Teams/Teams"; 4 | @forward "Logs/Logs"; 5 | @forward "Logs/Filters/Filters"; 6 | @forward "Logs/InfoAlert/InfoAlert"; 7 | -------------------------------------------------------------------------------- /components/QuestTabs/QuestCard/QuestCard.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .quest-card { 4 | .quest-card__name { 5 | margin: 0; 6 | color: var(--text-default); 7 | white-space: nowrap; 8 | overflow: hidden; 9 | text-overflow: ellipsis; 10 | } 11 | 12 | & p { 13 | margin: 0; 14 | } 15 | 16 | &.ant-card-bordered .ant-card-cover { 17 | margin-top: 0; 18 | margin-inline-start: 0; 19 | margin-inline-end: 0; 20 | user-select: none; 21 | } 22 | } 23 | 24 | .quest-card__anchor { 25 | display: flex; 26 | box-sizing: content-box; 27 | text-decoration: none; 28 | min-width: 0; 29 | 30 | .ant-card-cover { 31 | position: relative; 32 | //height: 128px; 33 | } 34 | 35 | .ant-card { 36 | width: 100%; 37 | box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.016); 38 | border: 1px solid var(--stroke-secondary); 39 | background-color: var(--background-primary); 40 | border-radius: 8px; 41 | overflow: hidden; 42 | 43 | .ant-card-body { 44 | padding: 8px 12px 12px; 45 | } 46 | } 47 | 48 | .quest-card__start { 49 | font-size: 12px; 50 | font-weight: 400; 51 | color: var(--text-secondary); 52 | } 53 | 54 | .status__wrapper { 55 | display: flex; 56 | align-items: center; 57 | padding-top: 6px; 58 | gap: 6px; 59 | } 60 | } 61 | 62 | a.quest-card__anchor + div { 63 | display: contents; 64 | } 65 | 66 | .quest-card__wrapper { 67 | padding: 0 !important; 68 | border-radius: 16px; 69 | background: transparent; 70 | 71 | .ant-card:not(.ant-card-bordered) { 72 | box-shadow: none; 73 | background: transparent; 74 | } 75 | 76 | .ant-card { 77 | border-radius: 16px; 78 | font-size: 14px; 79 | line-height: 22px; 80 | font-weight: 500; 81 | 82 | .ant-card-cover { 83 | position: relative; 84 | aspect-ratio: 4 / 3; 85 | } 86 | 87 | .ant-card-cover img { 88 | border-radius: 16px 16px 0 0; 89 | } 90 | 91 | .ant-card-body { 92 | display: flex; 93 | flex-direction: row; 94 | justify-content: space-between; 95 | padding: 24px 32px; 96 | background: var(--background-primary); 97 | border-radius: 0 0 16px 16px; 98 | } 99 | 100 | .ant-card-body:after, .ant-card-body:before { 101 | display: none; 102 | } 103 | } 104 | 105 | .quest-preview__information { 106 | display: flex; 107 | gap: 24px; 108 | padding-top: 12px; 109 | text-wrap: nowrap; 110 | 111 | .information__block { 112 | display: flex; 113 | gap: 8px; 114 | 115 | & img { 116 | align-self: center; 117 | } 118 | } 119 | } 120 | 121 | .quest-card__interactive { 122 | display: flex; 123 | flex-direction: column; 124 | text-align: center; 125 | gap: 4px; 126 | 127 | & button { 128 | width: 240px; 129 | } 130 | 131 | & p { 132 | text-wrap: nowrap; 133 | } 134 | } 135 | } 136 | 137 | @media (max-width: 639px) { 138 | .quest-card { 139 | .quest-preview__information { 140 | flex-direction: column; 141 | gap: 4px; 142 | padding-top: 16px; 143 | } 144 | } 145 | 146 | .quest-card__wrapper .ant-card .ant-card-body { 147 | flex-direction: column; 148 | 149 | .quest-card__interactive { 150 | padding-top: 16px; 151 | 152 | & button { 153 | width: unset; 154 | } 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /components/QuestTabs/QuestCard/QuestCard.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Card } from 'antd'; 3 | import Image from 'next/image'; 4 | import { getQuestStatusLabel } from '@/components/Quest/Quest.helpers'; 5 | import Link from 'next/link'; 6 | import { QuestHeaderProps } from '@/components/Quest/QuestHeader/QuestHeader'; 7 | import { getStartDateText } from '@/components/Quest/QuestHeader/QuestHeader.helpers'; 8 | 9 | 10 | export default function QuestCard({props} : {props?: QuestHeaderProps}) { 11 | const [src, setSrc] = useState(props?.media_link ?? 'https://storage.yandexcloud.net/questspace-img/assets/error-src.png'); 12 | if (!props) { 13 | return null; 14 | } 15 | 16 | const { 17 | id, 18 | name, 19 | start_time: startTime, 20 | registration_deadline: registrationDeadline, 21 | status 22 | } = props; 23 | 24 | const registrationDate = new Date(registrationDeadline); 25 | const startDate = new Date(startTime); 26 | const startDateLabel = getStartDateText(startDate); 27 | 28 | return ( 29 | 30 | setSrc('https://storage.yandexcloud.net/questspace-img/assets/error-src.png')} 41 | />} 42 | styles={{cover: {aspectRatio: '2/1'}}} 43 | > 44 |

{name}

45 |

{startDateLabel}

46 |
47 | {getQuestStatusLabel(registrationDate, status)} 48 |
49 |
50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /components/QuestTabs/QuestCardsList/QuestCardsList.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { IQuest } from '@/app/types/quest-interfaces'; 4 | import { customizedEmpty, wrapInCard } from '@/components/QuestTabs/QuestTabs.helpers'; 5 | 6 | export default function QuestCardsList({quests}: {quests?: IQuest[]}) { 7 | if (!quests || quests.length === 0) { 8 | return customizedEmpty(); 9 | } 10 | return ( 11 | <> 12 | {quests.map((quest) => wrapInCard(quest))} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /components/QuestTabs/QuestTabs.helpers.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ConfigProvider, Empty } from 'antd'; 2 | import { PlusOutlined, SmileOutlined } from '@ant-design/icons'; 3 | import QuestCard from '@/components/QuestTabs/QuestCard/QuestCard'; 4 | import Link from 'next/link'; 5 | import { IQuest } from '@/app/types/quest-interfaces'; 6 | import { uid } from '@/lib/utils/utils'; 7 | 8 | const selectTab = ['all', 'registered', 'owned'] as const; 9 | export type SelectTab = (typeof selectTab)[number]; 10 | 11 | 12 | // @ts-expect-error мы точно знаем, что в SelectTab string 13 | export const isSelectTab = (x: string): x is SelectTab => selectTab.includes(x); 14 | 15 | export const createQuestButton = ( 16 | 17 | 25 | 26 | ); 27 | 28 | export const customizedEmpty = () => ( 29 | } 32 | description={ 33 | 34 | Квесты не найдены 35 |
36 | Попробуйте{' '} 37 | 41 | создать квест 42 | 43 |
44 | } 45 | /> 46 | ); 47 | 48 | export function wrapInCard(quest: IQuest) { 49 | return ( 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /components/QuestTabs/QuestTabs.scss: -------------------------------------------------------------------------------- 1 | @use "../../globals" as *; 2 | 3 | .quest-tabs { 4 | .ant-tabs-tabpane { 5 | display: grid; 6 | grid-template-columns: $grid-column-1280; 7 | justify-content: stretch; 8 | gap: 16px; 9 | } 10 | 11 | .ant-tabs-content-holder { 12 | min-height: 250px; 13 | } 14 | 15 | .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn { 16 | text-shadow: none; 17 | } 18 | 19 | .ant-select-selection-item, .ant-select-arrow span { 20 | color: var(--text-blue); 21 | } 22 | 23 | .quest-tabpane { 24 | display: flex; 25 | flex-direction: column; 26 | gap: 16px; 27 | padding-top: 12px; 28 | } 29 | 30 | .create-quest__button { 31 | color: var(--text-blue); 32 | } 33 | 34 | &.ant-tabs .ant-tabs-nav .ant-tabs-nav-wrap { 35 | overflow: unset; 36 | } 37 | 38 | .empty__quests-not-found { 39 | display: flex; 40 | flex-direction: column; 41 | justify-content: center; 42 | align-items: center; 43 | padding: 48px 0; 44 | grid-column: 1/5; 45 | color: var(--text-default); 46 | 47 | .ant-empty-image { 48 | font-size: 48px; 49 | opacity: 0.5; 50 | height: auto; 51 | margin: 0; 52 | } 53 | 54 | .ant-empty-description { 55 | color: var(--text-default); 56 | } 57 | } 58 | } 59 | 60 | .quest-tabs__unauth { 61 | .ant-tabs-ink-bar { 62 | display: none; 63 | } 64 | 65 | .ant-tabs-nav-wrap { 66 | pointer-events: none; 67 | } 68 | } 69 | 70 | 71 | .quest-tabs__header { 72 | display: flex; 73 | width: 100%; 74 | padding: 0 0 4px; 75 | justify-content: space-between; 76 | border-bottom: 1px solid var(--stroke-secondary); 77 | } 78 | 79 | @media (max-width: 1279px) { 80 | .quest-tabs .ant-tabs-tabpane { 81 | grid-template-columns: $grid-column-960; 82 | } 83 | 84 | } 85 | 86 | @media (max-width: 959px) { 87 | .quest-tabs .ant-tabs-tabpane { 88 | grid-template-columns: $grid-column-640; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /components/QuestTabs/QuestTabs.server.tsx: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { getServerSession } from 'next-auth'; 4 | import authOptions from '@/app/api/auth/[...nextauth]/auth'; 5 | import { getFilteredQuests } from '@/app/api/api'; 6 | import { IFilteredQuestsResponse } from '@/app/types/quest-interfaces'; 7 | import { SelectTab } from '@/components/QuestTabs/QuestTabs.helpers'; 8 | 9 | export default async function getBackendQuests(tab: SelectTab, pageId?: string, pageSize = '12') { 10 | const session = await getServerSession(authOptions); 11 | const accessToken = session?.accessToken; 12 | const data = await getFilteredQuests( 13 | [`${tab}`], 14 | accessToken, 15 | pageId, 16 | pageSize 17 | ) 18 | .then(res => res as IFilteredQuestsResponse) 19 | .catch(err => { 20 | throw err; 21 | }); 22 | 23 | if (!data) { 24 | return null; 25 | } 26 | 27 | 28 | return data[tab]; 29 | } 30 | -------------------------------------------------------------------------------- /components/QuestTabs/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "QuestTabs"; 2 | @forward "QuestCard/QuestCard"; 3 | -------------------------------------------------------------------------------- /components/QuestTabs/questTabsSelectTheme.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeConfig } from "antd"; 2 | 3 | const questTabsSelectThemeConfig: ThemeConfig = { 4 | components: { 5 | Select: { 6 | colorTextPlaceholder: 'var(--text-blue)', 7 | colorPrimary: 'var(--text-blue)', 8 | colorPrimaryTextActive: 'var(--text-blue)', 9 | colorTextHeading: 'var(--text-blue)', 10 | fontWeightStrong: 400, 11 | colorIcon: 'var(--icon-outlined-blue)', 12 | colorIconHover: 'var(--icon-outlined-blue)', 13 | colorBgElevated: 'var(--background-primary)', 14 | colorText: 'var(--text-default)', 15 | optionSelectedBg: 'var(--background-blue)', 16 | optionActiveBg: 'var(--background-secondary)' 17 | } 18 | } 19 | }; 20 | 21 | export default questTabsSelectThemeConfig; -------------------------------------------------------------------------------- /components/Tasks/Brief/Brief.scss: -------------------------------------------------------------------------------- 1 | @use "../../../globals" as *; 2 | 3 | .brief__extra__content { 4 | font-family: $font-manrope; 5 | font-size: 16px; 6 | font-weight: 400; 7 | color: var(--text-default); 8 | } 9 | 10 | .ant-collapse>.ant-collapse-item:has(.brief__name_off) >.ant-collapse-header { 11 | color: var(--text-disabled); 12 | } 13 | 14 | .brief__edit { 15 | position: relative; 16 | display: flex; 17 | justify-content: space-between; 18 | gap: 16px 32px; 19 | border-top: 1px solid var(--stroke-secondary); 20 | padding-top: 24px; 21 | padding-bottom: 16px; 22 | } 23 | 24 | .brief__edit-buttons { 25 | display: flex; 26 | flex-direction: column; 27 | gap: 8px; 28 | } 29 | 30 | .brief__edit-input.ant-input { 31 | font-size: 16px; 32 | } 33 | 34 | .brief__edit-error { 35 | color: var(--text-red); 36 | } 37 | 38 | .brief__text { 39 | font-size: 16px; 40 | 41 | &:not(.brief__text_edit) { 42 | padding-top: 24px; 43 | padding-bottom: 16px; 44 | border-top: 1px solid var(--stroke-secondary); 45 | } 46 | 47 | p { 48 | margin: 0; 49 | } 50 | } 51 | 52 | @media screen and (max-width: $s-breakpoint-525) { 53 | .brief__extra__content { 54 | display: none; 55 | } 56 | 57 | .brief__edit { 58 | flex-direction: column; 59 | padding-bottom: 0; 60 | } 61 | 62 | .brief__edit-buttons { 63 | flex-direction: row; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /components/Tasks/Brief/BriefEditButtons/BriefEditButtons.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input } from 'antd'; 2 | import React, { useState } from 'react'; 3 | import remarkGfm from 'remark-gfm'; 4 | import Markdown from 'react-markdown'; 5 | import classNames from 'classnames'; 6 | import { useTasksContext } from '@/components/Tasks/ContextProvider/ContextProvider'; 7 | import { updateQuest } from '@/app/api/api'; 8 | import { IQuest } from '@/app/types/quest-interfaces'; 9 | import { useSession } from 'next-auth/react'; 10 | import { EditOutlined } from '@ant-design/icons'; 11 | 12 | const { TextArea } = Input; 13 | 14 | export default function BriefEditButtons() { 15 | const {data: contextData, updater: setContextData} = useTasksContext()!; 16 | const [ briefValue, setBriefValue ] = useState(contextData?.quest?.brief ?? ''); 17 | const [ isEditBrief, setIsEditBrief ] = useState(false); 18 | const { data: sessionData } = useSession(); 19 | const accessToken = sessionData?.accessToken; 20 | 21 | const handleSave = async () => { 22 | const data = { 23 | ...contextData.quest, 24 | brief: briefValue, 25 | } 26 | 27 | const result = await updateQuest(contextData.quest.id, data, accessToken) 28 | .then(resp => resp as IQuest) 29 | .catch(error => { 30 | throw error; 31 | }); 32 | 33 | if (result) { 34 | setContextData((prevState) => ({ 35 | ...prevState, 36 | quest: result 37 | })); 38 | } 39 | 40 | setIsEditBrief(false); 41 | } 42 | 43 | const handleCancel = () => { 44 | setBriefValue(contextData?.quest?.brief ?? ''); 45 | setIsEditBrief(false); 46 | } 47 | 48 | const getTextElement = () => contextData?.quest?.brief ? 49 | 54 | {briefValue} 55 | : 56 | Бриф не заполнен 57 | 58 | return ( 59 |
60 | { 61 | isEditBrief ? 62 |