├── .nvmrc ├── jest.setup.js ├── components ├── Header │ ├── _index.scss │ ├── HeaderAvatar │ │ ├── HeaderAvatar.scss │ │ └── HeaderAvatar.tsx │ ├── Header.scss │ └── Header.tsx ├── QuestTabs │ ├── _index.scss │ ├── QuestCardsList │ │ └── QuestCardsList.tsx │ ├── questTabsSelectTheme.tsx │ ├── QuestTabs.server.tsx │ ├── QuestTabs.helpers.tsx │ ├── QuestTabs.scss │ └── QuestCard │ │ ├── QuestCard.tsx │ │ └── QuestCard.scss ├── Profile │ ├── _index.scss │ ├── AvatarStub │ │ └── AvatarStub.tsx │ ├── EditProfile │ │ ├── EditProfile.helpers.ts │ │ ├── EditProfile.scss │ │ ├── EditName │ │ │ └── EditName.tsx │ │ ├── EditAvatar │ │ │ └── EditAvatar.tsx │ │ ├── EditPassword │ │ │ └── EditPassword.tsx │ │ └── EditProfile.tsx │ ├── Profile.scss │ └── Profile.tsx ├── Background │ ├── Background.types.ts │ ├── Background.scss │ └── Background.tsx ├── Quest │ ├── Quest.scss │ ├── QuestDescription │ │ ├── QuestDescription.scss │ │ └── QuestDescription.tsx │ ├── QuestAllTeams │ │ ├── QuestAllTeams.scss │ │ └── QuestAllTeams.tsx │ ├── _index.scss │ ├── QuestAdminPanel │ │ ├── QuestAdminPanel.scss │ │ └── QuestAdminPanel.tsx │ ├── QuestTeam │ │ ├── CreateTeam │ │ │ └── CreateTeam.scss │ │ ├── InviteModal │ │ │ ├── InviteModal.scss │ │ │ └── InviteModal.tsx │ │ └── QuestTeam.scss │ ├── EditQuest │ │ ├── EditQuest.scss │ │ └── QuestPreview │ │ │ ├── QuestPreview.scss │ │ │ └── QuestPreview.tsx │ ├── QuestResults │ │ ├── QuestResults.scss │ │ └── QuestResults.tsx │ ├── Quest.tsx │ ├── QuestParticipantsWrapper │ │ └── QuestParticipantsWrapper.tsx │ └── QuestHeader │ │ └── QuestHeader.scss ├── Body │ ├── Body.tsx │ └── Body.scss ├── QuestAdmin │ ├── _index.scss │ ├── QuestAdmin.helpers.ts │ ├── Logs │ │ ├── InfoAlert │ │ │ ├── InfoAlert.scss │ │ │ └── InfoAlert.tsx │ │ ├── Filters │ │ │ ├── Filters.scss │ │ │ └── Filters.tsx │ │ └── Logs.scss │ ├── Leaderboard │ │ ├── Leaderboard.scss │ │ └── Leaderboard.tsx │ ├── QuestAdmin.scss │ └── Teams │ │ └── Teams.scss ├── ContentWrapper │ ├── ContentWrapper.types.ts │ ├── ContentWrapper.tsx │ └── ContentWrapper.scss ├── CustomCountdown │ └── CustomCountdown.tsx ├── ExitButton │ ├── ExitButton.scss │ └── ExitButton.tsx ├── Tasks │ ├── _index.scss │ ├── Tasks.tsx │ ├── Task │ │ ├── Task.helpers.tsx │ │ └── EditTask │ │ │ └── EditTask.scss │ ├── ContextProvider │ │ └── ContextProvider.tsx │ ├── Brief │ │ ├── Brief.scss │ │ └── BriefEditButtons │ │ │ └── BriefEditButtons.tsx │ ├── TaskGroup │ │ ├── EditTaskGroup │ │ │ └── EditTaskGroup.scss │ │ └── TaskGroup.scss │ ├── PlayPageContent │ │ ├── PlayPageContent.scss │ │ └── PlayPageContent.tsx │ └── Tasks.scss ├── Logotype │ ├── Logotype.scss │ └── Logotype.tsx ├── _component-dir.scss ├── NextAuthProvider │ ├── NextAuthProvider.tsx │ └── SessionRefetchEvents.tsx ├── AuthForm │ ├── AuthForm.types.ts │ └── AuthForm.scss ├── ThemeChanger │ ├── ThemeChanger.scss │ └── ThemeChanger.tsx ├── CustomModal │ ├── CustomModal.tsx │ └── CustomModal.scss └── Footer │ ├── Footer.tsx │ └── Footer.scss ├── app ├── favicon.ico ├── (auth and errors) │ ├── [...not-found] │ │ └── page.tsx │ ├── auth │ │ └── page.tsx │ ├── invites │ │ ├── error │ │ │ └── page.tsx │ │ └── [path] │ │ │ └── page.tsx │ ├── not-found.tsx │ └── layout.tsx ├── (root) │ ├── quest │ │ ├── [id] │ │ │ ├── edit │ │ │ │ ├── page.tsx │ │ │ │ ├── about │ │ │ │ │ └── page.tsx │ │ │ │ ├── teams │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── TeamsTabClient.tsx │ │ │ │ ├── leaderboard │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── LeaderboardTabClient.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── logs │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── LogsTabClient.tsx │ │ │ │ └── tasks │ │ │ │ │ └── page.tsx │ │ │ ├── play │ │ │ │ └── page.tsx │ │ │ ├── not-found.tsx │ │ │ └── page.tsx │ │ └── create │ │ │ └── page.tsx │ ├── page.tsx │ └── layout.tsx ├── types │ ├── json-data.ts │ └── user-interfaces.ts ├── api │ ├── settings.ts │ ├── __mocks__ │ │ ├── User.mock.ts │ │ ├── Quest.mock.ts │ │ └── Task.mock.ts │ ├── custom-errors.ts │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── client │ │ └── constants.ts │ └── uploadToS3.ts ├── robots.ts ├── metadata.ts ├── sitemap.ts └── main.scss ├── lib ├── Manrope.woff2 ├── RobotoFlex.woff2 ├── fonts.ts └── utils │ ├── modalTypes.ts │ └── utils.ts ├── postcss.config.js ├── globals ├── _index.scss ├── _colors.scss ├── _variables.scss └── _theme.scss ├── .npmrc ├── .dockerignore ├── public ├── Questspace-Background.webp └── Questspace-Icon.svg ├── .eslintignore ├── .env ├── infra └── k8s │ └── questspace │ ├── frontend-service.yaml │ ├── questspace-secret.example.yaml │ └── questspace-frontend.yaml ├── .prettierrc.json ├── types └── next-auth.d.ts ├── .gitignore ├── jest.config.js ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ ├── node.yml │ └── deploy.yml ├── Dockerfile ├── .eslintrc.json ├── next.config.js ├── README.md ├── __tests__ └── api.test.ts └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 21.7.2 2 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import 'cross-fetch/polyfill'; -------------------------------------------------------------------------------- /components/Header/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "Header"; 2 | @forward "HeaderAvatar/HeaderAvatar"; 3 | -------------------------------------------------------------------------------- /components/QuestTabs/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "QuestTabs"; 2 | @forward "QuestCard/QuestCard"; 3 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Questspace-v2/questspace-frontend/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /components/Profile/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "Profile"; 2 | @forward "EditProfile/EditProfile"; 3 | 4 | -------------------------------------------------------------------------------- /lib/Manrope.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Questspace-v2/questspace-frontend/HEAD/lib/Manrope.woff2 -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /globals/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "variables"; 2 | @forward "colors"; 3 | 4 | @forward 'theme'; 5 | 6 | 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | engine-strict=true 3 | node-version=21.7.2 4 | save-exact=true 5 | -------------------------------------------------------------------------------- /lib/RobotoFlex.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Questspace-v2/questspace-frontend/HEAD/lib/RobotoFlex.woff2 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | .git 4 | Dockerfile 5 | LICENSE 6 | .dockerignore 7 | .gitignore 8 | README.md -------------------------------------------------------------------------------- /public/Questspace-Background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Questspace-v2/questspace-frontend/HEAD/public/Questspace-Background.webp -------------------------------------------------------------------------------- /components/Background/Background.types.ts: -------------------------------------------------------------------------------- 1 | export interface BackgroundProps { 2 | type: 'page' | 'footer'; 3 | className?: string; 4 | } 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/(root)/quest/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import AboutTab from './about/page'; 2 | 3 | export default function EditQuestPage() { 4 | return ( 5 | 6 | ); 7 | } -------------------------------------------------------------------------------- /components/Body/Body.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Body({ children }: React.PropsWithChildren) { 4 | return
{children}
; 5 | } 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/__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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/_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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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) -------------------------------------------------------------------------------- /public/Questspace-Icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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)/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/(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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/(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/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/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/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/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/(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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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)/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /__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 | }); -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/Header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import HeaderAvatar from '@/components/Header/HeaderAvatar/HeaderAvatar'; 4 | import Logotype from '@/components/Logotype/Logotype'; 5 | import { CSSProperties, useEffect, useRef, useState } from 'react'; 6 | import Link from 'next/link'; 7 | import { Button } from 'antd'; 8 | import { getRedirectParams } from '@/lib/utils/utils'; 9 | 10 | 11 | const pointerCursor: CSSProperties = { 12 | cursor: 'pointer', 13 | }; 14 | 15 | interface HeaderProps { 16 | isAuthorized: boolean 17 | } 18 | 19 | export default function Header({isAuthorized} : HeaderProps) { 20 | const redirectParams = getRedirectParams(); 21 | const isValidRedirect = redirectParams.get('route') === 'quest'; 22 | const [lastScrollY, setLastScrollY] = useState(0); 23 | const headerRef = useRef(null); 24 | const [headerHeight, setHeaderHeight] = useState(0); 25 | 26 | useEffect(() => { 27 | if (headerRef.current) { 28 | setHeaderHeight(headerRef.current.offsetHeight); 29 | } 30 | }, []); 31 | 32 | useEffect(() => { 33 | const handleScroll = () => { 34 | const currentScrollY = window.scrollY; 35 | 36 | // Определяем, скроллим вниз или вверх 37 | const scrollingDown = currentScrollY > lastScrollY; 38 | 39 | // Показываем хэдер если: 40 | // 1. Скроллим вверх 41 | // 2. Мы в самом верху страницы 42 | // 3. Мы еще не проскроллили высоту хэдера 43 | if (!scrollingDown || currentScrollY < headerHeight || currentScrollY <= 0) { 44 | headerRef?.current?.classList.remove('page-header__hidden'); 45 | } else { 46 | // Скрываем только если скроллим вниз И проскроллили больше чем высота хэдера 47 | headerRef?.current?.classList.add('page-header__hidden'); 48 | } 49 | 50 | setLastScrollY(currentScrollY); 51 | }; 52 | 53 | window.addEventListener('scroll', handleScroll, { passive: true }); 54 | 55 | return () => { 56 | window.removeEventListener('scroll', handleScroll); 57 | }; 58 | }, [lastScrollY, headerHeight]); 59 | 60 | return ( 61 |
62 |
63 | 64 | 65 | 66 | {isAuthorized ? 67 | 68 | : 69 | 70 | 71 | 72 | } 73 |
74 |
75 | ); 76 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 |