formatting

This commit is contained in:
Виталий Лавшонок
2025-11-04 15:04:59 +03:00
parent 3cd8e14288
commit 4972836164
60 changed files with 3604 additions and 2916 deletions

View File

@@ -1,4 +1,4 @@
import Balloon from "./balloon.svg";
import Account from "./account.svg"
import Balloon from './balloon.svg';
import Account from './account.svg';
export {Balloon, Account};
export { Balloon, Account };

View File

@@ -1,8 +1,8 @@
import Book from "./book.png"
import EyeClosed from "./eye-closed.svg";
import EyeOpen from "./eye-open.png";
import Edit from "./edit.svg";
import UserAdd from "./user-profile-add.svg";
import ChevroneDown from "./chevron-down.svg"
import Book from './book.png';
import EyeClosed from './eye-closed.svg';
import EyeOpen from './eye-open.png';
import Edit from './edit.svg';
import UserAdd from './user-profile-add.svg';
import ChevroneDown from './chevron-down.svg';
export {Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown}
export { Book, Edit, EyeClosed, EyeOpen, UserAdd, ChevroneDown };

View File

@@ -1,5 +1,5 @@
import arrowLeft from "./arrow-left-sm.svg";
import chevroneLeft from "./chevron-left.svg"
import chevroneRight from "./chevron-right.svg"
import arrowLeft from './arrow-left-sm.svg';
import chevroneLeft from './chevron-left.svg';
import chevroneRight from './chevron-right.svg';
export {arrowLeft, chevroneLeft, chevroneRight}
export { arrowLeft, chevroneLeft, chevroneRight };

View File

@@ -1,8 +1,15 @@
import eyeClosed from "./eye-closed.svg"
import eyeOpen from "./eye-open.png"
import googleLogo from "./google-logo.svg"
import upload from "./upload.svg"
import chevroneDropDownList from "./chevron-drop-down.svg"
import checkMark from "./check-mark.svg"
import eyeClosed from './eye-closed.svg';
import eyeOpen from './eye-open.png';
import googleLogo from './google-logo.svg';
import upload from './upload.svg';
import chevroneDropDownList from './chevron-drop-down.svg';
import checkMark from './check-mark.svg';
export {eyeClosed, eyeOpen, googleLogo, upload, chevroneDropDownList, checkMark}
export {
eyeClosed,
eyeOpen,
googleLogo,
upload,
chevroneDropDownList,
checkMark,
};

View File

@@ -1,8 +1,8 @@
import Account from "./account.svg";
import Clipboard from "./clipboard.svg";
import Cup from "./cup.svg";
import Home from "./home.svg";
import Openbook from "./openbook.svg";
import Users from "./users.svg";
import Account from './account.svg';
import Clipboard from './clipboard.svg';
import Cup from './cup.svg';
import Home from './home.svg';
import Openbook from './openbook.svg';
import Users from './users.svg';
export {Account, Clipboard, Cup, Home, Openbook, Users};
export { Account, Clipboard, Cup, Home, Openbook, Users };

View File

@@ -1,6 +1,5 @@
import IconSuccess from "./icon-success.svg"
import IconError from "./icon-error.svg"
import CopyIcon from "./copy-icon.svg"
import IconSuccess from './icon-success.svg';
import IconError from './icon-error.svg';
import CopyIcon from './copy-icon.svg';
export {IconError, IconSuccess, CopyIcon}
export { IconError, IconSuccess, CopyIcon };

View File

@@ -1,3 +1,3 @@
import Logo from "./Logo.svg"
import Logo from './Logo.svg';
export {Logo}
export { Logo };

View File

@@ -1,24 +1,24 @@
import axios from "axios";
import axios from 'axios';
const instance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: {
'Content-Type': 'application/json'
},
baseURL: import.meta.env.VITE_API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor: автоматически подставляет JWT, если есть
instance.interceptors.request.use(
(config) => {
const token = localStorage.getItem("jwt"); // или можно брать из Redux через store.getState()
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
(config) => {
const token = localStorage.getItem('jwt'); // или можно брать из Redux через store.getState()
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
export default instance;

View File

@@ -1,88 +1,90 @@
import React from "react";
import { cn } from "../../lib/cn";
import React from 'react';
import { cn } from '../../lib/cn';
interface ButtonProps {
disabled?: boolean;
text?: string;
className?: string;
onClick: () => void;
children?: React.ReactNode;
color?: "primary" | "secondary" | "error" | "warning" | "success";
disabled?: boolean;
text?: string;
className?: string;
onClick: () => void;
children?: React.ReactNode;
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
}
const ColorBgVariants = {
"primary": "bg-liquid-brightmain group-hover:ring-liquid-brightmain",
"secondary": "bg-liquid-darkmain group-hover:ring-liquid-darkmain",
"error": "bg-liquid-red group-hover:ring-liquid-red",
"warning": "bg-liquid-orange group-hover:ring-liquid-orange",
"success": "bg-liquid-green group-hover:ring-liquid-green",
}
primary: 'bg-liquid-brightmain group-hover:ring-liquid-brightmain',
secondary: 'bg-liquid-darkmain group-hover:ring-liquid-darkmain',
error: 'bg-liquid-red group-hover:ring-liquid-red',
warning: 'bg-liquid-orange group-hover:ring-liquid-orange',
success: 'bg-liquid-green group-hover:ring-liquid-green',
};
const ColorTextVariants = {
"primary": "group-hover:text-liquid-brightmain ",
"secondary": "group-hover:text-liquid-brightmain ",
"error": "group-hover:text-liquid-red ",
"warning": "group-hover:text-liquid-orange ",
"success": "group-hover:text-liquid-green ",
}
primary: 'group-hover:text-liquid-brightmain ',
secondary: 'group-hover:text-liquid-brightmain ',
error: 'group-hover:text-liquid-red ',
warning: 'group-hover:text-liquid-orange ',
success: 'group-hover:text-liquid-green ',
};
export const PrimaryButton: React.FC<ButtonProps> = ({
disabled = false,
text = "",
className,
onClick,
children,
color = "secondary",
disabled = false,
text = '',
className,
onClick,
children,
color = 'secondary',
}) => {
return (
<label
className={cn(
"grid relative cursor-pointer select-none group w-fit box-border",
disabled && "pointer-events-none",
className
)}
>
{/* Основной контейнер, */}
<div
className={cn(
"group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300",
"rounded-[10px]",
"group-hover:bg-liquid-lighter group-hover:ring-[1px] group-hover:ring-liquid-darkmain group-hover:ring-inset",
"px-[16px] py-[8px]",
ColorBgVariants[color],
disabled && "bg-liquid-lighter"
)}
>
{/* Скрытый button */}
<button
className={cn(
"absolute opacity-0 -z-10 h-0 w-0",
"[&:focus-visible+*]:outline-liquid-brightmain",
)}
disabled={disabled}
onClick={() => { onClick() }}
/>
return (
<label
className={cn(
'grid relative cursor-pointer select-none group w-fit box-border',
disabled && 'pointer-events-none',
className,
)}
>
{/* Основной контейнер, */}
<div
className={cn(
'group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300',
'rounded-[10px]',
'group-hover:bg-liquid-lighter group-hover:ring-[1px] group-hover:ring-liquid-darkmain group-hover:ring-inset',
'px-[16px] py-[8px]',
ColorBgVariants[color],
disabled && 'bg-liquid-lighter',
)}
>
{/* Скрытый button */}
<button
className={cn(
'absolute opacity-0 -z-10 h-0 w-0',
'[&:focus-visible+*]:outline-liquid-brightmain',
)}
disabled={disabled}
onClick={() => {
onClick();
}}
/>
{/* Граница при выделении через tab */}
<div
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]",
"rounded-[10px]",
"px-[16px] py-[8px]",
)}
>
{children || text}
</div>
<div
className={cn(
"transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]",
ColorTextVariants[color],
disabled && "text-liquid-light"
)}
>
{children || text}
</div>
</div>
</label>
);
{/* Граница при выделении через tab */}
<div
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]',
'rounded-[10px]',
'px-[16px] py-[8px]',
)}
>
{children || text}
</div>
<div
className={cn(
'transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]',
ColorTextVariants[color],
disabled && 'text-liquid-light',
)}
>
{children || text}
</div>
</div>
</label>
);
};

View File

@@ -1,70 +1,72 @@
import React from "react";
import { cn } from "../../lib/cn";
import React from 'react';
import { cn } from '../../lib/cn';
interface ButtonProps {
disabled?: boolean;
text?: string;
className?: string;
onClick: () => void;
children?: React.ReactNode;
disabled?: boolean;
text?: string;
className?: string;
onClick: () => void;
children?: React.ReactNode;
}
export const ReverseButton: React.FC<ButtonProps> = ({
disabled = false,
text = "",
className,
onClick,
children,
disabled = false,
text = '',
className,
onClick,
children,
}) => {
return (
<label
className={cn(
"grid relative cursor-pointer select-none group w-fit box-border",
disabled && "pointer-events-none",
className
)}
>
{/* Основной контейнер, */}
<div
className={cn(
"group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300",
"rounded-[10px]",
"group-hover:bg-liquid-darkmain ",
"px-[16px] py-[8px]",
"bg-liquid-lighter ring-[1px] ring-liquid-darkmain ring-inset",
disabled && "bg-liquid-lighter"
)}
>
{/* Скрытый button */}
<button
className={cn(
"absolute opacity-0 -z-10 h-0 w-0",
"[&:focus-visible+*]:outline-liquid-brightmain",
)}
disabled={disabled}
onClick={() => { onClick() }}
/>
return (
<label
className={cn(
'grid relative cursor-pointer select-none group w-fit box-border',
disabled && 'pointer-events-none',
className,
)}
>
{/* Основной контейнер, */}
<div
className={cn(
'group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300',
'rounded-[10px]',
'group-hover:bg-liquid-darkmain ',
'px-[16px] py-[8px]',
'bg-liquid-lighter ring-[1px] ring-liquid-darkmain ring-inset',
disabled && 'bg-liquid-lighter',
)}
>
{/* Скрытый button */}
<button
className={cn(
'absolute opacity-0 -z-10 h-0 w-0',
'[&:focus-visible+*]:outline-liquid-brightmain',
)}
disabled={disabled}
onClick={() => {
onClick();
}}
/>
{/* Граница при выделении через tab */}
<div
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]",
"rounded-[10px]",
"px-[16px] py-[8px]",
)}
>
{children || text}
</div>
<div
className={cn(
"transition-all duration-300 text-liquid-brightmain text-[18px] font-bold p-0 m-0 leading-[23px]",
"group-hover:text-liquid-white ",
disabled && "text-liquid-light"
)}
>
{children || text}
</div>
</div>
</label>
);
{/* Граница при выделении через tab */}
<div
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]',
'rounded-[10px]',
'px-[16px] py-[8px]',
)}
>
{children || text}
</div>
<div
className={cn(
'transition-all duration-300 text-liquid-brightmain text-[18px] font-bold p-0 m-0 leading-[23px]',
'group-hover:text-liquid-white ',
disabled && 'text-liquid-light',
)}
>
{children || text}
</div>
</div>
</label>
);
};

View File

@@ -1,69 +1,70 @@
import React from "react";
import { cn } from "../../lib/cn";
import React from 'react';
import { cn } from '../../lib/cn';
interface ButtonProps {
disabled?: boolean;
text?: string;
className?: string;
onClick: () => void;
children?: React.ReactNode;
disabled?: boolean;
text?: string;
className?: string;
onClick: () => void;
children?: React.ReactNode;
}
export const SecondaryButton: React.FC<ButtonProps> = ({
disabled = false,
text = "",
className,
onClick,
children,
disabled = false,
text = '',
className,
onClick,
children,
}) => {
return (
<label
className={cn(
"grid relative cursor-pointer select-none group w-fit box-border",
disabled && "pointer-events-none",
className
)}
>
{/* Основной контейнер, */}
<div
className={cn(
"group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300",
"rounded-[10px]",
"group-hover:bg-liquid-background",
"px-[16px] py-[8px]",
"bg-liquid-lighter"
)}
>
{/* Скрытый button */}
<button
className={cn(
"absolute opacity-0 -z-10 h-0 w-0",
"[&:focus-visible+*]:outline-liquid-brightmain",
)}
disabled={disabled}
onClick={() => { onClick() }}
/>
return (
<label
className={cn(
'grid relative cursor-pointer select-none group w-fit box-border',
disabled && 'pointer-events-none',
className,
)}
>
{/* Основной контейнер, */}
<div
className={cn(
'group-active:scale-90 flex items-center justify-center box-border z-10 relative transition-all duration-300',
'rounded-[10px]',
'group-hover:bg-liquid-background',
'px-[16px] py-[8px]',
'bg-liquid-lighter',
)}
>
{/* Скрытый button */}
<button
className={cn(
'absolute opacity-0 -z-10 h-0 w-0',
'[&:focus-visible+*]:outline-liquid-brightmain',
)}
disabled={disabled}
onClick={() => {
onClick();
}}
/>
{/* Граница при выделении через tab */}
<div
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]",
"rounded-[10px]",
"px-[16px] py-[8px]",
)}
>
{children || text}
</div>
<div
className={cn(
"transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]",
disabled && "text-liquid-light"
)}
>
{children || text}
</div>
</div>
</label>
);
{/* Граница при выделении через tab */}
<div
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]',
'rounded-[10px]',
'px-[16px] py-[8px]',
)}
>
{children || text}
</div>
<div
className={cn(
'transition-all duration-300 text-liquid-white text-[18px] font-bold p-0 m-0 leading-[23px]',
disabled && 'text-liquid-light',
)}
>
{children || text}
</div>
</div>
</label>
);
};

View File

