├── .gitignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── bun.lockb ├── next.config.ts ├── orval.config.ts ├── package.json ├── postcss.config.mjs ├── public ├── favicon.ico ├── opengraph.png └── touch-icons │ ├── 192x192.png │ └── 512x512.png ├── src ├── api │ ├── account.ts │ ├── course.ts │ ├── external.ts │ ├── index.ts │ ├── instance.ts │ ├── lesson.ts │ ├── mfa.ts │ ├── progress.ts │ ├── restriction.ts │ ├── session.ts │ └── users.ts ├── app │ ├── (public) │ │ ├── about │ │ │ └── page.tsx │ │ ├── courses │ │ │ ├── [slug] │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── document │ │ │ ├── privacy-policy │ │ │ │ └── page.tsx │ │ │ └── terms-of-use │ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── account │ │ ├── connections │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── sessions │ │ │ └── page.tsx │ │ └── settings │ │ │ └── page.tsx │ ├── auth │ │ ├── callback │ │ │ └── page.tsx │ │ ├── login │ │ │ └── page.tsx │ │ ├── recovery │ │ │ ├── [token] │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── register │ │ │ └── page.tsx │ │ └── verify │ │ │ └── [token] │ │ │ └── page.tsx │ ├── layout.tsx │ ├── lesson │ │ └── [slug] │ │ │ └── page.tsx │ ├── loading.tsx │ ├── manifest.ts │ ├── not-found.tsx │ ├── robots.ts │ └── sitemap.ts ├── components │ ├── account │ │ ├── connections │ │ │ ├── connection-error.tsx │ │ │ ├── connections.tsx │ │ │ └── unlink-provider.tsx │ │ ├── progress │ │ │ ├── courses-list.tsx │ │ │ ├── courses-tab.tsx │ │ │ ├── leaderboard.tsx │ │ │ ├── progress.tsx │ │ │ └── user-stats.tsx │ │ ├── sessions │ │ │ ├── remove-all-sessions.tsx │ │ │ ├── remove-session.tsx │ │ │ ├── session-item.tsx │ │ │ └── sessions.tsx │ │ └── settings │ │ │ ├── account-actions.tsx │ │ │ ├── account-form.tsx │ │ │ ├── appearance.tsx │ │ │ ├── avatar-form.tsx │ │ │ ├── disable-totp-form.tsx │ │ │ ├── display-name-form.tsx │ │ │ ├── email-form.tsx │ │ │ ├── enable-totp-form.tsx │ │ │ ├── password-form.tsx │ │ │ ├── preferences.tsx │ │ │ ├── profile-form.tsx │ │ │ ├── recovery-codes-modal.tsx │ │ │ ├── settings.tsx │ │ │ └── two-step-auth-form.tsx │ ├── analitycs │ │ └── yandex-metrika.tsx │ ├── auth │ │ ├── auth-social.tsx │ │ ├── auth-wrapper.tsx │ │ ├── login-form.tsx │ │ ├── mfa-form.tsx │ │ ├── new-password-form.tsx │ │ ├── register-form.tsx │ │ ├── reset-password-form.tsx │ │ └── verify-email.tsx │ ├── course │ │ ├── course-card.tsx │ │ ├── course-details.tsx │ │ ├── course-info.tsx │ │ ├── course-lessons.tsx │ │ ├── course-overview.tsx │ │ ├── course-progress.tsx │ │ └── course-summary.tsx │ ├── home │ │ ├── features.tsx │ │ ├── hero.tsx │ │ ├── popular.tsx │ │ └── telegram-cta.tsx │ ├── layout │ │ ├── footer.tsx │ │ ├── header.tsx │ │ ├── mobile-nav.tsx │ │ ├── nav-links.tsx │ │ ├── user-menu.tsx │ │ └── user-navigation.tsx │ ├── lesson │ │ ├── lesson-complete-button.tsx │ │ ├── lesson-container.tsx │ │ ├── lesson-player.tsx │ │ └── lesson-sidebar.tsx │ ├── providers │ │ ├── account-provider.tsx │ │ ├── ban-checker.tsx │ │ ├── course-provider.tsx │ │ ├── tanstack-query-provider.tsx │ │ └── theme-provider.tsx │ ├── shared │ │ ├── captcha.tsx │ │ ├── confirm-dialog.tsx │ │ ├── course-progress.tsx │ │ ├── ellipsis-loader.tsx │ │ ├── heading.tsx │ │ ├── logo.tsx │ │ ├── player.tsx │ │ └── sonner.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── progress.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── switch.tsx │ │ └── tabs.tsx ├── constants │ ├── app.ts │ ├── index.ts │ ├── routes.ts │ └── seo.ts ├── env.d.ts ├── generated │ ├── accountResponse.ts │ ├── activeRestrictionResponse.ts │ ├── changeEmailRequest.ts │ ├── changePasswordRequest.ts │ ├── courseProgressResponse.ts │ ├── courseResponse.ts │ ├── createCourseRequest.ts │ ├── createCourseResponse.ts │ ├── createLessonRequest.ts │ ├── createLessonResponse.ts │ ├── createProgressRequest.ts │ ├── createProgressResponse.ts │ ├── createRestrictionRequest.ts │ ├── createRestrictionRequestReason.ts │ ├── createUserRequest.ts │ ├── createUserResponse.ts │ ├── externalConnectResponse.ts │ ├── externalControllerCallbackParams.ts │ ├── externalStatusResponse.ts │ ├── index.ts │ ├── lastLessonResponse.ts │ ├── leaderResponse.ts │ ├── lessonResponse.ts │ ├── loginMfaResponse.ts │ ├── loginRequest.ts │ ├── loginSessionResponse.ts │ ├── meProgressResponse.ts │ ├── meProgressResponseLastLesson.ts │ ├── meStatisticsResponse.ts │ ├── mfaControllerVerifyBody.ts │ ├── mfaStatusResponse.ts │ ├── mfaVerifyRequest.ts │ ├── passwordResetRequest.ts │ ├── patchUserRequest.ts │ ├── progressResponse.ts │ ├── registrationsResponse.ts │ ├── sendPasswordResetRequest.ts │ ├── sessionControllerLogin200.ts │ ├── sessionControllerLoginAdmin200.ts │ ├── sessionResponse.ts │ ├── statisticsResponse.ts │ ├── totpDisableRequest.ts │ ├── totpEnableRequest.ts │ ├── totpGenerateSecretResponse.ts │ └── userResponse.ts ├── hooks │ ├── index.ts │ ├── use-auth.ts │ └── use-current.ts ├── lib │ ├── client │ │ ├── api.ts │ │ ├── error.ts │ │ └── types.ts │ ├── cookies │ │ └── session.ts │ └── utils │ │ ├── focus-ring.ts │ │ ├── format-date.ts │ │ ├── get-browser-icon.ts │ │ ├── get-lesson-label.ts │ │ ├── get-media-source.ts │ │ ├── index.ts │ │ └── tw-merge.ts ├── middleware.ts ├── server │ └── server.ts └── styles │ └── globals.css ├── tailwind.config.ts └── tsconfig.json /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | /design -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 4, 4 | "useTabs": true, 5 | "semi": false, 6 | "jsxSingleQuote": true, 7 | "singleQuote": true, 8 | "arrowParens": "avoid", 9 | "importOrder": [ 10 | "", 11 | "^@/app/(.*)$", 12 | "^@/components/(.*)$", 13 | "^@/constants/(.*)$", 14 | "^@/hooks/(.*)$", 15 | "^@/lib/(.*)$", 16 | "^@/server/(.*)$", 17 | "^@/styles/(.*)$", 18 | "^../(.*)$", 19 | "^./(.*)$" 20 | ], 21 | "importOrderSeparation": true, 22 | "importOrderSortSpecifiers": true, 23 | "plugins": [ 24 | "@trivago/prettier-plugin-sort-imports", 25 | "prettier-plugin-tailwindcss" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:1 AS base 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package.json bun.lockb ./ 6 | 7 | RUN bun install --frozen-lockfile 8 | 9 | FROM oven/bun:1 AS release 10 | 11 | COPY --from=base /usr/src/app/node_modules node_modules 12 | 13 | COPY . . 14 | 15 | RUN bun --bun run build 16 | 17 | CMD bun --bun run start 18 | 19 | EXPOSE 3000 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teacoder-team/frontend/a2f53148c33e40135bbc59ad9121039329e5bc5c/bun.lockb -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next' 2 | 3 | const config: NextConfig = { 4 | reactStrictMode: true, 5 | poweredByHeader: false, 6 | output: 'standalone', 7 | trailingSlash: false, 8 | skipTrailingSlashRedirect: true, 9 | images: { 10 | remotePatterns: [ 11 | { 12 | protocol: 'https', 13 | hostname: '**' 14 | } 15 | ] 16 | }, 17 | experimental: { 18 | optimizePackageImports: ['tailwindcss'] 19 | }, 20 | env: { 21 | APP_PORT: process.env['APP_PORT'], 22 | APP_URL: process.env['APP_URL'], 23 | COOKIE_DOMAIN: process.env['COOKIE_DOMAIN'], 24 | API_URL: process.env['API_URL'], 25 | STORAGE_URL: process.env['STORAGE_URL'], 26 | TURNSTILE_SITE_KEY: process.env['TURNSTILE_SITE_KEY'], 27 | YANDEX_METRIKA_ID: process.env['YANDEX_METRIKA_ID'], 28 | GOOGLE_ANALYTICS_ID: process.env['GOOGLE_ANALYTICS_ID'] 29 | } 30 | } 31 | 32 | export default config 33 | -------------------------------------------------------------------------------- /orval.config.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { defineConfig } from 'orval' 3 | 4 | config({ path: '.env' }) 5 | 6 | export default defineConfig({ 7 | client: { 8 | input: process.env['API_DOCS'], 9 | output: { 10 | schemas: './src/generated' 11 | } 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "description": "Frontend for Teacoder platform", 5 | "author": { 6 | "name": "Vadim", 7 | "url": "https://teacoder.ru", 8 | "email": "admin@teacoder.ru" 9 | }, 10 | "private": true, 11 | "license": "AGPL-3.0-only", 12 | "scripts": { 13 | "dev": "tsx -r dotenv/config ./src/server/server.ts", 14 | "build": "next build", 15 | "start": "next start -p 14701", 16 | "lint": "next lint", 17 | "generate": "orval --config ./orval.config.ts" 18 | }, 19 | "dependencies": { 20 | "@hookform/resolvers": "^3.10.0", 21 | "@kinescope/react-kinescope-player": "^0.5.3", 22 | "@next/third-parties": "15.2.4", 23 | "@radix-ui/react-accordion": "^1.2.3", 24 | "@radix-ui/react-alert-dialog": "^1.1.6", 25 | "@radix-ui/react-avatar": "^1.1.3", 26 | "@radix-ui/react-collapsible": "^1.1.3", 27 | "@radix-ui/react-dialog": "^1.1.6", 28 | "@radix-ui/react-dropdown-menu": "^2.1.6", 29 | "@radix-ui/react-label": "^2.1.1", 30 | "@radix-ui/react-progress": "^1.1.2", 31 | "@radix-ui/react-scroll-area": "^1.2.3", 32 | "@radix-ui/react-select": "^2.1.6", 33 | "@radix-ui/react-separator": "^1.1.2", 34 | "@radix-ui/react-slot": "^1.1.2", 35 | "@radix-ui/react-switch": "^1.2.4", 36 | "@radix-ui/react-tabs": "^1.1.3", 37 | "@radix-ui/react-tooltip": "^1.1.8", 38 | "@tanstack/react-query": "^5.66.0", 39 | "axios": "^1.8.4", 40 | "class-variance-authority": "^0.7.1", 41 | "clsx": "^2.1.1", 42 | "dotenv": "^16.4.7", 43 | "framer-motion": "^12.4.2", 44 | "geist": "^1.3.1", 45 | "input-otp": "^1.4.2", 46 | "js-cookie": "^3.0.5", 47 | "lucide-react": "^0.483.0", 48 | "next": "15.2.4", 49 | "next-themes": "^0.4.4", 50 | "react": "19.1.0", 51 | "react-circular-progressbar": "^2.2.0", 52 | "react-dom": "19.1.0", 53 | "react-hook-form": "^7.54.2", 54 | "react-icons": "^5.4.0", 55 | "react-turnstile": "^1.1.4", 56 | "recharts": "^2.15.1", 57 | "sonner": "^1.7.4", 58 | "tailwind-merge": "^3.0.2", 59 | "tailwindcss-animate": "^1.0.7", 60 | "zod": "^3.24.1" 61 | }, 62 | "devDependencies": { 63 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 64 | "@types/js-cookie": "^3.0.6", 65 | "@types/node": "^20", 66 | "@types/react": "19.0.12", 67 | "@types/react-dom": "19.0.4", 68 | "orval": "7.4.1", 69 | "postcss": "^8", 70 | "prettier": "^3.4.2", 71 | "prettier-plugin-tailwindcss": "^0.6.11", 72 | "tailwindcss": "^3.4.1", 73 | "typescript": "^5" 74 | }, 75 | "overrides": { 76 | "@types/react": "19.0.12", 77 | "@types/react-dom": "19.0.4" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teacoder-team/frontend/a2f53148c33e40135bbc59ad9121039329e5bc5c/public/favicon.ico -------------------------------------------------------------------------------- /public/opengraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teacoder-team/frontend/a2f53148c33e40135bbc59ad9121039329e5bc5c/public/opengraph.png -------------------------------------------------------------------------------- /public/touch-icons/192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teacoder-team/frontend/a2f53148c33e40135bbc59ad9121039329e5bc5c/public/touch-icons/192x192.png -------------------------------------------------------------------------------- /public/touch-icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teacoder-team/frontend/a2f53148c33e40135bbc59ad9121039329e5bc5c/public/touch-icons/512x512.png -------------------------------------------------------------------------------- /src/api/account.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AccountResponse, 3 | ChangeEmailRequest, 4 | ChangePasswordRequest, 5 | CreateUserRequest, 6 | CreateUserResponse, 7 | PasswordResetRequest, 8 | SendPasswordResetRequest 9 | } from '../generated' 10 | import { setSessionToken } from '../lib/cookies/session' 11 | 12 | import { api, instance } from './instance' 13 | 14 | export const getMe = async () => 15 | await instance 16 | .get('/auth/account') 17 | .then(response => response.data) 18 | 19 | export const createAccount = async (data: CreateUserRequest) => { 20 | const response = await api.post( 21 | '/auth/account/create', 22 | data 23 | ) 24 | 25 | if (response.data.token) { 26 | setSessionToken(response.data.token) 27 | 28 | instance.defaults.headers['X-Session-Token'] = response.data.token 29 | } 30 | 31 | return response.data 32 | } 33 | 34 | export const sendEmailVerification = () => instance.post('/auth/account/verify') 35 | 36 | export const verifyEmail = (code: string) => 37 | instance.post(`/auth/account/verify/${code}`) 38 | 39 | export const sendPasswordReset = (data: SendPasswordResetRequest) => 40 | api.post('/auth/account/reset_password', data) 41 | 42 | export const passwordReset = (data: PasswordResetRequest) => 43 | api.patch('/auth/account/reset_password', data) 44 | 45 | export const changeEmail = (data: ChangeEmailRequest) => 46 | instance.patch('/auth/account/change/email', data) 47 | 48 | export const changePassword = (data: ChangePasswordRequest) => 49 | instance.patch('/auth/account/change/password', data) 50 | -------------------------------------------------------------------------------- /src/api/course.ts: -------------------------------------------------------------------------------- 1 | import type { CourseResponse, LessonResponse } from '../generated' 2 | 3 | import { api, instance } from './instance' 4 | 5 | export const getCourses = async () => 6 | await api.get('/courses').then(response => response.data) 7 | 8 | export const getPopularCourses = async () => 9 | await api 10 | .get('/courses/popular') 11 | .then(response => response.data) 12 | 13 | export const getCourse = async (slug: string) => 14 | await api 15 | .get(`/courses/${slug}`) 16 | .then(response => response.data) 17 | 18 | export const getCourseLessons = async (id: string) => 19 | await instance 20 | .get(`/courses/${id}/lessons`) 21 | .then(response => response.data) 22 | 23 | export const incrementCourseViews = (id: string) => 24 | api.patch(`/courses/${id}/views`) 25 | -------------------------------------------------------------------------------- /src/api/external.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ExternalConnectResponse, 3 | ExternalStatusResponse 4 | } from '../generated' 5 | 6 | import { instance } from './instance' 7 | 8 | export const getAuthUrl = async (provider: 'google' | 'github') => 9 | await instance 10 | .post(`/auth/external/login/${provider}`) 11 | .then(response => response.data) 12 | 13 | export const fetchExternalStatus = async () => 14 | await instance 15 | .get('/auth/external') 16 | .then(response => response.data) 17 | 18 | export const getConnectUrl = async (provider: 'google' | 'github') => 19 | await instance 20 | .post(`/auth/external/connect/${provider}`) 21 | .then(response => response.data) 22 | 23 | export const unlinkAccount = async (provider: 'google' | 'github') => 24 | await instance.delete(`/auth/external/${provider}`) 25 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './account' 2 | export * from './course' 3 | export * from './instance' 4 | export * from './lesson' 5 | export * from './mfa' 6 | export * from './progress' 7 | export * from './session' 8 | export * from './users' 9 | -------------------------------------------------------------------------------- /src/api/instance.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import { APP_CONFIG } from '../constants/app' 4 | import { getSessionToken } from '../lib/cookies/session' 5 | 6 | export const api = axios.create({ 7 | baseURL: APP_CONFIG.apiUrl 8 | }) 9 | 10 | export const instance = axios.create({ 11 | baseURL: APP_CONFIG.apiUrl, 12 | headers: { 13 | 'X-Session-Token': getSessionToken() ?? '' 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /src/api/lesson.ts: -------------------------------------------------------------------------------- 1 | import type { CreateLessonRequest, LessonResponse } from '../generated' 2 | 3 | import { api, instance } from './instance' 4 | 5 | export const getLesson = async (slug: string) => 6 | await api 7 | .get(`/lessons/${slug}`) 8 | .then(response => response.data) 9 | 10 | export const getCompletedLessons = async (courseId: string) => 11 | await instance 12 | .get(`/lessons/${courseId}/progress`) 13 | .then(response => response.data) 14 | 15 | export const createLesson = (data: CreateLessonRequest) => 16 | instance.post('/lessons', data) 17 | -------------------------------------------------------------------------------- /src/api/mfa.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | LoginSessionResponse, 3 | MfaStatusResponse, 4 | MfaVerifyRequest, 5 | TotpDisableRequest, 6 | TotpEnableRequest, 7 | TotpGenerateSecretResponse 8 | } from '../generated' 9 | 10 | import { api, instance } from './instance' 11 | 12 | export const fetchMfaStatus = async () => 13 | await instance 14 | .get('/auth/mfa') 15 | .then(response => response.data) 16 | 17 | export const fetchRecovery = async () => 18 | await instance 19 | .get('/auth/mfa/recovery') 20 | .then(response => response.data) 21 | 22 | export const regenerateRecovery = () => 23 | instance.patch('/auth/mfa/recovery') 24 | 25 | export const totpEnable = (data: TotpEnableRequest) => 26 | instance.put('/auth/mfa/totp', data) 27 | 28 | export const totpGenerateSecret = async () => 29 | await instance 30 | .post('/auth/mfa/totp') 31 | .then(response => response.data) 32 | 33 | export const totpDisable = (data: TotpDisableRequest) => 34 | instance.delete('/auth/mfa/totp', { data }) 35 | 36 | export const verifyMfa = (data: MfaVerifyRequest) => 37 | api.post('/auth/mfa/verify', data) 38 | -------------------------------------------------------------------------------- /src/api/progress.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CreateProgressRequest, 3 | CreateProgressResponse 4 | } from '../generated' 5 | 6 | import { instance } from './instance' 7 | 8 | export const createProgress = async (data: CreateProgressRequest) => 9 | await instance 10 | .put('/progress', data) 11 | .then(response => response.data) 12 | 13 | export const getCourseProgress = async (courseId: string) => 14 | await instance 15 | .get(`/progress/${courseId}`) 16 | .then(response => response.data) 17 | -------------------------------------------------------------------------------- /src/api/restriction.ts: -------------------------------------------------------------------------------- 1 | import type { ActiveRestrictionResponse } from '../generated' 2 | 3 | import { instance } from './instance' 4 | 5 | export const getActiveRestriction = async () => 6 | await instance 7 | .get('/restriction') 8 | .then(response => response.data) 9 | -------------------------------------------------------------------------------- /src/api/session.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | LoginMfaResponse, 3 | LoginRequest, 4 | LoginSessionResponse, 5 | SessionResponse 6 | } from '../generated' 7 | import { removeSessionToken } from '../lib/cookies/session' 8 | 9 | import { api, instance } from './instance' 10 | 11 | export const login = async (data: LoginRequest) => 12 | await api 13 | .post< 14 | LoginSessionResponse | LoginMfaResponse 15 | >('/auth/session/login', data) 16 | .then(response => response.data) 17 | 18 | export const logout = async () => 19 | await instance 20 | .post('/auth/session/logout') 21 | .then(() => removeSessionToken()) 22 | 23 | export const getSessions = async () => 24 | await instance 25 | .get('/auth/session/all') 26 | .then(response => response.data) 27 | 28 | export const revokeSession = (id: string) => 29 | instance.delete(`/auth/session/${id}`) 30 | 31 | export const removeAllSessions = () => instance.delete('/auth/session/all') 32 | -------------------------------------------------------------------------------- /src/api/users.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | LeaderResponse, 3 | MeProgressResponse, 4 | MeStatisticsResponse, 5 | PatchUserRequest 6 | } from '../generated' 7 | 8 | import { api, instance } from './instance' 9 | 10 | export const getLeaders = async () => 11 | await api 12 | .get('/users/leaders') 13 | .then(response => response.data) 14 | 15 | export const getMeStatistics = async () => 16 | await instance 17 | .get('/users/@me/statistics') 18 | .then(response => response.data) 19 | 20 | export const getMeProgress = async () => 21 | await instance 22 | .get('/users/@me/progress') 23 | .then(response => response.data) 24 | 25 | export const changeAvatar = async (formData: FormData) => 26 | await instance 27 | .patch('/users/@me/avatar', formData, { 28 | headers: { 29 | 'Content-Type': 'multipart/form-data' 30 | } 31 | }) 32 | .then(response => response.data) 33 | 34 | export const patchUser = (data: PatchUserRequest) => 35 | instance.patch('/users/@me', data) 36 | -------------------------------------------------------------------------------- /src/app/(public)/about/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | export const metadata: Metadata = { 4 | title: 'Об основателе' 5 | } 6 | 7 | export default function AboutPage() { 8 | return ( 9 |
10 |
11 |
12 |

13 | Об основателе 14 |

15 |
16 |

17 | Меня зовут Вадим, и я — веб-разработчик. В свои 14 18 | лет я уже успел создать свою образовательную 19 | платформу{' '} 20 | TeaCoder и 21 | запустить YouTube-канал, где публикую видеоуроки по 22 | веб-разработке. 23 |

24 |

25 | Моя история началась в 11 лет, когда мой папа 26 | записал меня на офлайн-курсы по созданию сайтов. 27 | Честно говоря, с первого урока мне не очень 28 | понравилось, и я не был уверен, что это то, чем я 29 | хочу заниматься. Однако я решил продолжить, и 30 | продолжил ходить на эти курсы. Мы начали с простого 31 | конструктора сайтов Tilda, а затем я освоил основы 32 | HTML и CSS. Этот путь длился целый год. 33 |

34 |

35 | В 12 лет я продолжил обучаться на курсах по Python. 36 | Несмотря на то, что это был мой первый язык 37 | программирования и было довольно тяжело, я понял, 38 | насколько увлекательным может быть процесс создания 39 | программ. После этого я начал изучать JavaScript и 40 | PHP. В то время я был еще новичком, но это не 41 | остановило меня — я продолжил изучать и углублять 42 | свои знания самостоятельно, создавая проекты и решая 43 | задачи. 44 |

45 |

46 | Вскоре мне стало интересно работать с фреймворками, 47 | и я освоил React JS. Это позволило мне расширить 48 | горизонты и начать создавать более сложные и 49 | динамичные приложения. 50 |

51 |

52 | Сегодня я рад поделиться своими знаниями и опытом с 53 | другими. Моя платформа{' '} 54 | TeaCoder{' '} 55 | помогает начинающим веб-разработчикам погрузиться в 56 | мир программирования и создать свои первые проекты. 57 |

58 |

59 | Каждый день я стремлюсь учиться и открывать для себя 60 | что-то новое в мире технологий, и мне очень нравится 61 | то, чем я занимаюсь. Надеюсь, мой опыт вдохновит вас 62 | на создание собственных проектов и карьеру в 63 | веб-разработке! 64 |

65 |
66 |
67 |
68 |
69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/app/(public)/courses/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { notFound } from 'next/navigation' 3 | 4 | import { getCourse, getCourseLessons, getCourses } from '@/src/api' 5 | import { CourseDetails } from '@/src/components/course/course-details' 6 | import { CourseProvider } from '@/src/components/providers/course-provider' 7 | import { getMediaSource } from '@/src/lib/utils' 8 | 9 | export async function generateStaticParams() { 10 | const courses = await getCourses() 11 | 12 | const paths = courses.map(course => { 13 | return { 14 | params: { slug: course.slug } 15 | } 16 | }) 17 | 18 | return paths 19 | } 20 | 21 | export async function generateMetadata({ 22 | params 23 | }: { 24 | params: Promise<{ slug: string }> 25 | }): Promise { 26 | const { slug } = await params 27 | 28 | const course = await getCourse(slug).catch(error => { 29 | notFound() 30 | }) 31 | 32 | return { 33 | title: course.title, 34 | description: course.description, 35 | openGraph: { 36 | images: [ 37 | { 38 | url: getMediaSource(course.thumbnail ?? '', 'courses'), 39 | alt: course.title 40 | } 41 | ] 42 | }, 43 | twitter: { 44 | title: course.title, 45 | description: course.description ?? '', 46 | images: [ 47 | { 48 | url: getMediaSource(course.thumbnail ?? '', 'courses'), 49 | alt: course.title 50 | } 51 | ] 52 | } 53 | } 54 | } 55 | 56 | export default async function CoursePage({ 57 | params 58 | }: { 59 | params: Promise<{ slug: string }> 60 | }) { 61 | const { slug } = await params 62 | 63 | const course = await getCourse(slug).catch(error => { 64 | notFound() 65 | }) 66 | 67 | const lessons = await getCourseLessons(course.id) 68 | 69 | return ( 70 | 71 | 72 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/app/(public)/courses/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { getCourses } from '@/src/api' 4 | import { CourseCard } from '@/src/components/course/course-card' 5 | 6 | export const metadata: Metadata = { 7 | title: 'Курсы', 8 | description: 9 | 'Здесь собраны курсы по веб-разработке, которые помогут вам освоить самые востребованные технологии и инструменты.' 10 | } 11 | 12 | export default async function CoursesPage() { 13 | const courses = await getCourses() 14 | 15 | return ( 16 |
17 |
18 |

19 | Курсы 20 |

21 |

22 | Здесь собраны курсы по веб-разработке, которые помогут вам 23 | освоить самые востребованные технологии и инструменты. 24 |

25 |
26 |
27 | {courses.map((course, index) => ( 28 | 29 | ))} 30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/app/(public)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from 'react' 2 | 3 | import { Footer } from '@/src/components/layout/footer' 4 | import { Header } from '@/src/components/layout/header' 5 | 6 | export default function PublicLayout({ children }: { children: ReactNode }) { 7 | return ( 8 |
9 | {/*
10 |
11 |
12 |
13 |
*/} 14 | 15 |
16 |
17 | {children} 18 |
19 |
20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/app/(public)/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Fragment } from 'react' 3 | 4 | import { getPopularCourses } from '@/src/api' 5 | import { Features } from '@/src/components/home/features' 6 | import { Hero } from '@/src/components/home/hero' 7 | import { Popular } from '@/src/components/home/popular' 8 | import { TelegramCTA } from '@/src/components/home/telegram-cta' 9 | 10 | export const metadata: Metadata = { 11 | title: 'Образовательная платформа по веб разработке' 12 | } 13 | 14 | export default async function HomePage() { 15 | const courses = await getPopularCourses() 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/app/account/connections/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { Connections } from '@/src/components/account/connections/connections' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Сторонние сервисы' 7 | } 8 | 9 | export default function ConnectionsPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/account/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | 3 | import { Header } from '@/src/components/layout/header' 4 | import { UserNavigation } from '@/src/components/layout/user-navigation' 5 | import { AccountProvider } from '@/src/components/providers/account-provider' 6 | 7 | export default function AccountLayout({ children }: { children: ReactNode }) { 8 | return ( 9 | 10 |
11 |
12 |
13 |
14 |
15 | 16 |
17 | {children} 18 |
19 |
20 |
21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/account/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { Progress } from '@/src/components/account/progress/progress' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Мой прогресс' 7 | } 8 | 9 | export default function ProgressPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/account/sessions/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { Sessions } from '@/src/components/account/sessions/sessions' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Уcтройства' 7 | } 8 | 9 | export default function SessionsPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/account/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { Settings } from '@/src/components/account/settings/settings' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Настройки аккаунта' 7 | } 8 | 9 | export default function SettingsPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/auth/callback/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useRouter, useSearchParams } from 'next/navigation' 4 | import { useEffect } from 'react' 5 | 6 | import { instance } from '@/src/api' 7 | import { EllipsisLoader } from '@/src/components/shared/ellipsis-loader' 8 | import { setSessionToken } from '@/src/lib/cookies/session' 9 | 10 | export default function AuthCallbackPage() { 11 | const router = useRouter() 12 | 13 | useEffect(() => { 14 | const hash = window.location.hash 15 | const token = new URLSearchParams(hash.slice(1)).get('token') 16 | 17 | if (token) { 18 | setSessionToken(token) 19 | instance.defaults.headers['X-Session-Token'] = token 20 | router.push('/account/settings') 21 | } 22 | }, [router]) 23 | 24 | return ( 25 |
26 | 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { LoginForm } from '@/src/components/auth/login-form' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Войти в аккаунт' 7 | } 8 | 9 | export default function LoginPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/auth/recovery/[token]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { NewPasswordForm } from '@/src/components/auth/new-password-form' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Новый пароль' 7 | } 8 | 9 | export default async function NewPasswordPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/auth/recovery/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { ResetPasswordForm } from '@/src/components/auth/reset-password-form' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Сброс пароля' 7 | } 8 | 9 | export default function ResetPasswordPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/auth/register/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { RegisterForm } from '@/src/components/auth/register-form' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Создать аккаунт' 7 | } 8 | 9 | export default function RegisterPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/auth/verify/[token]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { VerifyEmail } from '@/src/components/auth/verify-email' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Верификация почты' 7 | } 8 | 9 | export default async function VerifyEmailPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { GoogleAnalytics } from '@next/third-parties/google' 2 | import { GeistSans } from 'geist/font/sans' 3 | import type { Metadata } from 'next' 4 | import type { ReactNode } from 'react' 5 | 6 | import { YandexMetrika } from '../components/analitycs/yandex-metrika' 7 | import { BanChecker } from '../components/providers/ban-checker' 8 | import { TanstackQueryProvider } from '../components/providers/tanstack-query-provider' 9 | import { ThemeProvider } from '../components/providers/theme-provider' 10 | import { Toaster } from '../components/shared/sonner' 11 | import { APP_CONFIG, SEO } from '../constants' 12 | 13 | import '@/src/styles/globals.css' 14 | 15 | export const metadata: Metadata = { 16 | title: { 17 | absolute: SEO.name, 18 | template: `%s - ${SEO.name}` 19 | }, 20 | description: SEO.description, 21 | metadataBase: new URL(APP_CONFIG.baseUrl), 22 | applicationName: SEO.name, 23 | keywords: SEO.keywords, 24 | icons: { 25 | icon: '/favicon.ico', 26 | shortcut: '/favicon.ico', 27 | apple: '/touch-icons/192x192.png', 28 | other: { 29 | rel: 'touch-icons', 30 | url: '/touch-icons/192x192.png', 31 | sizes: '192x192', 32 | type: 'image/png' 33 | } 34 | }, 35 | manifest: '/manifest.webmanifest', 36 | openGraph: { 37 | title: SEO.name, 38 | description: SEO.description, 39 | type: 'website', 40 | emails: ['support@teacoder.ru'], 41 | siteName: SEO.name, 42 | locale: 'ru_RU', 43 | images: [ 44 | { 45 | url: new URL(APP_CONFIG.baseUrl + '/opengraph.png'), 46 | width: 512, 47 | height: 512, 48 | alt: SEO.name 49 | } 50 | ], 51 | url: APP_CONFIG.baseUrl 52 | }, 53 | twitter: { 54 | card: 'summary_large_image', 55 | title: SEO.name, 56 | description: SEO.description, 57 | images: [ 58 | { 59 | url: new URL(APP_CONFIG.baseUrl + '/opengraph.png'), 60 | width: 512, 61 | height: 512, 62 | alt: SEO.name 63 | } 64 | ] 65 | }, 66 | formatDetection: SEO.formatDetection 67 | } 68 | 69 | export default function RootLayout({ children }: { children: ReactNode }) { 70 | return ( 71 | 72 | 73 | 74 | 80 | {children} 81 | 93 | 94 | 95 | {process.env['NODE_ENV'] === 'production' && ( 96 | <> 97 | 100 | 105 | 106 | )} 107 | 108 | 109 | 110 | 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /src/app/lesson/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { cookies } from 'next/headers' 3 | import { notFound } from 'next/navigation' 4 | 5 | import { api, getCourseLessons, getLesson } from '@/src/api' 6 | import { LessonCompleteButton } from '@/src/components/lesson/lesson-complete-button' 7 | import { LessonContainer } from '@/src/components/lesson/lesson-container' 8 | import { LessonPlayer } from '@/src/components/lesson/lesson-player' 9 | import { LessonSidebar } from '@/src/components/lesson/lesson-sidebar' 10 | 11 | export const revalidate = 60 12 | 13 | async function getUserProgress(courseId: string) { 14 | const cookie = await cookies() 15 | 16 | const token = cookie.get('token')?.value 17 | 18 | const { data: progressCount } = await api.get( 19 | `/progress/${courseId}`, 20 | { 21 | headers: { 22 | 'X-Session-Token': token ?? '' 23 | } 24 | } 25 | ) 26 | 27 | const { data: completedLessons } = await api.get( 28 | `/lessons/${courseId}/progress`, 29 | { 30 | headers: { 31 | 'X-Session-Token': token ?? '' 32 | } 33 | } 34 | ) 35 | 36 | return { progressCount, completedLessons } 37 | } 38 | 39 | export async function generateMetadata({ 40 | params 41 | }: { 42 | params: Promise<{ slug: string }> 43 | }): Promise { 44 | const { slug } = await params 45 | 46 | const lesson = await getLesson(slug).catch(error => { 47 | notFound() 48 | }) 49 | 50 | return { 51 | title: lesson.title, 52 | description: lesson.description 53 | } 54 | } 55 | 56 | export default async function LessonPage({ 57 | params 58 | }: { 59 | params: Promise<{ slug: string }> 60 | }) { 61 | const { slug } = await params 62 | 63 | const lesson = await getLesson(slug).catch(error => { 64 | notFound() 65 | }) 66 | 67 | const lessons = await getCourseLessons(lesson.course.id) 68 | 69 | const { progressCount, completedLessons } = await getUserProgress( 70 | lesson.course.id 71 | ) 72 | 73 | return ( 74 |
75 | 81 | 82 |

{lesson.title}

83 | 84 | {lesson.description && ( 85 |

86 | {lesson.description} 87 |

88 | )} 89 | 90 |
91 | 92 | 93 |
94 | 98 |
99 |
100 |
101 |
102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { EllipsisLoader } from '../components/shared/ellipsis-loader' 2 | 3 | export default function LoadingPage() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/app/manifest.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next' 2 | 3 | import { SEO } from '../constants' 4 | 5 | export default function manifest(): MetadataRoute.Manifest { 6 | return { 7 | name: SEO.name, 8 | short_name: SEO.name, 9 | categories: SEO.keywords, 10 | lang: 'ru_RU', 11 | description: SEO.description, 12 | start_url: '/', 13 | display: 'standalone', 14 | background_color: '#FFFFFF', 15 | theme_color: '#2563EB', 16 | orientation: 'portrait', 17 | icons: [ 18 | { 19 | src: '/touch-icons/192x192.png', 20 | sizes: '192x192', 21 | type: 'image/png' 22 | }, 23 | { 24 | src: '/touch-icons/512x512.png', 25 | sizes: '512x512', 26 | type: 'image/png' 27 | } 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeft } from 'lucide-react' 2 | import type { Metadata } from 'next' 3 | import Link from 'next/link' 4 | 5 | import { Button } from '../components/ui/button' 6 | import { ROUTES } from '../constants/routes' 7 | 8 | export const metadata: Metadata = { 9 | title: 'Страница не найдена' 10 | } 11 | 12 | export default function NotFoundPage() { 13 | return ( 14 |
15 |
16 |

404

17 |

18 | Кажется, мы потеряли эту страницу. 19 |

20 |
21 | 27 |
28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next' 2 | 3 | import { APP_CONFIG } from '../constants' 4 | 5 | export default function robots(): MetadataRoute.Robots { 6 | return { 7 | rules: { 8 | userAgent: '*', 9 | allow: '/', 10 | disallow: [ 11 | '/*?', 12 | '/*.html', 13 | '/auth/recovery/*', 14 | '/account/*', 15 | '/lesson/*', 16 | '*?*=*', 17 | '*?*=*&*=*', 18 | '*?*=*=*' 19 | ] 20 | }, 21 | host: APP_CONFIG.baseUrl, 22 | sitemap: `${APP_CONFIG.baseUrl}/sitemap.xml` 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next' 2 | 3 | import { getCourses } from '../api' 4 | import { APP_CONFIG } from '../constants' 5 | 6 | export default async function sitemap(): Promise { 7 | const courses: MetadataRoute.Sitemap = (await getCourses()).map(course => ({ 8 | url: `${APP_CONFIG.baseUrl}/${course.slug}`, 9 | lastModified: course.updatedAt, 10 | changeFrequency: 'monthly', 11 | priority: 0.9 12 | })) 13 | 14 | return [ 15 | { 16 | url: APP_CONFIG.baseUrl, 17 | lastModified: new Date(), 18 | changeFrequency: 'yearly', 19 | priority: 1 20 | }, 21 | ...courses 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/components/account/connections/connection-error.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { AlertCircle, AlertTriangle } from 'lucide-react' 4 | import { useSearchParams } from 'next/navigation' 5 | import { useEffect, useState } from 'react' 6 | 7 | import { Button } from '../../ui/button' 8 | import { 9 | Card, 10 | CardContent, 11 | CardDescription, 12 | CardFooter, 13 | CardHeader, 14 | CardTitle 15 | } from '../../ui/card' 16 | 17 | export function ConnectionError() { 18 | const searchParams = useSearchParams() 19 | const [isVisible, setIsVisible] = useState(false) 20 | const [errorInfo, setErrorInfo] = useState<{ 21 | title: string 22 | description: string 23 | details: string 24 | } | null>(null) 25 | 26 | useEffect(() => { 27 | const error = searchParams.get('error') 28 | 29 | if (error === 'already-linked') { 30 | setErrorInfo({ 31 | title: 'Аккаунт уже привязан', 32 | description: 33 | 'Этот аккаунт уже привязан к другому пользователю.', 34 | details: 35 | 'Пожалуйста, используйте другой аккаунт или свяжитесь с поддержкой по адресу support@teacoder.ru, чтобы решить эту проблему.' 36 | }) 37 | setIsVisible(true) 38 | } else if (error === 'email-taken') { 39 | setErrorInfo({ 40 | title: 'Почта уже используется', 41 | description: 42 | 'Указанная почта уже используется другим аккаунтом.', 43 | details: 44 | 'Попробуйте использовать другой адрес электронной почты или восстановить доступ к старому аккаунту.' 45 | }) 46 | setIsVisible(true) 47 | } 48 | }, [searchParams]) 49 | 50 | if (!isVisible || !errorInfo) return null 51 | 52 | return ( 53 |
54 | 55 | 56 |
57 | 58 | {errorInfo.title} 59 |
60 | {errorInfo.description} 61 |
62 | 63 |
64 |

{errorInfo.details}

65 |
66 |
67 | 68 | 75 | 76 |
77 |
78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /src/components/account/connections/connections.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useMutation, useQuery } from '@tanstack/react-query' 4 | import { useRouter, useSearchParams } from 'next/navigation' 5 | import { type ReactNode, useEffect } from 'react' 6 | import { FaGithub, FaGoogle } from 'react-icons/fa6' 7 | import { toast } from 'sonner' 8 | 9 | import { Heading } from '../../shared/heading' 10 | import { Button } from '../../ui/button' 11 | import { Card, CardContent } from '../../ui/card' 12 | 13 | import { ConnectionError } from './connection-error' 14 | import { UnlinkProvider } from './unlink-provider' 15 | import { fetchExternalStatus, getConnectUrl } from '@/src/api/external' 16 | 17 | interface Provider { 18 | name: string 19 | icon: ReactNode 20 | key: 'google' | 'github' 21 | description: string 22 | } 23 | 24 | export const providers: Provider[] = [ 25 | { 26 | name: 'Google', 27 | icon: , 28 | key: 'google', 29 | description: 30 | 'Настройте вход через Google для удобной и быстрой авторизации' 31 | }, 32 | { 33 | name: 'Github', 34 | icon: , 35 | key: 'github', 36 | description: 37 | 'Настройте вход через Github для удобной и быстрой авторизации' 38 | } 39 | ] 40 | 41 | export function Connections() { 42 | const router = useRouter() 43 | 44 | const { data, isLoading } = useQuery({ 45 | queryKey: ['fetch external status'], 46 | queryFn: () => fetchExternalStatus() 47 | }) 48 | 49 | const { mutate, isPending } = useMutation({ 50 | mutationKey: ['connect external account'], 51 | mutationFn: (provider: 'google' | 'github') => getConnectUrl(provider), 52 | onSuccess(data) { 53 | router.push(data.url) 54 | }, 55 | onError(error: any) { 56 | toast.error( 57 | error.response?.data?.message ?? 'Ошибка при подключении' 58 | ) 59 | } 60 | }) 61 | 62 | if (isLoading) return
Loading status...
63 | 64 | return ( 65 | <> 66 |
67 |
68 | 72 |
73 | {providers.map((provider, index) => ( 74 | 75 | 76 |
77 |
78 | {provider.icon} 79 |
80 |
81 |

82 | {provider.name} 83 |

84 |

85 | {provider.description} 86 |

87 |
88 |
89 | {data?.[provider.key] ? ( 90 | 93 | ) : ( 94 | 101 | )} 102 |
103 |
104 | ))} 105 |
106 |
107 |
108 | 109 | 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /src/components/account/connections/unlink-provider.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query' 2 | import { useState } from 'react' 3 | import { toast } from 'sonner' 4 | 5 | import { ConfirmDialog } from '../../shared/confirm-dialog' 6 | import { Button } from '../../ui/button' 7 | 8 | import { unlinkAccount } from '@/src/api/external' 9 | 10 | interface UnlinkProviderProps { 11 | provider: 'google' | 'github' 12 | } 13 | 14 | export function UnlinkProvider({ provider }: UnlinkProviderProps) { 15 | const [isOpen, setIsOpen] = useState(false) 16 | 17 | const queryClient = useQueryClient() 18 | 19 | const { mutate, isPending } = useMutation({ 20 | mutationKey: ['unlink account'], 21 | mutationFn: () => unlinkAccount(provider), 22 | onSuccess() { 23 | queryClient.invalidateQueries({ 24 | queryKey: ['fetch external status'] 25 | }) 26 | setIsOpen(false) 27 | }, 28 | onError(error: any) { 29 | toast.error( 30 | error.response?.data?.message ?? 'Ошибка при отключении' 31 | ) 32 | } 33 | }) 34 | 35 | return ( 36 | mutate()} 42 | isLoading={isPending} 43 | open={isOpen} 44 | onOpenChange={setIsOpen} 45 | > 46 | 47 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/components/account/progress/courses-list.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { BookOpen, ChevronRight } from 'lucide-react' 3 | 4 | import { CourseProgress } from '../../shared/course-progress' 5 | 6 | import { getMeProgress } from '@/src/api' 7 | import { Button } from '@/src/components/ui/button' 8 | import { 9 | Card, 10 | CardContent, 11 | CardDescription, 12 | CardFooter, 13 | CardHeader, 14 | CardTitle 15 | } from '@/src/components/ui/card' 16 | 17 | interface CoursesListProps { 18 | onViewAll: () => void 19 | } 20 | 21 | export function CoursesList({ onViewAll }: CoursesListProps) { 22 | const { data, isLoading } = useQuery({ 23 | queryKey: ['get me progress'], 24 | queryFn: () => getMeProgress() 25 | }) 26 | 27 | return ( 28 | 29 | 30 | 31 | Все курсы 32 | 33 | Ваш прогресс по курсам 34 | 35 | 36 |
37 | {data?.map(course => ( 38 |
39 |
40 |
41 | {course.title} 42 |
43 |
44 | {course.completedLessons}/ 45 | {course.totalLessons} уроков 46 |
47 |
48 | 53 |
54 | ))} 55 |
56 |
57 | 58 | 59 | 67 | 68 |
69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/components/account/progress/courses-tab.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import Link from 'next/link' 3 | 4 | import { CourseProgress } from '../../shared/course-progress' 5 | 6 | import { getMeProgress } from '@/src/api' 7 | import { Button } from '@/src/components/ui/button' 8 | import { 9 | Card, 10 | CardContent, 11 | CardDescription, 12 | CardHeader, 13 | CardTitle 14 | } from '@/src/components/ui/card' 15 | import { Separator } from '@/src/components/ui/separator' 16 | import { ROUTES } from '@/src/constants' 17 | 18 | export function CoursesTab() { 19 | const { data, isLoading } = useQuery({ 20 | queryKey: ['get me progress'], 21 | queryFn: () => getMeProgress() 22 | }) 23 | 24 | return ( 25 | 26 | 27 | Курсы 28 | Ваш прогресс по всем курсам 29 | 30 | 31 |
32 | {data?.map(course => ( 33 |
34 |
35 |
36 | {course.title} 37 |
38 |
39 | {course.completedLessons}/ 40 | {course.totalLessons} уроков 41 |
42 |
43 |
44 | 53 | 54 | {course.progress}% 55 | 56 |
57 |
58 | 59 | Последний доступ:{' '} 60 | {new Date( 61 | course.lastAccessed 62 | ).toLocaleDateString()} 63 | 64 | {course.lastLesson && ( 65 | 79 | )} 80 |
81 | {course.id !== data[data.length - 1].id && ( 82 | 83 | )} 84 |
85 | ))} 86 |
87 |
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/components/account/progress/leaderboard.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { ChevronRight, Trophy, Users } from 'lucide-react' 3 | 4 | import { Avatar, AvatarFallback, AvatarImage } from '../../ui/avatar' 5 | 6 | import { getLeaders } from '@/src/api' 7 | import { Button } from '@/src/components/ui/button' 8 | import { 9 | Card, 10 | CardContent, 11 | CardDescription, 12 | CardFooter, 13 | CardHeader, 14 | CardTitle 15 | } from '@/src/components/ui/card' 16 | import { getMediaSource } from '@/src/lib/utils' 17 | 18 | interface LeaderboardProps { 19 | limit?: number 20 | showButton?: boolean 21 | onViewAll?: () => void 22 | } 23 | 24 | export function Leaderboard({ 25 | limit, 26 | showButton, 27 | onViewAll 28 | }: LeaderboardProps) { 29 | const { data, isLoading } = useQuery({ 30 | queryKey: ['get leaders'], 31 | queryFn: () => getLeaders() 32 | }) 33 | 34 | const users = limit ? data?.slice(0, limit) : data 35 | 36 | return ( 37 | 38 | 39 | 40 | Рейтинг пользователей 41 | 42 | 43 | Пользователи с наибольшим количеством очков 44 | 45 | 46 | 47 |
48 | {users?.map((user, index) => { 49 | const position = index + 1 50 | 51 | return ( 52 |
56 |
57 |
58 | {position === 1 && ( 59 | 60 | )} 61 | {position === 2 && ( 62 | 63 | )} 64 | {position === 3 && ( 65 | 66 | )} 67 | {position > 3 && position} 68 |
69 |
70 | 71 | 78 | 79 | {user?.displayName.slice(0, 1)} 80 | 81 | 82 |

83 | {user.displayName} 84 |

85 |
86 |
87 |
88 | {user.points} очков 89 |
90 |
91 | ) 92 | })} 93 |
94 |
95 | {showButton && ( 96 | 97 | 105 | 106 | )} 107 |
108 | ) 109 | } 110 | -------------------------------------------------------------------------------- /src/components/account/progress/progress.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | 5 | import { Heading } from '../../shared/heading' 6 | 7 | import { CoursesList } from './courses-list' 8 | import { CoursesTab } from './courses-tab' 9 | import { Leaderboard } from './leaderboard' 10 | import { UserStats } from './user-stats' 11 | import { 12 | Tabs, 13 | TabsContent, 14 | TabsList, 15 | TabsTrigger 16 | } from '@/src/components/ui/tabs' 17 | 18 | export function Progress() { 19 | const [activeTab, setActiveTab] = useState('overview') 20 | 21 | return ( 22 |
23 |
24 | 28 | 29 | 35 | 36 | Обзор 37 | Курсы 38 | Рейтинг 39 | 40 | 41 | 42 | 43 | setActiveTab('courses')} 45 | /> 46 | setActiveTab('leaderboard')} 50 | /> 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/components/account/progress/user-stats.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { BookOpen, Crown, Medal, Trophy } from 'lucide-react' 3 | import React from 'react' 4 | import { CircularProgressbar } from 'react-circular-progressbar' 5 | import 'react-circular-progressbar/dist/styles.css' 6 | 7 | import { getMeStatistics } from '@/src/api' 8 | import { 9 | Card, 10 | CardContent, 11 | CardHeader, 12 | CardTitle 13 | } from '@/src/components/ui/card' 14 | 15 | export function UserStats() { 16 | const { data, isLoading } = useQuery({ 17 | queryKey: ['get me statistics'], 18 | queryFn: () => getMeStatistics() 19 | }) 20 | 21 | return isLoading ? ( 22 |
23 | {/* */} 24 | {/* */} 25 |
26 | ) : ( 27 |
28 | 29 | 30 | 31 | 32 | Очки и рейтинг 33 | 34 | 35 | 36 |
37 |
38 |
39 | {data?.totalPoints} 40 |
41 |
42 | Всего очков 43 |
44 |
45 |
46 |
47 |
48 | 49 | 50 | 51 | 52 | 53 | Прогресс обучения 54 | 55 | 56 | 57 |
58 |
59 |
60 | {data?.lessonsCompleted} 61 |
62 |
63 | Пройдено уроков 64 |
65 |
66 |
67 | 84 |
85 |
86 |
87 |
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/components/account/sessions/remove-all-sessions.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query' 2 | import { useState } from 'react' 3 | import { toast } from 'sonner' 4 | 5 | import { ConfirmDialog } from '../../shared/confirm-dialog' 6 | import { Button } from '../../ui/button' 7 | 8 | import { removeAllSessions } from '@/src/api' 9 | 10 | export function RemoveAllSessions() { 11 | const [isOpen, setIsOpen] = useState(false) 12 | 13 | const queryClient = useQueryClient() 14 | 15 | const { mutate, isPending } = useMutation({ 16 | mutationKey: ['remove all sessions'], 17 | mutationFn: () => removeAllSessions(), 18 | onSuccess() { 19 | queryClient.invalidateQueries({ queryKey: ['get sessions'] }) 20 | setIsOpen(false) 21 | }, 22 | onError(error: any) { 23 | toast.error( 24 | error.response?.data?.message ?? 'Ошибка при отключении' 25 | ) 26 | } 27 | }) 28 | 29 | return ( 30 | mutate()} 36 | isLoading={isPending} 37 | open={isOpen} 38 | onOpenChange={setIsOpen} 39 | > 40 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/account/sessions/remove-session.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query' 2 | import { useState } from 'react' 3 | import { toast } from 'sonner' 4 | 5 | import { ConfirmDialog } from '../../shared/confirm-dialog' 6 | import { Button } from '../../ui/button' 7 | 8 | import { revokeSession } from '@/src/api' 9 | 10 | interface RevokeSessionProps { 11 | id: string 12 | } 13 | 14 | export function RevokeSession({ id }: RevokeSessionProps) { 15 | const [isOpen, setIsOpen] = useState(false) 16 | 17 | const queryClient = useQueryClient() 18 | 19 | const { mutate, isPending } = useMutation({ 20 | mutationKey: ['revoke session', id], 21 | mutationFn: () => revokeSession(id), 22 | onSuccess() { 23 | queryClient.invalidateQueries({ queryKey: ['get sessions'] }) 24 | setIsOpen(false) 25 | }, 26 | onError(error: any) { 27 | toast.error( 28 | error.response?.data?.message ?? 'Ошибка при удалении сессии' 29 | ) 30 | } 31 | }) 32 | 33 | return ( 34 | mutate()} 40 | isLoading={isPending} 41 | open={isOpen} 42 | onOpenChange={setIsOpen} 43 | > 44 | 47 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/components/account/sessions/session-item.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from '../../ui/card' 2 | 3 | import { RevokeSession } from './remove-session' 4 | import { SessionResponse } from '@/src/generated' 5 | import { formatDate, getBrowserIcon } from '@/src/lib/utils' 6 | 7 | interface SessionItemProps { 8 | session: SessionResponse 9 | isCurrentSession?: boolean 10 | } 11 | 12 | export function SessionItem({ session, isCurrentSession }: SessionItemProps) { 13 | const Icon = getBrowserIcon(session.browser) 14 | 15 | return ( 16 | 17 | 18 |
19 |
20 | 21 |
22 |
23 |

24 | {session.browser}, {session.os} 25 |

26 |

27 | {isCurrentSession && ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | Текущее устройство 35 | 36 | 37 | 38 | )} 39 | {session.city}, {session.country} 40 | {!isCurrentSession && ( 41 | <> • {formatDate(session.createdAt)} 42 | )} 43 |

44 |
45 |
46 | {!isCurrentSession && } 47 |
48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/components/account/sessions/sessions.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useQuery } from '@tanstack/react-query' 4 | import { Loader2 } from 'lucide-react' 5 | import { Fragment } from 'react' 6 | 7 | import { Heading } from '../../shared/heading' 8 | 9 | import { RemoveAllSessions } from './remove-all-sessions' 10 | import { SessionItem } from './session-item' 11 | import { getSessions } from '@/src/api' 12 | 13 | export function Sessions() { 14 | const { data, isLoading } = useQuery({ 15 | queryKey: ['get sessions'], 16 | queryFn: () => getSessions() 17 | }) 18 | 19 | return ( 20 |
21 |
22 | {isLoading ? ( 23 |
24 | 25 |
26 | ) : ( 27 | 28 |
29 | 33 | 34 |
35 |
36 | {data?.map((session, index) => ( 37 | 42 | ))} 43 |
44 |
45 | )} 46 |
47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/components/account/settings/account-actions.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query' 2 | import { useRouter } from 'next/navigation' 3 | import { useState } from 'react' 4 | import { toast } from 'sonner' 5 | 6 | import { ConfirmDialog } from '../../shared/confirm-dialog' 7 | import { Button } from '../../ui/button' 8 | import { Card, CardContent } from '../../ui/card' 9 | 10 | import { logout } from '@/src/api' 11 | 12 | export function AccountActions() { 13 | const [isOpen, setIsOpen] = useState(false) 14 | 15 | const { push } = useRouter() 16 | 17 | const { mutate } = useMutation({ 18 | mutationKey: ['logout'], 19 | mutationFn: () => logout(), 20 | onSuccess() { 21 | setIsOpen(false) 22 | push('/auth/login') 23 | }, 24 | onError(error: any) { 25 | toast.error(error.response?.data?.message ?? 'Ошибка при выходе') 26 | } 27 | }) 28 | 29 | return ( 30 |
31 |

Действия

32 | 33 | 34 |
35 |
36 |
37 |

Выход

38 |

39 | Завершите сеанс, чтобы выйти из аккаунта на 40 | этом устройстве. 41 |

42 |
43 | mutate()} 49 | > 50 | 51 | 52 |
53 |
54 |
55 |
56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/components/account/settings/account-form.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from '../../ui/card' 2 | 3 | import { EmailForm } from './email-form' 4 | import { PasswordForm } from './password-form' 5 | import type { AccountResponse } from '@/src/generated' 6 | 7 | interface AccountFormProps { 8 | user: AccountResponse | undefined 9 | } 10 | 11 | export function AccountForm({ user }: AccountFormProps) { 12 | return ( 13 |
14 |

Аккаунт

15 | 16 | 17 |
18 | 19 | 20 |
21 |
22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/account/settings/appearance.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { Mail, Monitor } from 'lucide-react' 5 | import { useTheme } from 'next-themes' 6 | import { useEffect, useState } from 'react' 7 | import { useForm } from 'react-hook-form' 8 | import { z } from 'zod' 9 | 10 | import { Form, FormControl, FormField, FormItem } from '../../ui/form' 11 | import { 12 | Select, 13 | SelectContent, 14 | SelectItem, 15 | SelectTrigger, 16 | SelectValue 17 | } from '../../ui/select' 18 | 19 | const appearanceSchema = z.object({ 20 | theme: z.string() 21 | }) 22 | 23 | export type Appearance = z.infer 24 | 25 | export function AppearanceForm() { 26 | const { theme, setTheme } = useTheme() 27 | 28 | const form = useForm({ 29 | resolver: zodResolver(appearanceSchema), 30 | defaultValues: { 31 | theme: theme || 'system' 32 | } 33 | }) 34 | 35 | useEffect(() => { 36 | if (theme) { 37 | form.setValue('theme', theme) 38 | } 39 | }, [theme, form]) 40 | 41 | const handleThemeChange = (value: string) => { 42 | setTheme(value) 43 | } 44 | 45 | return ( 46 |
47 |
48 |
49 | 50 |
51 |
52 |

Тема

53 |

54 | Выберите светлую или тёмную тему, или синхронизируйте с 55 | настройками ОС. 56 |

57 |
58 |
59 |
60 |
61 | 62 | ( 66 | 67 | 91 | 92 | )} 93 | /> 94 | 95 | 96 |
97 |
98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /src/components/account/settings/avatar-form.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query' 2 | import { ChangeEvent, useState } from 'react' 3 | import { toast } from 'sonner' 4 | 5 | import { Avatar, AvatarFallback, AvatarImage } from '../../ui/avatar' 6 | import { Input } from '../../ui/input' 7 | 8 | import { changeAvatar } from '@/src/api' 9 | import type { AccountResponse } from '@/src/generated' 10 | import { getMediaSource } from '@/src/lib/utils' 11 | 12 | interface AvatarFormProps { 13 | user: AccountResponse | undefined 14 | } 15 | 16 | export function AvatarForm({ user }: AvatarFormProps) { 17 | const [preview, setPreview] = useState( 18 | user?.avatar ? getMediaSource(user.avatar, 'users') : null 19 | ) 20 | 21 | const queryClient = useQueryClient() 22 | 23 | const { mutate } = useMutation({ 24 | mutationKey: ['change user avatar'], 25 | mutationFn: (data: FormData) => changeAvatar(data), 26 | onSuccess: data => { 27 | setPreview(getMediaSource(data.file_id, 'users')) 28 | queryClient.invalidateQueries({ queryKey: ['get current'] }) 29 | toast.success('Аватар успешно обновлён') 30 | }, 31 | onError(error: any) { 32 | toast.error( 33 | error.response?.data?.message ?? 'Ошибка при обновлении аватара' 34 | ) 35 | } 36 | }) 37 | 38 | async function handleFileChange(event: ChangeEvent) { 39 | const file = event.target.files?.[0] 40 | 41 | if (file) { 42 | const formData = new FormData() 43 | formData.append('file', file) 44 | 45 | console.log('File added to FormData:', formData.get('file')) 46 | 47 | mutate(formData) 48 | } else { 49 | toast.error('Пожалуйста, выберите файл') 50 | } 51 | } 52 | 53 | return ( 54 |
55 | 69 |
70 |

Аватарка

71 |

72 | Форматы: JPEG, PNG, WEBP, GIF. Макс. размер: 10 МБ. 73 |

74 |
75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /src/components/account/settings/disable-totp-form.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod' 2 | import { useMutation, useQueryClient } from '@tanstack/react-query' 3 | import { useEffect, useState } from 'react' 4 | import { useForm } from 'react-hook-form' 5 | import { toast } from 'sonner' 6 | import { z } from 'zod' 7 | 8 | import { ConfirmDialog } from '../../shared/confirm-dialog' 9 | import { Button } from '../../ui/button' 10 | import { 11 | Form, 12 | FormControl, 13 | FormField, 14 | FormItem, 15 | FormLabel, 16 | FormMessage 17 | } from '../../ui/form' 18 | import { Input } from '../../ui/input' 19 | 20 | import { totpDisable } from '@/src/api' 21 | 22 | const disableTotpSchema = z.object({ 23 | password: z 24 | .string() 25 | .min(6, { message: 'Пароль должен содержать хотя бы 6 символов' }) 26 | }) 27 | 28 | export type DisableTotp = z.infer 29 | 30 | export function DisableTotpForm() { 31 | const [isOpen, setIsOpen] = useState(false) 32 | 33 | const queryClient = useQueryClient() 34 | 35 | const { mutateAsync, isPending } = useMutation({ 36 | mutationKey: ['totp disable'], 37 | mutationFn: (data: DisableTotp) => totpDisable(data), 38 | onSuccess() { 39 | queryClient.invalidateQueries({ queryKey: ['mfa status'] }) 40 | setIsOpen(false) 41 | }, 42 | onError(error: any) { 43 | toast.error( 44 | error.response?.data?.message ?? 'Ошибка при отключении' 45 | ) 46 | } 47 | }) 48 | 49 | const form = useForm({ 50 | resolver: zodResolver(disableTotpSchema), 51 | defaultValues: { 52 | password: '' 53 | } 54 | }) 55 | 56 | useEffect(() => { 57 | form.reset() 58 | }, [form, form.reset, form.formState.isSubmitSuccessful]) 59 | 60 | async function onSubmit(data: DisableTotp) { 61 | await mutateAsync(data) 62 | } 63 | 64 | return ( 65 | 69 |

70 | Вы уверены, что хотите отключить этот метод 71 | двухфакторной аутентификации?{' '} 72 |

73 | 74 |
75 | 79 | ( 83 | 84 | Пароль 85 | 86 | 92 | 93 | 94 | 95 | )} 96 | /> 97 | 98 | 99 |
100 | } 101 | confirmText='Отключить' 102 | destructive 103 | handleConfirm={form.handleSubmit(onSubmit)} 104 | isLoading={isPending} 105 | open={isOpen} 106 | onOpenChange={setIsOpen} 107 | > 108 | 109 | 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /src/components/account/settings/display-name-form.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod' 2 | import { useMutation } from '@tanstack/react-query' 3 | import { useForm } from 'react-hook-form' 4 | import { toast } from 'sonner' 5 | import { z } from 'zod' 6 | 7 | import { Button } from '../../ui/button' 8 | import { 9 | Form, 10 | FormControl, 11 | FormDescription, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage 16 | } from '../../ui/form' 17 | import { Input } from '../../ui/input' 18 | 19 | import { patchUser } from '@/src/api' 20 | import type { AccountResponse } from '@/src/generated' 21 | 22 | const displayNameSchema = z.object({ 23 | displayName: z.string({ message: 'Имя обязательно' }) 24 | }) 25 | 26 | export type DisplayName = z.infer 27 | 28 | interface DisplayNameFormProps { 29 | user: AccountResponse | undefined 30 | } 31 | 32 | export function DisplayNameForm({ user }: DisplayNameFormProps) { 33 | const { mutateAsync, isPending } = useMutation({ 34 | mutationKey: ['patch user'], 35 | mutationFn: (data: DisplayName) => patchUser(data), 36 | onSuccess() { 37 | toast.success('Профиль обновлён') 38 | }, 39 | onError(error: any) { 40 | toast.error( 41 | error.response?.data?.message ?? 'Ошибка при обновлении профиля' 42 | ) 43 | } 44 | }) 45 | 46 | const form = useForm({ 47 | resolver: zodResolver(displayNameSchema), 48 | values: { 49 | displayName: user?.displayName ?? '' 50 | } 51 | }) 52 | 53 | const { isDirty } = form.formState 54 | 55 | async function onSubmit(data: DisplayName) { 56 | await mutateAsync(data) 57 | } 58 | 59 | return ( 60 |
61 |
62 | 66 | ( 70 | 71 | Ваше имя 72 | 73 |
74 | 79 | {isDirty && ( 80 |
81 | 88 |
89 | )} 90 |
91 |
92 | 93 | Измените ваше имя на любое, какое захотите. 94 | 95 | 96 |
97 | )} 98 | /> 99 | 100 | 101 |
102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /src/components/account/settings/preferences.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from '../../ui/card' 2 | 3 | import { AppearanceForm } from './appearance' 4 | 5 | export function Preferences() { 6 | return ( 7 |
8 |

Внешний вид

9 | 10 | 11 |
12 | 13 |
14 |
15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/account/settings/profile-form.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from '../../ui/card' 2 | 3 | import { AvatarForm } from './avatar-form' 4 | import { DisplayNameForm } from './display-name-form' 5 | import type { AccountResponse } from '@/src/generated' 6 | 7 | interface ProfileForm { 8 | user: AccountResponse | undefined 9 | } 10 | 11 | export function ProfileForm({ user }: ProfileForm) { 12 | return ( 13 |
14 |

Профиль

15 | 16 | 17 |
18 | 19 | 20 |
21 |
22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/account/settings/settings.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useQuery } from '@tanstack/react-query' 4 | import { Loader2 } from 'lucide-react' 5 | import { Fragment } from 'react' 6 | 7 | import { Heading } from '../../shared/heading' 8 | 9 | import { AccountActions } from './account-actions' 10 | import { AccountForm } from './account-form' 11 | import { Preferences } from './preferences' 12 | import { ProfileForm } from './profile-form' 13 | import { TwoStepAuthForm } from './two-step-auth-form' 14 | import { fetchMfaStatus } from '@/src/api' 15 | import { useCurrent } from '@/src/hooks' 16 | 17 | export function Settings() { 18 | const { user } = useCurrent() 19 | 20 | const { data: status } = useQuery({ 21 | queryKey: ['mfa status'], 22 | queryFn: () => fetchMfaStatus() 23 | }) 24 | 25 | return ( 26 |
27 |
28 | 29 | 33 |
34 | 35 | 36 | 37 | 38 | 39 |
40 |
41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/account/settings/two-step-auth-form.tsx: -------------------------------------------------------------------------------- 1 | import { ListOrdered, Mail, Smartphone } from 'lucide-react' 2 | 3 | import { Badge } from '../../ui/badge' 4 | import { Card, CardContent } from '../../ui/card' 5 | 6 | import { DisableTotpForm } from './disable-totp-form' 7 | import { EnableTotpForm } from './enable-totp-form' 8 | import { RecoveryCodesModal } from './recovery-codes-modal' 9 | import type { MfaStatusResponse } from '@/src/generated' 10 | 11 | interface TwoFactorAuthFormProps { 12 | status: MfaStatusResponse | undefined 13 | } 14 | 15 | export function TwoStepAuthForm({ status }: TwoFactorAuthFormProps) { 16 | return ( 17 |
18 |

19 | Многофакторная аутентификация 20 |

21 | 22 | 23 |
24 |
25 |
26 |
27 | 28 |
29 |
30 |
31 |

32 | Приложение для аутентификации 33 |

34 | {status?.totpMfa ? ( 35 | 36 | Включено 37 | 38 | ) : ( 39 | 40 | Отключено 41 | 42 | )} 43 |
44 |

45 | {status?.totpMfa 46 | ? 'Двухфакторная аутентификация через TOTP включена. Для входа в аккаунт используйте приложение-аутентификатор, чтобы получить код.' 47 | : 'Обеспечьте безопасность своего аккаунта с помощью двухфакторной аутентификации через TOTP.'} 48 |

49 |
50 |
51 |
52 | {status?.totpMfa ? ( 53 | 54 | ) : ( 55 | 56 | )} 57 |
58 |
59 | 60 | {status?.recoveryActive && ( 61 |
62 |
63 |
64 | 65 |
66 |
67 |

68 | Коды восстановления 69 |

70 |

71 | Вы можете использовать коды 72 | восстановления для доступа к 73 | аккаунту, если потеряете доступ к 74 | своему устройству. 75 |

76 |
77 |
78 |
79 | 80 |
81 |
82 | )} 83 |
84 |
85 |
86 |
87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/components/analitycs/yandex-metrika.tsx: -------------------------------------------------------------------------------- 1 | import Script from 'next/script' 2 | 3 | interface YandexMetrikaProps { 4 | id: string 5 | } 6 | 7 | export function YandexMetrika({ id }: YandexMetrikaProps) { 8 | return ( 9 | <> 10 | 26 | 27 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/components/auth/auth-social.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useMutation } from '@tanstack/react-query' 4 | import { useRouter } from 'next/navigation' 5 | import { FaGithub } from 'react-icons/fa6' 6 | import { FcGoogle } from 'react-icons/fc' 7 | import { toast } from 'sonner' 8 | 9 | import { Button } from '../ui/button' 10 | 11 | import { getAuthUrl } from '@/src/api/external' 12 | 13 | export function AuthSocial() { 14 | const router = useRouter() 15 | 16 | const { mutate, isPending } = useMutation({ 17 | mutationKey: ['oauth login'], 18 | mutationFn: (provider: 'google' | 'github') => getAuthUrl(provider), 19 | onSuccess(data) { 20 | router.push(data.url) 21 | }, 22 | onError(error: any) { 23 | toast.error( 24 | error.response?.data?.message ?? 'Ошибка при создании URL' 25 | ) 26 | } 27 | }) 28 | 29 | return ( 30 |
31 | 39 | 47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/components/auth/auth-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import type { ReactNode } from 'react' 3 | 4 | import { AuthSocial } from './auth-social' 5 | 6 | interface AuthWrapperProps { 7 | children: ReactNode 8 | heading: string 9 | description?: string 10 | bottomText?: string 11 | bottomLinkText?: string 12 | bottomLinkHref?: string 13 | isShowSocial?: boolean 14 | } 15 | 16 | export function AuthWrapper({ 17 | children, 18 | heading, 19 | description, 20 | bottomText, 21 | bottomLinkText, 22 | bottomLinkHref, 23 | isShowSocial 24 | }: AuthWrapperProps) { 25 | return ( 26 |
27 |
28 |
29 |
30 |

{heading}

31 | {description && ( 32 |

33 | {description} 34 |

35 | )} 36 |
37 | {isShowSocial && } 38 |
{children}
39 | {/*

40 | Нажимая продолжить, вы соглашаетесь с нашим{' '} 41 | 46 | Пользовательским соглашением 47 | {' '} 48 | и{' '} 49 | 54 | Политикой Конфиденциальности 55 | 56 | . 57 |

*/} 58 | {bottomText && bottomLinkText && bottomLinkHref && ( 59 |

60 | {bottomText}{' '} 61 | 65 | {bottomLinkText} 66 | 67 |

68 | )} 69 |
70 |
71 |
72 | 73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/components/auth/new-password-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { useMutation } from '@tanstack/react-query' 5 | import { useParams, useRouter } from 'next/navigation' 6 | import { useEffect } from 'react' 7 | import { useForm } from 'react-hook-form' 8 | import { toast } from 'sonner' 9 | import { z } from 'zod' 10 | 11 | import { Button } from '../ui/button' 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage 19 | } from '../ui/form' 20 | import { Input } from '../ui/input' 21 | 22 | import { AuthWrapper } from './auth-wrapper' 23 | import { passwordReset } from '@/src/api' 24 | import { ROUTES } from '@/src/constants' 25 | 26 | const newPasswordSchema = z.object({ 27 | token: z.string().max(128, { message: 'Некорректный токен' }), 28 | password: z 29 | .string() 30 | .min(6, { message: 'Пароль должен содержать хотя бы 6 символов' }) 31 | .max(128, { message: 'Пароль должен содержать не более 128 символов' }) 32 | }) 33 | 34 | export type NewPassword = z.infer 35 | 36 | export function NewPasswordForm() { 37 | const { push } = useRouter() 38 | const { token } = useParams<{ token: string }>() 39 | 40 | const { mutateAsync, isPending } = useMutation({ 41 | mutationKey: ['password reset'], 42 | mutationFn: (data: NewPassword) => passwordReset(data), 43 | onSuccess() { 44 | push('/auth/login') 45 | }, 46 | onError(error: any) { 47 | toast.error( 48 | error.response?.data?.message ?? 'Ошибка при регистрации' 49 | ) 50 | } 51 | }) 52 | 53 | const form = useForm({ 54 | resolver: zodResolver(newPasswordSchema), 55 | defaultValues: { 56 | token: '', 57 | password: '' 58 | } 59 | }) 60 | 61 | useEffect(() => { 62 | form.reset() 63 | }, [form, form.reset, form.formState.isSubmitSuccessful]) 64 | 65 | async function onSubmit(data: NewPassword) { 66 | await mutateAsync({ 67 | token, 68 | password: data.password 69 | }) 70 | } 71 | 72 | return ( 73 | 80 |
81 | 85 | ( 89 | 90 | Пароль 91 | 92 | 98 | 99 | 100 | 101 | )} 102 | /> 103 | 111 | 112 | 113 |
114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /src/components/auth/verify-email.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useMutation } from '@tanstack/react-query' 4 | import { useParams, useRouter } from 'next/navigation' 5 | import { toast } from 'sonner' 6 | 7 | import { Button } from '../ui/button' 8 | 9 | import { AuthWrapper } from './auth-wrapper' 10 | import { verifyEmail } from '@/src/api' 11 | 12 | export function VerifyEmail() { 13 | const router = useRouter() 14 | const { token } = useParams<{ token: string }>() 15 | 16 | const { mutate, isPending } = useMutation({ 17 | mutationKey: ['verify email'], 18 | mutationFn: () => verifyEmail(token), 19 | onSuccess() { 20 | router.push('/account/settings') 21 | }, 22 | onError(error: any) { 23 | toast.error( 24 | error.response?.data?.message ?? 'Ошибка при верификации' 25 | ) 26 | } 27 | }) 28 | 29 | return ( 30 | 31 |

32 | Чтобы завершить подтверждение почты, нажми на кнопку ниже. 33 |

34 | 42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/course/course-card.tsx: -------------------------------------------------------------------------------- 1 | import { BookOpen } from 'lucide-react' 2 | import Image from 'next/image' 3 | import Link from 'next/link' 4 | import { FaYoutube } from 'react-icons/fa' 5 | 6 | import { Badge } from '../ui/badge' 7 | 8 | import { ROUTES } from '@/src/constants' 9 | import type { CourseResponse } from '@/src/generated' 10 | import { getLessonLabel, getMediaSource } from '@/src/lib/utils' 11 | 12 | interface CourseCardProps { 13 | course: CourseResponse 14 | } 15 | 16 | export function CourseCard({ course }: CourseCardProps) { 17 | return ( 18 | 22 |
23 | {course.title} 28 |
29 |
30 |

31 | {course.title} 32 |

33 |

42 | {course.description} 43 |

44 | 0 ? 'default' : 'error'} 46 | className='mt-3' 47 | > 48 | {course.lessons > 0 ? ( 49 | <> 50 | 51 | {course.lessons} {getLessonLabel(course.lessons)} 52 | 53 | ) : ( 54 | <> 55 | 56 | Youtube 57 | 58 | )} 59 | 60 |
61 | 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/components/course/course-details.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useQuery } from '@tanstack/react-query' 4 | import { useRef, useState } from 'react' 5 | 6 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs' 7 | 8 | import { CourseInfo } from './course-info' 9 | import { CourseLessons } from './course-lessons' 10 | import { CourseOverview } from './course-overview' 11 | import { CourseProgress } from './course-progress' 12 | import { CourseSummary } from './course-summary' 13 | import { getCompletedLessons } from '@/src/api' 14 | import type { CourseResponse, LessonResponse } from '@/src/generated' 15 | import { useAuth } from '@/src/hooks' 16 | 17 | interface CourseDetailsProps { 18 | course: CourseResponse 19 | lessons: LessonResponse[] 20 | } 21 | 22 | export function CourseDetails({ course, lessons }: CourseDetailsProps) { 23 | const [activeTab, setActiveTab] = useState('overview') 24 | const lessonsRef = useRef(null) 25 | 26 | const { isAuthorized } = useAuth() 27 | 28 | const { data: completedLessons, isLoading } = useQuery({ 29 | queryKey: ['get completed lessons'], 30 | queryFn: () => getCompletedLessons(course.id), 31 | enabled: isAuthorized 32 | }) 33 | 34 | function onStartCourse() { 35 | setActiveTab('lessons') 36 | if (lessonsRef.current) { 37 | lessonsRef.current.scrollIntoView({ behavior: 'smooth' }) 38 | } 39 | } 40 | 41 | if (isLoading) return
Загрузка...
42 | 43 | return ( 44 |
45 | 50 | {isAuthorized && lessons.length > 0 && ( 51 | 55 | )} 56 | 57 |
58 |
59 | {lessons.length ? ( 60 | 65 | 66 | 67 | Описание 68 | 69 | Уроки 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 |
78 | 82 |
83 |
84 |
85 | ) : ( 86 | 87 | )} 88 |
89 |
90 | 95 |
96 |
97 |
98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /src/components/course/course-info.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Card, CardContent, CardHeader, CardTitle } from '../ui/card' 4 | 5 | import type { CourseResponse, LessonResponse } from '@/src/generated' 6 | import { useAuth } from '@/src/hooks' 7 | import { getLessonLabel } from '@/src/lib/utils' 8 | 9 | interface CourseInfoProps { 10 | course: CourseResponse 11 | lessons: LessonResponse[] 12 | completedLessons: string[] 13 | } 14 | 15 | export function CourseInfo({ 16 | course, 17 | lessons, 18 | completedLessons 19 | }: CourseInfoProps) { 20 | const { isAuthorized } = useAuth() 21 | 22 | const formattedDate = new Date(course.createdAt).toLocaleDateString( 23 | 'ru-RU', 24 | { 25 | day: 'numeric', 26 | month: 'long', 27 | year: 'numeric' 28 | } 29 | ) 30 | 31 | const progressPercentage = 32 | (lessons?.length ?? 0) > 0 && (completedLessons?.length ?? 0) >= 0 33 | ? Math.round( 34 | ((completedLessons?.length ?? 0) / (lessons?.length ?? 0)) * 35 | 100 36 | ) 37 | : 0 38 | 39 | return ( 40 | 41 | 42 | Информация 43 | 44 | 45 |
46 |
47 |

48 | Добавлен 49 |

50 |

{formattedDate}

51 |
52 | 53 |
54 |

55 | Количество уроков 56 |

57 | {course.youtubeUrl && 58 | (lessons?.length > 0 ? ( 59 |

60 | {lessons.length}{' '} 61 | {getLessonLabel(lessons.length)} 62 |

63 | ) : ( 64 |

65 | Доступно на YouTube 66 |

67 | ))} 68 |
69 | 70 | {isAuthorized && lessons.length > 0 && ( 71 |
72 |

73 | Ваш прогресс 74 |

75 |

76 | {progressPercentage}% завершено 77 |

78 |
79 | )} 80 |
81 | 82 | {/*
83 | 87 |
*/} 88 |
89 |
90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/components/course/course-lessons.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircle } from 'lucide-react' 2 | import Link from 'next/link' 3 | 4 | import { ROUTES } from '@/src/constants' 5 | import type { LessonResponse } from '@/src/generated' 6 | import { useAuth } from '@/src/hooks' 7 | 8 | interface CourseLessonsProps { 9 | lessons: LessonResponse[] 10 | completedLessons: string[] 11 | } 12 | 13 | export function CourseLessons({ 14 | lessons = [], 15 | completedLessons = [] 16 | }: CourseLessonsProps) { 17 | const { isAuthorized } = useAuth() 18 | 19 | const totalLessons = lessons?.length ?? 0 20 | const completedCount = completedLessons?.length ?? 0 21 | 22 | return ( 23 |
24 |
25 |

Уроки

26 | {isAuthorized && ( 27 |

28 | {totalLessons} уроков • {completedCount} выполнено 29 |

30 | )} 31 |
32 |
    33 | {lessons.map(lesson => { 34 | const isCompleted = completedLessons.includes(lesson.id) 35 | 36 | return ( 37 |
  • 38 | 46 |
    47 |
    48 | {isCompleted ? ( 49 | 50 | ) : ( 51 | 52 | {lesson.position} 53 | 54 | )} 55 |
    56 | 57 |
    58 |

    59 | {lesson.title} 60 |

    61 | {lesson.description && ( 62 |

    63 | {lesson.description} 64 |

    65 | )} 66 |
    67 |
    68 | 69 |
  • 70 | ) 71 | })} 72 |
73 |
74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /src/components/course/course-overview.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react' 2 | 3 | import type { CourseResponse } from '@/src/generated' 4 | 5 | interface CourseOverviewProps { 6 | course: CourseResponse 7 | } 8 | 9 | export function CourseOverview({ course }: CourseOverviewProps) { 10 | return ( 11 | 12 |
13 |

О курсе

14 |

15 | {course.description} 16 |

17 |
18 | {/*
19 |

What you'll learn

20 |
    21 |
  • 22 | 23 | 24 | Build modern web applications with Next.js 14 25 | 26 |
  • 27 |
  • 28 | 29 | Master React Server Components 30 |
  • 31 |
  • 32 | 33 | Implement authentication and authorization 34 |
  • 35 |
  • 36 | 37 | Deploy your application to production 38 |
  • 39 |
40 |
*/} 41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/components/course/course-progress.tsx: -------------------------------------------------------------------------------- 1 | import { CourseProgress as Progress } from '../shared/course-progress' 2 | 3 | interface CourseProgressProps { 4 | totalLessons: number 5 | completedLessons: number 6 | } 7 | 8 | export function CourseProgress({ 9 | totalLessons, 10 | completedLessons 11 | }: CourseProgressProps) { 12 | const progressPercentage = 13 | totalLessons > 0 14 | ? Math.round((completedLessons / totalLessons) * 100) 15 | : 0 16 | 17 | return ( 18 |
19 |
20 | Ваш прогресс 21 | 22 | {completedLessons}/{totalLessons} уроков 23 | 24 |
25 | 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/course/course-summary.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { BookOpen, Code, Eye } from 'lucide-react' 4 | import { useTheme } from 'next-themes' 5 | import Link from 'next/link' 6 | import { type Dispatch, Fragment, type SetStateAction } from 'react' 7 | import { FaYoutube } from 'react-icons/fa' 8 | 9 | import { Badge } from '../ui/badge' 10 | import { Button } from '../ui/button' 11 | 12 | import { ROUTES } from '@/src/constants' 13 | import type { CourseResponse, LessonResponse } from '@/src/generated' 14 | import { useAuth } from '@/src/hooks' 15 | import { getMediaSource } from '@/src/lib/utils' 16 | 17 | interface CourseSummaryProps { 18 | course: CourseResponse 19 | lessons: LessonResponse[] 20 | setActiveTab: Dispatch> 21 | } 22 | 23 | export function CourseSummary({ 24 | course, 25 | lessons, 26 | setActiveTab 27 | }: CourseSummaryProps) { 28 | const { resolvedTheme } = useTheme() 29 | 30 | const { isAuthorized } = useAuth() 31 | 32 | return ( 33 |
34 | {resolvedTheme === 'light' && ( 35 | 36 |
37 |
38 | 39 | )} 40 | 41 |
42 |
43 |
44 | 45 | {course.views}{' '} 46 | просмотров 47 | 48 |
49 | 50 |

51 | {course.title} 52 |

53 | 54 |

55 | {course.description?.split('.').slice(0, 2).join('.') + 56 | '.'} 57 |

58 | 59 |
60 | {lessons.length > 0 && ( 61 | 68 | )} 69 | 70 | {course.youtubeUrl && ( 71 | 82 | )} 83 | 84 | {course.attachment && ( 85 | 101 | )} 102 |
103 |
104 | 105 | {/*
106 | {course.title} 113 |
*/} 114 |
115 |
116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /src/components/home/features.tsx: -------------------------------------------------------------------------------- 1 | import { AreaChart, BookOpen, Code } from 'lucide-react' 2 | 3 | export const Features = () => { 4 | const features = [ 5 | { 6 | icon: , 7 | title: 'Разнообразие курсов', 8 | description: 9 | 'На платформе есть курсы по программированию и другим темам. Все уроки доступны в любое время, так что ты можешь учиться в удобном ритме.' 10 | }, 11 | { 12 | icon: , 13 | title: 'Отслеживание прогресса', 14 | description: 15 | 'Следи за своими достижениями, выполняй задания и зарабатывай очки. Вся статистика сохраняется в профиле, чтобы ты видел свой рост.' 16 | }, 17 | { 18 | icon: , 19 | title: 'Практика с кодом', 20 | description: 21 | 'Все курсы включают видеоуроки и реальные примеры кода. Ты сможешь не только изучать теорию, но и сразу применять её на практике.' 22 | } 23 | ] 24 | 25 | return ( 26 |
27 |
28 |

29 | Что тебя ждёт на платформе? 30 |

31 |

32 | Получи доступ к удобным курсам по программированию, 33 | отслеживай свой прогресс и практикуйся с реальными примерами 34 | кода 35 |

36 |
37 |
38 | {features.map((reason, index) => ( 39 |
43 |
44 |
45 | {reason.icon} 46 |
47 |

48 | {reason.title} 49 |

50 |

51 | {reason.description} 52 |

53 |
54 |
55 | ))} 56 |
57 |
58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/components/home/hero.tsx: -------------------------------------------------------------------------------- 1 | import { Terminal } from 'lucide-react' 2 | import Link from 'next/link' 3 | 4 | import { Button } from '../ui/button' 5 | 6 | import { ROUTES } from '@/src/constants' 7 | 8 | export function Hero() { 9 | return ( 10 |
11 |
12 |

13 | Изучай веб-разработку 14 |
с{' '} 15 |
16 | TeaCoder 17 |

18 |

19 | Платформа для изучения веб-разработки с гибким подходом. 20 | Получи доступ к курсам, которые помогут развить навыки и 21 | разобраться в реальных примерах 22 |

23 |
24 |
25 | 36 |
37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/components/home/popular.tsx: -------------------------------------------------------------------------------- 1 | import { CourseCard } from '../course/course-card' 2 | 3 | import { CourseResponse } from '@/src/generated' 4 | 5 | interface PopularProps { 6 | courses: CourseResponse[] 7 | } 8 | 9 | export function Popular({ courses }: PopularProps) { 10 | return ( 11 |
12 |

13 | Популярные курсы 14 |

15 |

16 | Cамые популярные курсы среди пользователей платформы 17 |

18 |
19 | {courses.map((course, index) => ( 20 | 21 | ))} 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/home/telegram-cta.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { FaTelegram } from 'react-icons/fa6' 3 | 4 | import { Button } from '../ui/button' 5 | 6 | export function TelegramCTA() { 7 | return ( 8 |
9 |

10 | Присоединяйся в Telegram 11 |

12 | 13 |

14 | Подписывайся на наш канал! Получай последние новости, общайся с 15 | единомышленниками и будь в курсе самых актуальных событий. 16 |

17 | 18 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/components/layout/header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | 5 | import { Logo } from '../shared/logo' 6 | import { Button } from '../ui/button' 7 | 8 | import { MobileNav } from './mobile-nav' 9 | import { NavLinks } from './nav-links' 10 | import { UserMenu } from './user-menu' 11 | import { ROUTES } from '@/src/constants' 12 | import { useAuth } from '@/src/hooks' 13 | 14 | export function Header() { 15 | const { isAuthorized } = useAuth() 16 | 17 | return ( 18 |
19 |
20 |
21 |
22 | 26 | 27 | TeaCoder 28 | {' '} 29 |
30 |
31 | 32 |
33 |
34 |
35 | {isAuthorized ? ( 36 | 37 | ) : ( 38 |
39 | 42 | 47 |
48 | )} 49 |
50 |
51 | 52 |
53 |
54 |
55 |
56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/components/layout/nav-links.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | import { Button } from '../ui/button' 4 | 5 | import { ROUTES } from '@/src/constants' 6 | 7 | interface NavLink { 8 | title: string 9 | href: string 10 | } 11 | 12 | export const navLinks: NavLink[] = [ 13 | { title: 'Курсы', href: ROUTES.courses }, 14 | { title: 'Об основателе', href: ROUTES.about } 15 | ] 16 | 17 | export function NavLinks() { 18 | return ( 19 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/layout/user-menu.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useMutation } from '@tanstack/react-query' 4 | import { ChartArea, LogOut, Settings } from 'lucide-react' 5 | import Link from 'next/link' 6 | import { useRouter } from 'next/navigation' 7 | import { toast } from 'sonner' 8 | 9 | import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar' 10 | import { Button } from '../ui/button' 11 | import { 12 | DropdownMenu, 13 | DropdownMenuContent, 14 | DropdownMenuGroup, 15 | DropdownMenuItem, 16 | DropdownMenuLabel, 17 | DropdownMenuSeparator, 18 | DropdownMenuTrigger 19 | } from '../ui/dropdown-menu' 20 | 21 | import { logout } from '@/src/api' 22 | import { ROUTES } from '@/src/constants' 23 | import { useCurrent } from '@/src/hooks/use-current' 24 | import { getMediaSource } from '@/src/lib/utils' 25 | 26 | export function UserMenu() { 27 | const router = useRouter() 28 | 29 | const { user } = useCurrent() 30 | 31 | const { mutate } = useMutation({ 32 | mutationKey: ['logout'], 33 | mutationFn: () => logout(), 34 | onSuccess() { 35 | router.push('/auth/login') 36 | }, 37 | onError(error: any) { 38 | toast.error(error.response?.data?.message ?? 'Ошибка при выходе') 39 | } 40 | }) 41 | 42 | return ( 43 | 44 | 45 | 59 | 60 | 61 | 62 |
63 |

64 | {user?.displayName} 65 |

66 |

67 | {user?.email} 68 |

69 |
70 |
71 | 72 | 73 | 74 | 75 | 76 | Мой прогресс 77 | 78 | 79 | 80 | 81 | 82 | Настройки 83 | 84 | 85 | mutate()} 87 | className='!text-rose-600' 88 | > 89 | 90 | Выйти 91 | 92 | 93 |
94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /src/components/layout/user-navigation.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ChartArea, LinkIcon, MonitorSmartphone, Settings } from 'lucide-react' 4 | import Link from 'next/link' 5 | import { usePathname } from 'next/navigation' 6 | 7 | import { buttonVariants } from '../ui/button' 8 | 9 | import { ROUTES } from '@/src/constants' 10 | import { cn } from '@/src/lib/utils' 11 | 12 | export const links = [ 13 | { 14 | title: 'Мой прогресс', 15 | href: ROUTES.progress, 16 | icon: ChartArea 17 | }, 18 | { 19 | title: 'Настройки аккаунта', 20 | href: ROUTES.settings, 21 | icon: Settings 22 | }, 23 | { 24 | title: 'Устройства', 25 | href: ROUTES.sessions, 26 | icon: MonitorSmartphone 27 | }, 28 | { 29 | title: 'Связанные аккаунты', 30 | href: ROUTES.connections, 31 | icon: LinkIcon 32 | } 33 | ] 34 | 35 | export function UserNavigation() { 36 | const pathname = usePathname() 37 | 38 | return ( 39 |
40 | 64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/components/lesson/lesson-complete-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useMutation } from '@tanstack/react-query' 4 | import { CircleCheckBig, CircleX } from 'lucide-react' 5 | import { useRouter } from 'next/navigation' 6 | import { toast } from 'sonner' 7 | 8 | import { Button } from '../ui/button' 9 | 10 | import { createProgress } from '@/src/api' 11 | import type { LessonResponse } from '@/src/generated' 12 | import { cn } from '@/src/lib/utils' 13 | 14 | interface LessonCompleteButtonProps { 15 | lesson: LessonResponse 16 | completedLessons: string[] 17 | } 18 | 19 | export function LessonCompleteButton({ 20 | lesson, 21 | completedLessons 22 | }: LessonCompleteButtonProps) { 23 | const { push, refresh } = useRouter() 24 | 25 | const isCompleted = completedLessons.includes(lesson.id) 26 | 27 | const { mutate, isPending } = useMutation({ 28 | mutationKey: ['create progress course'], 29 | mutationFn: () => 30 | createProgress({ 31 | isCompleted: !isCompleted, 32 | lessonId: lesson.id 33 | }), 34 | onSuccess(data) { 35 | refresh() 36 | 37 | if (data.nextLesson && data.isCompleted) 38 | push(`/lesson/${data.nextLesson}`) 39 | 40 | if (!data.nextLesson && data.isCompleted) { 41 | } 42 | }, 43 | onError(error: any) { 44 | toast.error( 45 | error.response?.data?.message ?? 46 | 'Ошибка при обновлении прогресса' 47 | ) 48 | } 49 | }) 50 | 51 | const Icon = isCompleted ? CircleX : CircleCheckBig 52 | 53 | return ( 54 |
55 |
56 |
57 |

58 | {isCompleted 59 | ? 'Вы завершили этот урок!' 60 | : 'Вы готовы завершить этот урок?'} 61 |

62 |

63 | {isCompleted 64 | ? 'Отличная работа! Вы можете посмотреть свою статистику в личном кабинете.' 65 | : 'Не забудьте завершить урок, когда будете готовы.'} 66 |

67 |
68 | 88 |
89 |
90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/components/lesson/lesson-container.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | 3 | interface LessonContainerProps { 4 | children: ReactNode 5 | } 6 | 7 | export function LessonContainer({ children }: LessonContainerProps) { 8 | return ( 9 |
10 |
11 |
12 |
13 | {children} 14 |
15 |
16 |
17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/components/lesson/lesson-player.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import dynamic from 'next/dynamic' 4 | import { useRef, useState } from 'react' 5 | 6 | import { type KinescopePlayer } from '../shared/player' 7 | 8 | interface LessonPlayerProps { 9 | videoId: string 10 | } 11 | 12 | const Player = dynamic( 13 | () => import('../shared/player').then(mod => mod.Player), 14 | { 15 | ssr: false 16 | } 17 | ) 18 | 19 | export function LessonPlayer({ videoId }: LessonPlayerProps) { 20 | const playerRef = useRef(null) 21 | const [isLoading, setIsLoading] = useState(true) 22 | 23 | function handlePlayerReady() { 24 | setIsLoading(false) 25 | } 26 | 27 | return ( 28 |
29 | {isLoading &&

Загрузка...

} 30 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/components/lesson/lesson-sidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | ArrowLeft, 5 | CircleCheckBig, 6 | Library, 7 | PauseCircle, 8 | PlayCircle 9 | } from 'lucide-react' 10 | import Link from 'next/link' 11 | import { usePathname } from 'next/navigation' 12 | 13 | import { CourseProgress } from '../shared/course-progress' 14 | import { buttonVariants } from '../ui/button' 15 | import { ScrollArea } from '../ui/scroll-area' 16 | 17 | import { ROUTES } from '@/src/constants' 18 | import type { CourseResponse, LessonResponse } from '@/src/generated' 19 | import { cn } from '@/src/lib/utils' 20 | 21 | interface LessonSidebarProps { 22 | course: CourseResponse 23 | lessons: LessonResponse[] 24 | completedLessons: string[] 25 | progressCount: number 26 | } 27 | 28 | export function LessonSidebar({ 29 | course, 30 | lessons, 31 | completedLessons, 32 | progressCount 33 | }: LessonSidebarProps) { 34 | const pathname = usePathname() 35 | 36 | return ( 37 | 119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /src/components/providers/account-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useQuery } from '@tanstack/react-query' 4 | import type { ReactNode } from 'react' 5 | 6 | import { EllipsisLoader } from '../shared/ellipsis-loader' 7 | 8 | import { fetchMfaStatus } from '@/src/api' 9 | import { useCurrent } from '@/src/hooks' 10 | 11 | export function AccountProvider({ children }: { children: ReactNode }) { 12 | const { isLoading } = useCurrent() 13 | 14 | const { isLoading: isLoadingStatus } = useQuery({ 15 | queryKey: ['mfa status'], 16 | queryFn: () => fetchMfaStatus() 17 | }) 18 | 19 | if (isLoading || isLoadingStatus) { 20 | return ( 21 |
22 | 23 |
24 | ) 25 | } 26 | 27 | return <>{children} 28 | } 29 | -------------------------------------------------------------------------------- /src/components/providers/course-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useMutation } from '@tanstack/react-query' 4 | import { type ReactNode, useEffect } from 'react' 5 | 6 | import { incrementCourseViews } from '@/src/api' 7 | 8 | interface CourseProviderProps { 9 | id: string 10 | children: ReactNode 11 | } 12 | 13 | export function CourseProvider({ id, children }: CourseProviderProps) { 14 | const { mutate } = useMutation({ 15 | mutationKey: ['increment course views', id], 16 | mutationFn: () => incrementCourseViews(id) 17 | }) 18 | 19 | useEffect(() => { 20 | mutate() 21 | }, [mutate]) 22 | 23 | return <>{children} 24 | } 25 | -------------------------------------------------------------------------------- /src/components/providers/tanstack-query-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 4 | import { type ReactNode, useState } from 'react' 5 | 6 | export function TanstackQueryProvider({ children }: { children: ReactNode }) { 7 | const [client] = useState( 8 | new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | staleTime: Infinity, 12 | refetchInterval: false, 13 | refetchOnWindowFocus: false, 14 | refetchOnReconnect: false, 15 | refetchOnMount: false 16 | } 17 | } 18 | }) 19 | ) 20 | 21 | return {children} 22 | } 23 | -------------------------------------------------------------------------------- /src/components/providers/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ThemeProvider as NextThemesProvider } from 'next-themes' 4 | import type { ComponentProps } from 'react' 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: ComponentProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /src/components/shared/captcha.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from 'next-themes' 2 | import { useEffect } from 'react' 3 | import Turnstile, { type TurnstileProps } from 'react-turnstile' 4 | 5 | interface CaptchaProps extends Omit { 6 | onVerify: (token: string) => void 7 | } 8 | 9 | export function Captcha({ onVerify, ...props }: CaptchaProps) { 10 | const { resolvedTheme } = useTheme() 11 | 12 | return ( 13 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/components/shared/confirm-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, ReactNode } from 'react' 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogCancel, 6 | AlertDialogContent, 7 | AlertDialogDescription, 8 | AlertDialogFooter, 9 | AlertDialogHeader, 10 | AlertDialogTitle, 11 | AlertDialogTrigger 12 | } from '../ui/alert-dialog' 13 | import { Button } from '../ui/button' 14 | 15 | import { cn } from '@/src/lib/utils' 16 | 17 | interface ConfirmDialogProps { 18 | open: boolean 19 | onOpenChange: (open: boolean) => void 20 | title: ReactNode 21 | description: JSX.Element | string 22 | cancelBtnText?: string 23 | confirmText?: ReactNode 24 | destructive?: boolean 25 | handleConfirm: () => void 26 | isLoading?: boolean 27 | isDisabled?: boolean 28 | className?: string 29 | children?: ReactNode 30 | } 31 | 32 | export function ConfirmDialog({ 33 | title, 34 | description, 35 | children, 36 | className, 37 | confirmText, 38 | cancelBtnText, 39 | destructive, 40 | isLoading, 41 | isDisabled = false, 42 | handleConfirm, 43 | ...actions 44 | }: ConfirmDialogProps) { 45 | return ( 46 | 47 | {children} 48 | 49 | 50 | {title} 51 | 52 |
{description}
53 |
54 |
55 | 56 | 57 | {cancelBtnText ?? 'Отмена'} 58 | 59 | 67 | 68 |
69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /src/components/shared/course-progress.tsx: -------------------------------------------------------------------------------- 1 | import { Progress } from '../ui/progress' 2 | 3 | import { cn } from '@/src/lib/utils' 4 | 5 | interface CourseProgressProps { 6 | progress: number 7 | variant?: 'default' | 'success' 8 | size?: 'default' | 'sm' 9 | isShowPercentage?: boolean 10 | label?: string 11 | className?: string 12 | } 13 | 14 | export function CourseProgress({ 15 | progress, 16 | variant, 17 | size, 18 | isShowPercentage, 19 | label, 20 | className 21 | }: CourseProgressProps) { 22 | return ( 23 |
24 |
25 | {label && ( 26 | {label} 27 | )} 28 | {isShowPercentage && ( 29 | 30 | {progress}% 31 | 32 | )} 33 |
34 | div]:bg-emerald-600' 41 | : '[&>div]:bg-blue-500' 42 | )} 43 | /> 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/components/shared/ellipsis-loader.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { motion } from 'framer-motion' 4 | 5 | export function EllipsisLoader() { 6 | return ( 7 |
8 | {[...Array(3)].map((_, i) => ( 9 | 22 | ))} 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/shared/heading.tsx: -------------------------------------------------------------------------------- 1 | interface HeadingProps { 2 | title: string 3 | description: string 4 | } 5 | 6 | export function Heading({ title, description }: HeadingProps) { 7 | return ( 8 |
9 |

{title}

10 |

{description}

11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/shared/logo.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | className?: string 3 | color?: string 4 | } 5 | 6 | export function Logo({ 7 | className = 'size-14', 8 | color = 'fill-blue-500' 9 | }: Props) { 10 | return ( 11 | 18 | 19 | 23 | 27 | 31 | 32 | 33 | 34 | 40 | 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/shared/player.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import KinescopePlayer, { 4 | PlayerPropsTypes 5 | } from '@kinescope/react-kinescope-player' 6 | import type { RefObject } from 'react' 7 | 8 | interface PlayerProps extends PlayerPropsTypes { 9 | forwardRef?: RefObject 10 | } 11 | 12 | export function Player({ forwardRef, ...props }: PlayerProps) { 13 | return 14 | } 15 | 16 | export { KinescopePlayer } 17 | -------------------------------------------------------------------------------- /src/components/shared/sonner.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { ComponentProps } from 'react' 4 | import { Toaster as Sonner } from 'sonner' 5 | 6 | type ToasterProps = ComponentProps 7 | 8 | export function Toaster({ ...props }: ToasterProps) { 9 | return ( 10 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from 'class-variance-authority' 2 | import { type HTMLAttributes, forwardRef } from 'react' 3 | 4 | import { cn } from '@/src/lib/utils' 5 | 6 | const alertVariants = cva( 7 | 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-background text-foreground', 12 | warning: 13 | 'border-yellow-600/30 bg-yellow-50 text-yellow-800 [&>svg]:text-yellow-800 dark:bg-yellow-400/10 dark:text-yellow-500 dark:border-yellow-400/20', 14 | destructive: 15 | 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive' 16 | } 17 | }, 18 | defaultVariants: { 19 | variant: 'default' 20 | } 21 | } 22 | ) 23 | 24 | const Alert = forwardRef< 25 | HTMLDivElement, 26 | HTMLAttributes & VariantProps 27 | >(({ className, variant, ...props }, ref) => ( 28 |
34 | )) 35 | Alert.displayName = 'Alert' 36 | 37 | const AlertTitle = forwardRef< 38 | HTMLParagraphElement, 39 | HTMLAttributes 40 | >(({ className, ...props }, ref) => ( 41 |
49 | )) 50 | AlertTitle.displayName = 'AlertTitle' 51 | 52 | const AlertDescription = forwardRef< 53 | HTMLParagraphElement, 54 | HTMLAttributes 55 | >(({ className, ...props }, ref) => ( 56 |
61 | )) 62 | AlertDescription.displayName = 'AlertDescription' 63 | 64 | export { Alert, AlertDescription, AlertTitle } 65 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as AvatarPrimitive from '@radix-ui/react-avatar' 2 | import { 3 | type ComponentPropsWithoutRef, 4 | type ComponentRef, 5 | forwardRef 6 | } from 'react' 7 | 8 | import { cn } from '@/src/lib/utils' 9 | 10 | const Avatar = forwardRef< 11 | ComponentRef, 12 | ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | Avatar.displayName = AvatarPrimitive.Root.displayName 24 | 25 | const AvatarImage = forwardRef< 26 | ComponentRef, 27 | ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 34 | )) 35 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 36 | 37 | const AvatarFallback = forwardRef< 38 | ComponentRef, 39 | ComponentPropsWithoutRef 40 | >(({ className, ...props }, ref) => ( 41 | 49 | )) 50 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 51 | 52 | export { Avatar, AvatarFallback, AvatarImage } 53 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from 'class-variance-authority' 2 | import { type ComponentPropsWithoutRef, forwardRef } from 'react' 3 | 4 | import { cn } from '@/src/lib/utils' 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center gap-x-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset', 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | 'bg-blue-50 text-blue-900 ring-blue-600/30 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30', 13 | neutral: 14 | 'bg-gray-50 text-gray-900 ring-gray-500/30 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20', 15 | success: 16 | 'bg-emerald-50 text-emerald-900 ring-emerald-600/30 dark:bg-emerald-400/10 dark:text-emerald-400 dark:ring-emerald-400/20', 17 | error: 'bg-red-50 text-red-900 ring-red-600/20 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20', 18 | warning: 19 | 'bg-yellow-50 text-yellow-900 ring-yellow-600/30 dark:bg-yellow-400/10 dark:text-yellow-500 dark:ring-yellow-400/20' 20 | } 21 | }, 22 | defaultVariants: { 23 | variant: 'default' 24 | } 25 | } 26 | ) 27 | 28 | interface BadgeProps 29 | extends ComponentPropsWithoutRef<'span'>, 30 | VariantProps {} 31 | 32 | const Badge = forwardRef( 33 | ({ className, variant, ...props }: BadgeProps, forwardedRef) => { 34 | return ( 35 | 40 | ) 41 | } 42 | ) 43 | 44 | Badge.displayName = 'Badge' 45 | 46 | export { Badge, badgeVariants, type BadgeProps } 47 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot, Slottable } from '@radix-ui/react-slot' 2 | import { type VariantProps, cva } from 'class-variance-authority' 3 | import { Loader2 } from 'lucide-react' 4 | import { type ButtonHTMLAttributes, type ReactNode, forwardRef } from 'react' 5 | 6 | import { cn } from '@/src/lib/utils' 7 | 8 | const buttonVariants = cva( 9 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap select-none rounded-lg transition-all will-change-transform active:hover:scale-[0.98] active:hover:transform text-sm font-medium ring-offset-background focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 10 | { 11 | variants: { 12 | variant: { 13 | default: 14 | 'bg-primary text-primary-foreground hover:bg-primary/90', 15 | destructive: 16 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 17 | outline: 18 | 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground', 19 | secondary: 20 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 21 | ghost: 'hover:bg-accent hover:text-accent-foreground', 22 | link: 'text-primary underline-offset-4 hover:underline', 23 | primary: 24 | 'bg-blue-600 text-primary-foreground hover:bg-blue-600/90' 25 | }, 26 | size: { 27 | default: 'h-10 px-4 py-2', 28 | sm: 'h-9 px-4 py-2', 29 | xs: 'h-9 rounded-lg px-3 text-xs', 30 | lg: 'h-11 rounded-lg px-8', 31 | icon: 'h-10 w-10' 32 | } 33 | }, 34 | defaultVariants: { 35 | variant: 'default', 36 | size: 'default' 37 | } 38 | } 39 | ) 40 | 41 | export interface ButtonProps 42 | extends ButtonHTMLAttributes, 43 | VariantProps { 44 | asChild?: boolean 45 | isLoading?: boolean 46 | children?: ReactNode 47 | } 48 | 49 | const Button = forwardRef( 50 | ( 51 | { 52 | className, 53 | variant, 54 | size, 55 | children, 56 | isLoading = false, 57 | asChild = false, 58 | ...props 59 | }, 60 | ref 61 | ) => { 62 | const Comp = asChild ? Slot : 'button' 63 | return ( 64 | 73 | {isLoading && } 74 | {children} 75 | 76 | ) 77 | } 78 | ) 79 | Button.displayName = 'Button' 80 | 81 | export { Button, buttonVariants } 82 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import { type HTMLAttributes, forwardRef } from 'react' 2 | 3 | import { cn } from '@/src/lib/utils' 4 | 5 | const Card = forwardRef>( 6 | ({ className, ...props }, ref) => ( 7 |
15 | ) 16 | ) 17 | Card.displayName = 'Card' 18 | 19 | const CardHeader = forwardRef>( 20 | ({ className, ...props }, ref) => ( 21 |
26 | ) 27 | ) 28 | CardHeader.displayName = 'CardHeader' 29 | 30 | const CardTitle = forwardRef>( 31 | ({ className, ...props }, ref) => ( 32 |
40 | ) 41 | ) 42 | CardTitle.displayName = 'CardTitle' 43 | 44 | const CardDescription = forwardRef< 45 | HTMLDivElement, 46 | HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = 'CardDescription' 55 | 56 | const CardContent = forwardRef>( 57 | ({ className, ...props }, ref) => ( 58 |
59 | ) 60 | ) 61 | CardContent.displayName = 'CardContent' 62 | 63 | const CardFooter = forwardRef>( 64 | ({ className, ...props }, ref) => ( 65 |
70 | ) 71 | ) 72 | CardFooter.displayName = 'CardFooter' 73 | 74 | export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } 75 | -------------------------------------------------------------------------------- /src/components/ui/input-otp.tsx: -------------------------------------------------------------------------------- 1 | import { OTPInput, OTPInputContext } from 'input-otp' 2 | import { Minus } from 'lucide-react' 3 | import { 4 | type ComponentPropsWithoutRef, 5 | type ComponentRef, 6 | forwardRef, 7 | useContext 8 | } from 'react' 9 | 10 | import { cn } from '@/src/lib/utils' 11 | 12 | const InputOTP = forwardRef< 13 | ComponentRef, 14 | ComponentPropsWithoutRef 15 | >(({ className, containerClassName, ...props }, ref) => ( 16 | 25 | )) 26 | InputOTP.displayName = 'InputOTP' 27 | 28 | const InputOTPGroup = forwardRef< 29 | ComponentRef<'div'>, 30 | ComponentPropsWithoutRef<'div'> 31 | >(({ className, ...props }, ref) => ( 32 |
37 | )) 38 | InputOTPGroup.displayName = 'InputOTPGroup' 39 | 40 | const InputOTPSlot = forwardRef< 41 | ComponentRef<'div'>, 42 | ComponentPropsWithoutRef<'div'> & { index: number } 43 | >(({ index, className, ...props }, ref) => { 44 | const inputOTPContext = useContext(OTPInputContext) 45 | const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] 46 | 47 | return ( 48 |
58 | {char} 59 | {hasFakeCaret && ( 60 |
61 |
62 |
63 | )} 64 |
65 | ) 66 | }) 67 | InputOTPSlot.displayName = 'InputOTPSlot' 68 | 69 | const InputOTPSeparator = forwardRef< 70 | ComponentRef<'div'>, 71 | ComponentPropsWithoutRef<'div'> 72 | >(({ ...props }, ref) => ( 73 |
74 | 75 |
76 | )) 77 | InputOTPSeparator.displayName = 'InputOTPSeparator' 78 | 79 | export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } 80 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import { Eye, EyeOff } from 'lucide-react' 2 | import { type ComponentProps, forwardRef, useState } from 'react' 3 | 4 | import { cn } from '@/src/lib/utils' 5 | 6 | const Input = forwardRef>( 7 | ({ className, type, ...props }, ref) => { 8 | const [typeState, setTypeState] = useState(type) 9 | 10 | const isPassword = type === 'password' 11 | 12 | return ( 13 |
14 | 24 | {isPassword && ( 25 |
30 | 62 |
63 | )} 64 |
65 | ) 66 | } 67 | ) 68 | Input.displayName = 'Input' 69 | 70 | export { Input } 71 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from '@radix-ui/react-label' 2 | import { type VariantProps, cva } from 'class-variance-authority' 3 | import { 4 | type ComponentPropsWithoutRef, 5 | type ComponentRef, 6 | forwardRef 7 | } from 'react' 8 | 9 | import { cn } from '@/src/lib/utils' 10 | 11 | const labelVariants = cva( 12 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 13 | ) 14 | 15 | const Label = forwardRef< 16 | ComponentRef, 17 | ComponentPropsWithoutRef & 18 | VariantProps 19 | >(({ className, ...props }, ref) => ( 20 | 25 | )) 26 | Label.displayName = LabelPrimitive.Root.displayName 27 | 28 | export { Label } 29 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as ProgressPrimitive from '@radix-ui/react-progress' 4 | import { 5 | type ComponentPropsWithoutRef, 6 | type ComponentRef, 7 | forwardRef 8 | } from 'react' 9 | 10 | import { cn } from '@/src/lib/utils' 11 | 12 | const Progress = forwardRef< 13 | ComponentRef, 14 | ComponentPropsWithoutRef 15 | >(({ className, value, ...props }, ref) => ( 16 | 24 | 28 | 29 | )) 30 | Progress.displayName = ProgressPrimitive.Root.displayName 31 | 32 | export { Progress } 33 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' 4 | import { 5 | type ComponentPropsWithoutRef, 6 | type ComponentRef, 7 | forwardRef 8 | } from 'react' 9 | 10 | import { cn } from '@/src/lib/utils' 11 | 12 | const ScrollArea = forwardRef< 13 | ComponentRef, 14 | ComponentPropsWithoutRef 15 | >(({ className, children, ...props }, ref) => ( 16 | 21 | 22 | {children} 23 | 24 | 25 | 26 | 27 | )) 28 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 29 | 30 | const ScrollBar = forwardRef< 31 | ComponentRef, 32 | ComponentPropsWithoutRef 33 | >(({ className, orientation = 'vertical', ...props }, ref) => ( 34 | 47 | 48 | 49 | )) 50 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 51 | 52 | export { ScrollArea, ScrollBar } 53 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as SeparatorPrimitive from '@radix-ui/react-separator' 2 | import { 3 | type ComponentPropsWithoutRef, 4 | type ComponentRef, 5 | forwardRef 6 | } from 'react' 7 | 8 | import { cn } from '@/src/lib/utils' 9 | 10 | const Separator = forwardRef< 11 | ComponentRef, 12 | ComponentPropsWithoutRef 13 | >( 14 | ( 15 | { className, orientation = 'horizontal', decorative = true, ...props }, 16 | ref 17 | ) => ( 18 | 31 | ) 32 | ) 33 | Separator.displayName = SeparatorPrimitive.Root.displayName 34 | 35 | export { Separator } 36 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import type { HTMLAttributes } from 'react' 2 | 3 | import { cn } from '@/src/lib/utils' 4 | 5 | function Skeleton({ className, ...props }: HTMLAttributes) { 6 | return ( 7 |
11 | ) 12 | } 13 | 14 | export { Skeleton } 15 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as SwitchPrimitives from '@radix-ui/react-switch' 4 | import { 5 | type ComponentPropsWithoutRef, 6 | type ComponentRef, 7 | forwardRef 8 | } from 'react' 9 | 10 | import { cn } from '@/src/lib/utils' 11 | 12 | const Switch = forwardRef< 13 | ComponentRef, 14 | ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | 29 | 30 | )) 31 | Switch.displayName = SwitchPrimitives.Root.displayName 32 | 33 | export { Switch } 34 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as TabsPrimitives from '@radix-ui/react-tabs' 2 | import { 3 | type ComponentPropsWithoutRef, 4 | type ComponentRef, 5 | createContext, 6 | forwardRef, 7 | useContext 8 | } from 'react' 9 | 10 | import { cn, focusRing } from '@/src/lib/utils' 11 | 12 | const Tabs = ( 13 | props: Omit< 14 | ComponentPropsWithoutRef, 15 | 'orientation' 16 | > 17 | ) => { 18 | return 19 | } 20 | 21 | Tabs.displayName = 'Tabs' 22 | 23 | type TabsListVariant = 'line' | 'solid' 24 | 25 | const TabsListVariantContext = createContext('line') 26 | 27 | interface TabsListProps 28 | extends ComponentPropsWithoutRef { 29 | variant?: TabsListVariant 30 | } 31 | 32 | const variantStyles: Record = { 33 | line: cn('flex items-center justify-start border-b, border-gray-200'), 34 | solid: cn( 35 | 'inline-flex items-center justify-center rounded-md p-1 bg-gray-100' 36 | ) 37 | } 38 | 39 | const TabsList = forwardRef< 40 | ComponentRef, 41 | TabsListProps 42 | >(({ className, variant = 'line', children, ...props }, forwardedRef) => ( 43 | 48 | 49 | {children} 50 | 51 | 52 | )) 53 | 54 | TabsList.displayName = 'TabsList' 55 | 56 | function getVariantStyles(tabVariant: TabsListVariant) { 57 | switch (tabVariant) { 58 | case 'line': 59 | return '-mb-px items-center justify-center whitespace-nowrap border-b-2 border-muted-foreground px-3 pb-2 text-sm font-medium transition-all text-muted-foreground hover:text-muted-foreground/80 hover:border-muted-foreground/80 data-[state=active]:border-blue-600 data-[state=active]:text-blue-600 data-[disabled]:pointer-events-none data-[disabled]:text-gray-300' 60 | case 'solid': 61 | return 'inline-flex items-center justify-center whitespace-nowrap rounded px-3 py-1 text-sm font-medium ring-1 ring-inset transition-all text-muted-foreground hover:text-gray-700 ring-transparent data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow data-[disabled]:pointer-events-none data-[disabled]:text-gray-400 data-[disabled]:opacity-50' 62 | } 63 | } 64 | 65 | const TabsTrigger = forwardRef< 66 | ComponentRef, 67 | ComponentPropsWithoutRef 68 | >(({ className, children, ...props }, forwardedRef) => { 69 | const variant = useContext(TabsListVariantContext) 70 | return ( 71 | 76 | {children} 77 | 78 | ) 79 | }) 80 | 81 | TabsTrigger.displayName = 'TabsTrigger' 82 | 83 | const TabsContent = forwardRef< 84 | ComponentRef, 85 | ComponentPropsWithoutRef 86 | >(({ className, ...props }, forwardedRef) => ( 87 | 92 | )) 93 | 94 | TabsContent.displayName = 'TabsContent' 95 | 96 | export { Tabs, TabsContent, TabsList, TabsTrigger } 97 | -------------------------------------------------------------------------------- /src/constants/app.ts: -------------------------------------------------------------------------------- 1 | export const APP_CONFIG = { 2 | baseUrl: process.env['APP_URL'], 3 | apiUrl: process.env['API_URL'], 4 | storageUrl: process.env['STORAGE_URL'] 5 | } as const 6 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app' 2 | export * from './seo' 3 | export * from './routes' 4 | -------------------------------------------------------------------------------- /src/constants/routes.ts: -------------------------------------------------------------------------------- 1 | export const ROUTES = { 2 | // Common 3 | home: '/', 4 | about: '/about', 5 | 6 | // Auth 7 | login: '/auth/login', 8 | register: '/auth/register', 9 | recovery: '/auth/recovery', 10 | 11 | // Documents 12 | privacy: '/document/privacy-policy', 13 | terms: '/document/terms-of-use', 14 | 15 | // Courses 16 | courses: '/courses', 17 | course: (slug: string) => `/courses/${slug}`, 18 | lesson: (slug: string) => `/lesson/${slug}`, 19 | 20 | // Account 21 | progress: '/account', 22 | settings: '/account/settings', 23 | sessions: '/account/sessions', 24 | connections: '/account/connections' 25 | } 26 | -------------------------------------------------------------------------------- /src/constants/seo.ts: -------------------------------------------------------------------------------- 1 | import { APP_CONFIG } from './app' 2 | 3 | export const SEO = { 4 | name: 'TeaCoder', 5 | description: '', 6 | url: APP_CONFIG.baseUrl, 7 | keywords: [ 8 | 'веб-разработка', 9 | 'курсы по программированию', 10 | 'онлайн-курсы веб-разработки', 11 | 'программирование для начинающих', 12 | 'веб-технологии', 13 | 'фронтенд разработка', 14 | 'бэкенд разработка', 15 | 'создание сайтов', 16 | 'web development', 17 | 'programming courses', 18 | 'web development online courses', 19 | 'programming for beginners', 20 | 'web technologies', 21 | 'frontend development', 22 | 'backend development', 23 | 'website creation' 24 | ], 25 | formatDetection: { 26 | email: false, 27 | address: false, 28 | telephone: false 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace NodeJS { 4 | interface ProcessEnv { 5 | NODE_ENV: 'development' | 'production' | 'test' 6 | APP_PORT: string 7 | APP_URL: string 8 | COOKIE_DOMAIN: string 9 | API_URL: string 10 | API_DOCS: string 11 | STORAGE_URL: string 12 | TURNSTILE_SITE_KEY: string 13 | YANDEX_METRIKA_ID: string 14 | GOOGLE_ANALYTICS_ID: string 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/generated/accountResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface AccountResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Display name */ 13 | displayName: string; 14 | /** Email address */ 15 | email: string; 16 | /** Identifier of the user avatar */ 17 | avatar: string; 18 | /** Indicates whether the user has verified their email address */ 19 | isEmailVerified: boolean; 20 | } 21 | -------------------------------------------------------------------------------- /src/generated/activeRestrictionResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface ActiveRestrictionResponse { 10 | /** Date of restriction creation */ 11 | createdAt: string; 12 | /** Reason for the user restriction */ 13 | reason: string; 14 | /** End date of the restriction, if temporary */ 15 | until?: string; 16 | /** Information on whether the ban is permanent */ 17 | isPermanent: boolean; 18 | } 19 | -------------------------------------------------------------------------------- /src/generated/changeEmailRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface ChangeEmailRequest { 10 | /** Email address */ 11 | email: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/generated/changePasswordRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface ChangePasswordRequest { 10 | /** 11 | * New password 12 | * @minLength 6 13 | * @maxLength 128 14 | */ 15 | newPassword: string; 16 | /** 17 | * Confirmation of the new password 18 | * @minLength 6 19 | * @maxLength 128 20 | */ 21 | confirmPassword: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/generated/courseProgressResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CourseProgressResponse { 10 | /** Total number of lessons in the course */ 11 | totalLessons: number; 12 | /** Number of lessons completed by the user */ 13 | completedLessons: number; 14 | /** User progress in the course as a percentage */ 15 | progressPercentage: number; 16 | } 17 | -------------------------------------------------------------------------------- /src/generated/courseResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CourseResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Title of the course */ 13 | title: string; 14 | /** Slug of the course (unique URL identifier) */ 15 | slug: string; 16 | /** 17 | * Description of the course 18 | * @nullable 19 | */ 20 | description: string | null; 21 | /** 22 | * Identifier of the course thumbnail 23 | * @nullable 24 | */ 25 | thumbnail: string | null; 26 | /** 27 | * YouTube URL for course content or trailer 28 | * @nullable 29 | */ 30 | youtubeUrl: string | null; 31 | /** 32 | * Identifier for the course code repository 33 | * @nullable 34 | */ 35 | attachment: string | null; 36 | /** Whether the course is published or not */ 37 | isPublished: boolean; 38 | /** Number of views the course has */ 39 | views: number; 40 | /** Date when the course was created */ 41 | createdAt: string; 42 | /** Date when the course was last updated */ 43 | updatedAt: string; 44 | /** Number of lessons in the course */ 45 | lessons: number; 46 | } 47 | -------------------------------------------------------------------------------- /src/generated/createCourseRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateCourseRequest { 10 | /** Title of the course */ 11 | title: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/generated/createCourseResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateCourseResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/generated/createLessonRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateLessonRequest { 10 | /** Lesson title */ 11 | title: string; 12 | /** Course ID to which the lesson belongs */ 13 | courseId: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/generated/createLessonResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateLessonResponse { 10 | /** Unique lesson identifier */ 11 | id: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/generated/createProgressRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateProgressRequest { 10 | /** Indicates whether the lesson is completed */ 11 | isCompleted: boolean; 12 | /** Unique identifier of the lesson */ 13 | lessonId: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/generated/createProgressResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateProgressResponse { 10 | /** Next lesson identifier or null if no next lesson exists */ 11 | nextLesson: string; 12 | /** Indicates whether the lesson is completed */ 13 | isCompleted: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /src/generated/createRestrictionRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { CreateRestrictionRequestReason } from './createRestrictionRequestReason'; 9 | 10 | export interface CreateRestrictionRequest { 11 | /** Reason for banning the user */ 12 | reason: CreateRestrictionRequestReason; 13 | /** Date until the ban is active. If not provided, the ban is indefinite */ 14 | until?: string; 15 | /** ID of the user to be banned */ 16 | userId: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/generated/createRestrictionRequestReason.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Reason for banning the user 11 | */ 12 | export type CreateRestrictionRequestReason = typeof CreateRestrictionRequestReason[keyof typeof CreateRestrictionRequestReason]; 13 | 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-redeclare 16 | export const CreateRestrictionRequestReason = { 17 | INAPPROPRIATE_USERNAME: 'INAPPROPRIATE_USERNAME', 18 | SPAM: 'SPAM', 19 | OFFENSIVE_BEHAVIOR: 'OFFENSIVE_BEHAVIOR', 20 | } as const; 21 | -------------------------------------------------------------------------------- /src/generated/createUserRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateUserRequest { 10 | /** Display name */ 11 | name: string; 12 | /** Email address */ 13 | email: string; 14 | /** 15 | * Password 16 | * @minLength 6 17 | * @maxLength 128 18 | */ 19 | password: string; 20 | /** Captcha verification code */ 21 | captcha: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/generated/createUserResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateUserResponse { 10 | /** Unique session identifier */ 11 | id: string; 12 | /** Session token */ 13 | token: string; 14 | /** Unique user identifier */ 15 | userId: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/generated/externalConnectResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface ExternalConnectResponse { 10 | /** The URL for authorization via the external provider (e.g., Google, GitHub) */ 11 | url: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/generated/externalControllerCallbackParams.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export type ExternalControllerCallbackParams = { 10 | code: string 11 | state: string 12 | } 13 | -------------------------------------------------------------------------------- /src/generated/externalStatusResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface ExternalStatusResponse { 10 | /** Indicates whether the GitHub account is connected */ 11 | github: boolean; 12 | /** Indicates whether the Google account is connected */ 13 | google: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /src/generated/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export * from './accountResponse'; 10 | export * from './activeRestrictionResponse'; 11 | export * from './changeEmailRequest'; 12 | export * from './changePasswordRequest'; 13 | export * from './courseProgressResponse'; 14 | export * from './courseResponse'; 15 | export * from './createCourseRequest'; 16 | export * from './createCourseResponse'; 17 | export * from './createLessonRequest'; 18 | export * from './createLessonResponse'; 19 | export * from './createProgressRequest'; 20 | export * from './createProgressResponse'; 21 | export * from './createRestrictionRequest'; 22 | export * from './createRestrictionRequestReason'; 23 | export * from './createUserRequest'; 24 | export * from './createUserResponse'; 25 | export * from './externalConnectResponse'; 26 | export * from './externalControllerCallbackParams'; 27 | export * from './externalStatusResponse'; 28 | export * from './lastLessonResponse'; 29 | export * from './leaderResponse'; 30 | export * from './lessonResponse'; 31 | export * from './loginMfaResponse'; 32 | export * from './loginRequest'; 33 | export * from './loginSessionResponse'; 34 | export * from './meProgressResponse'; 35 | export * from './meProgressResponseLastLesson'; 36 | export * from './meStatisticsResponse'; 37 | export * from './mfaControllerVerifyBody'; 38 | export * from './mfaStatusResponse'; 39 | export * from './mfaVerifyRequest'; 40 | export * from './passwordResetRequest'; 41 | export * from './patchUserRequest'; 42 | export * from './progressResponse'; 43 | export * from './registrationsResponse'; 44 | export * from './sendPasswordResetRequest'; 45 | export * from './sessionControllerLogin200'; 46 | export * from './sessionControllerLoginAdmin200'; 47 | export * from './sessionResponse'; 48 | export * from './statisticsResponse'; 49 | export * from './totpDisableRequest'; 50 | export * from './totpEnableRequest'; 51 | export * from './totpGenerateSecretResponse'; 52 | export * from './userResponse'; -------------------------------------------------------------------------------- /src/generated/lastLessonResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface LastLessonResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Unique lesson slug */ 13 | slug: string; 14 | /** Lesson position in course */ 15 | position: number; 16 | } 17 | -------------------------------------------------------------------------------- /src/generated/leaderResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface LeaderResponse { 10 | /** Unique identifier of the user */ 11 | id: string; 12 | /** Display name of the user */ 13 | displayName: string; 14 | /** User avatar URL or identifier */ 15 | avatar: string; 16 | /** Points accumulated by the user */ 17 | points: number; 18 | } 19 | -------------------------------------------------------------------------------- /src/generated/lessonResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { CourseResponse } from './courseResponse'; 9 | 10 | export interface LessonResponse { 11 | /** Unique identifier */ 12 | id: string; 13 | /** Lesson title */ 14 | title: string; 15 | /** Unique lesson slug */ 16 | slug: string; 17 | /** Lesson description */ 18 | description: string; 19 | /** Lesson position in course */ 20 | position: number; 21 | /** Kinescope video ID */ 22 | kinescopeId: string; 23 | /** Is lesson published? */ 24 | isPublished: boolean; 25 | /** Course the lesson belongs to */ 26 | course: CourseResponse; 27 | /** Course ID the lesson belongs to */ 28 | courseId: string; 29 | /** Lesson creation date */ 30 | createdAt: string; 31 | /** Lesson last update date */ 32 | updatedAt: string; 33 | } 34 | -------------------------------------------------------------------------------- /src/generated/loginMfaResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface LoginMfaResponse { 10 | /** MFA ticket for further verification */ 11 | ticket: string; 12 | /** Allowed MFA methods */ 13 | allowedMethods: string[]; 14 | /** Unique user identifier */ 15 | userId: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/generated/loginRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface LoginRequest { 10 | /** Email address */ 11 | email: string; 12 | /** 13 | * Password 14 | * @minLength 6 15 | * @maxLength 128 16 | */ 17 | password: string; 18 | /** Captcha verification code */ 19 | captcha: string; 20 | } 21 | -------------------------------------------------------------------------------- /src/generated/loginSessionResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface LoginSessionResponse { 10 | /** Unique session identifier */ 11 | id: string; 12 | /** Session token */ 13 | token: string; 14 | /** Unique user identifier */ 15 | userId: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/generated/meProgressResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { MeProgressResponseLastLesson } from './meProgressResponseLastLesson'; 9 | 10 | export interface MeProgressResponse { 11 | /** Уникальный идентификатор курса */ 12 | id: string; 13 | /** Название курса */ 14 | title: string; 15 | /** Общее количество уроков в курсе */ 16 | totalLessons: number; 17 | /** Количество завершенных пользователем уроков */ 18 | completedLessons: number; 19 | /** Прогресс прохождения курса в процентах */ 20 | progress: number; 21 | /** Дата последнего прогресса в курсе (последний доступ) */ 22 | lastAccessed: string; 23 | /** 24 | * Последний просмотренный урок 25 | * @nullable 26 | */ 27 | lastLesson: MeProgressResponseLastLesson; 28 | } 29 | -------------------------------------------------------------------------------- /src/generated/meProgressResponseLastLesson.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { LastLessonResponse } from './lastLessonResponse'; 9 | 10 | /** 11 | * Последний просмотренный урок 12 | * @nullable 13 | */ 14 | export type MeProgressResponseLastLesson = LastLessonResponse | null; 15 | -------------------------------------------------------------------------------- /src/generated/meStatisticsResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface MeStatisticsResponse { 10 | /** Общее количество очков пользователя */ 11 | totalPoints: number; 12 | /** Место пользователя в рейтинге (чем меньше число, тем выше пользователь) */ 13 | ranking: number; 14 | /** Количество завершенных уроков и общее количество уроков (в формате X/Y) */ 15 | lessonsCompleted: string; 16 | /** Прогресс обучения в процентах */ 17 | learningProgressPercentage: number; 18 | /** Количество завершенных курсов (в которых пройдены все уроки) */ 19 | completedCourses: number; 20 | /** Количество курсов, которые находятся в процессе изучения (но еще не завершены) */ 21 | coursesInProgress: number; 22 | } 23 | -------------------------------------------------------------------------------- /src/generated/mfaControllerVerifyBody.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | // @ts-nocheck 9 | import type { MfaRecoveryRequest } from './mfaRecoveryRequest' 10 | import type { MfaTotpRequest } from './mfaTotpRequest' 11 | 12 | export type MfaControllerVerifyBody = MfaTotpRequest | MfaRecoveryRequest 13 | -------------------------------------------------------------------------------- /src/generated/mfaStatusResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface MfaStatusResponse { 10 | /** Indicates if TOTP MFA is enabled for the account */ 11 | totpMfa: boolean; 12 | /** Indicates if recovery codes are active for the account */ 13 | recoveryActive: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /src/generated/mfaVerifyRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface MfaVerifyRequest { 10 | /** MFA Ticket */ 11 | ticket: string; 12 | /** 13 | * 6-digit TOTP code 14 | * @minLength 6 15 | * @maxLength 6 16 | */ 17 | totpCode?: string; 18 | /** 19 | * One of the recovery codes 20 | * @minLength 11 21 | * @maxLength 11 22 | */ 23 | recoveryCode?: string; 24 | } 25 | -------------------------------------------------------------------------------- /src/generated/passwordResetRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface PasswordResetRequest { 10 | /** Reset token */ 11 | token: string; 12 | /** 13 | * New password 14 | * @minLength 6 15 | * @maxLength 128 16 | */ 17 | password: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/generated/patchUserRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface PatchUserRequest { 10 | /** Display name */ 11 | displayName: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/generated/progressResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface ProgressResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Indicates whether the lesson is completed */ 13 | isCompleted: boolean; 14 | /** User ID associated with the progress */ 15 | userId: string; 16 | /** Lesson ID associated with the progress */ 17 | lessonId: string; 18 | /** Date when the progress was created */ 19 | createdAt: string; 20 | /** Date when the progress was last updated */ 21 | updatedAt: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/generated/registrationsResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface RegistrationsResponse { 10 | /** Date of user registrations in YYYY-MM-DD format */ 11 | date: string; 12 | /** Number of users registered on the given date */ 13 | users: number; 14 | } 15 | -------------------------------------------------------------------------------- /src/generated/sendPasswordResetRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface SendPasswordResetRequest { 10 | /** Email associated with the account */ 11 | email: string; 12 | /** Captcha verification code */ 13 | captcha: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/generated/sessionControllerLogin200.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { LoginSessionResponse } from './loginSessionResponse'; 9 | import type { LoginMfaResponse } from './loginMfaResponse'; 10 | 11 | export type SessionControllerLogin200 = LoginSessionResponse | LoginMfaResponse; 12 | -------------------------------------------------------------------------------- /src/generated/sessionControllerLoginAdmin200.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { LoginSessionResponse } from './loginSessionResponse'; 9 | import type { LoginMfaResponse } from './loginMfaResponse'; 10 | 11 | export type SessionControllerLoginAdmin200 = LoginSessionResponse | LoginMfaResponse; 12 | -------------------------------------------------------------------------------- /src/generated/sessionResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface SessionResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Date and time when the session was created */ 13 | createdAt: string; 14 | /** Country from which the login occurred */ 15 | country: string; 16 | /** City from which the login occurred */ 17 | city: string; 18 | /** Name of the browser used */ 19 | browser: string; 20 | /** Operating system of the user */ 21 | os: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/generated/statisticsResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface StatisticsResponse { 10 | /** Total number of users */ 11 | users: number; 12 | /** Total number of courses */ 13 | courses: number; 14 | /** Total number of views across all course */ 15 | views: number; 16 | /** Total number of lessons */ 17 | lessons: number; 18 | } 19 | -------------------------------------------------------------------------------- /src/generated/totpDisableRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface TotpDisableRequest { 10 | /** 11 | * Password 12 | * @minLength 6 13 | * @maxLength 128 14 | */ 15 | password: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/generated/totpEnableRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface TotpEnableRequest { 10 | /** 11 | * PIN code for enabling TOTP 2FA 12 | * @minLength 6 13 | * @maxLength 6 14 | */ 15 | pin: string; 16 | /** TOTP secret key */ 17 | secret: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/generated/totpGenerateSecretResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface TotpGenerateSecretResponse { 10 | /** QR code URL for TOTP setup */ 11 | qrCodeUrl: string; 12 | /** TOTP secret key for generating one-time passwords */ 13 | secret: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/generated/userResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface UserResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Account creation date */ 13 | createdAt: string; 14 | /** Email address */ 15 | email: string; 16 | /** Username */ 17 | username: string; 18 | /** Display name */ 19 | displayName: string; 20 | /** 21 | * Identifier of the user avatar 22 | * @nullable 23 | */ 24 | avatar: string | null; 25 | /** Indicates whether the user is banned */ 26 | isBanned: boolean; 27 | /** Indicates whether multi-factor authentication is enabled */ 28 | isMfaEnabled: boolean; 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-auth' 2 | export * from './use-current' 3 | -------------------------------------------------------------------------------- /src/hooks/use-auth.ts: -------------------------------------------------------------------------------- 1 | import { getSessionToken } from '../lib/cookies/session' 2 | 3 | export function useAuth() { 4 | const token = getSessionToken() 5 | const isAuthorized = token !== undefined && token !== '' 6 | 7 | return { isAuthorized } 8 | } 9 | -------------------------------------------------------------------------------- /src/hooks/use-current.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { usePathname, useRouter } from 'next/navigation' 3 | import { useEffect } from 'react' 4 | 5 | import { instance } from '../api' 6 | import { getMe } from '../api/account' 7 | import { removeSessionToken } from '../lib/cookies/session' 8 | 9 | import { useAuth } from './use-auth' 10 | 11 | export function useCurrent() { 12 | const { isAuthorized } = useAuth() 13 | 14 | const { 15 | data: user, 16 | isLoading, 17 | error 18 | } = useQuery({ 19 | queryKey: ['get current'], 20 | queryFn: () => getMe(), 21 | retry: false, 22 | enabled: isAuthorized 23 | }) 24 | 25 | useEffect(() => { 26 | if (error) { 27 | removeSessionToken() 28 | 29 | delete instance.defaults.headers['X-Session-Token'] 30 | 31 | window.location.reload() 32 | } 33 | }, [error]) 34 | 35 | return { 36 | user, 37 | isLoading, 38 | error 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/client/error.ts: -------------------------------------------------------------------------------- 1 | export class APIError extends Error { 2 | public constructor( 3 | public statusCode: number, 4 | public message: string 5 | ) { 6 | super(message) 7 | 8 | Object.setPrototypeOf(this, new.target.prototype) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/client/types.ts: -------------------------------------------------------------------------------- 1 | export type SearchParams = { 2 | [key: string]: 3 | | string 4 | | number 5 | | boolean 6 | | undefined 7 | | Array 8 | } 9 | 10 | export interface RequestOptions extends RequestInit { 11 | headers?: Record 12 | params?: SearchParams 13 | } 14 | 15 | export type FetchRequestConfig = Params extends undefined 16 | ? { config?: RequestOptions } 17 | : { params: Params; config?: RequestOptions } 18 | -------------------------------------------------------------------------------- /src/lib/cookies/session.ts: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie' 2 | 3 | export const setSessionToken = (token: string) => 4 | Cookies.set('token', token, { 5 | domain: process.env['COOKIE_DOMAIN'], 6 | expires: 30 7 | }) 8 | 9 | export const getSessionToken = () => Cookies.get('token') 10 | 11 | export const removeSessionToken = () => 12 | Cookies.remove('token', { domain: process.env['COOKIE_DOMAIN'] }) 13 | -------------------------------------------------------------------------------- /src/lib/utils/focus-ring.ts: -------------------------------------------------------------------------------- 1 | export const focusRing = [ 2 | 'outline outline-offset-2 outline-0 focus-visible:outline-2', 3 | 'outline-blue-600 dark:outline-blue-600' 4 | ] 5 | -------------------------------------------------------------------------------- /src/lib/utils/format-date.ts: -------------------------------------------------------------------------------- 1 | export function formatDate(date: string | Date): string { 2 | const createdAt = new Date(date) 3 | const formattedDate = new Intl.DateTimeFormat('ru-RU', { 4 | day: '2-digit', 5 | month: 'long', 6 | hour: '2-digit', 7 | minute: '2-digit' 8 | }).format(createdAt) 9 | 10 | const [day, month, year, time] = formattedDate.split(' ') 11 | 12 | return `${day} ${month} в ${time}` 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/utils/get-browser-icon.ts: -------------------------------------------------------------------------------- 1 | import { CircleHelp } from 'lucide-react' 2 | import { 3 | FaChrome, 4 | FaEdge, 5 | FaFirefoxBrowser, 6 | FaOpera, 7 | FaSafari, 8 | FaYandex 9 | } from 'react-icons/fa' 10 | 11 | const browsers = [ 12 | { names: ['chrome'], icon: FaChrome }, 13 | { names: ['firefox'], icon: FaFirefoxBrowser }, 14 | { names: ['safari'], icon: FaSafari }, 15 | { names: ['edge', 'microsoft edge'], icon: FaEdge }, 16 | { names: ['opera'], icon: FaOpera }, 17 | { names: ['yandex', 'yandex browser'], icon: FaYandex } 18 | ] 19 | 20 | export function getBrowserIcon(browser: string) { 21 | const browserName = browser.toLowerCase() 22 | 23 | const found = browsers.find(({ names }) => 24 | names.some(name => browserName.includes(name)) 25 | ) 26 | 27 | return found ? found.icon : CircleHelp 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/utils/get-lesson-label.ts: -------------------------------------------------------------------------------- 1 | export function getLessonLabel(count: number) { 2 | if (count % 10 === 1 && count % 100 !== 11) { 3 | return 'урок' 4 | } else if ( 5 | count % 10 >= 2 && 6 | count % 10 <= 4 && 7 | (count % 100 < 10 || count % 100 >= 20) 8 | ) { 9 | return 'урока' 10 | } else { 11 | return 'уроков' 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/utils/get-media-source.ts: -------------------------------------------------------------------------------- 1 | import { APP_CONFIG } from '../../constants/app' 2 | 3 | export function getMediaSource( 4 | path: string, 5 | tag: 'users' | 'courses' | 'attachments' 6 | ) { 7 | if (!path) { 8 | return '' 9 | } 10 | 11 | if (path.startsWith('https://')) { 12 | return path 13 | } 14 | 15 | return `${APP_CONFIG.storageUrl}/${tag}/${path}` 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './format-date' 2 | export * from './get-browser-icon' 3 | export * from './focus-ring' 4 | export * from './get-media-source' 5 | export * from './tw-merge' 6 | export * from './get-lesson-label' 7 | -------------------------------------------------------------------------------- /src/lib/utils/tw-merge.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest, NextResponse } from 'next/server' 2 | 3 | export default async function middleware(request: NextRequest) { 4 | const { cookies, url } = request 5 | 6 | const token = cookies.get('token')?.value 7 | 8 | const isAuthPage = url.includes('/auth') 9 | const isVerifyPage = url.includes('/auth/verify') 10 | 11 | if (isVerifyPage) { 12 | if (!token) { 13 | return NextResponse.redirect(new URL('/auth/login', url)) 14 | } 15 | return NextResponse.next() 16 | } 17 | 18 | if (isAuthPage) { 19 | if (token) { 20 | return NextResponse.redirect(new URL('/account', url)) 21 | } 22 | return NextResponse.next() 23 | } 24 | 25 | if (!token) { 26 | return NextResponse.redirect(new URL('/auth/login', url)) 27 | } 28 | } 29 | 30 | export const config = { 31 | matcher: ['/auth/:path*', '/account/:path*', '/lesson/:path*'] 32 | } 33 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import http from 'http' 3 | import next from 'next' 4 | 5 | config({ path: '.env' }) 6 | 7 | const PORT = process.env['APP_PORT'] ?? 3000 8 | const app = next({ dev: true }) 9 | const handle = app.getRequestHandler() 10 | 11 | app.prepare().then(() => { 12 | try { 13 | const server = http.createServer((req, res) => { 14 | handle(req, res) 15 | }) 16 | 17 | server.listen(PORT, () => { 18 | console.log(`🚀 Server started on ${process.env['APP_URL']}`) 19 | console.log(`🔧 Backend is running at: ${process.env['API_URL']}`) 20 | }) 21 | } catch (error) { 22 | console.error(`❌ Error starting the server: ${error} 😞`) 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 0% 9%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 3.9%; 34 | 35 | --chart-1: 12 76% 61%; 36 | --chart-2: 173 58% 39%; 37 | --chart-3: 197 37% 24%; 38 | --chart-4: 43 74% 66%; 39 | --chart-5: 27 87% 67%; 40 | 41 | --radius: 0.5rem; 42 | } 43 | 44 | .dark { 45 | --background: 0 0% 10%; 46 | --foreground: 0 0% 98%; 47 | 48 | --card: 0 0% 10%; 49 | --card-foreground: 0 0% 98%; 50 | 51 | --popover: 0 0% 10%; 52 | --popover-foreground: 0 0% 98%; 53 | 54 | --primary: 0 0% 98%; 55 | --primary-foreground: 0 0% 98%; 56 | 57 | --secondary: 0 0% 15%; 58 | --secondary-foreground: 0 0% 98%; 59 | 60 | --muted: 0 0% 15%; 61 | --muted-foreground: 0 0% 63.9%; 62 | 63 | --accent: 0 0% 15%; 64 | --accent-foreground: 0 0% 98%; 65 | 66 | --destructive: 0 84% 60%; 67 | --destructive-foreground: 0 0% 98%; 68 | 69 | --border: 0 0% 20%; 70 | --input: 0 0% 20%; 71 | --ring: 0 0% 83.1%; 72 | 73 | --chart-1: 0 0% 60%; 74 | --chart-2: 0 0% 45%; 75 | --chart-3: 0 0% 30%; 76 | --chart-4: 0 0% 75%; 77 | --chart-5: 0 0% 50%; 78 | } 79 | } 80 | 81 | @layer base { 82 | * { 83 | @apply border-border; 84 | } 85 | 86 | body { 87 | @apply bg-background text-foreground; 88 | 89 | font-family: var(--font-geist-sans), sans-serif; 90 | } 91 | 92 | ::-webkit-scrollbar { 93 | width: 5px; 94 | } 95 | 96 | ::-webkit-scrollbar-track { 97 | background: transparent; 98 | } 99 | 100 | ::-webkit-scrollbar-thumb { 101 | background: hsl(var(--border)); 102 | border-radius: 5px; 103 | } 104 | 105 | * { 106 | scrollbar-width: thin; 107 | scrollbar-color: hsl(var(--border)) transparent; 108 | } 109 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | darkMode: ['class'], 5 | content: [ 6 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 8 | './src/app/**/*.{js,ts,jsx,tsx,mdx}' 9 | ], 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: [ 14 | 'var(--font-geist-sans)' 15 | ] 16 | }, 17 | colors: { 18 | border: 'hsl(var(--border))', 19 | input: 'hsl(var(--input))', 20 | ring: 'hsl(var(--ring))', 21 | background: 'hsl(var(--background))', 22 | foreground: 'hsl(var(--foreground))', 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | destructive: { 32 | DEFAULT: 'hsl(var(--destructive))', 33 | foreground: 'hsl(var(--destructive-foreground))' 34 | }, 35 | muted: { 36 | DEFAULT: 'hsl(var(--muted))', 37 | foreground: 'hsl(var(--muted-foreground))' 38 | }, 39 | accent: { 40 | DEFAULT: 'hsl(var(--accent))', 41 | foreground: 'hsl(var(--accent-foreground))' 42 | }, 43 | popover: { 44 | DEFAULT: 'hsl(var(--popover))', 45 | foreground: 'hsl(var(--popover-foreground))' 46 | }, 47 | card: { 48 | DEFAULT: 'hsl(var(--card))', 49 | foreground: 'hsl(var(--card-foreground))' 50 | }, 51 | chart: { 52 | '1': 'hsl(var(--chart-1))', 53 | '2': 'hsl(var(--chart-2))', 54 | '3': 'hsl(var(--chart-3))', 55 | '4': 'hsl(var(--chart-4))', 56 | '5': 'hsl(var(--chart-5))' 57 | }, 58 | sidebar: { 59 | DEFAULT: 'hsl(var(--sidebar-background))', 60 | foreground: 'hsl(var(--sidebar-foreground))', 61 | primary: 'hsl(var(--sidebar-primary))', 62 | 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', 63 | accent: 'hsl(var(--sidebar-accent))', 64 | 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', 65 | border: 'hsl(var(--sidebar-border))', 66 | ring: 'hsl(var(--sidebar-ring))' 67 | } 68 | }, 69 | borderRadius: { 70 | lg: 'var(--radius)', 71 | md: 'calc(var(--radius) - 2px)', 72 | sm: 'calc(var(--radius) - 4px)' 73 | }, 74 | keyframes: { 75 | 'accordion-down': { 76 | from: { 77 | height: '0' 78 | }, 79 | to: { 80 | height: 'var(--radix-accordion-content-height)' 81 | } 82 | }, 83 | 'accordion-up': { 84 | from: { 85 | height: 'var(--radix-accordion-content-height)' 86 | }, 87 | to: { 88 | height: '0' 89 | } 90 | } 91 | }, 92 | animation: { 93 | 'accordion-down': 'accordion-down 0.2s ease-out', 94 | 'accordion-up': 'accordion-up 0.2s ease-out' 95 | } 96 | } 97 | }, 98 | plugins: [require('tailwindcss-animate')] 99 | } 100 | export default config 101 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "baseUrl": ".", 21 | "paths": { 22 | "@/*": ["./*"] 23 | }, 24 | "target": "ES2017" 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | --------------------------------------------------------------------------------