diff --git a/src/assets/icons/auth/index.ts b/src/assets/icons/auth/index.ts index 70db5e3..0c604a5 100644 --- a/src/assets/icons/auth/index.ts +++ b/src/assets/icons/auth/index.ts @@ -1,4 +1,4 @@ -import Balloon from "./balloon.svg"; -import Account from "./account.svg" +import Balloon from './balloon.svg'; +import Account from './account.svg'; -export {Balloon, Account}; \ No newline at end of file +export { Balloon, Account }; diff --git a/src/assets/icons/groups/index.ts b/src/assets/icons/groups/index.ts index 86bef2e..1913817 100644 --- a/src/assets/icons/groups/index.ts +++ b/src/assets/icons/groups/index.ts @@ -1,8 +1,8 @@ -import Book from "./book.png" -import EyeClosed from "./eye-closed.svg"; -import EyeOpen from "./eye-open.png"; -import Edit from "./edit.svg"; -import UserAdd from "./user-profile-add.svg"; -import ChevroneDown from "./chevron-down.svg" +import Book from './book.png'; +import EyeClosed from './eye-closed.svg'; +import EyeOpen from './eye-open.png'; +import Edit from './edit.svg'; +import UserAdd from './user-profile-add.svg'; +import ChevroneDown from './chevron-down.svg'; -export {Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown} \ No newline at end of file +export { Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown }; diff --git a/src/assets/icons/header/index.ts b/src/assets/icons/header/index.ts index 5ea2a4b..333f6d9 100644 --- a/src/assets/icons/header/index.ts +++ b/src/assets/icons/header/index.ts @@ -1,5 +1,5 @@ -import arrowLeft from "./arrow-left-sm.svg"; -import chevroneLeft from "./chevron-left.svg" -import chevroneRight from "./chevron-right.svg" +import arrowLeft from './arrow-left-sm.svg'; +import chevroneLeft from './chevron-left.svg'; +import chevroneRight from './chevron-right.svg'; -export {arrowLeft, chevroneLeft, chevroneRight} \ No newline at end of file +export { arrowLeft, chevroneLeft, chevroneRight }; diff --git a/src/assets/icons/input/index.ts b/src/assets/icons/input/index.ts index 6409328..4888d41 100644 --- a/src/assets/icons/input/index.ts +++ b/src/assets/icons/input/index.ts @@ -1,8 +1,15 @@ -import eyeClosed from "./eye-closed.svg" -import eyeOpen from "./eye-open.png" -import googleLogo from "./google-logo.svg" -import upload from "./upload.svg" -import chevroneDropDownList from "./chevron-drop-down.svg" -import checkMark from "./check-mark.svg" +import eyeClosed from './eye-closed.svg'; +import eyeOpen from './eye-open.png'; +import googleLogo from './google-logo.svg'; +import upload from './upload.svg'; +import chevroneDropDownList from './chevron-drop-down.svg'; +import checkMark from './check-mark.svg'; -export {eyeClosed, eyeOpen, googleLogo, upload, chevroneDropDownList, checkMark} \ No newline at end of file +export { + eyeClosed, + eyeOpen, + googleLogo, + upload, + chevroneDropDownList, + checkMark, +}; diff --git a/src/assets/icons/menu/index.ts b/src/assets/icons/menu/index.ts index cbeba0a..2cbb7b1 100644 --- a/src/assets/icons/menu/index.ts +++ b/src/assets/icons/menu/index.ts @@ -1,8 +1,8 @@ -import Account from "./account.svg"; -import Clipboard from "./clipboard.svg"; -import Cup from "./cup.svg"; -import Home from "./home.svg"; -import Openbook from "./openbook.svg"; -import Users from "./users.svg"; +import Account from './account.svg'; +import Clipboard from './clipboard.svg'; +import Cup from './cup.svg'; +import Home from './home.svg'; +import Openbook from './openbook.svg'; +import Users from './users.svg'; -export {Account, Clipboard, Cup, Home, Openbook, Users}; \ No newline at end of file +export { Account, Clipboard, Cup, Home, Openbook, Users }; diff --git a/src/assets/icons/missions/index.ts b/src/assets/icons/missions/index.ts index 62ec89d..778fa2e 100644 --- a/src/assets/icons/missions/index.ts +++ b/src/assets/icons/missions/index.ts @@ -1,6 +1,5 @@ -import IconSuccess from "./icon-success.svg" -import IconError from "./icon-error.svg" -import CopyIcon from "./copy-icon.svg" +import IconSuccess from './icon-success.svg'; +import IconError from './icon-error.svg'; +import CopyIcon from './copy-icon.svg'; - -export {IconError, IconSuccess, CopyIcon} \ No newline at end of file +export { IconError, IconSuccess, CopyIcon }; diff --git a/src/assets/logos/index.ts b/src/assets/logos/index.ts index 42f784e..5ea6c09 100644 --- a/src/assets/logos/index.ts +++ b/src/assets/logos/index.ts @@ -1,3 +1,3 @@ -import Logo from "./Logo.svg" +import Logo from './Logo.svg'; -export {Logo} \ No newline at end of file +export { Logo }; diff --git a/src/axios.ts b/src/axios.ts index 3da69b2..f7cb60b 100644 --- a/src/axios.ts +++ b/src/axios.ts @@ -1,24 +1,24 @@ -import axios from "axios"; +import axios from 'axios'; const instance = axios.create({ - baseURL: import.meta.env.VITE_API_URL, - headers: { - 'Content-Type': 'application/json' - }, + baseURL: import.meta.env.VITE_API_URL, + headers: { + 'Content-Type': 'application/json', + }, }); // Request interceptor: автоматически подставляет JWT, если есть instance.interceptors.request.use( - (config) => { - const token = localStorage.getItem("jwt"); // или можно брать из Redux через store.getState() - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; - }, - (error) => { - return Promise.reject(error); - } + (config) => { + const token = localStorage.getItem('jwt'); // или можно брать из Redux через store.getState() + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + }, ); export default instance; diff --git a/src/components/button/PrimaryButton.tsx b/src/components/button/PrimaryButton.tsx index 5c763c5..d476289 100644 --- a/src/components/button/PrimaryButton.tsx +++ b/src/components/button/PrimaryButton.tsx @@ -1,88 +1,90 @@ -import React from "react"; -import { cn } from "../../lib/cn"; +import React from 'react'; +import { cn } from '../../lib/cn'; interface ButtonProps { - disabled?: boolean; - text?: string; - className?: string; - onClick: () => void; - children?: React.ReactNode; - color?: "primary" | "secondary" | "error" | "warning" | "success"; + disabled?: boolean; + text?: string; + className?: string; + onClick: () => void; + children?: React.ReactNode; + color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success'; } const ColorBgVariants = { - "primary": "bg-liquid-brightmain group-hover:ring-liquid-brightmain", - "secondary": "bg-liquid-darkmain group-hover:ring-liquid-darkmain", - "error": "bg-liquid-red group-hover:ring-liquid-red", - "warning": "bg-liquid-orange group-hover:ring-liquid-orange", - "success": "bg-liquid-green group-hover:ring-liquid-green", -} + primary: 'bg-liquid-brightmain group-hover:ring-liquid-brightmain', + secondary: 'bg-liquid-darkmain group-hover:ring-liquid-darkmain', + error: 'bg-liquid-red group-hover:ring-liquid-red', + warning: 'bg-liquid-orange group-hover:ring-liquid-orange', + success: 'bg-liquid-green group-hover:ring-liquid-green', +}; const ColorTextVariants = { - "primary": "group-hover:text-liquid-brightmain ", - "secondary": "group-hover:text-liquid-brightmain ", - "error": "group-hover:text-liquid-red ", - "warning": "group-hover:text-liquid-orange ", - "success": "group-hover:text-liquid-green ", -} + primary: 'group-hover:text-liquid-brightmain ', + secondary: 'group-hover:text-liquid-brightmain ', + error: 'group-hover:text-liquid-red ', + warning: 'group-hover:text-liquid-orange ', + success: 'group-hover:text-liquid-green ', +}; export const PrimaryButton: React.FC = ({ - disabled = false, - text = "", - className, - onClick, - children, - color = "secondary", + disabled = false, + text = '', + className, + onClick, + children, + color = 'secondary', }) => { - return ( - - ); + {/* Граница при выделении через tab */} +
+ {children || text} +
+
+ {children || text} +
+ + + ); }; diff --git a/src/components/button/ReverseButton.tsx b/src/components/button/ReverseButton.tsx index 393787a..9002476 100644 --- a/src/components/button/ReverseButton.tsx +++ b/src/components/button/ReverseButton.tsx @@ -1,70 +1,72 @@ -import React from "react"; -import { cn } from "../../lib/cn"; +import React from 'react'; +import { cn } from '../../lib/cn'; interface ButtonProps { - disabled?: boolean; - text?: string; - className?: string; - onClick: () => void; - children?: React.ReactNode; + disabled?: boolean; + text?: string; + className?: string; + onClick: () => void; + children?: React.ReactNode; } export const ReverseButton: React.FC = ({ - disabled = false, - text = "", - className, - onClick, - children, + disabled = false, + text = '', + className, + onClick, + children, }) => { - return ( - - ); + {/* Граница при выделении через tab */} +
+ {children || text} +
+
+ {children || text} +
+ + + ); }; diff --git a/src/components/button/SecondaryButton.tsx b/src/components/button/SecondaryButton.tsx index fb92feb..96be5b9 100644 --- a/src/components/button/SecondaryButton.tsx +++ b/src/components/button/SecondaryButton.tsx @@ -1,69 +1,70 @@ -import React from "react"; -import { cn } from "../../lib/cn"; +import React from 'react'; +import { cn } from '../../lib/cn'; interface ButtonProps { - disabled?: boolean; - text?: string; - className?: string; - onClick: () => void; - children?: React.ReactNode; - + disabled?: boolean; + text?: string; + className?: string; + onClick: () => void; + children?: React.ReactNode; } export const SecondaryButton: React.FC = ({ - disabled = false, - text = "", - className, - onClick, - children, + disabled = false, + text = '', + className, + onClick, + children, }) => { - return ( - - ); + {/* Граница при выделении через tab */} +
+ {children || text} +
+
+ {children || text} +
+ + + ); }; diff --git a/src/components/checkbox/Checkbox.tsx b/src/components/checkbox/Checkbox.tsx index 0153384..180363e 100644 --- a/src/components/checkbox/Checkbox.tsx +++ b/src/components/checkbox/Checkbox.tsx @@ -1,168 +1,167 @@ -import React from "react"; -import { cn } from "../../lib/cn"; -import { motion } from "framer-motion"; +import React from 'react'; +import { cn } from '../../lib/cn'; +import { motion } from 'framer-motion'; const pathVariants = { - hidden: { - opacity: 0, - pathLength: 0, - }, - visible: { - opacity: 1, - pathLength: 1, - transition: { - delay: 0.15, - duration: 0.4, - ease: "easeInOut", + hidden: { + opacity: 0, + pathLength: 0, + }, + visible: { + opacity: 1, + pathLength: 1, + transition: { + delay: 0.15, + duration: 0.4, + ease: 'easeInOut', + }, }, - }, }; const sizeVariants = { - sm: "h-4 w-4", - md: "h-5 w-5", - lg: "h-6 w-6", + sm: 'h-4 w-4', + md: 'h-5 w-5', + lg: 'h-6 w-6', }; const colorsVariants = { - default: "bg-default", - primary: "bg-liquid-brightmain", - secondary: "bg-liquid-darkmain", - success: "bg-liquid-green", - warning: "bg-liquid-orange", - danger: "bg-liquid-red", + default: 'bg-default', + primary: 'bg-liquid-brightmain', + secondary: 'bg-liquid-darkmain', + success: 'bg-liquid-green', + warning: 'bg-liquid-orange', + danger: 'bg-liquid-red', }; - const borderColorsVariants = { - default: "border-default", - primary: "border-liquid-brightmain", - secondary: "border-liquid-darkmain", - success: "border-liquid-green", - warning: "border-liquid-orange", - danger: "border-liquid-red", + default: 'border-default', + primary: 'border-liquid-brightmain', + secondary: 'border-liquid-darkmain', + success: 'border-liquid-green', + warning: 'border-liquid-orange', + danger: 'border-liquid-red', }; const focuseOutlineVariants = { - default: "[&:focus-visible+*]:outline-default", - primary: "[&:focus-visible+*]:outline-liquid-brightmain", - secondary: "[&:focus-visible+*]:outline-liquid-darkmain", - success: "[&:focus-visible+*]:outline-liquid-green", - warning: "[&:focus-visible+*]:outline-liquid-orange", - danger: "[&:focus-visible+*]:outline-liquid-red", + default: '[&:focus-visible+*]:outline-default', + primary: '[&:focus-visible+*]:outline-liquid-brightmain', + secondary: '[&:focus-visible+*]:outline-liquid-darkmain', + success: '[&:focus-visible+*]:outline-liquid-green', + warning: '[&:focus-visible+*]:outline-liquid-orange', + danger: '[&:focus-visible+*]:outline-liquid-red', }; const radiusVraiants = { - none: "", - sm: "rounded-[3.5px]", - md: "rounded-[5px]", - lg: "rounded-[7px]", - full: "rounded-full", + none: '', + sm: 'rounded-[3.5px]', + md: 'rounded-[5px]', + lg: 'rounded-[7px]', + full: 'rounded-full', }; interface CheckboxProps { - size?: "sm" | "md" | "lg"; - radius?: "none" | "sm" | "md" | "lg" | "full"; - disabled?: boolean; - color?: - | "default" - | "primary" - | "secondary" - | "success" - | "warning" - | "danger"; - label?: string; - variant?: "default" | "label"; - className?: string; - defaultState?: boolean; - onChange: (state: boolean) => void; + size?: 'sm' | 'md' | 'lg'; + radius?: 'none' | 'sm' | 'md' | 'lg' | 'full'; + disabled?: boolean; + color?: + | 'default' + | 'primary' + | 'secondary' + | 'success' + | 'warning' + | 'danger'; + label?: string; + variant?: 'default' | 'label'; + className?: string; + defaultState?: boolean; + onChange: (state: boolean) => void; } export const Checkbox: React.FC = ({ - size = "md", - radius = "md", - disabled = false, - color = "primary", - label = "", - variant = "label", - className, - onChange, - defaultState = false, + size = 'md', + radius = 'md', + disabled = false, + color = 'primary', + label = '', + variant = 'label', + className, + onChange, + defaultState = false, }) => { - const [active, setActive] = React.useState(defaultState); + const [active, setActive] = React.useState(defaultState); - React.useEffect(() => onChange(active), [active]); + React.useEffect(() => onChange(active), [active]); - return ( - -
- { - setActive(!active); - }} - /> -
- - - {active && ( - + return ( + - -
- {variant == "label" && ( -
- {label} -
- )} -
- ); + > +
+ { + setActive(!active); + }} + /> +
+ + + {active && ( + + )} + + +
+ {variant == 'label' && ( +
+ {label} +
+ )} + + ); }; diff --git a/src/components/drop-down-list/DropDownList.tsx b/src/components/drop-down-list/DropDownList.tsx index 690dcfa..38f994e 100644 --- a/src/components/drop-down-list/DropDownList.tsx +++ b/src/components/drop-down-list/DropDownList.tsx @@ -1,7 +1,7 @@ -import React from "react"; -import { cn } from "../../lib/cn"; -import { checkMark, chevroneDropDownList } from "../../assets/icons/input"; -import { useClickOutside } from "../../hooks/useClickOutside"; +import React from 'react'; +import { cn } from '../../lib/cn'; +import { checkMark, chevroneDropDownList } from '../../assets/icons/input'; +import { useClickOutside } from '../../hooks/useClickOutside'; export interface DropDownListItem { text: string; @@ -18,15 +18,16 @@ interface DropDownListProps { export const DropDownList: React.FC = ({ // disabled = false, - className = "", + className = '', onChange, defaultState, - items = [{ text: "", value: "" }], + items = [{ text: '', value: '' }], }) => { - if (items.length == 0) - items.push({ text: "", value: "" }); + if (items.length == 0) items.push({ text: '', value: '' }); - const [value, setValue] = React.useState(defaultState != undefined ? defaultState : items[0]); + const [value, setValue] = React.useState( + defaultState != undefined ? defaultState : items[0], + ); const [active, setActive] = React.useState(false); React.useEffect(() => onChange(value.value), [value]); @@ -37,67 +38,73 @@ export const DropDownList: React.FC = ({ setActive(false); }); - return ( -
-
+
{ setActive(!active); - } - }> + }} + > {value.text} -
- - +
+ className={cn( + ' absolute rounded-[10px] bg-liquid-lighter w-[180px] left-0 top-[48px] z-50 transition-all duration-300', + 'grid overflow-hidden', + active + ? 'grid-rows-[1fr] opacity-100' + : 'grid-rows-[0fr] opacity-0', + )} + >
-
- - {items.map((v, i) => +
+ {items.map((v, i) => (
{ setValue(v); setActive(false); - }}> + }} + > {v.text} - {v.text == value.text && - - } + {v.text == value.text && ( + + )}
- )} + ))}
); - }; diff --git a/src/components/input/Input.tsx b/src/components/input/Input.tsx index 6835065..22588b6 100644 --- a/src/components/input/Input.tsx +++ b/src/components/input/Input.tsx @@ -1,89 +1,95 @@ -import React from "react"; -import { cn } from "../../lib/cn"; -import { eyeClosed, eyeOpen } from "../../assets/icons/input"; +import React from 'react'; +import { cn } from '../../lib/cn'; +import { eyeClosed, eyeOpen } from '../../assets/icons/input'; interface inputProps { - name?: string; - type: "text" | "email" | "password" | "first_name" | "number"; - error?: string; - disabled?: boolean; - required?: boolean; - label?: string; - placeholder?: string; - className?: string; - onChange: (state: string) => void; - defaultState?: string; - autocomplete?: string; - onKeyDown?: (e: React.KeyboardEvent) => void; + name?: string; + type: 'text' | 'email' | 'password' | 'first_name' | 'number'; + error?: string; + disabled?: boolean; + required?: boolean; + label?: string; + placeholder?: string; + className?: string; + onChange: (state: string) => void; + defaultState?: string; + autocomplete?: string; + onKeyDown?: (e: React.KeyboardEvent) => void; } export const Input: React.FC = ({ - type = "text", - error = "", - // disabled = false, - // required = false, - label = "", - placeholder = "", - className = "", - onChange, - defaultState = "", - name = "", - autocomplete = "", - onKeyDown, + type = 'text', + error = '', + // disabled = false, + // required = false, + label = '', + placeholder = '', + className = '', + onChange, + defaultState = '', + name = '', + autocomplete = '', + onKeyDown, }) => { - const [value, setValue] = React.useState(defaultState); - const [visible, setVIsible] = React.useState(type != "password"); + const [value, setValue] = React.useState(defaultState); + const [visible, setVIsible] = React.useState(type != 'password'); - React.useEffect(() => onChange(value), [value]); - React.useEffect(() => setValue(defaultState), [defaultState]); + React.useEffect(() => onChange(value), [value]); + React.useEffect(() => setValue(defaultState), [defaultState]); + return ( +
+
+ {label} +
+
+ { + setValue(e.target.value); + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (onKeyDown) onKeyDown(e); + }} + /> + {type == 'password' && ( + { + setVIsible(!visible); + }} + /> + )} +
- - return ( -
-
- {label} -
-
- { - setValue(e.target.value); - }} - onKeyDown={(e: React.KeyboardEvent) => { - if (onKeyDown) - onKeyDown(e); - } - } - /> - { - type == "password" && - { - setVIsible(!visible); - }} /> - } -
- -
- {error} -
- -
- ); - +
+ {error} +
+
+ ); }; diff --git a/src/components/modal/Modal.tsx b/src/components/modal/Modal.tsx index 8c25d1e..a70eaf9 100644 --- a/src/components/modal/Modal.tsx +++ b/src/components/modal/Modal.tsx @@ -1,78 +1,80 @@ -import React from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { cn } from "../../lib/cn"; -import { useClickOutside } from "../../hooks/useClickOutside"; +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { cn } from '../../lib/cn'; +import { useClickOutside } from '../../hooks/useClickOutside'; -type ModalBackdrop = "opaque" | "blur"; +type ModalBackdrop = 'opaque' | 'blur'; interface ModalProps { - className?: string; - children?: React.ReactNode; - backdrop?: ModalBackdrop; - open: boolean; - defaultOpen?: boolean; - onOpenChange: (state: boolean) => void; + className?: string; + children?: React.ReactNode; + backdrop?: ModalBackdrop; + open: boolean; + defaultOpen?: boolean; + onOpenChange: (state: boolean) => void; } const modalbgVariants = { - closed: { opacity: 0 }, - open: { opacity: 1 }, + closed: { opacity: 0 }, + open: { opacity: 1 }, }; const modalVariants = { - closed: { opacity: 0, scale: 0.9 }, - open: { opacity: 1, scale: 1 }, + closed: { opacity: 0, scale: 0.9 }, + open: { opacity: 1, scale: 1 }, }; export const Modal: React.FC = ({ - children, - open, - backdrop, - className, - onOpenChange, + children, + open, + backdrop, + className, + onOpenChange, }) => { - const ref = React.useRef(null); + const ref = React.useRef(null); - useClickOutside(ref, () => { - onOpenChange(false); - }); + useClickOutside(ref, () => { + onOpenChange(false); + }); - return ( -
- - {open && ( - - )} - -
- - {open && ( - - {children} - - )} - -
-
- ); + return ( +
+ + {open && ( + + )} + +
+ + {open && ( + + {children} + + )} + +
+
+ ); }; diff --git a/src/components/switch/Switch.tsx b/src/components/switch/Switch.tsx index 8b812d8..f1bc0e7 100644 --- a/src/components/switch/Switch.tsx +++ b/src/components/switch/Switch.tsx @@ -1,187 +1,191 @@ -import React from "react"; -import { cn } from "../../lib/cn"; +import React from 'react'; +import { cn } from '../../lib/cn'; /* Варианты размера контейнера */ const sizeVariants = { - sm: "h-6 w-10", - md: "h-7 w-12", - lg: "h-8 w-14", + sm: 'h-6 w-10', + md: 'h-7 w-12', + lg: 'h-8 w-14', }; /* Варианты для скользящего шарика */ const switchVariants = { - size: { - sm: "h-4 w-4", - md: "h-5 w-5", - lg: "h-6 w-6", - }, - activeSize: { - sm: "group-active:w-5", - md: "group-active:w-6", - lg: "group-active:w-7", - }, - iconSize: { - sm: "h-3 w-3", - md: "h-[0.875rem] w-[0.875rem]", - lg: "h-4 w-4", - }, + size: { + sm: 'h-4 w-4', + md: 'h-5 w-5', + lg: 'h-6 w-6', + }, + activeSize: { + sm: 'group-active:w-5', + md: 'group-active:w-6', + lg: 'group-active:w-7', + }, + iconSize: { + sm: 'h-3 w-3', + md: 'h-[0.875rem] w-[0.875rem]', + lg: 'h-4 w-4', + }, }; const colorsVariants = { - default: "bg-default", - primary: "bg-liquid-brightmain", - secondary: "bg-liquid-darkmain", - success: "bg-liquid-green", - warning: "bg-liquid-orange", - danger: "bg-liquid-red", + default: 'bg-default', + primary: 'bg-liquid-brightmain', + secondary: 'bg-liquid-darkmain', + success: 'bg-liquid-green', + warning: 'bg-liquid-orange', + danger: 'bg-liquid-red', }; const focuseOutlineVariants = { - default: "[&:focus-visible+*]:outline-default", - primary: "[&:focus-visible+*]:outline-liquid-brightmain", - secondary: "[&:focus-visible+*]:outline-liquid-darkmain", - success: "[&:focus-visible+*]:outline-liquid-green", - warning: "[&:focus-visible+*]:outline-liquid-orange", - danger: "[&:focus-visible+*]:outline-liquid-red", + default: '[&:focus-visible+*]:outline-default', + primary: '[&:focus-visible+*]:outline-liquid-brightmain', + secondary: '[&:focus-visible+*]:outline-liquid-darkmain', + success: '[&:focus-visible+*]:outline-liquid-green', + warning: '[&:focus-visible+*]:outline-liquid-orange', + danger: '[&:focus-visible+*]:outline-liquid-red', }; /** * Иконка солнца */ const sun = ( - - - - + + + + ); /** * Иконка луны */ const moon = ( - - - + + + ); interface SwitchProps { - size?: "sm" | "md" | "lg"; - disabled?: boolean; - color?: - | "default" - | "primary" - | "secondary" - | "success" - | "warning" - | "danger"; - label?: string; - variant?: "default" | "label" | "icon" | "theme"; - className?: string; - defaultState?: boolean; - onChange: (state: boolean) => void; + size?: 'sm' | 'md' | 'lg'; + disabled?: boolean; + color?: + | 'default' + | 'primary' + | 'secondary' + | 'success' + | 'warning' + | 'danger'; + label?: string; + variant?: 'default' | 'label' | 'icon' | 'theme'; + className?: string; + defaultState?: boolean; + onChange: (state: boolean) => void; } export const Switch: React.FC = ({ - size = "sm", - disabled = false, - color = "primary", - label = "", - variant = "default", - className, - onChange, - defaultState = false, + size = 'sm', + disabled = false, + color = 'primary', + label = '', + variant = 'default', + className, + onChange, + defaultState = false, }) => { - const [active, setActive] = React.useState(defaultState); + const [active, setActive] = React.useState(defaultState); - React.useEffect(() => onChange(active), [active]); + React.useEffect(() => onChange(active), [active]); - return ( - - ); +
+ + {/* Шарик */} + + {variant == 'theme' && ( + <> +
+ {moon} +
+
+ {sun} +
+ + )} +
+
+ + {variant == 'label' && ( +
+ {label} +
+ )} + + ); }; diff --git a/src/config/colors.ts b/src/config/colors.ts index 8d3f780..86352cf 100644 --- a/src/config/colors.ts +++ b/src/config/colors.ts @@ -1,14 +1,14 @@ export default { - liquid: { - brightmain: "var(--color-liquid-brightmain)", - darkmain: "var(--color-liquid-darkmain)", - darker: "var(--color-liquid-darker)", - background: "var(--color-liquid-background)", - lighter: "var(--color-liquid-lighter)", - white: "var(--color-liquid-white)", - red: "var(--color-liquid-red)", - green: "var(--color-liquid-green)", - light: "var(--color-liquid-light)", - orange: "var(--color-liquid-orange)", - } + liquid: { + brightmain: 'var(--color-liquid-brightmain)', + darkmain: 'var(--color-liquid-darkmain)', + darker: 'var(--color-liquid-darker)', + background: 'var(--color-liquid-background)', + lighter: 'var(--color-liquid-lighter)', + white: 'var(--color-liquid-white)', + red: 'var(--color-liquid-red)', + green: 'var(--color-liquid-green)', + light: 'var(--color-liquid-light)', + orange: 'var(--color-liquid-orange)', + }, }; diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts index fb27289..e56bf50 100644 --- a/src/hooks/useClickOutside.ts +++ b/src/hooks/useClickOutside.ts @@ -1,18 +1,21 @@ -import React from "react"; +import React from 'react'; -export const useClickOutside = (ref: React.RefObject, onClickOutside: () => void) => { - React.useEffect(() => { - const handleClickOutside = (event: MouseEvent | TouchEvent) => { - if (ref.current && !ref.current.contains(event.target)) { - onClickOutside(); - } - } +export const useClickOutside = ( + ref: React.RefObject, + onClickOutside: () => void, +) => { + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + if (ref.current && !ref.current.contains(event.target)) { + onClickOutside(); + } + }; - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("touchstart", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("touchstart", handleClickOutside); - } - }, [ref, onClickOutside]); -} \ No newline at end of file + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchstart', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); + }; + }, [ref, onClickOutside]); +}; diff --git a/src/lib/cn.ts b/src/lib/cn.ts index 95b19a8..883213c 100644 --- a/src/lib/cn.ts +++ b/src/lib/cn.ts @@ -1,6 +1,6 @@ -import { ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - +import { ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} \ No newline at end of file + return twMerge(clsx(inputs)); +} diff --git a/src/main.tsx b/src/main.tsx index abace85..5e19935 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,16 +1,16 @@ -import { createRoot } from "react-dom/client"; -import App from "./App.tsx"; -import "./styles/index.css"; -import "./styles/palette/theme-dark.css"; -import "./styles/palette/theme-light.css"; -import { BrowserRouter } from "react-router-dom"; -import { Provider } from "react-redux"; -import { store } from "./redux/store"; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; +import './styles/index.css'; +import './styles/palette/theme-dark.css'; +import './styles/palette/theme-light.css'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from './redux/store'; -createRoot(document.getElementById("root")!).render( - - - - - +createRoot(document.getElementById('root')!).render( + + + + + , ); diff --git a/src/pages/ArticleEditor.tsx b/src/pages/ArticleEditor.tsx index 0f1d163..9b06271 100644 --- a/src/pages/ArticleEditor.tsx +++ b/src/pages/ArticleEditor.tsx @@ -1,103 +1,133 @@ -import { Route, Routes, useNavigate } from "react-router-dom"; +import { Route, Routes, useNavigate } from 'react-router-dom'; import Header from '../views/articleeditor/Header'; -import MarkdownEditor from "../views/articleeditor/Editor"; -import { useState } from "react"; -import { PrimaryButton } from "../components/button/PrimaryButton"; -import MarkdownPreview from "../views/articleeditor/MarckDownPreview"; -import { Input } from "../components/input/Input"; - +import MarkdownEditor from '../views/articleeditor/Editor'; +import { useState } from 'react'; +import { PrimaryButton } from '../components/button/PrimaryButton'; +import MarkdownPreview from '../views/articleeditor/MarckDownPreview'; +import { Input } from '../components/input/Input'; const ArticleEditor = () => { - const [code, setCode] = useState(""); - const [name, setName] = useState(""); + const [code, setCode] = useState(''); + const [name, setName] = useState(''); const navigate = useNavigate(); - - const [tagInput, setTagInput] = useState(""); + const [tagInput, setTagInput] = useState(''); const [tags, setTags] = useState([]); const addTag = () => { const newTag = tagInput.trim(); if (newTag && !tags.includes(newTag)) { setTags([...tags, newTag]); - setTagInput(""); + setTagInput(''); } }; const removeTag = (tagToRemove: string) => { - setTags(tags.filter(tag => tag !== tagToRemove)); + setTags(tags.filter((tag) => tag !== tagToRemove)); }; return (
- - } /> + } + /> } /> - - - } /> - -
Создание статьи
- - - { - console.log({ - name: name, - tags: tags, - text: code, - }) - - }} text="Опубликовать" className="mt-[20px]" /> - - - { setName(v) }} placeholder="Новая статья" /> - - - {/* Блок для тегов */} -
- -
- { setTagInput(v) }} - defaultState={tagInput} - placeholder="arrays" - onKeyDown={(e) => { - console.log(e.key); - if (e.key == "Enter") - addTag(); - } - } - /> - - + } + /> + +
+ Создание статьи
-
- {tags.map(tag => ( -
- {tag} - -
- ))} + + { + console.log({ + name: name, + tags: tags, + text: code, + }); + }} + text="Опубликовать" + className="mt-[20px]" + /> + + { + setName(v); + }} + placeholder="Новая статья" + /> + + {/* Блок для тегов */} +
+
+ { + setTagInput(v); + }} + defaultState={tagInput} + placeholder="arrays" + onKeyDown={(e) => { + console.log(e.key); + if (e.key == 'Enter') addTag(); + }} + /> + +
+
+ {tags.map((tag) => ( +
+ {tag} + +
+ ))} +
+ + navigate('editor')} + text="Редактировать текст" + className="mt-[20px]" + /> +
- - navigate("editor")} text="Редактировать текст" className="mt-[20px]" /> - -
- } /> + } + />
); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 99d7b21..1cdb00c 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,60 +1,75 @@ // import React from "react"; -import { Route, Routes } from "react-router-dom"; -import Login from "../views/home/auth/Login"; -import Register from "../views/home/auth/Register"; -import Menu from "../views/home/menu/Menu"; -import { useAppDispatch, useAppSelector } from "../redux/hooks"; -import { useEffect } from "react"; -import { fetchWhoAmI, logout } from "../redux/slices/auth"; -import Missions from "../views/home/missions/Missions"; -import Articles from "../views/home/articles/Articles"; -import Groups from "../views/home/groups/Groups"; -import Contests from "../views/home/contests/Contests"; -import { PrimaryButton } from "../components/button/PrimaryButton"; -import Group from "../views/home/groups/Group"; +import { Route, Routes } from 'react-router-dom'; +import Login from '../views/home/auth/Login'; +import Register from '../views/home/auth/Register'; +import Menu from '../views/home/menu/Menu'; +import { useAppDispatch, useAppSelector } from '../redux/hooks'; +import { useEffect } from 'react'; +import { fetchWhoAmI, logout } from '../redux/slices/auth'; +import Missions from '../views/home/missions/Missions'; +import Articles from '../views/home/articles/Articles'; +import Groups from '../views/home/groups/Groups'; +import Contests from '../views/home/contests/Contests'; +import { PrimaryButton } from '../components/button/PrimaryButton'; +import Group from '../views/home/groups/Group'; const Home = () => { - const name = useAppSelector((state) => state.auth.username); - const jwt = useAppSelector((state) => state.auth.jwt); - const dispatch = useAppDispatch(); + const name = useAppSelector((state) => state.auth.username); + const jwt = useAppSelector((state) => state.auth.jwt); + const dispatch = useAppDispatch(); - useEffect(() => { - dispatch(fetchWhoAmI()); - }, [jwt]) + useEffect(() => { + dispatch(fetchWhoAmI()); + }, [jwt]); - - return ( -
-
- -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -

