formatting
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import Balloon from "./balloon.svg";
|
import Balloon from './balloon.svg';
|
||||||
import Account from "./account.svg"
|
import Account from './account.svg';
|
||||||
|
|
||||||
export {Balloon, Account};
|
export { Balloon, Account };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Book from "./book.png"
|
import Book from './book.png';
|
||||||
import EyeClosed from "./eye-closed.svg";
|
import EyeClosed from './eye-closed.svg';
|
||||||
import EyeOpen from "./eye-open.png";
|
import EyeOpen from './eye-open.png';
|
||||||
import Edit from "./edit.svg";
|
import Edit from './edit.svg';
|
||||||
import UserAdd from "./user-profile-add.svg";
|
import UserAdd from './user-profile-add.svg';
|
||||||
import ChevroneDown from "./chevron-down.svg"
|
import ChevroneDown from './chevron-down.svg';
|
||||||
|
|
||||||
export {Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown}
|
export { Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import arrowLeft from "./arrow-left-sm.svg";
|
import arrowLeft from './arrow-left-sm.svg';
|
||||||
import chevroneLeft from "./chevron-left.svg"
|
import chevroneLeft from './chevron-left.svg';
|
||||||
import chevroneRight from "./chevron-right.svg"
|
import chevroneRight from './chevron-right.svg';
|
||||||
|
|
||||||
export {arrowLeft, chevroneLeft, chevroneRight}
|
export { arrowLeft, chevroneLeft, chevroneRight };
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import eyeClosed from "./eye-closed.svg"
|
import eyeClosed from './eye-closed.svg';
|
||||||
import eyeOpen from "./eye-open.png"
|
import eyeOpen from './eye-open.png';
|
||||||
import googleLogo from "./google-logo.svg"
|
import googleLogo from './google-logo.svg';
|
||||||
import upload from "./upload.svg"
|
import upload from './upload.svg';
|
||||||
import chevroneDropDownList from "./chevron-drop-down.svg"
|
import chevroneDropDownList from './chevron-drop-down.svg';
|
||||||
import checkMark from "./check-mark.svg"
|
import checkMark from './check-mark.svg';
|
||||||
|
|
||||||
export {eyeClosed, eyeOpen, googleLogo, upload, chevroneDropDownList, checkMark}
|
export {
|
||||||
|
eyeClosed,
|
||||||
|
eyeOpen,
|
||||||
|
googleLogo,
|
||||||
|
upload,
|
||||||
|
chevroneDropDownList,
|
||||||
|
checkMark,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Account from "./account.svg";
|
import Account from './account.svg';
|
||||||
import Clipboard from "./clipboard.svg";
|
import Clipboard from './clipboard.svg';
|
||||||
import Cup from "./cup.svg";
|
import Cup from './cup.svg';
|
||||||
import Home from "./home.svg";
|
import Home from './home.svg';
|
||||||
import Openbook from "./openbook.svg";
|
import Openbook from './openbook.svg';
|
||||||
import Users from "./users.svg";
|
import Users from './users.svg';
|
||||||
|
|
||||||
export {Account, Clipboard, Cup, Home, Openbook, Users};
|
export { Account, Clipboard, Cup, Home, Openbook, Users };
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import IconSuccess from "./icon-success.svg"
|
import IconSuccess from './icon-success.svg';
|
||||||
import IconError from "./icon-error.svg"
|
import IconError from './icon-error.svg';
|
||||||
import CopyIcon from "./copy-icon.svg"
|
import CopyIcon from './copy-icon.svg';
|
||||||
|
|
||||||
|
export { IconError, IconSuccess, CopyIcon };
|
||||||
export {IconError, IconSuccess, CopyIcon}
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import Logo from "./Logo.svg"
|
import Logo from './Logo.svg';
|
||||||
|
|
||||||
export {Logo}
|
export { Logo };
|
||||||
|
|||||||
30
src/axios.ts
30
src/axios.ts
@@ -1,24 +1,24 @@
|
|||||||
import axios from "axios";
|
import axios from 'axios';
|
||||||
|
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL,
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor: автоматически подставляет JWT, если есть
|
// Request interceptor: автоматически подставляет JWT, если есть
|
||||||
instance.interceptors.request.use(
|
instance.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = localStorage.getItem("jwt"); // или можно брать из Redux через store.getState()
|
const token = localStorage.getItem('jwt'); // или можно брать из Redux через store.getState()
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default instance;
|
export default instance;
|
||||||
|
|||||||
@@ -1,88 +1,90 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { cn } from "../../lib/cn";
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
color?: "primary" | "secondary" | "error" | "warning" | "success";
|
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ColorBgVariants = {
|
const ColorBgVariants = {
|
||||||
"primary": "bg-liquid-brightmain group-hover:ring-liquid-brightmain",
|
primary: 'bg-liquid-brightmain group-hover:ring-liquid-brightmain',
|
||||||
"secondary": "bg-liquid-darkmain group-hover:ring-liquid-darkmain",
|
secondary: 'bg-liquid-darkmain group-hover:ring-liquid-darkmain',
|
||||||
"error": "bg-liquid-red group-hover:ring-liquid-red",
|
error: 'bg-liquid-red group-hover:ring-liquid-red',
|
||||||
"warning": "bg-liquid-orange group-hover:ring-liquid-orange",
|
warning: 'bg-liquid-orange group-hover:ring-liquid-orange',
|
||||||
"success": "bg-liquid-green group-hover:ring-liquid-green",
|
success: 'bg-liquid-green group-hover:ring-liquid-green',
|
||||||
}
|
};
|
||||||
|
|
||||||
const ColorTextVariants = {
|
const ColorTextVariants = {
|
||||||
"primary": "group-hover:text-liquid-brightmain ",
|
primary: 'group-hover:text-liquid-brightmain ',
|
||||||
"secondary": "group-hover:text-liquid-brightmain ",
|
secondary: 'group-hover:text-liquid-brightmain ',
|
||||||
"error": "group-hover:text-liquid-red ",
|
error: 'group-hover:text-liquid-red ',
|
||||||
"warning": "group-hover:text-liquid-orange ",
|
warning: 'group-hover:text-liquid-orange ',
|
||||||
"success": "group-hover:text-liquid-green ",
|
success: 'group-hover:text-liquid-green ',
|
||||||
}
|
};
|
||||||
|
|
||||||
export const PrimaryButton: React.FC<ButtonProps> = ({
|
export const PrimaryButton: React.FC<ButtonProps> = ({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
text = "",
|
text = '',
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
color = "secondary",
|
color = 'secondary',
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid relative cursor-pointer select-none group w-fit box-border",
|
'grid relative cursor-pointer select-none group w-fit box-border',
|
||||||
disabled && "pointer-events-none",
|
disabled && 'pointer-events-none',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Основной контейнер, */}
|
{/* Основной контейнер, */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300",
|
'group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300',
|
||||||
"rounded-[10px]",
|
'rounded-[10px]',
|
||||||
"group-hover:bg-liquid-lighter group-hover:ring-[1px] group-hover:ring-liquid-darkmain group-hover:ring-inset",
|
'group-hover:bg-liquid-lighter group-hover:ring-[1px] group-hover:ring-liquid-darkmain group-hover:ring-inset',
|
||||||
"px-[16px] py-[8px]",
|
'px-[16px] py-[8px]',
|
||||||
ColorBgVariants[color],
|
ColorBgVariants[color],
|
||||||
disabled && "bg-liquid-lighter"
|
disabled && 'bg-liquid-lighter',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Скрытый button */}
|
{/* Скрытый button */}
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute opacity-0 -z-10 h-0 w-0",
|
'absolute opacity-0 -z-10 h-0 w-0',
|
||||||
"[&:focus-visible+*]:outline-liquid-brightmain",
|
'[&:focus-visible+*]:outline-liquid-brightmain',
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => { onClick() }}
|
onClick={() => {
|
||||||
/>
|
onClick();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Граница при выделении через tab */}
|
{/* Граница при выделении через tab */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute outline-offset-[2.5px] border-[2px] border-transparent outline-[2.5px] outline outline-transparent transition-all duration-300 text-transparent box-border text-[18px] font-bold p-0 ,m-0 leading-[23px]",
|
'absolute outline-offset-[2.5px] border-[2px] border-transparent outline-[2.5px] outline outline-transparent transition-all duration-300 text-transparent box-border text-[18px] font-bold p-0 ,m-0 leading-[23px]',
|
||||||
"rounded-[10px]",
|
'rounded-[10px]',
|
||||||
"px-[16px] py-[8px]",
|
'px-[16px] py-[8px]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children || text}
|
{children || text}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]",
|
'transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]',
|
||||||
ColorTextVariants[color],
|
ColorTextVariants[color],
|
||||||
disabled && "text-liquid-light"
|
disabled && 'text-liquid-light',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children || text}
|
{children || text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,70 +1,72 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { cn } from "../../lib/cn";
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReverseButton: React.FC<ButtonProps> = ({
|
export const ReverseButton: React.FC<ButtonProps> = ({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
text = "",
|
text = '',
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid relative cursor-pointer select-none group w-fit box-border",
|
'grid relative cursor-pointer select-none group w-fit box-border',
|
||||||
disabled && "pointer-events-none",
|
disabled && 'pointer-events-none',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Основной контейнер, */}
|
{/* Основной контейнер, */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300",
|
'group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300',
|
||||||
"rounded-[10px]",
|
'rounded-[10px]',
|
||||||
"group-hover:bg-liquid-darkmain ",
|
'group-hover:bg-liquid-darkmain ',
|
||||||
"px-[16px] py-[8px]",
|
'px-[16px] py-[8px]',
|
||||||
"bg-liquid-lighter ring-[1px] ring-liquid-darkmain ring-inset",
|
'bg-liquid-lighter ring-[1px] ring-liquid-darkmain ring-inset',
|
||||||
disabled && "bg-liquid-lighter"
|
disabled && 'bg-liquid-lighter',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Скрытый button */}
|
{/* Скрытый button */}
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute opacity-0 -z-10 h-0 w-0",
|
'absolute opacity-0 -z-10 h-0 w-0',
|
||||||
"[&:focus-visible+*]:outline-liquid-brightmain",
|
'[&:focus-visible+*]:outline-liquid-brightmain',
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => { onClick() }}
|
onClick={() => {
|
||||||
/>
|
onClick();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Граница при выделении через tab */}
|
{/* Граница при выделении через tab */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute outline-offset-[2.5px] border-[2px] border-transparent outline-[2.5px] outline outline-transparent transition-all duration-300 text-transparent box-border text-[18px] font-bold p-0 ,m-0 leading-[23px]",
|
'absolute outline-offset-[2.5px] border-[2px] border-transparent outline-[2.5px] outline outline-transparent transition-all duration-300 text-transparent box-border text-[18px] font-bold p-0 ,m-0 leading-[23px]',
|
||||||
"rounded-[10px]",
|
'rounded-[10px]',
|
||||||
"px-[16px] py-[8px]",
|
'px-[16px] py-[8px]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children || text}
|
{children || text}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300 text-liquid-brightmain text-[18px] font-bold p-0 m-0 leading-[23px]",
|
'transition-all duration-300 text-liquid-brightmain text-[18px] font-bold p-0 m-0 leading-[23px]',
|
||||||
"group-hover:text-liquid-white ",
|
'group-hover:text-liquid-white ',
|
||||||
disabled && "text-liquid-light"
|
disabled && 'text-liquid-light',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children || text}
|
{children || text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,69 +1,70 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { cn } from "../../lib/cn";
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SecondaryButton: React.FC<ButtonProps> = ({
|
export const SecondaryButton: React.FC<ButtonProps> = ({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
text = "",
|
text = '',
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid relative cursor-pointer select-none group w-fit box-border",
|
'grid relative cursor-pointer select-none group w-fit box-border',
|
||||||
disabled && "pointer-events-none",
|
disabled && 'pointer-events-none',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Основной контейнер, */}
|
{/* Основной контейнер, */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300",
|
'group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300',
|
||||||
"rounded-[10px]",
|
'rounded-[10px]',
|
||||||
"group-hover:bg-liquid-background",
|
'group-hover:bg-liquid-background',
|
||||||
"px-[16px] py-[8px]",
|
'px-[16px] py-[8px]',
|
||||||
"bg-liquid-lighter"
|
'bg-liquid-lighter',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Скрытый button */}
|
{/* Скрытый button */}
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute opacity-0 -z-10 h-0 w-0",
|
'absolute opacity-0 -z-10 h-0 w-0',
|
||||||
"[&:focus-visible+*]:outline-liquid-brightmain",
|
'[&:focus-visible+*]:outline-liquid-brightmain',
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => { onClick() }}
|
onClick={() => {
|
||||||
/>
|
onClick();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Граница при выделении через tab */}
|
{/* Граница при выделении через tab */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute outline-offset-[2.5px] border-[2px] border-transparent outline-[2.5px] outline outline-transparent transition-all duration-300 text-transparent box-border text-[18px] font-bold p-0 ,m-0 leading-[23px]",
|
'absolute outline-offset-[2.5px] border-[2px] border-transparent outline-[2.5px] outline outline-transparent transition-all duration-300 text-transparent box-border text-[18px] font-bold p-0 ,m-0 leading-[23px]',
|
||||||
"rounded-[10px]",
|
'rounded-[10px]',
|
||||||
"px-[16px] py-[8px]",
|
'px-[16px] py-[8px]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children || text}
|
{children || text}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]",
|
'transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]',
|
||||||
disabled && "text-liquid-light"
|
disabled && 'text-liquid-light',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children || text}
|
{children || text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,168 +1,167 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { cn } from "../../lib/cn";
|
import { cn } from '../../lib/cn';
|
||||||
import { motion } from "framer-motion";
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
const pathVariants = {
|
const pathVariants = {
|
||||||
hidden: {
|
hidden: {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
pathLength: 0,
|
pathLength: 0,
|
||||||
},
|
},
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
pathLength: 1,
|
pathLength: 1,
|
||||||
transition: {
|
transition: {
|
||||||
delay: 0.15,
|
delay: 0.15,
|
||||||
duration: 0.4,
|
duration: 0.4,
|
||||||
ease: "easeInOut",
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeVariants = {
|
const sizeVariants = {
|
||||||
sm: "h-4 w-4",
|
sm: 'h-4 w-4',
|
||||||
md: "h-5 w-5",
|
md: 'h-5 w-5',
|
||||||
lg: "h-6 w-6",
|
lg: 'h-6 w-6',
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorsVariants = {
|
const colorsVariants = {
|
||||||
default: "bg-default",
|
default: 'bg-default',
|
||||||
primary: "bg-liquid-brightmain",
|
primary: 'bg-liquid-brightmain',
|
||||||
secondary: "bg-liquid-darkmain",
|
secondary: 'bg-liquid-darkmain',
|
||||||
success: "bg-liquid-green",
|
success: 'bg-liquid-green',
|
||||||
warning: "bg-liquid-orange",
|
warning: 'bg-liquid-orange',
|
||||||
danger: "bg-liquid-red",
|
danger: 'bg-liquid-red',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const borderColorsVariants = {
|
const borderColorsVariants = {
|
||||||
default: "border-default",
|
default: 'border-default',
|
||||||
primary: "border-liquid-brightmain",
|
primary: 'border-liquid-brightmain',
|
||||||
secondary: "border-liquid-darkmain",
|
secondary: 'border-liquid-darkmain',
|
||||||
success: "border-liquid-green",
|
success: 'border-liquid-green',
|
||||||
warning: "border-liquid-orange",
|
warning: 'border-liquid-orange',
|
||||||
danger: "border-liquid-red",
|
danger: 'border-liquid-red',
|
||||||
};
|
};
|
||||||
|
|
||||||
const focuseOutlineVariants = {
|
const focuseOutlineVariants = {
|
||||||
default: "[&:focus-visible+*]:outline-default",
|
default: '[&:focus-visible+*]:outline-default',
|
||||||
primary: "[&:focus-visible+*]:outline-liquid-brightmain",
|
primary: '[&:focus-visible+*]:outline-liquid-brightmain',
|
||||||
secondary: "[&:focus-visible+*]:outline-liquid-darkmain",
|
secondary: '[&:focus-visible+*]:outline-liquid-darkmain',
|
||||||
success: "[&:focus-visible+*]:outline-liquid-green",
|
success: '[&:focus-visible+*]:outline-liquid-green',
|
||||||
warning: "[&:focus-visible+*]:outline-liquid-orange",
|
warning: '[&:focus-visible+*]:outline-liquid-orange',
|
||||||
danger: "[&:focus-visible+*]:outline-liquid-red",
|
danger: '[&:focus-visible+*]:outline-liquid-red',
|
||||||
};
|
};
|
||||||
|
|
||||||
const radiusVraiants = {
|
const radiusVraiants = {
|
||||||
none: "",
|
none: '',
|
||||||
sm: "rounded-[3.5px]",
|
sm: 'rounded-[3.5px]',
|
||||||
md: "rounded-[5px]",
|
md: 'rounded-[5px]',
|
||||||
lg: "rounded-[7px]",
|
lg: 'rounded-[7px]',
|
||||||
full: "rounded-full",
|
full: 'rounded-full',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CheckboxProps {
|
interface CheckboxProps {
|
||||||
size?: "sm" | "md" | "lg";
|
size?: 'sm' | 'md' | 'lg';
|
||||||
radius?: "none" | "sm" | "md" | "lg" | "full";
|
radius?: 'none' | 'sm' | 'md' | 'lg' | 'full';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
color?:
|
color?:
|
||||||
| "default"
|
| 'default'
|
||||||
| "primary"
|
| 'primary'
|
||||||
| "secondary"
|
| 'secondary'
|
||||||
| "success"
|
| 'success'
|
||||||
| "warning"
|
| 'warning'
|
||||||
| "danger";
|
| 'danger';
|
||||||
label?: string;
|
label?: string;
|
||||||
variant?: "default" | "label";
|
variant?: 'default' | 'label';
|
||||||
className?: string;
|
className?: string;
|
||||||
defaultState?: boolean;
|
defaultState?: boolean;
|
||||||
onChange: (state: boolean) => void;
|
onChange: (state: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Checkbox: React.FC<CheckboxProps> = ({
|
export const Checkbox: React.FC<CheckboxProps> = ({
|
||||||
size = "md",
|
size = 'md',
|
||||||
radius = "md",
|
radius = 'md',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
color = "primary",
|
color = 'primary',
|
||||||
label = "",
|
label = '',
|
||||||
variant = "label",
|
variant = 'label',
|
||||||
className,
|
className,
|
||||||
onChange,
|
onChange,
|
||||||
defaultState = false,
|
defaultState = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [active, setActive] = React.useState<boolean>(defaultState);
|
const [active, setActive] = React.useState<boolean>(defaultState);
|
||||||
|
|
||||||
React.useEffect(() => onChange(active), [active]);
|
React.useEffect(() => onChange(active), [active]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.label
|
<motion.label
|
||||||
className={cn(
|
className={cn(
|
||||||
variant == "label" && "grid-cols-[auto_1fr] items-center gap-2",
|
variant == 'label' && 'grid-cols-[auto_1fr] items-center gap-2',
|
||||||
"grid relative cursor-pointer p-2 select-none group ",
|
'grid relative cursor-pointer p-2 select-none group ',
|
||||||
className,
|
className,
|
||||||
disabled && "pointer-events-none opacity-50",
|
disabled && 'pointer-events-none opacity-50',
|
||||||
variant == "default" && ""
|
variant == 'default' && '',
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"group-hover:bg-default-100 group-active:scale-90 flex items-center justify-center bg-transparent hover:bg-default-100 box-border border-solid border-[1px] border-liquid-white z-10 relative transition-all duration-300",
|
|
||||||
sizeVariants[size],
|
|
||||||
radiusVraiants[radius],
|
|
||||||
active && borderColorsVariants[color]
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className={cn(
|
|
||||||
"absolute opacity-0 -z-10 h-0 w-0",
|
|
||||||
focuseOutlineVariants[color]
|
|
||||||
)}
|
|
||||||
disabled={disabled}
|
|
||||||
type="checkbox"
|
|
||||||
onChange={() => {
|
|
||||||
setActive(!active);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute outline-offset-[2.5px] outline-[2.5px] outline outline-transparent transition-all duration-200",
|
|
||||||
sizeVariants[size],
|
|
||||||
radiusVraiants[radius]
|
|
||||||
)}
|
|
||||||
></div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute transition-all duration-300",
|
|
||||||
sizeVariants[size],
|
|
||||||
colorsVariants[color],
|
|
||||||
radiusVraiants[radius],
|
|
||||||
active && "opacity-100 scale-100",
|
|
||||||
!active && "opacity-0 scale-0"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
{active && (
|
|
||||||
<motion.path
|
|
||||||
strokeWidth="1.5"
|
|
||||||
d="M5 8.22L7.66571 10.44L11.22 6"
|
|
||||||
stroke="white"
|
|
||||||
strokeLinecap="round"
|
|
||||||
variants={pathVariants}
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</svg>
|
>
|
||||||
</span>
|
<div
|
||||||
</div>
|
className={cn(
|
||||||
{variant == "label" && (
|
'group-hover:bg-default-100 group-active:scale-90 flex items-center justify-center bg-transparent hover:bg-default-100 box-border border-solid border-[1px] border-liquid-white z-10 relative transition-all duration-300',
|
||||||
<div className="select-none text-layout-foeground transition-all duration-200">
|
sizeVariants[size],
|
||||||
{label}
|
radiusVraiants[radius],
|
||||||
</div>
|
active && borderColorsVariants[color],
|
||||||
)}
|
)}
|
||||||
</motion.label>
|
>
|
||||||
);
|
<input
|
||||||
|
className={cn(
|
||||||
|
'absolute opacity-0 -z-10 h-0 w-0',
|
||||||
|
focuseOutlineVariants[color],
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
type="checkbox"
|
||||||
|
onChange={() => {
|
||||||
|
setActive(!active);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute outline-offset-[2.5px] outline-[2.5px] outline outline-transparent transition-all duration-200',
|
||||||
|
sizeVariants[size],
|
||||||
|
radiusVraiants[radius],
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute transition-all duration-300',
|
||||||
|
sizeVariants[size],
|
||||||
|
colorsVariants[color],
|
||||||
|
radiusVraiants[radius],
|
||||||
|
active && 'opacity-100 scale-100',
|
||||||
|
!active && 'opacity-0 scale-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<motion.path
|
||||||
|
strokeWidth="1.5"
|
||||||
|
d="M5 8.22L7.66571 10.44L11.22 6"
|
||||||
|
stroke="white"
|
||||||
|
strokeLinecap="round"
|
||||||
|
variants={pathVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{variant == 'label' && (
|
||||||
|
<div className="select-none text-layout-foeground transition-all duration-200">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.label>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { cn } from "../../lib/cn";
|
import { cn } from '../../lib/cn';
|
||||||
import { checkMark, chevroneDropDownList } from "../../assets/icons/input";
|
import { checkMark, chevroneDropDownList } from '../../assets/icons/input';
|
||||||
import { useClickOutside } from "../../hooks/useClickOutside";
|
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||||
|
|
||||||
export interface DropDownListItem {
|
export interface DropDownListItem {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -18,15 +18,16 @@ interface DropDownListProps {
|
|||||||
|
|
||||||
export const DropDownList: React.FC<DropDownListProps> = ({
|
export const DropDownList: React.FC<DropDownListProps> = ({
|
||||||
// disabled = false,
|
// disabled = false,
|
||||||
className = "",
|
className = '',
|
||||||
onChange,
|
onChange,
|
||||||
defaultState,
|
defaultState,
|
||||||
items = [{ text: "", value: "" }],
|
items = [{ text: '', value: '' }],
|
||||||
}) => {
|
}) => {
|
||||||
if (items.length == 0)
|
if (items.length == 0) items.push({ text: '', value: '' });
|
||||||
items.push({ text: "", value: "" });
|
|
||||||
|
|
||||||
const [value, setValue] = React.useState<DropDownListItem>(defaultState != undefined ? defaultState : items[0]);
|
const [value, setValue] = React.useState<DropDownListItem>(
|
||||||
|
defaultState != undefined ? defaultState : items[0],
|
||||||
|
);
|
||||||
const [active, setActive] = React.useState<boolean>(false);
|
const [active, setActive] = React.useState<boolean>(false);
|
||||||
|
|
||||||
React.useEffect(() => onChange(value.value), [value]);
|
React.useEffect(() => onChange(value.value), [value]);
|
||||||
@@ -37,67 +38,73 @@ export const DropDownList: React.FC<DropDownListProps> = ({
|
|||||||
setActive(false);
|
setActive(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn('relative', className)} ref={ref}>
|
||||||
"relative",
|
<div
|
||||||
className
|
className={cn(
|
||||||
)}
|
' flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px] w-[180px]',
|
||||||
ref={ref}
|
'text-[18px] font-bold cursor-pointer select-none',
|
||||||
>
|
'transitin-all active:scale-95 duration-300',
|
||||||
<div className={cn(" flex items-center h-[40px] rounded-[10px] bg-liquid-lighter px-[16px] w-[180px]",
|
)}
|
||||||
"text-[18px] font-bold cursor-pointer select-none",
|
|
||||||
"transitin-all active:scale-95 duration-300"
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActive(!active);
|
setActive(!active);
|
||||||
}
|
}}
|
||||||
}>
|
>
|
||||||
{value.text}
|
{value.text}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img src={chevroneDropDownList}
|
<img
|
||||||
className={cn(" absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none",
|
src={chevroneDropDownList}
|
||||||
active && " rotate-180"
|
className={cn(
|
||||||
)} />
|
' absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none',
|
||||||
|
active && ' rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(" absolute rounded-[10px] bg-liquid-lighter w-[180px] left-0 top-[48px] z-50 transition-all duration-300",
|
className={cn(
|
||||||
"grid overflow-hidden",
|
' absolute rounded-[10px] bg-liquid-lighter w-[180px] left-0 top-[48px] z-50 transition-all duration-300',
|
||||||
active ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
|
'grid overflow-hidden',
|
||||||
)}>
|
active
|
||||||
|
? 'grid-rows-[1fr] opacity-100'
|
||||||
|
: 'grid-rows-[0fr] opacity-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className=" overflow-hidden p-[8px]">
|
<div className=" overflow-hidden p-[8px]">
|
||||||
<div className={cn(
|
<div
|
||||||
" overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ",
|
className={cn(
|
||||||
)}>
|
' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
|
||||||
|
)}
|
||||||
{items.map((v, i) =>
|
>
|
||||||
|
{items.map((v, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer h-[36px] relative transition-all duration-300",
|
'cursor-pointer h-[36px] relative transition-all duration-300',
|
||||||
i + 1 != items.length && "border-b-liquid-light border-b-[1px]",
|
i + 1 != items.length &&
|
||||||
"text-[16px] font-medium cursor-pointer select-none flex items-center pl-[8px]",
|
'border-b-liquid-light border-b-[1px]',
|
||||||
"hover:bg-liquid-background",
|
'text-[16px] font-medium cursor-pointer select-none flex items-center pl-[8px]',
|
||||||
"first:rounded-t-[6px] last:rounded-b-[6px]"
|
'hover:bg-liquid-background',
|
||||||
|
'first:rounded-t-[6px] last:rounded-b-[6px]',
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setValue(v);
|
setValue(v);
|
||||||
setActive(false);
|
setActive(false);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{v.text}
|
{v.text}
|
||||||
|
|
||||||
{v.text == value.text &&
|
{v.text == value.text && (
|
||||||
<img src={checkMark} className=" absolute right-[8px]" />
|
<img
|
||||||
}
|
src={checkMark}
|
||||||
|
className=" absolute right-[8px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,89 +1,95 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { cn } from "../../lib/cn";
|
import { cn } from '../../lib/cn';
|
||||||
import { eyeClosed, eyeOpen } from "../../assets/icons/input";
|
import { eyeClosed, eyeOpen } from '../../assets/icons/input';
|
||||||
|
|
||||||
interface inputProps {
|
interface inputProps {
|
||||||
name?: string;
|
name?: string;
|
||||||
type: "text" | "email" | "password" | "first_name" | "number";
|
type: 'text' | 'email' | 'password' | 'first_name' | 'number';
|
||||||
error?: string;
|
error?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onChange: (state: string) => void;
|
onChange: (state: string) => void;
|
||||||
defaultState?: string;
|
defaultState?: string;
|
||||||
autocomplete?: string;
|
autocomplete?: string;
|
||||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Input: React.FC<inputProps> = ({
|
export const Input: React.FC<inputProps> = ({
|
||||||
type = "text",
|
type = 'text',
|
||||||
error = "",
|
error = '',
|
||||||
// disabled = false,
|
// disabled = false,
|
||||||
// required = false,
|
// required = false,
|
||||||
label = "",
|
label = '',
|
||||||
placeholder = "",
|
placeholder = '',
|
||||||
className = "",
|
className = '',
|
||||||
onChange,
|
onChange,
|
||||||
defaultState = "",
|
defaultState = '',
|
||||||
name = "",
|
name = '',
|
||||||
autocomplete = "",
|
autocomplete = '',
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
}) => {
|
}) => {
|
||||||
const [value, setValue] = React.useState<string>(defaultState);
|
const [value, setValue] = React.useState<string>(defaultState);
|
||||||
const [visible, setVIsible] = React.useState<boolean>(type != "password");
|
const [visible, setVIsible] = React.useState<boolean>(type != 'password');
|
||||||
|
|
||||||
React.useEffect(() => onChange(value), [value]);
|
React.useEffect(() => onChange(value), [value]);
|
||||||
React.useEffect(() => setValue(defaultState), [defaultState]);
|
React.useEffect(() => setValue(defaultState), [defaultState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', className)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-[18px] text-liquid-white font-medium h-[23px] mb-[10px] transition-all',
|
||||||
|
label == '' && 'h-0 mb-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className={cn(
|
||||||
|
'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light',
|
||||||
|
type == 'password' ? 'h-[40px]' : 'h-[36px]',
|
||||||
|
)}
|
||||||
|
value={value}
|
||||||
|
name={name}
|
||||||
|
autoComplete={autocomplete}
|
||||||
|
type={
|
||||||
|
type == 'password'
|
||||||
|
? visible
|
||||||
|
? 'text'
|
||||||
|
: 'password'
|
||||||
|
: type
|
||||||
|
}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (onKeyDown) onKeyDown(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{type == 'password' && (
|
||||||
|
<img
|
||||||
|
src={visible ? eyeOpen : eyeClosed}
|
||||||
|
className="w-[24px] h-[24px] cursor-pointer right-[16px] top-[8px] absolute"
|
||||||
|
onClick={() => {
|
||||||
|
setVIsible(!visible);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
return (
|
className={cn(
|
||||||
<div className={cn(
|
'text-liquid-red text-[14px] h-[18px] text-right mt-[5px]',
|
||||||
"relative",
|
error == '' && 'h-0 mt-0',
|
||||||
className
|
)}
|
||||||
)}>
|
>
|
||||||
<div className={cn("text-[18px] text-liquid-white font-medium h-[23px] mb-[10px] transition-all",
|
{error}
|
||||||
label == "" && "h-0 mb-0"
|
</div>
|
||||||
)}>
|
</div>
|
||||||
{label}
|
);
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
className={cn(
|
|
||||||
"bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light",
|
|
||||||
type == "password" ? "h-[40px]" : "h-[36px]"
|
|
||||||
)}
|
|
||||||
value={value}
|
|
||||||
name={name}
|
|
||||||
autoComplete={autocomplete}
|
|
||||||
type={type == "password" ? (visible ? "text" : "password") : type}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onChange={(e) => {
|
|
||||||
setValue(e.target.value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (onKeyDown)
|
|
||||||
onKeyDown(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
type == "password" &&
|
|
||||||
<img src={visible ? eyeOpen : eyeClosed} className="w-[24px] h-[24px] cursor-pointer right-[16px] top-[8px] absolute" onClick={() => {
|
|
||||||
setVIsible(!visible);
|
|
||||||
}} />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn("text-liquid-red text-[14px] h-[18px] text-right mt-[5px]",
|
|
||||||
error == "" && "h-0 mt-0"
|
|
||||||
)}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,78 +1,80 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { cn } from "../../lib/cn";
|
import { cn } from '../../lib/cn';
|
||||||
import { useClickOutside } from "../../hooks/useClickOutside";
|
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||||
|
|
||||||
type ModalBackdrop = "opaque" | "blur";
|
type ModalBackdrop = 'opaque' | 'blur';
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
backdrop?: ModalBackdrop;
|
backdrop?: ModalBackdrop;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
onOpenChange: (state: boolean) => void;
|
onOpenChange: (state: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modalbgVariants = {
|
const modalbgVariants = {
|
||||||
closed: { opacity: 0 },
|
closed: { opacity: 0 },
|
||||||
open: { opacity: 1 },
|
open: { opacity: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const modalVariants = {
|
const modalVariants = {
|
||||||
closed: { opacity: 0, scale: 0.9 },
|
closed: { opacity: 0, scale: 0.9 },
|
||||||
open: { opacity: 1, scale: 1 },
|
open: { opacity: 1, scale: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Modal: React.FC<ModalProps> = ({
|
export const Modal: React.FC<ModalProps> = ({
|
||||||
children,
|
children,
|
||||||
open,
|
open,
|
||||||
backdrop,
|
backdrop,
|
||||||
className,
|
className,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}) => {
|
}) => {
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useClickOutside(ref, () => {
|
useClickOutside(ref, () => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={modalbgVariants.closed}
|
initial={modalbgVariants.closed}
|
||||||
animate={modalbgVariants.open}
|
animate={modalbgVariants.open}
|
||||||
exit={modalbgVariants.closed}
|
exit={modalbgVariants.closed}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
" fixed top-0 left-0 h-svh w-svw backdrop-filter transition-all z-50",
|
' fixed top-0 left-0 h-svh w-svw backdrop-filter transition-all z-50',
|
||||||
backdrop == "blur" && open && "backdrop-blur-sm",
|
backdrop == 'blur' && open && 'backdrop-blur-sm',
|
||||||
backdrop == "opaque" && open && "bg-[#00000055] pointer-events-none",
|
backdrop == 'opaque' &&
|
||||||
)}
|
open &&
|
||||||
></motion.div>
|
'bg-[#00000055] pointer-events-none',
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
></motion.div>
|
||||||
<div className="fixed top-0 left-0 h-svh w-svw flex items-center justify-center pointer-events-none z-50">
|
)}
|
||||||
<AnimatePresence>
|
</AnimatePresence>
|
||||||
{open && (
|
<div className="fixed top-0 left-0 h-svh w-svw flex items-center justify-center pointer-events-none z-50">
|
||||||
<motion.div
|
<AnimatePresence>
|
||||||
ref={ref}
|
{open && (
|
||||||
className={cn(
|
<motion.div
|
||||||
"h-fit w-fit rounded-md pointer-events-auto",
|
ref={ref}
|
||||||
className
|
className={cn(
|
||||||
)}
|
'h-fit w-fit rounded-md pointer-events-auto',
|
||||||
initial={modalVariants.closed}
|
className,
|
||||||
animate={modalVariants.open}
|
)}
|
||||||
exit={modalVariants.closed}
|
initial={modalVariants.closed}
|
||||||
transition={{ duration: 0.15 }}
|
animate={modalVariants.open}
|
||||||
>
|
exit={modalVariants.closed}
|
||||||
{children}
|
transition={{ duration: 0.15 }}
|
||||||
</motion.div>
|
>
|
||||||
)}
|
{children}
|
||||||
</AnimatePresence>
|
</motion.div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</AnimatePresence>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,187 +1,191 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { cn } from "../../lib/cn";
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
/* Варианты размера контейнера */
|
/* Варианты размера контейнера */
|
||||||
const sizeVariants = {
|
const sizeVariants = {
|
||||||
sm: "h-6 w-10",
|
sm: 'h-6 w-10',
|
||||||
md: "h-7 w-12",
|
md: 'h-7 w-12',
|
||||||
lg: "h-8 w-14",
|
lg: 'h-8 w-14',
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Варианты для скользящего шарика */
|
/* Варианты для скользящего шарика */
|
||||||
const switchVariants = {
|
const switchVariants = {
|
||||||
size: {
|
size: {
|
||||||
sm: "h-4 w-4",
|
sm: 'h-4 w-4',
|
||||||
md: "h-5 w-5",
|
md: 'h-5 w-5',
|
||||||
lg: "h-6 w-6",
|
lg: 'h-6 w-6',
|
||||||
},
|
},
|
||||||
activeSize: {
|
activeSize: {
|
||||||
sm: "group-active:w-5",
|
sm: 'group-active:w-5',
|
||||||
md: "group-active:w-6",
|
md: 'group-active:w-6',
|
||||||
lg: "group-active:w-7",
|
lg: 'group-active:w-7',
|
||||||
},
|
},
|
||||||
iconSize: {
|
iconSize: {
|
||||||
sm: "h-3 w-3",
|
sm: 'h-3 w-3',
|
||||||
md: "h-[0.875rem] w-[0.875rem]",
|
md: 'h-[0.875rem] w-[0.875rem]',
|
||||||
lg: "h-4 w-4",
|
lg: 'h-4 w-4',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorsVariants = {
|
const colorsVariants = {
|
||||||
default: "bg-default",
|
default: 'bg-default',
|
||||||
primary: "bg-liquid-brightmain",
|
primary: 'bg-liquid-brightmain',
|
||||||
secondary: "bg-liquid-darkmain",
|
secondary: 'bg-liquid-darkmain',
|
||||||
success: "bg-liquid-green",
|
success: 'bg-liquid-green',
|
||||||
warning: "bg-liquid-orange",
|
warning: 'bg-liquid-orange',
|
||||||
danger: "bg-liquid-red",
|
danger: 'bg-liquid-red',
|
||||||
};
|
};
|
||||||
|
|
||||||
const focuseOutlineVariants = {
|
const focuseOutlineVariants = {
|
||||||
default: "[&:focus-visible+*]:outline-default",
|
default: '[&:focus-visible+*]:outline-default',
|
||||||
primary: "[&:focus-visible+*]:outline-liquid-brightmain",
|
primary: '[&:focus-visible+*]:outline-liquid-brightmain',
|
||||||
secondary: "[&:focus-visible+*]:outline-liquid-darkmain",
|
secondary: '[&:focus-visible+*]:outline-liquid-darkmain',
|
||||||
success: "[&:focus-visible+*]:outline-liquid-green",
|
success: '[&:focus-visible+*]:outline-liquid-green',
|
||||||
warning: "[&:focus-visible+*]:outline-liquid-orange",
|
warning: '[&:focus-visible+*]:outline-liquid-orange',
|
||||||
danger: "[&:focus-visible+*]:outline-liquid-red",
|
danger: '[&:focus-visible+*]:outline-liquid-red',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Иконка солнца
|
* Иконка солнца
|
||||||
*/
|
*/
|
||||||
const sun = (
|
const sun = (
|
||||||
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
d="M6 9.5C7.933 9.5 9.5 7.933 9.5 6C9.5 4.067 7.933 2.5 6 2.5C4.067 2.5 2.5 4.067 2.5 6C2.5 7.933 4.067 9.5 6 9.5Z"
|
d="M6 9.5C7.933 9.5 9.5 7.933 9.5 6C9.5 4.067 7.933 2.5 6 2.5C4.067 2.5 2.5 4.067 2.5 6C2.5 7.933 4.067 9.5 6 9.5Z"
|
||||||
fill="#292D32"
|
fill="#292D32"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M6 11.48C5.725 11.48 5.5 11.275 5.5 11V10.96C5.5 10.685 5.725 10.46 6 10.46C6.275 10.46 6.5 10.685 6.5 10.96C6.5 11.235 6.275 11.48 6 11.48ZM9.57 10.07C9.44 10.07 9.315 10.02 9.215 9.925L9.15 9.86C8.955 9.665 8.955 9.35 9.15 9.155C9.345 8.96 9.66 8.96 9.855 9.155L9.92 9.22C10.115 9.415 10.115 9.73 9.92 9.925C9.825 10.02 9.7 10.07 9.57 10.07ZM2.43 10.07C2.3 10.07 2.175 10.02 2.075 9.925C1.88 9.73 1.88 9.415 2.075 9.22L2.14 9.155C2.335 8.96 2.65 8.96 2.845 9.155C3.04 9.35 3.04 9.665 2.845 9.86L2.78 9.925C2.685 10.02 2.555 10.07 2.43 10.07ZM11 6.5H10.96C10.685 6.5 10.46 6.275 10.46 6C10.46 5.725 10.685 5.5 10.96 5.5C11.235 5.5 11.48 5.725 11.48 6C11.48 6.275 11.275 6.5 11 6.5ZM1.04 6.5H1C0.725 6.5 0.5 6.275 0.5 6C0.5 5.725 0.725 5.5 1 5.5C1.275 5.5 1.52 5.725 1.52 6C1.52 6.275 1.315 6.5 1.04 6.5ZM9.505 2.995C9.375 2.995 9.25 2.945 9.15 2.85C8.955 2.655 8.955 2.34 9.15 2.145L9.215 2.08C9.41 1.885 9.725 1.885 9.92 2.08C10.115 2.275 10.115 2.59 9.92 2.785L9.855 2.85C9.76 2.945 9.635 2.995 9.505 2.995ZM2.495 2.995C2.365 2.995 2.24 2.945 2.14 2.85L2.075 2.78C1.88 2.585 1.88 2.27 2.075 2.075C2.27 1.88 2.585 1.88 2.78 2.075L2.845 2.14C3.04 2.335 3.04 2.65 2.845 2.845C2.75 2.945 2.62 2.995 2.495 2.995ZM6 1.52C5.725 1.52 5.5 1.315 5.5 1.04V1C5.5 0.725 5.725 0.5 6 0.5C6.275 0.5 6.5 0.725 6.5 1C6.5 1.275 6.275 1.52 6 1.52Z"
|
d="M6 11.48C5.725 11.48 5.5 11.275 5.5 11V10.96C5.5 10.685 5.725 10.46 6 10.46C6.275 10.46 6.5 10.685 6.5 10.96C6.5 11.235 6.275 11.48 6 11.48ZM9.57 10.07C9.44 10.07 9.315 10.02 9.215 9.925L9.15 9.86C8.955 9.665 8.955 9.35 9.15 9.155C9.345 8.96 9.66 8.96 9.855 9.155L9.92 9.22C10.115 9.415 10.115 9.73 9.92 9.925C9.825 10.02 9.7 10.07 9.57 10.07ZM2.43 10.07C2.3 10.07 2.175 10.02 2.075 9.925C1.88 9.73 1.88 9.415 2.075 9.22L2.14 9.155C2.335 8.96 2.65 8.96 2.845 9.155C3.04 9.35 3.04 9.665 2.845 9.86L2.78 9.925C2.685 10.02 2.555 10.07 2.43 10.07ZM11 6.5H10.96C10.685 6.5 10.46 6.275 10.46 6C10.46 5.725 10.685 5.5 10.96 5.5C11.235 5.5 11.48 5.725 11.48 6C11.48 6.275 11.275 6.5 11 6.5ZM1.04 6.5H1C0.725 6.5 0.5 6.275 0.5 6C0.5 5.725 0.725 5.5 1 5.5C1.275 5.5 1.52 5.725 1.52 6C1.52 6.275 1.315 6.5 1.04 6.5ZM9.505 2.995C9.375 2.995 9.25 2.945 9.15 2.85C8.955 2.655 8.955 2.34 9.15 2.145L9.215 2.08C9.41 1.885 9.725 1.885 9.92 2.08C10.115 2.275 10.115 2.59 9.92 2.785L9.855 2.85C9.76 2.945 9.635 2.995 9.505 2.995ZM2.495 2.995C2.365 2.995 2.24 2.945 2.14 2.85L2.075 2.78C1.88 2.585 1.88 2.27 2.075 2.075C2.27 1.88 2.585 1.88 2.78 2.075L2.845 2.14C3.04 2.335 3.04 2.65 2.845 2.845C2.75 2.945 2.62 2.995 2.495 2.995ZM6 1.52C5.725 1.52 5.5 1.315 5.5 1.04V1C5.5 0.725 5.725 0.5 6 0.5C6.275 0.5 6.5 0.725 6.5 1C6.5 1.275 6.275 1.52 6 1.52Z"
|
||||||
fill="#292D32"
|
fill="#292D32"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Иконка луны
|
* Иконка луны
|
||||||
*/
|
*/
|
||||||
const moon = (
|
const moon = (
|
||||||
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
d="M10.765 7.965C10.685 7.83 10.46 7.62 9.89999 7.72C9.58999 7.775 9.27499 7.8 8.95999 7.785C7.79499 7.735 6.73999 7.2 6.00499 6.375C5.35499 5.65 4.95499 4.705 4.94999 3.685C4.94999 3.115 5.05999 2.565 5.28499 2.045C5.50499 1.54 5.34999 1.275 5.23999 1.165C5.12499 1.05 4.85499 0.890001 4.32499 1.11C2.27999 1.97 1.01499 4.02 1.16499 6.215C1.31499 8.28 2.76499 10.045 4.68499 10.71C5.14499 10.87 5.62999 10.965 6.12999 10.985C6.20999 10.99 6.28999 10.995 6.36999 10.995C8.04499 10.995 9.61499 10.205 10.605 8.86C10.94 8.395 10.85 8.1 10.765 7.965Z"
|
d="M10.765 7.965C10.685 7.83 10.46 7.62 9.89999 7.72C9.58999 7.775 9.27499 7.8 8.95999 7.785C7.79499 7.735 6.73999 7.2 6.00499 6.375C5.35499 5.65 4.95499 4.705 4.94999 3.685C4.94999 3.115 5.05999 2.565 5.28499 2.045C5.50499 1.54 5.34999 1.275 5.23999 1.165C5.12499 1.05 4.85499 0.890001 4.32499 1.11C2.27999 1.97 1.01499 4.02 1.16499 6.215C1.31499 8.28 2.76499 10.045 4.68499 10.71C5.14499 10.87 5.62999 10.965 6.12999 10.985C6.20999 10.99 6.28999 10.995 6.36999 10.995C8.04499 10.995 9.61499 10.205 10.605 8.86C10.94 8.395 10.85 8.1 10.765 7.965Z"
|
||||||
fill="#292D32"
|
fill="#292D32"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
interface SwitchProps {
|
interface SwitchProps {
|
||||||
size?: "sm" | "md" | "lg";
|
size?: 'sm' | 'md' | 'lg';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
color?:
|
color?:
|
||||||
| "default"
|
| 'default'
|
||||||
| "primary"
|
| 'primary'
|
||||||
| "secondary"
|
| 'secondary'
|
||||||
| "success"
|
| 'success'
|
||||||
| "warning"
|
| 'warning'
|
||||||
| "danger";
|
| 'danger';
|
||||||
label?: string;
|
label?: string;
|
||||||
variant?: "default" | "label" | "icon" | "theme";
|
variant?: 'default' | 'label' | 'icon' | 'theme';
|
||||||
className?: string;
|
className?: string;
|
||||||
defaultState?: boolean;
|
defaultState?: boolean;
|
||||||
onChange: (state: boolean) => void;
|
onChange: (state: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Switch: React.FC<SwitchProps> = ({
|
export const Switch: React.FC<SwitchProps> = ({
|
||||||
size = "sm",
|
size = 'sm',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
color = "primary",
|
color = 'primary',
|
||||||
label = "",
|
label = '',
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
className,
|
className,
|
||||||
onChange,
|
onChange,
|
||||||
defaultState = false,
|
defaultState = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [active, setActive] = React.useState<boolean>(defaultState);
|
const [active, setActive] = React.useState<boolean>(defaultState);
|
||||||
|
|
||||||
React.useEffect(() => onChange(active), [active]);
|
React.useEffect(() => onChange(active), [active]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={cn(
|
className={cn(
|
||||||
variant == "label" && "grid-cols-[auto_1fr] items-center gap-2",
|
variant == 'label' && 'grid-cols-[auto_1fr] items-center gap-2',
|
||||||
"grid relative cursor-pointer p-2 select-none group",
|
'grid relative cursor-pointer p-2 select-none group',
|
||||||
disabled && "pointer-events-none opacity-50",
|
disabled && 'pointer-events-none opacity-50',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
{/* Основной контейнер, */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
" flex items-center justify-center box-border z-10 relative transition-all duration-300 rounded-full",
|
|
||||||
sizeVariants[size],
|
|
||||||
active ? colorsVariants[color] : "bg-default-200"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Скрытый checkbox */}
|
|
||||||
<input
|
|
||||||
className={cn(
|
|
||||||
"absolute opacity-0 -z-10 h-0 w-0",
|
|
||||||
focuseOutlineVariants[color]
|
|
||||||
)}
|
|
||||||
disabled={disabled}
|
|
||||||
type="checkbox"
|
|
||||||
onChange={() => {
|
|
||||||
setActive(!active);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute outline-offset-[2.5px] outline-[2.5px] outline outline-transparent transition-all duration-300 rounded-full",
|
|
||||||
sizeVariants[size]
|
|
||||||
)}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
{/* Шарик */}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"bg-white rounded-full absolute transition-all duration-300 m-1 flex items-center justify-center",
|
|
||||||
switchVariants.size[size],
|
|
||||||
switchVariants.activeSize[size],
|
|
||||||
active
|
|
||||||
? "right-[0%]"
|
|
||||||
: "right-[calc(50%-0.25rem)] group-active:right-[calc(50%-0.5rem)]"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{variant == "theme" && (
|
{/* Основной контейнер, */}
|
||||||
<>
|
<div
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute transition-all duration-300",
|
' flex items-center justify-center box-border z-10 relative transition-all duration-300 rounded-full',
|
||||||
switchVariants.iconSize[size],
|
sizeVariants[size],
|
||||||
active ? "opacity-100 scale-100" : "opacity-0 scale-50"
|
active ? colorsVariants[color] : 'bg-default-200',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{moon}
|
{/* Скрытый checkbox */}
|
||||||
</div>
|
<input
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
'absolute opacity-0 -z-10 h-0 w-0',
|
||||||
"absolute transition-all duration-300",
|
focuseOutlineVariants[color],
|
||||||
switchVariants.iconSize[size],
|
)}
|
||||||
active ? "opacity-0 scale-50" : "opacity-100 scale-100"
|
disabled={disabled}
|
||||||
)}
|
type="checkbox"
|
||||||
>
|
onChange={() => {
|
||||||
{sun}
|
setActive(!active);
|
||||||
</div>
|
}}
|
||||||
</>
|
/>
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{variant == "label" && (
|
<div
|
||||||
<div className="select-none text-layout-foreground transition-all duration-200">
|
className={cn(
|
||||||
{label}
|
'absolute outline-offset-[2.5px] outline-[2.5px] outline outline-transparent transition-all duration-300 rounded-full',
|
||||||
</div>
|
sizeVariants[size],
|
||||||
)}
|
)}
|
||||||
</label>
|
></div>
|
||||||
);
|
|
||||||
|
{/* Шарик */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'bg-white rounded-full absolute transition-all duration-300 m-1 flex items-center justify-center',
|
||||||
|
switchVariants.size[size],
|
||||||
|
switchVariants.activeSize[size],
|
||||||
|
active
|
||||||
|
? 'right-[0%]'
|
||||||
|
: 'right-[calc(50%-0.25rem)] group-active:right-[calc(50%-0.5rem)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{variant == 'theme' && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute transition-all duration-300',
|
||||||
|
switchVariants.iconSize[size],
|
||||||
|
active
|
||||||
|
? 'opacity-100 scale-100'
|
||||||
|
: 'opacity-0 scale-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{moon}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute transition-all duration-300',
|
||||||
|
switchVariants.iconSize[size],
|
||||||
|
active
|
||||||
|
? 'opacity-0 scale-50'
|
||||||
|
: 'opacity-100 scale-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sun}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{variant == 'label' && (
|
||||||
|
<div className="select-none text-layout-foreground transition-all duration-200">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
export default {
|
export default {
|
||||||
liquid: {
|
liquid: {
|
||||||
brightmain: "var(--color-liquid-brightmain)",
|
brightmain: 'var(--color-liquid-brightmain)',
|
||||||
darkmain: "var(--color-liquid-darkmain)",
|
darkmain: 'var(--color-liquid-darkmain)',
|
||||||
darker: "var(--color-liquid-darker)",
|
darker: 'var(--color-liquid-darker)',
|
||||||
background: "var(--color-liquid-background)",
|
background: 'var(--color-liquid-background)',
|
||||||
lighter: "var(--color-liquid-lighter)",
|
lighter: 'var(--color-liquid-lighter)',
|
||||||
white: "var(--color-liquid-white)",
|
white: 'var(--color-liquid-white)',
|
||||||
red: "var(--color-liquid-red)",
|
red: 'var(--color-liquid-red)',
|
||||||
green: "var(--color-liquid-green)",
|
green: 'var(--color-liquid-green)',
|
||||||
light: "var(--color-liquid-light)",
|
light: 'var(--color-liquid-light)',
|
||||||
orange: "var(--color-liquid-orange)",
|
orange: 'var(--color-liquid-orange)',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
|
|
||||||
export const useClickOutside = (ref: React.RefObject<any>, onClickOutside: () => void) => {
|
export const useClickOutside = (
|
||||||
React.useEffect(() => {
|
ref: React.RefObject<any>,
|
||||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
onClickOutside: () => void,
|
||||||
if (ref.current && !ref.current.contains(event.target)) {
|
) => {
|
||||||
onClickOutside();
|
React.useEffect(() => {
|
||||||
}
|
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||||
}
|
if (ref.current && !ref.current.contains(event.target)) {
|
||||||
|
onClickOutside();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
document.addEventListener("touchstart", handleClickOutside);
|
document.addEventListener('touchstart', handleClickOutside);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
document.removeEventListener("touchstart", handleClickOutside);
|
document.removeEventListener('touchstart', handleClickOutside);
|
||||||
}
|
};
|
||||||
}, [ref, onClickOutside]);
|
}, [ref, onClickOutside]);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ClassValue, clsx } from "clsx";
|
import { ClassValue, clsx } from 'clsx';
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/main.tsx
28
src/main.tsx
@@ -1,16 +1,16 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from "./App.tsx";
|
import App from './App.tsx';
|
||||||
import "./styles/index.css";
|
import './styles/index.css';
|
||||||
import "./styles/palette/theme-dark.css";
|
import './styles/palette/theme-dark.css';
|
||||||
import "./styles/palette/theme-light.css";
|
import './styles/palette/theme-light.css';
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from 'react-redux';
|
||||||
import { store } from "./redux/store";
|
import { store } from './redux/store';
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<App />
|
<App />
|
||||||
</Provider>
|
</Provider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 Header from '../views/articleeditor/Header';
|
||||||
import MarkdownEditor from "../views/articleeditor/Editor";
|
import MarkdownEditor from '../views/articleeditor/Editor';
|
||||||
import { useState } from "react";
|
import { useState } from 'react';
|
||||||
import { PrimaryButton } from "../components/button/PrimaryButton";
|
import { PrimaryButton } from '../components/button/PrimaryButton';
|
||||||
import MarkdownPreview from "../views/articleeditor/MarckDownPreview";
|
import MarkdownPreview from '../views/articleeditor/MarckDownPreview';
|
||||||
import { Input } from "../components/input/Input";
|
import { Input } from '../components/input/Input';
|
||||||
|
|
||||||
|
|
||||||
const ArticleEditor = () => {
|
const ArticleEditor = () => {
|
||||||
const [code, setCode] = useState<string>("");
|
const [code, setCode] = useState<string>('');
|
||||||
const [name, setName] = useState<string>("");
|
const [name, setName] = useState<string>('');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [tagInput, setTagInput] = useState<string>('');
|
||||||
const [tagInput, setTagInput] = useState<string>("");
|
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
|
||||||
const addTag = () => {
|
const addTag = () => {
|
||||||
const newTag = tagInput.trim();
|
const newTag = tagInput.trim();
|
||||||
if (newTag && !tags.includes(newTag)) {
|
if (newTag && !tags.includes(newTag)) {
|
||||||
setTags([...tags, newTag]);
|
setTags([...tags, newTag]);
|
||||||
setTagInput("");
|
setTagInput('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTag = (tagToRemove: string) => {
|
const removeTag = (tagToRemove: string) => {
|
||||||
setTags(tags.filter(tag => tag !== tagToRemove));
|
setTags(tags.filter((tag) => tag !== tagToRemove));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen grid grid-rows-[60px,1fr]">
|
<div className="h-screen grid grid-rows-[60px,1fr]">
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="editor" element={<Header backUrl="/article/create" />} />
|
<Route
|
||||||
|
path="editor"
|
||||||
|
element={<Header backUrl="/article/create" />}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Header backUrl="/home/articles" />} />
|
<Route path="*" element={<Header backUrl="/home/articles" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="editor" element={<MarkdownEditor onChange={setCode} />} />
|
<Route
|
||||||
<Route path="*" element={
|
path="editor"
|
||||||
<div className="text-liquid-white">
|
element={<MarkdownEditor onChange={setCode} />}
|
||||||
<div className="text-[40px] font-bold">Создание статьи</div>
|
/>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
<PrimaryButton onClick={() => {
|
element={
|
||||||
console.log({
|
<div className="text-liquid-white">
|
||||||
name: name,
|
<div className="text-[40px] font-bold">
|
||||||
tags: tags,
|
Создание статьи
|
||||||
text: code,
|
|
||||||
})
|
|
||||||
|
|
||||||
}} text="Опубликовать" className="mt-[20px]" />
|
|
||||||
|
|
||||||
|
|
||||||
<Input name="articleName" autocomplete="articleName" className="mt-[20px] max-w-[600px]" type="text" label="Название" onChange={(v) => { setName(v) }} placeholder="Новая статья" />
|
|
||||||
|
|
||||||
|
|
||||||
{/* Блок для тегов */}
|
|
||||||
<div className="mt-[20px] max-w-[600px]">
|
|
||||||
|
|
||||||
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
|
|
||||||
<Input
|
|
||||||
name="articleTag"
|
|
||||||
autocomplete="articleTag"
|
|
||||||
className="mt-[20px] max-w-[600px]"
|
|
||||||
type="text"
|
|
||||||
label="Теги"
|
|
||||||
onChange={(v) => { setTagInput(v) }}
|
|
||||||
defaultState={tagInput}
|
|
||||||
placeholder="arrays"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
console.log(e.key);
|
|
||||||
if (e.key == "Enter")
|
|
||||||
addTag();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PrimaryButton onClick={addTag} text="Добавить" className="h-[40px] w-[140px]" />
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-[10px] mt-2">
|
|
||||||
{tags.map(tag => (
|
<PrimaryButton
|
||||||
<div
|
onClick={() => {
|
||||||
key={tag}
|
console.log({
|
||||||
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
|
name: name,
|
||||||
>
|
tags: tags,
|
||||||
<span>{tag}</span>
|
text: code,
|
||||||
<button onClick={() => removeTag(tag)} className="text-liquid-red font-bold ml-[5px]">×</button>
|
});
|
||||||
</div>
|
}}
|
||||||
))}
|
text="Опубликовать"
|
||||||
|
className="mt-[20px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
name="articleName"
|
||||||
|
autocomplete="articleName"
|
||||||
|
className="mt-[20px] max-w-[600px]"
|
||||||
|
type="text"
|
||||||
|
label="Название"
|
||||||
|
onChange={(v) => {
|
||||||
|
setName(v);
|
||||||
|
}}
|
||||||
|
placeholder="Новая статья"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Блок для тегов */}
|
||||||
|
<div className="mt-[20px] max-w-[600px]">
|
||||||
|
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
|
||||||
|
<Input
|
||||||
|
name="articleTag"
|
||||||
|
autocomplete="articleTag"
|
||||||
|
className="mt-[20px] max-w-[600px]"
|
||||||
|
type="text"
|
||||||
|
label="Теги"
|
||||||
|
onChange={(v) => {
|
||||||
|
setTagInput(v);
|
||||||
|
}}
|
||||||
|
defaultState={tagInput}
|
||||||
|
placeholder="arrays"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
console.log(e.key);
|
||||||
|
if (e.key == 'Enter') addTag();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={addTag}
|
||||||
|
text="Добавить"
|
||||||
|
className="h-[40px] w-[140px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-[10px] mt-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
|
||||||
|
>
|
||||||
|
<span>{tag}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
className="text-liquid-red font-bold ml-[5px]"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => navigate('editor')}
|
||||||
|
text="Редактировать текст"
|
||||||
|
className="mt-[20px]"
|
||||||
|
/>
|
||||||
|
<MarkdownPreview
|
||||||
|
content={code}
|
||||||
|
className="bg-transparent border-liquid-lighter border-[3px] rounder-[20px] mt-[20px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<PrimaryButton onClick={() => navigate("editor")} text="Редактировать текст" className="mt-[20px]" />
|
/>
|
||||||
<MarkdownPreview content={code} className="bg-transparent border-liquid-lighter border-[3px] rounder-[20px] mt-[20px]" />
|
|
||||||
</div>
|
|
||||||
} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,60 +1,75 @@
|
|||||||
// import React from "react";
|
// import React from "react";
|
||||||
import { Route, Routes } from "react-router-dom";
|
import { Route, Routes } from 'react-router-dom';
|
||||||
import Login from "../views/home/auth/Login";
|
import Login from '../views/home/auth/Login';
|
||||||
import Register from "../views/home/auth/Register";
|
import Register from '../views/home/auth/Register';
|
||||||
import Menu from "../views/home/menu/Menu";
|
import Menu from '../views/home/menu/Menu';
|
||||||
import { useAppDispatch, useAppSelector } from "../redux/hooks";
|
import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { fetchWhoAmI, logout } from "../redux/slices/auth";
|
import { fetchWhoAmI, logout } from '../redux/slices/auth';
|
||||||
import Missions from "../views/home/missions/Missions";
|
import Missions from '../views/home/missions/Missions';
|
||||||
import Articles from "../views/home/articles/Articles";
|
import Articles from '../views/home/articles/Articles';
|
||||||
import Groups from "../views/home/groups/Groups";
|
import Groups from '../views/home/groups/Groups';
|
||||||
import Contests from "../views/home/contests/Contests";
|
import Contests from '../views/home/contests/Contests';
|
||||||
import { PrimaryButton } from "../components/button/PrimaryButton";
|
import { PrimaryButton } from '../components/button/PrimaryButton';
|
||||||
import Group from "../views/home/groups/Group";
|
import Group from '../views/home/groups/Group';
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const name = useAppSelector((state) => state.auth.username);
|
const name = useAppSelector((state) => state.auth.username);
|
||||||
const jwt = useAppSelector((state) => state.auth.jwt);
|
const jwt = useAppSelector((state) => state.auth.jwt);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchWhoAmI());
|
dispatch(fetchWhoAmI());
|
||||||
}, [jwt])
|
}, [jwt]);
|
||||||
|
|
||||||
|
return (
|
||||||
return (
|
<div className="w-full bg-liquid-background grid grid-cols-[250px,1fr,250px] divide-x-[1px] divide-liquid-lighter">
|
||||||
<div className="w-full bg-liquid-background grid grid-cols-[250px,1fr,250px] divide-x-[1px] divide-liquid-lighter">
|
<div className="min-h-screen">
|
||||||
<div className="min-h-screen">
|
<Menu />
|
||||||
<Menu />
|
</div>
|
||||||
</div>
|
<div className="">
|
||||||
<div className="">
|
<Routes>
|
||||||
<Routes>
|
<Route path="login" element={<Login />} />
|
||||||
<Route path="login" element={<Login />} />
|
<Route path="account" element={<Login />} />
|
||||||
<Route path="account" element={<Login />} />
|
<Route path="register" element={<Register />} />
|
||||||
<Route path="register" element={<Register />} />
|
<Route path="missions/*" element={<Missions />} />
|
||||||
<Route path="missions/*" element={<Missions/>} />
|
<Route path="articles/*" element={<Articles />} />
|
||||||
<Route path="articles/*" element={<Articles/>} />
|
<Route path="group/:groupId" element={<Group />} />
|
||||||
<Route path="group/:groupId" element={<Group/>} />
|
<Route path="groups/*" element={<Groups />} />
|
||||||
<Route path="groups/*" element={<Groups/>} />
|
<Route path="contests/*" element={<Contests />} />
|
||||||
<Route path="contests/*" element={<Contests/>} />
|
<Route
|
||||||
<Route path="*" element={<>
|
path="*"
|
||||||
<p>{jwt}</p>
|
element={
|
||||||
<PrimaryButton onClick={() => {if (jwt) navigator.clipboard.writeText(jwt);}} text="скопировать токен" className="pt-[20px]"/>
|
<>
|
||||||
<p className="py-[20px]">{name}</p>
|
<p>{jwt}</p>
|
||||||
<PrimaryButton onClick={() => {dispatch(logout())}}>выйти</PrimaryButton>
|
<PrimaryButton
|
||||||
</>
|
onClick={() => {
|
||||||
}
|
if (jwt)
|
||||||
/>
|
navigator.clipboard.writeText(jwt);
|
||||||
</Routes>
|
}}
|
||||||
</div>
|
text="скопировать токен"
|
||||||
{
|
className="pt-[20px]"
|
||||||
<Routes>
|
/>
|
||||||
<Route path="articles/*" element={<div></div>} />
|
<p className="py-[20px]">{name}</p>
|
||||||
</Routes>
|
<PrimaryButton
|
||||||
}
|
onClick={() => {
|
||||||
</div>
|
dispatch(logout());
|
||||||
);
|
}}
|
||||||
|
>
|
||||||
|
выйти
|
||||||
|
</PrimaryButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
<Routes>
|
||||||
|
<Route path="articles/*" element={<div></div>} />
|
||||||
|
</Routes>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Home;
|
export default Home;
|
||||||
|
|||||||
@@ -10,187 +10,191 @@ import Header from '../views/mission/statement/Header';
|
|||||||
import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
|
import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
|
||||||
|
|
||||||
const Mission = () => {
|
const Mission = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
// Получаем параметры из URL
|
||||||
|
const { missionId } = useParams<{ missionId: string }>();
|
||||||
// Получаем параметры из URL
|
const mission = useAppSelector((state) => state.missions.currentMission);
|
||||||
const { missionId } = useParams<{ missionId: string }>();
|
const missionIdNumber = Number(missionId);
|
||||||
const mission = useAppSelector((state) => state.missions.currentMission);
|
if (!missionId || isNaN(missionIdNumber)) {
|
||||||
const missionIdNumber = Number(missionId);
|
return <Navigate to="/home" replace />;
|
||||||
if (!missionId || isNaN(missionIdNumber)) {
|
|
||||||
return <Navigate to="/home" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [code, setCode] = useState<string>("");
|
|
||||||
const [language, setLanguage] = useState<string>("");
|
|
||||||
|
|
||||||
const pollingRef = useRef<number | null>(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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [submissions]);
|
|
||||||
|
|
||||||
|
const [code, setCode] = useState<string>('');
|
||||||
|
const [language, setLanguage] = useState<string>('');
|
||||||
|
|
||||||
if (!mission || !mission.statements || mission.statements.length === 0) {
|
const pollingRef = useRef<number | null>(null);
|
||||||
return <div>Загрузка...</div>;
|
const submissions = useAppSelector(
|
||||||
}
|
(state) => state.submin.submitsById[missionIdNumber] || [],
|
||||||
|
|
||||||
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 submissionsRef = useRef(submissions);
|
||||||
|
|
||||||
// 2. Берём первый statement с форматом Html и языком russian
|
const startPolling = () => {
|
||||||
const htmlStatement = mission.statements.find(
|
if (pollingRef.current) return;
|
||||||
(stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Html"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!latexStatement) throw new Error("Не найден блок Latex на русском");
|
pollingRef.current = setInterval(async () => {
|
||||||
if (!htmlStatement) throw new Error("Не найден блок Html на русском");
|
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
||||||
|
|
||||||
// 3. Парсим данные из problem-properties.json
|
const hasWaiting = submissionsRef.current.some(
|
||||||
const statementTexts = JSON.parse(latexStatement.statementTexts["problem-properties.json"]);
|
(s: any) =>
|
||||||
|
s.solution.status == 'Waiting' ||
|
||||||
statementData = {
|
s.solution.testerState === 'Waiting',
|
||||||
id: missionIdNumber,
|
);
|
||||||
legend: statementTexts.legend,
|
if (!hasWaiting) {
|
||||||
timeLimit: statementTexts.timeLimit,
|
// Всё проверено — стоп
|
||||||
output: statementTexts.output,
|
if (pollingRef.current) {
|
||||||
input: statementTexts.input,
|
clearInterval(pollingRef.current);
|
||||||
sampleTests: statementTexts.sampleTests,
|
pollingRef.current = null;
|
||||||
name: statementTexts.name,
|
}
|
||||||
memoryLimit: statementTexts.memoryLimit,
|
}
|
||||||
tags: mission.tags,
|
}, 5000); // 10 секунд
|
||||||
notes: statementTexts.notes,
|
|
||||||
html: htmlStatement.statementTexts["problem.html"],
|
|
||||||
mediaFiles: latexStatement.mediaFiles
|
|
||||||
};
|
};
|
||||||
} 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;
|
||||||
|
|
||||||
<div className="h-screen grid grid-rows-[60px,1fr]">
|
if (submissions.length) {
|
||||||
<div className="">
|
const hasWaiting = submissions.some(
|
||||||
<Header missionId={missionIdNumber} />
|
(s) =>
|
||||||
</div>
|
s.solution.status === 'Waiting' ||
|
||||||
|
s.solution.testerState === 'Waiting',
|
||||||
|
);
|
||||||
|
|
||||||
<div className="grid grid-cols-2 h-full min-h-0 gap-[20px]">
|
if (hasWaiting) {
|
||||||
<div className="overflow-y-auto min-h-0 overflow-hidden">
|
startPolling();
|
||||||
<Statement
|
}
|
||||||
{...statementData}
|
}
|
||||||
|
}, [submissions]);
|
||||||
|
|
||||||
/>
|
if (!mission || !mission.statements || mission.statements.length === 0) {
|
||||||
|
return <div>Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="h-screen grid grid-rows-[60px,1fr]">
|
||||||
|
<div className="">
|
||||||
|
<Header missionId={missionIdNumber} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 h-full min-h-0 gap-[20px]">
|
||||||
|
<div className="overflow-y-auto min-h-0 overflow-hidden">
|
||||||
|
<Statement {...statementData} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto min-h-0 overflow-hidden pb-[20px]">
|
||||||
|
<div className=" grid grid-rows-[1fr,45px,230px] grid-flow-row h-full w-full gap-[20px] ">
|
||||||
|
<div className="w-full relative ">
|
||||||
|
<CodeEditor
|
||||||
|
onChange={(value: string) => {
|
||||||
|
setCode(value);
|
||||||
|
}}
|
||||||
|
onChangeLanguage={(value: string) => {
|
||||||
|
setLanguage(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<PrimaryButton
|
||||||
|
text="Отправить"
|
||||||
|
onClick={async () => {
|
||||||
|
await dispatch(
|
||||||
|
submitMission({
|
||||||
|
missionId: missionIdNumber,
|
||||||
|
language: language,
|
||||||
|
languageVersion: 'latest',
|
||||||
|
sourceCode: code,
|
||||||
|
contestId: null,
|
||||||
|
}),
|
||||||
|
).unwrap();
|
||||||
|
dispatch(
|
||||||
|
fetchMySubmitsByMission(
|
||||||
|
missionIdNumber,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-full w-full ">
|
||||||
|
<MissionSubmissions missionId={missionIdNumber} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<div className="overflow-y-auto min-h-0 overflow-hidden pb-[20px]">
|
|
||||||
<div className=' grid grid-rows-[1fr,45px,230px] grid-flow-row h-full w-full gap-[20px] '>
|
|
||||||
<div className='w-full relative '>
|
|
||||||
<CodeEditor
|
|
||||||
onChange={(value: string) => { setCode(value); }}
|
|
||||||
onChangeLanguage={((value: string) => { setLanguage(value); })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<PrimaryButton text='Отправить' onClick={async () => {
|
|
||||||
await dispatch(submitMission({
|
|
||||||
missionId: missionIdNumber,
|
|
||||||
language: language,
|
|
||||||
languageVersion: "latest",
|
|
||||||
sourceCode: code,
|
|
||||||
contestId: null,
|
|
||||||
|
|
||||||
})).unwrap();
|
|
||||||
dispatch(fetchMySubmitsByMission(missionIdNumber));
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='h-full w-full '>
|
|
||||||
<MissionSubmissions missionId={missionIdNumber} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Mission;
|
export default Mission;
|
||||||
|
|||||||
@@ -1,188 +1,260 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from "../../axios";
|
import axios from '../../axios';
|
||||||
|
|
||||||
// Типы данных
|
// Типы данных
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
jwt: string | null;
|
jwt: string | null;
|
||||||
refreshToken: string | null;
|
refreshToken: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
status: "idle" | "loading" | "successful" | "failed";
|
status: 'idle' | 'loading' | 'successful' | 'failed';
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация состояния
|
// Инициализация состояния
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
jwt: null,
|
jwt: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
username: null,
|
username: null,
|
||||||
status: "idle",
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// AsyncThunk: Регистрация
|
// AsyncThunk: Регистрация
|
||||||
export const registerUser = createAsyncThunk(
|
export const registerUser = createAsyncThunk(
|
||||||
"auth/register",
|
'auth/register',
|
||||||
async (
|
async (
|
||||||
{ username, email, password }: { username: string; email: string; password: string },
|
{
|
||||||
{ rejectWithValue }
|
username,
|
||||||
) => {
|
email,
|
||||||
try {
|
password,
|
||||||
const response = await axios.post("/authentication/register", { username, email, password });
|
}: { username: string; email: string; password: string },
|
||||||
return response.data; // { jwt, refreshToken }
|
{ rejectWithValue },
|
||||||
} catch (err: any) {
|
) => {
|
||||||
return rejectWithValue(err.response?.data?.message || "Registration failed");
|
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: Логин
|
// AsyncThunk: Логин
|
||||||
export const loginUser = createAsyncThunk(
|
export const loginUser = createAsyncThunk(
|
||||||
"auth/login",
|
'auth/login',
|
||||||
async (
|
async (
|
||||||
{ username, password }: { username: string; password: string },
|
{ username, password }: { username: string; password: string },
|
||||||
{ rejectWithValue }
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post("/authentication/login", { username, password });
|
const response = await axios.post('/authentication/login', {
|
||||||
return response.data; // { jwt, refreshToken }
|
username,
|
||||||
} catch (err: any) {
|
password,
|
||||||
return rejectWithValue(err.response?.data?.message || "Login failed");
|
});
|
||||||
}
|
return response.data; // { jwt, refreshToken }
|
||||||
}
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Login failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// AsyncThunk: Обновление токена
|
// AsyncThunk: Обновление токена
|
||||||
export const refreshToken = createAsyncThunk(
|
export const refreshToken = createAsyncThunk(
|
||||||
"auth/refresh",
|
'auth/refresh',
|
||||||
async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => {
|
async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post("/authentication/refresh", { refreshToken });
|
const response = await axios.post('/authentication/refresh', {
|
||||||
return response.data; // { username }
|
refreshToken,
|
||||||
} catch (err: any) {
|
});
|
||||||
return rejectWithValue(err.response?.data?.message || "Refresh token failed");
|
return response.data; // { username }
|
||||||
}
|
} catch (err: any) {
|
||||||
}
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Refresh token failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// AsyncThunk: Получение информации о пользователе
|
// AsyncThunk: Получение информации о пользователе
|
||||||
export const fetchWhoAmI = createAsyncThunk(
|
export const fetchWhoAmI = createAsyncThunk(
|
||||||
"auth/whoami",
|
'auth/whoami',
|
||||||
async (_, { rejectWithValue }) => {
|
async (_, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get("/authentication/whoami");
|
const response = await axios.get('/authentication/whoami');
|
||||||
return response.data; // { username }
|
return response.data; // { username }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data?.message || "Failed to fetch user info");
|
return rejectWithValue(
|
||||||
}
|
err.response?.data?.message || 'Failed to fetch user info',
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// AsyncThunk: Загрузка токенов из localStorage
|
// AsyncThunk: Загрузка токенов из localStorage
|
||||||
export const loadTokensFromLocalStorage = createAsyncThunk(
|
export const loadTokensFromLocalStorage = createAsyncThunk(
|
||||||
"auth/loadTokens",
|
'auth/loadTokens',
|
||||||
async (_, { }) => {
|
async (_, {}) => {
|
||||||
const jwt = localStorage.getItem("jwt");
|
const jwt = localStorage.getItem('jwt');
|
||||||
const refreshToken = localStorage.getItem("refreshToken");
|
const refreshToken = localStorage.getItem('refreshToken');
|
||||||
|
|
||||||
if (jwt && refreshToken) {
|
if (jwt && refreshToken) {
|
||||||
axios.defaults.headers.common['Authorization'] = `Bearer ${jwt}`;
|
axios.defaults.headers.common['Authorization'] = `Bearer ${jwt}`;
|
||||||
return { jwt, refreshToken };
|
return { jwt, refreshToken };
|
||||||
} else {
|
} else {
|
||||||
return { jwt: null, refreshToken: null };
|
return { jwt: null, refreshToken: null };
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Slice
|
// Slice
|
||||||
const authSlice = createSlice({
|
const authSlice = createSlice({
|
||||||
name: "auth",
|
name: 'auth',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
logout: (state) => {
|
logout: (state) => {
|
||||||
state.jwt = null;
|
state.jwt = null;
|
||||||
state.refreshToken = null;
|
state.refreshToken = null;
|
||||||
state.username = null;
|
state.username = null;
|
||||||
state.status = "idle";
|
state.status = 'idle';
|
||||||
state.error = null;
|
state.error = null;
|
||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem('jwt');
|
||||||
localStorage.removeItem("refreshToken");
|
localStorage.removeItem('refreshToken');
|
||||||
delete axios.defaults.headers.common['Authorization'];
|
delete axios.defaults.headers.common['Authorization'];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
extraReducers: (builder) => {
|
||||||
extraReducers: (builder) => {
|
// Регистрация
|
||||||
// Регистрация
|
builder.addCase(registerUser.pending, (state) => {
|
||||||
builder.addCase(registerUser.pending, (state) => {
|
state.status = 'loading';
|
||||||
state.status = "loading";
|
state.error = null;
|
||||||
state.error = null;
|
});
|
||||||
});
|
builder.addCase(
|
||||||
builder.addCase(registerUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => {
|
registerUser.fulfilled,
|
||||||
state.status = "successful";
|
(
|
||||||
state.jwt = action.payload.jwt;
|
state,
|
||||||
state.refreshToken = action.payload.refreshToken;
|
action: PayloadAction<{ jwt: string; refreshToken: string }>,
|
||||||
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
|
) => {
|
||||||
localStorage.setItem("jwt", action.payload.jwt);
|
state.status = 'successful';
|
||||||
localStorage.setItem("refreshToken", action.payload.refreshToken);
|
state.jwt = action.payload.jwt;
|
||||||
});
|
state.refreshToken = action.payload.refreshToken;
|
||||||
builder.addCase(registerUser.rejected, (state, action: PayloadAction<any>) => {
|
axios.defaults.headers.common[
|
||||||
state.status = "failed";
|
'Authorization'
|
||||||
state.error = action.payload;
|
] = `Bearer ${action.payload.jwt}`;
|
||||||
});
|
localStorage.setItem('jwt', action.payload.jwt);
|
||||||
|
localStorage.setItem(
|
||||||
|
'refreshToken',
|
||||||
|
action.payload.refreshToken,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(
|
||||||
|
registerUser.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
state.status = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Логин
|
// Логин
|
||||||
builder.addCase(loginUser.pending, (state) => {
|
builder.addCase(loginUser.pending, (state) => {
|
||||||
state.status = "loading";
|
state.status = 'loading';
|
||||||
state.error = null;
|
state.error = null;
|
||||||
});
|
});
|
||||||
builder.addCase(loginUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => {
|
builder.addCase(
|
||||||
state.status = "successful";
|
loginUser.fulfilled,
|
||||||
state.jwt = action.payload.jwt;
|
(
|
||||||
state.refreshToken = action.payload.refreshToken;
|
state,
|
||||||
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
|
action: PayloadAction<{ jwt: string; refreshToken: string }>,
|
||||||
localStorage.setItem("jwt", action.payload.jwt);
|
) => {
|
||||||
localStorage.setItem("refreshToken", action.payload.refreshToken);
|
state.status = 'successful';
|
||||||
});
|
state.jwt = action.payload.jwt;
|
||||||
builder.addCase(loginUser.rejected, (state, action: PayloadAction<any>) => {
|
state.refreshToken = action.payload.refreshToken;
|
||||||
state.status = "failed";
|
axios.defaults.headers.common[
|
||||||
state.error = action.payload;
|
'Authorization'
|
||||||
});
|
] = `Bearer ${action.payload.jwt}`;
|
||||||
|
localStorage.setItem('jwt', action.payload.jwt);
|
||||||
|
localStorage.setItem(
|
||||||
|
'refreshToken',
|
||||||
|
action.payload.refreshToken,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(
|
||||||
|
loginUser.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
state.status = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Обновление токена
|
// Обновление токена
|
||||||
builder.addCase(refreshToken.pending, (state) => {
|
builder.addCase(refreshToken.pending, (state) => {
|
||||||
state.status = "loading";
|
state.status = 'loading';
|
||||||
state.error = null;
|
state.error = null;
|
||||||
});
|
});
|
||||||
builder.addCase(refreshToken.fulfilled, (state, action: PayloadAction<{ username: string }>) => {
|
builder.addCase(
|
||||||
state.status = "successful";
|
refreshToken.fulfilled,
|
||||||
state.username = action.payload.username;
|
(state, action: PayloadAction<{ username: string }>) => {
|
||||||
});
|
state.status = 'successful';
|
||||||
builder.addCase(refreshToken.rejected, (state, action: PayloadAction<any>) => {
|
state.username = action.payload.username;
|
||||||
state.status = "failed";
|
},
|
||||||
state.error = action.payload;
|
);
|
||||||
});
|
builder.addCase(
|
||||||
|
refreshToken.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
state.status = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Получение информации о пользователе
|
// Получение информации о пользователе
|
||||||
builder.addCase(fetchWhoAmI.pending, (state) => {
|
builder.addCase(fetchWhoAmI.pending, (state) => {
|
||||||
state.status = "loading";
|
state.status = 'loading';
|
||||||
state.error = null;
|
state.error = null;
|
||||||
});
|
});
|
||||||
builder.addCase(fetchWhoAmI.fulfilled, (state, action: PayloadAction<{ username: string }>) => {
|
builder.addCase(
|
||||||
state.status = "successful";
|
fetchWhoAmI.fulfilled,
|
||||||
state.username = action.payload.username;
|
(state, action: PayloadAction<{ username: string }>) => {
|
||||||
});
|
state.status = 'successful';
|
||||||
builder.addCase(fetchWhoAmI.rejected, (state, action: PayloadAction<any>) => {
|
state.username = action.payload.username;
|
||||||
state.status = "failed";
|
},
|
||||||
state.error = action.payload;
|
);
|
||||||
});
|
builder.addCase(
|
||||||
|
fetchWhoAmI.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
state.status = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Загрузка токенов из localStorage
|
// Загрузка токенов из localStorage
|
||||||
builder.addCase(loadTokensFromLocalStorage.fulfilled, (state, action: PayloadAction<{ jwt: string | null; refreshToken: string | null }>) => {
|
builder.addCase(
|
||||||
state.jwt = action.payload.jwt;
|
loadTokensFromLocalStorage.fulfilled,
|
||||||
state.refreshToken = action.payload.refreshToken;
|
(
|
||||||
if (action.payload.jwt) {
|
state,
|
||||||
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
|
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;
|
export const { logout } = authSlice.actions;
|
||||||
|
|||||||
@@ -1,58 +1,58 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from "../../axios";
|
import axios from '../../axios';
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// Типы
|
// Типы
|
||||||
// =====================
|
// =====================
|
||||||
|
|
||||||
export interface Mission {
|
export interface Mission {
|
||||||
missionId: number;
|
missionId: number;
|
||||||
name: string;
|
name: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Member {
|
export interface Member {
|
||||||
userId: number;
|
userId: number;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Contest {
|
export interface Contest {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
scheduleType: string;
|
scheduleType: string;
|
||||||
startsAt: string;
|
startsAt: string;
|
||||||
endsAt: string;
|
endsAt: string;
|
||||||
availableFrom: string | null;
|
availableFrom: string | null;
|
||||||
availableUntil: string | null;
|
availableUntil: string | null;
|
||||||
attemptDurationMinutes: number | null;
|
attemptDurationMinutes: number | null;
|
||||||
groupId: number | null;
|
groupId: number | null;
|
||||||
groupName: string | null;
|
groupName: string | null;
|
||||||
missions: Mission[];
|
missions: Mission[];
|
||||||
articles: any[];
|
articles: any[];
|
||||||
members: Member[];
|
members: Member[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContestsResponse {
|
interface ContestsResponse {
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
contests: Contest[];
|
contests: Contest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateContestBody {
|
export interface CreateContestBody {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
scheduleType: "FixedWindow" | "Flexible";
|
scheduleType: 'FixedWindow' | 'Flexible';
|
||||||
startsAt: string;
|
startsAt: string;
|
||||||
endsAt: string;
|
endsAt: string;
|
||||||
availableFrom: string | null;
|
availableFrom: string | null;
|
||||||
availableUntil: string | null;
|
availableUntil: string | null;
|
||||||
attemptDurationMinutes: number | null;
|
attemptDurationMinutes: number | null;
|
||||||
groupId: number | null;
|
groupId: number | null;
|
||||||
missionIds: number[];
|
missionIds: number[];
|
||||||
articleIds: number[];
|
articleIds: number[];
|
||||||
participantIds: number[];
|
participantIds: number[];
|
||||||
organizerIds: number[];
|
organizerIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -60,19 +60,19 @@ export interface CreateContestBody {
|
|||||||
// =====================
|
// =====================
|
||||||
|
|
||||||
interface ContestsState {
|
interface ContestsState {
|
||||||
contests: Contest[];
|
contests: Contest[];
|
||||||
selectedContest: Contest | null;
|
selectedContest: Contest | null;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
status: "idle" | "loading" | "successful" | "failed";
|
status: 'idle' | 'loading' | 'successful' | 'failed';
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ContestsState = {
|
const initialState: ContestsState = {
|
||||||
contests: [],
|
contests: [],
|
||||||
selectedContest: null,
|
selectedContest: null,
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
status: "idle",
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -81,47 +81,60 @@ const initialState: ContestsState = {
|
|||||||
|
|
||||||
// Получение списка контестов
|
// Получение списка контестов
|
||||||
export const fetchContests = createAsyncThunk(
|
export const fetchContests = createAsyncThunk(
|
||||||
"contests/fetchAll",
|
'contests/fetchAll',
|
||||||
async (
|
async (
|
||||||
params: { page?: number; pageSize?: number; groupId?: number | null } = {},
|
params: {
|
||||||
{ rejectWithValue }
|
page?: number;
|
||||||
) => {
|
pageSize?: number;
|
||||||
try {
|
groupId?: number | null;
|
||||||
const { page = 0, pageSize = 10, groupId } = params;
|
} = {},
|
||||||
const response = await axios.get<ContestsResponse>("/contests", {
|
{ rejectWithValue },
|
||||||
params: { page, pageSize, groupId },
|
) => {
|
||||||
});
|
try {
|
||||||
return response.data;
|
const { page = 0, pageSize = 10, groupId } = params;
|
||||||
} catch (err: any) {
|
const response = await axios.get<ContestsResponse>('/contests', {
|
||||||
return rejectWithValue(err.response?.data?.message || "Failed to fetch contests");
|
params: { page, pageSize, groupId },
|
||||||
}
|
});
|
||||||
}
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Failed to fetch contests',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Получение одного контеста по ID
|
// Получение одного контеста по ID
|
||||||
export const fetchContestById = createAsyncThunk(
|
export const fetchContestById = createAsyncThunk(
|
||||||
"contests/fetchById",
|
'contests/fetchById',
|
||||||
async (id: number, { rejectWithValue }) => {
|
async (id: number, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get<Contest>(`/contests/${id}`);
|
const response = await axios.get<Contest>(`/contests/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data?.message || "Failed to fetch contest");
|
return rejectWithValue(
|
||||||
}
|
err.response?.data?.message || 'Failed to fetch contest',
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Создание нового контеста
|
// Создание нового контеста
|
||||||
export const createContest = createAsyncThunk(
|
export const createContest = createAsyncThunk(
|
||||||
"contests/create",
|
'contests/create',
|
||||||
async (contestData: CreateContestBody, { rejectWithValue }) => {
|
async (contestData: CreateContestBody, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<Contest>("/contests", contestData);
|
const response = await axios.post<Contest>(
|
||||||
return response.data;
|
'/contests',
|
||||||
} catch (err: any) {
|
contestData,
|
||||||
return rejectWithValue(err.response?.data?.message || "Failed to create contest");
|
);
|
||||||
}
|
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({
|
const contestsSlice = createSlice({
|
||||||
name: "contests",
|
name: 'contests',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
clearSelectedContest: (state) => {
|
clearSelectedContest: (state) => {
|
||||||
state.selectedContest = null;
|
state.selectedContest = null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
extraReducers: (builder) => {
|
||||||
extraReducers: (builder) => {
|
// fetchContests
|
||||||
// fetchContests
|
builder.addCase(fetchContests.pending, (state) => {
|
||||||
builder.addCase(fetchContests.pending, (state) => {
|
state.status = 'loading';
|
||||||
state.status = "loading";
|
state.error = null;
|
||||||
state.error = null;
|
});
|
||||||
});
|
builder.addCase(
|
||||||
builder.addCase(fetchContests.fulfilled, (state, action: PayloadAction<ContestsResponse>) => {
|
fetchContests.fulfilled,
|
||||||
state.status = "successful";
|
(state, action: PayloadAction<ContestsResponse>) => {
|
||||||
state.contests = action.payload.contests;
|
state.status = 'successful';
|
||||||
state.hasNextPage = action.payload.hasNextPage;
|
state.contests = action.payload.contests;
|
||||||
});
|
state.hasNextPage = action.payload.hasNextPage;
|
||||||
builder.addCase(fetchContests.rejected, (state, action: PayloadAction<any>) => {
|
},
|
||||||
state.status = "failed";
|
);
|
||||||
state.error = action.payload;
|
builder.addCase(
|
||||||
});
|
fetchContests.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
state.status = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// fetchContestById
|
// fetchContestById
|
||||||
builder.addCase(fetchContestById.pending, (state) => {
|
builder.addCase(fetchContestById.pending, (state) => {
|
||||||
state.status = "loading";
|
state.status = 'loading';
|
||||||
state.error = null;
|
state.error = null;
|
||||||
});
|
});
|
||||||
builder.addCase(fetchContestById.fulfilled, (state, action: PayloadAction<Contest>) => {
|
builder.addCase(
|
||||||
state.status = "successful";
|
fetchContestById.fulfilled,
|
||||||
state.selectedContest = action.payload;
|
(state, action: PayloadAction<Contest>) => {
|
||||||
});
|
state.status = 'successful';
|
||||||
builder.addCase(fetchContestById.rejected, (state, action: PayloadAction<any>) => {
|
state.selectedContest = action.payload;
|
||||||
state.status = "failed";
|
},
|
||||||
state.error = action.payload;
|
);
|
||||||
});
|
builder.addCase(
|
||||||
|
fetchContestById.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
state.status = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// createContest
|
// createContest
|
||||||
builder.addCase(createContest.pending, (state) => {
|
builder.addCase(createContest.pending, (state) => {
|
||||||
state.status = "loading";
|
state.status = 'loading';
|
||||||
state.error = null;
|
state.error = null;
|
||||||
});
|
});
|
||||||
builder.addCase(createContest.fulfilled, (state, action: PayloadAction<Contest>) => {
|
builder.addCase(
|
||||||
state.status = "successful";
|
createContest.fulfilled,
|
||||||
state.contests.unshift(action.payload);
|
(state, action: PayloadAction<Contest>) => {
|
||||||
});
|
state.status = 'successful';
|
||||||
builder.addCase(createContest.rejected, (state, action: PayloadAction<any>) => {
|
state.contests.unshift(action.payload);
|
||||||
state.status = "failed";
|
},
|
||||||
state.error = action.payload;
|
);
|
||||||
});
|
builder.addCase(
|
||||||
},
|
createContest.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
state.status = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
|
|||||||
@@ -1,282 +1,349 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from "../../axios";
|
import axios from '../../axios';
|
||||||
|
|
||||||
// ─── Типы ────────────────────────────────────────────
|
// ─── Типы ────────────────────────────────────────────
|
||||||
|
|
||||||
type Status = "idle" | "loading" | "successful" | "failed";
|
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||||
|
|
||||||
export interface GroupMember {
|
export interface GroupMember {
|
||||||
userId: number;
|
userId: number;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
members: GroupMember[];
|
members: GroupMember[];
|
||||||
contests: any[];
|
contests: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupsState {
|
interface GroupsState {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
currentGroup: Group | null;
|
currentGroup: Group | null;
|
||||||
statuses: {
|
statuses: {
|
||||||
create: Status;
|
create: Status;
|
||||||
update: Status;
|
update: Status;
|
||||||
delete: Status;
|
delete: Status;
|
||||||
fetchMy: Status;
|
fetchMy: Status;
|
||||||
fetchById: Status;
|
fetchById: Status;
|
||||||
addMember: Status;
|
addMember: Status;
|
||||||
removeMember: Status;
|
removeMember: Status;
|
||||||
};
|
};
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: GroupsState = {
|
const initialState: GroupsState = {
|
||||||
groups: [],
|
groups: [],
|
||||||
currentGroup: null,
|
currentGroup: null,
|
||||||
statuses: {
|
statuses: {
|
||||||
create: "idle",
|
create: 'idle',
|
||||||
update: "idle",
|
update: 'idle',
|
||||||
delete: "idle",
|
delete: 'idle',
|
||||||
fetchMy: "idle",
|
fetchMy: 'idle',
|
||||||
fetchById: "idle",
|
fetchById: 'idle',
|
||||||
addMember: "idle",
|
addMember: 'idle',
|
||||||
removeMember: "idle",
|
removeMember: 'idle',
|
||||||
},
|
},
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// ─── Async Thunks ─────────────────────────────────────
|
// ─── Async Thunks ─────────────────────────────────────
|
||||||
|
|
||||||
// POST /groups
|
// POST /groups
|
||||||
export const createGroup = createAsyncThunk(
|
export const createGroup = createAsyncThunk(
|
||||||
"groups/createGroup",
|
'groups/createGroup',
|
||||||
async (
|
async (
|
||||||
{ name, description }: { name: string; description: string },
|
{ name, description }: { name: string; description: string },
|
||||||
{ rejectWithValue }
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post("/groups", { name, description });
|
const response = await axios.post('/groups', { name, description });
|
||||||
return response.data as Group;
|
return response.data as Group;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data?.message || "Ошибка при создании группы");
|
return rejectWithValue(
|
||||||
}
|
err.response?.data?.message || 'Ошибка при создании группы',
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// PUT /groups/{groupId}
|
// PUT /groups/{groupId}
|
||||||
export const updateGroup = createAsyncThunk(
|
export const updateGroup = createAsyncThunk(
|
||||||
"groups/updateGroup",
|
'groups/updateGroup',
|
||||||
async (
|
async (
|
||||||
{ groupId, name, description }: { groupId: number; name: string; description: string },
|
{
|
||||||
{ rejectWithValue }
|
groupId,
|
||||||
) => {
|
name,
|
||||||
try {
|
description,
|
||||||
const response = await axios.put(`/groups/${groupId}`, { name, description });
|
}: { groupId: number; name: string; description: string },
|
||||||
return response.data as Group;
|
{ rejectWithValue },
|
||||||
} catch (err: any) {
|
) => {
|
||||||
return rejectWithValue(err.response?.data?.message || "Ошибка при обновлении группы");
|
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}
|
// DELETE /groups/{groupId}
|
||||||
export const deleteGroup = createAsyncThunk(
|
export const deleteGroup = createAsyncThunk(
|
||||||
"groups/deleteGroup",
|
'groups/deleteGroup',
|
||||||
async (groupId: number, { rejectWithValue }) => {
|
async (groupId: number, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/groups/${groupId}`);
|
await axios.delete(`/groups/${groupId}`);
|
||||||
return groupId;
|
return groupId;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data?.message || "Ошибка при удалении группы");
|
return rejectWithValue(
|
||||||
}
|
err.response?.data?.message || 'Ошибка при удалении группы',
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /groups/my
|
// GET /groups/my
|
||||||
export const fetchMyGroups = createAsyncThunk(
|
export const fetchMyGroups = createAsyncThunk(
|
||||||
"groups/fetchMyGroups",
|
'groups/fetchMyGroups',
|
||||||
async (_, { rejectWithValue }) => {
|
async (_, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get("/groups/my");
|
const response = await axios.get('/groups/my');
|
||||||
return response.data.groups as Group[];
|
return response.data.groups as Group[];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data?.message || "Ошибка при получении групп");
|
return rejectWithValue(
|
||||||
}
|
err.response?.data?.message || 'Ошибка при получении групп',
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /groups/{groupId}
|
// GET /groups/{groupId}
|
||||||
export const fetchGroupById = createAsyncThunk(
|
export const fetchGroupById = createAsyncThunk(
|
||||||
"groups/fetchGroupById",
|
'groups/fetchGroupById',
|
||||||
async (groupId: number, { rejectWithValue }) => {
|
async (groupId: number, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`/groups/${groupId}`);
|
const response = await axios.get(`/groups/${groupId}`);
|
||||||
return response.data as Group;
|
return response.data as Group;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data?.message || "Ошибка при получении группы");
|
return rejectWithValue(
|
||||||
}
|
err.response?.data?.message || 'Ошибка при получении группы',
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /groups/members
|
// POST /groups/members
|
||||||
export const addGroupMember = createAsyncThunk(
|
export const addGroupMember = createAsyncThunk(
|
||||||
"groups/addGroupMember",
|
'groups/addGroupMember',
|
||||||
async ({ userId, role }: { userId: number; role: string }, { rejectWithValue }) => {
|
async (
|
||||||
try {
|
{ userId, role }: { userId: number; role: string },
|
||||||
await axios.post("/groups/members", { userId, role });
|
{ rejectWithValue },
|
||||||
return { userId, role };
|
) => {
|
||||||
} catch (err: any) {
|
try {
|
||||||
return rejectWithValue(err.response?.data?.message || "Ошибка при добавлении участника");
|
await axios.post('/groups/members', { userId, role });
|
||||||
}
|
return { userId, role };
|
||||||
}
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Ошибка при добавлении участника',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE /groups/{groupId}/members/{memberId}
|
// DELETE /groups/{groupId}/members/{memberId}
|
||||||
export const removeGroupMember = createAsyncThunk(
|
export const removeGroupMember = createAsyncThunk(
|
||||||
"groups/removeGroupMember",
|
'groups/removeGroupMember',
|
||||||
async (
|
async (
|
||||||
{ groupId, memberId }: { groupId: number; memberId: number },
|
{ groupId, memberId }: { groupId: number; memberId: number },
|
||||||
{ rejectWithValue }
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/groups/${groupId}/members/${memberId}`);
|
await axios.delete(`/groups/${groupId}/members/${memberId}`);
|
||||||
return { groupId, memberId };
|
return { groupId, memberId };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data?.message || "Ошибка при удалении участника");
|
return rejectWithValue(
|
||||||
}
|
err.response?.data?.message || 'Ошибка при удалении участника',
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Slice ────────────────────────────────────────────
|
// ─── Slice ────────────────────────────────────────────
|
||||||
|
|
||||||
const groupsSlice = createSlice({
|
const groupsSlice = createSlice({
|
||||||
name: "groups",
|
name: 'groups',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
clearCurrentGroup: (state) => {
|
clearCurrentGroup: (state) => {
|
||||||
state.currentGroup = null;
|
state.currentGroup = null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
extraReducers: (builder) => {
|
||||||
extraReducers: (builder) => {
|
// ─── CREATE GROUP ───
|
||||||
// ─── CREATE GROUP ───
|
builder.addCase(createGroup.pending, (state) => {
|
||||||
builder.addCase(createGroup.pending, (state) => {
|
state.statuses.create = 'loading';
|
||||||
state.statuses.create = "loading";
|
state.error = null;
|
||||||
state.error = null;
|
});
|
||||||
});
|
builder.addCase(
|
||||||
builder.addCase(createGroup.fulfilled, (state, action: PayloadAction<Group>) => {
|
createGroup.fulfilled,
|
||||||
state.statuses.create = "successful";
|
(state, action: PayloadAction<Group>) => {
|
||||||
state.groups.push(action.payload);
|
state.statuses.create = 'successful';
|
||||||
});
|
state.groups.push(action.payload);
|
||||||
builder.addCase(createGroup.rejected, (state, action: PayloadAction<any>) => {
|
},
|
||||||
state.statuses.create = "failed";
|
);
|
||||||
state.error = action.payload;
|
builder.addCase(
|
||||||
});
|
createGroup.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
state.statuses.create = 'failed';
|
||||||
// ─── UPDATE GROUP ───
|
state.error = action.payload;
|
||||||
builder.addCase(updateGroup.pending, (state) => {
|
},
|
||||||
state.statuses.update = "loading";
|
|
||||||
state.error = null;
|
|
||||||
});
|
|
||||||
builder.addCase(updateGroup.fulfilled, (state, action: PayloadAction<Group>) => {
|
|
||||||
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<any>) => {
|
|
||||||
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<number>) => {
|
|
||||||
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<any>) => {
|
|
||||||
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<Group[]>) => {
|
|
||||||
state.statuses.fetchMy = "successful";
|
|
||||||
state.groups = action.payload;
|
|
||||||
});
|
|
||||||
builder.addCase(fetchMyGroups.rejected, (state, action: PayloadAction<any>) => {
|
|
||||||
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<Group>) => {
|
|
||||||
state.statuses.fetchById = "successful";
|
|
||||||
state.currentGroup = action.payload;
|
|
||||||
});
|
|
||||||
builder.addCase(fetchGroupById.rejected, (state, action: PayloadAction<any>) => {
|
|
||||||
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<any>) => {
|
|
||||||
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<any>) => {
|
|
||||||
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<Group>) => {
|
||||||
|
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<any>) => {
|
||||||
|
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<number>) => {
|
||||||
|
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<any>) => {
|
||||||
|
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<Group[]>) => {
|
||||||
|
state.statuses.fetchMy = 'successful';
|
||||||
|
state.groups = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(
|
||||||
|
fetchMyGroups.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
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<Group>) => {
|
||||||
|
state.statuses.fetchById = 'successful';
|
||||||
|
state.currentGroup = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(
|
||||||
|
fetchGroupById.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
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<any>) => {
|
||||||
|
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<any>) => {
|
||||||
|
state.statuses.removeMember = 'failed';
|
||||||
|
state.error = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { clearCurrentGroup } = groupsSlice.actions;
|
export const { clearCurrentGroup } = groupsSlice.actions;
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
import { createSlice, PayloadAction} from "@reduxjs/toolkit";
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
// Типы данных
|
// Типы данных
|
||||||
interface StorState {
|
interface StorState {
|
||||||
menu: {
|
menu: {
|
||||||
activePage: string;
|
activePage: string;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация состояния
|
// Инициализация состояния
|
||||||
const initialState: StorState = {
|
const initialState: StorState = {
|
||||||
menu: {
|
menu: {
|
||||||
activePage: "",
|
activePage: '',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Slice
|
// Slice
|
||||||
const storeSlice = createSlice({
|
const storeSlice = createSlice({
|
||||||
name: "store",
|
name: 'store',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setMenuActivePage: (state, activePage: PayloadAction<string>) => {
|
setMenuActivePage: (state, activePage: PayloadAction<string>) => {
|
||||||
state.menu.activePage = activePage.payload;
|
state.menu.activePage = activePage.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { setMenuActivePage } = storeSlice.actions;
|
export const { setMenuActivePage } = storeSlice.actions;
|
||||||
|
|||||||
@@ -1,184 +1,224 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from "../../axios";
|
import axios from '../../axios';
|
||||||
|
|
||||||
// Типы данных
|
// Типы данных
|
||||||
export interface Submit {
|
export interface Submit {
|
||||||
id?: number;
|
id?: number;
|
||||||
missionId: number;
|
missionId: number;
|
||||||
language: string;
|
language: string;
|
||||||
languageVersion: string;
|
languageVersion: string;
|
||||||
sourceCode: string;
|
sourceCode: string;
|
||||||
contestId: number | null;
|
contestId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Solution {
|
export interface Solution {
|
||||||
id: number;
|
id: number;
|
||||||
missionId: number;
|
missionId: number;
|
||||||
language: string;
|
language: string;
|
||||||
languageVersion: string;
|
languageVersion: string;
|
||||||
sourceCode: string;
|
sourceCode: string;
|
||||||
status: string;
|
status: string;
|
||||||
time: string;
|
time: string;
|
||||||
testerState: string;
|
testerState: string;
|
||||||
testerErrorCode: string;
|
testerErrorCode: string;
|
||||||
testerMessage: string;
|
testerMessage: string;
|
||||||
currentTest: number;
|
currentTest: number;
|
||||||
amountOfTests: number;
|
amountOfTests: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MissionSubmit {
|
export interface MissionSubmit {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
solution: Solution;
|
solution: Solution;
|
||||||
contestId: number | null;
|
contestId: number | null;
|
||||||
contestName: string | null;
|
contestName: string | null;
|
||||||
sourceType: string;
|
sourceType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubmitState {
|
interface SubmitState {
|
||||||
submits: Submit[];
|
submits: Submit[];
|
||||||
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
|
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
|
||||||
currentSubmit?: Submit;
|
currentSubmit?: Submit;
|
||||||
status: "idle" | "loading" | "successful" | "failed";
|
status: 'idle' | 'loading' | 'successful' | 'failed';
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Начальное состояние
|
// Начальное состояние
|
||||||
const initialState: SubmitState = {
|
const initialState: SubmitState = {
|
||||||
submits: [],
|
submits: [],
|
||||||
submitsById: {}, // ✅ инициализация
|
submitsById: {}, // ✅ инициализация
|
||||||
currentSubmit: undefined,
|
currentSubmit: undefined,
|
||||||
status: "idle",
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// AsyncThunk: Отправка решения
|
// AsyncThunk: Отправка решения
|
||||||
export const submitMission = createAsyncThunk(
|
export const submitMission = createAsyncThunk(
|
||||||
"submit/submitMission",
|
'submit/submitMission',
|
||||||
async (submitData: Submit, { rejectWithValue }) => {
|
async (submitData: Submit, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post("/submits", submitData);
|
const response = await axios.post('/submits', submitData);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data?.message || "Submit failed");
|
return rejectWithValue(
|
||||||
}
|
err.response?.data?.message || 'Submit failed',
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// AsyncThunk: Получить все свои отправки
|
// AsyncThunk: Получить все свои отправки
|
||||||
export const fetchMySubmits = createAsyncThunk(
|
export const fetchMySubmits = createAsyncThunk(
|
||||||
"submit/fetchMySubmits",
|
'submit/fetchMySubmits',
|
||||||
async (_, { rejectWithValue }) => {
|
async (_, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get("/submits/my");
|
const response = await axios.get('/submits/my');
|
||||||
return response.data as Submit[];
|
return response.data as Submit[];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data?.message || "Failed to fetch submits");
|
return rejectWithValue(
|
||||||
}
|
err.response?.data?.message || 'Failed to fetch submits',
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// AsyncThunk: Получить конкретную отправку по ID
|
// AsyncThunk: Получить конкретную отправку по ID
|
||||||
export const fetchSubmitById = createAsyncThunk(
|
export const fetchSubmitById = createAsyncThunk(
|
||||||
"submit/fetchSubmitById",
|
'submit/fetchSubmitById',
|
||||||
async (id: number, { rejectWithValue }) => {
|
async (id: number, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`/submits/${id}`);
|
const response = await axios.get(`/submits/${id}`);
|
||||||
return response.data as Submit;
|
return response.data as Submit;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(err.response?.data?.message || "Failed to fetch submit");
|
return rejectWithValue(
|
||||||
}
|
err.response?.data?.message || 'Failed to fetch submit',
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ✅ AsyncThunk: Получить отправки для конкретной миссии (новая структура)
|
// ✅ AsyncThunk: Получить отправки для конкретной миссии (новая структура)
|
||||||
export const fetchMySubmitsByMission = createAsyncThunk(
|
export const fetchMySubmitsByMission = createAsyncThunk(
|
||||||
"submit/fetchMySubmitsByMission",
|
'submit/fetchMySubmitsByMission',
|
||||||
async (missionId: number, { rejectWithValue }) => {
|
async (missionId: number, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`/submits/my/mission/${missionId}`);
|
const response = await axios.get(
|
||||||
return { missionId, data: response.data as MissionSubmit[] };
|
`/submits/my/mission/${missionId}`,
|
||||||
} catch (err: any) {
|
);
|
||||||
return rejectWithValue(err.response?.data?.message || "Failed to fetch mission submits");
|
return { missionId, data: response.data as MissionSubmit[] };
|
||||||
}
|
} catch (err: any) {
|
||||||
}
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Failed to fetch mission submits',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Slice
|
// Slice
|
||||||
const submitSlice = createSlice({
|
const submitSlice = createSlice({
|
||||||
name: "submit",
|
name: 'submit',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
clearCurrentSubmit: (state) => {
|
clearCurrentSubmit: (state) => {
|
||||||
state.currentSubmit = undefined;
|
state.currentSubmit = undefined;
|
||||||
state.status = "idle";
|
state.status = 'idle';
|
||||||
state.error = null;
|
state.error = null;
|
||||||
|
},
|
||||||
|
clearSubmitsByMission: (state, action: PayloadAction<number>) => {
|
||||||
|
delete state.submitsById[action.payload];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
clearSubmitsByMission: (state, action: PayloadAction<number>) => {
|
extraReducers: (builder) => {
|
||||||
delete state.submitsById[action.payload];
|
// Отправка решения
|
||||||
|
builder.addCase(submitMission.pending, (state) => {
|
||||||
|
state.status = 'loading';
|
||||||
|
state.error = null;
|
||||||
|
});
|
||||||
|
builder.addCase(
|
||||||
|
submitMission.fulfilled,
|
||||||
|
(state, action: PayloadAction<Submit>) => {
|
||||||
|
state.status = 'successful';
|
||||||
|
state.submits.push(action.payload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(
|
||||||
|
submitMission.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
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<Submit[]>) => {
|
||||||
|
state.status = 'successful';
|
||||||
|
state.submits = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(
|
||||||
|
fetchMySubmits.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
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<Submit>) => {
|
||||||
|
state.status = 'successful';
|
||||||
|
state.currentSubmit = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(
|
||||||
|
fetchSubmitById.rejected,
|
||||||
|
(state, action: PayloadAction<any>) => {
|
||||||
|
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<any>) => {
|
||||||
|
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<Submit>) => {
|
|
||||||
state.status = "successful";
|
|
||||||
state.submits.push(action.payload);
|
|
||||||
});
|
|
||||||
builder.addCase(submitMission.rejected, (state, action: PayloadAction<any>) => {
|
|
||||||
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<Submit[]>) => {
|
|
||||||
state.status = "successful";
|
|
||||||
state.submits = action.payload;
|
|
||||||
});
|
|
||||||
builder.addCase(fetchMySubmits.rejected, (state, action: PayloadAction<any>) => {
|
|
||||||
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<Submit>) => {
|
|
||||||
state.status = "successful";
|
|
||||||
state.currentSubmit = action.payload;
|
|
||||||
});
|
|
||||||
builder.addCase(fetchSubmitById.rejected, (state, action: PayloadAction<any>) => {
|
|
||||||
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<any>) => {
|
|
||||||
state.status = "failed";
|
|
||||||
state.error = action.payload;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { clearCurrentSubmit, clearSubmitsByMission } = submitSlice.actions;
|
export const { clearCurrentSubmit, clearSubmitsByMission } =
|
||||||
|
submitSlice.actions;
|
||||||
export const submitReducer = submitSlice.reducer;
|
export const submitReducer = submitSlice.reducer;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit";
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import { authReducer } from "./slices/auth";
|
import { authReducer } from './slices/auth';
|
||||||
import { storeReducer } from "./slices/store";
|
import { storeReducer } from './slices/store';
|
||||||
import { missionsReducer } from "./slices/missions";
|
import { missionsReducer } from './slices/missions';
|
||||||
import { submitReducer } from "./slices/submit";
|
import { submitReducer } from './slices/submit';
|
||||||
import { contestsReducer } from "./slices/contests";
|
import { contestsReducer } from './slices/contests';
|
||||||
import { groupsReducer } from "./slices/groups";
|
import { groupsReducer } from './slices/groups';
|
||||||
|
|
||||||
|
|
||||||
// использование
|
// использование
|
||||||
// import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
// import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||||
@@ -15,17 +14,16 @@ import { groupsReducer } from "./slices/groups";
|
|||||||
// const dispatch = useAppDispatch();
|
// const dispatch = useAppDispatch();
|
||||||
// const user = useAppSelector((state) => state.user);
|
// const user = useAppSelector((state) => state.user);
|
||||||
|
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
//user: userReducer,
|
//user: userReducer,
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
store: storeReducer,
|
store: storeReducer,
|
||||||
missions: missionsReducer,
|
missions: missionsReducer,
|
||||||
submin: submitReducer,
|
submin: submitReducer,
|
||||||
contests: contestsReducer,
|
contests: contestsReducer,
|
||||||
groups: groupsReducer,
|
groups: groupsReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// тип состояния всего стора
|
// тип состояния всего стора
|
||||||
|
|||||||
@@ -2,116 +2,108 @@
|
|||||||
@import 'tailwindcss/components';
|
@import 'tailwindcss/components';
|
||||||
@import 'tailwindcss/utilities';
|
@import 'tailwindcss/utilities';
|
||||||
|
|
||||||
@import "./latex-container.css";
|
@import './latex-container.css';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/
|
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/
|
||||||
/* outline: 1px solid green; */
|
/* outline: 1px solid green; */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100svh;
|
height: 100svh;
|
||||||
/* @apply bg-layout-background; */
|
/* @apply bg-layout-background; */
|
||||||
/* transition: all linear 200ms; */
|
/* transition: all linear 200ms; */
|
||||||
|
|
||||||
font-family: 'Source Code Pro', monospace;
|
font-family: 'Source Code Pro', monospace;
|
||||||
|
|
||||||
/* font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; */
|
/* font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; */
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
background-color: var(--color-liquid-background);
|
background-color: var(--color-liquid-background);
|
||||||
color: rgba(255, 255, 255, 0.87);
|
color: rgba(255, 255, 255, 0.87);
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Общий контейнер полосы прокрутки */
|
/* Общий контейнер полосы прокрутки */
|
||||||
.thin-scrollbar::-webkit-scrollbar {
|
.thin-scrollbar::-webkit-scrollbar {
|
||||||
width: 4px; /* ширина вертикального */
|
width: 4px; /* ширина вертикального */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Трек (фон) */
|
/* Трек (фон) */
|
||||||
.thin-scrollbar::-webkit-scrollbar-track {
|
.thin-scrollbar::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ползунок (thumb) */
|
/* Ползунок (thumb) */
|
||||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: var(--color-liquid-light);
|
background: var(--color-liquid-light);
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Общий контейнер полосы прокрутки */
|
/* Общий контейнер полосы прокрутки */
|
||||||
.medium-scrollbar::-webkit-scrollbar {
|
.medium-scrollbar::-webkit-scrollbar {
|
||||||
width: 8px; /* ширина вертикального */
|
width: 8px; /* ширина вертикального */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Трек (фон) */
|
/* Трек (фон) */
|
||||||
.medium-scrollbar::-webkit-scrollbar-track {
|
.medium-scrollbar::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ползунок (thumb) */
|
/* Ползунок (thumb) */
|
||||||
.medium-scrollbar::-webkit-scrollbar-thumb {
|
.medium-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: var(--color-liquid-light);
|
background: var(--color-liquid-light);
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Общий контейнер полосы прокрутки */
|
/* Общий контейнер полосы прокрутки */
|
||||||
.thin-dark-scrollbar::-webkit-scrollbar {
|
.thin-dark-scrollbar::-webkit-scrollbar {
|
||||||
width: 4px; /* ширина вертикального */
|
width: 4px; /* ширина вертикального */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Трек (фон) */
|
/* Трек (фон) */
|
||||||
.thin-dark-scrollbar::-webkit-scrollbar-track {
|
.thin-dark-scrollbar::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ползунок (thumb) */
|
/* Ползунок (thumb) */
|
||||||
.thin-dark-scrollbar::-webkit-scrollbar-thumb {
|
.thin-dark-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: var(--color-liquid-lighter);
|
background: var(--color-liquid-lighter);
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
html::-webkit-scrollbar {
|
html::-webkit-scrollbar {
|
||||||
width: 8px; /* ширина вертикального */
|
width: 8px; /* ширина вертикального */
|
||||||
}
|
}
|
||||||
/* Трек (фон) */
|
/* Трек (фон) */
|
||||||
html::-webkit-scrollbar-track {
|
html::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
/* Ползунок (thumb) */
|
/* Ползунок (thumb) */
|
||||||
html::-webkit-scrollbar-thumb {
|
html::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--color-liquid-lighter);
|
background-color: var(--color-liquid-lighter);
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
|
|
||||||
.latex-container p {
|
.latex-container p {
|
||||||
text-align: justify; /* выравнивание по ширине */
|
text-align: justify; /* выравнивание по ширине */
|
||||||
text-justify: inter-word;
|
text-justify: inter-word;
|
||||||
margin-bottom: 0.8em; /* небольшой отступ между абзацами */
|
margin-bottom: 0.8em; /* небольшой отступ между абзацами */
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
/* text-indent: 1em; */
|
/* text-indent: 1em; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.latex-container ol {
|
.latex-container ol {
|
||||||
padding-left: 1.5em; /* отступ для нумерации */
|
padding-left: 1.5em; /* отступ для нумерации */
|
||||||
margin: 0.5em 0; /* небольшой отступ сверху и снизу */
|
margin: 0.5em 0; /* небольшой отступ сверху и снизу */
|
||||||
line-height: 1.5; /* удобный межстрочный интервал */
|
line-height: 1.5; /* удобный межстрочный интервал */
|
||||||
font-family: "Inter", sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.latex-container ol li {
|
.latex-container ol li {
|
||||||
margin-bottom: 0.4em; /* расстояние между пунктами */
|
margin-bottom: 0.4em; /* расстояние между пунктами */
|
||||||
}
|
}
|
||||||
|
|
||||||
.latex-container .section-title{
|
.latex-container .section-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
@import 'tailwindcss/base';
|
@import 'tailwindcss/base';
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root[data-theme~="dark"] {
|
:root[data-theme~='dark'] {
|
||||||
--color-liquid-brightmain: #00DBD9;
|
--color-liquid-brightmain: #00dbd9;
|
||||||
--color-liquid-darkmain: #075867;
|
--color-liquid-darkmain: #075867;
|
||||||
--color-liquid-darker: #141515;
|
--color-liquid-darker: #141515;
|
||||||
--color-liquid-background: #202222;
|
--color-liquid-background: #202222;
|
||||||
--color-liquid-lighter: #2A2E2F;
|
--color-liquid-lighter: #2a2e2f;
|
||||||
--color-liquid-white: #EDF6F7;
|
--color-liquid-white: #edf6f7;
|
||||||
--color-liquid-red: #F13E5F;
|
--color-liquid-red: #f13e5f;
|
||||||
--color-liquid-green: #10BE59;
|
--color-liquid-green: #10be59;
|
||||||
--color-liquid-light: #576466;
|
--color-liquid-light: #576466;
|
||||||
--color-liquid-orange: #FF951B;
|
--color-liquid-orange: #ff951b;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
@import 'tailwindcss/base';
|
@import 'tailwindcss/base';
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--color-liquid-brightmain: #00DBD9;
|
--color-liquid-brightmain: #00dbd9;
|
||||||
--color-liquid-darkmain: #075867;
|
--color-liquid-darkmain: #075867;
|
||||||
--color-liquid-darker: #141515;
|
--color-liquid-darker: #141515;
|
||||||
--color-liquid-background: #202222;
|
--color-liquid-background: #202222;
|
||||||
--color-liquid-lighter: #2A2E2F;
|
--color-liquid-lighter: #2a2e2f;
|
||||||
--color-liquid-white: #EDF6F7;
|
--color-liquid-white: #edf6f7;
|
||||||
--color-liquid-red: #F13E5F;
|
--color-liquid-red: #f13e5f;
|
||||||
--color-liquid-green: #10BE59;
|
--color-liquid-green: #10be59;
|
||||||
--color-liquid-light: #576466;
|
--color-liquid-light: #576466;
|
||||||
--color-liquid-orange: #FF951B;
|
--color-liquid-orange: #ff951b;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from 'react';
|
||||||
import axios from "../../axios";
|
import axios from '../../axios';
|
||||||
import "highlight.js/styles/github-dark.css";
|
import 'highlight.js/styles/github-dark.css';
|
||||||
|
|
||||||
import MarkdownPreview from "./MarckDownPreview";
|
|
||||||
|
|
||||||
|
import MarkdownPreview from './MarckDownPreview';
|
||||||
|
|
||||||
interface MarkdownEditorProps {
|
interface MarkdownEditorProps {
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkdownEditor: FC<MarkdownEditorProps> = ({ defaultValue, onChange }) => {
|
const MarkdownEditor: FC<MarkdownEditorProps> = ({
|
||||||
const [markdown, setMarkdown] = useState<string>(defaultValue || `# 🌙 Добро пожаловать в Markdown-редактор
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const [markdown, setMarkdown] = useState<string>(
|
||||||
|
defaultValue ||
|
||||||
|
`# 🌙 Добро пожаловать в Markdown-редактор
|
||||||
|
|
||||||
Добро пожаловать в **Markdown-редактор**!
|
Добро пожаловать в **Markdown-редактор**!
|
||||||
Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇
|
Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇
|
||||||
@@ -205,34 +209,42 @@ print(greet("Мир"))
|
|||||||
|
|
||||||
**🖤 Конец демонстрации. Спасибо, что используешь Markdown-редактор!**
|
**🖤 Конец демонстрации. Спасибо, что используешь Markdown-редактор!**
|
||||||
|
|
||||||
`);
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChange(markdown);
|
onChange(markdown);
|
||||||
}, [markdown]);
|
}, [markdown]);
|
||||||
|
|
||||||
// Обработчик вставки
|
// Обработчик вставки
|
||||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
const handlePaste = async (
|
||||||
|
e: React.ClipboardEvent<HTMLTextAreaElement>,
|
||||||
|
) => {
|
||||||
const items = e.clipboardData.items;
|
const items = e.clipboardData.items;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.type.startsWith("image/")) {
|
if (item.type.startsWith('image/')) {
|
||||||
e.preventDefault(); // предотвращаем вставку картинки как текста
|
e.preventDefault(); // предотвращаем вставку картинки как текста
|
||||||
|
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post("/media/upload", formData, {
|
const response = await axios.post(
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
'/media/upload',
|
||||||
});
|
formData,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const imageUrl = response.data.url;
|
const imageUrl = response.data.url;
|
||||||
// Вставляем ссылку на картинку в текст
|
// Вставляем ссылку на картинку в текст
|
||||||
const cursorPos = (e.target as HTMLTextAreaElement).selectionStart;
|
const cursorPos = (e.target as HTMLTextAreaElement)
|
||||||
|
.selectionStart;
|
||||||
const newText =
|
const newText =
|
||||||
markdown.slice(0, cursorPos) +
|
markdown.slice(0, cursorPos) +
|
||||||
`<img src=\"${imageUrl}\" alt=\"img\"/>` +
|
`<img src=\"${imageUrl}\" alt=\"img\"/>` +
|
||||||
@@ -240,7 +252,7 @@ print(greet("Мир"))
|
|||||||
|
|
||||||
setMarkdown(newText);
|
setMarkdown(newText);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Ошибка загрузки изображения:", err);
|
console.error('Ошибка загрузки изображения:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,15 +263,22 @@ print(greet("Мир"))
|
|||||||
{/* Предпросмотр */}
|
{/* Предпросмотр */}
|
||||||
<div className="overflow-y-auto min-h-0 overflow-hidden">
|
<div className="overflow-y-auto min-h-0 overflow-hidden">
|
||||||
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
|
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
|
||||||
<h2 className="text-lg font-semibold mb-3 text-gray-100">👀 Предпросмотр</h2>
|
<h2 className="text-lg font-semibold mb-3 text-gray-100">
|
||||||
<MarkdownPreview content={markdown} className="h-[calc(100%-40px)]"/>
|
👀 Предпросмотр
|
||||||
|
</h2>
|
||||||
|
<MarkdownPreview
|
||||||
|
content={markdown}
|
||||||
|
className="h-[calc(100%-40px)]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Редактор */}
|
{/* Редактор */}
|
||||||
<div className="overflow-y-auto min-h-0 overflow-hidden">
|
<div className="overflow-y-auto min-h-0 overflow-hidden">
|
||||||
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
|
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
|
||||||
<h2 className="text-lg font-semibold mb-3 text-gray-100">📝 Редактор</h2>
|
<h2 className="text-lg font-semibold mb-3 text-gray-100">
|
||||||
|
📝 Редактор
|
||||||
|
</h2>
|
||||||
<textarea
|
<textarea
|
||||||
value={markdown}
|
value={markdown}
|
||||||
onChange={(e) => setMarkdown(e.target.value)}
|
onChange={(e) => setMarkdown(e.target.value)}
|
||||||
|
|||||||
@@ -1,29 +1,39 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { arrowLeft } from "../../assets/icons/header";
|
import { arrowLeft } from '../../assets/icons/header';
|
||||||
import { Logo } from "../../assets/logos";
|
import { Logo } from '../../assets/logos';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
backUrl?: string;
|
backUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Header: React.FC<HeaderProps> = ({ backUrl = '/home/articles' }) => {
|
||||||
const Header: React.FC<HeaderProps> = ({
|
|
||||||
backUrl="/home/articles",
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
|
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
|
||||||
<img src={Logo} alt="Logo" className="h-[28px] w-auto cursor-pointer" onClick={() => { navigate("/home") }} />
|
<img
|
||||||
|
src={Logo}
|
||||||
|
alt="Logo"
|
||||||
|
className="h-[28px] w-auto cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/home');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<img src={arrowLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(backUrl) }} />
|
<img
|
||||||
|
src={arrowLeft}
|
||||||
|
alt="back"
|
||||||
|
className="h-[24px] w-[24px] cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(backUrl);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* <div className="flex gap-[10px]">
|
{/* <div className="flex gap-[10px]">
|
||||||
<img src={chevroneLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId - 1}`) }} />
|
<img src={chevroneLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId - 1}`) }} />
|
||||||
<span>{missionId}</span>
|
<span>{missionId}</span>
|
||||||
<img src={chevroneRight} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId + 1}`) }} />
|
<img src={chevroneRight} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId + 1}`) }} />
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { FC } from "react";
|
import { FC } from 'react';
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeHighlight from "rehype-highlight";
|
import rehypeHighlight from 'rehype-highlight';
|
||||||
import rehypeRaw from "rehype-raw";
|
import rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSanitize from "rehype-sanitize";
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
import "highlight.js/styles/github-dark.css";
|
import 'highlight.js/styles/github-dark.css';
|
||||||
|
|
||||||
import { defaultSchema } from "hast-util-sanitize";
|
import { defaultSchema } from 'hast-util-sanitize';
|
||||||
import { cn } from "../../lib/cn";
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
const schema = {
|
const schema = {
|
||||||
...defaultSchema,
|
...defaultSchema,
|
||||||
@@ -15,9 +15,9 @@ const schema = {
|
|||||||
...defaultSchema.attributes,
|
...defaultSchema.attributes,
|
||||||
div: [
|
div: [
|
||||||
...(defaultSchema.attributes?.div || []),
|
...(defaultSchema.attributes?.div || []),
|
||||||
["style"] // разрешаем атрибут style на div
|
['style'], // разрешаем атрибут style на div
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MarkdownPreviewProps {
|
interface MarkdownPreviewProps {
|
||||||
@@ -25,13 +25,25 @@ interface MarkdownPreviewProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkdownPreview: FC<MarkdownPreviewProps> = ({ content, className="" }) => {
|
const MarkdownPreview: FC<MarkdownPreviewProps> = ({
|
||||||
|
content,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex-1 bg-[#161b22] rounded-lg shadow-lg p-6", className)}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-1 bg-[#161b22] rounded-lg shadow-lg p-6',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar">
|
<div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, schema], rehypeHighlight]}
|
rehypePlugins={[
|
||||||
|
rehypeRaw,
|
||||||
|
[rehypeSanitize, schema],
|
||||||
|
rehypeHighlight,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
|
|
||||||
export interface ArticleItemProps {
|
export interface ArticleItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -6,17 +6,17 @@ export interface ArticleItemProps {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ArticleItem: React.FC<ArticleItemProps> = ({
|
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
|
||||||
id, name, tags
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full relative rounded-[10px] text-liquid-white mb-[20px]",
|
<div
|
||||||
// type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
|
className={cn(
|
||||||
"gap-[20px] px-[20px] py-[10px] box-border ",
|
'w-full relative rounded-[10px] text-liquid-white mb-[20px]',
|
||||||
"border-b-[1px] border-b-liquid-lighter",
|
// type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
|
||||||
)}>
|
'gap-[20px] px-[20px] py-[10px] box-border ',
|
||||||
|
'border-b-[1px] border-b-liquid-lighter',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="h-[23px] flex ">
|
<div className="h-[23px] flex ">
|
||||||
|
|
||||||
<div className="text-[18px] font-bold w-[60px] mr-[20px] flex items-center">
|
<div className="text-[18px] font-bold w-[60px] mr-[20px] flex items-center">
|
||||||
#{id}
|
#{id}
|
||||||
</div>
|
</div>
|
||||||
@@ -25,15 +25,18 @@ const ArticleItem: React.FC<ArticleItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
||||||
{tags.map((v, i) =>
|
{tags.map((v, i) => (
|
||||||
<div key={i} className={cn(
|
<div
|
||||||
"rounded-full px-[16px] py-[8px] bg-liquid-lighter",
|
key={i}
|
||||||
v == "Sertificated" && "text-liquid-green")}>
|
className={cn(
|
||||||
|
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
|
||||||
|
v == 'Sertificated' && 'text-liquid-green',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{v}
|
{v}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { useAppDispatch } from "../../../redux/hooks";
|
import { useAppDispatch } from '../../../redux/hooks';
|
||||||
import ArticleItem from "./ArticleItem";
|
import ArticleItem from './ArticleItem';
|
||||||
import { setMenuActivePage } from "../../../redux/slices/store";
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
|
||||||
export interface Article {
|
export interface Article {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -12,159 +11,152 @@ export interface Article {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const Articles = () => {
|
const Articles = () => {
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const articles: Article[] = [
|
const articles: Article[] = [
|
||||||
{
|
{
|
||||||
"id": 1,
|
id: 1,
|
||||||
"name": "Todo List App",
|
name: 'Todo List App',
|
||||||
"tags": ["Sertificated", "state", "list"],
|
tags: ['Sertificated', 'state', 'list'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
id: 2,
|
||||||
"name": "Search Filter Component",
|
name: 'Search Filter Component',
|
||||||
"tags": ["filter", "props", "hooks"],
|
tags: ['filter', 'props', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
id: 3,
|
||||||
"name": "User Card List",
|
name: 'User Card List',
|
||||||
"tags": ["components", "props", "array"],
|
tags: ['components', 'props', 'array'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
id: 4,
|
||||||
"name": "Theme Switcher",
|
name: 'Theme Switcher',
|
||||||
"tags": ["Sertificated", "theme", "hooks"],
|
tags: ['Sertificated', 'theme', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
id: 2,
|
||||||
"name": "Search Filter Component",
|
name: 'Search Filter Component',
|
||||||
"tags": ["filter", "props", "hooks"],
|
tags: ['filter', 'props', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
id: 3,
|
||||||
"name": "User Card List",
|
name: 'User Card List',
|
||||||
"tags": ["components", "props", "array"],
|
tags: ['components', 'props', 'array'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
id: 4,
|
||||||
"name": "Theme Switcher",
|
name: 'Theme Switcher',
|
||||||
"tags": ["Sertificated", "theme", "hooks"],
|
tags: ['Sertificated', 'theme', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
id: 2,
|
||||||
"name": "Search Filter Component",
|
name: 'Search Filter Component',
|
||||||
"tags": ["filter", "props", "hooks"],
|
tags: ['filter', 'props', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
id: 3,
|
||||||
"name": "User Card List",
|
name: 'User Card List',
|
||||||
"tags": ["components", "props", "array"],
|
tags: ['components', 'props', 'array'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
id: 4,
|
||||||
"name": "Theme Switcher",
|
name: 'Theme Switcher',
|
||||||
"tags": ["Sertificated", "theme", "hooks"],
|
tags: ['Sertificated', 'theme', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
id: 2,
|
||||||
"name": "Search Filter Component",
|
name: 'Search Filter Component',
|
||||||
"tags": ["filter", "props", "hooks"],
|
tags: ['filter', 'props', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
id: 3,
|
||||||
"name": "User Card List",
|
name: 'User Card List',
|
||||||
"tags": ["components", "props", "array"],
|
tags: ['components', 'props', 'array'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
id: 4,
|
||||||
"name": "Theme Switcher",
|
name: 'Theme Switcher',
|
||||||
"tags": ["Sertificated", "theme", "hooks"],
|
tags: ['Sertificated', 'theme', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
id: 2,
|
||||||
"name": "Search Filter Component",
|
name: 'Search Filter Component',
|
||||||
"tags": ["filter", "props", "hooks"],
|
tags: ['filter', 'props', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
id: 3,
|
||||||
"name": "User Card List",
|
name: 'User Card List',
|
||||||
"tags": ["components", "props", "array"],
|
tags: ['components', 'props', 'array'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
id: 4,
|
||||||
"name": "Theme Switcher",
|
name: 'Theme Switcher',
|
||||||
"tags": ["Sertificated", "theme", "hooks"],
|
tags: ['Sertificated', 'theme', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
id: 2,
|
||||||
"name": "Search Filter Component",
|
name: 'Search Filter Component',
|
||||||
"tags": ["filter", "props", "hooks"],
|
tags: ['filter', 'props', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
id: 3,
|
||||||
"name": "User Card List",
|
name: 'User Card List',
|
||||||
"tags": ["components", "props", "array"],
|
tags: ['components', 'props', 'array'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
id: 4,
|
||||||
"name": "Theme Switcher",
|
name: 'Theme Switcher',
|
||||||
"tags": ["Sertificated", "theme", "hooks"],
|
tags: ['Sertificated', 'theme', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
id: 2,
|
||||||
"name": "Search Filter Component",
|
name: 'Search Filter Component',
|
||||||
"tags": ["filter", "props", "hooks"],
|
tags: ['filter', 'props', 'hooks'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
id: 3,
|
||||||
"name": "User Card List",
|
name: 'User Card List',
|
||||||
"tags": ["components", "props", "array"],
|
tags: ['components', 'props', 'array'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
id: 4,
|
||||||
"name": "Theme Switcher",
|
name: 'Theme Switcher',
|
||||||
"tags": ["Sertificated", "theme", "hooks"],
|
tags: ['Sertificated', 'theme', 'hooks'],
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage("articles"))
|
dispatch(setMenuActivePage('articles'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
||||||
<div className="h-full box-border">
|
<div className="h-full box-border">
|
||||||
|
|
||||||
<div className="relative flex items-center mb-[20px]">
|
<div className="relative flex items-center mb-[20px]">
|
||||||
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
|
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
|
||||||
Статьи
|
Статьи
|
||||||
</div>
|
</div>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
onClick={() => {navigate("/article/create")}}
|
onClick={() => {
|
||||||
|
navigate('/article/create');
|
||||||
|
}}
|
||||||
text="Создать статью"
|
text="Создать статью"
|
||||||
className="absolute right-0"
|
className="absolute right-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-liquid-lighter h-[50px] mb-[20px]">
|
<div className="bg-liquid-lighter h-[50px] mb-[20px]"></div>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
{articles.map((v, i) => (
|
{articles.map((v, i) => (
|
||||||
<ArticleItem key={i} {...v} />
|
<ArticleItem key={i} {...v} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>pages</div>
|
||||||
<div>
|
|
||||||
pages
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,113 +1,133 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from 'react';
|
||||||
import { PrimaryButton } from "../../../components/button/PrimaryButton";
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { Input } from "../../../components/input/Input";
|
import { Input } from '../../../components/input/Input';
|
||||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { loginUser } from "../../../redux/slices/auth";
|
import { loginUser } from '../../../redux/slices/auth';
|
||||||
// import { cn } from "../../../lib/cn";
|
// import { cn } from "../../../lib/cn";
|
||||||
import { setMenuActivePage } from "../../../redux/slices/store";
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { Balloon } from "../../../assets/icons/auth";
|
import { Balloon } from '../../../assets/icons/auth';
|
||||||
import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { googleLogo } from "../../../assets/icons/input";
|
import { googleLogo } from '../../../assets/icons/input';
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [username, setUsername] = useState<string>("");
|
const [username, setUsername] = useState<string>('');
|
||||||
const [password, setPassword] = useState<string>("");
|
const [password, setPassword] = useState<string>('');
|
||||||
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
|
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
|
||||||
|
|
||||||
const { status, jwt } = useAppSelector((state) => state.auth);
|
const { status, jwt } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
|
// const [err, setErr] = useState<string>("");
|
||||||
|
|
||||||
// const [err, setErr] = useState<string>("");
|
// После успешного логина
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(setMenuActivePage('account'));
|
||||||
|
console.log(submitClicked);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// После успешного логина
|
useEffect(() => {
|
||||||
useEffect(() => {
|
if (jwt) {
|
||||||
dispatch(setMenuActivePage("account"))
|
navigate('/home/offices'); // или другая страница после входа
|
||||||
console.log(submitClicked);
|
}
|
||||||
}, []);
|
}, [jwt]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleLogin = () => {
|
||||||
if (jwt) {
|
// setErr(err == "" ? "Неверная почта и/или пароль" : "");
|
||||||
navigate("/home/offices"); // или другая страница после входа
|
setSubmitClicked(true);
|
||||||
}
|
|
||||||
}, [jwt]);
|
|
||||||
|
|
||||||
const handleLogin = () => {
|
if (!username || !password) return;
|
||||||
// setErr(err == "" ? "Неверная почта и/или пароль" : "");
|
|
||||||
setSubmitClicked(true);
|
|
||||||
|
|
||||||
if (!username || !password) return;
|
dispatch(loginUser({ username, password }));
|
||||||
|
};
|
||||||
|
|
||||||
dispatch(loginUser({ username, password }));
|
return (
|
||||||
};
|
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
||||||
|
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
||||||
|
<div className="flex items-center justify-center ">
|
||||||
|
<img src={Balloon} />
|
||||||
|
</div>
|
||||||
|
<div className=" relative pointer-events-auto">
|
||||||
|
<div>
|
||||||
|
<div className="text-[40px] text-liquid-white font-bold h-[50px]">
|
||||||
|
С возвращением
|
||||||
|
</div>
|
||||||
|
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
||||||
|
Вход в аккаунт
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<Input
|
||||||
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
name="login"
|
||||||
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
autocomplete="login"
|
||||||
<div className="flex items-center justify-center ">
|
className="mt-[10px]"
|
||||||
<img src={Balloon} />
|
type="text"
|
||||||
</div>
|
label="Логин"
|
||||||
<div className=" relative pointer-events-auto">
|
onChange={(v) => {
|
||||||
<div>
|
setUsername(v);
|
||||||
<div className="text-[40px] text-liquid-white font-bold h-[50px]">
|
}}
|
||||||
С возвращением
|
placeholder="login"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="password"
|
||||||
|
autocomplete="password"
|
||||||
|
className="mt-[10px]"
|
||||||
|
type="password"
|
||||||
|
label="Пароль"
|
||||||
|
onChange={(v) => {
|
||||||
|
setPassword(v);
|
||||||
|
}}
|
||||||
|
placeholder="abCD1234"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-[10px]">
|
||||||
|
<Link
|
||||||
|
to={''}
|
||||||
|
className={
|
||||||
|
'text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline '
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Забыли пароль?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-[10px]">
|
||||||
|
<PrimaryButton
|
||||||
|
className="w-full mb-[8px]"
|
||||||
|
onClick={handleLogin}
|
||||||
|
text={status === 'loading' ? 'Вход...' : 'Вход'}
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
/>
|
||||||
|
<SecondaryButton className="w-full" onClick={() => {}}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src={googleLogo}
|
||||||
|
className="h-[24px] w-[24px] mr-[15px]"
|
||||||
|
/>
|
||||||
|
Вход с Google
|
||||||
|
</div>
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center mt-[10px]">
|
||||||
|
<span>
|
||||||
|
Нет аккаунта?{' '}
|
||||||
|
<Link
|
||||||
|
to={'/home/register'}
|
||||||
|
className={
|
||||||
|
'text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline '
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Регистрация
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
|
||||||
Вход в аккаунт
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<Input name="login" autocomplete="login" className="mt-[10px]" type="text" label="Логин" onChange={(v) => { setUsername(v) }} placeholder="login" />
|
|
||||||
<Input name="password" autocomplete="password" className="mt-[10px]" type="password" label="Пароль" onChange={(v) => { setPassword(v) }} placeholder="abCD1234" />
|
|
||||||
|
|
||||||
<div className="flex justify-end mt-[10px]">
|
|
||||||
<Link
|
|
||||||
to={""}
|
|
||||||
className={"text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline "}>
|
|
||||||
Забыли пароль?
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="mt-[10px]">
|
|
||||||
<PrimaryButton
|
|
||||||
className="w-full mb-[8px]"
|
|
||||||
onClick={handleLogin}
|
|
||||||
text={status === "loading" ? "Вход..." : "Вход"}
|
|
||||||
disabled={status === "loading"}
|
|
||||||
/>
|
|
||||||
<SecondaryButton
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => { }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<img src={googleLogo} className="h-[24px] w-[24px] mr-[15px]" />
|
|
||||||
Вход с Google
|
|
||||||
</div>
|
|
||||||
</SecondaryButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex justify-center mt-[10px]">
|
|
||||||
<span>
|
|
||||||
Нет аккаунта? <Link
|
|
||||||
to={"/home/register"}
|
|
||||||
className={"text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline "}>
|
|
||||||
Регистрация
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Login;
|
export default Login;
|
||||||
|
|||||||
@@ -1,126 +1,169 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from 'react';
|
||||||
import { PrimaryButton } from "../../../components/button/PrimaryButton";
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { Input } from "../../../components/input/Input";
|
import { Input } from '../../../components/input/Input';
|
||||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { registerUser } from "../../../redux/slices/auth";
|
import { registerUser } from '../../../redux/slices/auth';
|
||||||
// import { cn } from "../../../lib/cn";
|
// import { cn } from "../../../lib/cn";
|
||||||
import { setMenuActivePage } from "../../../redux/slices/store";
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { Balloon } from "../../../assets/icons/auth";
|
import { Balloon } from '../../../assets/icons/auth';
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from 'react-router-dom';
|
||||||
import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { Checkbox } from "../../../components/checkbox/Checkbox";
|
import { Checkbox } from '../../../components/checkbox/Checkbox';
|
||||||
import { googleLogo } from "../../../assets/icons/input";
|
import { googleLogo } from '../../../assets/icons/input';
|
||||||
|
|
||||||
|
|
||||||
const Register = () => {
|
const Register = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [username, setUsername] = useState<string>("");
|
const [username, setUsername] = useState<string>('');
|
||||||
const [email, setEmail] = useState<string>("");
|
const [email, setEmail] = useState<string>('');
|
||||||
const [password, setPassword] = useState<string>("");
|
const [password, setPassword] = useState<string>('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState<string>("");
|
const [confirmPassword, setConfirmPassword] = useState<string>('');
|
||||||
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
|
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
|
||||||
|
|
||||||
const { status, jwt } = useAppSelector((state) => state.auth);
|
const { status, jwt } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
// После успешной регистрации — переход в систему
|
// После успешной регистрации — переход в систему
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage("account"))
|
dispatch(setMenuActivePage('account'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
navigate("/home");
|
navigate('/home');
|
||||||
}
|
}
|
||||||
console.log(submitClicked);
|
console.log(submitClicked);
|
||||||
}, [jwt]);
|
}, [jwt]);
|
||||||
|
|
||||||
const handleRegister = () => {
|
const handleRegister = () => {
|
||||||
setSubmitClicked(true);
|
setSubmitClicked(true);
|
||||||
|
|
||||||
if (!username || !email || !password || !confirmPassword) return;
|
if (!username || !email || !password || !confirmPassword) return;
|
||||||
if (password !== confirmPassword) return;
|
if (password !== confirmPassword) return;
|
||||||
|
|
||||||
dispatch(registerUser({ username, email, password }));
|
dispatch(registerUser({ username, email, password }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
<div className="h-svh w-svw fixed pointer-events-none top-0 left-0 flex items-center justify-center">
|
||||||
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
<div className="grid gap-[80px] grid-cols-[400px,384px] box-border relative ">
|
||||||
<div className="flex items-center justify-center ">
|
<div className="flex items-center justify-center ">
|
||||||
<img src={Balloon} />
|
<img src={Balloon} />
|
||||||
</div>
|
</div>
|
||||||
<div className=" relative pointer-events-auto">
|
<div className=" relative pointer-events-auto">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[40px] text-liquid-white font-bold h-[50px]">
|
<div className="text-[40px] text-liquid-white font-bold h-[50px]">
|
||||||
Добро пожаловать
|
Добро пожаловать
|
||||||
|
</div>
|
||||||
|
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
||||||
|
Регистрация
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
name="email"
|
||||||
|
autocomplete="email"
|
||||||
|
className="mt-[10px]"
|
||||||
|
type="email"
|
||||||
|
label="Почта"
|
||||||
|
onChange={(v) => {
|
||||||
|
setEmail(v);
|
||||||
|
}}
|
||||||
|
placeholder="example@gmail.com"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="login"
|
||||||
|
autocomplete="login"
|
||||||
|
className="mt-[10px]"
|
||||||
|
type="text"
|
||||||
|
label="Логин пользователя"
|
||||||
|
onChange={(v) => {
|
||||||
|
setUsername(v);
|
||||||
|
}}
|
||||||
|
placeholder="login"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="password"
|
||||||
|
autocomplete="password"
|
||||||
|
className="mt-[10px]"
|
||||||
|
type="password"
|
||||||
|
label="Пароль"
|
||||||
|
onChange={(v) => {
|
||||||
|
setPassword(v);
|
||||||
|
}}
|
||||||
|
placeholder="abCD1234"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="confirm-password"
|
||||||
|
autocomplete="confirm-password"
|
||||||
|
className="mt-[10px]"
|
||||||
|
type="password"
|
||||||
|
label="Повторите пароль"
|
||||||
|
onChange={(v) => {
|
||||||
|
setConfirmPassword(v);
|
||||||
|
}}
|
||||||
|
placeholder="abCD1234"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className=" flex items-center mt-[10px] h-[24px]">
|
||||||
|
<Checkbox
|
||||||
|
onChange={(value: boolean) => {
|
||||||
|
value;
|
||||||
|
}}
|
||||||
|
className="p-0 w-fit m-[2.75px]"
|
||||||
|
size="md"
|
||||||
|
color="secondary"
|
||||||
|
variant="default"
|
||||||
|
/>
|
||||||
|
<span className="text-[14px] font-medium text-liquid-light h-[18px] ml-[10px]">
|
||||||
|
Я принимаю{' '}
|
||||||
|
<Link to={'/home'} className={' underline'}>
|
||||||
|
политику конфиденциальности
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-[10px]">
|
||||||
|
<PrimaryButton
|
||||||
|
className="w-full mb-[8px]"
|
||||||
|
onClick={() => handleRegister()}
|
||||||
|
text={
|
||||||
|
status === 'loading'
|
||||||
|
? 'Регистрация...'
|
||||||
|
: 'Регистрация'
|
||||||
|
}
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
/>
|
||||||
|
<SecondaryButton className="w-full" onClick={() => {}}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src={googleLogo}
|
||||||
|
className="h-[24px] w-[24px] mr-[15px]"
|
||||||
|
/>
|
||||||
|
Регистрация с Google
|
||||||
|
</div>
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center mt-[10px]">
|
||||||
|
<span>
|
||||||
|
Уже есть аккаунт?{' '}
|
||||||
|
<Link
|
||||||
|
to={'/home/login'}
|
||||||
|
className={
|
||||||
|
'text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline '
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Авторизация
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] text-liquid-light font-bold h-[23px]">
|
|
||||||
Регистрация
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<Input name="email" autocomplete="email" className="mt-[10px]" type="email" label="Почта" onChange={(v) => {setEmail(v)}} placeholder="example@gmail.com" />
|
|
||||||
<Input name="login" autocomplete="login" className="mt-[10px]" type="text" label="Логин пользователя" onChange={(v) => {setUsername(v)}} placeholder="login" />
|
|
||||||
<Input name="password" autocomplete="password" className="mt-[10px]" type="password" label="Пароль" onChange={(v) => {setPassword(v)}} placeholder="abCD1234" />
|
|
||||||
<Input name="confirm-password" autocomplete="confirm-password" className="mt-[10px]" type="password" label="Повторите пароль" onChange={(v) => {setConfirmPassword(v)}} placeholder="abCD1234" />
|
|
||||||
|
|
||||||
<div className=" flex items-center mt-[10px] h-[24px]">
|
|
||||||
<Checkbox
|
|
||||||
onChange={(value: boolean) => { value; }}
|
|
||||||
className="p-0 w-fit m-[2.75px]"
|
|
||||||
size="md"
|
|
||||||
color="secondary"
|
|
||||||
variant="default" />
|
|
||||||
<span className="text-[14px] font-medium text-liquid-light h-[18px] ml-[10px]">
|
|
||||||
Я принимаю <Link
|
|
||||||
to={"/home"}
|
|
||||||
className={" underline"}
|
|
||||||
>
|
|
||||||
политику конфиденциальности
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-[10px]">
|
|
||||||
<PrimaryButton
|
|
||||||
className="w-full mb-[8px]"
|
|
||||||
onClick={() => handleRegister()}
|
|
||||||
text={status === "loading" ? "Регистрация..." : "Регистрация"}
|
|
||||||
disabled={status === "loading"}
|
|
||||||
/>
|
|
||||||
<SecondaryButton
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => { }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<img src={googleLogo} className="h-[24px] w-[24px] mr-[15px]" />
|
|
||||||
Регистрация с Google
|
|
||||||
</div>
|
|
||||||
</SecondaryButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex justify-center mt-[10px]">
|
|
||||||
<span>
|
|
||||||
Уже есть аккаунт? <Link
|
|
||||||
to={"/home/login"}
|
|
||||||
className={"text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline "}>
|
|
||||||
Авторизация
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Register;
|
export default Register;
|
||||||
|
|||||||
@@ -1,65 +1,74 @@
|
|||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
import { Account } from "../../../assets/icons/auth";
|
import { Account } from '../../../assets/icons/auth';
|
||||||
import { PrimaryButton } from "../../../components/button/PrimaryButton";
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { ReverseButton } from "../../../components/button/ReverseButton";
|
import { ReverseButton } from '../../../components/button/ReverseButton';
|
||||||
|
|
||||||
export interface ContestItemProps {
|
export interface ContestItemProps {
|
||||||
name: string;
|
name: string;
|
||||||
startAt: string;
|
startAt: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
members: number;
|
members: number;
|
||||||
statusRegister: "reg" | "nonreg";
|
statusRegister: 'reg' | 'nonreg';
|
||||||
type: "first" | "second";
|
type: 'first' | 'second';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
const day = date.getDate().toString().padStart(2, "0");
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
|
|
||||||
const hours = date.getHours().toString().padStart(2, "0");
|
const hours = date.getHours().toString().padStart(2, '0');
|
||||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
|
||||||
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatWaitTime(ms: number): string {
|
function formatWaitTime(ms: number): string {
|
||||||
const minutes = Math.floor(ms / 60000);
|
const minutes = Math.floor(ms / 60000);
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
if (days > 0) {
|
if (days > 0) {
|
||||||
const remainder = days % 10;
|
const remainder = days % 10;
|
||||||
let suffix = "дней";
|
let suffix = 'дней';
|
||||||
if (remainder === 1 && days !== 11) suffix = "день";
|
if (remainder === 1 && days !== 11) suffix = 'день';
|
||||||
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) suffix = "дня";
|
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
|
||||||
return `${days} ${suffix}`;
|
suffix = 'дня';
|
||||||
} else if (hours > 0) {
|
return `${days} ${suffix}`;
|
||||||
const mins = minutes % 60;
|
} else if (hours > 0) {
|
||||||
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
|
const mins = minutes % 60;
|
||||||
} else {
|
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
|
||||||
return `${minutes} мин`;
|
} else {
|
||||||
}
|
return `${minutes} мин`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContestItem: React.FC<ContestItemProps> = ({
|
const ContestItem: React.FC<ContestItemProps> = ({
|
||||||
name, startAt, duration, members, statusRegister, type
|
name,
|
||||||
|
startAt,
|
||||||
|
duration,
|
||||||
|
members,
|
||||||
|
statusRegister,
|
||||||
|
type,
|
||||||
}) => {
|
}) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const waitTime = new Date(startAt).getTime() - now.getTime();
|
const waitTime = new Date(startAt).getTime() - now.getTime();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px]",
|
<div
|
||||||
waitTime <= 0 ? "grid grid-cols-6" : "grid grid-cols-7",
|
className={cn(
|
||||||
"items-center font-bold text-liquid-white",
|
'w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px]',
|
||||||
type == "first" ? " bg-liquid-lighter" : " bg-liquid-background"
|
waitTime <= 0 ? 'grid grid-cols-6' : 'grid grid-cols-7',
|
||||||
)}>
|
'items-center font-bold text-liquid-white',
|
||||||
<div className="text-left font-bold text-[18px]">
|
type == 'first'
|
||||||
{name}
|
? ' bg-liquid-lighter'
|
||||||
</div>
|
: ' bg-liquid-background',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-left font-bold text-[18px]">{name}</div>
|
||||||
<div className="text-center text-liquid-brightmain font-normal ">
|
<div className="text-center text-liquid-brightmain font-normal ">
|
||||||
{/* {authors.map((v, i) => <p key={i}>{v}</p>)} */}
|
{/* {authors.map((v, i) => <p key={i}>{v}</p>)} */}
|
||||||
valavshonok
|
valavshonok
|
||||||
@@ -67,29 +76,29 @@ const ContestItem: React.FC<ContestItemProps> = ({
|
|||||||
<div className="text-center text-nowrap whitespace-pre-line">
|
<div className="text-center text-nowrap whitespace-pre-line">
|
||||||
{formatDate(startAt)}
|
{formatDate(startAt)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">{formatWaitTime(duration)}</div>
|
||||||
{formatWaitTime(duration)}
|
{waitTime > 0 && (
|
||||||
</div>
|
|
||||||
{
|
|
||||||
waitTime > 0 &&
|
|
||||||
<div className="text-center whitespace-pre-line ">
|
<div className="text-center whitespace-pre-line ">
|
||||||
|
{'До начала\n' + formatWaitTime(waitTime)}
|
||||||
{"До начала\n" + formatWaitTime(waitTime)}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
<div className="items-center justify-center flex gap-[10px] flex-row w-full">
|
<div className="items-center justify-center flex gap-[10px] flex-row w-full">
|
||||||
<div>{members}</div>
|
<div>{members}</div>
|
||||||
<img src={Account} className="h-[24px] w-[24px]"/>
|
<img src={Account} className="h-[24px] w-[24px]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
{
|
{statusRegister == 'reg' ? (
|
||||||
statusRegister == "reg" ?
|
<>
|
||||||
<> <PrimaryButton onClick={() => {}} text="Регистрация"/></>
|
{' '}
|
||||||
:
|
<PrimaryButton onClick={() => {}} text="Регистрация" />
|
||||||
<> <ReverseButton onClick={() => {}} text="Вы записаны"/></>
|
</>
|
||||||
}
|
) : (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<ReverseButton onClick={() => {}} text="Вы записаны" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import ContestsBlock from "./ContestsBlock";
|
import ContestsBlock from './ContestsBlock';
|
||||||
import { setMenuActivePage } from "../../../redux/slices/store";
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { fetchContests } from "../../../redux/slices/contests";
|
import { fetchContests } from '../../../redux/slices/contests';
|
||||||
|
|
||||||
const Contests = () => {
|
const Contests = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -17,12 +17,14 @@ const Contests = () => {
|
|||||||
|
|
||||||
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
|
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage("contests"));
|
dispatch(setMenuActivePage('contests'));
|
||||||
dispatch(fetchContests({}));
|
dispatch(fetchContests({}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading == "loading") {
|
if (loading == 'loading') {
|
||||||
return <div className="text-liquid-white p-4">Загрузка контестов...</div>;
|
return (
|
||||||
|
<div className="text-liquid-white p-4">Загрузка контестов...</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -33,7 +35,11 @@ const Contests = () => {
|
|||||||
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
|
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
|
||||||
<div className="h-full box-border">
|
<div className="h-full box-border">
|
||||||
<div className="relative flex items-center mb-[20px]">
|
<div className="relative flex items-center mb-[20px]">
|
||||||
<div className={cn("h-[50px] text-[40px] font-bold text-liquid-white flex items-center")}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-[50px] text-[40px] font-bold text-liquid-white flex items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
Контесты
|
Контесты
|
||||||
</div>
|
</div>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
@@ -49,8 +55,7 @@ const Contests = () => {
|
|||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Текущие"
|
title="Текущие"
|
||||||
contests={contests.filter((contest) => {
|
contests={contests.filter((contest) => {
|
||||||
const endTime =
|
const endTime = new Date(contest.endsAt).getTime();
|
||||||
new Date(contest.endsAt).getTime()
|
|
||||||
return endTime >= now.getTime();
|
return endTime >= now.getTime();
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
@@ -59,8 +64,7 @@ const Contests = () => {
|
|||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Прошедшие"
|
title="Прошедшие"
|
||||||
contests={contests.filter((contest) => {
|
contests={contests.filter((contest) => {
|
||||||
const endTime =
|
const endTime = new Date(contest.endsAt).getTime();
|
||||||
new Date(contest.endsAt).getTime()
|
|
||||||
return endTime < now.getTime();
|
return endTime < now.getTime();
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { useState, FC } from "react";
|
import { useState, FC } from 'react';
|
||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
import { ChevroneDown } from "../../../assets/icons/groups";
|
import { ChevroneDown } from '../../../assets/icons/groups';
|
||||||
import ContestItem from "./ContestItem";
|
import ContestItem from './ContestItem';
|
||||||
import { Contest } from "../../../redux/slices/contests";
|
import { Contest } from '../../../redux/slices/contests';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface ContestsBlockProps {
|
interface ContestsBlockProps {
|
||||||
contests: Contest[];
|
contests: Contest[];
|
||||||
@@ -13,46 +10,61 @@ interface ContestsBlockProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ContestsBlock: FC<ContestsBlockProps> = ({
|
||||||
const ContestsBlock: FC<ContestsBlockProps> = ({ contests, title, className }) => {
|
contests,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
const [active, setActive] = useState<boolean>(title != "Скрытые");
|
}) => {
|
||||||
|
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
<div className={cn(" border-b-[1px] border-b-liquid-lighter rounded-[10px]",
|
className={cn(
|
||||||
className
|
' border-b-[1px] border-b-liquid-lighter rounded-[10px]',
|
||||||
)}>
|
className,
|
||||||
<div className={cn(" h-[40px] text-[24px] font-bold flex gap-[10px] items-center cursor-pointer border-b-[1px] border-b-transparent transition-all duration-300",
|
|
||||||
active && "border-b-liquid-lighter"
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
' h-[40px] text-[24px] font-bold flex gap-[10px] items-center cursor-pointer border-b-[1px] border-b-transparent transition-all duration-300',
|
||||||
|
active && 'border-b-liquid-lighter',
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActive(!active)
|
setActive(!active);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
<img src={ChevroneDown} className={cn("transition-all duration-300",
|
<img
|
||||||
active && "rotate-180"
|
src={ChevroneDown}
|
||||||
)} />
|
className={cn(
|
||||||
|
'transition-all duration-300',
|
||||||
|
active && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(" grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300",
|
<div
|
||||||
active && "grid-rows-[1fr] opacity-100"
|
className={cn(
|
||||||
)}>
|
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
|
||||||
|
active && 'grid-rows-[1fr] opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="pb-[10px] pt-[20px]">
|
<div className="pb-[10px] pt-[20px]">
|
||||||
{
|
{contests.map((v, i) => (
|
||||||
contests.map((v, i) => <ContestItem
|
<ContestItem
|
||||||
key={i}
|
key={i}
|
||||||
name={v.name}
|
name={v.name}
|
||||||
startAt={v.startsAt}
|
startAt={v.startsAt}
|
||||||
statusRegister={"reg"}
|
statusRegister={'reg'}
|
||||||
duration={new Date(v.endsAt).getTime() - new Date(v.startsAt).getTime()}
|
duration={
|
||||||
members={v.members.length}
|
new Date(v.endsAt).getTime() -
|
||||||
type={i % 2 ? "second" : "first"} />)
|
new Date(v.startsAt).getTime()
|
||||||
}
|
}
|
||||||
|
members={v.members.length}
|
||||||
|
type={i % 2 ? 'second' : 'first'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import { FC } from "react";
|
import { FC } from 'react';
|
||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
import { useParams, Navigate } from "react-router-dom";
|
import { useParams, Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface GroupsBlockProps {}
|
interface GroupsBlockProps {}
|
||||||
|
|
||||||
const Group: FC<GroupsBlockProps> = () => {
|
const Group: FC<GroupsBlockProps> = () => {
|
||||||
const { groupId } = useParams<{ groupId: string }>();
|
const { groupId } = useParams<{ groupId: string }>();
|
||||||
const groupIdNumber = Number(groupId);
|
const groupIdNumber = Number(groupId);
|
||||||
|
|
||||||
if (!groupId || isNaN(groupIdNumber) || !groupIdNumber) {
|
if (!groupId || isNaN(groupIdNumber) || !groupIdNumber) {
|
||||||
return <Navigate to="/home/groups" replace />;
|
return <Navigate to="/home/groups" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b-[1px] border-b-liquid-lighter rounded-[10px]"
|
'border-b-[1px] border-b-liquid-lighter rounded-[10px]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{groupIdNumber}
|
{groupIdNumber}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Group;
|
export default Group;
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
import { Book, UserAdd, Edit, EyeClosed, EyeOpen } from "../../../assets/icons/groups";
|
import {
|
||||||
import { useNavigate } from "react-router-dom";
|
Book,
|
||||||
import { GroupUpdate } from "./Groups";
|
UserAdd,
|
||||||
|
Edit,
|
||||||
|
EyeClosed,
|
||||||
|
EyeOpen,
|
||||||
|
} from '../../../assets/icons/groups';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { GroupUpdate } from './Groups';
|
||||||
|
|
||||||
export interface GroupItemProps {
|
export interface GroupItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
role: "menager" | "member" | "owner" | "viewer";
|
role: 'menager' | 'member' | 'owner' | 'viewer';
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -13,61 +19,64 @@ export interface GroupItemProps {
|
|||||||
setUpdateGroup: (value: GroupUpdate) => void;
|
setUpdateGroup: (value: GroupUpdate) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface IconComponentProps {
|
interface IconComponentProps {
|
||||||
src: string;
|
src: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconComponent: React.FC<IconComponentProps> = ({
|
const IconComponent: React.FC<IconComponentProps> = ({ src, onClick }) => {
|
||||||
src,
|
return (
|
||||||
onClick
|
<img
|
||||||
}) => {
|
src={src}
|
||||||
|
onClick={(e) => {
|
||||||
return <img
|
e.stopPropagation();
|
||||||
src={src}
|
if (onClick) onClick();
|
||||||
onClick={(e) => {
|
}}
|
||||||
e.stopPropagation();
|
className="hover:bg-liquid-light rounded-[5px] cursor-pointer transition-all duration-300"
|
||||||
if (onClick)
|
/>
|
||||||
onClick();
|
);
|
||||||
}}
|
};
|
||||||
className="hover:bg-liquid-light rounded-[5px] cursor-pointer transition-all duration-300"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
const GroupItem: React.FC<GroupItemProps> = ({
|
const GroupItem: React.FC<GroupItemProps> = ({
|
||||||
id, name, visible, role, description, setUpdateGroup, setUpdateActive
|
id,
|
||||||
|
name,
|
||||||
|
visible,
|
||||||
|
role,
|
||||||
|
description,
|
||||||
|
setUpdateGroup,
|
||||||
|
setUpdateActive,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full h-[120px] box-border relative rounded-[10px] p-[10px] text-liquid-white bg-liquid-lighter cursor-pointer",
|
<div
|
||||||
)}
|
className={cn(
|
||||||
|
'w-full h-[120px] box-border relative rounded-[10px] p-[10px] text-liquid-white bg-liquid-lighter cursor-pointer',
|
||||||
|
)}
|
||||||
onClick={() => navigate(`/group/${id}`)}
|
onClick={() => navigate(`/group/${id}`)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[100px,1fr] gap-[20px]">
|
<div className="grid grid-cols-[100px,1fr] gap-[20px]">
|
||||||
<img src={Book} className="bg-liquid-brightmain rounded-[10px]"/>
|
<img
|
||||||
|
src={Book}
|
||||||
|
className="bg-liquid-brightmain rounded-[10px]"
|
||||||
|
/>
|
||||||
<div className="grid grid-flow-row grid-rows-[1fr,24px]">
|
<div className="grid grid-flow-row grid-rows-[1fr,24px]">
|
||||||
<div className="text-[18px] font-bold">
|
<div className="text-[18px] font-bold">{name}</div>
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
<div className=" flex gap-[10px]">
|
<div className=" flex gap-[10px]">
|
||||||
{
|
{(role == 'menager' || role == 'owner') && (
|
||||||
(role == "menager" || role == "owner") && <IconComponent src={UserAdd}/>
|
<IconComponent src={UserAdd} />
|
||||||
}
|
)}
|
||||||
{
|
{(role == 'menager' || role == 'owner') && (
|
||||||
(role == "menager" || role == "owner") && <IconComponent src={Edit} onClick={() => {
|
<IconComponent
|
||||||
|
src={Edit}
|
||||||
setUpdateGroup({id, name, description });
|
onClick={() => {
|
||||||
setUpdateActive(true);
|
setUpdateGroup({ id, name, description });
|
||||||
}} />
|
setUpdateActive(true);
|
||||||
}
|
}}
|
||||||
{
|
/>
|
||||||
visible == false && <IconComponent src={EyeOpen} />
|
)}
|
||||||
}
|
{visible == false && <IconComponent src={EyeOpen} />}
|
||||||
{
|
{visible == true && <IconComponent src={EyeClosed} />}
|
||||||
visible == true && <IconComponent src={EyeClosed} />
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import GroupsBlock from "./GroupsBlock";
|
import GroupsBlock from './GroupsBlock';
|
||||||
import { setMenuActivePage } from "../../../redux/slices/store";
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { fetchMyGroups } from "../../../redux/slices/groups";
|
import { fetchMyGroups } from '../../../redux/slices/groups';
|
||||||
import ModalCreate from "./ModalCreate";
|
import ModalCreate from './ModalCreate';
|
||||||
import ModalUpdate from "./ModalUpdate";
|
import ModalUpdate from './ModalUpdate';
|
||||||
|
|
||||||
export interface GroupUpdate {
|
export interface GroupUpdate {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -17,11 +17,14 @@ export interface GroupUpdate {
|
|||||||
const Groups = () => {
|
const Groups = () => {
|
||||||
const [modalActive, setModalActive] = useState<boolean>(false);
|
const [modalActive, setModalActive] = useState<boolean>(false);
|
||||||
const [modelUpdateActive, setModalUpdateActive] = useState<boolean>(false);
|
const [modelUpdateActive, setModalUpdateActive] = useState<boolean>(false);
|
||||||
const [updateGroup, setUpdateGroup] = useState<GroupUpdate>({ id: 0, name: "", description: "" });
|
const [updateGroup, setUpdateGroup] = useState<GroupUpdate>({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
// Берём группы из стора
|
// Берём группы из стора
|
||||||
const groups = useAppSelector((store) => store.groups.groups);
|
const groups = useAppSelector((store) => store.groups.groups);
|
||||||
|
|
||||||
@@ -29,8 +32,8 @@ const Groups = () => {
|
|||||||
const currentUserName = useAppSelector((store) => store.auth.username);
|
const currentUserName = useAppSelector((store) => store.auth.username);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage("groups"));
|
dispatch(setMenuActivePage('groups'));
|
||||||
dispatch(fetchMyGroups())
|
dispatch(fetchMyGroups());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
// Разделяем группы
|
// Разделяем группы
|
||||||
@@ -44,17 +47,23 @@ const Groups = () => {
|
|||||||
const hidden: typeof groups = []; // пока пустые, без логики
|
const hidden: typeof groups = []; // пока пустые, без логики
|
||||||
|
|
||||||
groups.forEach((group) => {
|
groups.forEach((group) => {
|
||||||
const me = group.members.find((m) => m.username === currentUserName);
|
const me = group.members.find(
|
||||||
|
(m) => m.username === currentUserName,
|
||||||
|
);
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
|
|
||||||
if (me.role === "Administrator") {
|
if (me.role === 'Administrator') {
|
||||||
managed.push(group);
|
managed.push(group);
|
||||||
} else {
|
} else {
|
||||||
current.push(group);
|
current.push(group);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { managedGroups: managed, currentGroups: current, hiddenGroups: hidden };
|
return {
|
||||||
|
managedGroups: managed,
|
||||||
|
currentGroups: current,
|
||||||
|
hiddenGroups: hidden,
|
||||||
|
};
|
||||||
}, [groups, currentUserName]);
|
}, [groups, currentUserName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,13 +72,15 @@ const Groups = () => {
|
|||||||
<div className="relative flex items-center mb-[20px]">
|
<div className="relative flex items-center mb-[20px]">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-[50px] text-[40px] font-bold text-liquid-white flex items-center"
|
'h-[50px] text-[40px] font-bold text-liquid-white flex items-center',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Группы
|
Группы
|
||||||
</div>
|
</div>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
onClick={() => { setModalActive(true); }}
|
onClick={() => {
|
||||||
|
setModalActive(true);
|
||||||
|
}}
|
||||||
text="Создать группу"
|
text="Создать группу"
|
||||||
className="absolute right-0"
|
className="absolute right-0"
|
||||||
/>
|
/>
|
||||||
@@ -83,7 +94,6 @@ const Groups = () => {
|
|||||||
groups={managedGroups}
|
groups={managedGroups}
|
||||||
setUpdateActive={setModalUpdateActive}
|
setUpdateActive={setModalUpdateActive}
|
||||||
setUpdateGroup={setUpdateGroup}
|
setUpdateGroup={setUpdateGroup}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
<GroupsBlock
|
<GroupsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
@@ -101,7 +111,6 @@ const Groups = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<ModalCreate setActive={setModalActive} active={modalActive} />
|
<ModalCreate setActive={setModalActive} active={modalActive} />
|
||||||
<ModalUpdate
|
<ModalUpdate
|
||||||
setActive={setModalUpdateActive}
|
setActive={setModalUpdateActive}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, FC } from "react";
|
import { useState, FC } from 'react';
|
||||||
import GroupItem from "./GroupItem";
|
import GroupItem from './GroupItem';
|
||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
import { ChevroneDown } from "../../../assets/icons/groups";
|
import { ChevroneDown } from '../../../assets/icons/groups';
|
||||||
import { Group } from "../../../redux/slices/groups";
|
import { Group } from '../../../redux/slices/groups';
|
||||||
import { GroupUpdate } from "./Groups";
|
import { GroupUpdate } from './Groups';
|
||||||
|
|
||||||
interface GroupsBlockProps {
|
interface GroupsBlockProps {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
@@ -13,46 +13,60 @@ interface GroupsBlockProps {
|
|||||||
setUpdateGroup: (value: GroupUpdate) => void;
|
setUpdateGroup: (value: GroupUpdate) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GroupsBlock: FC<GroupsBlockProps> = ({
|
||||||
const GroupsBlock: FC<GroupsBlockProps> = ({ groups, title, className, setUpdateActive, setUpdateGroup }) => {
|
groups,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
const [active, setActive] = useState<boolean>(title != "Скрытые");
|
setUpdateActive,
|
||||||
|
setUpdateGroup,
|
||||||
|
}) => {
|
||||||
|
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
<div className={cn(" border-b-[1px] border-b-liquid-lighter rounded-[10px]",
|
className={cn(
|
||||||
className
|
' border-b-[1px] border-b-liquid-lighter rounded-[10px]',
|
||||||
)}>
|
className,
|
||||||
<div className={cn(" h-[40px] text-[24px] font-bold flex gap-[10px] border-b-[1px] border-b-transparent items-center cursor-pointer transition-all duration-300",
|
|
||||||
active && " border-b-liquid-lighter"
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
' h-[40px] text-[24px] font-bold flex gap-[10px] border-b-[1px] border-b-transparent items-center cursor-pointer transition-all duration-300',
|
||||||
|
active && ' border-b-liquid-lighter',
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActive(!active)
|
setActive(!active);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
<img src={ChevroneDown} className={cn("transition-all duration-300",
|
<img
|
||||||
active && "rotate-180"
|
src={ChevroneDown}
|
||||||
)}/>
|
className={cn(
|
||||||
|
'transition-all duration-300',
|
||||||
|
active && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(" grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300",
|
<div
|
||||||
active && "grid-rows-[1fr] opacity-100"
|
className={cn(
|
||||||
)}>
|
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
|
||||||
|
active && 'grid-rows-[1fr] opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-[20px] pt-[20px] pb-[20px] box-border">
|
<div className="grid grid-cols-3 gap-[20px] pt-[20px] pb-[20px] box-border">
|
||||||
{
|
{groups.map((v, i) => (
|
||||||
groups.map((v, i) => <GroupItem
|
<GroupItem
|
||||||
key={i}
|
key={i}
|
||||||
id={v.id}
|
id={v.id}
|
||||||
visible={true}
|
visible={true}
|
||||||
description={v.description}
|
description={v.description}
|
||||||
setUpdateActive={setUpdateActive}
|
setUpdateActive={setUpdateActive}
|
||||||
setUpdateGroup={setUpdateGroup}
|
setUpdateGroup={setUpdateGroup}
|
||||||
role={"owner"}
|
role={'owner'}
|
||||||
name={v.name}/>)
|
name={v.name}
|
||||||
}
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { Modal } from "../../../components/modal/Modal";
|
import { Modal } from '../../../components/modal/Modal';
|
||||||
import { PrimaryButton } from "../../../components/button/PrimaryButton";
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { Input } from "../../../components/input/Input";
|
import { Input } from '../../../components/input/Input';
|
||||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { createGroup } from "../../../redux/slices/groups";
|
import { createGroup } from '../../../redux/slices/groups';
|
||||||
|
|
||||||
interface ModalCreateProps {
|
interface ModalCreateProps {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -12,27 +12,63 @@ interface ModalCreateProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
||||||
const [name, setName] = useState<string>("");
|
const [name, setName] = useState<string>('');
|
||||||
const [description, setDescription] = useState<string>("");
|
const [description, setDescription] = useState<string>('');
|
||||||
const status = useAppSelector((state) => state.groups.statuses.create);
|
const status = useAppSelector((state) => state.groups.statuses.create);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status == "successful") {
|
if (status == 'successful') {
|
||||||
setActive(false);
|
setActive(false);
|
||||||
}
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white" onOpenChange={setActive} open={active} backdrop="blur" >
|
<Modal
|
||||||
|
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
||||||
|
onOpenChange={setActive}
|
||||||
|
open={active}
|
||||||
|
backdrop="blur"
|
||||||
|
>
|
||||||
<div className="w-[500px]">
|
<div className="w-[500px]">
|
||||||
<div className="font-bold text-[30px]">Создать группу</div>
|
<div className="font-bold text-[30px]">Создать группу</div>
|
||||||
<Input name="name" autocomplete="name" className="mt-[10px]" type="text" label="Название" onChange={(v) => { setName(v) }} placeholder="login" />
|
<Input
|
||||||
<Input name="description" autocomplete="description" className="mt-[10px]" type="text" label="Описание" onChange={(v) => { setDescription(v) }} placeholder="login" />
|
name="name"
|
||||||
|
autocomplete="name"
|
||||||
|
className="mt-[10px]"
|
||||||
|
type="text"
|
||||||
|
label="Название"
|
||||||
|
onChange={(v) => {
|
||||||
|
setName(v);
|
||||||
|
}}
|
||||||
|
placeholder="login"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="description"
|
||||||
|
autocomplete="description"
|
||||||
|
className="mt-[10px]"
|
||||||
|
type="text"
|
||||||
|
label="Описание"
|
||||||
|
onChange={(v) => {
|
||||||
|
setDescription(v);
|
||||||
|
}}
|
||||||
|
placeholder="login"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
||||||
<PrimaryButton onClick={() => { dispatch(createGroup({ name, description })) }} text="Создать" disabled={status == "loading"} />
|
<PrimaryButton
|
||||||
<SecondaryButton onClick={() => { setActive(false); }} text="Отмена" />
|
onClick={() => {
|
||||||
|
dispatch(createGroup({ name, description }));
|
||||||
|
}}
|
||||||
|
text="Создать"
|
||||||
|
disabled={status == 'loading'}
|
||||||
|
/>
|
||||||
|
<SecondaryButton
|
||||||
|
onClick={() => {
|
||||||
|
setActive(false);
|
||||||
|
}}
|
||||||
|
text="Отмена"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -40,4 +76,3 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default ModalCreate;
|
export default ModalCreate;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { Modal } from "../../../components/modal/Modal";
|
import { Modal } from '../../../components/modal/Modal';
|
||||||
import { PrimaryButton } from "../../../components/button/PrimaryButton";
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { Input } from "../../../components/input/Input";
|
import { Input } from '../../../components/input/Input';
|
||||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { deleteGroup, updateGroup } from "../../../redux/slices/groups";
|
import { deleteGroup, updateGroup } from '../../../redux/slices/groups';
|
||||||
|
|
||||||
interface ModalUpdateProps {
|
interface ModalUpdateProps {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -14,36 +14,95 @@ interface ModalUpdateProps {
|
|||||||
groupDescription: string;
|
groupDescription: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModalUpdate: FC<ModalUpdateProps> = ({ active, setActive, groupName, groupId, groupDescription }) => {
|
const ModalUpdate: FC<ModalUpdateProps> = ({
|
||||||
const [name, setName] = useState<string>("");
|
active,
|
||||||
const [description, setDescription] = useState<string>("");
|
setActive,
|
||||||
const statusUpdate = useAppSelector((state) => state.groups.statuses.update);
|
groupName,
|
||||||
const statusDelete = useAppSelector((state) => state.groups.statuses.delete);
|
groupId,
|
||||||
|
groupDescription,
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState<string>('');
|
||||||
|
const [description, setDescription] = useState<string>('');
|
||||||
|
const statusUpdate = useAppSelector(
|
||||||
|
(state) => state.groups.statuses.update,
|
||||||
|
);
|
||||||
|
const statusDelete = useAppSelector(
|
||||||
|
(state) => state.groups.statuses.delete,
|
||||||
|
);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusUpdate == "successful"){
|
if (statusUpdate == 'successful') {
|
||||||
setActive(false);
|
setActive(false);
|
||||||
}
|
}
|
||||||
}, [statusUpdate]);
|
}, [statusUpdate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusDelete == "successful"){
|
if (statusDelete == 'successful') {
|
||||||
setActive(false);
|
setActive(false);
|
||||||
}
|
}
|
||||||
}, [statusDelete]);
|
}, [statusDelete]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white" onOpenChange={setActive} open={active} backdrop="blur" >
|
<Modal
|
||||||
|
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
||||||
|
onOpenChange={setActive}
|
||||||
|
open={active}
|
||||||
|
backdrop="blur"
|
||||||
|
>
|
||||||
<div className="w-[500px]">
|
<div className="w-[500px]">
|
||||||
<div className="font-bold text-[30px]">Изменить группу {groupName} #{groupId}</div>
|
<div className="font-bold text-[30px]">
|
||||||
<Input name="name" autocomplete="name" className="mt-[10px]" type="text" label="Новое название" defaultState={groupName} onChange={(v) => { setName(v)}} placeholder="login"/>
|
Изменить группу {groupName} #{groupId}
|
||||||
<Input name="description" autocomplete="description" className="mt-[10px]" type="text" label="Описание" onChange={(v) => { setDescription(v)}} placeholder="login" defaultState={groupDescription}/>
|
</div>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
autocomplete="name"
|
||||||
|
className="mt-[10px]"
|
||||||
|
type="text"
|
||||||
|
label="Новое название"
|
||||||
|
defaultState={groupName}
|
||||||
|
onChange={(v) => {
|
||||||
|
setName(v);
|
||||||
|
}}
|
||||||
|
placeholder="login"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="description"
|
||||||
|
autocomplete="description"
|
||||||
|
className="mt-[10px]"
|
||||||
|
type="text"
|
||||||
|
label="Описание"
|
||||||
|
onChange={(v) => {
|
||||||
|
setDescription(v);
|
||||||
|
}}
|
||||||
|
placeholder="login"
|
||||||
|
defaultState={groupDescription}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
||||||
<PrimaryButton onClick={() => {dispatch(deleteGroup(groupId))}} text="Удалить" disabled={statusDelete=="loading"} color="error"/>
|
<PrimaryButton
|
||||||
<PrimaryButton onClick={() => {dispatch(updateGroup({name, description, groupId}))}} text="Обновить" disabled={statusUpdate=="loading"}/>
|
onClick={() => {
|
||||||
<SecondaryButton onClick={() => {setActive(false);}} text="Отмена" />
|
dispatch(deleteGroup(groupId));
|
||||||
|
}}
|
||||||
|
text="Удалить"
|
||||||
|
disabled={statusDelete == 'loading'}
|
||||||
|
color="error"
|
||||||
|
/>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(
|
||||||
|
updateGroup({ name, description, groupId }),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
text="Обновить"
|
||||||
|
disabled={statusUpdate == 'loading'}
|
||||||
|
/>
|
||||||
|
<SecondaryButton
|
||||||
|
onClick={() => {
|
||||||
|
setActive(false);
|
||||||
|
}}
|
||||||
|
text="Отмена"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -51,4 +110,3 @@ const ModalUpdate: FC<ModalUpdateProps> = ({ active, setActive, groupName, group
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default ModalUpdate;
|
export default ModalUpdate;
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,63 @@
|
|||||||
import { Logo } from "../../../assets/logos";
|
import { Logo } from '../../../assets/logos';
|
||||||
import {Account, Clipboard, Cup, Home, Openbook, Users} from "../../../assets/icons/menu";
|
import {
|
||||||
import MenuItem from "./MenuItem";
|
Account,
|
||||||
import { useAppSelector } from "../../../redux/hooks";
|
Clipboard,
|
||||||
|
Cup,
|
||||||
|
Home,
|
||||||
|
Openbook,
|
||||||
|
Users,
|
||||||
|
} from '../../../assets/icons/menu';
|
||||||
|
import MenuItem from './MenuItem';
|
||||||
|
import { useAppSelector } from '../../../redux/hooks';
|
||||||
|
|
||||||
const Menu = () => {
|
const Menu = () => {
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{text: "Главная", href: "/home", icon: Home, page: "home" },
|
{ text: 'Главная', href: '/home', icon: Home, page: 'home' },
|
||||||
{text: "Задачи", href: "/home/missions", icon: Clipboard, page: "missions" },
|
{
|
||||||
{text: "Статьи", href: "/home/articles", icon: Openbook, page: "articles" },
|
text: 'Задачи',
|
||||||
{text: "Группы", href: "/home/groups", icon: Users, page: "groups" },
|
href: '/home/missions',
|
||||||
{text: "Контесты", href: "/home/contests", icon: Cup, page: "contests" },
|
icon: Clipboard,
|
||||||
{text: "Аккаунт", href: "/home/account", icon: Account, page: "account" },
|
page: 'missions',
|
||||||
];
|
},
|
||||||
const activePage = useAppSelector((state) => state.store.menu.activePage);
|
{
|
||||||
|
text: 'Статьи',
|
||||||
|
href: '/home/articles',
|
||||||
|
icon: Openbook,
|
||||||
|
page: 'articles',
|
||||||
|
},
|
||||||
|
{ text: 'Группы', href: '/home/groups', icon: Users, page: 'groups' },
|
||||||
|
{
|
||||||
|
text: 'Контесты',
|
||||||
|
href: '/home/contests',
|
||||||
|
icon: Cup,
|
||||||
|
page: 'contests',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Аккаунт',
|
||||||
|
href: '/home/account',
|
||||||
|
icon: Account,
|
||||||
|
page: 'account',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const activePage = useAppSelector((state) => state.store.menu.activePage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[250px] fixed top-0 items-center box-border p-[20px] pt-[35px]">
|
<div className="w-[250px] fixed top-0 items-center box-border p-[20px] pt-[35px]">
|
||||||
<img src={Logo} className="w-[173px]" />
|
<img src={Logo} className="w-[173px]" />
|
||||||
<div className="">
|
<div className="">
|
||||||
{menuItems.map((v, i) => (
|
{menuItems.map((v, i) => (
|
||||||
<MenuItem key={i} icon={v.icon} text={v.text} href={v.href} active={v.page == activePage} page={v.page}/>
|
<MenuItem
|
||||||
))}
|
key={i}
|
||||||
</div>
|
icon={v.icon}
|
||||||
</div>
|
text={v.text}
|
||||||
);
|
href={v.href}
|
||||||
|
active={v.page == activePage}
|
||||||
|
page={v.page}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Menu;
|
export default Menu;
|
||||||
|
|||||||
@@ -1,37 +1,44 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from 'react-router-dom';
|
||||||
import { useAppDispatch } from "../../../redux/hooks";
|
import { useAppDispatch } from '../../../redux/hooks';
|
||||||
import { setMenuActivePage } from "../../../redux/slices/store";
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
|
|
||||||
interface MenuItemProps {
|
interface MenuItemProps {
|
||||||
icon: string; // SVG или любой JSX
|
icon: string; // SVG или любой JSX
|
||||||
text: string;
|
text: string;
|
||||||
href: string;
|
href: string;
|
||||||
page: string;
|
page: string;
|
||||||
active?: boolean; // необязательный, по умолчанию false
|
active?: boolean; // необязательный, по умолчанию false
|
||||||
}
|
}
|
||||||
|
|
||||||
const MenuItem: React.FC<MenuItemProps> = ({ icon, text = "", href = "", active = false, page = "" }) => {
|
const MenuItem: React.FC<MenuItemProps> = ({
|
||||||
const dispatch = useAppDispatch();
|
icon,
|
||||||
|
text = '',
|
||||||
|
href = '',
|
||||||
|
active = false,
|
||||||
|
page = '',
|
||||||
|
}) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-3 p-[16px] rounded-[10px\] h-[40px] text-[18px] font-bold
|
flex items-center gap-3 p-[16px] rounded-[10px\] h-[40px] text-[18px] font-bold
|
||||||
transition-all duration-300 text-liquid-white mt-[20px]
|
transition-all duration-300 text-liquid-white mt-[20px]
|
||||||
active:scale-95
|
active:scale-95
|
||||||
${active ? "bg-liquid-darkmain hover:bg-liquid-lighter hover:ring-[1px] hover:ring-liquid-darkmain hover:ring-inset"
|
${
|
||||||
: " hover:bg-liquid-lighter"}
|
active
|
||||||
|
? 'bg-liquid-darkmain hover:bg-liquid-lighter hover:ring-[1px] hover:ring-liquid-darkmain hover:ring-inset'
|
||||||
|
: ' hover:bg-liquid-lighter'
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
onClick={
|
onClick={() => dispatch(setMenuActivePage(page))}
|
||||||
() => dispatch(setMenuActivePage(page))
|
>
|
||||||
}
|
<img src={icon} />
|
||||||
>
|
<span>{text}</span>
|
||||||
<img src={icon} />
|
</Link>
|
||||||
<span>{text}</span>
|
);
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MenuItem;
|
export default MenuItem;
|
||||||
|
|||||||
@@ -1,71 +1,78 @@
|
|||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
import { IconError, IconSuccess } from "../../../assets/icons/missions";
|
import { IconError, IconSuccess } from '../../../assets/icons/missions';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export interface MissionItemProps {
|
export interface MissionItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
authorId: number;
|
authorId: number;
|
||||||
name: string;
|
name: string;
|
||||||
difficulty: "Easy" | "Medium" | "Hard";
|
difficulty: 'Easy' | 'Medium' | 'Hard';
|
||||||
tags: string[];
|
tags: string[];
|
||||||
timeLimit: number;
|
timeLimit: number;
|
||||||
memoryLimit: number;
|
memoryLimit: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
type: "first" | "second";
|
type: 'first' | 'second';
|
||||||
status: "empty" | "success" | "error";
|
status: 'empty' | 'success' | 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMilliseconds(ms: number): string {
|
export function formatMilliseconds(ms: number): string {
|
||||||
const rounded = Math.round(ms) / 1000;
|
const rounded = Math.round(ms) / 1000;
|
||||||
const formatted = rounded.toString().replace(/\.?0+$/, '');
|
const formatted = rounded.toString().replace(/\.?0+$/, '');
|
||||||
return `${formatted} c`;
|
return `${formatted} c`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatBytesToMB(bytes: number): string {
|
export function formatBytesToMB(bytes: number): string {
|
||||||
const megabytes = Math.floor(bytes / (1024 * 1024));
|
const megabytes = Math.floor(bytes / (1024 * 1024));
|
||||||
return `${megabytes} МБ`;
|
return `${megabytes} МБ`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MissionItem: React.FC<MissionItemProps> = ({
|
const MissionItem: React.FC<MissionItemProps> = ({
|
||||||
id, name, difficulty, timeLimit, memoryLimit, type, status
|
id,
|
||||||
|
name,
|
||||||
|
difficulty,
|
||||||
|
timeLimit,
|
||||||
|
memoryLimit,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("h-[44px] w-full relative rounded-[10px] text-liquid-white",
|
<div
|
||||||
type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
|
className={cn(
|
||||||
"grid grid-cols-[80px,1fr,1fr,60px,24px] grid-flow-col gap-[20px] px-[20px] box-border items-center",
|
'h-[44px] w-full relative rounded-[10px] text-liquid-white',
|
||||||
status == "error" && "border-l-[11px] border-l-liquid-red pl-[9px]",
|
type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
|
||||||
status == "success" && "border-l-[11px] border-l-liquid-green pl-[9px]",
|
'grid grid-cols-[80px,1fr,1fr,60px,24px] grid-flow-col gap-[20px] px-[20px] box-border items-center',
|
||||||
"cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300",
|
status == 'error' &&
|
||||||
)}
|
'border-l-[11px] border-l-liquid-red pl-[9px]',
|
||||||
onClick={() => {navigate(`/mission/${id}`)}}
|
status == 'success' &&
|
||||||
|
'border-l-[11px] border-l-liquid-green pl-[9px]',
|
||||||
|
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/mission/${id}`);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-[18px] font-bold">
|
<div className="text-[18px] font-bold">#{id}</div>
|
||||||
#{id}
|
<div className="text-[18px] font-bold">{name}</div>
|
||||||
</div>
|
|
||||||
<div className="text-[18px] font-bold">
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
<div className="text-[12px] text-right">
|
<div className="text-[12px] text-right">
|
||||||
стандартный ввод/вывод {formatMilliseconds(timeLimit)}, {formatBytesToMB(memoryLimit)}
|
стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '}
|
||||||
|
{formatBytesToMB(memoryLimit)}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div
|
||||||
"text-center text-[18px]",
|
className={cn(
|
||||||
difficulty == "Hard" && "text-liquid-red",
|
'text-center text-[18px]',
|
||||||
difficulty == "Medium" && "text-liquid-orange",
|
difficulty == 'Hard' && 'text-liquid-red',
|
||||||
difficulty == "Easy" && "text-liquid-green",
|
difficulty == 'Medium' && 'text-liquid-orange',
|
||||||
)}>
|
difficulty == 'Easy' && 'text-liquid-green',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{difficulty}
|
{difficulty}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[24px] w-[24px]">
|
<div className="h-[24px] w-[24px]">
|
||||||
{
|
{status == 'error' && <img src={IconError} />}
|
||||||
status == "error" && <img src={IconError}/>
|
{status == 'success' && <img src={IconSuccess} />}
|
||||||
}
|
|
||||||
{
|
|
||||||
status == "success" && <img src={IconSuccess}/>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import MissionItem from "./MissionItem";
|
import MissionItem from './MissionItem';
|
||||||
import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
import { setMenuActivePage } from "../../../redux/slices/store";
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { fetchMissions } from "../../../redux/slices/missions";
|
import { fetchMissions } from '../../../redux/slices/missions';
|
||||||
import ModalCreate from "./ModalCreate";
|
import ModalCreate from './ModalCreate';
|
||||||
|
|
||||||
|
|
||||||
export interface Mission {
|
export interface Mission {
|
||||||
id: number;
|
id: number;
|
||||||
authorId: number;
|
authorId: number;
|
||||||
name: string;
|
name: string;
|
||||||
difficulty: "Easy" | "Medium" | "Hard";
|
difficulty: 'Easy' | 'Medium' | 'Hard';
|
||||||
tags: string[];
|
tags: string[];
|
||||||
timeLimit: number;
|
timeLimit: number;
|
||||||
memoryLimit: number;
|
memoryLimit: number;
|
||||||
@@ -21,60 +20,60 @@ export interface Mission {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Missions = () => {
|
const Missions = () => {
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [modalActive, setModalActive] = useState<boolean>(false);
|
const [modalActive, setModalActive] = useState<boolean>(false);
|
||||||
|
|
||||||
const missions = useAppSelector((state) => state.missions.missions);
|
const missions = useAppSelector((state) => state.missions.missions);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage("missions"))
|
dispatch(setMenuActivePage('missions'));
|
||||||
dispatch(fetchMissions({}))
|
dispatch(fetchMissions({}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
|
||||||
<div className="h-full box-border">
|
<div className="h-full box-border">
|
||||||
|
|
||||||
<div className="relative flex items-center mb-[20px]">
|
<div className="relative flex items-center mb-[20px]">
|
||||||
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
|
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
|
||||||
Задачи
|
Задачи
|
||||||
</div>
|
</div>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
onClick={() => {setModalActive(true)}}
|
onClick={() => {
|
||||||
|
setModalActive(true);
|
||||||
|
}}
|
||||||
text="Добавить задачу"
|
text="Добавить задачу"
|
||||||
className="absolute right-0"
|
className="absolute right-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-liquid-lighter h-[50px] mb-[20px]">
|
<div className="bg-liquid-lighter h-[50px] mb-[20px]"></div>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
{missions.map((v, i) => (
|
||||||
{missions.map((v, i) => (
|
<MissionItem
|
||||||
<MissionItem
|
key={i}
|
||||||
key={i}
|
id={v.id}
|
||||||
id={v.id}
|
authorId={v.authorId}
|
||||||
authorId={v.authorId}
|
name={v.name}
|
||||||
name={v.name}
|
difficulty={'Easy'}
|
||||||
difficulty={"Easy"}
|
tags={v.tags}
|
||||||
tags={v.tags}
|
timeLimit={1000}
|
||||||
timeLimit={1000}
|
memoryLimit={256 * 1024 * 1024}
|
||||||
memoryLimit={256 * 1024 * 1024}
|
createdAt={v.createdAt}
|
||||||
createdAt={v.createdAt}
|
updatedAt={v.updatedAt}
|
||||||
updatedAt={v.updatedAt}
|
type={i % 2 == 0 ? 'first' : 'second'}
|
||||||
type={i % 2 == 0 ? "first" : "second"}
|
status={
|
||||||
status={i == 0 || i == 3 || i == 7 ? "success" : (i == 2 || i == 4 || i == 9 ? "error" : "empty")}/>
|
i == 0 || i == 3 || i == 7
|
||||||
))}
|
? 'success'
|
||||||
|
: i == 2 || i == 4 || i == 9
|
||||||
|
? 'error'
|
||||||
|
: 'empty'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>pages</div>
|
||||||
<div>
|
|
||||||
pages
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalCreate setActive={setModalActive} active={modalActive} />
|
<ModalCreate setActive={setModalActive} active={modalActive} />
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
|||||||
}
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(setMissionsStatus({ key: 'upload', status: 'idle' }));
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
||||||
@@ -152,6 +156,8 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
|||||||
text="Отмена"
|
text="Отмена"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{status == 'failed' && <div>error</div>}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,141 +1,153 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from 'react';
|
||||||
import Editor from "@monaco-editor/react";
|
import Editor from '@monaco-editor/react';
|
||||||
import { upload } from "../../../assets/icons/input";
|
import { upload } from '../../../assets/icons/input';
|
||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
import { DropDownList } from "../../../components/drop-down-list/DropDownList";
|
import { DropDownList } from '../../../components/drop-down-list/DropDownList';
|
||||||
|
|
||||||
const languageMap: Record<string, string> = {
|
const languageMap: Record<string, string> = {
|
||||||
c: "cpp",
|
c: 'cpp',
|
||||||
"C++": "cpp",
|
'C++': 'cpp',
|
||||||
java: "java",
|
java: 'java',
|
||||||
python: "python",
|
python: 'python',
|
||||||
pascal: "pascal",
|
pascal: 'pascal',
|
||||||
kotlin: "kotlin",
|
kotlin: 'kotlin',
|
||||||
csharp: "csharp"
|
csharp: 'csharp',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CodeEditorProps {
|
export interface CodeEditorProps {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onChangeLanguage: (value: string) => void;
|
onChangeLanguage: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) => {
|
const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||||
const [language, setLanguage] = useState<string>("C++");
|
onChange,
|
||||||
const [code, setCode] = useState<string>("");
|
onChangeLanguage,
|
||||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
}) => {
|
||||||
|
const [language, setLanguage] = useState<string>('C++');
|
||||||
|
const [code, setCode] = useState<string>('');
|
||||||
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ value: 'c', text: 'C' },
|
||||||
|
{ value: 'C++', text: 'C++' },
|
||||||
|
{ value: 'java', text: 'Java' },
|
||||||
|
{ value: 'python', text: 'Python' },
|
||||||
|
{ value: 'pascal', text: 'Pascal' },
|
||||||
|
{ value: 'kotlin', text: 'Kotlin' },
|
||||||
|
{ value: 'csharp', text: 'C#' },
|
||||||
|
];
|
||||||
|
|
||||||
const items = [
|
useEffect(() => {
|
||||||
{ value: "c", text: "C" },
|
onChange(code);
|
||||||
{ value: "C++", text: "C++" },
|
}, [code]);
|
||||||
{ value: "java", text: "Java" },
|
useEffect(() => {
|
||||||
{ value: "python", text: "Python" },
|
onChangeLanguage(language);
|
||||||
{ value: "pascal", text: "Pascal" },
|
}, [language]);
|
||||||
{ value: "kotlin", text: "Kotlin" },
|
|
||||||
{ value: "csharp", text: "C#" },
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
onChange(code);
|
const file = e.target.files?.[0];
|
||||||
}, [code])
|
if (!file) return;
|
||||||
useEffect(() => {
|
|
||||||
onChangeLanguage(language);
|
|
||||||
}, [language])
|
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const reader = new FileReader();
|
||||||
const file = e.target.files?.[0];
|
reader.onload = (event) => {
|
||||||
if (!file) return;
|
const text = event.target?.result;
|
||||||
|
if (typeof text === 'string') setCode(text);
|
||||||
const reader = new FileReader();
|
};
|
||||||
reader.onload = (event) => {
|
reader.readAsText(file);
|
||||||
const text = event.target?.result;
|
e.target.value = '';
|
||||||
if (typeof text === "string") setCode(text);
|
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
|
||||||
e.target.value = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
const droppedFile = e.dataTransfer.files[0];
|
const droppedFile = e.dataTransfer.files[0];
|
||||||
if (!droppedFile) return;
|
if (!droppedFile) return;
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
const text = event.target?.result;
|
const text = event.target?.result;
|
||||||
if (typeof text === "string") setCode(text);
|
if (typeof text === 'string') setCode(text);
|
||||||
|
};
|
||||||
|
reader.readAsText(droppedFile);
|
||||||
};
|
};
|
||||||
reader.readAsText(droppedFile);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||||
e.preventDefault(); // обязательно
|
e.preventDefault(); // обязательно
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnter = (e: React.DragEvent<HTMLLabelElement>) => {
|
const handleDragEnter = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
|
const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full h-full">
|
<div className="flex flex-col w-full h-full">
|
||||||
{/* Панель выбора языка и загрузки файла */}
|
{/* Панель выбора языка и загрузки файла */}
|
||||||
<div className="flex items-center justify-between py-3 ">
|
<div className="flex items-center justify-between py-3 ">
|
||||||
<div className="flex items-center gap-[20px]">
|
<div className="flex items-center gap-[20px]">
|
||||||
<DropDownList items={items} onChange={(v) => { setLanguage(v) }} defaultState={{ value: "C++", text: "C++" }}/>
|
<DropDownList
|
||||||
|
items={items}
|
||||||
|
onChange={(v) => {
|
||||||
|
setLanguage(v);
|
||||||
|
}}
|
||||||
|
defaultState={{ value: 'C++', text: 'C++' }}
|
||||||
|
/>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
className={cn("h-[40px] w-[250px] rounded-[10px] px-[16px] relative flex items-center cursor-pointer transition-all bg-liquid-lighter outline-dashed outline-[2px] outline-transparent active:scale-[95%]",
|
className={cn(
|
||||||
isDragging && "outline-blue-500 "
|
'h-[40px] w-[250px] rounded-[10px] px-[16px] relative flex items-center cursor-pointer transition-all bg-liquid-lighter outline-dashed outline-[2px] outline-transparent active:scale-[95%]',
|
||||||
)}
|
isDragging && 'outline-blue-500 ',
|
||||||
onDrop={handleDrop}
|
)}
|
||||||
onDragOver={handleDragOver}
|
onDrop={handleDrop}
|
||||||
onDragEnter={handleDragEnter}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragEnter={handleDragEnter}
|
||||||
>
|
onDragLeave={handleDragLeave}
|
||||||
<span className="text-[18px] text-liquid-white font-bold pointer-events-none">
|
>
|
||||||
{"Загрузить решение"}
|
<span className="text-[18px] text-liquid-white font-bold pointer-events-none">
|
||||||
</span>
|
{'Загрузить решение'}
|
||||||
<img src={upload} className="absolute right-[16px] pointer-events-none" />
|
</span>
|
||||||
<input
|
<img
|
||||||
type="file"
|
src={upload}
|
||||||
onChange={(e) => handleFileUpload(e)}
|
className="absolute right-[16px] pointer-events-none"
|
||||||
className="hidden"
|
/>
|
||||||
/>
|
<input
|
||||||
</label>
|
type="file"
|
||||||
|
onChange={(e) => handleFileUpload(e)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monaco Editor */}
|
||||||
|
<div className="bg-[#1E1E1E] py-[10px] h-full rounded-[10px]">
|
||||||
|
<Editor
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
language={languageMap[language]}
|
||||||
|
value={code}
|
||||||
|
onChange={(value) => setCode(value ?? '')}
|
||||||
|
theme="vs-dark"
|
||||||
|
options={{
|
||||||
|
fontSize: 14,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
automaticLayout: true,
|
||||||
|
quickSuggestions: true,
|
||||||
|
suggestOnTriggerCharacters: true,
|
||||||
|
tabSize: 4,
|
||||||
|
insertSpaces: true,
|
||||||
|
detectIndentation: false,
|
||||||
|
autoIndent: 'full',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
|
||||||
{/* Monaco Editor */}
|
|
||||||
<div className="bg-[#1E1E1E] py-[10px] h-full rounded-[10px]">
|
|
||||||
<Editor
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
language={languageMap[language]}
|
|
||||||
value={code}
|
|
||||||
onChange={(value) => setCode(value ?? "")}
|
|
||||||
theme="vs-dark"
|
|
||||||
options={{
|
|
||||||
fontSize: 14,
|
|
||||||
minimap: { enabled: false },
|
|
||||||
automaticLayout: true,
|
|
||||||
quickSuggestions: true,
|
|
||||||
suggestOnTriggerCharacters: true,
|
|
||||||
tabSize: 4,
|
|
||||||
insertSpaces: true,
|
|
||||||
detectIndentation: false,
|
|
||||||
autoIndent: "full",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CodeEditor;
|
export default CodeEditor;
|
||||||
|
|||||||
@@ -1,28 +1,57 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { chevroneLeft, chevroneRight, arrowLeft } from "../../../assets/icons/header";
|
import {
|
||||||
import { Logo } from "../../../assets/logos";
|
chevroneLeft,
|
||||||
import { useNavigate } from "react-router-dom";
|
chevroneRight,
|
||||||
|
arrowLeft,
|
||||||
|
} from '../../../assets/icons/header';
|
||||||
|
import { Logo } from '../../../assets/logos';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
missionId: number;
|
missionId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({
|
const Header: React.FC<HeaderProps> = ({ missionId }) => {
|
||||||
missionId
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
|
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
|
||||||
<img src={Logo} alt="Logo" className="h-[28px] w-auto cursor-pointer" onClick={() => { navigate("/home") }} />
|
<img
|
||||||
|
src={Logo}
|
||||||
|
alt="Logo"
|
||||||
|
className="h-[28px] w-auto cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/home');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<img src={arrowLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate("/home/missions") }} />
|
<img
|
||||||
|
src={arrowLeft}
|
||||||
|
alt="back"
|
||||||
|
className="h-[24px] w-[24px] cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/home/missions');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex gap-[10px]">
|
<div className="flex gap-[10px]">
|
||||||
<img src={chevroneLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId - 1}`) }} />
|
<img
|
||||||
|
src={chevroneLeft}
|
||||||
|
alt="back"
|
||||||
|
className="h-[24px] w-[24px] cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/mission/${missionId - 1}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<span>{missionId}</span>
|
<span>{missionId}</span>
|
||||||
<img src={chevroneRight} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId + 1}`) }} />
|
<img
|
||||||
|
src={chevroneRight}
|
||||||
|
alt="back"
|
||||||
|
className="h-[24px] w-[24px] cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/mission/${missionId + 1}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,113 +1,131 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
MathJax?: {
|
MathJax?: {
|
||||||
startup?: { promise?: Promise<void> };
|
startup?: { promise?: Promise<void> };
|
||||||
typesetPromise?: (elements?: Element[]) => Promise<void>;
|
typesetPromise?: (elements?: Element[]) => Promise<void>;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaFile {
|
interface MediaFile {
|
||||||
id: number;
|
id: number;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
mediaUrl: string;
|
mediaUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LaTextContainerProps {
|
interface LaTextContainerProps {
|
||||||
html: string;
|
html: string;
|
||||||
latex: string;
|
latex: string;
|
||||||
mediaFiles?: MediaFile[];
|
mediaFiles?: MediaFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let mathJaxPromise: Promise<void> | null = null;
|
let mathJaxPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
const loadMathJax = () => {
|
const loadMathJax = () => {
|
||||||
if (mathJaxPromise) return mathJaxPromise;
|
if (mathJaxPromise) return mathJaxPromise;
|
||||||
|
|
||||||
mathJaxPromise = new Promise<void>((resolve, reject) => {
|
mathJaxPromise = new Promise<void>((resolve, reject) => {
|
||||||
if (window.MathJax?.typesetPromise) {
|
if (window.MathJax?.typesetPromise) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(window as any).MathJax = {
|
(window as any).MathJax = {
|
||||||
tex: {
|
tex: {
|
||||||
inlineMath: [["$$$", "$$$"]],
|
inlineMath: [['$$$', '$$$']],
|
||||||
displayMath: [["$$$$$$", "$$$$$$"]],
|
displayMath: [['$$$$$$', '$$$$$$']],
|
||||||
processEscapes: true,
|
processEscapes: true,
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
skipHtmlTags: ["script", "noscript", "style", "textarea", "pre", "code"],
|
skipHtmlTags: [
|
||||||
},
|
'script',
|
||||||
startup: { typeset: false },
|
'noscript',
|
||||||
};
|
'style',
|
||||||
|
'textarea',
|
||||||
|
'pre',
|
||||||
|
'code',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
startup: { typeset: false },
|
||||||
|
};
|
||||||
|
|
||||||
const script = document.createElement("script");
|
const script = document.createElement('script');
|
||||||
script.id = "mathjax-script";
|
script.id = 'mathjax-script';
|
||||||
script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js";
|
script.src =
|
||||||
script.async = true;
|
'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js';
|
||||||
|
script.async = true;
|
||||||
|
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
window.MathJax?.startup?.promise?.then(resolve).catch(reject);
|
window.MathJax?.startup?.promise?.then(resolve).catch(reject);
|
||||||
};
|
};
|
||||||
|
|
||||||
script.onerror = reject;
|
script.onerror = reject;
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
|
|
||||||
return mathJaxPromise;
|
return mathJaxPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
const replaceImages = (html: string, latex: string, mediaFiles?: MediaFile[]) => {
|
const replaceImages = (
|
||||||
const parser = new DOMParser();
|
html: string,
|
||||||
const doc = parser.parseFromString(html, "text/html");
|
latex: string,
|
||||||
|
mediaFiles?: MediaFile[],
|
||||||
|
) => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
|
||||||
const latexImageNames = Array.from(latex.matchAll(/\\includegraphics\{(.+?)\}/g)).map(
|
const latexImageNames = Array.from(
|
||||||
(match) => match[1]
|
latex.matchAll(/\\includegraphics\{(.+?)\}/g),
|
||||||
);
|
).map((match) => match[1]);
|
||||||
|
|
||||||
const imgs = doc.querySelectorAll<HTMLImageElement>("img.tex-graphics");
|
const imgs = doc.querySelectorAll<HTMLImageElement>('img.tex-graphics');
|
||||||
|
|
||||||
imgs.forEach((img, idx) => {
|
imgs.forEach((img, idx) => {
|
||||||
const imageName = latexImageNames[idx];
|
const imageName = latexImageNames[idx];
|
||||||
if (!imageName || !mediaFiles) return;
|
if (!imageName || !mediaFiles) return;
|
||||||
const mediaFile = mediaFiles.find((f) => f.fileName === imageName);
|
const mediaFile = mediaFiles.find((f) => f.fileName === imageName);
|
||||||
if (mediaFile) img.src = mediaFile.mediaUrl;
|
if (mediaFile) img.src = mediaFile.mediaUrl;
|
||||||
});
|
});
|
||||||
|
|
||||||
return doc.body.innerHTML;
|
return doc.body.innerHTML;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LaTextContainer: React.FC<LaTextContainerProps> = ({ html, latex, mediaFiles }) => {
|
const LaTextContainer: React.FC<LaTextContainerProps> = ({
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
html,
|
||||||
const [processedHtml, setProcessedHtml] = useState<string>(html);
|
latex,
|
||||||
|
mediaFiles,
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [processedHtml, setProcessedHtml] = useState<string>(html);
|
||||||
|
|
||||||
// 1️⃣ Обновляем HTML при изменении входных данных
|
// 1️⃣ Обновляем HTML при изменении входных данных
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProcessedHtml(replaceImages(html, latex, mediaFiles));
|
setProcessedHtml(replaceImages(html, latex, mediaFiles));
|
||||||
}, [html, latex, mediaFiles]);
|
}, [html, latex, mediaFiles]);
|
||||||
|
|
||||||
// 2️⃣ После рендера обновленного HTML применяем MathJax
|
// 2️⃣ После рендера обновленного HTML применяем MathJax
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const renderMath = () => {
|
const renderMath = () => {
|
||||||
if (containerRef.current && window.MathJax?.typesetPromise) {
|
if (containerRef.current && window.MathJax?.typesetPromise) {
|
||||||
window.MathJax.typesetPromise([containerRef.current]).catch(console.error);
|
window.MathJax.typesetPromise([containerRef.current]).catch(
|
||||||
}
|
console.error,
|
||||||
};
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
loadMathJax().then(renderMath).catch(console.error);
|
loadMathJax().then(renderMath).catch(console.error);
|
||||||
}, [processedHtml]); // 👈 ключевой момент — триггерим именно по processedHtml
|
}, [processedHtml]); // 👈 ключевой момент — триггерим именно по processedHtml
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="latex-container"
|
className="latex-container"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
dangerouslySetInnerHTML={{ __html: processedHtml }}
|
dangerouslySetInnerHTML={{ __html: processedHtml }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LaTextContainer;
|
export default LaTextContainer;
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import SubmissionItem from "./SubmissionItem";
|
import SubmissionItem from './SubmissionItem';
|
||||||
import { useAppSelector } from "../../../redux/hooks";
|
import { useAppSelector } from '../../../redux/hooks';
|
||||||
import { FC, useEffect } from "react";
|
import { FC, useEffect } from 'react';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface Mission {
|
export interface Mission {
|
||||||
id: number;
|
id: number;
|
||||||
authorId: number;
|
authorId: number;
|
||||||
name: string;
|
name: string;
|
||||||
difficulty: "Easy" | "Medium" | "Hard";
|
difficulty: 'Easy' | 'Medium' | 'Hard';
|
||||||
tags: string[];
|
tags: string[];
|
||||||
timeLimit: number;
|
timeLimit: number;
|
||||||
memoryLimit: number;
|
memoryLimit: number;
|
||||||
@@ -16,39 +14,45 @@ export interface Mission {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MissionSubmissionsProps{
|
interface MissionSubmissionsProps {
|
||||||
missionId: number;
|
missionId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MissionSubmissions: FC<MissionSubmissionsProps> = ({missionId}) => {
|
const MissionSubmissions: FC<MissionSubmissionsProps> = ({ missionId }) => {
|
||||||
const submissions = useAppSelector((state) => state.submin.submitsById[missionId]);
|
const submissions = useAppSelector(
|
||||||
|
(state) => state.submin.submitsById[missionId],
|
||||||
useEffect(() => {
|
);
|
||||||
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
useEffect(() => {}, []);
|
||||||
|
|
||||||
const checkStatus = (status: string) => {
|
const checkStatus = (status: string) => {
|
||||||
if (status == "IncorrectAnswer")
|
if (status == 'IncorrectAnswer') return 'wronganswer';
|
||||||
return "wronganswer";
|
if (status == 'TimeLimitError') return 'timelimit';
|
||||||
if (status == "TimeLimitError")
|
|
||||||
return "timelimit";
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full box-border overflow-y-scroll overflow-x-hidden thin-scrollbar pr-[10px]">
|
<div className="h-full w-full box-border overflow-y-scroll overflow-x-hidden thin-scrollbar pr-[10px]">
|
||||||
|
{submissions &&
|
||||||
|
submissions.map((v, i) => (
|
||||||
{submissions && submissions.map((v, i) => (
|
<SubmissionItem
|
||||||
<SubmissionItem
|
key={i}
|
||||||
key={i}
|
id={v.id}
|
||||||
id={v.id}
|
language={v.solution.language}
|
||||||
language={v.solution.language}
|
time={v.solution.time}
|
||||||
time={v.solution.time}
|
verdict={
|
||||||
verdict={v.solution.testerMessage?.includes("Compilation failed") ? "Compilation failed" : v.solution.testerMessage}
|
v.solution.testerMessage?.includes(
|
||||||
type={i % 2 ? "second" : "first"}
|
'Compilation failed',
|
||||||
status={v.solution.testerMessage == "All tests passed" ? "success" : checkStatus(v.solution.testerErrorCode)}
|
)
|
||||||
|
? 'Compilation failed'
|
||||||
|
: v.solution.testerMessage
|
||||||
|
}
|
||||||
|
type={i % 2 ? 'second' : 'first'}
|
||||||
|
status={
|
||||||
|
v.solution.testerMessage == 'All tests passed'
|
||||||
|
? 'success'
|
||||||
|
: checkStatus(v.solution.testerErrorCode)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,53 +1,47 @@
|
|||||||
import React, { FC } from "react";
|
import React, { FC } from 'react';
|
||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
import LaTextContainer from "./LaTextContainer";
|
import LaTextContainer from './LaTextContainer';
|
||||||
import { CopyIcon } from "../../../assets/icons/missions";
|
import { CopyIcon } from '../../../assets/icons/missions';
|
||||||
// import FullLatexRenderer from "./FullLatexRenderer";
|
// import FullLatexRenderer from "./FullLatexRenderer";
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface CopyableDivPropd {
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface CopyableDivPropd{
|
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CopyableDiv: FC<CopyableDivPropd> = ({ content }) => {
|
const CopyableDiv: FC<CopyableDivPropd> = ({ content }) => {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(content);
|
|
||||||
alert("Скопировано!");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Ошибка копирования:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative p-[10px] bg-liquid-lighter rounded-[10px] whitespace-pre-line"
|
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={CopyIcon}
|
|
||||||
alt="copy"
|
|
||||||
className={cn("absolute top-2 right-2 w-6 h-6 cursor-pointer opacity-0 transition-all duration-300 hover:h-7 hover:w-7 hover:top-[6px] hover:right-[6px]",
|
|
||||||
hovered && " opacity-100"
|
|
||||||
)}
|
|
||||||
onClick={handleCopy}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
alert('Скопировано!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка копирования:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative p-[10px] bg-liquid-lighter rounded-[10px] whitespace-pre-line"
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={CopyIcon}
|
||||||
|
alt="copy"
|
||||||
|
className={cn(
|
||||||
|
'absolute top-2 right-2 w-6 h-6 cursor-pointer opacity-0 transition-all duration-300 hover:h-7 hover:w-7 hover:top-[6px] hover:right-[6px]',
|
||||||
|
hovered && ' opacity-100',
|
||||||
|
)}
|
||||||
|
onClick={handleCopy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export interface StatementData {
|
export interface StatementData {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -65,10 +59,10 @@ export interface StatementData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractDivByClass(html: string, className: string): string {
|
function extractDivByClass(html: string, className: string): string {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(html, "text/html");
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
const div = doc.querySelector(`div.${className}`);
|
const div = doc.querySelector(`div.${className}`);
|
||||||
return div ? div.outerHTML : "";
|
return div ? div.outerHTML : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const Statement: React.FC<StatementData> = ({
|
const Statement: React.FC<StatementData> = ({
|
||||||
@@ -77,63 +71,110 @@ const Statement: React.FC<StatementData> = ({
|
|||||||
tags,
|
tags,
|
||||||
timeLimit = 1000,
|
timeLimit = 1000,
|
||||||
memoryLimit = 256 * 1024 * 1024,
|
memoryLimit = 256 * 1024 * 1024,
|
||||||
legend = "",
|
legend = '',
|
||||||
input = "",
|
input = '',
|
||||||
output = "",
|
output = '',
|
||||||
sampleTests = [],
|
sampleTests = [],
|
||||||
notes = "",
|
notes = '',
|
||||||
html = "",
|
html = '',
|
||||||
mediaFiles,
|
mediaFiles,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full h-full medium-scrollbar pl-[20px] pr-[12px] gap-[20px] text-liquid-white overflow-y-scroll thin-dark-scrollbar [scrollbar-gutter:stable]">
|
<div className="flex flex-col w-full h-full medium-scrollbar pl-[20px] pr-[12px] gap-[20px] text-liquid-white overflow-y-scroll thin-dark-scrollbar [scrollbar-gutter:stable]">
|
||||||
<div>
|
<div>
|
||||||
<p className="h-[50px] text-[40px] font-bold text-liquid-white">{name}</p>
|
<p className="h-[50px] text-[40px] font-bold text-liquid-white">
|
||||||
<p className="h-[23px] text-[18px] font-bold text-liquid-light">Задача #{id}</p>
|
{name}
|
||||||
|
</p>
|
||||||
|
<p className="h-[23px] text-[18px] font-bold text-liquid-light">
|
||||||
|
Задача #{id}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-[10px] w-full flex-wrap">
|
<div className="flex gap-[10px] w-full flex-wrap">
|
||||||
{tags && tags.map((v, i) => <div key={i} className="px-[16px] py-[8px] rounded-full bg-liquid-lighter ">{v}</div>)}
|
{tags &&
|
||||||
|
tags.map((v, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="px-[16px] py-[8px] rounded-full bg-liquid-lighter "
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ограничение по времени на тест:</span> {timeLimit / 1000} секунда</p>
|
<p className="text-liquid-white h-[20px] text-[18px] font-bold">
|
||||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ограничение по памяти на тест:</span> {memoryLimit / 1024 / 1024} мегабайт</p>
|
<span className="text-liquid-light">
|
||||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ввод:</span> стандартный ввод</p>
|
ограничение по времени на тест:
|
||||||
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">вывод:</span> стандартный вывод</p>
|
</span>{' '}
|
||||||
|
{timeLimit / 1000} секунда
|
||||||
|
</p>
|
||||||
|
<p className="text-liquid-white h-[20px] text-[18px] font-bold">
|
||||||
|
<span className="text-liquid-light">
|
||||||
|
ограничение по памяти на тест:
|
||||||
|
</span>{' '}
|
||||||
|
{memoryLimit / 1024 / 1024} мегабайт
|
||||||
|
</p>
|
||||||
|
<p className="text-liquid-white h-[20px] text-[18px] font-bold">
|
||||||
|
<span className="text-liquid-light">ввод:</span> стандартный
|
||||||
|
ввод
|
||||||
|
</p>
|
||||||
|
<p className="text-liquid-white h-[20px] text-[18px] font-bold">
|
||||||
|
<span className="text-liquid-light">вывод:</span>{' '}
|
||||||
|
стандартный вывод
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-[10px] mt-[20px]">
|
<div className="flex flex-col gap-[10px] mt-[20px]">
|
||||||
<LaTextContainer html={extractDivByClass(html, "legend")} latex={legend} mediaFiles={mediaFiles}/>
|
<LaTextContainer
|
||||||
|
html={extractDivByClass(html, 'legend')}
|
||||||
|
latex={legend}
|
||||||
|
mediaFiles={mediaFiles}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-[10px]">
|
<div className="flex flex-col gap-[10px]">
|
||||||
<LaTextContainer html={extractDivByClass(html, "input-specification")} latex={input} mediaFiles={mediaFiles}/>
|
<LaTextContainer
|
||||||
|
html={extractDivByClass(html, 'input-specification')}
|
||||||
|
latex={input}
|
||||||
|
mediaFiles={mediaFiles}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-[10px]">
|
<div className="flex flex-col gap-[10px]">
|
||||||
<LaTextContainer html={extractDivByClass(html, "output-specification")} latex={output} mediaFiles={mediaFiles}/>
|
<LaTextContainer
|
||||||
|
html={extractDivByClass(html, 'output-specification')}
|
||||||
|
latex={output}
|
||||||
|
mediaFiles={mediaFiles}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-[10px]">
|
<div className="flex flex-col gap-[10px]">
|
||||||
<div className="text-[18px] font-bold">{sampleTests.length == 1 ? "Пример" : "Примеры"}</div>
|
<div className="text-[18px] font-bold">
|
||||||
|
{sampleTests.length == 1 ? 'Пример' : 'Примеры'}
|
||||||
|
</div>
|
||||||
|
|
||||||
{sampleTests.map((v, i) =>
|
{sampleTests.map((v, i) => (
|
||||||
<div key={i} className="flex flex-col gap-[10px]">
|
<div key={i} className="flex flex-col gap-[10px]">
|
||||||
<div className="text-[14px] font-bold">Входные данные</div>
|
<div className="text-[14px] font-bold">
|
||||||
<CopyableDiv content={v.input}/>
|
Входные данные
|
||||||
<div className="text-[14px] font-bold">Выходные данные</div>
|
</div>
|
||||||
<CopyableDiv content={v.output}/>
|
<CopyableDiv content={v.input} />
|
||||||
|
<div className="text-[14px] font-bold">
|
||||||
|
Выходные данные
|
||||||
|
</div>
|
||||||
|
<CopyableDiv content={v.output} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-[10px]">
|
<div className="flex flex-col gap-[10px]">
|
||||||
<LaTextContainer html={extractDivByClass(html, "note")} latex={notes} mediaFiles={mediaFiles}/>
|
<LaTextContainer
|
||||||
|
html={extractDivByClass(html, 'note')}
|
||||||
|
latex={notes}
|
||||||
|
mediaFiles={mediaFiles}
|
||||||
|
/>
|
||||||
<div>Автор: Jacks</div>
|
<div>Автор: Jacks</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Statement;
|
export default Statement;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cn } from "../../../lib/cn";
|
import { cn } from '../../../lib/cn';
|
||||||
// import { IconError, IconSuccess } from "../../../assets/icons/missions";
|
// import { IconError, IconSuccess } from "../../../assets/icons/missions";
|
||||||
// import { useNavigate } from "react-router-dom";
|
// import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@ export interface SubmissionItemProps {
|
|||||||
language: string;
|
language: string;
|
||||||
time: string;
|
time: string;
|
||||||
verdict: string;
|
verdict: string;
|
||||||
type: "first" | "second";
|
type: 'first' | 'second';
|
||||||
status?: "success" | "wronganswer" | "timelimit";
|
status?: 'success' | 'wronganswer' | 'timelimit';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMilliseconds(ms: number): string {
|
export function formatMilliseconds(ms: number): string {
|
||||||
@@ -23,16 +23,16 @@ export function formatBytesToMB(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
const day = date.getDate().toString().padStart(2, "0");
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
|
|
||||||
const hours = date.getHours().toString().padStart(2, "0");
|
const hours = date.getHours().toString().padStart(2, '0');
|
||||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
|
||||||
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SubmissionItem: React.FC<SubmissionItemProps> = ({
|
const SubmissionItem: React.FC<SubmissionItemProps> = ({
|
||||||
@@ -46,30 +46,34 @@ const SubmissionItem: React.FC<SubmissionItemProps> = ({
|
|||||||
// const navigate = useNavigate();
|
// const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(" w-full relative rounded-[10px] text-liquid-white",
|
<div
|
||||||
type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
|
className={cn(
|
||||||
"grid grid-cols-[80px,1fr,1fr,2fr] grid-flow-col gap-[20px] px-[20px] box-border items-center",
|
' w-full relative rounded-[10px] text-liquid-white',
|
||||||
status == "wronganswer" && "border-l-[11px] border-l-liquid-red pl-[9px]",
|
type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
|
||||||
status == "timelimit" && "border-l-[11px] border-l-liquid-orange pl-[9px]",
|
'grid grid-cols-[80px,1fr,1fr,2fr] grid-flow-col gap-[20px] px-[20px] box-border items-center',
|
||||||
status == "success" && "border-l-[11px] border-l-liquid-green pl-[9px]",
|
status == 'wronganswer' &&
|
||||||
"cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300",
|
'border-l-[11px] border-l-liquid-red pl-[9px]',
|
||||||
)}
|
status == 'timelimit' &&
|
||||||
onClick={() => { }}
|
'border-l-[11px] border-l-liquid-orange pl-[9px]',
|
||||||
|
status == 'success' &&
|
||||||
|
'border-l-[11px] border-l-liquid-green pl-[9px]',
|
||||||
|
'cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300',
|
||||||
|
)}
|
||||||
|
onClick={() => {}}
|
||||||
>
|
>
|
||||||
<div className="text-[18px] font-bold">
|
<div className="text-[18px] font-bold">#{id}</div>
|
||||||
#{id}
|
|
||||||
</div>
|
|
||||||
<div className="text-[18px] font-bold text-center">
|
<div className="text-[18px] font-bold text-center">
|
||||||
{formatDate(time)}
|
{formatDate(time)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] font-bold text-center">
|
<div className="text-[18px] font-bold text-center">{language}</div>
|
||||||
{language}
|
<div
|
||||||
</div>
|
className={cn(
|
||||||
<div className={cn("text-[18px] font-bold text-center",
|
'text-[18px] font-bold text-center',
|
||||||
status == "wronganswer" && "text-liquid-red",
|
status == 'wronganswer' && 'text-liquid-red',
|
||||||
status == "timelimit" && "text-liquid-orange",
|
status == 'timelimit' && 'text-liquid-orange',
|
||||||
status == "success" && "text-liquid-green",
|
status == 'success' && 'text-liquid-green',
|
||||||
)} >
|
)}
|
||||||
|
>
|
||||||
{verdict}
|
{verdict}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user