@@ -1,168 +1,167 @@
import React from "react";
import { cn } from "../../lib/cn";
import { motion } from "framer-motion";
import React from 'react';
import { cn } from '../../lib/cn';
import { motion } from 'framer-motion';
const pathVariants = {
hidden: {
opacity: 0,
pathLength: 0,
},
visible: {
opacity: 1,
pathLength: 1,
transition: {
delay: 0.15,
duration: 0.4,
ease: "easeInOut",
hidden: {
opacity: 0,
pathLength: 0,
},
visible: {
opacity: 1,
pathLength: 1,
transition: {
delay: 0.15,
duration: 0.4,
ease: 'easeInOut',
},
},
},
};
const sizeVariants = {
sm: "h-4 w-4",
md: "h-5 w-5",
lg: "h-6 w-6",
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6',
};
const colorsVariants = {
default: "bg-default",
primary: "bg-liquid-brightmain",
secondary: "bg-liquid-darkmain",
success: "bg-liquid-green",
warning: "bg-liquid-orange",
danger: "bg-liquid-red",
default: 'bg-default',
primary: 'bg-liquid-brightmain',
secondary: 'bg-liquid-darkmain',
success: 'bg-liquid-green',
warning: 'bg-liquid-orange',
danger: 'bg-liquid-red',
};
const borderColorsVariants = {
default: "border-default",
primary: "border-liquid-brightmain",
secondary: "border-liquid-darkmain",
success: "border-liquid-green",
warning: "border-liquid-orange",
danger: "border-liquid-red",
default: 'border-default',
primary: 'border-liquid-brightmain',
secondary: 'border-liquid-darkmain',
success: 'border-liquid-green',
warning: 'border-liquid-orange',
danger: 'border-liquid-red',
};
const focuseOutlineVariants = {
default: "[&:focus-visible+*]:outline-default",
primary: "[&:focus-visible+*]:outline-liquid-brightmain",
secondary: "[&:focus-visible+*]:outline-liquid-darkmain",
success: "[&:focus-visible+*]:outline-liquid-green",
warning: "[&:focus-visible+*]:outline-liquid-orange",
danger: "[&:focus-visible+*]:outline-liquid-red",
default: '[&:focus-visible+*]:outline-default',
primary: '[&:focus-visible+*]:outline-liquid-brightmain',
secondary: '[&:focus-visible+*]:outline-liquid-darkmain',
success: '[&:focus-visible+*]:outline-liquid-green',
warning: '[&:focus-visible+*]:outline-liquid-orange',
danger: '[&:focus-visible+*]:outline-liquid-red',
};
const radiusVraiants = {
none: "",
sm: "rounded-[3.5px]",
md: "rounded-[5px]",
lg: "rounded-[7px]",
full: "rounded-full",
none: '',
sm: 'rounded-[3.5px]',
md: 'rounded-[5px]',
lg: 'rounded-[7px]',
full: 'rounded-full',
};
interface CheckboxProps {
size?: "sm" | "md" | "lg";
radius?: "none" | "sm" | "md" | "lg" | "full";
disabled?: boolean;
color?:
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger";
label?: string;
variant?: "default" | "label";
className?: string;
defaultState?: boolean;
onChange: (state: boolean) => void;
size?: 'sm' | 'md' | 'lg';
radius?: 'none' | 'sm' | 'md' | 'lg' | 'full';
disabled?: boolean;
color?:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'danger';
label?: string;
variant?: 'default' | 'label';
className?: string;
defaultState?: boolean;
onChange: (state: boolean) => void;
}
export const Checkbox: React.FC<CheckboxProps> = ({
size = "md",
radius = "md",
disabled = false,
color = "primary",
label = "",
variant = "label",
className,
onChange,
defaultState = false,
size = 'md',
radius = 'md',
disabled = false,
color = 'primary',
label = '',
variant = 'label',
className,
onChange,
defaultState = false,
}) => {
const [active, setActive] = React.useState<boolean>(defaultState);
const [active, setActive] = React.useState<boolean>(defaultState);
React.useEffect(() => onChange(active), [active]);
React.useEffect(() => onChange(active), [active]);
return (
<motion.label
className={cn(
variant == "label" && "grid-cols-[auto_1fr] items-center gap-2",
"grid relative cursor-pointer p-2 select-none group ",
className,
disabled && "pointer-events-none opacity-50",
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"
/>
return (
<motion.label
className={cn(
variant == 'label' && 'grid-cols-[auto_1fr] items-center gap-2',
'grid relative cursor-pointer p-2 select-none group ',
className,
disabled && 'pointer-events-none opacity-50',
variant == 'default' && '',
)}
</svg>
</span>
</div>
{variant == "label" && (
<div className="select-none text-layout-foeground transition-all duration-200">
{label}
</div>
)}
</motion.label>
);
>
<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>
{variant == 'label' && (
<div className="select-none text-layout-foeground transition-all duration-200">
{label}
</div>
)}
</motion.label>
);
};

View File

@@ -1,7 +1,7 @@
import React from "react";
import { cn } from "../../lib/cn";
import { checkMark, chevroneDropDownList } from "../../assets/icons/input";
import { useClickOutside } from "../../hooks/useClickOutside";
import React from 'react';
import { cn } from '../../lib/cn';
import { checkMark, chevroneDropDownList } from '../../assets/icons/input';
import { useClickOutside } from '../../hooks/useClickOutside';
export interface DropDownListItem {
text: string;
@@ -18,15 +18,16 @@ interface DropDownListProps {
export const DropDownList: React.FC<DropDownListProps> = ({
// disabled = false,
className = "",
className = '',
onChange,
defaultState,
items = [{ text: "", value: "" }],
items = [{ text: '', value: '' }],
}) => {
if (items.length == 0)
items.push({ text: "", value: "" });
if (items.length == 0) items.push({ text: '', value: '' });
const [value, setValue] = React.useState<DropDownListItem>(defaultState != undefined ? defaultState : items[0]);
const [value, setValue] = React.useState<DropDownListItem>(
defaultState != undefined ? defaultState : items[0],
);
const [active, setActive] = React.useState<boolean>(false);
React.useEffect(() => onChange(value.value), [value]);
@@ -37,67 +38,73 @@ export const DropDownList: React.FC<DropDownListProps> = ({
setActive(false);
});
return (
<div className={cn(
"relative",
className
)}
ref={ref}
>
<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"
)}
<div className={cn('relative', className)} ref={ref}>
<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={() => {
setActive(!active);
}
}>
}}
>
{value.text}
</div>
<img src={chevroneDropDownList}
className={cn(" absolute right-[16px] h-[24px] w-[24px] top-[8.5px] rotate-0 transition-all duration-300 pointer-events-none",
active && " rotate-180"
)} />
<img
src={chevroneDropDownList}
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
className={cn(" absolute rounded-[10px] bg-liquid-lighter w-[180px] left-0 top-[48px] z-50 transition-all duration-300",
"grid overflow-hidden",
active ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}>
className={cn(
' absolute rounded-[10px] bg-liquid-lighter w-[180px] left-0 top-[48px] z-50 transition-all duration-300',
'grid overflow-hidden',
active
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0',
)}
>
<div className=" overflow-hidden p-[8px]">
<div className={cn(
" overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ",
)}>
{items.map((v, i) =>
<div
className={cn(
' overflow-y-scroll max-h-[200px] thin-scrollbar pr-[8px] ',
)}
>
{items.map((v, i) => (
<div
key={i}
className={cn(
"cursor-pointer h-[36px] relative transition-all duration-300",
i + 1 != items.length && "border-b-liquid-light border-b-[1px]",
"text-[16px] font-medium cursor-pointer select-none flex items-center pl-[8px]",
"hover:bg-liquid-background",
"first:rounded-t-[6px] last:rounded-b-[6px]"
'cursor-pointer h-[36px] relative transition-all duration-300',
i + 1 != items.length &&
'border-b-liquid-light border-b-[1px]',
'text-[16px] font-medium cursor-pointer select-none flex items-center pl-[8px]',
'hover:bg-liquid-background',
'first:rounded-t-[6px] last:rounded-b-[6px]',
)}
onClick={() => {
setValue(v);
setActive(false);
}}>
}}
>
{v.text}
{v.text == value.text &&
<img src={checkMark} className=" absolute right-[8px]" />
}
{v.text == value.text && (
<img
src={checkMark}
className=" absolute right-[8px]"
/>
)}
</div>
)}
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,89 +1,95 @@
import React from "react";
import { cn } from "../../lib/cn";
import { eyeClosed, eyeOpen } from "../../assets/icons/input";
import React from 'react';
import { cn } from '../../lib/cn';
import { eyeClosed, eyeOpen } from '../../assets/icons/input';
interface inputProps {
name?: string;
type: "text" | "email" | "password" | "first_name" | "number";
error?: string;
disabled?: boolean;
required?: boolean;
label?: string;
placeholder?: string;
className?: string;
onChange: (state: string) => void;
defaultState?: string;
autocomplete?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
name?: string;
type: 'text' | 'email' | 'password' | 'first_name' | 'number';
error?: string;
disabled?: boolean;
required?: boolean;
label?: string;
placeholder?: string;
className?: string;
onChange: (state: string) => void;
defaultState?: string;
autocomplete?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
}
export const Input: React.FC<inputProps> = ({
type = "text",
error = "",
// disabled = false,
// required = false,
label = "",
placeholder = "",
className = "",
onChange,
defaultState = "",
name = "",
autocomplete = "",
onKeyDown,
type = 'text',
error = '',
// disabled = false,
// required = false,
label = '',
placeholder = '',
className = '',
onChange,
defaultState = '',
name = '',
autocomplete = '',
onKeyDown,
}) => {
const [value, setValue] = React.useState<string>(defaultState);
const [visible, setVIsible] = React.useState<boolean>(type != "password");
const [value, setValue] = React.useState<string>(defaultState);
const [visible, setVIsible] = React.useState<boolean>(type != 'password');
React.useEffect(() => onChange(value), [value]);
React.useEffect(() => setValue(defaultState), [defaultState]);
React.useEffect(() => onChange(value), [value]);
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>
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 className={cn("text-liquid-red text-[14px] h-[18px] text-right mt-[5px]",
error == "" && "h-0 mt-0"
)}>
{error}
</div>
</div>
);
<div
className={cn(
'text-liquid-red text-[14px] h-[18px] text-right mt-[5px]',
error == '' && 'h-0 mt-0',
)}
>
{error}
</div>
</div>
);
};

View File

@@ -1,78 +1,80 @@
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "../../lib/cn";
import { useClickOutside } from "../../hooks/useClickOutside";
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '../../lib/cn';
import { useClickOutside } from '../../hooks/useClickOutside';
type ModalBackdrop = "opaque" | "blur";
type ModalBackdrop = 'opaque' | 'blur';
interface ModalProps {
className?: string;
children?: React.ReactNode;
backdrop?: ModalBackdrop;
open: boolean;
defaultOpen?: boolean;
onOpenChange: (state: boolean) => void;
className?: string;
children?: React.ReactNode;
backdrop?: ModalBackdrop;
open: boolean;
defaultOpen?: boolean;
onOpenChange: (state: boolean) => void;
}
const modalbgVariants = {
closed: { opacity: 0 },
open: { opacity: 1 },
closed: { opacity: 0 },
open: { opacity: 1 },
};
const modalVariants = {
closed: { opacity: 0, scale: 0.9 },
open: { opacity: 1, scale: 1 },
closed: { opacity: 0, scale: 0.9 },
open: { opacity: 1, scale: 1 },
};
export const Modal: React.FC<ModalProps> = ({
children,
open,
backdrop,
className,
onOpenChange,
children,
open,
backdrop,
className,
onOpenChange,
}) => {
const ref = React.useRef<HTMLDivElement>(null);
const ref = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => {
onOpenChange(false);
});
useClickOutside(ref, () => {
onOpenChange(false);
});
return (
<div>
<AnimatePresence>
{open && (
<motion.div
initial={modalbgVariants.closed}
animate={modalbgVariants.open}
exit={modalbgVariants.closed}
transition={{ duration: 0.15 }}
className={cn(
" fixed top-0 left-0 h-svh w-svw backdrop-filter transition-all z-50",
backdrop == "blur" && open && "backdrop-blur-sm",
backdrop == "opaque" && open && "bg-[#00000055] pointer-events-none",
)}
></motion.div>
)}
</AnimatePresence>
<div className="fixed top-0 left-0 h-svh w-svw flex items-center justify-center pointer-events-none z-50">
<AnimatePresence>
{open && (
<motion.div
ref={ref}
className={cn(
"h-fit w-fit rounded-md pointer-events-auto",
className
)}
initial={modalVariants.closed}
animate={modalVariants.open}
exit={modalVariants.closed}
transition={{ duration: 0.15 }}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
return (
<div>
<AnimatePresence>
{open && (
<motion.div
initial={modalbgVariants.closed}
animate={modalbgVariants.open}
exit={modalbgVariants.closed}
transition={{ duration: 0.15 }}
className={cn(
' fixed top-0 left-0 h-svh w-svw backdrop-filter transition-all z-50',
backdrop == 'blur' && open && 'backdrop-blur-sm',
backdrop == 'opaque' &&
open &&
'bg-[#00000055] pointer-events-none',
)}
></motion.div>
)}
</AnimatePresence>
<div className="fixed top-0 left-0 h-svh w-svw flex items-center justify-center pointer-events-none z-50">
<AnimatePresence>
{open && (
<motion.div
ref={ref}
className={cn(
'h-fit w-fit rounded-md pointer-events-auto',
className,
)}
initial={modalVariants.closed}
animate={modalVariants.open}
exit={modalVariants.closed}
transition={{ duration: 0.15 }}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
};

View File

@@ -1,187 +1,191 @@
import React from "react";
import { cn } from "../../lib/cn";
import React from 'react';
import { cn } from '../../lib/cn';
/* Варианты размера контейнера */
const sizeVariants = {
sm: "h-6 w-10",
md: "h-7 w-12",
lg: "h-8 w-14",
sm: 'h-6 w-10',
md: 'h-7 w-12',
lg: 'h-8 w-14',
};
/* Варианты для скользящего шарика */
const switchVariants = {
size: {
sm: "h-4 w-4",
md: "h-5 w-5",
lg: "h-6 w-6",
},
activeSize: {
sm: "group-active:w-5",
md: "group-active:w-6",
lg: "group-active:w-7",
},
iconSize: {
sm: "h-3 w-3",
md: "h-[0.875rem] w-[0.875rem]",
lg: "h-4 w-4",
},
size: {
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6',
},
activeSize: {
sm: 'group-active:w-5',
md: 'group-active:w-6',
lg: 'group-active:w-7',
},
iconSize: {
sm: 'h-3 w-3',
md: 'h-[0.875rem] w-[0.875rem]',
lg: 'h-4 w-4',
},
};
const colorsVariants = {
default: "bg-default",
primary: "bg-liquid-brightmain",
secondary: "bg-liquid-darkmain",
success: "bg-liquid-green",
warning: "bg-liquid-orange",
danger: "bg-liquid-red",
default: 'bg-default',
primary: 'bg-liquid-brightmain',
secondary: 'bg-liquid-darkmain',
success: 'bg-liquid-green',
warning: 'bg-liquid-orange',
danger: 'bg-liquid-red',
};
const focuseOutlineVariants = {
default: "[&:focus-visible+*]:outline-default",
primary: "[&:focus-visible+*]:outline-liquid-brightmain",
secondary: "[&:focus-visible+*]:outline-liquid-darkmain",
success: "[&:focus-visible+*]:outline-liquid-green",
warning: "[&:focus-visible+*]:outline-liquid-orange",
danger: "[&:focus-visible+*]:outline-liquid-red",
default: '[&:focus-visible+*]:outline-default',
primary: '[&:focus-visible+*]:outline-liquid-brightmain',
secondary: '[&:focus-visible+*]:outline-liquid-darkmain',
success: '[&:focus-visible+*]:outline-liquid-green',
warning: '[&:focus-visible+*]:outline-liquid-orange',
danger: '[&:focus-visible+*]:outline-liquid-red',
};
/**
* Иконка солнца
*/
const sun = (
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"
fill="#292D32"
/>
<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"
fill="#292D32"
/>
</svg>
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"
fill="#292D32"
/>
<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"
fill="#292D32"
/>
</svg>
);
/**
* Иконка луны
*/
const moon = (
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"
fill="#292D32"
/>
</svg>
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"
fill="#292D32"
/>
</svg>
);
interface SwitchProps {
size?: "sm" | "md" | "lg";
disabled?: boolean;
color?:
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger";
label?: string;
variant?: "default" | "label" | "icon" | "theme";
className?: string;
defaultState?: boolean;
onChange: (state: boolean) => void;
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
color?:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'danger';
label?: string;
variant?: 'default' | 'label' | 'icon' | 'theme';
className?: string;
defaultState?: boolean;
onChange: (state: boolean) => void;
}
export const Switch: React.FC<SwitchProps> = ({
size = "sm",
disabled = false,
color = "primary",
label = "",
variant = "default",
className,
onChange,
defaultState = false,
size = 'sm',
disabled = false,
color = 'primary',
label = '',
variant = 'default',
className,
onChange,
defaultState = false,
}) => {
const [active, setActive] = React.useState<boolean>(defaultState);
const [active, setActive] = React.useState<boolean>(defaultState);
React.useEffect(() => onChange(active), [active]);
React.useEffect(() => onChange(active), [active]);
return (
<label
className={cn(
variant == "label" && "grid-cols-[auto_1fr] items-center gap-2",
"grid relative cursor-pointer p-2 select-none group",
disabled && "pointer-events-none opacity-50",
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)]"
)}
return (
<label
className={cn(
variant == 'label' && 'grid-cols-[auto_1fr] items-center gap-2',
'grid relative cursor-pointer p-2 select-none group',
disabled && 'pointer-events-none opacity-50',
className,
)}
>
{variant == "theme" && (
<>
<div
{/* Основной контейнер, */}
<div
className={cn(
"absolute transition-all duration-300",
switchVariants.iconSize[size],
active ? "opacity-100 scale-100" : "opacity-0 scale-50"
' flex items-center justify-center box-border z-10 relative transition-all duration-300 rounded-full',
sizeVariants[size],
active ? colorsVariants[color] : 'bg-default-200',
)}
>
{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>
>
{/* Скрытый checkbox */}
<input
className={cn(
'absolute opacity-0 -z-10 h-0 w-0',
focuseOutlineVariants[color],
)}
disabled={disabled}
type="checkbox"
onChange={() => {
setActive(!active);
}}
/>
{variant == "label" && (
<div className="select-none text-layout-foreground transition-all duration-200">
{label}
</div>
)}
</label>
);
<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
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>
);
};

View File

@@ -1,14 +1,14 @@
export default {
liquid: {
brightmain: "var(--color-liquid-brightmain)",
darkmain: "var(--color-liquid-darkmain)",
darker: "var(--color-liquid-darker)",
background: "var(--color-liquid-background)",
lighter: "var(--color-liquid-lighter)",
white: "var(--color-liquid-white)",
red: "var(--color-liquid-red)",
green: "var(--color-liquid-green)",
light: "var(--color-liquid-light)",
orange: "var(--color-liquid-orange)",
}
liquid: {
brightmain: 'var(--color-liquid-brightmain)',
darkmain: 'var(--color-liquid-darkmain)',
darker: 'var(--color-liquid-darker)',
background: 'var(--color-liquid-background)',
lighter: 'var(--color-liquid-lighter)',
white: 'var(--color-liquid-white)',
red: 'var(--color-liquid-red)',
green: 'var(--color-liquid-green)',
light: 'var(--color-liquid-light)',
orange: 'var(--color-liquid-orange)',
},
};

View File

@@ -1,18 +1,21 @@
import React from "react";
import React from 'react';
export const useClickOutside = (ref: React.RefObject<any>, onClickOutside: () => void) => {
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(event.target)) {
onClickOutside();
}
}
export const useClickOutside = (
ref: React.RefObject<any>,
onClickOutside: () => void,
) => {
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(event.target)) {
onClickOutside();
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchstart", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchstart", handleClickOutside);
}
}, [ref, onClickOutside]);
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
};
}, [ref, onClickOutside]);
};

View File

@@ -1,6 +1,6 @@
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
return twMerge(clsx(inputs));
}

View File

@@ -1,16 +1,16 @@
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./styles/index.css";
import "./styles/palette/theme-dark.css";
import "./styles/palette/theme-light.css";
import { BrowserRouter } from "react-router-dom";
import { Provider } from "react-redux";
import { store } from "./redux/store";
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './styles/index.css';
import './styles/palette/theme-dark.css';
import './styles/palette/theme-light.css';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { store } from './redux/store';
createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<Provider store={store}>
<App />
</Provider>
</BrowserRouter>
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<Provider store={store}>
<App />
</Provider>
</BrowserRouter>,
);

View File

