├── .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 ;
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 |
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 |
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 |
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 |
:
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 |
:
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 |
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 |
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 |
82 | )
83 | }
84 |
--------------------------------------------------------------------------------
/components/Tasks/ContextProvider/ContextProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { createContext, useContext, useState } from 'react';
4 | import { IQuestTaskGroups } from '@/app/types/quest-interfaces';
5 |
6 | export interface IContext {
7 | data: IQuestTaskGroups,
8 | updater: React.Dispatch>
9 | }
10 |
11 | const TasksContext = createContext(null);
12 |
13 | export default function ContextProvider({children, questData}: {
14 | children: React.ReactNode,
15 | questData: IQuestTaskGroups
16 | }) {
17 | const [state, setState] = useState(questData);
18 |
19 | return (
20 | // eslint-disable-next-line react/jsx-no-constructed-context-values
21 |
22 | {children}
23 |
24 | );
25 | }
26 |
27 | export function useTasksContext() {
28 | return useContext(TasksContext);
29 | }
30 |
--------------------------------------------------------------------------------
/components/Tasks/PlayPageContent/PlayPageContent.scss:
--------------------------------------------------------------------------------
1 | @use "../../../globals" as *;
2 |
3 | .play-page {
4 | display: flex;
5 | flex-direction: column;
6 | gap: 16px;
7 | max-width: 800px;
8 | width: 100%;
9 |
10 | & input[type="text"] {
11 | font-size: 16px !important;
12 | line-height: normal;
13 | }
14 | }
15 |
16 |
17 | .play-page__tasks {
18 | display: flex;
19 | flex-direction: column;
20 | gap: 16px;
21 | }
22 |
23 | .play-page__content-wrapper {
24 | padding-top: 24px;
25 | padding-bottom: 24px;
26 | }
27 |
28 | .play-page__header__content {
29 | display: flex;
30 | flex-direction: column;
31 | gap: 12px;
32 | }
33 |
34 | .play-page__information {
35 | display: flex;
36 | column-gap: 24px;
37 | row-gap: 8px;
38 | flex-wrap: wrap;
39 |
40 | .information__block {
41 | display: flex;
42 | gap: 8px;
43 | }
44 |
45 | & span:not(.anticon) {
46 | font-size: 14px;
47 | }
48 | }
49 |
50 | .play-page__tasks .task__wrapper {
51 | width: 100%;
52 | }
53 |
54 | .before-start__wrapper {
55 | display: flex;
56 | flex-direction: column;
57 | align-items: center;
58 | gap: 16px;
59 |
60 | .before-start__text {
61 | font-size: 32px;
62 | }
63 |
64 | .before-start__countdown {
65 | font-size: 120px;
66 | line-height: 117%;
67 | }
68 | }
69 |
70 | @media screen and (max-width: $l-breakpoint-959) {
71 | .before-start__wrapper {
72 | .before-start__countdown {
73 | font-size: 100px;
74 | }
75 | }
76 | }
77 |
78 | @media screen and (max-width: $xm-breakpoint-799) {
79 | .before-start__wrapper {
80 | .before-start__text {
81 | font-size: 24px;
82 | }
83 | .before-start__countdown {
84 | font-size: 80px;
85 | }
86 | }
87 | }
88 |
89 | @media screen and (max-width: $m-breakpoint-639) {
90 | .before-start__wrapper {
91 | .before-start__countdown {
92 | font-size: 60px;
93 | }
94 | }
95 | }
96 |
97 | @media screen and (max-width: $s-breakpoint-525) {
98 | .before-start__wrapper {
99 | .before-start__countdown {
100 | font-size: 40px;
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/components/Tasks/PlayPageContent/PlayPageContent.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import Link from 'next/link';
5 | import { Button } from 'antd';
6 | import Tasks from '@/components/Tasks/Tasks';
7 | import { TasksMode } from '@/components/Tasks/Task/Task.helpers';
8 | import { IQuestTaskGroupsResponse} from '@/app/types/quest-interfaces';
9 | import ContentWrapper from '@/components/ContentWrapper/ContentWrapper';
10 | import { ArrowLeftOutlined, HourglassOutlined, TeamOutlined } from '@ant-design/icons';
11 | import { getLongTimeDiff, getRemainingVerb, QuestStatus } from '@/components/Quest/Quest.helpers';
12 | import classNames from 'classnames';
13 | import dynamic from 'next/dynamic';
14 |
15 | const Countdown = dynamic(() => import('../../CustomCountdown/CustomCountdown'), {
16 | ssr: false
17 | })
18 |
19 |
20 | export default function PlayPageContent({props}: {props: IQuestTaskGroupsResponse}) {
21 | const {name: teamName} = props.team;
22 | const {name: questName, id: questId, finish_time: finishTime, status, start_time: startTime} = props.quest;
23 | const nowDate = new Date();
24 | const timeLabel = getLongTimeDiff(nowDate, new Date(finishTime));
25 | const remainingVerb = getRemainingVerb(nowDate, new Date(finishTime));
26 | const isBeforeQuestStart = status === QuestStatus.StatusOnRegistration as string ||
27 | status === QuestStatus.StatusRegistrationDone as string;
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
37 |
38 |
{questName}: Задачи
39 |
40 |
41 |
42 | {teamName}
43 |
44 | {status === 'RUNNING' && (
45 |
46 |
47 |
48 | {remainingVerb} {timeLabel}
49 |
50 |
51 | )}
52 |
53 |
54 |
55 |
56 | {isBeforeQuestStart && (
57 |
58 |
59 |
До старта
60 |
61 |
62 |
63 | )}
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/components/Tasks/Task/EditTask/EditTask.scss:
--------------------------------------------------------------------------------
1 | @use "globals" as *;
2 |
3 | .edit-task__content {
4 | form + form {
5 | margin-top: 16px;
6 | }
7 |
8 | .ant-row {
9 | @media screen and (max-width: $m-breakpoint-639) {
10 | flex-wrap: wrap;
11 | row-gap: 10px;
12 | }
13 | }
14 |
15 | .edit-task__labels {
16 | width: 184px;
17 | color: var(--text-default);
18 | }
19 |
20 | .edit-task__image-form-item .ant-form-item-control-input-content {
21 | display: flex;
22 | gap: 16px;
23 | justify-content: space-between;
24 | }
25 |
26 | .edit-task__image-form-item-new .ant-form-item-control-input-content {
27 | display: flex;
28 | gap: 16px;
29 | align-items: flex-end;
30 | }
31 |
32 | .edit-task__image {
33 | display: flex;
34 | margin: 0;
35 | gap: 8px;
36 | min-width: 0;
37 | order: -1;
38 | color: var(--text-default);
39 | }
40 |
41 | .edit-task__image p {
42 | margin: 0;
43 | white-space: nowrap;
44 | overflow: hidden;
45 | text-overflow: ellipsis;
46 | align-content: center;
47 | flex: 1;
48 | }
49 |
50 | .edit-task__image-validation-error, .edit-task__answers-error {
51 | color: var(--text-red);
52 | margin: 0;
53 | }
54 |
55 | .edit-task__drag {
56 | touch-action: pan-y;
57 | max-width: 100%;
58 | }
59 |
60 | .ant-form-item-explain-error .edit-task__image-validation-error {
61 | padding: 5px 0 0;
62 | }
63 |
64 | .edit-task__hints-list, .edit-task__answers-list {
65 | display: flex;
66 | flex-direction: column;
67 | row-gap: 8px;
68 |
69 | .ant-input-affix-wrapper {
70 | border-radius: 2px;
71 | }
72 | }
73 |
74 | .edit-task__add-button {
75 | padding: 4px 0;
76 | }
77 |
78 | .edit-task__file-extensions {
79 | color: var(--text-secondary);
80 | padding: 0 0 6px;
81 | margin: 0;
82 | }
83 |
84 | .edit-task__delete-image-button {
85 | color: var(--icon-outlined-red);
86 | padding-left: 8px;
87 | }
88 |
89 | .ant-form-item-control-input-content .ant-upload-wrapper {
90 | display: flex;
91 | flex-direction: column-reverse;
92 | }
93 |
94 | .edit-task__add-file-button {
95 | padding: 4px 0;
96 | }
97 |
98 | .ant-input-suffix {
99 | color: var(--stroke-secondary);
100 | transition-behavior: normal;
101 | transition-delay: 0s;
102 | transition-duration: 0.2s;
103 | transition-property: all;
104 | transition-timing-function: ease;
105 | }
106 |
107 | .ant-input-affix-wrapper-readonly {
108 | &:hover, &:focus, &:focus-within {
109 | .ant-input-suffix {
110 | color: var(--text-blue);
111 | }
112 | }
113 | }
114 |
115 | .ant-input {
116 | border-radius: 2px;
117 | }
118 |
119 |
120 | .ant-form-item {
121 | margin: 0;
122 | }
123 |
124 | .ant-form {
125 | display: flex;
126 | flex-direction: column;
127 | gap: 16px;
128 | }
129 |
130 | .ant-modal-body {
131 | margin-bottom: 40px;
132 | }
133 |
134 | &.ant-modal-content .ant-modal-footer {
135 | display: flex;
136 | justify-content: flex-start;
137 | margin: 0;
138 | padding-top: 16px;
139 | border-top: 1px solid var(--stroke-secondary);
140 | }
141 |
142 | .task-hint__wrapper {
143 | display: flex;
144 | flex-direction: column;
145 | padding: 8px;
146 | gap: 4px;
147 | border-radius: 4px;
148 | background: var(--background-disabled);
149 |
150 | .task-hint__name {
151 | display: flex;
152 | flex-direction: row;
153 | gap: 4px;
154 | width: 100%;
155 |
156 | .ant-form-item {
157 | width: 100%;
158 | }
159 | }
160 |
161 | input, button, textarea {
162 | background: var(--background-primary) !important;
163 | }
164 |
165 | > .task-hint__form-item.ant-form-item:not(.ant-form-item:first-child) {
166 | margin: 0;
167 | }
168 | }
169 | }
170 |
171 | .ant-modal-root .ant-modal.edit-task__modal {
172 | margin: 0;
173 | max-width: 100%;
174 | }
175 |
--------------------------------------------------------------------------------
/components/Tasks/Task/Task.helpers.tsx:
--------------------------------------------------------------------------------
1 | import TaskEditButtons from "@/components/Tasks/Task/EditTask/TaskEditButtons/TaskEditButtons";
2 | import { ITask, ITaskGroup } from '@/app/types/quest-interfaces';
3 |
4 | export const enum TasksMode {
5 | EDIT = 'edit',
6 | PLAY = 'play'
7 | }
8 |
9 | export const getTaskExtra = (edit: boolean, mobile526: boolean, taskGroupProps: Pick, task: ITask, questId: string) => {
10 | if (edit) {
11 | return (
12 |
18 | );
19 | }
20 |
21 | return null;
22 | }
23 |
--------------------------------------------------------------------------------
/components/Tasks/TaskGroup/EditTaskGroup/EditTaskGroup.scss:
--------------------------------------------------------------------------------
1 | @use "globals" as *;
2 |
3 | .edit-task-group__content {
4 | .ant-row {
5 | row-gap: 5px;
6 | flex-flow: unset;
7 |
8 | @media screen and (max-width: $m-breakpoint-639) {
9 | flex-direction: column;
10 | }
11 | }
12 | .edit-task-group__labels {
13 | width: 184px;
14 | min-width: 184px;
15 | color: var(--text-default);
16 |
17 | @media screen and (max-width: $m-breakpoint-639) {
18 | width: unset;
19 | }
20 | }
21 |
22 | .edit-task-group__file-extensions {
23 | color: var(--text-secondary);
24 | padding: 0 0 6px;
25 | margin: 0;
26 | }
27 |
28 | .ant-input-suffix {
29 | color: var(--stroke-secondary);
30 | transition-behavior: normal;
31 | transition-delay: 0s;
32 | transition-duration: 0.2s;
33 | transition-property: all;
34 | transition-timing-function: ease;
35 | }
36 |
37 | .ant-input-affix-wrapper-readonly {
38 | &:hover, &:focus, &:focus-within {
39 | .ant-input-suffix {
40 | color: var(--text-blue);
41 | }
42 | }
43 | }
44 |
45 | .ant-input {
46 | border-radius: 2px;
47 | }
48 |
49 | .ant-form-item {
50 | margin: 0;
51 | }
52 |
53 | .ant-form {
54 | display: flex;
55 | flex-direction: column;
56 | gap: 16px;
57 | }
58 |
59 | .ant-modal-body {
60 | margin-bottom: 40px;
61 | }
62 |
63 | &.ant-modal-content .ant-modal-footer {
64 | display: flex;
65 | justify-content: flex-start;
66 | margin: 0;
67 | padding-top: 16px;
68 | border-top: 1px solid var(--stroke-secondary);
69 | }
70 |
71 | .text-disabled {
72 | color: var(--text-disabled);
73 | }
74 |
75 | .text-secondary {
76 | color: var(--text-secondary);
77 | }
78 | }
79 |
80 | .ant-modal-root .ant-modal.edit-task-group__modal {
81 | margin: 0;
82 | max-width: 100%;
83 | }
84 |
--------------------------------------------------------------------------------
/components/Tasks/TaskGroup/TaskGroup.scss:
--------------------------------------------------------------------------------
1 | @use "../../../globals" as *;
2 |
3 | .task-group__task {
4 | display: flex;
5 | max-width: 100%;
6 | justify-content: space-between;
7 | gap: 32px;
8 | border-top: 1px solid var(--stroke-secondary);
9 | }
10 |
11 | .ant-collapse-item .task-group__name.roboto-flex-header.closed-group {
12 | .ant-collapse-header-text {
13 | color: var(--text-green);
14 | }
15 | }
16 |
17 | .ant-collapse-item .green-check-circle {
18 | width: 24px;
19 | height: 24px;
20 | color: var(--icon-filled-green);
21 | }
22 |
23 | .closed-group__title {
24 | display: flex;
25 | gap: 8px;
26 | align-items: center;
27 | }
28 |
29 | .task-group__name .task-group__score {
30 | display: block;
31 | font-family: $font-manrope;
32 | font-size: 16px;
33 | color: var(--text-green);
34 | line-height: 24px;
35 | align-self: baseline;
36 | }
37 |
38 | .task-group__name .task-group__countdown {
39 | display: block;
40 | font-family: $font-manrope;
41 | font-size: 16px;
42 | line-height: 24px;
43 | }
44 |
45 | .task-group__description {
46 | padding: 24px 0 16px;
47 | border-top: 1px solid var(--stroke-secondary);
48 |
49 | p {
50 | margin: 0;
51 | }
52 | }
53 |
54 | .task-group__settings-wrapper {
55 | display: flex;
56 | flex-direction: column;
57 | gap: 8px;
58 | padding: 24px 0 16px;
59 | border-top: 1px solid var(--stroke-secondary);
60 |
61 | .task-group__settings-header {
62 | font-size: 24px;
63 |
64 | @media screen and (max-width: $xm-breakpoint-799) {
65 | font-size: 20px;
66 | }
67 | }
68 |
69 | .task-group__settings-row {
70 | display: flex;
71 | flex-direction: row;
72 | gap: 16px;
73 |
74 | .task-group__setting-name {
75 | width: 168px;
76 | min-width: 168px;
77 | }
78 |
79 | .task-group__setting-value p {
80 | margin: 0;
81 | word-break: break-word;
82 | }
83 | }
84 | }
85 |
86 | .task-group__name-with-score {
87 | display: flex;
88 | justify-content: space-between;
89 | align-items: center;
90 | margin-right: 12px;
91 |
92 | .task-group__name-text {
93 | white-space: nowrap;
94 | overflow: hidden;
95 | text-overflow: ellipsis;
96 | display: block;
97 | width: 100%;
98 | }
99 | }
100 |
101 | .task-group__collapse-buttons .task-group-extra__burger-button.ant-btn {
102 | display: none;
103 | }
104 |
105 | .task-group-extra__burger-button span {
106 | color: var(--text-blue);
107 | }
108 |
109 | .task-group-extra__dropdown.ant-dropdown .ant-dropdown-menu{
110 | border-radius: 2px;
111 |
112 | .ant-dropdown-menu-title-content {
113 | display: flex;
114 | column-gap: 8px;
115 | }
116 | }
117 |
118 | @media (max-width: $l-breakpoint-959) {
119 | .task-group__collapse-buttons .ant-btn {
120 | gap: 8px;
121 |
122 | & span:not(.anticon) {
123 | display: none;
124 | }
125 | }
126 | }
127 |
128 | @media (max-width: $s-breakpoint-525) {
129 | .task-group__collapse-buttons .task-group-extra__burger-button.ant-btn {
130 | display: unset;
131 | }
132 |
133 | .task-group__collapse-buttons.tasks__collapse-buttons .ant-btn:not(.task-group-extra__burger-button) {
134 | display: none;
135 | }
136 |
137 | .task-group__name-with-score {
138 | flex-direction: column;
139 | gap: 4px;
140 | align-items: flex-start;
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/components/Tasks/Tasks.scss:
--------------------------------------------------------------------------------
1 | @use "../../globals" as *;
2 |
3 | .tasks__content-wrapper {
4 | padding-top: 24px;
5 | padding-bottom: 24px;
6 |
7 | &:has(.sticky-header) {
8 | position: relative;
9 | }
10 | }
11 |
12 | .tasks__collapse {
13 | background: red;
14 | .ant-collapse-item >.ant-collapse-header.tasks__name {
15 | align-items: center;
16 | padding: 0;
17 | line-height: 0.94;
18 | }
19 |
20 | &.ant-collapse .ant-collapse-content>.ant-collapse-content-box {
21 | padding: 0;
22 | }
23 |
24 | &.ant-collapse-ghost >.ant-collapse-item >.ant-collapse-content >.ant-collapse-content-box {
25 | padding-block-end: 0;
26 | }
27 |
28 | .tasks__collapse-buttons {
29 | display: flex;
30 | gap: 8px;
31 | }
32 |
33 | &.ant-collapse > .ant-collapse-item > .ant-collapse-header.sticky-header {
34 | position: sticky;
35 | top: 48px;
36 | transition: top 0.3s ease-in-out !important;
37 | background: var(--background-primary);
38 | box-shadow: none;
39 | border-bottom: 1px solid var(--stroke-primary);
40 | z-index: 10;
41 | padding: 12px 16px 12px;
42 | margin: -12px -16px -12px;
43 | border-radius: 0;
44 |
45 | &:not(:has(.task-group__task-name_hidden)) {
46 | padding: 4px 16px;
47 | margin: -4px -16px;
48 |
49 | .ant-collapse-expand-icon {
50 | align-self: baseline;
51 | }
52 | }
53 | }
54 | }
55 |
56 | .tasks__collapse-buttons .ant-btn {
57 | display: flex;
58 | align-items: center;
59 | gap: 10px;
60 |
61 | .anticon+span {
62 | margin-inline-start: 0;
63 | }
64 | }
65 |
66 | .tasks__name {
67 | font-size: 32px;
68 |
69 | .ant-collapse-header-text {
70 | white-space: nowrap;
71 | overflow: hidden;
72 | text-overflow: ellipsis;
73 | }
74 |
75 | & .ant-collapse-arrow>svg {
76 | width: 24px;
77 | height: 24px;
78 | }
79 | }
80 |
81 | .tasks__edit-buttons {
82 | width: 210px;
83 | flex: 0 0 auto;
84 | }
85 |
86 | .tasks__edit-buttons__text {
87 | display: unset;
88 | }
89 |
90 | @media (max-width: $xm-breakpoint-799) {
91 | .tasks__name {
92 | font-size: 24px;
93 | }
94 | }
95 |
96 | @media (max-width: $s-breakpoint-525) {
97 | .task-group-extra__burger-button.ant-btn {
98 | display: unset;
99 | }
100 |
101 | .task-group__collapse-buttons .ant-btn:not(.task-group-extra__burger-button) {
102 | display: none;
103 | }
104 |
105 | .tasks__edit-buttons {
106 | width: 100%;
107 | }
108 |
109 | .ant-btn >span.tasks__edit-buttons__text {
110 | display: none;
111 | }
112 |
113 | .tasks__collapse {
114 | .ant-collapse-item >.ant-collapse-header.tasks__name:has(.task-group__score) {
115 | align-items: flex-start;
116 | }
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/components/Tasks/Tasks.tsx:
--------------------------------------------------------------------------------
1 | import { TasksMode } from '@/components/Tasks/Task/Task.helpers';
2 | import TaskGroup from '@/components/Tasks/TaskGroup/TaskGroup';
3 | import { IQuestTaskGroups } from '@/app/types/quest-interfaces';
4 | import Brief from '@/components/Tasks/Brief/Brief';
5 |
6 | export default function Tasks({mode, props} : {mode: TasksMode, props: IQuestTaskGroups}) {
7 | return (
8 | <>
9 |
10 | {props.task_groups?.map((taskGroup) => )}
11 | >
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/components/Tasks/_index.scss:
--------------------------------------------------------------------------------
1 | @forward "TaskGroup/TaskGroup";
2 | @forward "Task/Task";
3 | @forward "Task/EditTask/EditTask";
4 | @forward "TaskGroup/EditTaskGroup/EditTaskGroup";
5 | @forward "PlayPageContent/PlayPageContent";
6 | @forward "Brief/Brief";
7 | @forward "Tasks";
8 |
--------------------------------------------------------------------------------
/components/ThemeChanger/ThemeChanger.scss:
--------------------------------------------------------------------------------
1 | .theme-changer {
2 | display: flex;
3 | flex-direction: column;
4 | }
5 |
6 | .theme-changer__header {
7 | font-size: 14px;
8 | font-weight: 700;
9 | line-height: 16px;
10 | color: var(--text-default);
11 | margin: 0;
12 | height: 26px;
13 | }
14 |
15 | .theme-changer__radio-group.ant-radio-group {
16 | display: flex;
17 | flex-direction: column;
18 | row-gap: 5px;
19 | }
20 |
21 | .ant-dropdown:has(.theme-changer) {
22 | .ant-dropdown-menu .ant-dropdown-menu-item:has(.theme-changer) {
23 | &:hover, &:active, &:focus-visible {
24 | background-color: unset;
25 | }
26 | }
27 |
28 | .ant-dropdown-menu .ant-dropdown-menu-item-divider {
29 | margin: 0 12px 5px 12px;
30 |
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/components/ThemeChanger/ThemeChanger.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from 'next-themes'
2 | import { Radio, RadioChangeEvent } from 'antd';
3 | import React from 'react';
4 |
5 | export default function ThemeChanger() {
6 | const { theme, setTheme } = useTheme();
7 |
8 | const onChange = (e: RadioChangeEvent) => {
9 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
10 | setTheme(e.target.value);
11 | }
12 |
13 | return (
14 |
15 |
Тема
16 |
17 | Светлая 🌝
18 | Темная 🌚
19 | Как в системе 💻
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/components/_component-dir.scss:
--------------------------------------------------------------------------------
1 | @use 'AuthForm/AuthForm';
2 | @use 'Background/Background';
3 | @use 'Body/Body';
4 | @use 'ContentWrapper/ContentWrapper';
5 | @use 'CustomModal/CustomModal';
6 | @use 'ExitButton/ExitButton';
7 | @use 'Footer/Footer';
8 | @use 'Header';
9 | @use 'Logotype/Logotype';
10 | @use 'Profile';
11 | @use 'Quest';
12 | @use 'QuestAdmin';
13 | @use 'QuestTabs';
14 | @use 'Tasks';
15 | @use 'ThemeChanger/ThemeChanger';
16 |
--------------------------------------------------------------------------------
/globals/_colors.scss:
--------------------------------------------------------------------------------
1 | $white: #FFFFFF;
2 | $anti-flash-white: #F0F0F0;
3 | $cultured: #F5F5F5;
4 | $light-silver: #D9D9D9;
5 | $silver-chalice: #ADABAB;
6 | $philippine-gray: #8C8C8C;
7 | $dark-philippine-gray: #8B8B8B;
8 | $gray: #7C7B7B;
9 | $granite-gray: #5F5F5F;
10 | $dark-liver: #4E4E4E;
11 | $dark-charcoal: #333333;
12 | $eerie-black: #1F1F1F;
13 | $raisin-black: #202020;
14 | $black: #000000;
15 |
16 |
17 | $pastel-red: #FF5C61;
18 | $deep-carmine-pink: #F13036;
19 | $lava: #CF1322;
20 | $dark-tangerine: #FAAD14;
21 | $gamboge: #D89614;
22 | $kelly-green: #52C41A;
23 | $dark-kelly-green: #49AA19;
24 | $slimy-green: #389E0D;
25 | $bubbles: #E6F7FF;
26 | $pale-cyan: #91D5FF;
27 | $dodger-blue: #1890FF;
28 | $light-dodger-blue: #2797FF;
29 | $absolute-zero: #0050B3;
30 | $cool-black: #002766;
31 | $lavender: #B27BFF;
32 | $medium-purple: #9E6DE3;
33 | $blue-violet: #722ED1;
34 | $american-violet: #531DAB;
35 |
--------------------------------------------------------------------------------
/globals/_index.scss:
--------------------------------------------------------------------------------
1 | @forward "variables";
2 | @forward "colors";
3 |
4 | @forward 'theme';
5 |
6 |
7 |
--------------------------------------------------------------------------------
/globals/_theme.scss:
--------------------------------------------------------------------------------
1 | @use 'colors' as *;
2 |
3 | html[data-theme="light"] {
4 | /* backgrounds */
5 | --background-default: #{$cultured};
6 | --background-primary: #{$white};
7 | --background-secondary: #{$anti-flash-white};
8 | --background-disabled: #{$cultured};
9 | --background-red: #{$deep-carmine-pink};
10 | --background-green: #{$kelly-green};
11 | --background-blue: #{$dodger-blue};
12 | --background-blue-secondary: #{$bubbles};
13 | --background-yellow: #{$dark-tangerine};
14 |
15 | /* strokes */
16 | --stroke-primary: #{$dark-charcoal};
17 | --stroke-secondary: #{$light-silver};
18 | --stroke-blue: #{$pale-cyan};
19 |
20 | /* texts */
21 | --text-default: #{$eerie-black};
22 | --text-primary: #{$white};
23 | --text-secondary: #{$granite-gray};
24 | --text-disabled: #{$philippine-gray};
25 | --text-red: #{$lava};
26 | --text-green: #{$slimy-green};
27 | --text-blue: #{$dodger-blue};
28 | --text-yellow: #{$dark-tangerine};
29 | --text-purple: #{$american-violet};
30 |
31 | /* filled icons */
32 | --icon-filled-default: #{$eerie-black};
33 | --icon-filled-primary: #{$white};
34 | --icon-filled-secondary: #{$granite-gray};
35 | --icon-filled-disabled: #{$philippine-gray};
36 | --icon-filled-red: #{$deep-carmine-pink};
37 | --icon-filled-green: #{$kelly-green};
38 | --icon-filled-blue: #{$dodger-blue};
39 | --icon-filled-yellow: #{$dark-tangerine};
40 | --icon-filled-purple: #{$blue-violet};
41 |
42 | /* outlined icons */
43 | --icon-outlined-default: #{$eerie-black};
44 | --icon-outlined-primary: #{$white};
45 | --icon-outlined-secondary: #{$granite-gray};
46 | --icon-outlined-disabled: #{$philippine-gray};
47 | --icon-outlined-red: #{$lava};
48 | --icon-outlined-green: #{$slimy-green};
49 | --icon-outlined-blue: #{$dodger-blue};
50 | --icon-outlined-yellow: #{$dark-tangerine};
51 | --icon-outlined-purple: #{$american-violet};
52 | }
53 |
54 | html[data-theme="dark"] {
55 | /* backgrounds */
56 | --background-default: #{$black};
57 | --background-primary: #{$dark-charcoal};
58 | --background-secondary: #{$raisin-black};
59 | --background-disabled: #{$dark-liver};
60 | --background-red: #{$deep-carmine-pink};
61 | --background-green: #{$dark-kelly-green};
62 | --background-blue: #{$light-dodger-blue};
63 | --background-blue-secondary: #{$cool-black};
64 | --background-yellow: #{$gamboge};
65 |
66 | /* strokes */
67 | --stroke-primary: #{$dark-philippine-gray};
68 | --stroke-secondary: #{$eerie-black};
69 | --stroke-blue: #{$absolute-zero};
70 |
71 | /* texts */
72 | --text-default: #{$white};
73 | --text-primary: #{$white};
74 | --text-secondary: #{$silver-chalice};
75 | --text-disabled: #{$gray};
76 | --text-red: #{$pastel-red};
77 | --text-green: #{$kelly-green};
78 | --text-blue: #{$light-dodger-blue};
79 | --text-yellow: #{$gamboge};
80 | --text-purple: #{$lavender};
81 |
82 | /* filled icons */
83 | --icon-filled-default: #{$white};
84 | --icon-filled-primary: #{$white};
85 | --icon-filled-secondary: #{$silver-chalice};
86 | --icon-filled-disabled: #{$gray};
87 | --icon-filled-red: #{$deep-carmine-pink};
88 | --icon-filled-green: #{$dark-kelly-green};
89 | --icon-filled-blue: #{$light-dodger-blue};
90 | --icon-filled-yellow: #{$gamboge};
91 | --icon-filled-purple: #{$medium-purple};
92 |
93 | /* outlined icons */
94 | --icon-outlined-default: #{$white};
95 | --icon-outlined-primary: #{$white};
96 | --icon-outlined-secondary: #{$silver-chalice};
97 | --icon-outlined-disabled: #{$gray};
98 | --icon-outlined-red: #{$pastel-red};
99 | --icon-outlined-green: #{$kelly-green};
100 | --icon-outlined-blue: #{$light-dodger-blue};
101 | --icon-outlined-yellow: #{$gamboge};
102 | --icon-outlined-purple: #{$lavender};
103 | }
104 |
--------------------------------------------------------------------------------
/globals/_variables.scss:
--------------------------------------------------------------------------------
1 | @use 'colors' as *;
2 |
3 | // breakpoints
4 | $xss-breakpoint-319: 319px;
5 | $xs-breakpoint-374: 374px;
6 | $s-breakpoint-525: 525px;
7 | $m-breakpoint-639: 639px;
8 | $xm-breakpoint-799: 799px;
9 | $l-breakpoint-959: 959px;
10 | $xl-breakpoint-1279: 1279px;
11 | $xxl-breakpoint-1439: 1439px;
12 |
13 | // other
14 | $side-margins-16: 16px;
15 | $side-margins-24: 24px;
16 | $side-margins-32: 32px;
17 | $main-gap: 16px;
18 | $max-items-width: 1250px;
19 |
20 | // fonts
21 | $font-robotoflex: var(--font-robotoflex);
22 | $font-manrope: var(--font-manrope);
23 | $large-font-size: 48px;
24 | $medium-font-size: 32px;
25 | $small-font-size: 24px;
26 |
27 | // columns
28 | $grid-column-640: 1fr 1fr;
29 | $grid-column-960: 1fr 1fr 1fr;
30 | $grid-column-1280: 1fr 1fr 1fr 1fr;
31 |
32 | @mixin status-color($color) {
33 | color: var(--text-#{$color});
34 | font-weight: 500;
35 |
36 | & circle {
37 | fill: var(--icon-filled-#{$color});
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/infra/k8s/questspace/frontend-service.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: frontend-service
5 | namespace: questspace
6 | spec:
7 | selector:
8 | app: questspace-frontend
9 | ports:
10 | - protocol: TCP
11 | port: 3000
12 | targetPort: 3000
13 |
--------------------------------------------------------------------------------
/infra/k8s/questspace/questspace-frontend.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: frontend-deployment
5 | namespace: questspace
6 | spec:
7 | replicas: 1
8 | selector:
9 | matchLabels:
10 | app: questspace-frontend
11 | template:
12 | metadata:
13 | labels:
14 | app: questspace-frontend
15 | spec:
16 | imagePullSecrets:
17 | - name: docker-registry-secret
18 | containers:
19 | - name: frontend-container
20 | image: __VERSION__
21 | imagePullPolicy: Always
22 | ports:
23 | - containerPort: 3000
24 | env:
25 | - name: NEXTAUTH_URL
26 | value: questspace.fun
27 | - name: NODE_ENV
28 | value: production
29 | - name: NEXTAUTH_SECRET
30 | valueFrom:
31 | secretKeyRef:
32 | name: questspace-nextauth-secret
33 | key: nextauth-secret
34 | - name: GOOGLE_CLIENT_ID
35 | valueFrom:
36 | secretKeyRef:
37 | name: questspace-google-secret
38 | key: google-client-id
39 | - name: GOOGLE_CLIENT_SECRET
40 | valueFrom:
41 | secretKeyRef:
42 | name: questspace-google-secret
43 | key: google-oauth-secret
44 | - name: AWS_ACCESS_KEY_ID
45 | valueFrom:
46 | secretKeyRef:
47 | name: questspace-s3-secret
48 | key: access-key-id
49 | - name: AWS_SECRET_KEY_ID
50 | valueFrom:
51 | secretKeyRef:
52 | name: questspace-s3-secret
53 | key: secret-key-id
54 |
--------------------------------------------------------------------------------
/infra/k8s/questspace/questspace-secret.example.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Secret
3 | type: Opaque
4 | metadata:
5 | name: questspace-nextauth-secret
6 | namespace: questspace
7 | data:
8 | nextauth-secret: ZmQyZTQyNGQwMzBjMWVkNDY1YmY4MWUyZmI3MjJjOGU=
9 | ---
10 |
11 | apiVersion: v1
12 | kind: Secret
13 | type: Opaque
14 | metadata:
15 | name: questspace-s3-secret
16 | namespace: questspace
17 | data:
18 | access-key-id: QVdTX0FDQ0VTU19LRVlfSUQ=
19 | secret-key-id: QVdTX1NFQ1JFVF9LRVlfSUQ=
20 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const nextJest = require('next/jest')
2 |
3 | /** @type {import('jest').Config} */
4 | const createJestConfig = nextJest({
5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
6 | dir: './',
7 | })
8 |
9 | // Add any custom config to be passed to Jest
10 | const config = {
11 | coverageProvider: 'v8',
12 | testEnvironment: 'jsdom',
13 | // Add more setup options before each test is run
14 | setupFilesAfterEnv: ['/jest.setup.js'],
15 | preset: 'ts-jest',
16 | }
17 |
18 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
19 | module.exports = createJestConfig(config)
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 | import 'cross-fetch/polyfill';
--------------------------------------------------------------------------------
/lib/Manrope.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Questspace-v2/questspace-frontend/24ba50f57ebd02b895409f32b5eea97c0fe8d4b5/lib/Manrope.woff2
--------------------------------------------------------------------------------
/lib/RobotoFlex.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Questspace-v2/questspace-frontend/24ba50f57ebd02b895409f32b5eea97c0fe8d4b5/lib/RobotoFlex.woff2
--------------------------------------------------------------------------------
/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | import localFont from 'next/font/local';
2 |
3 | export const manrope = localFont({
4 | src: './Manrope.woff2',
5 | style: 'normal',
6 | variable: '--font-manrope',
7 | display: 'swap',
8 | fallback: ['sans-serif'],
9 | });
10 |
11 | export const robotoFlex = localFont({
12 | src: './RobotoFlex.woff2',
13 | style: 'normal',
14 | weight: '700',
15 | variable: '--font-robotoflex',
16 | display: 'swap',
17 | fallback: ['Helvetica'],
18 | });
19 |
--------------------------------------------------------------------------------
/lib/utils/modalTypes.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export type ValidationStatus = '' | 'success' | 'error' | 'warning' | 'validating' | undefined;
4 |
5 | export const enum TeamModal {
6 | CREATE_TEAM,
7 | INVITE_LINK
8 | }
9 |
10 | export type TeamModalType = TeamModal | null;
11 |
12 | export interface ModalProps {
13 | questId?: string,
14 | inviteLink?: string,
15 | setCurrentModal?: React.Dispatch>,
16 | currentModal?: TeamModalType,
17 | registrationType?: 'AUTO' | 'VERIFY'
18 | }
19 |
--------------------------------------------------------------------------------
/lib/utils/utils.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ALLOWED_USERS_ID } from '@/app/api/client/constants';
4 | import { usePathname } from 'next/navigation';
5 |
6 | export const getClassnames = (...classes: string[]) : string => classes.join(' ').trim();
7 |
8 | export const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2);
9 |
10 | export const getCenter = (clientWidth: number, clientHeight: number) => {
11 | const centerY = clientHeight / 2;
12 | const centerX = clientWidth / 2;
13 | return {x: centerX, y: centerY};
14 | }
15 |
16 | export const parseToMarkdown = (str?: string): string => str?.replaceAll('\\n', '\n') ?? '';
17 |
18 | export const isAllowedUser = (userId: string) : boolean => ALLOWED_USERS_ID.includes(userId);
19 |
20 | export const getRedirectParams = () => {
21 | const location = usePathname();
22 | const splitParams = location.split('/').slice(1);
23 |
24 | return new URLSearchParams({route: splitParams[0], id: splitParams[1]});
25 | }
26 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | async headers() {
4 | return [{
5 | //cache all images, fonts, etc. in the public folder
6 | //Note: Next.js default is 'public, max-age=0' which cases many reloads on Safari!
7 | //Note: we use version hashes and therefore can use immutable
8 | source: '/:all*(svg|jpg|jpeg|png|gif|ico|webp|mp4|ttf|otf|woff|woff2)',
9 |
10 | headers: [{
11 | key: 'cache-control',
12 | value: 'public, max-age=31536000, immutable'
13 | }]
14 | }];
15 | },
16 | images: {
17 | dangerouslyAllowSVG: true,
18 | unoptimized: false,
19 | remotePatterns: [
20 | {
21 | protocol: "https",
22 | hostname: "api.dicebear.com",
23 | port: "",
24 | pathname: "/**"
25 | },
26 | {
27 | protocol: "https",
28 | hostname: "storage.yandexcloud.net",
29 | port: "",
30 | pathname: "/questspace-img/**",
31 | },
32 | {
33 | protocol: 'https',
34 | hostname: 'source.unsplash.com',
35 | port: '',
36 | pathname: '/**'
37 | },
38 | {
39 | hostname: 'lh3.googleusercontent.com'
40 | }
41 | ],
42 | },
43 | output: "standalone"
44 | };
45 |
46 | module.exports = nextConfig;
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "questspace-frontend",
3 | "version": "0.1.0",
4 | "browserslist": [
5 | "> 0.2%, not dead"
6 | ],
7 | "private": true,
8 | "engines": {
9 | "node": "^21.7.2",
10 | "npm": "^10.5.0"
11 | },
12 | "scripts": {
13 | "dev": "next dev -H test.questspace.fun --experimental-https --experimental-https-key ./certificates/private_key.pem --experimental-https-cert certificates/questspace_cert.pem",
14 | "build": "next build",
15 | "start": "next start",
16 | "lint": "next lint",
17 | "test": "jest"
18 | },
19 | "dependencies": {
20 | "@ant-design/cssinjs": "^1.17.0",
21 | "@ant-design/icons": "^5.2.6",
22 | "@ant-design/nextjs-registry": "1.0.0",
23 | "@aws-sdk/client-s3": "3.787.0",
24 | "@dnd-kit/core": "6.1.0",
25 | "@dnd-kit/sortable": "8.0.0",
26 | "@dnd-kit/utilities": "3.2.2",
27 | "@jest/globals": "29.7.0",
28 | "@types/http-errors": "2.0.4",
29 | "antd": "5.14.1",
30 | "antd-img-crop": "4.21.0",
31 | "classnames": "2.5.1",
32 | "dayjs": "1.11.10",
33 | "http-errors": "2.0.0",
34 | "next": "14.2.25",
35 | "next-auth": "4.24.7",
36 | "next-themes": "0.3.0",
37 | "rc-upload": "4.5.2",
38 | "react": "18.2.0",
39 | "react-countdown": "2.3.6",
40 | "react-dom": "18.2.0",
41 | "react-intersection-observer": "9.8.2",
42 | "react-markdown": "9.0.1",
43 | "react-virtualized-auto-sizer": "1.0.24",
44 | "react-window": "1.8.10",
45 | "react-window-infinite-loader": "1.0.9",
46 | "remark-gfm": "4.0.0",
47 | "swiper": "11.1.14",
48 | "yet-another-react-lightbox": "3.21.9"
49 | },
50 | "devDependencies": {
51 | "@testing-library/jest-dom": "6.4.2",
52 | "@testing-library/react": "14.2.2",
53 | "@types/jest": "29.5.12",
54 | "@types/node": "latest",
55 | "@types/react": "latest",
56 | "@types/react-dom": "latest",
57 | "@types/react-window": "1.8.8",
58 | "@types/react-window-infinite-loader": "1.0.9",
59 | "@typescript-eslint/eslint-plugin": "^6.7.3",
60 | "@typescript-eslint/parser": "^6.7.3",
61 | "autoprefixer": "latest",
62 | "cross-fetch": "4.0.0",
63 | "eslint": "8.50.0",
64 | "eslint-config-airbnb": "^19.0.4",
65 | "eslint-config-airbnb-typescript": "^17.1.0",
66 | "eslint-config-next": "14.1.0",
67 | "eslint-config-prettier": "^9.0.0",
68 | "eslint-plugin-import": "^2.28.1",
69 | "eslint-plugin-jsx-a11y": "^6.7.1",
70 | "eslint-plugin-promise": "^6.1.1",
71 | "eslint-plugin-react": "^7.33.2",
72 | "eslint-plugin-react-hooks": "^4.6.0",
73 | "http-status-codes": "2.3.0",
74 | "jest": "29.7.0",
75 | "jest-environment-jsdom": "29.7.0",
76 | "jest-fetch-mock": "3.0.3",
77 | "postcss": "latest",
78 | "prettier": "3.0.3",
79 | "sass": "1.77.8",
80 | "sharp": "0.33.3",
81 | "ts-jest": "29.1.2",
82 | "ts-node": "10.9.2",
83 | "typescript": "^5.2.2"
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | autoprefixer: {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/public/Questspace-Background.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Questspace-v2/questspace-frontend/24ba50f57ebd02b895409f32b5eea97c0fe8d4b5/public/Questspace-Background.webp
--------------------------------------------------------------------------------
/public/Questspace-Icon.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "allowUnreachableCode": false,
5 | "allowUnusedLabels": false,
6 | "declaration": true,
7 | "declarationMap": true,
8 | "noImplicitReturns": true,
9 | "pretty": true,
10 | "sourceMap": true,
11 | "target": "es5",
12 | "lib": ["dom", "dom.iterable", "esnext"],
13 | "allowJs": true,
14 | "skipLibCheck": true,
15 | "strict": true,
16 | "noEmit": true,
17 | "esModuleInterop": true,
18 | "module": "esnext",
19 | "moduleResolution": "bundler",
20 | "resolveJsonModule": true,
21 | "isolatedModules": true,
22 | "jsx": "preserve",
23 | "incremental": true,
24 | "plugins": [
25 | {
26 | "name": "next"
27 | }
28 | ],
29 | "paths": {
30 | "@/*": ["./*"],
31 | "@/fonts": ["./lib/fonts"]
32 | }
33 | },
34 | "include": ["next-env.d.ts", "**/*.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
35 | "exclude": ["node_modules"]
36 | }
37 |
--------------------------------------------------------------------------------
/types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import { DefaultSession } from 'next-auth';
2 | import { IUser } from '@/app/types/user-interfaces';
3 |
4 | declare module "next-auth" {
5 | interface Session {
6 | accessToken: string,
7 | isOAuthProvider: boolean,
8 | user: {
9 | id: string,
10 | } & DefaultSession["user"]
11 | }
12 |
13 | interface User {
14 | user: IUser,
15 | access_token: string
16 | }
17 | }
18 |
19 | declare module "next-auth/jwt" {
20 | interface JWT {
21 | id: string,
22 | isOAuthProvider: boolean
23 | }
24 | }
--------------------------------------------------------------------------------