{jwt}

- {if (jwt) navigator.clipboard.writeText(jwt);}} text="скопировать токен" className="pt-[20px]"/> -

{name}

- {dispatch(logout())}}>выйти - - } - /> -
-
- { - -
} /> -
- } -
- ); + return ( +
+
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +

{jwt}

+ { + if (jwt) + navigator.clipboard.writeText(jwt); + }} + text="скопировать токен" + className="pt-[20px]" + /> +

{name}

+ { + dispatch(logout()); + }} + > + выйти + + + } + /> +
+
+ { + +
} /> + + } +
+ ); }; export default Home; diff --git a/src/pages/Mission.tsx b/src/pages/Mission.tsx index e44f0c3..24a81d7 100644 --- a/src/pages/Mission.tsx +++ b/src/pages/Mission.tsx @@ -10,187 +10,191 @@ import Header from '../views/mission/statement/Header'; import MissionSubmissions from '../views/mission/statement/MissionSubmissions'; const Mission = () => { + const dispatch = useAppDispatch(); - const dispatch = useAppDispatch(); - - // Получаем параметры из URL - const { missionId } = useParams<{ missionId: string }>(); - const mission = useAppSelector((state) => state.missions.currentMission); - const missionIdNumber = Number(missionId); - if (!missionId || isNaN(missionIdNumber)) { - return ; - } - - const [code, setCode] = useState(""); - const [language, setLanguage] = useState(""); - - const pollingRef = useRef(null); - const submissions = useAppSelector((state) => state.submin.submitsById[missionIdNumber] || []); - const submissionsRef = useRef(submissions); - - - - - - const startPolling = () => { - if (pollingRef.current) - return; - - pollingRef.current = setInterval(async () => { - dispatch(fetchMySubmitsByMission(missionIdNumber)); - - const hasWaiting = submissionsRef.current.some( - (s: any) => s.solution.status == "Waiting" || s.solution.testerState === "Waiting" - ); - if (!hasWaiting) { - // Всё проверено — стоп - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - } - }, 5000); // 10 секунд - }; - - - - useEffect(() => { - dispatch(fetchMissionById(missionIdNumber)); - dispatch(fetchMySubmitsByMission(missionIdNumber)); - }, [missionIdNumber]); - - useEffect(() => { - }, [submissions]); - - useEffect(() => { - return () => { - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - }; - }, []); - - - useEffect(() => { - submissionsRef.current = submissions; - - if (submissions.length) { - const hasWaiting = submissions.some( - s => s.solution.status === "Waiting" || s.solution.testerState === "Waiting" - ); - - if (hasWaiting) { - startPolling(); - } + // Получаем параметры из URL + const { missionId } = useParams<{ missionId: string }>(); + const mission = useAppSelector((state) => state.missions.currentMission); + const missionIdNumber = Number(missionId); + if (!missionId || isNaN(missionIdNumber)) { + return ; } - }, [submissions]); + const [code, setCode] = useState(''); + const [language, setLanguage] = useState(''); - if (!mission || !mission.statements || mission.statements.length === 0) { - return
Загрузка...
; - } - - interface StatementData { - id: number; - legend?: string; - timeLimit?: number; - output?: string; - input?: string; - sampleTests?: any[]; - name?: string; - memoryLimit?: number; - tags?: string[]; - notes?: string; - html?: string; - mediaFiles?: any[]; - } - - let statementData: StatementData = { id: mission.id }; - - try { - // 1. Берём первый statement с форматом Latex и языком russian - const latexStatement = mission.statements.find( - (stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Latex" + const pollingRef = useRef(null); + const submissions = useAppSelector( + (state) => state.submin.submitsById[missionIdNumber] || [], ); + const submissionsRef = useRef(submissions); - // 2. Берём первый statement с форматом Html и языком russian - const htmlStatement = mission.statements.find( - (stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Html" - ); + const startPolling = () => { + if (pollingRef.current) return; - if (!latexStatement) throw new Error("Не найден блок Latex на русском"); - if (!htmlStatement) throw new Error("Не найден блок Html на русском"); + pollingRef.current = setInterval(async () => { + dispatch(fetchMySubmitsByMission(missionIdNumber)); - // 3. Парсим данные из problem-properties.json - const statementTexts = JSON.parse(latexStatement.statementTexts["problem-properties.json"]); - - statementData = { - id: missionIdNumber, - legend: statementTexts.legend, - timeLimit: statementTexts.timeLimit, - output: statementTexts.output, - input: statementTexts.input, - sampleTests: statementTexts.sampleTests, - name: statementTexts.name, - memoryLimit: statementTexts.memoryLimit, - tags: mission.tags, - notes: statementTexts.notes, - html: htmlStatement.statementTexts["problem.html"], - mediaFiles: latexStatement.mediaFiles + const hasWaiting = submissionsRef.current.some( + (s: any) => + s.solution.status == 'Waiting' || + s.solution.testerState === 'Waiting', + ); + if (!hasWaiting) { + // Всё проверено — стоп + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } + }, 5000); // 10 секунд }; - } catch (err) { - console.error("Ошибка парсинга statementTexts:", err); - } + useEffect(() => { + dispatch(fetchMissionById(missionIdNumber)); + dispatch(fetchMySubmitsByMission(missionIdNumber)); + }, [missionIdNumber]); + useEffect(() => {}, [submissions]); + useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + }; + }, []); - return ( + useEffect(() => { + submissionsRef.current = submissions; -
-
-
-
+ if (submissions.length) { + const hasWaiting = submissions.some( + (s) => + s.solution.status === 'Waiting' || + s.solution.testerState === 'Waiting', + ); -
-
- + if (!mission || !mission.statements || mission.statements.length === 0) { + return
Загрузка...
; + } + + interface StatementData { + id: number; + legend?: string; + timeLimit?: number; + output?: string; + input?: string; + sampleTests?: any[]; + name?: string; + memoryLimit?: number; + tags?: string[]; + notes?: string; + html?: string; + mediaFiles?: any[]; + } + + let statementData: StatementData = { id: mission.id }; + + try { + // 1. Берём первый statement с форматом Latex и языком russian + const latexStatement = mission.statements.find( + (stmt: any) => + stmt && stmt.language === 'russian' && stmt.format === 'Latex', + ); + + // 2. Берём первый statement с форматом Html и языком russian + const htmlStatement = mission.statements.find( + (stmt: any) => + stmt && stmt.language === 'russian' && stmt.format === 'Html', + ); + + if (!latexStatement) throw new Error('Не найден блок Latex на русском'); + if (!htmlStatement) throw new Error('Не найден блок Html на русском'); + + // 3. Парсим данные из problem-properties.json + const statementTexts = JSON.parse( + latexStatement.statementTexts['problem-properties.json'], + ); + + statementData = { + id: missionIdNumber, + legend: statementTexts.legend, + timeLimit: statementTexts.timeLimit, + output: statementTexts.output, + input: statementTexts.input, + sampleTests: statementTexts.sampleTests, + name: statementTexts.name, + memoryLimit: statementTexts.memoryLimit, + tags: mission.tags, + notes: statementTexts.notes, + html: htmlStatement.statementTexts['problem.html'], + mediaFiles: latexStatement.mediaFiles, + }; + } catch (err) { + console.error('Ошибка парсинга statementTexts:', err); + } + + return ( +
+
+
+
+ +
+
+ +
+ +
+
+
+ { + setCode(value); + }} + onChangeLanguage={(value: string) => { + setLanguage(value); + }} + /> +
+
+ { + await dispatch( + submitMission({ + missionId: missionIdNumber, + language: language, + languageVersion: 'latest', + sourceCode: code, + contestId: null, + }), + ).unwrap(); + dispatch( + fetchMySubmitsByMission( + missionIdNumber, + ), + ); + }} + /> +
+ +
+ +
+
+
+
- -
-
-
- { setCode(value); }} - onChangeLanguage={((value: string) => { setLanguage(value); })} - /> -
-
- { - await dispatch(submitMission({ - missionId: missionIdNumber, - language: language, - languageVersion: "latest", - sourceCode: code, - contestId: null, - - })).unwrap(); - dispatch(fetchMySubmitsByMission(missionIdNumber)); - }} /> -
- -
- -
-
-
-
-
- ); + ); }; export default Mission; diff --git a/src/redux/slices/auth.ts b/src/redux/slices/auth.ts index 64d4bee..6ea53ca 100644 --- a/src/redux/slices/auth.ts +++ b/src/redux/slices/auth.ts @@ -1,188 +1,260 @@ -import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; -import axios from "../../axios"; +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from '../../axios'; // Типы данных interface AuthState { - jwt: string | null; - refreshToken: string | null; - username: string | null; - status: "idle" | "loading" | "successful" | "failed"; - error: string | null; + jwt: string | null; + refreshToken: string | null; + username: string | null; + status: 'idle' | 'loading' | 'successful' | 'failed'; + error: string | null; } // Инициализация состояния const initialState: AuthState = { - jwt: null, - refreshToken: null, - username: null, - status: "idle", - error: null, + jwt: null, + refreshToken: null, + username: null, + status: 'idle', + error: null, }; // AsyncThunk: Регистрация export const registerUser = createAsyncThunk( - "auth/register", - async ( - { username, email, password }: { username: string; email: string; password: string }, - { rejectWithValue } - ) => { - try { - const response = await axios.post("/authentication/register", { username, email, password }); - return response.data; // { jwt, refreshToken } - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Registration failed"); - } - } + 'auth/register', + async ( + { + username, + email, + password, + }: { username: string; email: string; password: string }, + { rejectWithValue }, + ) => { + try { + const response = await axios.post('/authentication/register', { + username, + email, + password, + }); + return response.data; // { jwt, refreshToken } + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Registration failed', + ); + } + }, ); // AsyncThunk: Логин export const loginUser = createAsyncThunk( - "auth/login", - async ( - { username, password }: { username: string; password: string }, - { rejectWithValue } - ) => { - try { - const response = await axios.post("/authentication/login", { username, password }); - return response.data; // { jwt, refreshToken } - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Login failed"); - } - } + 'auth/login', + async ( + { username, password }: { username: string; password: string }, + { rejectWithValue }, + ) => { + try { + const response = await axios.post('/authentication/login', { + username, + password, + }); + return response.data; // { jwt, refreshToken } + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Login failed', + ); + } + }, ); // AsyncThunk: Обновление токена export const refreshToken = createAsyncThunk( - "auth/refresh", - async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => { - try { - const response = await axios.post("/authentication/refresh", { refreshToken }); - return response.data; // { username } - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Refresh token failed"); - } - } + 'auth/refresh', + async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => { + try { + const response = await axios.post('/authentication/refresh', { + refreshToken, + }); + return response.data; // { username } + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Refresh token failed', + ); + } + }, ); // AsyncThunk: Получение информации о пользователе export const fetchWhoAmI = createAsyncThunk( - "auth/whoami", - async (_, { rejectWithValue }) => { - try { - const response = await axios.get("/authentication/whoami"); - return response.data; // { username } - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Failed to fetch user info"); - } - } + 'auth/whoami', + async (_, { rejectWithValue }) => { + try { + const response = await axios.get('/authentication/whoami'); + return response.data; // { username } + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to fetch user info', + ); + } + }, ); // AsyncThunk: Загрузка токенов из localStorage export const loadTokensFromLocalStorage = createAsyncThunk( - "auth/loadTokens", - async (_, { }) => { - const jwt = localStorage.getItem("jwt"); - const refreshToken = localStorage.getItem("refreshToken"); + 'auth/loadTokens', + async (_, {}) => { + const jwt = localStorage.getItem('jwt'); + const refreshToken = localStorage.getItem('refreshToken'); - if (jwt && refreshToken) { - axios.defaults.headers.common['Authorization'] = `Bearer ${jwt}`; - return { jwt, refreshToken }; - } else { - return { jwt: null, refreshToken: null }; - } - } + if (jwt && refreshToken) { + axios.defaults.headers.common['Authorization'] = `Bearer ${jwt}`; + return { jwt, refreshToken }; + } else { + return { jwt: null, refreshToken: null }; + } + }, ); // Slice const authSlice = createSlice({ - name: "auth", - initialState, - reducers: { - logout: (state) => { - state.jwt = null; - state.refreshToken = null; - state.username = null; - state.status = "idle"; - state.error = null; - localStorage.removeItem("jwt"); - localStorage.removeItem("refreshToken"); - delete axios.defaults.headers.common['Authorization']; + name: 'auth', + initialState, + reducers: { + logout: (state) => { + state.jwt = null; + state.refreshToken = null; + state.username = null; + state.status = 'idle'; + state.error = null; + localStorage.removeItem('jwt'); + localStorage.removeItem('refreshToken'); + delete axios.defaults.headers.common['Authorization']; + }, }, - }, - extraReducers: (builder) => { - // Регистрация - builder.addCase(registerUser.pending, (state) => { - state.status = "loading"; - state.error = null; - }); - builder.addCase(registerUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => { - state.status = "successful"; - state.jwt = action.payload.jwt; - state.refreshToken = action.payload.refreshToken; - axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; - localStorage.setItem("jwt", action.payload.jwt); - localStorage.setItem("refreshToken", action.payload.refreshToken); - }); - builder.addCase(registerUser.rejected, (state, action: PayloadAction) => { - state.status = "failed"; - state.error = action.payload; - }); + extraReducers: (builder) => { + // Регистрация + builder.addCase(registerUser.pending, (state) => { + state.status = 'loading'; + state.error = null; + }); + builder.addCase( + registerUser.fulfilled, + ( + state, + action: PayloadAction<{ jwt: string; refreshToken: string }>, + ) => { + state.status = 'successful'; + state.jwt = action.payload.jwt; + state.refreshToken = action.payload.refreshToken; + axios.defaults.headers.common[ + 'Authorization' + ] = `Bearer ${action.payload.jwt}`; + localStorage.setItem('jwt', action.payload.jwt); + localStorage.setItem( + 'refreshToken', + action.payload.refreshToken, + ); + }, + ); + builder.addCase( + registerUser.rejected, + (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + ); - // Логин - builder.addCase(loginUser.pending, (state) => { - state.status = "loading"; - state.error = null; - }); - builder.addCase(loginUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => { - state.status = "successful"; - state.jwt = action.payload.jwt; - state.refreshToken = action.payload.refreshToken; - axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; - localStorage.setItem("jwt", action.payload.jwt); - localStorage.setItem("refreshToken", action.payload.refreshToken); - }); - builder.addCase(loginUser.rejected, (state, action: PayloadAction) => { - state.status = "failed"; - state.error = action.payload; - }); + // Логин + builder.addCase(loginUser.pending, (state) => { + state.status = 'loading'; + state.error = null; + }); + builder.addCase( + loginUser.fulfilled, + ( + state, + action: PayloadAction<{ jwt: string; refreshToken: string }>, + ) => { + state.status = 'successful'; + state.jwt = action.payload.jwt; + state.refreshToken = action.payload.refreshToken; + axios.defaults.headers.common[ + 'Authorization' + ] = `Bearer ${action.payload.jwt}`; + localStorage.setItem('jwt', action.payload.jwt); + localStorage.setItem( + 'refreshToken', + action.payload.refreshToken, + ); + }, + ); + builder.addCase( + loginUser.rejected, + (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + ); - // Обновление токена - builder.addCase(refreshToken.pending, (state) => { - state.status = "loading"; - state.error = null; - }); - builder.addCase(refreshToken.fulfilled, (state, action: PayloadAction<{ username: string }>) => { - state.status = "successful"; - state.username = action.payload.username; - }); - builder.addCase(refreshToken.rejected, (state, action: PayloadAction) => { - state.status = "failed"; - state.error = action.payload; - }); + // Обновление токена + builder.addCase(refreshToken.pending, (state) => { + state.status = 'loading'; + state.error = null; + }); + builder.addCase( + refreshToken.fulfilled, + (state, action: PayloadAction<{ username: string }>) => { + state.status = 'successful'; + state.username = action.payload.username; + }, + ); + builder.addCase( + refreshToken.rejected, + (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + ); - // Получение информации о пользователе - builder.addCase(fetchWhoAmI.pending, (state) => { - state.status = "loading"; - state.error = null; - }); - builder.addCase(fetchWhoAmI.fulfilled, (state, action: PayloadAction<{ username: string }>) => { - state.status = "successful"; - state.username = action.payload.username; - }); - builder.addCase(fetchWhoAmI.rejected, (state, action: PayloadAction) => { - state.status = "failed"; - state.error = action.payload; - }); + // Получение информации о пользователе + builder.addCase(fetchWhoAmI.pending, (state) => { + state.status = 'loading'; + state.error = null; + }); + builder.addCase( + fetchWhoAmI.fulfilled, + (state, action: PayloadAction<{ username: string }>) => { + state.status = 'successful'; + state.username = action.payload.username; + }, + ); + builder.addCase( + fetchWhoAmI.rejected, + (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + ); - // Загрузка токенов из localStorage - builder.addCase(loadTokensFromLocalStorage.fulfilled, (state, action: PayloadAction<{ jwt: string | null; refreshToken: string | null }>) => { - state.jwt = action.payload.jwt; - state.refreshToken = action.payload.refreshToken; - if (action.payload.jwt) { - axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; - } - }); - }, + // Загрузка токенов из localStorage + builder.addCase( + loadTokensFromLocalStorage.fulfilled, + ( + state, + action: PayloadAction<{ + jwt: string | null; + refreshToken: string | null; + }>, + ) => { + state.jwt = action.payload.jwt; + state.refreshToken = action.payload.refreshToken; + if (action.payload.jwt) { + axios.defaults.headers.common[ + 'Authorization' + ] = `Bearer ${action.payload.jwt}`; + } + }, + ); + }, }); export const { logout } = authSlice.actions; diff --git a/src/redux/slices/contests.ts b/src/redux/slices/contests.ts index 687b228..9ccd751 100644 --- a/src/redux/slices/contests.ts +++ b/src/redux/slices/contests.ts @@ -1,58 +1,58 @@ -import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; -import axios from "../../axios"; +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from '../../axios'; // ===================== // Типы // ===================== export interface Mission { - missionId: number; - name: string; - sortOrder: number; + missionId: number; + name: string; + sortOrder: number; } export interface Member { - userId: number; - username: string; - role: string; + userId: number; + username: string; + role: string; } export interface Contest { - id: number; - name: string; - description: string; - scheduleType: string; - startsAt: string; - endsAt: string; - availableFrom: string | null; - availableUntil: string | null; - attemptDurationMinutes: number | null; - groupId: number | null; - groupName: string | null; - missions: Mission[]; - articles: any[]; - members: Member[]; + id: number; + name: string; + description: string; + scheduleType: string; + startsAt: string; + endsAt: string; + availableFrom: string | null; + availableUntil: string | null; + attemptDurationMinutes: number | null; + groupId: number | null; + groupName: string | null; + missions: Mission[]; + articles: any[]; + members: Member[]; } interface ContestsResponse { - hasNextPage: boolean; - contests: Contest[]; + hasNextPage: boolean; + contests: Contest[]; } export interface CreateContestBody { - name: string; - description: string; - scheduleType: "FixedWindow" | "Flexible"; - startsAt: string; - endsAt: string; - availableFrom: string | null; - availableUntil: string | null; - attemptDurationMinutes: number | null; - groupId: number | null; - missionIds: number[]; - articleIds: number[]; - participantIds: number[]; - organizerIds: number[]; + name: string; + description: string; + scheduleType: 'FixedWindow' | 'Flexible'; + startsAt: string; + endsAt: string; + availableFrom: string | null; + availableUntil: string | null; + attemptDurationMinutes: number | null; + groupId: number | null; + missionIds: number[]; + articleIds: number[]; + participantIds: number[]; + organizerIds: number[]; } // ===================== @@ -60,19 +60,19 @@ export interface CreateContestBody { // ===================== interface ContestsState { - contests: Contest[]; - selectedContest: Contest | null; - hasNextPage: boolean; - status: "idle" | "loading" | "successful" | "failed"; - error: string | null; + contests: Contest[]; + selectedContest: Contest | null; + hasNextPage: boolean; + status: 'idle' | 'loading' | 'successful' | 'failed'; + error: string | null; } const initialState: ContestsState = { - contests: [], - selectedContest: null, - hasNextPage: false, - status: "idle", - error: null, + contests: [], + selectedContest: null, + hasNextPage: false, + status: 'idle', + error: null, }; // ===================== @@ -81,47 +81,60 @@ const initialState: ContestsState = { // Получение списка контестов export const fetchContests = createAsyncThunk( - "contests/fetchAll", - async ( - params: { page?: number; pageSize?: number; groupId?: number | null } = {}, - { rejectWithValue } - ) => { - try { - const { page = 0, pageSize = 10, groupId } = params; - const response = await axios.get("/contests", { - params: { page, pageSize, groupId }, - }); - return response.data; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Failed to fetch contests"); - } - } + 'contests/fetchAll', + async ( + params: { + page?: number; + pageSize?: number; + groupId?: number | null; + } = {}, + { rejectWithValue }, + ) => { + try { + const { page = 0, pageSize = 10, groupId } = params; + const response = await axios.get('/contests', { + params: { page, pageSize, groupId }, + }); + return response.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to fetch contests', + ); + } + }, ); // Получение одного контеста по ID export const fetchContestById = createAsyncThunk( - "contests/fetchById", - async (id: number, { rejectWithValue }) => { - try { - const response = await axios.get(`/contests/${id}`); - return response.data; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Failed to fetch contest"); - } - } + 'contests/fetchById', + async (id: number, { rejectWithValue }) => { + try { + const response = await axios.get(`/contests/${id}`); + return response.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to fetch contest', + ); + } + }, ); // Создание нового контеста export const createContest = createAsyncThunk( - "contests/create", - async (contestData: CreateContestBody, { rejectWithValue }) => { - try { - const response = await axios.post("/contests", contestData); - return response.data; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Failed to create contest"); - } - } + 'contests/create', + async (contestData: CreateContestBody, { rejectWithValue }) => { + try { + const response = await axios.post( + '/contests', + contestData, + ); + return response.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to create contest', + ); + } + }, ); // ===================== @@ -129,57 +142,75 @@ export const createContest = createAsyncThunk( // ===================== const contestsSlice = createSlice({ - name: "contests", - initialState, - reducers: { - clearSelectedContest: (state) => { - state.selectedContest = null; + name: 'contests', + initialState, + reducers: { + clearSelectedContest: (state) => { + state.selectedContest = null; + }, }, - }, - extraReducers: (builder) => { - // fetchContests - builder.addCase(fetchContests.pending, (state) => { - state.status = "loading"; - state.error = null; - }); - builder.addCase(fetchContests.fulfilled, (state, action: PayloadAction) => { - state.status = "successful"; - state.contests = action.payload.contests; - state.hasNextPage = action.payload.hasNextPage; - }); - builder.addCase(fetchContests.rejected, (state, action: PayloadAction) => { - state.status = "failed"; - state.error = action.payload; - }); + extraReducers: (builder) => { + // fetchContests + builder.addCase(fetchContests.pending, (state) => { + state.status = 'loading'; + state.error = null; + }); + builder.addCase( + fetchContests.fulfilled, + (state, action: PayloadAction) => { + state.status = 'successful'; + state.contests = action.payload.contests; + state.hasNextPage = action.payload.hasNextPage; + }, + ); + builder.addCase( + fetchContests.rejected, + (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + ); - // fetchContestById - builder.addCase(fetchContestById.pending, (state) => { - state.status = "loading"; - state.error = null; - }); - builder.addCase(fetchContestById.fulfilled, (state, action: PayloadAction) => { - state.status = "successful"; - state.selectedContest = action.payload; - }); - builder.addCase(fetchContestById.rejected, (state, action: PayloadAction) => { - state.status = "failed"; - state.error = action.payload; - }); + // fetchContestById + builder.addCase(fetchContestById.pending, (state) => { + state.status = 'loading'; + state.error = null; + }); + builder.addCase( + fetchContestById.fulfilled, + (state, action: PayloadAction) => { + state.status = 'successful'; + state.selectedContest = action.payload; + }, + ); + builder.addCase( + fetchContestById.rejected, + (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + ); - // createContest - builder.addCase(createContest.pending, (state) => { - state.status = "loading"; - state.error = null; - }); - builder.addCase(createContest.fulfilled, (state, action: PayloadAction) => { - state.status = "successful"; - state.contests.unshift(action.payload); - }); - builder.addCase(createContest.rejected, (state, action: PayloadAction) => { - state.status = "failed"; - state.error = action.payload; - }); - }, + // createContest + builder.addCase(createContest.pending, (state) => { + state.status = 'loading'; + state.error = null; + }); + builder.addCase( + createContest.fulfilled, + (state, action: PayloadAction) => { + state.status = 'successful'; + state.contests.unshift(action.payload); + }, + ); + builder.addCase( + createContest.rejected, + (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + ); + }, }); // ===================== diff --git a/src/redux/slices/groups.ts b/src/redux/slices/groups.ts index c42a9e5..e9c586d 100644 --- a/src/redux/slices/groups.ts +++ b/src/redux/slices/groups.ts @@ -1,282 +1,349 @@ -import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; -import axios from "../../axios"; +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from '../../axios'; // ─── Типы ──────────────────────────────────────────── -type Status = "idle" | "loading" | "successful" | "failed"; +type Status = 'idle' | 'loading' | 'successful' | 'failed'; export interface GroupMember { - userId: number; - username: string; - role: string; + userId: number; + username: string; + role: string; } export interface Group { - id: number; - name: string; - description: string; - members: GroupMember[]; - contests: any[]; + id: number; + name: string; + description: string; + members: GroupMember[]; + contests: any[]; } interface GroupsState { - groups: Group[]; - currentGroup: Group | null; - statuses: { - create: Status; - update: Status; - delete: Status; - fetchMy: Status; - fetchById: Status; - addMember: Status; - removeMember: Status; - }; - error: string | null; + groups: Group[]; + currentGroup: Group | null; + statuses: { + create: Status; + update: Status; + delete: Status; + fetchMy: Status; + fetchById: Status; + addMember: Status; + removeMember: Status; + }; + error: string | null; } const initialState: GroupsState = { - groups: [], - currentGroup: null, - statuses: { - create: "idle", - update: "idle", - delete: "idle", - fetchMy: "idle", - fetchById: "idle", - addMember: "idle", - removeMember: "idle", - }, - error: null, + groups: [], + currentGroup: null, + statuses: { + create: 'idle', + update: 'idle', + delete: 'idle', + fetchMy: 'idle', + fetchById: 'idle', + addMember: 'idle', + removeMember: 'idle', + }, + error: null, }; - // ─── Async Thunks ───────────────────────────────────── // POST /groups export const createGroup = createAsyncThunk( - "groups/createGroup", - async ( - { name, description }: { name: string; description: string }, - { rejectWithValue } - ) => { - try { - const response = await axios.post("/groups", { name, description }); - return response.data as Group; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Ошибка при создании группы"); - } - } + 'groups/createGroup', + async ( + { name, description }: { name: string; description: string }, + { rejectWithValue }, + ) => { + try { + const response = await axios.post('/groups', { name, description }); + return response.data as Group; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при создании группы', + ); + } + }, ); // PUT /groups/{groupId} export const updateGroup = createAsyncThunk( - "groups/updateGroup", - async ( - { groupId, name, description }: { groupId: number; name: string; description: string }, - { rejectWithValue } - ) => { - try { - const response = await axios.put(`/groups/${groupId}`, { name, description }); - return response.data as Group; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Ошибка при обновлении группы"); - } - } + 'groups/updateGroup', + async ( + { + groupId, + name, + description, + }: { groupId: number; name: string; description: string }, + { rejectWithValue }, + ) => { + try { + const response = await axios.put(`/groups/${groupId}`, { + name, + description, + }); + return response.data as Group; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при обновлении группы', + ); + } + }, ); // DELETE /groups/{groupId} export const deleteGroup = createAsyncThunk( - "groups/deleteGroup", - async (groupId: number, { rejectWithValue }) => { - try { - await axios.delete(`/groups/${groupId}`); - return groupId; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Ошибка при удалении группы"); - } - } + 'groups/deleteGroup', + async (groupId: number, { rejectWithValue }) => { + try { + await axios.delete(`/groups/${groupId}`); + return groupId; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при удалении группы', + ); + } + }, ); // GET /groups/my export const fetchMyGroups = createAsyncThunk( - "groups/fetchMyGroups", - async (_, { rejectWithValue }) => { - try { - const response = await axios.get("/groups/my"); - return response.data.groups as Group[]; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Ошибка при получении групп"); - } - } + 'groups/fetchMyGroups', + async (_, { rejectWithValue }) => { + try { + const response = await axios.get('/groups/my'); + return response.data.groups as Group[]; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при получении групп', + ); + } + }, ); // GET /groups/{groupId} export const fetchGroupById = createAsyncThunk( - "groups/fetchGroupById", - async (groupId: number, { rejectWithValue }) => { - try { - const response = await axios.get(`/groups/${groupId}`); - return response.data as Group; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Ошибка при получении группы"); - } - } + 'groups/fetchGroupById', + async (groupId: number, { rejectWithValue }) => { + try { + const response = await axios.get(`/groups/${groupId}`); + return response.data as Group; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при получении группы', + ); + } + }, ); // POST /groups/members export const addGroupMember = createAsyncThunk( - "groups/addGroupMember", - async ({ userId, role }: { userId: number; role: string }, { rejectWithValue }) => { - try { - await axios.post("/groups/members", { userId, role }); - return { userId, role }; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Ошибка при добавлении участника"); - } - } + 'groups/addGroupMember', + async ( + { userId, role }: { userId: number; role: string }, + { rejectWithValue }, + ) => { + try { + await axios.post('/groups/members', { userId, role }); + return { userId, role }; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || + 'Ошибка при добавлении участника', + ); + } + }, ); // DELETE /groups/{groupId}/members/{memberId} export const removeGroupMember = createAsyncThunk( - "groups/removeGroupMember", - async ( - { groupId, memberId }: { groupId: number; memberId: number }, - { rejectWithValue } - ) => { - try { - await axios.delete(`/groups/${groupId}/members/${memberId}`); - return { groupId, memberId }; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Ошибка при удалении участника"); - } - } + 'groups/removeGroupMember', + async ( + { groupId, memberId }: { groupId: number; memberId: number }, + { rejectWithValue }, + ) => { + try { + await axios.delete(`/groups/${groupId}/members/${memberId}`); + return { groupId, memberId }; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Ошибка при удалении участника', + ); + } + }, ); // ─── Slice ──────────────────────────────────────────── const groupsSlice = createSlice({ - name: "groups", - initialState, - reducers: { - clearCurrentGroup: (state) => { - state.currentGroup = null; + name: 'groups', + initialState, + reducers: { + clearCurrentGroup: (state) => { + state.currentGroup = null; + }, }, - }, - extraReducers: (builder) => { - // ─── CREATE GROUP ─── - builder.addCase(createGroup.pending, (state) => { - state.statuses.create = "loading"; - state.error = null; - }); - builder.addCase(createGroup.fulfilled, (state, action: PayloadAction) => { - state.statuses.create = "successful"; - state.groups.push(action.payload); - }); - builder.addCase(createGroup.rejected, (state, action: PayloadAction) => { - state.statuses.create = "failed"; - state.error = action.payload; - }); - - - // ─── UPDATE GROUP ─── - builder.addCase(updateGroup.pending, (state) => { - state.statuses.update = "loading"; - state.error = null; - }); - builder.addCase(updateGroup.fulfilled, (state, action: PayloadAction) => { - state.statuses.update = "successful"; - const index = state.groups.findIndex((g) => g.id === action.payload.id); - if (index !== -1) state.groups[index] = action.payload; - if (state.currentGroup?.id === action.payload.id) { - state.currentGroup = action.payload; - } - }); - builder.addCase(updateGroup.rejected, (state, action: PayloadAction) => { - state.statuses.update = "failed"; - state.error = action.payload; - }); - - - - // ─── DELETE GROUP ─── - builder.addCase(deleteGroup.pending, (state) => { - state.statuses.delete = "loading"; - state.error = null; - }); - builder.addCase(deleteGroup.fulfilled, (state, action: PayloadAction) => { - state.statuses.delete = "successful"; - state.groups = state.groups.filter((g) => g.id !== action.payload); - if (state.currentGroup?.id === action.payload) state.currentGroup = null; - }); - builder.addCase(deleteGroup.rejected, (state, action: PayloadAction) => { - state.statuses.delete = "failed"; - state.error = action.payload; - }); - - - // ─── FETCH MY GROUPS ─── - builder.addCase(fetchMyGroups.pending, (state) => { - state.statuses.fetchMy = "loading"; - state.error = null; - }); - builder.addCase(fetchMyGroups.fulfilled, (state, action: PayloadAction) => { - state.statuses.fetchMy = "successful"; - state.groups = action.payload; - }); - builder.addCase(fetchMyGroups.rejected, (state, action: PayloadAction) => { - state.statuses.fetchMy = "failed"; - state.error = action.payload; - }); - - - // ─── FETCH GROUP BY ID ─── - builder.addCase(fetchGroupById.pending, (state) => { - state.statuses.fetchById = "loading"; - state.error = null; - }); - builder.addCase(fetchGroupById.fulfilled, (state, action: PayloadAction) => { - state.statuses.fetchById = "successful"; - state.currentGroup = action.payload; - }); - builder.addCase(fetchGroupById.rejected, (state, action: PayloadAction) => { - state.statuses.fetchById = "failed"; - state.error = action.payload; - }); - - - // ─── ADD MEMBER ─── - builder.addCase(addGroupMember.pending, (state) => { - state.statuses.addMember = "loading"; - state.error = null; - }); - builder.addCase(addGroupMember.fulfilled, (state) => { - state.statuses.addMember = "successful"; - }); - builder.addCase(addGroupMember.rejected, (state, action: PayloadAction) => { - state.statuses.addMember = "failed"; - state.error = action.payload; - }); - - - // ─── REMOVE MEMBER ─── - builder.addCase(removeGroupMember.pending, (state) => { - state.statuses.removeMember = "loading"; - state.error = null; - }); - builder.addCase(removeGroupMember.fulfilled, (state, action: PayloadAction<{ groupId: number; memberId: number }>) => { - state.statuses.removeMember = "successful"; - if (state.currentGroup && state.currentGroup.id === action.payload.groupId) { - state.currentGroup.members = state.currentGroup.members.filter( - (m) => m.userId !== action.payload.memberId + extraReducers: (builder) => { + // ─── CREATE GROUP ─── + builder.addCase(createGroup.pending, (state) => { + state.statuses.create = 'loading'; + state.error = null; + }); + builder.addCase( + createGroup.fulfilled, + (state, action: PayloadAction) => { + state.statuses.create = 'successful'; + state.groups.push(action.payload); + }, + ); + builder.addCase( + createGroup.rejected, + (state, action: PayloadAction) => { + state.statuses.create = 'failed'; + state.error = action.payload; + }, ); - } - }); - builder.addCase(removeGroupMember.rejected, (state, action: PayloadAction) => { - state.statuses.removeMember = "failed"; - state.error = action.payload; - }); - }, + // ─── UPDATE GROUP ─── + builder.addCase(updateGroup.pending, (state) => { + state.statuses.update = 'loading'; + state.error = null; + }); + builder.addCase( + updateGroup.fulfilled, + (state, action: PayloadAction) => { + state.statuses.update = 'successful'; + const index = state.groups.findIndex( + (g) => g.id === action.payload.id, + ); + if (index !== -1) state.groups[index] = action.payload; + if (state.currentGroup?.id === action.payload.id) { + state.currentGroup = action.payload; + } + }, + ); + builder.addCase( + updateGroup.rejected, + (state, action: PayloadAction) => { + state.statuses.update = 'failed'; + state.error = action.payload; + }, + ); + + // ─── DELETE GROUP ─── + builder.addCase(deleteGroup.pending, (state) => { + state.statuses.delete = 'loading'; + state.error = null; + }); + builder.addCase( + deleteGroup.fulfilled, + (state, action: PayloadAction) => { + state.statuses.delete = 'successful'; + state.groups = state.groups.filter( + (g) => g.id !== action.payload, + ); + if (state.currentGroup?.id === action.payload) + state.currentGroup = null; + }, + ); + builder.addCase( + deleteGroup.rejected, + (state, action: PayloadAction) => { + state.statuses.delete = 'failed'; + state.error = action.payload; + }, + ); + + // ─── FETCH MY GROUPS ─── + builder.addCase(fetchMyGroups.pending, (state) => { + state.statuses.fetchMy = 'loading'; + state.error = null; + }); + builder.addCase( + fetchMyGroups.fulfilled, + (state, action: PayloadAction) => { + state.statuses.fetchMy = 'successful'; + state.groups = action.payload; + }, + ); + builder.addCase( + fetchMyGroups.rejected, + (state, action: PayloadAction) => { + state.statuses.fetchMy = 'failed'; + state.error = action.payload; + }, + ); + + // ─── FETCH GROUP BY ID ─── + builder.addCase(fetchGroupById.pending, (state) => { + state.statuses.fetchById = 'loading'; + state.error = null; + }); + builder.addCase( + fetchGroupById.fulfilled, + (state, action: PayloadAction) => { + state.statuses.fetchById = 'successful'; + state.currentGroup = action.payload; + }, + ); + builder.addCase( + fetchGroupById.rejected, + (state, action: PayloadAction) => { + state.statuses.fetchById = 'failed'; + state.error = action.payload; + }, + ); + + // ─── ADD MEMBER ─── + builder.addCase(addGroupMember.pending, (state) => { + state.statuses.addMember = 'loading'; + state.error = null; + }); + builder.addCase(addGroupMember.fulfilled, (state) => { + state.statuses.addMember = 'successful'; + }); + builder.addCase( + addGroupMember.rejected, + (state, action: PayloadAction) => { + state.statuses.addMember = 'failed'; + state.error = action.payload; + }, + ); + + // ─── REMOVE MEMBER ─── + builder.addCase(removeGroupMember.pending, (state) => { + state.statuses.removeMember = 'loading'; + state.error = null; + }); + builder.addCase( + removeGroupMember.fulfilled, + ( + state, + action: PayloadAction<{ groupId: number; memberId: number }>, + ) => { + state.statuses.removeMember = 'successful'; + if ( + state.currentGroup && + state.currentGroup.id === action.payload.groupId + ) { + state.currentGroup.members = + state.currentGroup.members.filter( + (m) => m.userId !== action.payload.memberId, + ); + } + }, + ); + builder.addCase( + removeGroupMember.rejected, + (state, action: PayloadAction) => { + state.statuses.removeMember = 'failed'; + state.error = action.payload; + }, + ); + }, }); export const { clearCurrentGroup } = groupsSlice.actions; diff --git a/src/redux/slices/store.ts b/src/redux/slices/store.ts index d36e857..d67bb2b 100644 --- a/src/redux/slices/store.ts +++ b/src/redux/slices/store.ts @@ -1,29 +1,28 @@ -import { createSlice, PayloadAction} from "@reduxjs/toolkit"; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; // Типы данных interface StorState { - menu: { - activePage: string; - } + menu: { + activePage: string; + }; } // Инициализация состояния const initialState: StorState = { menu: { - activePage: "", - } + activePage: '', + }, }; - // Slice const storeSlice = createSlice({ - name: "store", - initialState, - reducers: { - setMenuActivePage: (state, activePage: PayloadAction) => { - state.menu.activePage = activePage.payload; + name: 'store', + initialState, + reducers: { + setMenuActivePage: (state, activePage: PayloadAction) => { + state.menu.activePage = activePage.payload; + }, }, - }, }); export const { setMenuActivePage } = storeSlice.actions; diff --git a/src/redux/slices/submit.ts b/src/redux/slices/submit.ts index fbe3265..b70efe8 100644 --- a/src/redux/slices/submit.ts +++ b/src/redux/slices/submit.ts @@ -1,184 +1,224 @@ -import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; -import axios from "../../axios"; +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from '../../axios'; // Типы данных export interface Submit { - id?: number; - missionId: number; - language: string; - languageVersion: string; - sourceCode: string; - contestId: number | null; + id?: number; + missionId: number; + language: string; + languageVersion: string; + sourceCode: string; + contestId: number | null; } export interface Solution { - id: number; - missionId: number; - language: string; - languageVersion: string; - sourceCode: string; - status: string; - time: string; - testerState: string; - testerErrorCode: string; - testerMessage: string; - currentTest: number; - amountOfTests: number; + id: number; + missionId: number; + language: string; + languageVersion: string; + sourceCode: string; + status: string; + time: string; + testerState: string; + testerErrorCode: string; + testerMessage: string; + currentTest: number; + amountOfTests: number; } export interface MissionSubmit { - id: number; - userId: number; - solution: Solution; - contestId: number | null; - contestName: string | null; - sourceType: string; + id: number; + userId: number; + solution: Solution; + contestId: number | null; + contestName: string | null; + sourceType: string; } interface SubmitState { - submits: Submit[]; - submitsById: Record; // ✅ добавлено - currentSubmit?: Submit; - status: "idle" | "loading" | "successful" | "failed"; - error: string | null; + submits: Submit[]; + submitsById: Record; // ✅ добавлено + currentSubmit?: Submit; + status: 'idle' | 'loading' | 'successful' | 'failed'; + error: string | null; } // Начальное состояние const initialState: SubmitState = { - submits: [], - submitsById: {}, // ✅ инициализация - currentSubmit: undefined, - status: "idle", - error: null, + submits: [], + submitsById: {}, // ✅ инициализация + currentSubmit: undefined, + status: 'idle', + error: null, }; // AsyncThunk: Отправка решения export const submitMission = createAsyncThunk( - "submit/submitMission", - async (submitData: Submit, { rejectWithValue }) => { - try { - const response = await axios.post("/submits", submitData); - return response.data; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Submit failed"); - } - } + 'submit/submitMission', + async (submitData: Submit, { rejectWithValue }) => { + try { + const response = await axios.post('/submits', submitData); + return response.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Submit failed', + ); + } + }, ); // AsyncThunk: Получить все свои отправки export const fetchMySubmits = createAsyncThunk( - "submit/fetchMySubmits", - async (_, { rejectWithValue }) => { - try { - const response = await axios.get("/submits/my"); - return response.data as Submit[]; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Failed to fetch submits"); - } - } + 'submit/fetchMySubmits', + async (_, { rejectWithValue }) => { + try { + const response = await axios.get('/submits/my'); + return response.data as Submit[]; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to fetch submits', + ); + } + }, ); // AsyncThunk: Получить конкретную отправку по ID export const fetchSubmitById = createAsyncThunk( - "submit/fetchSubmitById", - async (id: number, { rejectWithValue }) => { - try { - const response = await axios.get(`/submits/${id}`); - return response.data as Submit; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Failed to fetch submit"); - } - } + 'submit/fetchSubmitById', + async (id: number, { rejectWithValue }) => { + try { + const response = await axios.get(`/submits/${id}`); + return response.data as Submit; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to fetch submit', + ); + } + }, ); // ✅ AsyncThunk: Получить отправки для конкретной миссии (новая структура) export const fetchMySubmitsByMission = createAsyncThunk( - "submit/fetchMySubmitsByMission", - async (missionId: number, { rejectWithValue }) => { - try { - const response = await axios.get(`/submits/my/mission/${missionId}`); - return { missionId, data: response.data as MissionSubmit[] }; - } catch (err: any) { - return rejectWithValue(err.response?.data?.message || "Failed to fetch mission submits"); - } - } + 'submit/fetchMySubmitsByMission', + async (missionId: number, { rejectWithValue }) => { + try { + const response = await axios.get( + `/submits/my/mission/${missionId}`, + ); + return { missionId, data: response.data as MissionSubmit[] }; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || + 'Failed to fetch mission submits', + ); + } + }, ); // Slice const submitSlice = createSlice({ - name: "submit", - initialState, - reducers: { - clearCurrentSubmit: (state) => { - state.currentSubmit = undefined; - state.status = "idle"; - state.error = null; + name: 'submit', + initialState, + reducers: { + clearCurrentSubmit: (state) => { + state.currentSubmit = undefined; + state.status = 'idle'; + state.error = null; + }, + clearSubmitsByMission: (state, action: PayloadAction) => { + delete state.submitsById[action.payload]; + }, }, - clearSubmitsByMission: (state, action: PayloadAction) => { - delete state.submitsById[action.payload]; + extraReducers: (builder) => { + // Отправка решения + builder.addCase(submitMission.pending, (state) => { + state.status = 'loading'; + state.error = null; + }); + builder.addCase( + submitMission.fulfilled, + (state, action: PayloadAction) => { + state.status = 'successful'; + state.submits.push(action.payload); + }, + ); + builder.addCase( + submitMission.rejected, + (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + ); + + // Получить все свои отправки + builder.addCase(fetchMySubmits.pending, (state) => { + state.status = 'loading'; + state.error = null; + }); + builder.addCase( + fetchMySubmits.fulfilled, + (state, action: PayloadAction) => { + state.status = 'successful'; + state.submits = action.payload; + }, + ); + builder.addCase( + fetchMySubmits.rejected, + (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + ); + + // Получить отправку по ID + builder.addCase(fetchSubmitById.pending, (state) => { + state.status = 'loading'; + state.error = null; + }); + builder.addCase( + fetchSubmitById.fulfilled, + (state, action: PayloadAction) => { + state.status = 'successful'; + state.currentSubmit = action.payload; + }, + ); + builder.addCase( + fetchSubmitById.rejected, + (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + ); + + // ✅ Получить отправки по миссии + builder.addCase(fetchMySubmitsByMission.pending, (state) => { + state.status = 'loading'; + state.error = null; + }); + builder.addCase( + fetchMySubmitsByMission.fulfilled, + ( + state, + action: PayloadAction<{ + missionId: number; + data: MissionSubmit[]; + }>, + ) => { + state.status = 'successful'; + state.submitsById[action.payload.missionId] = + action.payload.data; + }, + ); + builder.addCase( + fetchMySubmitsByMission.rejected, + (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + ); }, - }, - extraReducers: (builder) => { - // Отправка решения - builder.addCase(submitMission.pending, (state) => { - state.status = "loading"; - state.error = null; - }); - builder.addCase(submitMission.fulfilled, (state, action: PayloadAction) => { - state.status = "successful"; - state.submits.push(action.payload); - }); - builder.addCase(submitMission.rejected, (state, action: PayloadAction) => { - state.status = "failed"; - state.error = action.payload; - }); - - // Получить все свои отправки - builder.addCase(fetchMySubmits.pending, (state) => { - state.status = "loading"; - state.error = null; - }); - builder.addCase(fetchMySubmits.fulfilled, (state, action: PayloadAction) => { - state.status = "successful"; - state.submits = action.payload; - }); - builder.addCase(fetchMySubmits.rejected, (state, action: PayloadAction) => { - state.status = "failed"; - state.error = action.payload; - }); - - // Получить отправку по ID - builder.addCase(fetchSubmitById.pending, (state) => { - state.status = "loading"; - state.error = null; - }); - builder.addCase(fetchSubmitById.fulfilled, (state, action: PayloadAction) => { - state.status = "successful"; - state.currentSubmit = action.payload; - }); - builder.addCase(fetchSubmitById.rejected, (state, action: PayloadAction) => { - state.status = "failed"; - state.error = action.payload; - }); - - // ✅ Получить отправки по миссии - builder.addCase(fetchMySubmitsByMission.pending, (state) => { - state.status = "loading"; - state.error = null; - }); - builder.addCase( - fetchMySubmitsByMission.fulfilled, - (state, action: PayloadAction<{ missionId: number; data: MissionSubmit[] }>) => { - state.status = "successful"; - state.submitsById[action.payload.missionId] = action.payload.data; - } - ); - builder.addCase(fetchMySubmitsByMission.rejected, (state, action: PayloadAction) => { - state.status = "failed"; - state.error = action.payload; - }); - }, }); -export const { clearCurrentSubmit, clearSubmitsByMission } = submitSlice.actions; +export const { clearCurrentSubmit, clearSubmitsByMission } = + submitSlice.actions; export const submitReducer = submitSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 3c609e8..5075b68 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,11 +1,10 @@ -import { configureStore } from "@reduxjs/toolkit"; -import { authReducer } from "./slices/auth"; -import { storeReducer } from "./slices/store"; -import { missionsReducer } from "./slices/missions"; -import { submitReducer } from "./slices/submit"; -import { contestsReducer } from "./slices/contests"; -import { groupsReducer } from "./slices/groups"; - +import { configureStore } from '@reduxjs/toolkit'; +import { authReducer } from './slices/auth'; +import { storeReducer } from './slices/store'; +import { missionsReducer } from './slices/missions'; +import { submitReducer } from './slices/submit'; +import { contestsReducer } from './slices/contests'; +import { groupsReducer } from './slices/groups'; // использование // import { useAppDispatch, useAppSelector } from '../redux/hooks'; @@ -15,17 +14,16 @@ import { groupsReducer } from "./slices/groups"; // const dispatch = useAppDispatch(); // const user = useAppSelector((state) => state.user); - export const store = configureStore({ - reducer: { - //user: userReducer, - auth: authReducer, - store: storeReducer, - missions: missionsReducer, - submin: submitReducer, - contests: contestsReducer, - groups: groupsReducer, - }, + reducer: { + //user: userReducer, + auth: authReducer, + store: storeReducer, + missions: missionsReducer, + submin: submitReducer, + contests: contestsReducer, + groups: groupsReducer, + }, }); // тип состояния всего стора diff --git a/src/styles/index.css b/src/styles/index.css index 2063dc2..b873281 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -2,116 +2,108 @@ @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; -@import "./latex-container.css"; +@import './latex-container.css'; * { - -webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/ - /* outline: 1px solid green; */ -} - + -webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/ + /* outline: 1px solid green; */ +} :root { - color-scheme: light dark; - width: 100%; - height: 100svh; - /* @apply bg-layout-background; */ - /* transition: all linear 200ms; */ + color-scheme: light dark; + width: 100%; + height: 100svh; + /* @apply bg-layout-background; */ + /* transition: all linear 200ms; */ - font-family: 'Source Code Pro', monospace; - - /* font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; */ - font-weight: 400; - line-height: 1.5; - background-color: var(--color-liquid-background); - color: rgba(255, 255, 255, 0.87); + font-family: 'Source Code Pro', monospace; + + /* font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; */ + font-weight: 400; + line-height: 1.5; + background-color: var(--color-liquid-background); + color: rgba(255, 255, 255, 0.87); } #root { - width: 100%; - height: 100vh; + width: 100%; + height: 100vh; } body { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; - margin: 0; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + margin: 0; } - /* Общий контейнер полосы прокрутки */ .thin-scrollbar::-webkit-scrollbar { - width: 4px; /* ширина вертикального */ + width: 4px; /* ширина вертикального */ } /* Трек (фон) */ .thin-scrollbar::-webkit-scrollbar-track { - background: transparent; + background: transparent; } /* Ползунок (thumb) */ .thin-scrollbar::-webkit-scrollbar-thumb { - background: var(--color-liquid-light); - border-radius: 1000px; - cursor: pointer; + background: var(--color-liquid-light); + border-radius: 1000px; + cursor: pointer; } - /* Общий контейнер полосы прокрутки */ .medium-scrollbar::-webkit-scrollbar { - width: 8px; /* ширина вертикального */ + width: 8px; /* ширина вертикального */ } /* Трек (фон) */ .medium-scrollbar::-webkit-scrollbar-track { - background: transparent; + background: transparent; } /* Ползунок (thumb) */ .medium-scrollbar::-webkit-scrollbar-thumb { - background: var(--color-liquid-light); - border-radius: 1000px; - cursor: pointer; + background: var(--color-liquid-light); + border-radius: 1000px; + cursor: pointer; } - - /* Общий контейнер полосы прокрутки */ .thin-dark-scrollbar::-webkit-scrollbar { - width: 4px; /* ширина вертикального */ + width: 4px; /* ширина вертикального */ } /* Трек (фон) */ .thin-dark-scrollbar::-webkit-scrollbar-track { - background: transparent; + background: transparent; } /* Ползунок (thumb) */ .thin-dark-scrollbar::-webkit-scrollbar-thumb { - background: var(--color-liquid-lighter); - border-radius: 1000px; - cursor: pointer; + background: var(--color-liquid-lighter); + border-radius: 1000px; + cursor: pointer; } - - - html { scrollbar-gutter: stable; padding-left: 8px; } html::-webkit-scrollbar { - width: 8px; /* ширина вертикального */ + width: 8px; /* ширина вертикального */ } /* Трек (фон) */ html::-webkit-scrollbar-track { - background: transparent; + background: transparent; } /* Ползунок (thumb) */ html::-webkit-scrollbar-thumb { - background-color: var(--color-liquid-lighter); - border-radius: 1000px; - cursor: pointer; -} \ No newline at end of file + background-color: var(--color-liquid-lighter); + border-radius: 1000px; + cursor: pointer; +} diff --git a/src/styles/latex-container.css b/src/styles/latex-container.css index 600b047..ddd0e74 100644 --- a/src/styles/latex-container.css +++ b/src/styles/latex-container.css @@ -1,26 +1,24 @@ - .latex-container p { - text-align: justify; /* выравнивание по ширине */ - text-justify: inter-word; - margin-bottom: 0.8em; /* небольшой отступ между абзацами */ - line-height: 1.2; - /* text-indent: 1em; */ + text-align: justify; /* выравнивание по ширине */ + text-justify: inter-word; + margin-bottom: 0.8em; /* небольшой отступ между абзацами */ + line-height: 1.2; + /* text-indent: 1em; */ } .latex-container ol { - padding-left: 1.5em; /* отступ для нумерации */ - margin: 0.5em 0; /* небольшой отступ сверху и снизу */ - line-height: 1.5; /* удобный межстрочный интервал */ - font-family: "Inter", sans-serif; - font-size: 1rem; + padding-left: 1.5em; /* отступ для нумерации */ + margin: 0.5em 0; /* небольшой отступ сверху и снизу */ + line-height: 1.5; /* удобный межстрочный интервал */ + font-family: 'Inter', sans-serif; + font-size: 1rem; } .latex-container ol li { - margin-bottom: 0.4em; /* расстояние между пунктами */ + margin-bottom: 0.4em; /* расстояние между пунктами */ } -.latex-container .section-title{ - font-size: 16px; - font-weight: bold; +.latex-container .section-title { + font-size: 16px; + font-weight: bold; } - diff --git a/src/styles/palette/theme-dark.css b/src/styles/palette/theme-dark.css index 435b197..061e3b2 100644 --- a/src/styles/palette/theme-dark.css +++ b/src/styles/palette/theme-dark.css @@ -1,16 +1,16 @@ @import 'tailwindcss/base'; @layer base { - :root[data-theme~="dark"] { - --color-liquid-brightmain: #00DBD9; - --color-liquid-darkmain: #075867; - --color-liquid-darker: #141515; - --color-liquid-background: #202222; - --color-liquid-lighter: #2A2E2F; - --color-liquid-white: #EDF6F7; - --color-liquid-red: #F13E5F; - --color-liquid-green: #10BE59; - --color-liquid-light: #576466; - --color-liquid-orange: #FF951B; - } -} \ No newline at end of file + :root[data-theme~='dark'] { + --color-liquid-brightmain: #00dbd9; + --color-liquid-darkmain: #075867; + --color-liquid-darker: #141515; + --color-liquid-background: #202222; + --color-liquid-lighter: #2a2e2f; + --color-liquid-white: #edf6f7; + --color-liquid-red: #f13e5f; + --color-liquid-green: #10be59; + --color-liquid-light: #576466; + --color-liquid-orange: #ff951b; + } +} diff --git a/src/styles/palette/theme-light.css b/src/styles/palette/theme-light.css index 1d6bedc..77b2504 100644 --- a/src/styles/palette/theme-light.css +++ b/src/styles/palette/theme-light.css @@ -1,16 +1,16 @@ @import 'tailwindcss/base'; @layer base { - :root { - --color-liquid-brightmain: #00DBD9; - --color-liquid-darkmain: #075867; - --color-liquid-darker: #141515; - --color-liquid-background: #202222; - --color-liquid-lighter: #2A2E2F; - --color-liquid-white: #EDF6F7; - --color-liquid-red: #F13E5F; - --color-liquid-green: #10BE59; - --color-liquid-light: #576466; - --color-liquid-orange: #FF951B; - } -} \ No newline at end of file + :root { + --color-liquid-brightmain: #00dbd9; + --color-liquid-darkmain: #075867; + --color-liquid-darker: #141515; + --color-liquid-background: #202222; + --color-liquid-lighter: #2a2e2f; + --color-liquid-white: #edf6f7; + --color-liquid-red: #f13e5f; + --color-liquid-green: #10be59; + --color-liquid-light: #576466; + --color-liquid-orange: #ff951b; + } +} diff --git a/src/views/articleeditor/Editor.tsx b/src/views/articleeditor/Editor.tsx index b7a2645..38e4e43 100644 --- a/src/views/articleeditor/Editor.tsx +++ b/src/views/articleeditor/Editor.tsx @@ -1,17 +1,21 @@ -import { FC, useEffect, useState } from "react"; -import axios from "../../axios"; -import "highlight.js/styles/github-dark.css"; - -import MarkdownPreview from "./MarckDownPreview"; +import { FC, useEffect, useState } from 'react'; +import axios from '../../axios'; +import 'highlight.js/styles/github-dark.css'; +import MarkdownPreview from './MarckDownPreview'; interface MarkdownEditorProps { defaultValue?: string; onChange: (value: string) => void; } -const MarkdownEditor: FC = ({ defaultValue, onChange }) => { - const [markdown, setMarkdown] = useState(defaultValue || `# 🌙 Добро пожаловать в Markdown-редактор +const MarkdownEditor: FC = ({ + defaultValue, + onChange, +}) => { + const [markdown, setMarkdown] = useState( + defaultValue || + `# 🌙 Добро пожаловать в Markdown-редактор Добро пожаловать в **Markdown-редактор**! Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇 @@ -205,34 +209,42 @@ print(greet("Мир")) **🖤 Конец демонстрации. Спасибо, что используешь Markdown-редактор!** -`); +`, + ); useEffect(() => { onChange(markdown); }, [markdown]); // Обработчик вставки - const handlePaste = async (e: React.ClipboardEvent) => { + const handlePaste = async ( + e: React.ClipboardEvent, + ) => { const items = e.clipboardData.items; for (const item of items) { - if (item.type.startsWith("image/")) { + if (item.type.startsWith('image/')) { e.preventDefault(); // предотвращаем вставку картинки как текста const file = item.getAsFile(); if (!file) return; const formData = new FormData(); - formData.append("file", file); + formData.append('file', file); try { - const response = await axios.post("/media/upload", formData, { - headers: { "Content-Type": "multipart/form-data" }, - }); + const response = await axios.post( + '/media/upload', + formData, + { + headers: { 'Content-Type': 'multipart/form-data' }, + }, + ); const imageUrl = response.data.url; // Вставляем ссылку на картинку в текст - const cursorPos = (e.target as HTMLTextAreaElement).selectionStart; + const cursorPos = (e.target as HTMLTextAreaElement) + .selectionStart; const newText = markdown.slice(0, cursorPos) + `\"img\"/` + @@ -240,7 +252,7 @@ print(greet("Мир")) setMarkdown(newText); } catch (err) { - console.error("Ошибка загрузки изображения:", err); + console.error('Ошибка загрузки изображения:', err); } } } @@ -251,15 +263,22 @@ print(greet("Мир")) {/* Предпросмотр */}
-

👀 Предпросмотр

- +

+ 👀 Предпросмотр +

+
{/* Редактор */}
-

📝 Редактор

+

+ 📝 Редактор +