@@ -1,103 +1,133 @@
import { Route, Routes, useNavigate } from "react-router-dom";
import { Route, Routes, useNavigate } from 'react-router-dom';
import Header from '../views/articleeditor/Header';
import MarkdownEditor from "../views/articleeditor/Editor";
import { useState } from "react";
import { PrimaryButton } from "../components/button/PrimaryButton";
import MarkdownPreview from "../views/articleeditor/MarckDownPreview";
import { Input } from "../components/input/Input";
import MarkdownEditor from '../views/articleeditor/Editor';
import { useState } from 'react';
import { PrimaryButton } from '../components/button/PrimaryButton';
import MarkdownPreview from '../views/articleeditor/MarckDownPreview';
import { Input } from '../components/input/Input';
const ArticleEditor = () => {
const [code, setCode] = useState<string>("");
const [name, setName] = useState<string>("");
const [code, setCode] = useState<string>('');
const [name, setName] = useState<string>('');
const navigate = useNavigate();
const [tagInput, setTagInput] = useState<string>("");
const [tagInput, setTagInput] = useState<string>('');
const [tags, setTags] = useState<string[]>([]);
const addTag = () => {
const newTag = tagInput.trim();
if (newTag && !tags.includes(newTag)) {
setTags([...tags, newTag]);
setTagInput("");
setTagInput('');
}
};
const removeTag = (tagToRemove: string) => {
setTags(tags.filter(tag => tag !== tagToRemove));
setTags(tags.filter((tag) => tag !== tagToRemove));
};
return (
<div className="h-screen grid grid-rows-[60px,1fr]">
<Routes>
<Route path="editor" element={<Header backUrl="/article/create" />} />
<Route
path="editor"
element={<Header backUrl="/article/create" />}
/>
<Route path="*" element={<Header backUrl="/home/articles" />} />
</Routes>
<Routes>
<Route path="editor" element={<MarkdownEditor onChange={setCode} />} />
<Route path="*" element={
<div className="text-liquid-white">
<div className="text-[40px] font-bold">Создание статьи</div>
<PrimaryButton onClick={() => {
console.log({
name: name,
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]" />
<Route
path="editor"
element={<MarkdownEditor onChange={setCode} />}
/>
<Route
path="*"
element={
<div className="text-liquid-white">
<div className="text-[40px] font-bold">
Создание статьи
</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>
))}
<PrimaryButton
onClick={() => {
console.log({
name: name,
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 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>
<PrimaryButton
onClick={() => navigate('editor')}
text="Редактировать текст"
className="mt-[20px]"
/>
<MarkdownPreview
content={code}
className="bg-transparent border-liquid-lighter border-[3px] rounder-[20px] mt-[20px]"
/>
</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>
</div>
);

View File

@@ -1,60 +1,75 @@
// import React from "react";
import { Route, Routes } from "react-router-dom";
import Login from "../views/home/auth/Login";
import Register from "../views/home/auth/Register";
import Menu from "../views/home/menu/Menu";
import { useAppDispatch, useAppSelector } from "../redux/hooks";
import { useEffect } from "react";
import { fetchWhoAmI, logout } from "../redux/slices/auth";
import Missions from "../views/home/missions/Missions";
import Articles from "../views/home/articles/Articles";
import Groups from "../views/home/groups/Groups";
import Contests from "../views/home/contests/Contests";
import { PrimaryButton } from "../components/button/PrimaryButton";
import Group from "../views/home/groups/Group";
import { Route, Routes } from 'react-router-dom';
import Login from '../views/home/auth/Login';
import Register from '../views/home/auth/Register';
import Menu from '../views/home/menu/Menu';
import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { useEffect } from 'react';
import { fetchWhoAmI, logout } from '../redux/slices/auth';
import Missions from '../views/home/missions/Missions';
import Articles from '../views/home/articles/Articles';
import Groups from '../views/home/groups/Groups';
import Contests from '../views/home/contests/Contests';
import { PrimaryButton } from '../components/button/PrimaryButton';
import Group from '../views/home/groups/Group';
const Home = () => {
const name = useAppSelector((state) => state.auth.username);
const jwt = useAppSelector((state) => state.auth.jwt);
const dispatch = useAppDispatch();
const name = useAppSelector((state) => state.auth.username);
const jwt = useAppSelector((state) => state.auth.jwt);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(fetchWhoAmI());
}, [jwt])
useEffect(() => {
dispatch(fetchWhoAmI());
}, [jwt]);
return (
<div className="w-full bg-liquid-background grid grid-cols-[250px,1fr,250px] divide-x-[1px] divide-liquid-lighter">
<div className="min-h-screen">
<Menu />
</div>
<div className="">
<Routes>
<Route path="login" element={<Login />} />
<Route path="account" element={<Login />} />
<Route path="register" element={<Register />} />
<Route path="missions/*" element={<Missions/>} />
<Route path="articles/*" element={<Articles/>} />
<Route path="group/:groupId" element={<Group/>} />
<Route path="groups/*" element={<Groups/>} />
<Route path="contests/*" element={<Contests/>} />
<Route path="*" element={<>
<p>{jwt}</p>
<PrimaryButton onClick={() => {if (jwt) navigator.clipboard.writeText(jwt);}} text="скопировать токен" className="pt-[20px]"/>
<p className="py-[20px]">{name}</p>
<PrimaryButton onClick={() => {dispatch(logout())}}>выйти</PrimaryButton>
</>
}
/>
</Routes>
</div>
{
<Routes>
<Route path="articles/*" element={<div></div>} />
</Routes>
}
</div>
);
return (
<div className="w-full bg-liquid-background grid grid-cols-[250px,1fr,250px] divide-x-[1px] divide-liquid-lighter">
<div className="min-h-screen">
<Menu />
</div>
<div className="">
<Routes>
<Route path="login" element={<Login />} />
<Route path="account" element={<Login />} />
<Route path="register" element={<Register />} />
<Route path="missions/*" element={<Missions />} />
<Route path="articles/*" element={<Articles />} />
<Route path="group/:groupId" element={<Group />} />
<Route path="groups/*" element={<Groups />} />
<Route path="contests/*" element={<Contests />} />
<Route
path="*"
element={
<>
<p>{jwt}</p>
<PrimaryButton
onClick={() => {
if (jwt)
navigator.clipboard.writeText(jwt);
}}
text="скопировать токен"
className="pt-[20px]"
/>
<p className="py-[20px]">{name}</p>
<PrimaryButton
onClick={() => {
dispatch(logout());
}}
>
выйти
</PrimaryButton>
</>
}
/>
</Routes>
</div>
{
<Routes>
<Route path="articles/*" element={<div></div>} />
</Routes>
}
</div>
);
};
export default Home;

View File

@@ -10,187 +10,191 @@ import Header from '../views/mission/statement/Header';
import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
const Mission = () => {
const dispatch = useAppDispatch();
const dispatch = useAppDispatch();
// Получаем параметры из URL
const { missionId } = useParams<{ missionId: string }>();
const mission = useAppSelector((state) => state.missions.currentMission);
const missionIdNumber = Number(missionId);
if (!missionId || isNaN(missionIdNumber)) {
return <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();
}
// Получаем параметры из URL
const { missionId } = useParams<{ missionId: string }>();
const mission = useAppSelector((state) => state.missions.currentMission);
const missionIdNumber = Number(missionId);
if (!missionId || isNaN(missionIdNumber)) {
return <Navigate to="/home" replace />;
}
}, [submissions]);
const [code, setCode] = useState<string>('');
const [language, setLanguage] = useState<string>('');
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"
const pollingRef = useRef<number | null>(null);
const submissions = useAppSelector(
(state) => state.submin.submitsById[missionIdNumber] || [],
);
const submissionsRef = useRef(submissions);
// 2. Берём первый statement с форматом Html и языком russian
const htmlStatement = mission.statements.find(
(stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Html"
);
const startPolling = () => {
if (pollingRef.current) return;
if (!latexStatement) throw new Error("Не найден блок Latex на русском");
if (!htmlStatement) throw new Error("Не найден блок Html на русском");
pollingRef.current = setInterval(async () => {
dispatch(fetchMySubmitsByMission(missionIdNumber));
// 3. Парсим данные из problem-properties.json
const statementTexts = JSON.parse(latexStatement.statementTexts["problem-properties.json"]);
statementData = {
id: missionIdNumber,
legend: statementTexts.legend,
timeLimit: statementTexts.timeLimit,
output: statementTexts.output,
input: statementTexts.input,
sampleTests: statementTexts.sampleTests,
name: statementTexts.name,
memoryLimit: statementTexts.memoryLimit,
tags: mission.tags,
notes: statementTexts.notes,
html: htmlStatement.statementTexts["problem.html"],
mediaFiles: latexStatement.mediaFiles
const hasWaiting = submissionsRef.current.some(
(s: any) =>
s.solution.status == 'Waiting' ||
s.solution.testerState === 'Waiting',
);
if (!hasWaiting) {
// Всё проверено — стоп
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
}, 5000); // 10 секунд
};
} catch (err) {
console.error("Ошибка парсинга statementTexts:", err);
}
useEffect(() => {
dispatch(fetchMissionById(missionIdNumber));
dispatch(fetchMySubmitsByMission(missionIdNumber));
}, [missionIdNumber]);
useEffect(() => {}, [submissions]);
useEffect(() => {
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, []);
return (
useEffect(() => {
submissionsRef.current = submissions;
<div className="h-screen grid grid-rows-[60px,1fr]">
<div className="">
<Header missionId={missionIdNumber} />
</div>
if (submissions.length) {
const hasWaiting = submissions.some(
(s) =>
s.solution.status === 'Waiting' ||
s.solution.testerState === 'Waiting',
);
<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}
if (hasWaiting) {
startPolling();
}
}
}, [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 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;

View File

@@ -1,188 +1,260 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import axios from "../../axios";
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// Типы данных
interface AuthState {
jwt: string | null;
refreshToken: string | null;
username: string | null;
status: "idle" | "loading" | "successful" | "failed";
error: string | null;
jwt: string | null;
refreshToken: string | null;
username: string | null;
status: 'idle' | 'loading' | 'successful' | 'failed';
error: string | null;
}
// Инициализация состояния
const initialState: AuthState = {
jwt: null,
refreshToken: null,
username: null,
status: "idle",
error: null,
jwt: null,
refreshToken: null,
username: null,
status: 'idle',
error: null,
};
// AsyncThunk: Регистрация
export const registerUser = createAsyncThunk(
"auth/register",
async (
{ username, email, password }: { username: string; email: string; password: string },
{ rejectWithValue }
) => {
try {
const response = await axios.post("/authentication/register", { username, email, password });
return response.data; // { jwt, refreshToken }
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Registration failed");
}
}
'auth/register',
async (
{
username,
email,
password,
}: { username: string; email: string; password: string },
{ rejectWithValue },
) => {
try {
const response = await axios.post('/authentication/register', {
username,
email,
password,
});
return response.data; // { jwt, refreshToken }
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Registration failed',
);
}
},
);
// AsyncThunk: Логин
export const loginUser = createAsyncThunk(
"auth/login",
async (
{ username, password }: { username: string; password: string },
{ rejectWithValue }
) => {
try {
const response = await axios.post("/authentication/login", { username, password });
return response.data; // { jwt, refreshToken }
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Login failed");
}
}
'auth/login',
async (
{ username, password }: { username: string; password: string },
{ rejectWithValue },
) => {
try {
const response = await axios.post('/authentication/login', {
username,
password,
});
return response.data; // { jwt, refreshToken }
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Login failed',
);
}
},
);
// AsyncThunk: Обновление токена
export const refreshToken = createAsyncThunk(
"auth/refresh",
async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => {
try {
const response = await axios.post("/authentication/refresh", { refreshToken });
return response.data; // { username }
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Refresh token failed");
}
}
'auth/refresh',
async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => {
try {
const response = await axios.post('/authentication/refresh', {
refreshToken,
});
return response.data; // { username }
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Refresh token failed',
);
}
},
);
// AsyncThunk: Получение информации о пользователе
export const fetchWhoAmI = createAsyncThunk(
"auth/whoami",
async (_, { rejectWithValue }) => {
try {
const response = await axios.get("/authentication/whoami");
return response.data; // { username }
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch user info");
}
}
'auth/whoami',
async (_, { rejectWithValue }) => {
try {
const response = await axios.get('/authentication/whoami');
return response.data; // { username }
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to fetch user info',
);
}
},
);
// AsyncThunk: Загрузка токенов из localStorage
export const loadTokensFromLocalStorage = createAsyncThunk(
"auth/loadTokens",
async (_, { }) => {
const jwt = localStorage.getItem("jwt");
const refreshToken = localStorage.getItem("refreshToken");
'auth/loadTokens',
async (_, {}) => {
const jwt = localStorage.getItem('jwt');
const refreshToken = localStorage.getItem('refreshToken');
if (jwt && refreshToken) {
axios.defaults.headers.common['Authorization'] = `Bearer ${jwt}`;
return { jwt, refreshToken };
} else {
return { jwt: null, refreshToken: null };
}
}
if (jwt && refreshToken) {
axios.defaults.headers.common['Authorization'] = `Bearer ${jwt}`;
return { jwt, refreshToken };
} else {
return { jwt: null, refreshToken: null };
}
},
);
// Slice
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
logout: (state) => {
state.jwt = null;
state.refreshToken = null;
state.username = null;
state.status = "idle";
state.error = null;
localStorage.removeItem("jwt");
localStorage.removeItem("refreshToken");
delete axios.defaults.headers.common['Authorization'];
name: 'auth',
initialState,
reducers: {
logout: (state) => {
state.jwt = null;
state.refreshToken = null;
state.username = null;
state.status = 'idle';
state.error = null;
localStorage.removeItem('jwt');
localStorage.removeItem('refreshToken');
delete axios.defaults.headers.common['Authorization'];
},
},
},
extraReducers: (builder) => {
// Регистрация
builder.addCase(registerUser.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(registerUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => {
state.status = "successful";
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
localStorage.setItem("jwt", action.payload.jwt);
localStorage.setItem("refreshToken", action.payload.refreshToken);
});
builder.addCase(registerUser.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
state.error = action.payload;
});
extraReducers: (builder) => {
// Регистрация
builder.addCase(registerUser.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(
registerUser.fulfilled,
(
state,
action: PayloadAction<{ jwt: string; refreshToken: string }>,
) => {
state.status = 'successful';
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
axios.defaults.headers.common[
'Authorization'
] = `Bearer ${action.payload.jwt}`;
localStorage.setItem('jwt', action.payload.jwt);
localStorage.setItem(
'refreshToken',
action.payload.refreshToken,
);
},
);
builder.addCase(
registerUser.rejected,
(state, action: PayloadAction<any>) => {
state.status = 'failed';
state.error = action.payload;
},
);
// Логин
builder.addCase(loginUser.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(loginUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => {
state.status = "successful";
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
localStorage.setItem("jwt", action.payload.jwt);
localStorage.setItem("refreshToken", action.payload.refreshToken);
});
builder.addCase(loginUser.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
state.error = action.payload;
});
// Логин
builder.addCase(loginUser.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(
loginUser.fulfilled,
(
state,
action: PayloadAction<{ jwt: string; refreshToken: string }>,
) => {
state.status = 'successful';
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
axios.defaults.headers.common[
'Authorization'
] = `Bearer ${action.payload.jwt}`;
localStorage.setItem('jwt', action.payload.jwt);
localStorage.setItem(
'refreshToken',
action.payload.refreshToken,
);
},
);
builder.addCase(
loginUser.rejected,
(state, action: PayloadAction<any>) => {
state.status = 'failed';
state.error = action.payload;
},
);
// Обновление токена
builder.addCase(refreshToken.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(refreshToken.fulfilled, (state, action: PayloadAction<{ username: string }>) => {
state.status = "successful";
state.username = action.payload.username;
});
builder.addCase(refreshToken.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
state.error = action.payload;
});
// Обновление токена
builder.addCase(refreshToken.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(
refreshToken.fulfilled,
(state, action: PayloadAction<{ username: string }>) => {
state.status = 'successful';
state.username = action.payload.username;
},
);
builder.addCase(
refreshToken.rejected,
(state, action: PayloadAction<any>) => {
state.status = 'failed';
state.error = action.payload;
},
);
// Получение информации о пользователе
builder.addCase(fetchWhoAmI.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(fetchWhoAmI.fulfilled, (state, action: PayloadAction<{ username: string }>) => {
state.status = "successful";
state.username = action.payload.username;
});
builder.addCase(fetchWhoAmI.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
state.error = action.payload;
});
// Получение информации о пользователе
builder.addCase(fetchWhoAmI.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(
fetchWhoAmI.fulfilled,
(state, action: PayloadAction<{ username: string }>) => {
state.status = 'successful';
state.username = action.payload.username;
},
);
builder.addCase(
fetchWhoAmI.rejected,
(state, action: PayloadAction<any>) => {
state.status = 'failed';
state.error = action.payload;
},
);
// Загрузка токенов из localStorage
builder.addCase(loadTokensFromLocalStorage.fulfilled, (state, action: PayloadAction<{ jwt: string | null; refreshToken: string | null }>) => {
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
if (action.payload.jwt) {
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
}
});
},
// Загрузка токенов из localStorage
builder.addCase(
loadTokensFromLocalStorage.fulfilled,
(
state,
action: PayloadAction<{
jwt: string | null;
refreshToken: string | null;
}>,
) => {
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
if (action.payload.jwt) {
axios.defaults.headers.common[
'Authorization'
] = `Bearer ${action.payload.jwt}`;
}
},
);
},
});
export const { logout } = authSlice.actions;

View File

@@ -1,58 +1,58 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import axios from "../../axios";
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// =====================
// Типы
// =====================
export interface Mission {
missionId: number;
name: string;
sortOrder: number;
missionId: number;
name: string;
sortOrder: number;
}
export interface Member {
userId: number;
username: string;
role: string;
userId: number;
username: string;
role: string;
}
export interface Contest {
id: number;
name: string;
description: string;
scheduleType: string;
startsAt: string;
endsAt: string;
availableFrom: string | null;
availableUntil: string | null;
attemptDurationMinutes: number | null;
groupId: number | null;
groupName: string | null;
missions: Mission[];
articles: any[];
members: Member[];
id: number;
name: string;
description: string;
scheduleType: string;
startsAt: string;
endsAt: string;
availableFrom: string | null;
availableUntil: string | null;
attemptDurationMinutes: number | null;
groupId: number | null;
groupName: string | null;
missions: Mission[];
articles: any[];
members: Member[];
}
interface ContestsResponse {
hasNextPage: boolean;
contests: Contest[];
hasNextPage: boolean;
contests: Contest[];
}
export interface CreateContestBody {
name: string;
description: string;
scheduleType: "FixedWindow" | "Flexible";
startsAt: string;
endsAt: string;
availableFrom: string | null;
availableUntil: string | null;
attemptDurationMinutes: number | null;
groupId: number | null;
missionIds: number[];
articleIds: number[];
participantIds: number[];
organizerIds: number[];
name: string;
description: string;
scheduleType: 'FixedWindow' | 'Flexible';
startsAt: string;
endsAt: string;
availableFrom: string | null;
availableUntil: string | null;
attemptDurationMinutes: number | null;
groupId: number | null;
missionIds: number[];
articleIds: number[];
participantIds: number[];
organizerIds: number[];
}
// =====================
@@ -60,19 +60,19 @@ export interface CreateContestBody {
// =====================
interface ContestsState {
contests: Contest[];
selectedContest: Contest | null;
hasNextPage: boolean;
status: "idle" | "loading" | "successful" | "failed";
error: string | null;
contests: Contest[];
selectedContest: Contest | null;
hasNextPage: boolean;
status: 'idle' | 'loading' | 'successful' | 'failed';
error: string | null;
}
const initialState: ContestsState = {
contests: [],
selectedContest: null,
hasNextPage: false,
status: "idle",
error: null,
contests: [],
selectedContest: null,
hasNextPage: false,
status: 'idle',
error: null,
};
// =====================
@@ -81,47 +81,60 @@ const initialState: ContestsState = {
// Получение списка контестов
export const fetchContests = createAsyncThunk(
"contests/fetchAll",
async (
params: { page?: number; pageSize?: number; groupId?: number | null } = {},
{ rejectWithValue }
) => {
try {
const { page = 0, pageSize = 10, groupId } = params;
const response = await axios.get<ContestsResponse>("/contests", {
params: { page, pageSize, groupId },
});
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch contests");
}
}
'contests/fetchAll',
async (
params: {
page?: number;
pageSize?: number;
groupId?: number | null;
} = {},
{ rejectWithValue },
) => {
try {
const { page = 0, pageSize = 10, groupId } = params;
const response = await axios.get<ContestsResponse>('/contests', {
params: { page, pageSize, groupId },
});
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to fetch contests',
);
}
},
);
// Получение одного контеста по ID
export const fetchContestById = createAsyncThunk(
"contests/fetchById",
async (id: number, { rejectWithValue }) => {
try {
const response = await axios.get<Contest>(`/contests/${id}`);
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch contest");
}
}
'contests/fetchById',
async (id: number, { rejectWithValue }) => {
try {
const response = await axios.get<Contest>(`/contests/${id}`);
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to fetch contest',
);
}
},
);
// Создание нового контеста
export const createContest = createAsyncThunk(
"contests/create",
async (contestData: CreateContestBody, { rejectWithValue }) => {
try {
const response = await axios.post<Contest>("/contests", contestData);
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to create contest");
}
}
'contests/create',
async (contestData: CreateContestBody, { rejectWithValue }) => {
try {
const response = await axios.post<Contest>(
'/contests',
contestData,
);
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to create contest',
);
}
},
);
// =====================
@@ -129,57 +142,75 @@ export const createContest = createAsyncThunk(
// =====================
const contestsSlice = createSlice({
name: "contests",
initialState,
reducers: {
clearSelectedContest: (state) => {
state.selectedContest = null;
name: 'contests',
initialState,
reducers: {
clearSelectedContest: (state) => {
state.selectedContest = null;
},
},
},
extraReducers: (builder) => {
// fetchContests
builder.addCase(fetchContests.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(fetchContests.fulfilled, (state, action: PayloadAction<ContestsResponse>) => {
state.status = "successful";
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;
});
extraReducers: (builder) => {
// fetchContests
builder.addCase(fetchContests.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(
fetchContests.fulfilled,
(state, action: PayloadAction<ContestsResponse>) => {
state.status = 'successful';
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;
},
);
// fetchContestById
builder.addCase(fetchContestById.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(fetchContestById.fulfilled, (state, action: PayloadAction<Contest>) => {
state.status = "successful";
state.selectedContest = action.payload;
});
builder.addCase(fetchContestById.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
state.error = action.payload;
});
// fetchContestById
builder.addCase(fetchContestById.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(
fetchContestById.fulfilled,
(state, action: PayloadAction<Contest>) => {
state.status = 'successful';
state.selectedContest = action.payload;
},
);
builder.addCase(
fetchContestById.rejected,
(state, action: PayloadAction<any>) => {
state.status = 'failed';
state.error = action.payload;
},
);
// createContest
builder.addCase(createContest.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(createContest.fulfilled, (state, action: PayloadAction<Contest>) => {
state.status = "successful";
state.contests.unshift(action.payload);
});
builder.addCase(createContest.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
state.error = action.payload;
});
},
// createContest
builder.addCase(createContest.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(
createContest.fulfilled,
(state, action: PayloadAction<Contest>) => {
state.status = 'successful';
state.contests.unshift(action.payload);
},
);
builder.addCase(
createContest.rejected,
(state, action: PayloadAction<any>) => {
state.status = 'failed';
state.error = action.payload;
},
);
},
});
// =====================

View File

@@ -1,282 +1,349 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import axios from "../../axios";
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// ─── Типы ────────────────────────────────────────────
type Status = "idle" | "loading" | "successful" | "failed";
type Status = 'idle' | 'loading' | 'successful' | 'failed';
export interface GroupMember {
userId: number;
username: string;
role: string;
userId: number;
username: string;
role: string;
}
export interface Group {
id: number;
name: string;
description: string;
members: GroupMember[];
contests: any[];
id: number;
name: string;
description: string;
members: GroupMember[];
contests: any[];
}
interface GroupsState {
groups: Group[];
currentGroup: Group | null;
statuses: {
create: Status;
update: Status;
delete: Status;
fetchMy: Status;
fetchById: Status;
addMember: Status;
removeMember: Status;
};
error: string | null;
groups: Group[];
currentGroup: Group | null;
statuses: {
create: Status;
update: Status;
delete: Status;
fetchMy: Status;
fetchById: Status;
addMember: Status;
removeMember: Status;
};
error: string | null;
}
const initialState: GroupsState = {
groups: [],
currentGroup: null,
statuses: {
create: "idle",
update: "idle",
delete: "idle",
fetchMy: "idle",
fetchById: "idle",
addMember: "idle",
removeMember: "idle",
},
error: null,
groups: [],
currentGroup: null,
statuses: {
create: 'idle',
update: 'idle',
delete: 'idle',
fetchMy: 'idle',
fetchById: 'idle',
addMember: 'idle',
removeMember: 'idle',
},
error: null,
};
// ─── Async Thunks ─────────────────────────────────────
// POST /groups
export const createGroup = createAsyncThunk(
"groups/createGroup",
async (
{ name, description }: { name: string; description: string },
{ rejectWithValue }
) => {
try {
const response = await axios.post("/groups", { name, description });
return response.data as Group;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Ошибка при создании группы");
}
}
'groups/createGroup',
async (
{ name, description }: { name: string; description: string },
{ rejectWithValue },
) => {
try {
const response = await axios.post('/groups', { name, description });
return response.data as Group;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при создании группы',
);
}
},
);
// PUT /groups/{groupId}
export const updateGroup = createAsyncThunk(
"groups/updateGroup",
async (
{ groupId, name, description }: { groupId: number; name: string; description: string },
{ rejectWithValue }
) => {
try {
const response = await axios.put(`/groups/${groupId}`, { name, description });
return response.data as Group;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Ошибка при обновлении группы");
}
}
'groups/updateGroup',
async (
{
groupId,
name,
description,
}: { groupId: number; name: string; description: string },
{ rejectWithValue },
) => {
try {
const response = await axios.put(`/groups/${groupId}`, {
name,
description,
});
return response.data as Group;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при обновлении группы',
);
}
},
);
// DELETE /groups/{groupId}
export const deleteGroup = createAsyncThunk(
"groups/deleteGroup",
async (groupId: number, { rejectWithValue }) => {
try {
await axios.delete(`/groups/${groupId}`);
return groupId;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Ошибка при удалении группы");
}
}
'groups/deleteGroup',
async (groupId: number, { rejectWithValue }) => {
try {
await axios.delete(`/groups/${groupId}`);
return groupId;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при удалении группы',
);
}
},
);
// GET /groups/my
export const fetchMyGroups = createAsyncThunk(
"groups/fetchMyGroups",
async (_, { rejectWithValue }) => {
try {
const response = await axios.get("/groups/my");
return response.data.groups as Group[];
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Ошибка при получении групп");
}
}
'groups/fetchMyGroups',
async (_, { rejectWithValue }) => {
try {
const response = await axios.get('/groups/my');
return response.data.groups as Group[];
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении групп',
);
}
},
);
// GET /groups/{groupId}
export const fetchGroupById = createAsyncThunk(
"groups/fetchGroupById",
async (groupId: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/groups/${groupId}`);
return response.data as Group;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Ошибка при получении группы");
}
}
'groups/fetchGroupById',
async (groupId: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/groups/${groupId}`);
return response.data as Group;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при получении группы',
);
}
},
);
// POST /groups/members
export const addGroupMember = createAsyncThunk(
"groups/addGroupMember",
async ({ userId, role }: { userId: number; role: string }, { rejectWithValue }) => {
try {
await axios.post("/groups/members", { userId, role });
return { userId, role };
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Ошибка при добавлении участника");
}
}
'groups/addGroupMember',
async (
{ userId, role }: { userId: number; role: string },
{ rejectWithValue },
) => {
try {
await axios.post('/groups/members', { userId, role });
return { userId, role };
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Ошибка при добавлении участника',
);
}
},
);
// DELETE /groups/{groupId}/members/{memberId}
export const removeGroupMember = createAsyncThunk(
"groups/removeGroupMember",
async (
{ groupId, memberId }: { groupId: number; memberId: number },
{ rejectWithValue }
) => {
try {
await axios.delete(`/groups/${groupId}/members/${memberId}`);
return { groupId, memberId };
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Ошибка при удалении участника");
}
}
'groups/removeGroupMember',
async (
{ groupId, memberId }: { groupId: number; memberId: number },
{ rejectWithValue },
) => {
try {
await axios.delete(`/groups/${groupId}/members/${memberId}`);
return { groupId, memberId };
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при удалении участника',
);
}
},
);
// ─── Slice ────────────────────────────────────────────
const groupsSlice = createSlice({
name: "groups",
initialState,
reducers: {
clearCurrentGroup: (state) => {
state.currentGroup = null;
name: 'groups',
initialState,
reducers: {
clearCurrentGroup: (state) => {
state.currentGroup = null;
},
},
},
extraReducers: (builder) => {
// ─── CREATE GROUP ───
builder.addCase(createGroup.pending, (state) => {
state.statuses.create = "loading";
state.error = null;
});
builder.addCase(createGroup.fulfilled, (state, action: PayloadAction<Group>) => {
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;
});
// ─── 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
extraReducers: (builder) => {
// ─── CREATE GROUP ───
builder.addCase(createGroup.pending, (state) => {
state.statuses.create = 'loading';
state.error = null;
});
builder.addCase(
createGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
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(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;

View File

@@ -1,29 +1,28 @@
import { createSlice, PayloadAction} from "@reduxjs/toolkit";
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// Типы данных
interface StorState {
menu: {
activePage: string;
}
menu: {
activePage: string;
};
}
// Инициализация состояния
const initialState: StorState = {
menu: {
activePage: "",
}
activePage: '',
},
};
// Slice
const storeSlice = createSlice({
name: "store",
initialState,
reducers: {
setMenuActivePage: (state, activePage: PayloadAction<string>) => {
state.menu.activePage = activePage.payload;
name: 'store',
initialState,
reducers: {
setMenuActivePage: (state, activePage: PayloadAction<string>) => {
state.menu.activePage = activePage.payload;
},
},
},
});
export const { setMenuActivePage } = storeSlice.actions;

View File

@@ -1,184 +1,224 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import axios from "../../axios";
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// Типы данных
export interface Submit {
id?: number;
missionId: number;
language: string;
languageVersion: string;
sourceCode: string;
contestId: number | null;
id?: number;
missionId: number;
language: string;
languageVersion: string;
sourceCode: string;
contestId: number | null;
}
export interface Solution {
id: number;
missionId: number;
language: string;
languageVersion: string;
sourceCode: string;
status: string;
time: string;
testerState: string;
testerErrorCode: string;
testerMessage: string;
currentTest: number;
amountOfTests: number;
id: number;
missionId: number;
language: string;
languageVersion: string;
sourceCode: string;
status: string;
time: string;
testerState: string;
testerErrorCode: string;
testerMessage: string;
currentTest: number;
amountOfTests: number;
}
export interface MissionSubmit {
id: number;
userId: number;
solution: Solution;
contestId: number | null;
contestName: string | null;
sourceType: string;
id: number;
userId: number;
solution: Solution;
contestId: number | null;
contestName: string | null;
sourceType: string;
}
interface SubmitState {
submits: Submit[];
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
currentSubmit?: Submit;
status: "idle" | "loading" | "successful" | "failed";
error: string | null;
submits: Submit[];
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
currentSubmit?: Submit;
status: 'idle' | 'loading' | 'successful' | 'failed';
error: string | null;
}
// Начальное состояние
const initialState: SubmitState = {
submits: [],
submitsById: {}, // ✅ инициализация
currentSubmit: undefined,
status: "idle",
error: null,
submits: [],
submitsById: {}, // ✅ инициализация
currentSubmit: undefined,
status: 'idle',
error: null,
};
// AsyncThunk: Отправка решения
export const submitMission = createAsyncThunk(
"submit/submitMission",
async (submitData: Submit, { rejectWithValue }) => {
try {
const response = await axios.post("/submits", submitData);
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Submit failed");
}
}
'submit/submitMission',
async (submitData: Submit, { rejectWithValue }) => {
try {
const response = await axios.post('/submits', submitData);
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Submit failed',
);
}
},
);
// AsyncThunk: Получить все свои отправки
export const fetchMySubmits = createAsyncThunk(
"submit/fetchMySubmits",
async (_, { rejectWithValue }) => {
try {
const response = await axios.get("/submits/my");
return response.data as Submit[];
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch submits");
}
}
'submit/fetchMySubmits',
async (_, { rejectWithValue }) => {
try {
const response = await axios.get('/submits/my');
return response.data as Submit[];
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to fetch submits',
);
}
},
);
// AsyncThunk: Получить конкретную отправку по ID
export const fetchSubmitById = createAsyncThunk(
"submit/fetchSubmitById",
async (id: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/submits/${id}`);
return response.data as Submit;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch submit");
}
}
'submit/fetchSubmitById',
async (id: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/submits/${id}`);
return response.data as Submit;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Failed to fetch submit',
);
}
},
);
// ✅ AsyncThunk: Получить отправки для конкретной миссии (новая структура)
export const fetchMySubmitsByMission = createAsyncThunk(
"submit/fetchMySubmitsByMission",
async (missionId: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/submits/my/mission/${missionId}`);
return { missionId, data: response.data as MissionSubmit[] };
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch mission submits");
}
}
'submit/fetchMySubmitsByMission',
async (missionId: number, { rejectWithValue }) => {
try {
const response = await axios.get(
`/submits/my/mission/${missionId}`,
);
return { missionId, data: response.data as MissionSubmit[] };
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Failed to fetch mission submits',
);
}
},
);
// Slice
const submitSlice = createSlice({
name: "submit",
initialState,
reducers: {
clearCurrentSubmit: (state) => {
state.currentSubmit = undefined;
state.status = "idle";
state.error = null;
name: 'submit',
initialState,
reducers: {
clearCurrentSubmit: (state) => {
state.currentSubmit = undefined;
state.status = 'idle';
state.error = null;
},
clearSubmitsByMission: (state, action: PayloadAction<number>) => {
delete state.submitsById[action.payload];
},
},
clearSubmitsByMission: (state, action: PayloadAction<number>) => {
delete state.submitsById[action.payload];
extraReducers: (builder) => {
// Отправка решения
builder.addCase(submitMission.pending, (state) => {
state.status = 'loading';
state.error = null;
});
builder.addCase(
submitMission.fulfilled,
(state, action: PayloadAction<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;

View File

@@ -1,11 +1,10 @@
import { configureStore } from "@reduxjs/toolkit";
import { authReducer } from "./slices/auth";
import { storeReducer } from "./slices/store";
import { missionsReducer } from "./slices/missions";
import { submitReducer } from "./slices/submit";
import { contestsReducer } from "./slices/contests";
import { groupsReducer } from "./slices/groups";
import { configureStore } from '@reduxjs/toolkit';
import { authReducer } from './slices/auth';
import { storeReducer } from './slices/store';
import { missionsReducer } from './slices/missions';
import { submitReducer } from './slices/submit';
import { contestsReducer } from './slices/contests';
import { groupsReducer } from './slices/groups';
// использование
// import { useAppDispatch, useAppSelector } from '../redux/hooks';
@@ -15,17 +14,16 @@ import { groupsReducer } from "./slices/groups";
// const dispatch = useAppDispatch();
// const user = useAppSelector((state) => state.user);
export const store = configureStore({
reducer: {
//user: userReducer,
auth: authReducer,
store: storeReducer,
missions: missionsReducer,
submin: submitReducer,
contests: contestsReducer,
groups: groupsReducer,
},
reducer: {
//user: userReducer,
auth: authReducer,
store: storeReducer,
missions: missionsReducer,
submin: submitReducer,
contests: contestsReducer,
groups: groupsReducer,
},
});
// тип состояния всего стора

View File

@@ -2,116 +2,108 @@
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import "./latex-container.css";
@import './latex-container.css';
* {
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/
/* outline: 1px solid green; */
}
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/
/* outline: 1px solid green; */
}
:root {
color-scheme: light dark;
width: 100%;
height: 100svh;
/* @apply bg-layout-background; */
/* transition: all linear 200ms; */
color-scheme: light dark;
width: 100%;
height: 100svh;
/* @apply bg-layout-background; */
/* transition: all linear 200ms; */
font-family: 'Source Code Pro', monospace;
/* font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; */
font-weight: 400;
line-height: 1.5;
background-color: var(--color-liquid-background);
color: rgba(255, 255, 255, 0.87);
font-family: 'Source Code Pro', monospace;
/* font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; */
font-weight: 400;
line-height: 1.5;
background-color: var(--color-liquid-background);
color: rgba(255, 255, 255, 0.87);
}
#root {
width: 100%;
height: 100vh;
width: 100%;
height: 100vh;
}
body {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
margin: 0;
}
/* Общий контейнер полосы прокрутки */
.thin-scrollbar::-webkit-scrollbar {
width: 4px; /* ширина вертикального */
width: 4px; /* ширина вертикального */
}
/* Трек (фон) */
.thin-scrollbar::-webkit-scrollbar-track {
background: transparent;
background: transparent;
}
/* Ползунок (thumb) */
.thin-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-liquid-light);
border-radius: 1000px;
cursor: pointer;
background: var(--color-liquid-light);
border-radius: 1000px;
cursor: pointer;
}
/* Общий контейнер полосы прокрутки */
.medium-scrollbar::-webkit-scrollbar {
width: 8px; /* ширина вертикального */
width: 8px; /* ширина вертикального */
}
/* Трек (фон) */
.medium-scrollbar::-webkit-scrollbar-track {
background: transparent;
background: transparent;
}
/* Ползунок (thumb) */
.medium-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-liquid-light);
border-radius: 1000px;
cursor: pointer;
background: var(--color-liquid-light);
border-radius: 1000px;
cursor: pointer;
}
/* Общий контейнер полосы прокрутки */
.thin-dark-scrollbar::-webkit-scrollbar {
width: 4px; /* ширина вертикального */
width: 4px; /* ширина вертикального */
}
/* Трек (фон) */
.thin-dark-scrollbar::-webkit-scrollbar-track {
background: transparent;
background: transparent;
}
/* Ползунок (thumb) */
.thin-dark-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-liquid-lighter);
border-radius: 1000px;
cursor: pointer;
background: var(--color-liquid-lighter);
border-radius: 1000px;
cursor: pointer;
}
html {
scrollbar-gutter: stable;
padding-left: 8px;
}
html::-webkit-scrollbar {
width: 8px; /* ширина вертикального */
width: 8px; /* ширина вертикального */
}
/* Трек (фон) */
html::-webkit-scrollbar-track {
background: transparent;
background: transparent;
}
/* Ползунок (thumb) */
html::-webkit-scrollbar-thumb {
background-color: var(--color-liquid-lighter);
border-radius: 1000px;
cursor: pointer;
}
background-color: var(--color-liquid-lighter);
border-radius: 1000px;
cursor: pointer;
}

View File

@@ -1,26 +1,24 @@
.latex-container p {
text-align: justify; /* выравнивание по ширине */
text-justify: inter-word;
margin-bottom: 0.8em; /* небольшой отступ между абзацами */
line-height: 1.2;
/* text-indent: 1em; */
text-align: justify; /* выравнивание по ширине */
text-justify: inter-word;
margin-bottom: 0.8em; /* небольшой отступ между абзацами */
line-height: 1.2;
/* text-indent: 1em; */
}
.latex-container ol {
padding-left: 1.5em; /* отступ для нумерации */
margin: 0.5em 0; /* небольшой отступ сверху и снизу */
line-height: 1.5; /* удобный межстрочный интервал */
font-family: "Inter", sans-serif;
font-size: 1rem;
padding-left: 1.5em; /* отступ для нумерации */
margin: 0.5em 0; /* небольшой отступ сверху и снизу */
line-height: 1.5; /* удобный межстрочный интервал */
font-family: 'Inter', sans-serif;
font-size: 1rem;
}
.latex-container ol li {
margin-bottom: 0.4em; /* расстояние между пунктами */
margin-bottom: 0.4em; /* расстояние между пунктами */
}
.latex-container .section-title{
font-size: 16px;
font-weight: bold;
.latex-container .section-title {
font-size: 16px;
font-weight: bold;
}

View File

@@ -1,16 +1,16 @@
@import 'tailwindcss/base';
@layer base {
:root[data-theme~="dark"] {
--color-liquid-brightmain: #00DBD9;
--color-liquid-darkmain: #075867;
--color-liquid-darker: #141515;
--color-liquid-background: #202222;
--color-liquid-lighter: #2A2E2F;
--color-liquid-white: #EDF6F7;
--color-liquid-red: #F13E5F;
--color-liquid-green: #10BE59;
--color-liquid-light: #576466;
--color-liquid-orange: #FF951B;
}
}
:root[data-theme~='dark'] {
--color-liquid-brightmain: #00dbd9;
--color-liquid-darkmain: #075867;
--color-liquid-darker: #141515;
--color-liquid-background: #202222;
--color-liquid-lighter: #2a2e2f;
--color-liquid-white: #edf6f7;
--color-liquid-red: #f13e5f;
--color-liquid-green: #10be59;
--color-liquid-light: #576466;
--color-liquid-orange: #ff951b;
}
}

View File

@@ -1,16 +1,16 @@
@import 'tailwindcss/base';
@layer base {
:root {
--color-liquid-brightmain: #00DBD9;
--color-liquid-darkmain: #075867;
--color-liquid-darker: #141515;
--color-liquid-background: #202222;
--color-liquid-lighter: #2A2E2F;
--color-liquid-white: #EDF6F7;
--color-liquid-red: #F13E5F;
--color-liquid-green: #10BE59;
--color-liquid-light: #576466;
--color-liquid-orange: #FF951B;
}
}
:root {
--color-liquid-brightmain: #00dbd9;
--color-liquid-darkmain: #075867;
--color-liquid-darker: #141515;
--color-liquid-background: #202222;
--color-liquid-lighter: #2a2e2f;
--color-liquid-white: #edf6f7;
--color-liquid-red: #f13e5f;
--color-liquid-green: #10be59;
--color-liquid-light: #576466;
--color-liquid-orange: #ff951b;
}
}

View File

@@ -1,17 +1,21 @@
import { FC, useEffect, useState } from "react";
import axios from "../../axios";
import "highlight.js/styles/github-dark.css";
import MarkdownPreview from "./MarckDownPreview";
import { FC, useEffect, useState } from 'react';
import axios from '../../axios';
import 'highlight.js/styles/github-dark.css';
import MarkdownPreview from './MarckDownPreview';
interface MarkdownEditorProps {
defaultValue?: string;
onChange: (value: string) => void;
}
const MarkdownEditor: FC<MarkdownEditorProps> = ({ defaultValue, onChange }) => {
const [markdown, setMarkdown] = useState<string>(defaultValue || `# 🌙 Добро пожаловать в Markdown-редактор
const MarkdownEditor: FC<MarkdownEditorProps> = ({
defaultValue,
onChange,
}) => {
const [markdown, setMarkdown] = useState<string>(
defaultValue ||
`# 🌙 Добро пожаловать в Markdown-редактор
Добро пожаловать в **Markdown-редактор**!
Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇
@@ -205,34 +209,42 @@ print(greet("Мир"))
**🖤 Конец демонстрации. Спасибо, что используешь Markdown-редактор!**
`);
`,
);
useEffect(() => {
onChange(markdown);
}, [markdown]);
// Обработчик вставки
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const handlePaste = async (
e: React.ClipboardEvent<HTMLTextAreaElement>,
) => {
const items = e.clipboardData.items;
for (const item of items) {
if (item.type.startsWith("image/")) {
if (item.type.startsWith('image/')) {
e.preventDefault(); // предотвращаем вставку картинки как текста
const file = item.getAsFile();
if (!file) return;
const formData = new FormData();
formData.append("file", file);
formData.append('file', file);
try {
const response = await axios.post("/media/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
const response = await axios.post(
'/media/upload',
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
},
);
const imageUrl = response.data.url;
// Вставляем ссылку на картинку в текст
const cursorPos = (e.target as HTMLTextAreaElement).selectionStart;
const cursorPos = (e.target as HTMLTextAreaElement)
.selectionStart;
const newText =
markdown.slice(0, cursorPos) +
`<img src=\"${imageUrl}\" alt=\"img\"/>` +
@@ -240,7 +252,7 @@ print(greet("Мир"))
setMarkdown(newText);
} 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="p-4 border-r border-gray-700 flex flex-col h-full">
<h2 className="text-lg font-semibold mb-3 text-gray-100">👀 Предпросмотр</h2>
<MarkdownPreview content={markdown} className="h-[calc(100%-40px)]"/>
<h2 className="text-lg font-semibold mb-3 text-gray-100">
👀 Предпросмотр
</h2>
<MarkdownPreview
content={markdown}
className="h-[calc(100%-40px)]"
/>
</div>
</div>
{/* Редактор */}
<div className="overflow-y-auto min-h-0 overflow-hidden">
<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
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}

View File

@@ -1,29 +1,39 @@
import React from "react";
import { arrowLeft } from "../../assets/icons/header";
import { Logo } from "../../assets/logos";
import { useNavigate } from "react-router-dom";
import React from 'react';
import { arrowLeft } from '../../assets/icons/header';
import { Logo } from '../../assets/logos';
import { useNavigate } from 'react-router-dom';
interface HeaderProps {
backUrl?: string;
}
const Header: React.FC<HeaderProps> = ({
backUrl="/home/articles",
}) => {
const Header: React.FC<HeaderProps> = ({ backUrl = '/home/articles' }) => {
const navigate = useNavigate();
return (
<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]">
<img src={chevroneLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId - 1}`) }} />
<span>{missionId}</span>
<img src={chevroneRight} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId + 1}`) }} />
</div> */}
</header>
);
};

View File

@@ -1,13 +1,13 @@
import { FC } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import "highlight.js/styles/github-dark.css";
import { FC } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import 'highlight.js/styles/github-dark.css';
import { defaultSchema } from "hast-util-sanitize";
import { cn } from "../../lib/cn";
import { defaultSchema } from 'hast-util-sanitize';
import { cn } from '../../lib/cn';
const schema = {
...defaultSchema,
@@ -15,9 +15,9 @@ const schema = {
...defaultSchema.attributes,
div: [
...(defaultSchema.attributes?.div || []),
["style"] // разрешаем атрибут style на div
]
}
['style'], // разрешаем атрибут style на div
],
},
};
interface MarkdownPreviewProps {
@@ -25,13 +25,25 @@ interface MarkdownPreviewProps {
className?: string;
}
const MarkdownPreview: FC<MarkdownPreviewProps> = ({ content, className="" }) => {
const MarkdownPreview: FC<MarkdownPreviewProps> = ({
content,
className = '',
}) => {
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">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, schema], rehypeHighlight]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, schema],
rehypeHighlight,
]}
>
{content}
</ReactMarkdown>

View File

@@ -1,4 +1,4 @@
import { cn } from "../../../lib/cn";
import { cn } from '../../../lib/cn';
export interface ArticleItemProps {
id: number;
@@ -6,17 +6,17 @@ export interface ArticleItemProps {
tags: string[];
}
const ArticleItem: React.FC<ArticleItemProps> = ({
id, name, tags
}) => {
const ArticleItem: React.FC<ArticleItemProps> = ({ id, name, tags }) => {
return (
<div className={cn("w-full relative rounded-[10px] text-liquid-white mb-[20px]",
// 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={cn(
'w-full relative rounded-[10px] text-liquid-white mb-[20px]',
// 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="text-[18px] font-bold w-[60px] mr-[20px] flex items-center">
#{id}
</div>
@@ -25,15 +25,18 @@ const ArticleItem: React.FC<ArticleItemProps> = ({
</div>
</div>
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
{tags.map((v, i) =>
<div key={i} className={cn(
"rounded-full px-[16px] py-[8px] bg-liquid-lighter",
v == "Sertificated" && "text-liquid-green")}>
{tags.map((v, i) => (
<div
key={i}
className={cn(
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
v == 'Sertificated' && 'text-liquid-green',
)}
>
{v}
</div>
)}
))}
</div>
</div>
);
};

View File

@@ -1,10 +1,9 @@
import { useEffect } from "react";
import { SecondaryButton } from "../../../components/button/SecondaryButton";
import { useAppDispatch } from "../../../redux/hooks";
import ArticleItem from "./ArticleItem";
import { setMenuActivePage } from "../../../redux/slices/store";
import { useNavigate } from "react-router-dom";
import { useEffect } from 'react';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { useAppDispatch } from '../../../redux/hooks';
import ArticleItem from './ArticleItem';
import { setMenuActivePage } from '../../../redux/slices/store';
import { useNavigate } from 'react-router-dom';
export interface Article {
id: number;
@@ -12,159 +11,152 @@ export interface Article {
tags: string[];
}
const Articles = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const articles: Article[] = [
{
"id": 1,
"name": "Todo List App",
"tags": ["Sertificated", "state", "list"],
id: 1,
name: 'Todo List App',
tags: ['Sertificated', 'state', 'list'],
},
{
"id": 2,
"name": "Search Filter Component",
"tags": ["filter", "props", "hooks"],
id: 2,
name: 'Search Filter Component',
tags: ['filter', 'props', 'hooks'],
},
{
"id": 3,
"name": "User Card List",
"tags": ["components", "props", "array"],
id: 3,
name: 'User Card List',
tags: ['components', 'props', 'array'],
},
{
"id": 4,
"name": "Theme Switcher",
"tags": ["Sertificated", "theme", "hooks"],
id: 4,
name: 'Theme Switcher',
tags: ['Sertificated', 'theme', 'hooks'],
},
{
"id": 2,
"name": "Search Filter Component",
"tags": ["filter", "props", "hooks"],
id: 2,
name: 'Search Filter Component',
tags: ['filter', 'props', 'hooks'],
},
{
"id": 3,
"name": "User Card List",
"tags": ["components", "props", "array"],
id: 3,
name: 'User Card List',
tags: ['components', 'props', 'array'],
},
{
"id": 4,
"name": "Theme Switcher",
"tags": ["Sertificated", "theme", "hooks"],
id: 4,
name: 'Theme Switcher',
tags: ['Sertificated', 'theme', 'hooks'],
},
{
"id": 2,
"name": "Search Filter Component",
"tags": ["filter", "props", "hooks"],
id: 2,
name: 'Search Filter Component',
tags: ['filter', 'props', 'hooks'],
},
{
"id": 3,
"name": "User Card List",
"tags": ["components", "props", "array"],
id: 3,
name: 'User Card List',
tags: ['components', 'props', 'array'],
},
{
"id": 4,
"name": "Theme Switcher",
"tags": ["Sertificated", "theme", "hooks"],
id: 4,
name: 'Theme Switcher',
tags: ['Sertificated', 'theme', 'hooks'],
},
{
"id": 2,
"name": "Search Filter Component",
"tags": ["filter", "props", "hooks"],
id: 2,
name: 'Search Filter Component',
tags: ['filter', 'props', 'hooks'],
},
{
"id": 3,
"name": "User Card List",
"tags": ["components", "props", "array"],
id: 3,
name: 'User Card List',
tags: ['components', 'props', 'array'],
},
{
"id": 4,
"name": "Theme Switcher",
"tags": ["Sertificated", "theme", "hooks"],
id: 4,
name: 'Theme Switcher',
tags: ['Sertificated', 'theme', 'hooks'],
},
{
"id": 2,
"name": "Search Filter Component",
"tags": ["filter", "props", "hooks"],
id: 2,
name: 'Search Filter Component',
tags: ['filter', 'props', 'hooks'],
},
{
"id": 3,
"name": "User Card List",
"tags": ["components", "props", "array"],
id: 3,
name: 'User Card List',
tags: ['components', 'props', 'array'],
},
{
"id": 4,
"name": "Theme Switcher",
"tags": ["Sertificated", "theme", "hooks"],
id: 4,
name: 'Theme Switcher',
tags: ['Sertificated', 'theme', 'hooks'],
},
{
"id": 2,
"name": "Search Filter Component",
"tags": ["filter", "props", "hooks"],
id: 2,
name: 'Search Filter Component',
tags: ['filter', 'props', 'hooks'],
},
{
"id": 3,
"name": "User Card List",
"tags": ["components", "props", "array"],
id: 3,
name: 'User Card List',
tags: ['components', 'props', 'array'],
},
{
"id": 4,
"name": "Theme Switcher",
"tags": ["Sertificated", "theme", "hooks"],
id: 4,
name: 'Theme Switcher',
tags: ['Sertificated', 'theme', 'hooks'],
},
{
"id": 2,
"name": "Search Filter Component",
"tags": ["filter", "props", "hooks"],
id: 2,
name: 'Search Filter Component',
tags: ['filter', 'props', 'hooks'],
},
{
"id": 3,
"name": "User Card List",
"tags": ["components", "props", "array"],
id: 3,
name: 'User Card List',
tags: ['components', 'props', 'array'],
},
{
"id": 4,
"name": "Theme Switcher",
"tags": ["Sertificated", "theme", "hooks"],
}
id: 4,
name: 'Theme Switcher',
tags: ['Sertificated', 'theme', 'hooks'],
},
];
useEffect(() => {
dispatch(setMenuActivePage("articles"))
}, []);
useEffect(() => {
dispatch(setMenuActivePage('articles'));
}, []);
return (
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
<div className="h-full box-border">
<div className="relative flex items-center mb-[20px]">
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
Статьи
</div>
<SecondaryButton
onClick={() => {navigate("/article/create")}}
onClick={() => {
navigate('/article/create');
}}
text="Создать статью"
className="absolute right-0"
/>
</div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]">
</div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]"></div>
<div>
{articles.map((v, i) => (
<ArticleItem key={i} {...v} />
))}
</div>
<div>
pages
</div>
<div>pages</div>
</div>
</div>
);

View File

@@ -1,113 +1,133 @@
import { useState, useEffect } from "react";
import { PrimaryButton } from "../../../components/button/PrimaryButton";
import { Input } from "../../../components/input/Input";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { Link, useNavigate } from "react-router-dom";
import { loginUser } from "../../../redux/slices/auth";
import { useState, useEffect } from 'react';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { Link, useNavigate } from 'react-router-dom';
import { loginUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn";
import { setMenuActivePage } from "../../../redux/slices/store";
import { Balloon } from "../../../assets/icons/auth";
import { SecondaryButton } from "../../../components/button/SecondaryButton";
import { googleLogo } from "../../../assets/icons/input";
import { setMenuActivePage } from '../../../redux/slices/store';
import { Balloon } from '../../../assets/icons/auth';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { googleLogo } from '../../../assets/icons/input';
const Login = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [username, setUsername] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
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(() => {
dispatch(setMenuActivePage("account"))
console.log(submitClicked);
}, []);
useEffect(() => {
if (jwt) {
navigate('/home/offices'); // или другая страница после входа
}
}, [jwt]);
useEffect(() => {
if (jwt) {
navigate("/home/offices"); // или другая страница после входа
}
}, [jwt]);
const handleLogin = () => {
// setErr(err == "" ? "Неверная почта и/или пароль" : "");
setSubmitClicked(true);
const handleLogin = () => {
// setErr(err == "" ? "Неверная почта и/или пароль" : "");
setSubmitClicked(true);
if (!username || !password) return;
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 (
<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]">
С возвращением
<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 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>
);
);
};
export default Login;

View File

@@ -1,126 +1,169 @@
import { useState, useEffect } from "react";
import { PrimaryButton } from "../../../components/button/PrimaryButton";
import { Input } from "../../../components/input/Input";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { useNavigate } from "react-router-dom";
import { registerUser } from "../../../redux/slices/auth";
import { useState, useEffect } from 'react';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useNavigate } from 'react-router-dom';
import { registerUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn";
import { setMenuActivePage } from "../../../redux/slices/store";
import { Balloon } from "../../../assets/icons/auth";
import { Link } from "react-router-dom";
import { SecondaryButton } from "../../../components/button/SecondaryButton";
import { Checkbox } from "../../../components/checkbox/Checkbox";
import { googleLogo } from "../../../assets/icons/input";
import { setMenuActivePage } from '../../../redux/slices/store';
import { Balloon } from '../../../assets/icons/auth';
import { Link } from 'react-router-dom';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Checkbox } from '../../../components/checkbox/Checkbox';
import { googleLogo } from '../../../assets/icons/input';
const Register = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [username, setUsername] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [confirmPassword, setConfirmPassword] = useState<string>("");
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
const [username, setUsername] = useState<string>('');
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [confirmPassword, setConfirmPassword] = useState<string>('');
const [submitClicked, setSubmitClicked] = useState<boolean>(false);
const { status, jwt } = useAppSelector((state) => state.auth);
const { status, jwt } = useAppSelector((state) => state.auth);
// После успешной регистрации — переход в систему
// После успешной регистрации — переход в систему
useEffect(() => {
dispatch(setMenuActivePage("account"))
}, []);
useEffect(() => {
dispatch(setMenuActivePage('account'));
}, []);
useEffect(() => {
if (jwt) {
navigate("/home");
}
console.log(submitClicked);
}, [jwt]);
useEffect(() => {
if (jwt) {
navigate('/home');
}
console.log(submitClicked);
}, [jwt]);
const handleRegister = () => {
setSubmitClicked(true);
const handleRegister = () => {
setSubmitClicked(true);
if (!username || !email || !password || !confirmPassword) return;
if (password !== confirmPassword) return;
if (!username || !email || !password || !confirmPassword) return;
if (password !== confirmPassword) return;
dispatch(registerUser({ username, email, password }));
};
dispatch(registerUser({ username, email, 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]">
Добро пожаловать
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>
<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 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>
);
);
};
export default Register;

View File

@@ -1,65 +1,74 @@
import { cn } from "../../../lib/cn";
import { Account } from "../../../assets/icons/auth";
import { PrimaryButton } from "../../../components/button/PrimaryButton";
import { ReverseButton } from "../../../components/button/ReverseButton";
import { cn } from '../../../lib/cn';
import { Account } from '../../../assets/icons/auth';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { ReverseButton } from '../../../components/button/ReverseButton';
export interface ContestItemProps {
name: string;
startAt: string;
duration: number;
members: number;
statusRegister: "reg" | "nonreg";
type: "first" | "second";
statusRegister: 'reg' | 'nonreg';
type: 'first' | 'second';
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
const hours = date.getHours().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 {
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
const remainder = days % 10;
let suffix = "дней";
if (remainder === 1 && days !== 11) suffix = "день";
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) suffix = "дня";
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
if (days > 0) {
const remainder = days % 10;
let suffix = 'дней';
if (remainder === 1 && days !== 11) suffix = 'день';
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
suffix = 'дня';
return `${days} ${suffix}`;
} else if (hours > 0) {
const mins = minutes % 60;
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
} else {
return `${minutes} мин`;
}
}
const ContestItem: React.FC<ContestItemProps> = ({
name, startAt, duration, members, statusRegister, type
name,
startAt,
duration,
members,
statusRegister,
type,
}) => {
const now = new Date();
const waitTime = new Date(startAt).getTime() - now.getTime();
return (
<div className={cn("w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px]",
waitTime <= 0 ? "grid grid-cols-6" : "grid grid-cols-7",
"items-center font-bold text-liquid-white",
type == "first" ? " bg-liquid-lighter" : " bg-liquid-background"
)}>
<div className="text-left font-bold text-[18px]">
{name}
</div>
<div
className={cn(
'w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px]',
waitTime <= 0 ? 'grid grid-cols-6' : 'grid grid-cols-7',
'items-center font-bold text-liquid-white',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
>
<div className="text-left font-bold text-[18px]">{name}</div>
<div className="text-center text-liquid-brightmain font-normal ">
{/* {authors.map((v, i) => <p key={i}>{v}</p>)} */}
valavshonok
@@ -67,29 +76,29 @@ const ContestItem: React.FC<ContestItemProps> = ({
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(startAt)}
</div>
<div className="text-center">
{formatWaitTime(duration)}
</div>
{
waitTime > 0 &&
<div className="text-center">{formatWaitTime(duration)}</div>
{waitTime > 0 && (
<div className="text-center whitespace-pre-line ">
{"До начала\n" + formatWaitTime(waitTime)}
{'До начала\n' + formatWaitTime(waitTime)}
</div>
}
)}
<div className="items-center justify-center flex gap-[10px] flex-row w-full">
<div>{members}</div>
<img src={Account} className="h-[24px] w-[24px]"/>
<img src={Account} className="h-[24px] w-[24px]" />
</div>
<div className="flex items-center justify-end">
{
statusRegister == "reg" ?
<> <PrimaryButton onClick={() => {}} text="Регистрация"/></>
:
<> <ReverseButton onClick={() => {}} text="Вы записаны"/></>
}
{statusRegister == 'reg' ? (
<>
{' '}
<PrimaryButton onClick={() => {}} text="Регистрация" />
</>
) : (
<>
{' '}
<ReverseButton onClick={() => {}} text="Вы записаны" />
</>
)}
</div>
</div>
);
};

View File

@@ -1,10 +1,10 @@
import { useEffect } from "react";
import { SecondaryButton } from "../../../components/button/SecondaryButton";
import { cn } from "../../../lib/cn";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import ContestsBlock from "./ContestsBlock";
import { setMenuActivePage } from "../../../redux/slices/store";
import { fetchContests } from "../../../redux/slices/contests";
import { useEffect } from 'react';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { cn } from '../../../lib/cn';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import ContestsBlock from './ContestsBlock';
import { setMenuActivePage } from '../../../redux/slices/store';
import { fetchContests } from '../../../redux/slices/contests';
const Contests = () => {
const dispatch = useAppDispatch();
@@ -17,12 +17,14 @@ const Contests = () => {
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
useEffect(() => {
dispatch(setMenuActivePage("contests"));
dispatch(setMenuActivePage('contests'));
dispatch(fetchContests({}));
}, []);
if (loading == "loading") {
return <div className="text-liquid-white p-4">Загрузка контестов...</div>;
if (loading == 'loading') {
return (
<div className="text-liquid-white p-4">Загрузка контестов...</div>
);
}
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 box-border">
<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>
<SecondaryButton
@@ -49,8 +55,7 @@ const Contests = () => {
className="mb-[20px]"
title="Текущие"
contests={contests.filter((contest) => {
const endTime =
new Date(contest.endsAt).getTime()
const endTime = new Date(contest.endsAt).getTime();
return endTime >= now.getTime();
})}
/>
@@ -59,8 +64,7 @@ const Contests = () => {
className="mb-[20px]"
title="Прошедшие"
contests={contests.filter((contest) => {
const endTime =
new Date(contest.endsAt).getTime()
const endTime = new Date(contest.endsAt).getTime();
return endTime < now.getTime();
})}
/>

View File

@@ -1,11 +1,8 @@
import { useState, FC } from "react";
import { cn } from "../../../lib/cn";
import { ChevroneDown } from "../../../assets/icons/groups";
import ContestItem from "./ContestItem";
import { Contest } from "../../../redux/slices/contests";
import { useState, FC } from 'react';
import { cn } from '../../../lib/cn';
import { ChevroneDown } from '../../../assets/icons/groups';
import ContestItem from './ContestItem';
import { Contest } from '../../../redux/slices/contests';
interface ContestsBlockProps {
contests: Contest[];
@@ -13,46 +10,61 @@ interface ContestsBlockProps {
className?: string;
}
const ContestsBlock: FC<ContestsBlockProps> = ({ contests, title, className }) => {
const [active, setActive] = useState<boolean>(title != "Скрытые");
const ContestsBlock: FC<ContestsBlockProps> = ({
contests,
title,
className,
}) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые');
return (
<div className={cn(" 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(
' 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',
)}
onClick={() => {
setActive(!active)
}}>
setActive(!active);
}}
>
<span>{title}</span>
<img src={ChevroneDown} className={cn("transition-all duration-300",
active && "rotate-180"
)} />
<img
src={ChevroneDown}
className={cn(
'transition-all duration-300',
active && 'rotate-180',
)}
/>
</div>
<div className={cn(" grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300",
active && "grid-rows-[1fr] opacity-100"
)}>
<div
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="pb-[10px] pt-[20px]">
{
contests.map((v, i) => <ContestItem
key={i}
name={v.name}
startAt={v.startsAt}
statusRegister={"reg"}
duration={new Date(v.endsAt).getTime() - new Date(v.startsAt).getTime()}
members={v.members.length}
type={i % 2 ? "second" : "first"} />)
}
{contests.map((v, i) => (
<ContestItem
key={i}
name={v.name}
startAt={v.startsAt}
statusRegister={'reg'}
duration={
new Date(v.endsAt).getTime() -
new Date(v.startsAt).getTime()
}
members={v.members.length}
type={i % 2 ? 'second' : 'first'}
/>
))}
</div>
</div>
</div>
</div>

View File

@@ -1,26 +1,26 @@
import { FC } from "react";
import { cn } from "../../../lib/cn";
import { useParams, Navigate } from "react-router-dom";
import { FC } from 'react';
import { cn } from '../../../lib/cn';
import { useParams, Navigate } from 'react-router-dom';
interface GroupsBlockProps {}
const Group: FC<GroupsBlockProps> = () => {
const { groupId } = useParams<{ groupId: string }>();
const groupIdNumber = Number(groupId);
const { groupId } = useParams<{ groupId: string }>();
const groupIdNumber = Number(groupId);
if (!groupId || isNaN(groupIdNumber) || !groupIdNumber) {
return <Navigate to="/home/groups" replace />;
}
if (!groupId || isNaN(groupIdNumber) || !groupIdNumber) {
return <Navigate to="/home/groups" replace />;
}
return (
<div
className={cn(
"border-b-[1px] border-b-liquid-lighter rounded-[10px]"
)}
>
{groupIdNumber}
</div>
);
return (
<div
className={cn(
'border-b-[1px] border-b-liquid-lighter rounded-[10px]',
)}
>
{groupIdNumber}
</div>
);
};
export default Group;

View File

@@ -1,11 +1,17 @@
import { cn } from "../../../lib/cn";
import { Book, UserAdd, Edit, EyeClosed, EyeOpen } from "../../../assets/icons/groups";
import { useNavigate } from "react-router-dom";
import { GroupUpdate } from "./Groups";
import { cn } from '../../../lib/cn';
import {
Book,
UserAdd,
Edit,
EyeClosed,
EyeOpen,
} from '../../../assets/icons/groups';
import { useNavigate } from 'react-router-dom';
import { GroupUpdate } from './Groups';
export interface GroupItemProps {
id: number;
role: "menager" | "member" | "owner" | "viewer";
role: 'menager' | 'member' | 'owner' | 'viewer';
visible: boolean;
name: string;
description: string;
@@ -13,61 +19,64 @@ export interface GroupItemProps {
setUpdateGroup: (value: GroupUpdate) => void;
}
interface IconComponentProps {
src: string;
onClick?: () => void;
}
const IconComponent: React.FC<IconComponentProps> = ({
src,
onClick
}) => {
return <img
src={src}
onClick={(e) => {
e.stopPropagation();
if (onClick)
onClick();
}}
className="hover:bg-liquid-light rounded-[5px] cursor-pointer transition-all duration-300"
/>
}
const IconComponent: React.FC<IconComponentProps> = ({ src, onClick }) => {
return (
<img
src={src}
onClick={(e) => {
e.stopPropagation();
if (onClick) onClick();
}}
className="hover:bg-liquid-light rounded-[5px] cursor-pointer transition-all duration-300"
/>
);
};
const GroupItem: React.FC<GroupItemProps> = ({
id, name, visible, role, description, setUpdateGroup, setUpdateActive
id,
name,
visible,
role,
description,
setUpdateGroup,
setUpdateActive,
}) => {
const navigate = useNavigate();
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}`)}
>
<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="text-[18px] font-bold">
{name}
</div>
<div className="text-[18px] font-bold">{name}</div>
<div className=" flex gap-[10px]">
{
(role == "menager" || role == "owner") && <IconComponent src={UserAdd}/>
}
{
(role == "menager" || role == "owner") && <IconComponent src={Edit} onClick={() => {
setUpdateGroup({id, name, description });
setUpdateActive(true);
}} />
}
{
visible == false && <IconComponent src={EyeOpen} />
}
{
visible == true && <IconComponent src={EyeClosed} />
}
{(role == 'menager' || role == 'owner') && (
<IconComponent src={UserAdd} />
)}
{(role == 'menager' || role == 'owner') && (
<IconComponent
src={Edit}
onClick={() => {
setUpdateGroup({ id, name, description });
setUpdateActive(true);
}}
/>
)}
{visible == false && <IconComponent src={EyeOpen} />}
{visible == true && <IconComponent src={EyeClosed} />}
</div>
</div>
</div>

View File

@@ -1,12 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { SecondaryButton } from "../../../components/button/SecondaryButton";
import { cn } from "../../../lib/cn";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import GroupsBlock from "./GroupsBlock";
import { setMenuActivePage } from "../../../redux/slices/store";
import { fetchMyGroups } from "../../../redux/slices/groups";
import ModalCreate from "./ModalCreate";
import ModalUpdate from "./ModalUpdate";
import { useEffect, useMemo, useState } from 'react';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { cn } from '../../../lib/cn';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import GroupsBlock from './GroupsBlock';
import { setMenuActivePage } from '../../../redux/slices/store';
import { fetchMyGroups } from '../../../redux/slices/groups';
import ModalCreate from './ModalCreate';
import ModalUpdate from './ModalUpdate';
export interface GroupUpdate {
id: number;
@@ -17,11 +17,14 @@ export interface GroupUpdate {
const Groups = () => {
const [modalActive, setModalActive] = 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 groups = useAppSelector((store) => store.groups.groups);
@@ -29,8 +32,8 @@ const Groups = () => {
const currentUserName = useAppSelector((store) => store.auth.username);
useEffect(() => {
dispatch(setMenuActivePage("groups"));
dispatch(fetchMyGroups())
dispatch(setMenuActivePage('groups'));
dispatch(fetchMyGroups());
}, [dispatch]);
// Разделяем группы
@@ -44,17 +47,23 @@ const Groups = () => {
const hidden: typeof groups = []; // пока пустые, без логики
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.role === "Administrator") {
if (me.role === 'Administrator') {
managed.push(group);
} else {
current.push(group);
}
});
return { managedGroups: managed, currentGroups: current, hiddenGroups: hidden };
return {
managedGroups: managed,
currentGroups: current,
hiddenGroups: hidden,
};
}, [groups, currentUserName]);
return (
@@ -63,13 +72,15 @@ const Groups = () => {
<div className="relative flex items-center mb-[20px]">
<div
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>
<SecondaryButton
onClick={() => { setModalActive(true); }}
onClick={() => {
setModalActive(true);
}}
text="Создать группу"
className="absolute right-0"
/>
@@ -83,7 +94,6 @@ const Groups = () => {
groups={managedGroups}
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
/>
<GroupsBlock
className="mb-[20px]"
@@ -101,7 +111,6 @@ const Groups = () => {
/>
</div>
<ModalCreate setActive={setModalActive} active={modalActive} />
<ModalUpdate
setActive={setModalUpdateActive}

View File

@@ -1,9 +1,9 @@
import { useState, FC } from "react";
import GroupItem from "./GroupItem";
import { cn } from "../../../lib/cn";
import { ChevroneDown } from "../../../assets/icons/groups";
import { Group } from "../../../redux/slices/groups";
import { GroupUpdate } from "./Groups";
import { useState, FC } from 'react';
import GroupItem from './GroupItem';
import { cn } from '../../../lib/cn';
import { ChevroneDown } from '../../../assets/icons/groups';
import { Group } from '../../../redux/slices/groups';
import { GroupUpdate } from './Groups';
interface GroupsBlockProps {
groups: Group[];
@@ -13,46 +13,60 @@ interface GroupsBlockProps {
setUpdateGroup: (value: GroupUpdate) => void;
}
const GroupsBlock: FC<GroupsBlockProps> = ({ groups, title, className, setUpdateActive, setUpdateGroup }) => {
const [active, setActive] = useState<boolean>(title != "Скрытые");
const GroupsBlock: FC<GroupsBlockProps> = ({
groups,
title,
className,
setUpdateActive,
setUpdateGroup,
}) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые');
return (
<div className={cn(" 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(
' 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',
)}
onClick={() => {
setActive(!active)
}}>
setActive(!active);
}}
>
<span>{title}</span>
<img src={ChevroneDown} className={cn("transition-all duration-300",
active && "rotate-180"
)}/>
<img
src={ChevroneDown}
className={cn(
'transition-all duration-300',
active && 'rotate-180',
)}
/>
</div>
<div className={cn(" grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300",
active && "grid-rows-[1fr] opacity-100"
)}>
<div
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="grid grid-cols-3 gap-[20px] pt-[20px] pb-[20px] box-border">
{
groups.map((v, i) => <GroupItem
key={i}
id={v.id}
visible={true}
description={v.description}
setUpdateActive={setUpdateActive}
setUpdateGroup={setUpdateGroup}
role={"owner"}
name={v.name}/>)
}
{groups.map((v, i) => (
<GroupItem
key={i}
id={v.id}
visible={true}
description={v.description}
setUpdateActive={setUpdateActive}
setUpdateGroup={setUpdateGroup}
role={'owner'}
name={v.name}
/>
))}
</div>
</div>
</div>

View File

@@ -1,10 +1,10 @@
import { FC, useEffect, useState } from "react";
import { Modal } from "../../../components/modal/Modal";
import { PrimaryButton } from "../../../components/button/PrimaryButton";
import { SecondaryButton } from "../../../components/button/SecondaryButton";
import { Input } from "../../../components/input/Input";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { createGroup } from "../../../redux/slices/groups";
import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../components/modal/Modal';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { createGroup } from '../../../redux/slices/groups';
interface ModalCreateProps {
active: boolean;
@@ -12,27 +12,63 @@ interface ModalCreateProps {
}
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
const [name, setName] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const status = useAppSelector((state) => state.groups.statuses.create);
const dispatch = useAppDispatch();
useEffect(() => {
if (status == "successful") {
if (status == 'successful') {
setActive(false);
}
}, [status]);
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="font-bold text-[30px]">Создать группу</div>
<Input 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" />
<Input
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]">
<PrimaryButton onClick={() => { dispatch(createGroup({ name, description })) }} text="Создать" disabled={status == "loading"} />
<SecondaryButton onClick={() => { setActive(false); }} text="Отмена" />
<PrimaryButton
onClick={() => {
dispatch(createGroup({ name, description }));
}}
text="Создать"
disabled={status == 'loading'}
/>
<SecondaryButton
onClick={() => {
setActive(false);
}}
text="Отмена"
/>
</div>
</div>
</Modal>
@@ -40,4 +76,3 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
};
export default ModalCreate;

View File

@@ -1,10 +1,10 @@
import { FC, useEffect, useState } from "react";
import { Modal } from "../../../components/modal/Modal";
import { PrimaryButton } from "../../../components/button/PrimaryButton";
import { SecondaryButton } from "../../../components/button/SecondaryButton";
import { Input } from "../../../components/input/Input";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { deleteGroup, updateGroup } from "../../../redux/slices/groups";
import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../components/modal/Modal';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { deleteGroup, updateGroup } from '../../../redux/slices/groups';
interface ModalUpdateProps {
active: boolean;
@@ -14,36 +14,95 @@ interface ModalUpdateProps {
groupDescription: string;
}
const ModalUpdate: FC<ModalUpdateProps> = ({ active, setActive, groupName, 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 ModalUpdate: FC<ModalUpdateProps> = ({
active,
setActive,
groupName,
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();
useEffect(() => {
if (statusUpdate == "successful"){
if (statusUpdate == 'successful') {
setActive(false);
}
}, [statusUpdate]);
useEffect(() => {
if (statusDelete == "successful"){
if (statusDelete == 'successful') {
setActive(false);
}
}, [statusDelete]);
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="font-bold text-[30px]">Изменить группу {groupName} #{groupId}</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="font-bold text-[30px]">
Изменить группу {groupName} #{groupId}
</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]">
<PrimaryButton onClick={() => {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 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
onClick={() => {
dispatch(
updateGroup({ name, description, groupId }),
);
}}
text="Обновить"
disabled={statusUpdate == 'loading'}
/>
<SecondaryButton
onClick={() => {
setActive(false);
}}
text="Отмена"
/>
</div>
</div>
</Modal>
@@ -51,4 +110,3 @@ const ModalUpdate: FC<ModalUpdateProps> = ({ active, setActive, groupName, group
};
export default ModalUpdate;

View File

@@ -1,29 +1,63 @@
import { Logo } from "../../../assets/logos";
import {Account, Clipboard, Cup, Home, Openbook, Users} from "../../../assets/icons/menu";
import MenuItem from "./MenuItem";
import { useAppSelector } from "../../../redux/hooks";
import { Logo } from '../../../assets/logos';
import {
Account,
Clipboard,
Cup,
Home,
Openbook,
Users,
} from '../../../assets/icons/menu';
import MenuItem from './MenuItem';
import { useAppSelector } from '../../../redux/hooks';
const Menu = () => {
const menuItems = [
{text: "Главная", href: "/home", icon: Home, page: "home" },
{text: "Задачи", href: "/home/missions", icon: Clipboard, page: "missions" },
{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);
const menuItems = [
{ text: 'Главная', href: '/home', icon: Home, page: 'home' },
{
text: 'Задачи',
href: '/home/missions',
icon: Clipboard,
page: 'missions',
},
{
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 (
<div className="w-[250px] fixed top-0 items-center box-border p-[20px] pt-[35px]">
<img src={Logo} className="w-[173px]" />
<div className="">
{menuItems.map((v, i) => (
<MenuItem key={i} icon={v.icon} text={v.text} href={v.href} active={v.page == activePage} page={v.page}/>
))}
</div>
</div>
);
return (
<div className="w-[250px] fixed top-0 items-center box-border p-[20px] pt-[35px]">
<img src={Logo} className="w-[173px]" />
<div className="">
{menuItems.map((v, i) => (
<MenuItem
key={i}
icon={v.icon}
text={v.text}
href={v.href}
active={v.page == activePage}
page={v.page}
/>
))}
</div>
</div>
);
};
export default Menu;

View File

@@ -1,37 +1,44 @@
import React from "react";
import { Link } from "react-router-dom";
import { useAppDispatch } from "../../../redux/hooks";
import { setMenuActivePage } from "../../../redux/slices/store";
import React from 'react';
import { Link } from 'react-router-dom';
import { useAppDispatch } from '../../../redux/hooks';
import { setMenuActivePage } from '../../../redux/slices/store';
interface MenuItemProps {
icon: string; // SVG или любой JSX
text: string;
href: string;
page: string;
active?: boolean; // необязательный, по умолчанию false
icon: string; // SVG или любой JSX
text: string;
href: string;
page: string;
active?: boolean; // необязательный, по умолчанию false
}
const MenuItem: React.FC<MenuItemProps> = ({ icon, text = "", href = "", active = false, page = "" }) => {
const dispatch = useAppDispatch();
const MenuItem: React.FC<MenuItemProps> = ({
icon,
text = '',
href = '',
active = false,
page = '',
}) => {
const dispatch = useAppDispatch();
return (
<Link
to={href}
className={`
return (
<Link
to={href}
className={`
flex items-center gap-3 p-[16px] rounded-[10px\] h-[40px] text-[18px] font-bold
transition-all duration-300 text-liquid-white mt-[20px]
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={
() => dispatch(setMenuActivePage(page))
}
>
<img src={icon} />
<span>{text}</span>
</Link>
);
onClick={() => dispatch(setMenuActivePage(page))}
>
<img src={icon} />
<span>{text}</span>
</Link>
);
};
export default MenuItem;

View File

@@ -1,71 +1,78 @@
import { cn } from "../../../lib/cn";
import { IconError, IconSuccess } from "../../../assets/icons/missions";
import { useNavigate } from "react-router-dom";
import { cn } from '../../../lib/cn';
import { IconError, IconSuccess } from '../../../assets/icons/missions';
import { useNavigate } from 'react-router-dom';
export interface MissionItemProps {
id: number;
authorId: number;
name: string;
difficulty: "Easy" | "Medium" | "Hard";
difficulty: 'Easy' | 'Medium' | 'Hard';
tags: string[];
timeLimit: number;
memoryLimit: number;
createdAt: string;
updatedAt: string;
type: "first" | "second";
status: "empty" | "success" | "error";
type: 'first' | 'second';
status: 'empty' | 'success' | 'error';
}
export function formatMilliseconds(ms: number): string {
const rounded = Math.round(ms) / 1000;
const formatted = rounded.toString().replace(/\.?0+$/, '');
return `${formatted} c`;
const rounded = Math.round(ms) / 1000;
const formatted = rounded.toString().replace(/\.?0+$/, '');
return `${formatted} c`;
}
export function formatBytesToMB(bytes: number): string {
const megabytes = Math.floor(bytes / (1024 * 1024));
return `${megabytes} МБ`;
const megabytes = Math.floor(bytes / (1024 * 1024));
return `${megabytes} МБ`;
}
const MissionItem: React.FC<MissionItemProps> = ({
id, name, difficulty, timeLimit, memoryLimit, type, status
id,
name,
difficulty,
timeLimit,
memoryLimit,
type,
status,
}) => {
const navigate = useNavigate();
return (
<div className={cn("h-[44px] w-full relative rounded-[10px] text-liquid-white",
type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
"grid grid-cols-[80px,1fr,1fr,60px,24px] grid-flow-col gap-[20px] px-[20px] box-border items-center",
status == "error" && "border-l-[11px] border-l-liquid-red 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={() => {navigate(`/mission/${id}`)}}
<div
className={cn(
'h-[44px] w-full relative rounded-[10px] text-liquid-white',
type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
'grid grid-cols-[80px,1fr,1fr,60px,24px] grid-flow-col gap-[20px] px-[20px] box-border items-center',
status == 'error' &&
'border-l-[11px] border-l-liquid-red 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={() => {
navigate(`/mission/${id}`);
}}
>
<div className="text-[18px] font-bold">
#{id}
</div>
<div className="text-[18px] font-bold">
{name}
</div>
<div className="text-[18px] font-bold">#{id}</div>
<div className="text-[18px] font-bold">{name}</div>
<div className="text-[12px] text-right">
стандартный ввод/вывод {formatMilliseconds(timeLimit)}, {formatBytesToMB(memoryLimit)}
стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '}
{formatBytesToMB(memoryLimit)}
</div>
<div className={cn(
"text-center text-[18px]",
difficulty == "Hard" && "text-liquid-red",
difficulty == "Medium" && "text-liquid-orange",
difficulty == "Easy" && "text-liquid-green",
)}>
<div
className={cn(
'text-center text-[18px]',
difficulty == 'Hard' && 'text-liquid-red',
difficulty == 'Medium' && 'text-liquid-orange',
difficulty == 'Easy' && 'text-liquid-green',
)}
>
{difficulty}
</div>
<div className="h-[24px] w-[24px]">
{
status == "error" && <img src={IconError}/>
}
{
status == "success" && <img src={IconSuccess}/>
}
{status == 'error' && <img src={IconError} />}
{status == 'success' && <img src={IconSuccess} />}
</div>
</div>
);

View File

@@ -1,18 +1,17 @@
import MissionItem from "./MissionItem";
import { SecondaryButton } from "../../../components/button/SecondaryButton";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { useEffect, useState } from "react";
import { setMenuActivePage } from "../../../redux/slices/store";
import { useNavigate } from "react-router-dom";
import { fetchMissions } from "../../../redux/slices/missions";
import ModalCreate from "./ModalCreate";
import MissionItem from './MissionItem';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useEffect, useState } from 'react';
import { setMenuActivePage } from '../../../redux/slices/store';
import { useNavigate } from 'react-router-dom';
import { fetchMissions } from '../../../redux/slices/missions';
import ModalCreate from './ModalCreate';
export interface Mission {
id: number;
authorId: number;
name: string;
difficulty: "Easy" | "Medium" | "Hard";
difficulty: 'Easy' | 'Medium' | 'Hard';
tags: string[];
timeLimit: number;
memoryLimit: number;
@@ -21,60 +20,60 @@ export interface Mission {
}
const Missions = () => {
const dispatch = useAppDispatch();
const [modalActive, setModalActive] = useState<boolean>(false);
const missions = useAppSelector((state) => state.missions.missions);
useEffect(() => {
dispatch(setMenuActivePage("missions"))
dispatch(fetchMissions({}))
dispatch(setMenuActivePage('missions'));
dispatch(fetchMissions({}));
}, []);
return (
<div className=" h-full w-full box-border p-[20px] pt-[20px]">
<div className="h-full box-border">
<div className="relative flex items-center mb-[20px]">
<div className="h-[50px] text-[40px] font-bold text-liquid-white flex items-center">
Задачи
</div>
<SecondaryButton
onClick={() => {setModalActive(true)}}
<SecondaryButton
onClick={() => {
setModalActive(true);
}}
text="Добавить задачу"
className="absolute right-0"
/>
</div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]">
</div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]"></div>
<div>
{missions.map((v, i) => (
<MissionItem
key={i}
id={v.id}
authorId={v.authorId}
name={v.name}
difficulty={"Easy"}
tags={v.tags}
timeLimit={1000}
memoryLimit={256 * 1024 * 1024}
createdAt={v.createdAt}
updatedAt={v.updatedAt}
type={i % 2 == 0 ? "first" : "second"}
status={i == 0 || i == 3 || i == 7 ? "success" : (i == 2 || i == 4 || i == 9 ? "error" : "empty")}/>
))}
{missions.map((v, i) => (
<MissionItem
key={i}
id={v.id}
authorId={v.authorId}
name={v.name}
difficulty={'Easy'}
tags={v.tags}
timeLimit={1000}
memoryLimit={256 * 1024 * 1024}
createdAt={v.createdAt}
updatedAt={v.updatedAt}
type={i % 2 == 0 ? 'first' : 'second'}
status={
i == 0 || i == 3 || i == 7
? 'success'
: i == 2 || i == 4 || i == 9
? 'error'
: 'empty'
}
/>
))}
</div>
<div>
pages
</div>
<div>pages</div>
</div>
<ModalCreate setActive={setModalActive} active={modalActive} />

View File

@@ -59,6 +59,10 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
}
}, [status]);
useEffect(() => {
dispatch(setMissionsStatus({ key: 'upload', status: 'idle' }));
}, [active]);
return (
<Modal
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="Отмена"
/>
</div>
{status == 'failed' && <div>error</div>}
</div>
</Modal>
);

View File

@@ -1,141 +1,153 @@
import React, { useEffect, useState } from "react";
import Editor from "@monaco-editor/react";
import { upload } from "../../../assets/icons/input";
import { cn } from "../../../lib/cn";
import { DropDownList } from "../../../components/drop-down-list/DropDownList";
import React, { useEffect, useState } from 'react';
import Editor from '@monaco-editor/react';
import { upload } from '../../../assets/icons/input';
import { cn } from '../../../lib/cn';
import { DropDownList } from '../../../components/drop-down-list/DropDownList';
const languageMap: Record<string, string> = {
c: "cpp",
"C++": "cpp",
java: "java",
python: "python",
pascal: "pascal",
kotlin: "kotlin",
csharp: "csharp"
c: 'cpp',
'C++': 'cpp',
java: 'java',
python: 'python',
pascal: 'pascal',
kotlin: 'kotlin',
csharp: 'csharp',
};
export interface CodeEditorProps {
export interface CodeEditorProps {
onChange: (value: string) => void;
onChangeLanguage: (value: string) => void;
}
const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) => {
const [language, setLanguage] = useState<string>("C++");
const [code, setCode] = useState<string>("");
const [isDragging, setIsDragging] = useState<boolean>(false);
const CodeEditor: React.FC<CodeEditorProps> = ({
onChange,
onChangeLanguage,
}) => {
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 = [
{ 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#" },
];
useEffect(() => {
onChange(code);
}, [code]);
useEffect(() => {
onChangeLanguage(language);
}, [language]);
useEffect(() => {
onChange(code);
}, [code])
useEffect(() => {
onChangeLanguage(language);
}, [language])
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result;
if (typeof text === "string") setCode(text);
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result;
if (typeof text === 'string') setCode(text);
};
reader.readAsText(file);
e.target.value = '';
};
reader.readAsText(file);
e.target.value = "";
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
const droppedFile = e.dataTransfer.files[0];
if (!droppedFile) return;
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
const droppedFile = e.dataTransfer.files[0];
if (!droppedFile) return;
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result;
if (typeof text === "string") setCode(text);
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result;
if (typeof text === 'string') setCode(text);
};
reader.readAsText(droppedFile);
};
reader.readAsText(droppedFile);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault(); // обязательно
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault(); // обязательно
};
const handleDragEnter = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragEnter = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
};
return (
<div className="flex flex-col w-full h-full">
{/* Панель выбора языка и загрузки файла */}
<div className="flex items-center justify-between py-3 ">
<div className="flex items-center gap-[20px]">
<DropDownList items={items} onChange={(v) => { setLanguage(v) }} defaultState={{ value: "C++", text: "C++" }}/>
return (
<div className="flex flex-col w-full h-full">
{/* Панель выбора языка и загрузки файла */}
<div className="flex items-center justify-between py-3 ">
<div className="flex items-center gap-[20px]">
<DropDownList
items={items}
onChange={(v) => {
setLanguage(v);
}}
defaultState={{ value: 'C++', text: 'C++' }}
/>
<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%]",
isDragging && "outline-blue-500 "
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
>
<span className="text-[18px] text-liquid-white font-bold pointer-events-none">
{"Загрузить решение"}
</span>
<img src={upload} className="absolute right-[16px] pointer-events-none" />
<input
type="file"
onChange={(e) => handleFileUpload(e)}
className="hidden"
/>
</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%]',
isDragging && 'outline-blue-500 ',
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
>
<span className="text-[18px] text-liquid-white font-bold pointer-events-none">
{'Загрузить решение'}
</span>
<img
src={upload}
className="absolute right-[16px] pointer-events-none"
/>
<input
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>
{/* 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;

View File

@@ -1,28 +1,57 @@
import React from "react";
import { chevroneLeft, chevroneRight, arrowLeft } from "../../../assets/icons/header";
import { Logo } from "../../../assets/logos";
import { useNavigate } from "react-router-dom";
import React from 'react';
import {
chevroneLeft,
chevroneRight,
arrowLeft,
} from '../../../assets/icons/header';
import { Logo } from '../../../assets/logos';
import { useNavigate } from 'react-router-dom';
interface HeaderProps {
missionId: number;
}
const Header: React.FC<HeaderProps> = ({
missionId
}) => {
const Header: React.FC<HeaderProps> = ({ missionId }) => {
const navigate = useNavigate();
return (
<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]">
<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>
<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>
</header>
);
};

View File

@@ -1,113 +1,131 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from 'react';
declare global {
interface Window {
MathJax?: {
startup?: { promise?: Promise<void> };
typesetPromise?: (elements?: Element[]) => Promise<void>;
[key: string]: any;
};
}
interface Window {
MathJax?: {
startup?: { promise?: Promise<void> };
typesetPromise?: (elements?: Element[]) => Promise<void>;
[key: string]: any;
};
}
}
interface MediaFile {
id: number;
fileName: string;
mediaUrl: string;
id: number;
fileName: string;
mediaUrl: string;
}
interface LaTextContainerProps {
html: string;
latex: string;
mediaFiles?: MediaFile[];
html: string;
latex: string;
mediaFiles?: MediaFile[];
}
let mathJaxPromise: Promise<void> | null = null;
const loadMathJax = () => {
if (mathJaxPromise) return mathJaxPromise;
if (mathJaxPromise) return mathJaxPromise;
mathJaxPromise = new Promise<void>((resolve, reject) => {
if (window.MathJax?.typesetPromise) {
resolve();
return;
}
mathJaxPromise = new Promise<void>((resolve, reject) => {
if (window.MathJax?.typesetPromise) {
resolve();
return;
}
(window as any).MathJax = {
tex: {
inlineMath: [["$$$", "$$$"]],
displayMath: [["$$$$$$", "$$$$$$"]],
processEscapes: true,
},
options: {
skipHtmlTags: ["script", "noscript", "style", "textarea", "pre", "code"],
},
startup: { typeset: false },
};
(window as any).MathJax = {
tex: {
inlineMath: [['$$$', '$$$']],
displayMath: [['$$$$$$', '$$$$$$']],
processEscapes: true,
},
options: {
skipHtmlTags: [
'script',
'noscript',
'style',
'textarea',
'pre',
'code',
],
},
startup: { typeset: false },
};
const script = document.createElement("script");
script.id = "mathjax-script";
script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js";
script.async = true;
const script = document.createElement('script');
script.id = 'mathjax-script';
script.src =
'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js';
script.async = true;
script.onload = () => {
window.MathJax?.startup?.promise?.then(resolve).catch(reject);
};
script.onload = () => {
window.MathJax?.startup?.promise?.then(resolve).catch(reject);
};
script.onerror = reject;
document.head.appendChild(script);
});
script.onerror = reject;
document.head.appendChild(script);
});
return mathJaxPromise;
return mathJaxPromise;
};
const replaceImages = (html: string, latex: string, mediaFiles?: MediaFile[]) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const replaceImages = (
html: string,
latex: string,
mediaFiles?: MediaFile[],
) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const latexImageNames = Array.from(latex.matchAll(/\\includegraphics\{(.+?)\}/g)).map(
(match) => match[1]
);
const latexImageNames = Array.from(
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) => {
const imageName = latexImageNames[idx];
if (!imageName || !mediaFiles) return;
const mediaFile = mediaFiles.find((f) => f.fileName === imageName);
if (mediaFile) img.src = mediaFile.mediaUrl;
});
imgs.forEach((img, idx) => {
const imageName = latexImageNames[idx];
if (!imageName || !mediaFiles) return;
const mediaFile = mediaFiles.find((f) => f.fileName === imageName);
if (mediaFile) img.src = mediaFile.mediaUrl;
});
return doc.body.innerHTML;
return doc.body.innerHTML;
};
const LaTextContainer: React.FC<LaTextContainerProps> = ({ html, latex, mediaFiles }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [processedHtml, setProcessedHtml] = useState<string>(html);
const LaTextContainer: React.FC<LaTextContainerProps> = ({
html,
latex,
mediaFiles,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [processedHtml, setProcessedHtml] = useState<string>(html);
// 1⃣ Обновляем HTML при изменении входных данных
useEffect(() => {
setProcessedHtml(replaceImages(html, latex, mediaFiles));
}, [html, latex, mediaFiles]);
// 1⃣ Обновляем HTML при изменении входных данных
useEffect(() => {
setProcessedHtml(replaceImages(html, latex, mediaFiles));
}, [html, latex, mediaFiles]);
// 2⃣ После рендера обновленного HTML применяем MathJax
useEffect(() => {
const renderMath = () => {
if (containerRef.current && window.MathJax?.typesetPromise) {
window.MathJax.typesetPromise([containerRef.current]).catch(console.error);
}
};
// 2⃣ После рендера обновленного HTML применяем MathJax
useEffect(() => {
const renderMath = () => {
if (containerRef.current && window.MathJax?.typesetPromise) {
window.MathJax.typesetPromise([containerRef.current]).catch(
console.error,
);
}
};
loadMathJax().then(renderMath).catch(console.error);
}, [processedHtml]); // 👈 ключевой момент — триггерим именно по processedHtml
loadMathJax().then(renderMath).catch(console.error);
}, [processedHtml]); // 👈 ключевой момент — триггерим именно по processedHtml
return (
<div
className="latex-container"
ref={containerRef}
dangerouslySetInnerHTML={{ __html: processedHtml }}
/>
);
return (
<div
className="latex-container"
ref={containerRef}
dangerouslySetInnerHTML={{ __html: processedHtml }}
/>
);
};
export default LaTextContainer;

View File

@@ -1,14 +1,12 @@
import SubmissionItem from "./SubmissionItem";
import { useAppSelector } from "../../../redux/hooks";
import { FC, useEffect } from "react";
import SubmissionItem from './SubmissionItem';
import { useAppSelector } from '../../../redux/hooks';
import { FC, useEffect } from 'react';
export interface Mission {
id: number;
authorId: number;
name: string;
difficulty: "Easy" | "Medium" | "Hard";
difficulty: 'Easy' | 'Medium' | 'Hard';
tags: string[];
timeLimit: number;
memoryLimit: number;
@@ -16,39 +14,45 @@ export interface Mission {
updatedAt: string;
}
interface MissionSubmissionsProps{
interface MissionSubmissionsProps {
missionId: number;
}
const MissionSubmissions: FC<MissionSubmissionsProps> = ({missionId}) => {
const submissions = useAppSelector((state) => state.submin.submitsById[missionId]);
useEffect(() => {
}, []);
const MissionSubmissions: FC<MissionSubmissionsProps> = ({ missionId }) => {
const submissions = useAppSelector(
(state) => state.submin.submitsById[missionId],
);
useEffect(() => {}, []);
const checkStatus = (status: string) => {
if (status == "IncorrectAnswer")
return "wronganswer";
if (status == "TimeLimitError")
return "timelimit";
if (status == 'IncorrectAnswer') return 'wronganswer';
if (status == 'TimeLimitError') return 'timelimit';
return undefined;
}
};
return (
<div className="h-full w-full box-border overflow-y-scroll overflow-x-hidden thin-scrollbar pr-[10px]">
{submissions && submissions.map((v, i) => (
<SubmissionItem
key={i}
id={v.id}
language={v.solution.language}
time={v.solution.time}
verdict={v.solution.testerMessage?.includes("Compilation failed") ? "Compilation failed" : v.solution.testerMessage}
type={i % 2 ? "second" : "first"}
status={v.solution.testerMessage == "All tests passed" ? "success" : checkStatus(v.solution.testerErrorCode)}
{submissions &&
submissions.map((v, i) => (
<SubmissionItem
key={i}
id={v.id}
language={v.solution.language}
time={v.solution.time}
verdict={
v.solution.testerMessage?.includes(
'Compilation failed',
)
? 'Compilation failed'
: v.solution.testerMessage
}
type={i % 2 ? 'second' : 'first'}
status={
v.solution.testerMessage == 'All tests passed'
? 'success'
: checkStatus(v.solution.testerErrorCode)
}
/>
))}
</div>

View File

@@ -1,53 +1,47 @@
import React, { FC } from "react";
import { cn } from "../../../lib/cn";
import LaTextContainer from "./LaTextContainer";
import { CopyIcon } from "../../../assets/icons/missions";
import React, { FC } from 'react';
import { cn } from '../../../lib/cn';
import LaTextContainer from './LaTextContainer';
import { CopyIcon } from '../../../assets/icons/missions';
// import FullLatexRenderer from "./FullLatexRenderer";
import { useState } from 'react';
import { useState } from "react";
interface CopyableDivPropd{
interface CopyableDivPropd {
content: string;
}
const CopyableDiv: FC<CopyableDivPropd> = ({ content }) => {
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 [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>
);
};
export interface StatementData {
id?: number;
@@ -65,10 +59,10 @@ export interface StatementData {
}
function extractDivByClass(html: string, className: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const div = doc.querySelector(`div.${className}`);
return div ? div.outerHTML : "";
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const div = doc.querySelector(`div.${className}`);
return div ? div.outerHTML : '';
}
const Statement: React.FC<StatementData> = ({
@@ -77,63 +71,110 @@ const Statement: React.FC<StatementData> = ({
tags,
timeLimit = 1000,
memoryLimit = 256 * 1024 * 1024,
legend = "",
input = "",
output = "",
legend = '',
input = '',
output = '',
sampleTests = [],
notes = "",
html = "",
notes = '',
html = '',
mediaFiles,
}) => {
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>
<p className="h-[50px] text-[40px] font-bold text-liquid-white">{name}</p>
<p className="h-[23px] text-[18px] font-bold text-liquid-light">Задача #{id}</p>
<p className="h-[50px] text-[40px] font-bold text-liquid-white">
{name}
</p>
<p className="h-[23px] text-[18px] font-bold text-liquid-light">
Задача #{id}
</p>
</div>
<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 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"><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>
<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">
<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 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 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 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 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 className="text-[14px] font-bold">Входные данные</div>
<CopyableDiv content={v.input}/>
<div className="text-[14px] font-bold">Выходные данные</div>
<CopyableDiv content={v.output}/>
<div className="text-[14px] font-bold">
Входные данные
</div>
<CopyableDiv content={v.input} />
<div className="text-[14px] font-bold">
Выходные данные
</div>
<CopyableDiv content={v.output} />
</div>
)}
))}
</div>
<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>
</div>
);
};
export default Statement;
export default Statement;

View File

@@ -1,4 +1,4 @@
import { cn } from "../../../lib/cn";
import { cn } from '../../../lib/cn';
// import { IconError, IconSuccess } from "../../../assets/icons/missions";
// import { useNavigate } from "react-router-dom";
@@ -7,8 +7,8 @@ export interface SubmissionItemProps {
language: string;
time: string;
verdict: string;
type: "first" | "second";
status?: "success" | "wronganswer" | "timelimit";
type: 'first' | 'second';
status?: 'success' | 'wronganswer' | 'timelimit';
}
export function formatMilliseconds(ms: number): string {
@@ -23,16 +23,16 @@ export function formatBytesToMB(bytes: number): 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 month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
const hours = date.getHours().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> = ({
@@ -46,30 +46,34 @@ const SubmissionItem: React.FC<SubmissionItemProps> = ({
// const navigate = useNavigate();
return (
<div className={cn(" w-full relative rounded-[10px] text-liquid-white",
type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
"grid grid-cols-[80px,1fr,1fr,2fr] grid-flow-col gap-[20px] px-[20px] box-border items-center",
status == "wronganswer" && "border-l-[11px] border-l-liquid-red pl-[9px]",
status == "timelimit" && "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={cn(
' w-full relative rounded-[10px] text-liquid-white',
type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
'grid grid-cols-[80px,1fr,1fr,2fr] grid-flow-col gap-[20px] px-[20px] box-border items-center',
status == 'wronganswer' &&
'border-l-[11px] border-l-liquid-red pl-[9px]',
status == 'timelimit' &&
'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">
#{id}
</div>
<div className="text-[18px] font-bold">#{id}</div>
<div className="text-[18px] font-bold text-center">
{formatDate(time)}
</div>
<div className="text-[18px] font-bold text-center">
{language}
</div>
<div className={cn("text-[18px] font-bold text-center",
status == "wronganswer" && "text-liquid-red",
status == "timelimit" && "text-liquid-orange",
status == "success" && "text-liquid-green",
)} >
<div className="text-[18px] font-bold text-center">{language}</div>
<div
className={cn(
'text-[18px] font-bold text-center',
status == 'wronganswer' && 'text-liquid-red',
status == 'timelimit' && 'text-liquid-orange',
status == 'success' && 'text-liquid-green',
)}
>
{verdict}
</div>
</div>