├── src ├── vite-env.d.ts ├── lib │ └── API.ts ├── pages │ ├── NotFound.tsx │ ├── Loading.tsx │ ├── Error.tsx │ ├── Login.tsx │ ├── Blogs.tsx │ ├── Admins.tsx │ └── Trainers.tsx ├── middlewares │ ├── Axios.tsx │ └── Fetcher.tsx ├── index.css ├── layouts │ └── RootLayout.tsx ├── main.tsx ├── Store │ └── indexStore.tsx ├── Types │ └── indexTypes.tsx ├── Slicers │ └── UserSlicer.tsx ├── components │ ├── ui │ │ ├── Header.tsx │ │ ├── PhotoCarousel.tsx │ │ └── UserDropDown.tsx │ └── shared │ │ ├── AdminModal.tsx │ │ ├── AdminEditModal.tsx │ │ ├── ChangePassword.tsx │ │ ├── BlogModal.tsx │ │ ├── TrainerModal.tsx │ │ ├── TrainerEditModal.tsx │ │ └── BlogEditModal.tsx └── App.tsx ├── postcss.config.js ├── vercel.json ├── tsconfig.json ├── vite.config.ts ├── tailwind.config.js ├── .gitignore ├── index.html ├── tsconfig.node.json ├── tsconfig.app.json ├── eslint.config.js ├── package.json └── README.md /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/API.ts: -------------------------------------------------------------------------------- 1 | // export const BASE_URL = 'http://localhost:3000/api/' 2 | export const BASE_URL = "https://server.7sportcenter.uz/api/" 3 | -------------------------------------------------------------------------------- /src/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const NotFound: React.FC = () => { 4 | return
NotFound
; 5 | }; 6 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/:path*", 5 | "destination": "/index.html" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | 7 | }); 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /src/middlewares/Axios.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { BASE_URL } from "../lib/API"; 3 | 4 | const token = localStorage.getItem("ssctoken") || ""; 5 | 6 | export const Axios = axios.create({ 7 | baseURL: BASE_URL, 8 | headers: { 9 | Authorization: token, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/pages/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Clipboard from "react-spinners/ClipLoader"; 3 | 4 | export const Loading: React.FC = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300..900;1,300..900&display=swap"); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | * { 8 | font-family: "Rubik", serif; 9 | } 10 | 11 | a.active { 12 | text-decoration: underline; 13 | } 14 | -------------------------------------------------------------------------------- /src/middlewares/Fetcher.tsx: -------------------------------------------------------------------------------- 1 | import { BASE_URL } from "../lib/API"; 2 | 3 | const token = localStorage.getItem("ssctoken") || ""; 4 | 5 | export const fetcher = (url: string) => 6 | fetch(`${BASE_URL}${url}`, { 7 | headers: { 8 | Authorization: `${token}`, 9 | }, 10 | }).then((res) => res.json()); 11 | -------------------------------------------------------------------------------- /src/layouts/RootLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom"; 2 | import Header from "../components/ui/Header"; 3 | 4 | function RootLayout() { 5 | return ( 6 | <> 7 |
8 |
9 | 10 |
11 | 12 | ); 13 | } 14 | 15 | export default RootLayout; 16 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import App from "./App.tsx"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Provider } from "react-redux"; 4 | import { store } from "./Store/indexStore.tsx"; 5 | import "./index.css"; 6 | 7 | createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/Store/indexStore.tsx: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import UserSlicer from "../Slicers/UserSlicer"; 3 | 4 | export const store = configureStore({ 5 | reducer: { 6 | user: UserSlicer, 7 | }, 8 | }); 9 | 10 | export type RootState = ReturnType; 11 | export type AppDispatch = typeof store.dispatch; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 7sportcenter 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Types/indexTypes.tsx: -------------------------------------------------------------------------------- 1 | export interface UserTypes { 2 | fullName: string; 3 | password: string; 4 | phoneNumber: string; 5 | _id: string; 6 | } 7 | 8 | export interface TrainerTypes { 9 | photo: null | any; 10 | fullName: string; 11 | experience: string; 12 | level: string; 13 | students: string; 14 | _id: string; 15 | } 16 | 17 | export interface BlogTypes { 18 | title: string; 19 | description: string; 20 | photos: string[]; 21 | _id: string; 22 | createdAt: string; 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/Error.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | export const Error: React.FC = () => { 5 | return ( 6 |
7 |

404

8 |

что-то не так, пожалуйста, вернитесь назад

9 | 13 | Назад 14 | 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "falcon-admin", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@reduxjs/toolkit": "^2.5.0", 14 | "axios": "^1.7.9", 15 | "lucide-react": "^0.469.0", 16 | "react": "^18.3.1", 17 | "react-dom": "^18.3.1", 18 | "react-redux": "^9.2.0", 19 | "react-router-dom": "^7.1.0", 20 | "react-spinners": "^0.15.0", 21 | "swr": "^2.3.2" 22 | }, 23 | "devDependencies": { 24 | "@eslint/js": "^9.17.0", 25 | "@types/react": "^18.3.17", 26 | "@types/react-dom": "^18.3.5", 27 | "@vitejs/plugin-react": "^4.3.4", 28 | "autoprefixer": "^10.4.20", 29 | "eslint": "^9.17.0", 30 | "eslint-plugin-react-hooks": "^5.0.0", 31 | "eslint-plugin-react-refresh": "^0.4.16", 32 | "globals": "^15.13.0", 33 | "postcss": "^8.4.49", 34 | "tailwindcss": "^3.4.17", 35 | "typescript": "~5.6.2", 36 | "typescript-eslint": "^8.18.1", 37 | "vite": "^6.0.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Slicers/UserSlicer.tsx: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { UserTypes } from "../Types/indexTypes"; 3 | 4 | interface UserState { 5 | data: UserTypes; 6 | isPending: boolean; 7 | error: string; 8 | isAuth: boolean; 9 | } 10 | 11 | const initialState: UserState = { 12 | data: { 13 | fullName: "", 14 | password: "", 15 | phoneNumber: "", 16 | _id: "", 17 | }, 18 | isPending: false, 19 | error: "", 20 | isAuth: false, 21 | }; 22 | 23 | const UserSlicer = createSlice({ 24 | name: "User", 25 | initialState, 26 | reducers: { 27 | setUser(state, { payload }: PayloadAction) { 28 | state.data = payload; 29 | state.isPending = false; 30 | state.isAuth = true; 31 | state.error = ""; 32 | }, 33 | setPending(state) { 34 | state.isPending = true; 35 | }, 36 | setError(state, { payload }: PayloadAction) { 37 | state.error = payload; 38 | state.isPending = false; 39 | state.isAuth = false; 40 | }, 41 | }, 42 | }); 43 | 44 | export const { setUser, setPending, setError } = UserSlicer.actions; 45 | export default UserSlicer.reducer; 46 | -------------------------------------------------------------------------------- /src/components/ui/Header.tsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from "react-router-dom"; 2 | import { useState } from "react"; 3 | import UserDropdown from "./UserDropDown"; 4 | import { useSelector } from "react-redux"; 5 | import { RootState } from "../../Store/indexStore"; 6 | import ChangePasswordDialog from "../shared/ChangePassword"; 7 | 8 | function Header() { 9 | const { data } = useSelector((state: RootState) => state.user); 10 | const [showPasswordDialog, setShowPasswordDialog] = useState(false); 11 | 12 | return ( 13 | 28 | ); 29 | } 30 | 31 | export default Header; 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 2 | import RootLayout from "./layouts/RootLayout"; 3 | import { Admins } from "./pages/Admins"; 4 | import { useDispatch, useSelector } from "react-redux"; 5 | import { Axios } from "./middlewares/Axios"; 6 | import { setError, setPending, setUser } from "./Slicers/UserSlicer"; 7 | import { useEffect, useMemo } from "react"; 8 | import { RootState } from "./Store/indexStore"; 9 | import { Loading } from "./pages/Loading"; 10 | import { Login } from "./pages/Login"; 11 | import { Error } from "./pages/Error"; 12 | import { Trainers } from "./pages/Trainers"; 13 | import { Blogs } from "./pages/Blogs"; 14 | 15 | function App() { 16 | const { isPending, isAuth } = useSelector((state: RootState) => state.user); 17 | const dispatch = useDispatch(); 18 | 19 | useEffect(() => { 20 | async function getMyData() { 21 | try { 22 | dispatch(setPending()); 23 | const response = await Axios.get("admin/profile"); 24 | 25 | if (!response.data.message) { 26 | dispatch(setUser(response.data)); 27 | } else { 28 | dispatch(setError(response.data.message)); 29 | } 30 | } catch (error: any) { 31 | dispatch(setError(error.response?.data || "Unknown Token")); 32 | } 33 | } 34 | getMyData(); 35 | }, [dispatch]); 36 | 37 | const router = useMemo(() => { 38 | if (isPending) { 39 | return createBrowserRouter([ 40 | { 41 | path: "/", 42 | element: , 43 | }, 44 | ]); 45 | } 46 | if (isAuth) { 47 | return createBrowserRouter([ 48 | { 49 | path: "/", 50 | element: , 51 | children: [ 52 | { 53 | index: true, 54 | element: , 55 | }, 56 | { 57 | path: "trainers", 58 | element: , 59 | }, 60 | { 61 | path: "blogs", 62 | element: , 63 | }, 64 | { 65 | path: "*", 66 | element: , 67 | }, 68 | ], 69 | }, 70 | ]); 71 | } else { 72 | return createBrowserRouter([ 73 | { 74 | path: "/", 75 | element: , 76 | }, 77 | { 78 | path: "*", 79 | element: , 80 | }, 81 | ]); 82 | } 83 | }, [isAuth, isPending]); 84 | 85 | return ; 86 | } 87 | 88 | export default App; 89 | -------------------------------------------------------------------------------- /src/components/ui/PhotoCarousel.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | import { useState } from "react"; 4 | import { ChevronLeft, ChevronRight } from "lucide-react"; 5 | 6 | interface PhotoCarouselProps { 7 | photos: string[]; 8 | altText?: string; 9 | } 10 | 11 | export const PhotoCarousel: React.FC = ({ 12 | photos, 13 | altText = "", 14 | }) => { 15 | const [currentIndex, setCurrentIndex] = useState(0); 16 | 17 | if (!photos || photos.length === 0) { 18 | return ( 19 |
20 |

Нет фото

21 |
22 | ); 23 | } 24 | 25 | const goToPrevious = () => { 26 | const isFirstSlide = currentIndex === 0; 27 | const newIndex = isFirstSlide ? photos.length - 1 : currentIndex - 1; 28 | setCurrentIndex(newIndex); 29 | }; 30 | 31 | const goToNext = () => { 32 | const isLastSlide = currentIndex === photos.length - 1; 33 | const newIndex = isLastSlide ? 0 : currentIndex + 1; 34 | setCurrentIndex(newIndex); 35 | }; 36 | 37 | const goToSlide = (slideIndex: number) => { 38 | setCurrentIndex(slideIndex); 39 | }; 40 | 41 | return ( 42 |
43 | {photos.length > 1 && ( 44 | <> 45 | 52 | 59 | 60 | )} 61 | 62 |
63 | {altText} 68 |
69 | 70 | {photos.length > 1 && ( 71 |
72 | {photos.map((_, slideIndex) => ( 73 |
83 | )} 84 |
85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/components/ui/UserDropDown.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from "react"; 2 | import { Settings, LogOut } from "lucide-react"; 3 | 4 | interface UserDropdownProps { 5 | user: { 6 | fullName: string; 7 | phoneNumber: string; 8 | } | null; 9 | setShowPasswordDialog: (show: boolean) => void; 10 | } 11 | 12 | const UserDropdown = ({ user, setShowPasswordDialog }: UserDropdownProps) => { 13 | const [isOpen, setIsOpen] = useState(false); 14 | 15 | const dropdownRef = useRef(null); 16 | 17 | const getInitials = (name: string | undefined) => { 18 | if (!name) return "U"; 19 | return name 20 | .split(" ") 21 | .map((word) => word.charAt(0)) 22 | .join("") 23 | .toUpperCase() 24 | .slice(0, 2); 25 | }; 26 | 27 | // Close dropdown when clicking outside 28 | useEffect(() => { 29 | const handleClickOutside = (event: MouseEvent) => { 30 | if ( 31 | dropdownRef.current && 32 | !dropdownRef.current.contains(event.target as Node) 33 | ) { 34 | setIsOpen(false); 35 | } 36 | }; 37 | 38 | document.addEventListener("mousedown", handleClickOutside); 39 | return () => { 40 | document.removeEventListener("mousedown", handleClickOutside); 41 | }; 42 | }, []); 43 | 44 | const handlePasswordChange = () => { 45 | setShowPasswordDialog(true); 46 | setIsOpen(false); 47 | }; 48 | 49 | const handleLogout = () => { 50 | if (window.confirm("Вы реально хотите выйти?")) { 51 | localStorage.removeItem("ssctoken"); 52 | window.location.href = "/"; 53 | } 54 | setIsOpen(false); 55 | }; 56 | 57 | return ( 58 |
59 |
60 | {/* Avatar Button */} 61 | 67 | 68 | {/* Dropdown Menu */} 69 | {isOpen && ( 70 |
71 | {/* User Info */} 72 |
73 |
74 |

75 | {user?.fullName} 76 |

77 |

78 | {user?.phoneNumber} 79 |

80 |
81 |
82 | 83 | {/* Menu Items */} 84 |
85 | 92 | 93 | 100 |
101 |
102 | )} 103 |
104 |
105 | ); 106 | }; 107 | 108 | export default UserDropdown; 109 | -------------------------------------------------------------------------------- /src/pages/Login.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Axios } from "../middlewares/Axios"; 3 | 4 | interface LoginResponse { 5 | token: string; 6 | message: string; 7 | } 8 | 9 | export const Login: React.FC = () => { 10 | const [formData, setFormData] = useState({ 11 | phoneNumber: "", 12 | password: "", 13 | }); 14 | 15 | const [isLoading, setIsLoading] = useState(false); 16 | const [error, setError] = useState(""); 17 | const [errors, setErrors] = useState({ 18 | phoneNumber: "", 19 | password: "", 20 | }); 21 | 22 | const validateForm = () => { 23 | let valid = true; 24 | const newErrors = { phoneNumber: "", password: "" }; 25 | 26 | if (!formData.phoneNumber.trim()) { 27 | newErrors.phoneNumber = "Введите номер телефона"; 28 | valid = false; 29 | } else if (!/^\d{9}$/.test(formData.phoneNumber)) { 30 | newErrors.phoneNumber = "Номер телефона должен содержать ровно 9 цифр"; 31 | valid = false; 32 | } 33 | 34 | if (!formData.password.trim()) { 35 | newErrors.password = "Введите пароль"; 36 | valid = false; 37 | } else if (formData.password.length < 6) { 38 | newErrors.password = "Пароль должен содержать не менее 6 символов"; 39 | valid = false; 40 | } 41 | 42 | setErrors(newErrors); 43 | return valid; 44 | }; 45 | 46 | const handleInputChange = (e: React.ChangeEvent) => { 47 | const { name, value } = e.target; 48 | setFormData((prevData) => ({ ...prevData, [name]: value })); 49 | setErrors((prevErrors) => ({ ...prevErrors, [name]: "" })); 50 | }; 51 | 52 | const handleFormSubmit = async (e: React.FormEvent) => { 53 | e.preventDefault(); 54 | if (!validateForm()) return; 55 | 56 | setIsLoading(true); 57 | try { 58 | const response = ( 59 | await Axios.post("/admin/login", formData) 60 | ).data; 61 | 62 | if (response.token) { 63 | localStorage.setItem("ssctoken", response.token); 64 | window.location.href = "/"; 65 | } 66 | } catch (error: any) { 67 | setError( 68 | error.response?.data?.message || 69 | "Что-то пошло не так, попробуйте ещё раз" 70 | ); 71 | } finally { 72 | setIsLoading(false); 73 | } 74 | }; 75 | 76 | return ( 77 |
78 |
83 |
84 |

85 | 7sportcenter 86 |

87 |

88 | Войдите, чтобы получить доступ к панели управления 89 |

90 |
91 | {error && ( 92 |

93 | {error} 94 |

95 | )} 96 | 115 | 133 | 142 |
143 |
144 | ); 145 | }; 146 | -------------------------------------------------------------------------------- /src/components/shared/AdminModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Axios } from "../../middlewares/Axios"; 3 | 4 | type ModalProps = { 5 | setIsModalActive: React.Dispatch>; 6 | mutate: any; 7 | }; 8 | 9 | export const AdminModal: React.FC = ({ 10 | setIsModalActive, 11 | mutate, 12 | }) => { 13 | const [formData, setFormData] = useState({ 14 | fullName: "", 15 | phoneNumber: "", 16 | password: "", 17 | }); 18 | 19 | const [errors, setErrors] = useState({ 20 | fullName: "", 21 | phoneNumber: "", 22 | password: "", 23 | }); 24 | 25 | const validateForm = () => { 26 | let valid = true; 27 | const newErrors = { fullName: "", phoneNumber: "", password: "" }; 28 | 29 | if (!formData.fullName.trim()) { 30 | newErrors.fullName = "Введите ваше полное имя"; 31 | valid = false; 32 | } 33 | 34 | if (!formData.phoneNumber.trim()) { 35 | newErrors.phoneNumber = "Введите номер телефона"; 36 | valid = false; 37 | } else if (!/^\d{9}$/.test(formData.phoneNumber)) { 38 | newErrors.phoneNumber = "Номер телефона должен содержать ровно 9 цифр"; 39 | valid = false; 40 | } 41 | 42 | if (!formData.password.trim()) { 43 | newErrors.password = "Введите пароль"; 44 | valid = false; 45 | } else if (formData.password.length < 6) { 46 | newErrors.password = "Пароль должен содержать не менее 6 символов"; 47 | valid = false; 48 | } 49 | 50 | setErrors(newErrors); 51 | return valid; 52 | }; 53 | 54 | const handleInputChange = (e: React.ChangeEvent) => { 55 | const { name, value } = e.target; 56 | setFormData((prevData) => ({ ...prevData, [name]: value })); 57 | setErrors((prevErrors) => ({ ...prevErrors, [name]: "" })); 58 | }; 59 | 60 | const handleSubmit = async (e: React.FormEvent) => { 61 | e.preventDefault(); 62 | if (!validateForm()) return; 63 | 64 | try { 65 | const response = await Axios.post("admin/register", formData); 66 | if (response.data) { 67 | alert("Администратор успешно добавлен!"); 68 | setIsModalActive(false); 69 | mutate(); 70 | } 71 | } catch (error: any) { 72 | alert(error.response?.data.message || "Произошла ошибка"); 73 | } 74 | }; 75 | 76 | const handleOutsideClick = (e: React.MouseEvent) => { 77 | if ((e.target as HTMLElement).classList.contains("modal-overlay")) { 78 | setIsModalActive(false); 79 | } 80 | }; 81 | 82 | return ( 83 |
87 |
91 |

92 | Создать нового администратора 93 |

94 | 109 | 126 | 141 |
142 | 149 | 152 |
153 |
154 |
155 | ); 156 | }; 157 | -------------------------------------------------------------------------------- /src/components/shared/AdminEditModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { Axios } from "../../middlewares/Axios"; 3 | import useSWR from "swr"; 4 | import { fetcher } from "../../middlewares/Fetcher"; 5 | import { UserTypes } from "../../Types/indexTypes"; 6 | 7 | type EditDataTypes = { 8 | id: string; 9 | isEditing: boolean; 10 | }; 11 | 12 | type ModalProps = { 13 | editData: EditDataTypes; 14 | setEditData: React.Dispatch>; 15 | }; 16 | 17 | export const AdminEditModal: React.FC = ({ 18 | editData, 19 | setEditData, 20 | }) => { 21 | const { data, error, isLoading, mutate } = useSWR( 22 | `/admin`, 23 | fetcher 24 | ); 25 | 26 | const [formData, setFormData] = useState({ 27 | _id: "", 28 | fullName: "", 29 | phoneNumber: "", 30 | password: "", 31 | }); 32 | 33 | const [errors, setErrors] = useState({ 34 | fullName: "", 35 | phoneNumber: "", 36 | password: "", 37 | }); 38 | 39 | useEffect(() => { 40 | if (data) { 41 | const user = data.find((user) => user._id === editData.id); 42 | if (user) setFormData({ ...user, password: "" }); 43 | } 44 | }, [data, editData.id]); 45 | 46 | const validateForm = () => { 47 | let valid = true; 48 | const newErrors = { fullName: "", phoneNumber: "", password: "" }; 49 | 50 | if (!formData.fullName.trim()) { 51 | newErrors.fullName = "Введите ваше полное имя"; 52 | valid = false; 53 | } 54 | 55 | if (!formData.phoneNumber.trim()) { 56 | newErrors.phoneNumber = "Введите номер телефона"; 57 | valid = false; 58 | } else if (!/^\d{9}$/.test(formData.phoneNumber)) { 59 | newErrors.phoneNumber = "Номер телефона должен содержать ровно 9 цифр"; 60 | valid = false; 61 | } 62 | 63 | if (!formData.password.trim()) { 64 | newErrors.password = "Пароль обязателен"; 65 | valid = false; 66 | } else if (formData.password.length < 6) { 67 | newErrors.password = "Пароль должен содержать не менее 6 символов"; 68 | valid = false; 69 | } 70 | 71 | setErrors(newErrors); 72 | return valid; 73 | }; 74 | 75 | const handleSubmit = async (e: React.FormEvent) => { 76 | e.preventDefault(); 77 | if (!validateForm()) return; 78 | 79 | try { 80 | const response = await Axios.put(`admin/${editData.id}`, formData); 81 | if (response.data) { 82 | setEditData((prevData) => ({ ...prevData, isEditing: false })); 83 | mutate(); 84 | } 85 | } catch (error: any) { 86 | alert(error.response?.data.message || "Произошла ошибка"); 87 | } 88 | }; 89 | 90 | const handleOutsideClick = (e: React.MouseEvent) => { 91 | if ((e.target as HTMLElement).classList.contains("modal-overlay")) { 92 | setEditData((prevData) => ({ ...prevData, isEditing: false })); 93 | } 94 | }; 95 | 96 | const handleInputChange = (e: React.ChangeEvent) => { 97 | const { name, value } = e.target; 98 | setFormData((prevData) => ({ ...prevData, [name]: value })); 99 | setErrors((prevErrors) => ({ ...prevErrors, [name]: "" })); 100 | }; 101 | 102 | if (isLoading) { 103 | return ( 104 |
105 | 106 |
107 | ); 108 | } 109 | 110 | if (error) { 111 | return ( 112 |
113 |

{error}

114 |
115 | ); 116 | } 117 | 118 | return ( 119 |
123 |
127 |

128 | Редактировать администратора 129 |

130 | 145 | 162 | 174 |
175 | 184 | 190 |
191 |
192 |
193 | ); 194 | }; 195 | -------------------------------------------------------------------------------- /src/pages/Blogs.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useState, useRef, useEffect } from "react"; 3 | import useSWR from "swr"; 4 | import type { BlogTypes } from "../Types/indexTypes"; 5 | import { fetcher } from "../middlewares/Fetcher"; 6 | import { Axios } from "../middlewares/Axios"; 7 | import BlogModal from "../components/shared/BlogModal"; 8 | import BlogEditModal from "../components/shared/BlogEditModal"; 9 | import { PhotoCarousel } from "../components/ui/PhotoCarousel"; 10 | import { MoreVertical, Edit, Trash2 } from "lucide-react"; 11 | 12 | export const Blogs: React.FC = () => { 13 | const [isModalActive, setIsModalActive] = useState(false); 14 | const [isEditModalActive, setIsEditModalActive] = useState(false); 15 | const [selectedBlog, setSelectedBlog] = useState( 16 | undefined 17 | ); 18 | const [activeDropdown, setActiveDropdown] = useState(null); 19 | const dropdownRef = useRef(null); 20 | 21 | const { data, error, isLoading, mutate } = useSWR( 22 | `/blog`, 23 | fetcher 24 | ); 25 | 26 | // Close dropdown when clicking outside 27 | useEffect(() => { 28 | const handleClickOutside = (event: MouseEvent) => { 29 | if ( 30 | dropdownRef.current && 31 | !dropdownRef.current.contains(event.target as Node) 32 | ) { 33 | setActiveDropdown(null); 34 | } 35 | }; 36 | 37 | document.addEventListener("mousedown", handleClickOutside); 38 | return () => { 39 | document.removeEventListener("mousedown", handleClickOutside); 40 | }; 41 | }, []); 42 | 43 | const handleDeleteBlog = async (id: string) => { 44 | const isConfirmed = window.confirm("Вы уверены, что хотите удалить блог?"); 45 | if (!isConfirmed) return; 46 | 47 | try { 48 | await Axios.delete(`/blog/${id}`); 49 | alert("Блог успешно удален!"); 50 | mutate((prevState) => prevState?.filter((blog) => blog._id !== id)); 51 | setActiveDropdown(null); 52 | } catch (error) { 53 | alert("Произошла ошибка при удалении блога."); 54 | } 55 | }; 56 | 57 | const handleEditBlog = (blog: BlogTypes) => { 58 | setSelectedBlog(blog); 59 | setIsEditModalActive(true); 60 | setActiveDropdown(null); 61 | }; 62 | 63 | const handleAddNewBlog = () => { 64 | setIsModalActive(true); 65 | }; 66 | 67 | const toggleDropdown = (blogId: string) => { 68 | setActiveDropdown(activeDropdown === blogId ? null : blogId); 69 | }; 70 | 71 | if (isLoading) { 72 | return ( 73 |
74 | 75 |
76 | ); 77 | } 78 | 79 | if (error) { 80 | return ( 81 |
82 |

{error}

83 |
84 | ); 85 | } 86 | 87 | return ( 88 | <> 89 |
90 |
91 |

Блоги

92 | 98 |
99 | 100 | {data && data?.length <= 0 ? ( 101 |
102 |

Нет блогов

103 |
104 | ) : ( 105 |
106 | {data?.map((blog: BlogTypes) => ( 107 |
108 | 109 |
110 |
111 |

112 | {blog.title} 113 |

114 |
115 | 122 | 123 | {activeDropdown === blog._id && ( 124 |
125 |
126 | 133 | 140 |
141 |
142 | )} 143 |
144 |
145 |

146 | {blog.description} 147 |

148 |
149 |
150 | ))} 151 |
152 | )} 153 |
154 | 155 | {isModalActive && ( 156 | 157 | )} 158 | 159 | {isEditModalActive && selectedBlog && ( 160 | 165 | )} 166 | 167 | ); 168 | }; 169 | -------------------------------------------------------------------------------- /src/pages/Admins.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useState, useRef, useEffect } from "react"; 3 | import useSWR from "swr"; 4 | import type { UserTypes } from "../Types/indexTypes"; 5 | import { fetcher } from "../middlewares/Fetcher"; 6 | import { AdminModal } from "../components/shared/AdminModal"; 7 | import { AdminEditModal } from "../components/shared/AdminEditModal"; 8 | import { Axios } from "../middlewares/Axios"; 9 | import { MoreVertical, Edit, Trash2 } from "lucide-react"; 10 | import { useSelector } from "react-redux"; 11 | import { RootState } from "../Store/indexStore"; 12 | 13 | export const Admins: React.FC = () => { 14 | const { data: currentUser } = useSelector((state: RootState) => state.user); 15 | const [isModalActive, setIsModalActive] = useState(false); 16 | const [editData, setEditData] = useState({ 17 | id: "", 18 | isEditing: false, 19 | }); 20 | const [activeDropdown, setActiveDropdown] = useState(null); 21 | const dropdownRef = useRef(null); 22 | 23 | const { data, error, isLoading, mutate } = useSWR( 24 | `/admin`, 25 | fetcher 26 | ); 27 | 28 | useEffect(() => { 29 | const handleClickOutside = (event: MouseEvent) => { 30 | if ( 31 | dropdownRef.current && 32 | !dropdownRef.current.contains(event.target as Node) 33 | ) { 34 | setActiveDropdown(null); 35 | } 36 | }; 37 | 38 | document.addEventListener("mousedown", handleClickOutside); 39 | return () => { 40 | document.removeEventListener("mousedown", handleClickOutside); 41 | }; 42 | }, []); 43 | 44 | const handleDeleteAdmin = async (id: string) => { 45 | const isConfirmed = window.confirm( 46 | "Haqiqatan ham administratorni olib tashlamoqchimisiz?" 47 | ); 48 | if (!isConfirmed) return; 49 | 50 | try { 51 | await Axios.delete(`admin/${id}`); 52 | alert("Administrator muvaffaqiyatli olib tashlandi!"); 53 | mutate((prevState) => prevState?.filter((admin) => admin._id !== id)); 54 | setActiveDropdown(null); 55 | } catch (error) { 56 | alert("Administratorni oʻchirishda xatolik yuz berdi."); 57 | } 58 | }; 59 | 60 | const handleEditAdmin = (adminId: string) => { 61 | setEditData((prevData) => ({ 62 | ...prevData, 63 | id: adminId, 64 | isEditing: true, 65 | })); 66 | setActiveDropdown(null); 67 | }; 68 | 69 | const toggleDropdown = (adminId: string) => { 70 | setActiveDropdown(activeDropdown === adminId ? null : adminId); 71 | }; 72 | 73 | function formatUzPhone(number: string) { 74 | return `+998 ${number.slice(0, 2)} ${number.slice(2, 5)} ${number.slice( 75 | 5, 76 | 7 77 | )} ${number.slice(7, 9)}`; 78 | } 79 | 80 | if (isLoading) { 81 | return ( 82 |
83 | 84 |
85 | ); 86 | } 87 | 88 | if (error) { 89 | return ( 90 |
91 |

{error}

92 |
93 | ); 94 | } 95 | 96 | return ( 97 | <> 98 |
99 |
100 |

Administratorlar

101 | 107 |
108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | {data?.map((admin: UserTypes) => ( 118 | 119 | 133 | 165 | 166 | ))} 167 | 168 |
FoydalanuvchiAction
120 |
121 | {admin.fullName} 122 | {currentUser._id === admin._id ? ( 123 | 124 | ) : null} 125 |
126 | 130 | {formatUzPhone(admin.phoneNumber)} 131 | 132 |
134 |
135 | 142 | 143 | {activeDropdown === admin._id && ( 144 |
145 |
146 | 153 | 160 |
161 |
162 | )} 163 |
164 |
169 |
170 | 171 | {isModalActive && ( 172 | 173 | )} 174 | 175 | {editData.isEditing && ( 176 | 177 | )} 178 | 179 | ); 180 | }; 181 | -------------------------------------------------------------------------------- /src/components/shared/ChangePassword.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useState } from "react"; 3 | import { Axios } from "../../middlewares/Axios"; 4 | 5 | interface ChangePasswordDialogProps { 6 | open: boolean; 7 | onOpenChange: (open: boolean) => void; 8 | } 9 | 10 | const ChangePasswordDialog = ({ 11 | open, 12 | onOpenChange, 13 | }: ChangePasswordDialogProps) => { 14 | const [loading, setLoading] = useState(false); 15 | const [formData, setFormData] = useState({ 16 | currentPassword: "", 17 | newPassword: "", 18 | confirmPassword: "", 19 | }); 20 | 21 | const handleSubmit = async (e: React.FormEvent) => { 22 | e.preventDefault(); 23 | 24 | if (formData.newPassword !== formData.confirmPassword) { 25 | console.log({ 26 | title: "Xatolik", 27 | description: "Yangi parollar mos kelmaydi", 28 | variant: "destructive", 29 | }); 30 | return; 31 | } 32 | 33 | if (formData.newPassword.length < 6) { 34 | console.log({ 35 | title: "Xatolik", 36 | description: "Parol kamida 6 ta belgidan iborat bo'lishi kerak", 37 | variant: "destructive", 38 | }); 39 | return; 40 | } 41 | 42 | setLoading(true); 43 | try { 44 | await Axios.post("auth/change-password", { 45 | currentPassword: formData.currentPassword, 46 | newPassword: formData.newPassword, 47 | }); 48 | console.log({ 49 | title: "Muvaffaqiyat", 50 | description: "Parol muvaffaqiyatli o'zgartirildi", 51 | }); 52 | onOpenChange(false); 53 | setFormData({ 54 | currentPassword: "", 55 | newPassword: "", 56 | confirmPassword: "", 57 | }); 58 | } catch (error: any) { 59 | console.log({ 60 | title: "Xatolik", 61 | description: 62 | error.response?.data?.message || "Parolni o'zgartirishda xatolik", 63 | variant: "destructive", 64 | }); 65 | } finally { 66 | setLoading(false); 67 | } 68 | }; 69 | 70 | const handleBackdropClick = (e: React.MouseEvent) => { 71 | if (e.target === e.currentTarget) { 72 | onOpenChange(false); 73 | } 74 | }; 75 | 76 | if (!open) return null; 77 | 78 | return ( 79 |
83 |
84 | {/* Header */} 85 |
86 |

87 | Parolni o'zgartirish 88 |

89 |

90 | Yangi parolni kiriting. Parol kamida 6 ta belgidan iborat bo'lishi 91 | kerak. 92 |

93 |
94 | 95 | {/* Form */} 96 |
97 |
98 | {/* Current Password */} 99 |
100 | 106 | 111 | setFormData((prev) => ({ 112 | ...prev, 113 | currentPassword: e.target.value, 114 | })) 115 | } 116 | required 117 | className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" 118 | placeholder="Joriy parolni kiriting" 119 | /> 120 |
121 | 122 | {/* New Password */} 123 |
124 | 130 | 135 | setFormData((prev) => ({ 136 | ...prev, 137 | newPassword: e.target.value, 138 | })) 139 | } 140 | required 141 | className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" 142 | placeholder="Yangi parolni kiriting" 143 | /> 144 |
145 | 146 | {/* Confirm Password */} 147 |
148 | 154 | 159 | setFormData((prev) => ({ 160 | ...prev, 161 | confirmPassword: e.target.value, 162 | })) 163 | } 164 | required 165 | className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" 166 | placeholder="Yangi parolni qayta kiriting" 167 | /> 168 |
169 |
170 | 171 | {/* Footer */} 172 |
173 | 180 | 209 |
210 |
211 |
212 |
213 | ); 214 | }; 215 | 216 | export default ChangePasswordDialog; 217 | -------------------------------------------------------------------------------- /src/pages/Trainers.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useState, useRef, useEffect } from "react"; 3 | import useSWR from "swr"; 4 | import type { TrainerTypes } from "../Types/indexTypes"; 5 | import { fetcher } from "../middlewares/Fetcher"; 6 | import { Axios } from "../middlewares/Axios"; 7 | import TrainerEditModal from "../components/shared/TrainerEditModal"; 8 | import TrainerModal from "../components/shared/TrainerModal"; 9 | import { MoreVertical, Edit, Trash2 } from "lucide-react"; 10 | 11 | export const Trainers: React.FC = () => { 12 | const [isModalActive, setIsModalActive] = useState(false); 13 | const [editData, setEditData] = useState({ 14 | id: "", 15 | isEditing: false, 16 | }); 17 | const [activeDropdown, setActiveDropdown] = useState(null); 18 | const dropdownRef = useRef(null); 19 | 20 | const { data, error, isLoading, mutate } = useSWR( 21 | `/trainer`, 22 | fetcher 23 | ); 24 | 25 | // Close dropdown when clicking outside 26 | useEffect(() => { 27 | const handleClickOutside = (event: MouseEvent) => { 28 | if ( 29 | dropdownRef.current && 30 | !dropdownRef.current.contains(event.target as Node) 31 | ) { 32 | setActiveDropdown(null); 33 | } 34 | }; 35 | 36 | document.addEventListener("mousedown", handleClickOutside); 37 | return () => { 38 | document.removeEventListener("mousedown", handleClickOutside); 39 | }; 40 | }, []); 41 | 42 | const handleDeleteTrainer = async (id: string) => { 43 | const isConfirmed = window.confirm( 44 | "Вы уверены, что хотите удалить трейнера?" 45 | ); 46 | if (!isConfirmed) return; 47 | 48 | try { 49 | await Axios.delete(`/trainer/${id}`); 50 | alert("Трейнер успешно удален!"); 51 | mutate((prevState) => prevState?.filter((trainer) => trainer._id !== id)); 52 | setActiveDropdown(null); 53 | } catch (error) { 54 | alert("Произошла ошибка при удалении трейнера."); 55 | } 56 | }; 57 | 58 | const handleEditTrainer = (trainerId: string) => { 59 | setEditData((prevData) => ({ 60 | ...prevData, 61 | id: trainerId, 62 | isEditing: true, 63 | })); 64 | setActiveDropdown(null); 65 | }; 66 | 67 | const toggleDropdown = (trainerId: string) => { 68 | setActiveDropdown(activeDropdown === trainerId ? null : trainerId); 69 | }; 70 | 71 | if (isLoading) { 72 | return ( 73 |
74 | 75 |
76 | ); 77 | } 78 | 79 | if (error) { 80 | return ( 81 |
82 |

{error}

83 |
84 | ); 85 | } 86 | 87 | return ( 88 | <> 89 |
90 |
91 |

Трейнеры

92 | 98 |
99 | 100 | {data && data?.length <= 0 ? ( 101 |
102 |

Нет трейнеров

103 |
104 | ) : ( 105 |
106 | 107 | 108 | 109 | 110 | 111 | 112 | 115 | 116 | 117 | 118 | {data?.map((trainer: TrainerTypes) => ( 119 | 120 | 133 | 138 | 143 | 175 | 176 | ))} 177 | 178 |
ФотоИмяОпыт 113 | Действия 114 |
121 |
122 | {trainer.fullName} { 127 | const target = e.target as HTMLImageElement; 128 | target.src = "/placeholder.svg?height=64&width=64"; 129 | }} 130 | /> 131 |
132 |
134 |
135 | {trainer.fullName} 136 |
137 |
139 | 140 | {trainer.experience} лет опыта 141 | 142 | 144 |
145 | 152 | 153 | {activeDropdown === trainer._id && ( 154 |
155 |
156 | 163 | 170 |
171 |
172 | )} 173 |
174 |
179 |
180 | )} 181 |
182 | 183 | {isModalActive && ( 184 | 185 | )} 186 | 187 | {editData.isEditing && ( 188 | 189 | )} 190 | 191 | ); 192 | }; 193 | -------------------------------------------------------------------------------- /src/components/shared/BlogModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Axios } from "../../middlewares/Axios"; 3 | import { X } from "lucide-react"; 4 | 5 | type ModalProps = { 6 | setIsModalActive: React.Dispatch>; 7 | mutate: any; 8 | }; 9 | 10 | export default function BlogModal({ setIsModalActive, mutate }: ModalProps) { 11 | const [formData, setFormData] = useState({ 12 | title: "", 13 | description: "", 14 | photos: [] as File[], 15 | }); 16 | 17 | const [errors, setErrors] = useState>({}); 18 | const [isUploading, setIsUploading] = useState(false); 19 | const [previews, setPreviews] = useState([]); 20 | 21 | const handleInputChange = (e: React.ChangeEvent) => { 22 | const { name, value } = e.target; 23 | setFormData((prevData) => ({ ...prevData, [name]: value })); 24 | setErrors((prevErrors) => ({ ...prevErrors, [name]: "" })); 25 | }; 26 | 27 | const handleFileChange = (e: React.ChangeEvent) => { 28 | if (!e.target.files || e.target.files.length === 0) return; 29 | 30 | const filesArray = Array.from(e.target.files); 31 | 32 | const newPreviews = filesArray.map((file) => URL.createObjectURL(file)); 33 | 34 | setFormData((prevData) => ({ 35 | ...prevData, 36 | photos: [...prevData.photos, ...filesArray], 37 | })); 38 | 39 | setPreviews((prev) => [...prev, ...newPreviews]); 40 | 41 | e.target.value = ""; 42 | }; 43 | 44 | const removePhoto = (index: number) => { 45 | setFormData((prevData) => ({ 46 | ...prevData, 47 | photos: prevData.photos.filter((_, i) => i !== index), 48 | })); 49 | 50 | setPreviews((prev) => prev.filter((_, i) => i !== index)); 51 | 52 | setErrors((prev) => ({ ...prev, photos: "" })); 53 | }; 54 | 55 | const validateForm = () => { 56 | let isValid = true; 57 | const newErrors: Record = {}; 58 | 59 | if (!formData.title.trim()) { 60 | newErrors.title = "Название блога обязательно"; 61 | isValid = false; 62 | } 63 | 64 | if (!formData.description.trim()) { 65 | newErrors.description = "Описание обязательно"; 66 | isValid = false; 67 | } 68 | 69 | if (formData.photos.length === 0) { 70 | newErrors.photos = "Добавьте хотя бы одну фотографию"; 71 | isValid = false; 72 | } 73 | 74 | setErrors(newErrors); 75 | return isValid; 76 | }; 77 | 78 | const handleSubmit = async (e: React.FormEvent) => { 79 | e.preventDefault(); 80 | if (!validateForm()) return; 81 | 82 | try { 83 | setIsUploading(true); 84 | const formDataToSend = new FormData(); 85 | formDataToSend.append("title", formData.title); 86 | formDataToSend.append("description", formData.description); 87 | 88 | formData.photos.forEach((photo) => { 89 | formDataToSend.append(`photos`, photo); 90 | }); 91 | 92 | await Axios.post("/blog", formDataToSend); 93 | alert("Блог успешно добавлен!"); 94 | setIsModalActive(false); 95 | mutate(); 96 | } catch (error: any) { 97 | alert(error.response?.data.message || "Произошла ошибка"); 98 | } finally { 99 | setIsUploading(false); 100 | } 101 | }; 102 | 103 | const handleOutsideClick = (e: React.MouseEvent) => { 104 | if ((e.target as HTMLElement).classList.contains("modal-overlay")) { 105 | setIsModalActive(false); 106 | } 107 | }; 108 | 109 | React.useEffect(() => { 110 | return () => { 111 | previews.forEach((url) => URL.revokeObjectURL(url)); 112 | }; 113 | }, [previews]); 114 | 115 | return ( 116 |
120 |
124 |

Добавить новый блог

125 | 142 | 160 |
161 | 165 | 166 | {previews.length > 0 && ( 167 |
168 | {previews.map((preview, index) => ( 169 |
170 | {`Preview 175 | 183 |
184 | ))} 185 |
186 | )} 187 | 188 |
189 | 208 |
209 | {errors.photos && ( 210 |

{errors.photos}

211 | )} 212 | {formData.photos.length > 0 && ( 213 |

214 | Выбрано файлов: {formData.photos.length} 215 |

216 | )} 217 |
218 | 219 |
220 | 227 | 234 |
235 |
236 |
237 | ); 238 | } 239 | -------------------------------------------------------------------------------- /src/components/shared/TrainerModal.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useState } from "react"; 3 | import { Axios } from "../../middlewares/Axios"; 4 | 5 | type ModalProps = { 6 | setIsModalActive: React.Dispatch>; 7 | mutate: any; 8 | }; 9 | 10 | export default function TrainerModal({ setIsModalActive, mutate }: ModalProps) { 11 | const [formData, setFormData] = useState({ 12 | photo: null as File | null, 13 | fullName: "", 14 | experience: "", 15 | level: "", 16 | students: "", 17 | }); 18 | 19 | const [errors, setErrors] = useState>({}); 20 | 21 | const [isUploading, setIsUploading] = useState(false); 22 | 23 | const handleInputChange = (e: React.ChangeEvent) => { 24 | const { name, value } = e.target; 25 | setFormData((prevData) => ({ ...prevData, [name]: value })); 26 | setErrors((prevErrors) => ({ ...prevErrors, [name]: "" })); 27 | }; 28 | 29 | const handleFileChange = (e: React.ChangeEvent) => { 30 | if (!e.target.files || e.target.files.length === 0) return; 31 | const file = e.target.files[0]; 32 | 33 | if (!file.type.startsWith("image/")) { 34 | setErrors((prevErrors) => ({ 35 | ...prevErrors, 36 | photo: "Файл должен быть изображением.", 37 | })); 38 | return; 39 | } 40 | 41 | setFormData((prevData) => ({ ...prevData, photo: file })); 42 | }; 43 | 44 | const validateForm = () => { 45 | let isValid = true; 46 | const newErrors: Record = {}; 47 | 48 | if (!formData.fullName.trim()) { 49 | newErrors.fullName = "Полное имя обязательно"; 50 | isValid = false; 51 | } 52 | 53 | if (!formData.level.trim()) { 54 | newErrors.level = "Уровень (пояс) обязателен"; 55 | isValid = false; 56 | } 57 | 58 | if (!formData.students.trim() || isNaN(Number(formData.students))) { 59 | newErrors.experience = "Число студентов должен быть числом"; 60 | isValid = false; 61 | } 62 | 63 | if (!formData.photo) { 64 | newErrors.photo = "Фото обязательно"; 65 | isValid = false; 66 | } 67 | 68 | setErrors(newErrors); 69 | return isValid; 70 | }; 71 | 72 | const handleSubmit = async (e: React.FormEvent) => { 73 | e.preventDefault(); 74 | if (!validateForm()) return; 75 | 76 | try { 77 | setIsUploading(true); 78 | const formDataToSend = new FormData(); 79 | formDataToSend.append("fullName", formData.fullName); 80 | formDataToSend.append("experience", formData.experience); 81 | formDataToSend.append("level", formData.level); 82 | formDataToSend.append("students", formData.students); 83 | if (formData.photo) formDataToSend.append("photo", formData.photo); 84 | 85 | await Axios.post("/trainer", formDataToSend); 86 | alert("Тренер успешно добавлен!"); 87 | setIsModalActive(false); 88 | mutate(); 89 | } catch (error: any) { 90 | alert(error.response?.data.message || "Произошла ошибка"); 91 | } finally { 92 | setIsUploading(false); 93 | } 94 | }; 95 | 96 | const handleOutsideClick = (e: React.MouseEvent) => { 97 | if ((e.target as HTMLElement).classList.contains("modal-overlay")) { 98 | setIsModalActive(false); 99 | } 100 | }; 101 | 102 | return ( 103 |
107 |
111 |

112 | Добавить нового трейнера 113 |

114 | 131 | 149 | 181 | 199 | 200 |
201 | 205 |
206 | 231 | {formData.photo && ( 232 | 239 | )} 240 |
241 | {errors.photo && ( 242 |

{errors.photo}

243 | )} 244 |
245 | 246 |
247 | 254 | 261 |
262 |
263 |
264 | ); 265 | } 266 | -------------------------------------------------------------------------------- /src/components/shared/TrainerEditModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { Axios } from "../../middlewares/Axios"; 3 | import useSWR from "swr"; 4 | import { fetcher } from "../../middlewares/Fetcher"; 5 | import { TrainerTypes } from "../../Types/indexTypes"; 6 | 7 | type EditDataTypes = { 8 | id: string; 9 | isEditing: boolean; 10 | }; 11 | 12 | type ModalProps = { 13 | editData: EditDataTypes; 14 | setEditData: React.Dispatch>; 15 | }; 16 | 17 | export default function AdminEditModal({ editData, setEditData }: ModalProps) { 18 | const { data, error, isLoading, mutate } = useSWR( 19 | `/trainer`, 20 | fetcher 21 | ); 22 | 23 | const [formData, setFormData] = useState({ 24 | _id: "", 25 | photo: null as File | null, 26 | fullName: "", 27 | experience: "", 28 | level: "", 29 | students: "", 30 | }); 31 | 32 | const [errors, setErrors] = useState>({}); 33 | 34 | const [isUploading, setIsUploading] = useState(false); 35 | 36 | useEffect(() => { 37 | if (data) { 38 | const trainer = data.find((user) => user._id === editData.id); 39 | if (trainer) setFormData(trainer); 40 | return; 41 | } 42 | }, [data, editData.id]); 43 | 44 | const handleFileChange = (e: React.ChangeEvent) => { 45 | if (!e.target.files || e.target.files.length === 0) return; 46 | const file = e.target.files[0]; 47 | 48 | if (!file.type.startsWith("image/")) { 49 | setErrors((prevErrors) => ({ 50 | ...prevErrors, 51 | photo: "Файл должен быть изображением.", 52 | })); 53 | return; 54 | } 55 | 56 | setFormData((prevData) => ({ ...prevData, photo: file })); 57 | }; 58 | 59 | const validateForm = () => { 60 | let isValid = true; 61 | const newErrors: Record = {}; 62 | 63 | if (!formData.fullName.trim()) { 64 | newErrors.fullName = "Полное имя обязательно"; 65 | isValid = false; 66 | } 67 | 68 | if (!formData.level.trim()) { 69 | newErrors.level = "Уровень (пояс) обязателен"; 70 | isValid = false; 71 | } 72 | 73 | if (!formData.students.trim() || isNaN(Number(formData.students))) { 74 | newErrors.experience = "Число студентов должен быть числом"; 75 | isValid = false; 76 | } 77 | 78 | if (!formData.photo) { 79 | newErrors.photo = "Фото обязательно"; 80 | isValid = false; 81 | } 82 | 83 | setErrors(newErrors); 84 | return isValid; 85 | }; 86 | 87 | const handleSubmit = async (e: React.FormEvent) => { 88 | e.preventDefault(); 89 | if (!validateForm()) return; 90 | 91 | try { 92 | setIsUploading(true); 93 | const formDataToSend = new FormData(); 94 | formDataToSend.append("fullName", formData.fullName); 95 | formDataToSend.append("experience", formData.experience); 96 | formDataToSend.append("level", formData.level); 97 | formDataToSend.append("students", formData.students); 98 | if (formData.photo) formDataToSend.append("photo", formData.photo); 99 | await Axios.put(`/trainer/${formData._id}`, formDataToSend); 100 | alert("Тренер успешно изменен!"); 101 | setEditData((prevData) => ({ ...prevData, isEditing: false, id: "" })); 102 | mutate(); 103 | } catch (error: any) { 104 | alert(error.response?.data.message || "Произошла ошибка"); 105 | } finally { 106 | setIsUploading(false); 107 | } 108 | }; 109 | 110 | const handleOutsideClick = (e: React.MouseEvent) => { 111 | if ((e.target as HTMLElement).classList.contains("modal-overlay")) { 112 | setEditData((prevData) => ({ ...prevData, isEditing: false })); 113 | } 114 | }; 115 | 116 | const handleInputChange = (e: React.ChangeEvent) => { 117 | const { name, value } = e.target; 118 | setFormData((prevData) => ({ ...prevData, [name]: value })); 119 | setErrors((prevErrors) => ({ ...prevErrors, [name]: "" })); 120 | }; 121 | 122 | if (isLoading) { 123 | return ( 124 |
125 | 126 |
127 | ); 128 | } 129 | 130 | if (error) { 131 | return ( 132 |
133 |

{error}

134 |
135 | ); 136 | } 137 | 138 | return ( 139 |
143 |
147 |

148 | Редактировать администратора 149 |

150 | 165 | 166 | 184 | 216 | 234 | 235 |
236 | 240 |
241 | 268 | {formData.photo && ( 269 | 276 | )} 277 |
278 | {errors.photo && ( 279 |

{errors.photo}

280 | )} 281 |
282 | 283 |
284 | 293 | 300 |
301 |
302 |
303 | ); 304 | } 305 | -------------------------------------------------------------------------------- /src/components/shared/BlogEditModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { Axios } from "../../middlewares/Axios"; 3 | import { X } from "lucide-react"; 4 | 5 | type BlogData = { 6 | _id: string; 7 | title: string; 8 | description: string; 9 | photos: string[]; 10 | }; 11 | 12 | type EditModalProps = { 13 | setIsModalActive: React.Dispatch>; 14 | mutate: any; 15 | blogData: BlogData; 16 | }; 17 | 18 | export default function BlogEditModal({ 19 | setIsModalActive, 20 | mutate, 21 | blogData, 22 | }: EditModalProps) { 23 | const [formData, setFormData] = useState({ 24 | title: "", 25 | description: "", 26 | photos: [] as File[], 27 | }); 28 | 29 | const [existingPhotos, setExistingPhotos] = useState([]); 30 | const [errors, setErrors] = useState>({}); 31 | const [isUploading, setIsUploading] = useState(false); 32 | const [previews, setPreviews] = useState([]); 33 | 34 | // Load existing data 35 | useEffect(() => { 36 | if (blogData) { 37 | setFormData({ 38 | title: blogData.title || "", 39 | description: blogData.description || "", 40 | photos: [], 41 | }); 42 | 43 | // Set existing photos 44 | if (blogData.photos && blogData.photos.length > 0) { 45 | setExistingPhotos(blogData.photos); 46 | } 47 | } 48 | }, [blogData]); 49 | 50 | const handleInputChange = (e: React.ChangeEvent) => { 51 | const { name, value } = e.target; 52 | setFormData((prevData) => ({ ...prevData, [name]: value })); 53 | setErrors((prevErrors) => ({ ...prevErrors, [name]: "" })); 54 | }; 55 | 56 | const handleFileChange = (e: React.ChangeEvent) => { 57 | if (!e.target.files || e.target.files.length === 0) return; 58 | 59 | const filesArray = Array.from(e.target.files); 60 | const newPreviews = filesArray.map((file) => URL.createObjectURL(file)); 61 | 62 | setFormData((prevData) => ({ 63 | ...prevData, 64 | photos: [...prevData.photos, ...filesArray], 65 | })); 66 | 67 | setPreviews((prev) => [...prev, ...newPreviews]); 68 | e.target.value = ""; 69 | }; 70 | 71 | const removeNewPhoto = (index: number) => { 72 | setFormData((prevData) => ({ 73 | ...prevData, 74 | photos: prevData.photos.filter((_, i) => i !== index), 75 | })); 76 | 77 | setPreviews((prev) => prev.filter((_, i) => i !== index)); 78 | setErrors((prev) => ({ ...prev, photos: "" })); 79 | }; 80 | 81 | const removeExistingPhoto = (index: number) => { 82 | setExistingPhotos((prev) => prev.filter((_, i) => i !== index)); 83 | setErrors((prev) => ({ ...prev, photos: "" })); 84 | }; 85 | 86 | const validateForm = () => { 87 | let isValid = true; 88 | const newErrors: Record = {}; 89 | 90 | if (!formData.title.trim()) { 91 | newErrors.title = "Название блога обязательно"; 92 | isValid = false; 93 | } 94 | 95 | if (!formData.description.trim()) { 96 | newErrors.description = "Описание обязательно"; 97 | isValid = false; 98 | } 99 | 100 | if (existingPhotos.length === 0 && formData.photos.length === 0) { 101 | newErrors.photos = "Добавьте хотя бы одну фотографию"; 102 | isValid = false; 103 | } 104 | 105 | setErrors(newErrors); 106 | return isValid; 107 | }; 108 | 109 | const handleSubmit = async (e: React.FormEvent) => { 110 | e.preventDefault(); 111 | if (!validateForm()) return; 112 | 113 | try { 114 | setIsUploading(true); 115 | const formDataToSend = new FormData(); 116 | formDataToSend.append("title", formData.title); 117 | formDataToSend.append("description", formData.description); 118 | 119 | existingPhotos.forEach((photo) => { 120 | formDataToSend.append(`existingPhotos`, photo); 121 | }); 122 | 123 | formData.photos.forEach((photo) => { 124 | formDataToSend.append(`photos`, photo); 125 | }); 126 | 127 | await Axios.put(`/blog/${blogData._id}`, formDataToSend); 128 | alert("Блог успешно обновлен!"); 129 | 130 | setIsModalActive(false); 131 | mutate(); 132 | } catch (error: any) { 133 | alert( 134 | error.response?.data.message || "Произошла ошибка при обновлении блога" 135 | ); 136 | } finally { 137 | setIsUploading(false); 138 | } 139 | }; 140 | 141 | const handleOutsideClick = (e: React.MouseEvent) => { 142 | if ((e.target as HTMLElement).classList.contains("modal-overlay")) { 143 | setIsModalActive(false); 144 | } 145 | }; 146 | 147 | React.useEffect(() => { 148 | return () => { 149 | previews.forEach((url) => URL.revokeObjectURL(url)); 150 | }; 151 | }, [previews]); 152 | 153 | return ( 154 |
158 |
162 |

Редактировать блог

163 | 180 | 198 |
199 | 203 | 204 | {/* Existing photos section */} 205 | {existingPhotos.length > 0 && ( 206 | <> 207 |

208 | Существующие фотографии: 209 |

210 |
211 | {existingPhotos.map((photo, index) => ( 212 |
213 | {`Existing 218 | 226 |
227 | ))} 228 |
229 | 230 | )} 231 | 232 | {/* New photos preview section */} 233 | {previews.length > 0 && ( 234 | <> 235 |

Новые фотографии:

236 |
237 | {previews.map((preview, index) => ( 238 |
239 | {`New 244 | 252 |
253 | ))} 254 |
255 | 256 | )} 257 | 258 |
259 | 278 |
279 | {errors.photos && ( 280 |

{errors.photos}

281 | )} 282 | 283 | {/* Photo count summary */} 284 | {(existingPhotos.length > 0 || formData.photos.length > 0) && ( 285 |

286 | Всего фотографий: {existingPhotos.length + formData.photos.length} 287 | {existingPhotos.length > 0 && 288 | ` (${existingPhotos.length} существующих)`} 289 | {formData.photos.length > 0 && 290 | ` (${formData.photos.length} новых)`} 291 |

292 | )} 293 |
294 | 295 |
296 | 303 | 310 |
311 |
312 |
313 | ); 314 | } 315 | --------------------------------------------------------------------------------