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 Balloon from './balloon.svg';
import Account from "./account.svg" import Account from './account.svg';
export { Balloon, Account }; export { Balloon, Account };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import React from "react"; import React from 'react';
import { cn } from "../../lib/cn"; import { cn } from '../../lib/cn';
import { eyeClosed, eyeOpen } from "../../assets/icons/input"; import { eyeClosed, eyeOpen } from '../../assets/icons/input';
interface inputProps { interface inputProps {
name?: string; name?: string;
type: "text" | "email" | "password" | "first_name" | "number"; type: 'text' | 'email' | 'password' | 'first_name' | 'number';
error?: string; error?: string;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
@@ -18,72 +18,78 @@ interface inputProps {
} }
export const Input: React.FC<inputProps> = ({ export const Input: React.FC<inputProps> = ({
type = "text", type = 'text',
error = "", error = '',
// disabled = false, // disabled = false,
// required = false, // required = false,
label = "", label = '',
placeholder = "", placeholder = '',
className = "", className = '',
onChange, onChange,
defaultState = "", defaultState = '',
name = "", name = '',
autocomplete = "", autocomplete = '',
onKeyDown, onKeyDown,
}) => { }) => {
const [value, setValue] = React.useState<string>(defaultState); const [value, setValue] = React.useState<string>(defaultState);
const [visible, setVIsible] = React.useState<boolean>(type != "password"); const [visible, setVIsible] = React.useState<boolean>(type != 'password');
React.useEffect(() => onChange(value), [value]); React.useEffect(() => onChange(value), [value]);
React.useEffect(() => setValue(defaultState), [defaultState]); React.useEffect(() => setValue(defaultState), [defaultState]);
return ( return (
<div className={cn( <div className={cn('relative', className)}>
"relative", <div
className className={cn(
)}> 'text-[18px] text-liquid-white font-medium h-[23px] mb-[10px] transition-all',
<div className={cn("text-[18px] text-liquid-white font-medium h-[23px] mb-[10px] transition-all", label == '' && 'h-0 mb-0',
label == "" && "h-0 mb-0" )}
)}> >
{label} {label}
</div> </div>
<div className="relative"> <div className="relative">
<input <input
className={cn( className={cn(
"bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light", 'bg-liquid-lighter w-full rounded-[10px] outline-none pl-[16px] py-[8px] placeholder:text-liquid-light',
type == "password" ? "h-[40px]" : "h-[36px]" type == 'password' ? 'h-[40px]' : 'h-[36px]',
)} )}
value={value} value={value}
name={name} name={name}
autoComplete={autocomplete} autoComplete={autocomplete}
type={type == "password" ? (visible ? "text" : "password") : type} type={
type == 'password'
? visible
? 'text'
: 'password'
: type
}
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => { onChange={(e) => {
setValue(e.target.value); setValue(e.target.value);
}} }}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => { onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (onKeyDown) if (onKeyDown) onKeyDown(e);
onKeyDown(e); }}
}
}
/> />
{ {type == 'password' && (
type == "password" && <img
<img src={visible ? eyeOpen : eyeClosed} className="w-[24px] h-[24px] cursor-pointer right-[16px] top-[8px] absolute" onClick={() => { src={visible ? eyeOpen : eyeClosed}
className="w-[24px] h-[24px] cursor-pointer right-[16px] top-[8px] absolute"
onClick={() => {
setVIsible(!visible); setVIsible(!visible);
}} /> }}
} />
)}
</div> </div>
<div className={cn("text-liquid-red text-[14px] h-[18px] text-right mt-[5px]", <div
error == "" && "h-0 mt-0" className={cn(
)}> 'text-liquid-red text-[14px] h-[18px] text-right mt-[5px]',
error == '' && 'h-0 mt-0',
)}
>
{error} {error}
</div> </div>
</div> </div>
); );
}; };

View File

@@ -1,9 +1,9 @@
import React from "react"; import React from 'react';
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from 'framer-motion';
import { cn } from "../../lib/cn"; import { cn } from '../../lib/cn';
import { useClickOutside } from "../../hooks/useClickOutside"; import { useClickOutside } from '../../hooks/useClickOutside';
type ModalBackdrop = "opaque" | "blur"; type ModalBackdrop = 'opaque' | 'blur';
interface ModalProps { interface ModalProps {
className?: string; className?: string;
@@ -47,9 +47,11 @@ export const Modal: React.FC<ModalProps> = ({
exit={modalbgVariants.closed} exit={modalbgVariants.closed}
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
className={cn( className={cn(
" fixed top-0 left-0 h-svh w-svw backdrop-filter transition-all z-50", ' fixed top-0 left-0 h-svh w-svw backdrop-filter transition-all z-50',
backdrop == "blur" && open && "backdrop-blur-sm", backdrop == 'blur' && open && 'backdrop-blur-sm',
backdrop == "opaque" && open && "bg-[#00000055] pointer-events-none", backdrop == 'opaque' &&
open &&
'bg-[#00000055] pointer-events-none',
)} )}
></motion.div> ></motion.div>
)} )}
@@ -60,8 +62,8 @@ export const Modal: React.FC<ModalProps> = ({
<motion.div <motion.div
ref={ref} ref={ref}
className={cn( className={cn(
"h-fit w-fit rounded-md pointer-events-auto", 'h-fit w-fit rounded-md pointer-events-auto',
className className,
)} )}
initial={modalVariants.closed} initial={modalVariants.closed}
animate={modalVariants.open} animate={modalVariants.open}

View File

@@ -1,48 +1,48 @@
import React from "react"; import React from 'react';
import { cn } from "../../lib/cn"; import { cn } from '../../lib/cn';
/* Варианты размера контейнера */ /* Варианты размера контейнера */
const sizeVariants = { const sizeVariants = {
sm: "h-6 w-10", sm: 'h-6 w-10',
md: "h-7 w-12", md: 'h-7 w-12',
lg: "h-8 w-14", lg: 'h-8 w-14',
}; };
/* Варианты для скользящего шарика */ /* Варианты для скользящего шарика */
const switchVariants = { const switchVariants = {
size: { size: {
sm: "h-4 w-4", sm: 'h-4 w-4',
md: "h-5 w-5", md: 'h-5 w-5',
lg: "h-6 w-6", lg: 'h-6 w-6',
}, },
activeSize: { activeSize: {
sm: "group-active:w-5", sm: 'group-active:w-5',
md: "group-active:w-6", md: 'group-active:w-6',
lg: "group-active:w-7", lg: 'group-active:w-7',
}, },
iconSize: { iconSize: {
sm: "h-3 w-3", sm: 'h-3 w-3',
md: "h-[0.875rem] w-[0.875rem]", md: 'h-[0.875rem] w-[0.875rem]',
lg: "h-4 w-4", lg: 'h-4 w-4',
}, },
}; };
const colorsVariants = { const colorsVariants = {
default: "bg-default", default: 'bg-default',
primary: "bg-liquid-brightmain", primary: 'bg-liquid-brightmain',
secondary: "bg-liquid-darkmain", secondary: 'bg-liquid-darkmain',
success: "bg-liquid-green", success: 'bg-liquid-green',
warning: "bg-liquid-orange", warning: 'bg-liquid-orange',
danger: "bg-liquid-red", danger: 'bg-liquid-red',
}; };
const focuseOutlineVariants = { const focuseOutlineVariants = {
default: "[&:focus-visible+*]:outline-default", default: '[&:focus-visible+*]:outline-default',
primary: "[&:focus-visible+*]:outline-liquid-brightmain", primary: '[&:focus-visible+*]:outline-liquid-brightmain',
secondary: "[&:focus-visible+*]:outline-liquid-darkmain", secondary: '[&:focus-visible+*]:outline-liquid-darkmain',
success: "[&:focus-visible+*]:outline-liquid-green", success: '[&:focus-visible+*]:outline-liquid-green',
warning: "[&:focus-visible+*]:outline-liquid-orange", warning: '[&:focus-visible+*]:outline-liquid-orange',
danger: "[&:focus-visible+*]:outline-liquid-red", danger: '[&:focus-visible+*]:outline-liquid-red',
}; };
/** /**
@@ -74,28 +74,28 @@ const moon = (
); );
interface SwitchProps { interface SwitchProps {
size?: "sm" | "md" | "lg"; size?: 'sm' | 'md' | 'lg';
disabled?: boolean; disabled?: boolean;
color?: color?:
| "default" | 'default'
| "primary" | 'primary'
| "secondary" | 'secondary'
| "success" | 'success'
| "warning" | 'warning'
| "danger"; | 'danger';
label?: string; label?: string;
variant?: "default" | "label" | "icon" | "theme"; variant?: 'default' | 'label' | 'icon' | 'theme';
className?: string; className?: string;
defaultState?: boolean; defaultState?: boolean;
onChange: (state: boolean) => void; onChange: (state: boolean) => void;
} }
export const Switch: React.FC<SwitchProps> = ({ export const Switch: React.FC<SwitchProps> = ({
size = "sm", size = 'sm',
disabled = false, disabled = false,
color = "primary", color = 'primary',
label = "", label = '',
variant = "default", variant = 'default',
className, className,
onChange, onChange,
defaultState = false, defaultState = false,
@@ -107,25 +107,25 @@ export const Switch: React.FC<SwitchProps> = ({
return ( return (
<label <label
className={cn( className={cn(
variant == "label" && "grid-cols-[auto_1fr] items-center gap-2", variant == 'label' && 'grid-cols-[auto_1fr] items-center gap-2',
"grid relative cursor-pointer p-2 select-none group", 'grid relative cursor-pointer p-2 select-none group',
disabled && "pointer-events-none opacity-50", disabled && 'pointer-events-none opacity-50',
className className,
)} )}
> >
{/* Основной контейнер, */} {/* Основной контейнер, */}
<div <div
className={cn( className={cn(
" flex items-center justify-center box-border z-10 relative transition-all duration-300 rounded-full", ' flex items-center justify-center box-border z-10 relative transition-all duration-300 rounded-full',
sizeVariants[size], sizeVariants[size],
active ? colorsVariants[color] : "bg-default-200" active ? colorsVariants[color] : 'bg-default-200',
)} )}
> >
{/* Скрытый checkbox */} {/* Скрытый checkbox */}
<input <input
className={cn( className={cn(
"absolute opacity-0 -z-10 h-0 w-0", 'absolute opacity-0 -z-10 h-0 w-0',
focuseOutlineVariants[color] focuseOutlineVariants[color],
)} )}
disabled={disabled} disabled={disabled}
type="checkbox" type="checkbox"
@@ -136,38 +136,42 @@ export const Switch: React.FC<SwitchProps> = ({
<div <div
className={cn( className={cn(
"absolute outline-offset-[2.5px] outline-[2.5px] outline outline-transparent transition-all duration-300 rounded-full", 'absolute outline-offset-[2.5px] outline-[2.5px] outline outline-transparent transition-all duration-300 rounded-full',
sizeVariants[size] sizeVariants[size],
)} )}
></div> ></div>
{/* Шарик */} {/* Шарик */}
<span <span
className={cn( className={cn(
"bg-white rounded-full absolute transition-all duration-300 m-1 flex items-center justify-center", 'bg-white rounded-full absolute transition-all duration-300 m-1 flex items-center justify-center',
switchVariants.size[size], switchVariants.size[size],
switchVariants.activeSize[size], switchVariants.activeSize[size],
active active
? "right-[0%]" ? 'right-[0%]'
: "right-[calc(50%-0.25rem)] group-active:right-[calc(50%-0.5rem)]" : 'right-[calc(50%-0.25rem)] group-active:right-[calc(50%-0.5rem)]',
)} )}
> >
{variant == "theme" && ( {variant == 'theme' && (
<> <>
<div <div
className={cn( className={cn(
"absolute transition-all duration-300", 'absolute transition-all duration-300',
switchVariants.iconSize[size], switchVariants.iconSize[size],
active ? "opacity-100 scale-100" : "opacity-0 scale-50" active
? 'opacity-100 scale-100'
: 'opacity-0 scale-50',
)} )}
> >
{moon} {moon}
</div> </div>
<div <div
className={cn( className={cn(
"absolute transition-all duration-300", 'absolute transition-all duration-300',
switchVariants.iconSize[size], switchVariants.iconSize[size],
active ? "opacity-0 scale-50" : "opacity-100 scale-100" active
? 'opacity-0 scale-50'
: 'opacity-100 scale-100',
)} )}
> >
{sun} {sun}
@@ -177,7 +181,7 @@ export const Switch: React.FC<SwitchProps> = ({
</span> </span>
</div> </div>
{variant == "label" && ( {variant == 'label' && (
<div className="select-none text-layout-foreground transition-all duration-200"> <div className="select-none text-layout-foreground transition-all duration-200">
{label} {label}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,66 +1,80 @@
import { Route, Routes, useNavigate } from "react-router-dom"; import { Route, Routes, useNavigate } from 'react-router-dom';
import Header from '../views/articleeditor/Header'; import Header from '../views/articleeditor/Header';
import MarkdownEditor from "../views/articleeditor/Editor"; import MarkdownEditor from '../views/articleeditor/Editor';
import { useState } from "react"; import { useState } from 'react';
import { PrimaryButton } from "../components/button/PrimaryButton"; import { PrimaryButton } from '../components/button/PrimaryButton';
import MarkdownPreview from "../views/articleeditor/MarckDownPreview"; import MarkdownPreview from '../views/articleeditor/MarckDownPreview';
import { Input } from "../components/input/Input"; import { Input } from '../components/input/Input';
const ArticleEditor = () => { const ArticleEditor = () => {
const [code, setCode] = useState<string>(""); const [code, setCode] = useState<string>('');
const [name, setName] = useState<string>(""); const [name, setName] = useState<string>('');
const navigate = useNavigate(); const navigate = useNavigate();
const [tagInput, setTagInput] = useState<string>('');
const [tagInput, setTagInput] = useState<string>("");
const [tags, setTags] = useState<string[]>([]); const [tags, setTags] = useState<string[]>([]);
const addTag = () => { const addTag = () => {
const newTag = tagInput.trim(); const newTag = tagInput.trim();
if (newTag && !tags.includes(newTag)) { if (newTag && !tags.includes(newTag)) {
setTags([...tags, newTag]); setTags([...tags, newTag]);
setTagInput(""); setTagInput('');
} }
}; };
const removeTag = (tagToRemove: string) => { const removeTag = (tagToRemove: string) => {
setTags(tags.filter(tag => tag !== tagToRemove)); setTags(tags.filter((tag) => tag !== tagToRemove));
}; };
return ( return (
<div className="h-screen grid grid-rows-[60px,1fr]"> <div className="h-screen grid grid-rows-[60px,1fr]">
<Routes> <Routes>
<Route path="editor" element={<Header backUrl="/article/create" />} /> <Route
path="editor"
element={<Header backUrl="/article/create" />}
/>
<Route path="*" element={<Header backUrl="/home/articles" />} /> <Route path="*" element={<Header backUrl="/home/articles" />} />
</Routes> </Routes>
<Routes> <Routes>
<Route path="editor" element={<MarkdownEditor onChange={setCode} />} /> <Route
<Route path="*" element={ path="editor"
element={<MarkdownEditor onChange={setCode} />}
/>
<Route
path="*"
element={
<div className="text-liquid-white"> <div className="text-liquid-white">
<div className="text-[40px] font-bold">Создание статьи</div> <div className="text-[40px] font-bold">
Создание статьи
</div>
<PrimaryButton
<PrimaryButton onClick={() => { onClick={() => {
console.log({ console.log({
name: name, name: name,
tags: tags, tags: tags,
text: code, text: code,
}) });
}}
}} text="Опубликовать" className="mt-[20px]" /> text="Опубликовать"
className="mt-[20px]"
/>
<Input name="articleName" autocomplete="articleName" className="mt-[20px] max-w-[600px]" type="text" label="Название" onChange={(v) => { setName(v) }} placeholder="Новая статья" />
<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="mt-[20px] max-w-[600px]">
<div className="grid grid-cols-[1fr,140px] items-end gap-2"> <div className="grid grid-cols-[1fr,140px] items-end gap-2">
<Input <Input
name="articleTag" name="articleTag"
@@ -68,36 +82,52 @@ const ArticleEditor = () => {
className="mt-[20px] max-w-[600px]" className="mt-[20px] max-w-[600px]"
type="text" type="text"
label="Теги" label="Теги"
onChange={(v) => { setTagInput(v) }} onChange={(v) => {
setTagInput(v);
}}
defaultState={tagInput} defaultState={tagInput}
placeholder="arrays" placeholder="arrays"
onKeyDown={(e) => { onKeyDown={(e) => {
console.log(e.key); console.log(e.key);
if (e.key == "Enter") if (e.key == 'Enter') addTag();
addTag(); }}
} />
} <PrimaryButton
onClick={addTag}
text="Добавить"
className="h-[40px] w-[140px]"
/> />
<PrimaryButton onClick={addTag} text="Добавить" className="h-[40px] w-[140px]" />
</div> </div>
<div className="flex flex-wrap gap-[10px] mt-2"> <div className="flex flex-wrap gap-[10px] mt-2">
{tags.map(tag => ( {tags.map((tag) => (
<div <div
key={tag} key={tag}
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full" className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
> >
<span>{tag}</span> <span>{tag}</span>
<button onClick={() => removeTag(tag)} className="text-liquid-red font-bold ml-[5px]">×</button> <button
onClick={() => removeTag(tag)}
className="text-liquid-red font-bold ml-[5px]"
>
×
</button>
</div> </div>
))} ))}
</div> </div>
</div> </div>
<PrimaryButton onClick={() => navigate("editor")} text="Редактировать текст" className="mt-[20px]" /> <PrimaryButton
<MarkdownPreview content={code} className="bg-transparent border-liquid-lighter border-[3px] rounder-[20px] mt-[20px]" /> onClick={() => navigate('editor')}
text="Редактировать текст"
className="mt-[20px]"
/>
<MarkdownPreview
content={code}
className="bg-transparent border-liquid-lighter border-[3px] rounder-[20px] mt-[20px]"
/>
</div> </div>
} /> }
/>
</Routes> </Routes>
</div> </div>
); );

View File

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

View File

@@ -10,7 +10,6 @@ import Header from '../views/mission/statement/Header';
import MissionSubmissions from '../views/mission/statement/MissionSubmissions'; import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
const Mission = () => { const Mission = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
// Получаем параметры из URL // Получаем параметры из URL
@@ -21,26 +20,25 @@ const Mission = () => {
return <Navigate to="/home" replace />; return <Navigate to="/home" replace />;
} }
const [code, setCode] = useState<string>(""); const [code, setCode] = useState<string>('');
const [language, setLanguage] = useState<string>(""); const [language, setLanguage] = useState<string>('');
const pollingRef = useRef<number | null>(null); const pollingRef = useRef<number | null>(null);
const submissions = useAppSelector((state) => state.submin.submitsById[missionIdNumber] || []); const submissions = useAppSelector(
(state) => state.submin.submitsById[missionIdNumber] || [],
);
const submissionsRef = useRef(submissions); const submissionsRef = useRef(submissions);
const startPolling = () => { const startPolling = () => {
if (pollingRef.current) if (pollingRef.current) return;
return;
pollingRef.current = setInterval(async () => { pollingRef.current = setInterval(async () => {
dispatch(fetchMySubmitsByMission(missionIdNumber)); dispatch(fetchMySubmitsByMission(missionIdNumber));
const hasWaiting = submissionsRef.current.some( const hasWaiting = submissionsRef.current.some(
(s: any) => s.solution.status == "Waiting" || s.solution.testerState === "Waiting" (s: any) =>
s.solution.status == 'Waiting' ||
s.solution.testerState === 'Waiting',
); );
if (!hasWaiting) { if (!hasWaiting) {
// Всё проверено — стоп // Всё проверено — стоп
@@ -52,15 +50,12 @@ const Mission = () => {
}, 5000); // 10 секунд }, 5000); // 10 секунд
}; };
useEffect(() => { useEffect(() => {
dispatch(fetchMissionById(missionIdNumber)); dispatch(fetchMissionById(missionIdNumber));
dispatch(fetchMySubmitsByMission(missionIdNumber)); dispatch(fetchMySubmitsByMission(missionIdNumber));
}, [missionIdNumber]); }, [missionIdNumber]);
useEffect(() => { useEffect(() => {}, [submissions]);
}, [submissions]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -71,13 +66,14 @@ const Mission = () => {
}; };
}, []); }, []);
useEffect(() => { useEffect(() => {
submissionsRef.current = submissions; submissionsRef.current = submissions;
if (submissions.length) { if (submissions.length) {
const hasWaiting = submissions.some( const hasWaiting = submissions.some(
s => s.solution.status === "Waiting" || s.solution.testerState === "Waiting" (s) =>
s.solution.status === 'Waiting' ||
s.solution.testerState === 'Waiting',
); );
if (hasWaiting) { if (hasWaiting) {
@@ -86,7 +82,6 @@ const Mission = () => {
} }
}, [submissions]); }, [submissions]);
if (!mission || !mission.statements || mission.statements.length === 0) { if (!mission || !mission.statements || mission.statements.length === 0) {
return <div>Загрузка...</div>; return <div>Загрузка...</div>;
} }
@@ -111,19 +106,23 @@ const Mission = () => {
try { try {
// 1. Берём первый statement с форматом Latex и языком russian // 1. Берём первый statement с форматом Latex и языком russian
const latexStatement = mission.statements.find( const latexStatement = mission.statements.find(
(stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Latex" (stmt: any) =>
stmt && stmt.language === 'russian' && stmt.format === 'Latex',
); );
// 2. Берём первый statement с форматом Html и языком russian // 2. Берём первый statement с форматом Html и языком russian
const htmlStatement = mission.statements.find( const htmlStatement = mission.statements.find(
(stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Html" (stmt: any) =>
stmt && stmt.language === 'russian' && stmt.format === 'Html',
); );
if (!latexStatement) throw new Error("Не найден блок Latex на русском"); if (!latexStatement) throw new Error('Не найден блок Latex на русском');
if (!htmlStatement) throw new Error("Не найден блок Html на русском"); if (!htmlStatement) throw new Error('Не найден блок Html на русском');
// 3. Парсим данные из problem-properties.json // 3. Парсим данные из problem-properties.json
const statementTexts = JSON.parse(latexStatement.statementTexts["problem-properties.json"]); const statementTexts = JSON.parse(
latexStatement.statementTexts['problem-properties.json'],
);
statementData = { statementData = {
id: missionIdNumber, id: missionIdNumber,
@@ -136,18 +135,14 @@ const Mission = () => {
memoryLimit: statementTexts.memoryLimit, memoryLimit: statementTexts.memoryLimit,
tags: mission.tags, tags: mission.tags,
notes: statementTexts.notes, notes: statementTexts.notes,
html: htmlStatement.statementTexts["problem.html"], html: htmlStatement.statementTexts['problem.html'],
mediaFiles: latexStatement.mediaFiles mediaFiles: latexStatement.mediaFiles,
}; };
} catch (err) { } catch (err) {
console.error("Ошибка парсинга statementTexts:", err); console.error('Ошибка парсинга statementTexts:', err);
} }
return ( return (
<div className="h-screen grid grid-rows-[60px,1fr]"> <div className="h-screen grid grid-rows-[60px,1fr]">
<div className=""> <div className="">
<Header missionId={missionIdNumber} /> <Header missionId={missionIdNumber} />
@@ -155,35 +150,44 @@ const Mission = () => {
<div className="grid grid-cols-2 h-full min-h-0 gap-[20px]"> <div className="grid grid-cols-2 h-full min-h-0 gap-[20px]">
<div className="overflow-y-auto min-h-0 overflow-hidden"> <div className="overflow-y-auto min-h-0 overflow-hidden">
<Statement <Statement {...statementData} />
{...statementData}
/>
</div> </div>
<div className="overflow-y-auto min-h-0 overflow-hidden pb-[20px]"> <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=" grid grid-rows-[1fr,45px,230px] grid-flow-row h-full w-full gap-[20px] ">
<div className='w-full relative '> <div className="w-full relative ">
<CodeEditor <CodeEditor
onChange={(value: string) => { setCode(value); }} onChange={(value: string) => {
onChangeLanguage={((value: string) => { setLanguage(value); })} setCode(value);
}}
onChangeLanguage={(value: string) => {
setLanguage(value);
}}
/> />
</div> </div>
<div> <div>
<PrimaryButton text='Отправить' onClick={async () => { <PrimaryButton
await dispatch(submitMission({ text="Отправить"
onClick={async () => {
await dispatch(
submitMission({
missionId: missionIdNumber, missionId: missionIdNumber,
language: language, language: language,
languageVersion: "latest", languageVersion: 'latest',
sourceCode: code, sourceCode: code,
contestId: null, contestId: null,
}),
})).unwrap(); ).unwrap();
dispatch(fetchMySubmitsByMission(missionIdNumber)); dispatch(
}} /> fetchMySubmitsByMission(
missionIdNumber,
),
);
}}
/>
</div> </div>
<div className='h-full w-full '> <div className="h-full w-full ">
<MissionSubmissions missionId={missionIdNumber} /> <MissionSubmissions missionId={missionIdNumber} />
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from "../../axios"; import axios from '../../axios';
// Типы данных // Типы данных
export interface Submit { export interface Submit {
@@ -39,7 +39,7 @@ interface SubmitState {
submits: Submit[]; submits: Submit[];
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
currentSubmit?: Submit; currentSubmit?: Submit;
status: "idle" | "loading" | "successful" | "failed"; status: 'idle' | 'loading' | 'successful' | 'failed';
error: string | null; error: string | null;
} }
@@ -48,70 +48,81 @@ const initialState: SubmitState = {
submits: [], submits: [],
submitsById: {}, // ✅ инициализация submitsById: {}, // ✅ инициализация
currentSubmit: undefined, currentSubmit: undefined,
status: "idle", status: 'idle',
error: null, error: null,
}; };
// AsyncThunk: Отправка решения // AsyncThunk: Отправка решения
export const submitMission = createAsyncThunk( export const submitMission = createAsyncThunk(
"submit/submitMission", 'submit/submitMission',
async (submitData: Submit, { rejectWithValue }) => { async (submitData: Submit, { rejectWithValue }) => {
try { try {
const response = await axios.post("/submits", submitData); const response = await axios.post('/submits', submitData);
return response.data; return response.data;
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Submit failed"); return rejectWithValue(
} err.response?.data?.message || 'Submit failed',
);
} }
},
); );
// AsyncThunk: Получить все свои отправки // AsyncThunk: Получить все свои отправки
export const fetchMySubmits = createAsyncThunk( export const fetchMySubmits = createAsyncThunk(
"submit/fetchMySubmits", 'submit/fetchMySubmits',
async (_, { rejectWithValue }) => { async (_, { rejectWithValue }) => {
try { try {
const response = await axios.get("/submits/my"); const response = await axios.get('/submits/my');
return response.data as Submit[]; return response.data as Submit[];
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch submits"); return rejectWithValue(
} err.response?.data?.message || 'Failed to fetch submits',
);
} }
},
); );
// AsyncThunk: Получить конкретную отправку по ID // AsyncThunk: Получить конкретную отправку по ID
export const fetchSubmitById = createAsyncThunk( export const fetchSubmitById = createAsyncThunk(
"submit/fetchSubmitById", 'submit/fetchSubmitById',
async (id: number, { rejectWithValue }) => { async (id: number, { rejectWithValue }) => {
try { try {
const response = await axios.get(`/submits/${id}`); const response = await axios.get(`/submits/${id}`);
return response.data as Submit; return response.data as Submit;
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch submit"); return rejectWithValue(
} err.response?.data?.message || 'Failed to fetch submit',
);
} }
},
); );
// ✅ AsyncThunk: Получить отправки для конкретной миссии (новая структура) // ✅ AsyncThunk: Получить отправки для конкретной миссии (новая структура)
export const fetchMySubmitsByMission = createAsyncThunk( export const fetchMySubmitsByMission = createAsyncThunk(
"submit/fetchMySubmitsByMission", 'submit/fetchMySubmitsByMission',
async (missionId: number, { rejectWithValue }) => { async (missionId: number, { rejectWithValue }) => {
try { try {
const response = await axios.get(`/submits/my/mission/${missionId}`); const response = await axios.get(
`/submits/my/mission/${missionId}`,
);
return { missionId, data: response.data as MissionSubmit[] }; return { missionId, data: response.data as MissionSubmit[] };
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch mission submits"); return rejectWithValue(
} err.response?.data?.message ||
'Failed to fetch mission submits',
);
} }
},
); );
// Slice // Slice
const submitSlice = createSlice({ const submitSlice = createSlice({
name: "submit", name: 'submit',
initialState, initialState,
reducers: { reducers: {
clearCurrentSubmit: (state) => { clearCurrentSubmit: (state) => {
state.currentSubmit = undefined; state.currentSubmit = undefined;
state.status = "idle"; state.status = 'idle';
state.error = null; state.error = null;
}, },
clearSubmitsByMission: (state, action: PayloadAction<number>) => { clearSubmitsByMission: (state, action: PayloadAction<number>) => {
@@ -121,64 +132,93 @@ const submitSlice = createSlice({
extraReducers: (builder) => { extraReducers: (builder) => {
// Отправка решения // Отправка решения
builder.addCase(submitMission.pending, (state) => { builder.addCase(submitMission.pending, (state) => {
state.status = "loading"; state.status = 'loading';
state.error = null; state.error = null;
}); });
builder.addCase(submitMission.fulfilled, (state, action: PayloadAction<Submit>) => { builder.addCase(
state.status = "successful"; submitMission.fulfilled,
(state, action: PayloadAction<Submit>) => {
state.status = 'successful';
state.submits.push(action.payload); state.submits.push(action.payload);
}); },
builder.addCase(submitMission.rejected, (state, action: PayloadAction<any>) => { );
state.status = "failed"; builder.addCase(
submitMission.rejected,
(state, action: PayloadAction<any>) => {
state.status = 'failed';
state.error = action.payload; state.error = action.payload;
}); },
);
// Получить все свои отправки // Получить все свои отправки
builder.addCase(fetchMySubmits.pending, (state) => { builder.addCase(fetchMySubmits.pending, (state) => {
state.status = "loading"; state.status = 'loading';
state.error = null; state.error = null;
}); });
builder.addCase(fetchMySubmits.fulfilled, (state, action: PayloadAction<Submit[]>) => { builder.addCase(
state.status = "successful"; fetchMySubmits.fulfilled,
(state, action: PayloadAction<Submit[]>) => {
state.status = 'successful';
state.submits = action.payload; state.submits = action.payload;
}); },
builder.addCase(fetchMySubmits.rejected, (state, action: PayloadAction<any>) => { );
state.status = "failed"; builder.addCase(
fetchMySubmits.rejected,
(state, action: PayloadAction<any>) => {
state.status = 'failed';
state.error = action.payload; state.error = action.payload;
}); },
);
// Получить отправку по ID // Получить отправку по ID
builder.addCase(fetchSubmitById.pending, (state) => { builder.addCase(fetchSubmitById.pending, (state) => {
state.status = "loading"; state.status = 'loading';
state.error = null; state.error = null;
}); });
builder.addCase(fetchSubmitById.fulfilled, (state, action: PayloadAction<Submit>) => { builder.addCase(
state.status = "successful"; fetchSubmitById.fulfilled,
(state, action: PayloadAction<Submit>) => {
state.status = 'successful';
state.currentSubmit = action.payload; state.currentSubmit = action.payload;
}); },
builder.addCase(fetchSubmitById.rejected, (state, action: PayloadAction<any>) => { );
state.status = "failed"; builder.addCase(
fetchSubmitById.rejected,
(state, action: PayloadAction<any>) => {
state.status = 'failed';
state.error = action.payload; state.error = action.payload;
}); },
);
// ✅ Получить отправки по миссии // ✅ Получить отправки по миссии
builder.addCase(fetchMySubmitsByMission.pending, (state) => { builder.addCase(fetchMySubmitsByMission.pending, (state) => {
state.status = "loading"; state.status = 'loading';
state.error = null; state.error = null;
}); });
builder.addCase( builder.addCase(
fetchMySubmitsByMission.fulfilled, fetchMySubmitsByMission.fulfilled,
(state, action: PayloadAction<{ missionId: number; data: MissionSubmit[] }>) => { (
state.status = "successful"; state,
state.submitsById[action.payload.missionId] = action.payload.data; 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>) => { builder.addCase(
state.status = "failed"; fetchMySubmitsByMission.rejected,
(state, action: PayloadAction<any>) => {
state.status = 'failed';
state.error = action.payload; state.error = action.payload;
}); },
);
}, },
}); });
export const { clearCurrentSubmit, clearSubmitsByMission } = submitSlice.actions; export const { clearCurrentSubmit, clearSubmitsByMission } =
submitSlice.actions;
export const submitReducer = submitSlice.reducer; export const submitReducer = submitSlice.reducer;

View File

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

View File

@@ -2,14 +2,13 @@
@import 'tailwindcss/components'; @import 'tailwindcss/components';
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
@import "./latex-container.css"; @import './latex-container.css';
* { * {
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/ -webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/
/* outline: 1px solid green; */ /* outline: 1px solid green; */
} }
:root { :root {
color-scheme: light dark; color-scheme: light dark;
width: 100%; width: 100%;
@@ -40,7 +39,6 @@ body {
margin: 0; margin: 0;
} }
/* Общий контейнер полосы прокрутки */ /* Общий контейнер полосы прокрутки */
.thin-scrollbar::-webkit-scrollbar { .thin-scrollbar::-webkit-scrollbar {
width: 4px; /* ширина вертикального */ width: 4px; /* ширина вертикального */
@@ -58,7 +56,6 @@ body {
cursor: pointer; cursor: pointer;
} }
/* Общий контейнер полосы прокрутки */ /* Общий контейнер полосы прокрутки */
.medium-scrollbar::-webkit-scrollbar { .medium-scrollbar::-webkit-scrollbar {
width: 8px; /* ширина вертикального */ width: 8px; /* ширина вертикального */
@@ -76,8 +73,6 @@ body {
cursor: pointer; cursor: pointer;
} }
/* Общий контейнер полосы прокрутки */ /* Общий контейнер полосы прокрутки */
.thin-dark-scrollbar::-webkit-scrollbar { .thin-dark-scrollbar::-webkit-scrollbar {
width: 4px; /* ширина вертикального */ width: 4px; /* ширина вертикального */
@@ -95,9 +90,6 @@ body {
cursor: pointer; cursor: pointer;
} }
html { html {
scrollbar-gutter: stable; scrollbar-gutter: stable;
padding-left: 8px; padding-left: 8px;

View File

@@ -1,4 +1,3 @@
.latex-container p { .latex-container p {
text-align: justify; /* выравнивание по ширине */ text-align: justify; /* выравнивание по ширине */
text-justify: inter-word; text-justify: inter-word;
@@ -11,7 +10,7 @@
padding-left: 1.5em; /* отступ для нумерации */ padding-left: 1.5em; /* отступ для нумерации */
margin: 0.5em 0; /* небольшой отступ сверху и снизу */ margin: 0.5em 0; /* небольшой отступ сверху и снизу */
line-height: 1.5; /* удобный межстрочный интервал */ line-height: 1.5; /* удобный межстрочный интервал */
font-family: "Inter", sans-serif; font-family: 'Inter', sans-serif;
font-size: 1rem; font-size: 1rem;
} }
@@ -23,4 +22,3 @@
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
import { FC } from "react"; import { FC } from 'react';
import ReactMarkdown from "react-markdown"; import ReactMarkdown from 'react-markdown';
import remarkGfm from "remark-gfm"; import remarkGfm from 'remark-gfm';
import rehypeHighlight from "rehype-highlight"; import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from "rehype-raw"; import rehypeRaw from 'rehype-raw';
import rehypeSanitize from "rehype-sanitize"; import rehypeSanitize from 'rehype-sanitize';
import "highlight.js/styles/github-dark.css"; import 'highlight.js/styles/github-dark.css';
import { defaultSchema } from "hast-util-sanitize"; import { defaultSchema } from 'hast-util-sanitize';
import { cn } from "../../lib/cn"; import { cn } from '../../lib/cn';
const schema = { const schema = {
...defaultSchema, ...defaultSchema,
@@ -15,9 +15,9 @@ const schema = {
...defaultSchema.attributes, ...defaultSchema.attributes,
div: [ div: [
...(defaultSchema.attributes?.div || []), ...(defaultSchema.attributes?.div || []),
["style"] // разрешаем атрибут style на div ['style'], // разрешаем атрибут style на div
] ],
} },
}; };
interface MarkdownPreviewProps { interface MarkdownPreviewProps {
@@ -25,13 +25,25 @@ interface MarkdownPreviewProps {
className?: string; className?: string;
} }
const MarkdownPreview: FC<MarkdownPreviewProps> = ({ content, className="" }) => { const MarkdownPreview: FC<MarkdownPreviewProps> = ({
content,
className = '',
}) => {
return ( return (
<div className={cn("flex-1 bg-[#161b22] rounded-lg shadow-lg p-6", className)}> <div
className={cn(
'flex-1 bg-[#161b22] rounded-lg shadow-lg p-6',
className,
)}
>
<div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar"> <div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, schema], rehypeHighlight]} rehypePlugins={[
rehypeRaw,
[rehypeSanitize, schema],
rehypeHighlight,
]}
> >
{content} {content}
</ReactMarkdown> </ReactMarkdown>

View File

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

View File

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

View File

@@ -1,37 +1,36 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
import { PrimaryButton } from "../../../components/button/PrimaryButton"; import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from "../../../components/input/Input"; import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from 'react-router-dom';
import { loginUser } from "../../../redux/slices/auth"; import { loginUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn"; // import { cn } from "../../../lib/cn";
import { setMenuActivePage } from "../../../redux/slices/store"; import { setMenuActivePage } from '../../../redux/slices/store';
import { Balloon } from "../../../assets/icons/auth"; import { Balloon } from '../../../assets/icons/auth';
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { googleLogo } from "../../../assets/icons/input"; import { googleLogo } from '../../../assets/icons/input';
const Login = () => { const Login = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const [username, setUsername] = useState<string>(""); const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>(""); const [password, setPassword] = useState<string>('');
const [submitClicked, setSubmitClicked] = useState<boolean>(false); const [submitClicked, setSubmitClicked] = useState<boolean>(false);
const { status, jwt } = useAppSelector((state) => state.auth); const { status, jwt } = useAppSelector((state) => state.auth);
// const [err, setErr] = useState<string>(""); // const [err, setErr] = useState<string>("");
// После успешного логина // После успешного логина
useEffect(() => { useEffect(() => {
dispatch(setMenuActivePage("account")) dispatch(setMenuActivePage('account'));
console.log(submitClicked); console.log(submitClicked);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (jwt) { if (jwt) {
navigate("/home/offices"); // или другая страница после входа navigate('/home/offices'); // или другая страница после входа
} }
}, [jwt]); }, [jwt]);
@@ -60,51 +59,72 @@ const Login = () => {
</div> </div>
</div> </div>
<Input
<Input name="login" autocomplete="login" className="mt-[10px]" type="text" label="Логин" onChange={(v) => { setUsername(v) }} placeholder="login" /> name="login"
<Input name="password" autocomplete="password" className="mt-[10px]" type="password" label="Пароль" onChange={(v) => { setPassword(v) }} placeholder="abCD1234" /> 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]"> <div className="flex justify-end mt-[10px]">
<Link <Link
to={""} to={''}
className={"text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline "}> className={
'text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline '
}
>
Забыли пароль? Забыли пароль?
</Link> </Link>
</div> </div>
<div className="mt-[10px]"> <div className="mt-[10px]">
<PrimaryButton <PrimaryButton
className="w-full mb-[8px]" className="w-full mb-[8px]"
onClick={handleLogin} onClick={handleLogin}
text={status === "loading" ? "Вход..." : "Вход"} text={status === 'loading' ? 'Вход...' : 'Вход'}
disabled={status === "loading"} disabled={status === 'loading'}
/> />
<SecondaryButton <SecondaryButton className="w-full" onClick={() => {}}>
className="w-full"
onClick={() => { }}
>
<div className="flex items-center"> <div className="flex items-center">
<img src={googleLogo} className="h-[24px] w-[24px] mr-[15px]" /> <img
src={googleLogo}
className="h-[24px] w-[24px] mr-[15px]"
/>
Вход с Google Вход с Google
</div> </div>
</SecondaryButton> </SecondaryButton>
</div> </div>
<div className="flex justify-center mt-[10px]"> <div className="flex justify-center mt-[10px]">
<span> <span>
Нет аккаунта? <Link Нет аккаунта?{' '}
to={"/home/register"} <Link
className={"text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline "}> to={'/home/register'}
className={
'text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline '
}
>
Регистрация Регистрация
</Link> </Link>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );

View File

@@ -1,26 +1,25 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
import { PrimaryButton } from "../../../components/button/PrimaryButton"; import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from "../../../components/input/Input"; import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { registerUser } from "../../../redux/slices/auth"; import { registerUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn"; // import { cn } from "../../../lib/cn";
import { setMenuActivePage } from "../../../redux/slices/store"; import { setMenuActivePage } from '../../../redux/slices/store';
import { Balloon } from "../../../assets/icons/auth"; import { Balloon } from '../../../assets/icons/auth';
import { Link } from "react-router-dom"; import { Link } from 'react-router-dom';
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Checkbox } from "../../../components/checkbox/Checkbox"; import { Checkbox } from '../../../components/checkbox/Checkbox';
import { googleLogo } from "../../../assets/icons/input"; import { googleLogo } from '../../../assets/icons/input';
const Register = () => { const Register = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const [username, setUsername] = useState<string>(""); const [username, setUsername] = useState<string>('');
const [email, setEmail] = useState<string>(""); const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>(""); const [password, setPassword] = useState<string>('');
const [confirmPassword, setConfirmPassword] = useState<string>(""); const [confirmPassword, setConfirmPassword] = useState<string>('');
const [submitClicked, setSubmitClicked] = useState<boolean>(false); const [submitClicked, setSubmitClicked] = useState<boolean>(false);
const { status, jwt } = useAppSelector((state) => state.auth); const { status, jwt } = useAppSelector((state) => state.auth);
@@ -28,12 +27,12 @@ const Register = () => {
// После успешной регистрации — переход в систему // После успешной регистрации — переход в систему
useEffect(() => { useEffect(() => {
dispatch(setMenuActivePage("account")) dispatch(setMenuActivePage('account'));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (jwt) { if (jwt) {
navigate("/home"); navigate('/home');
} }
console.log(submitClicked); console.log(submitClicked);
}, [jwt]); }, [jwt]);
@@ -63,61 +62,105 @@ const Register = () => {
</div> </div>
</div> </div>
<Input
<Input name="email" autocomplete="email" className="mt-[10px]" type="email" label="Почта" onChange={(v) => {setEmail(v)}} placeholder="example@gmail.com" /> name="email"
<Input name="login" autocomplete="login" className="mt-[10px]" type="text" label="Логин пользователя" onChange={(v) => {setUsername(v)}} placeholder="login" /> autocomplete="email"
<Input name="password" autocomplete="password" className="mt-[10px]" type="password" label="Пароль" onChange={(v) => {setPassword(v)}} placeholder="abCD1234" /> className="mt-[10px]"
<Input name="confirm-password" autocomplete="confirm-password" className="mt-[10px]" type="password" label="Повторите пароль" onChange={(v) => {setConfirmPassword(v)}} placeholder="abCD1234" /> 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]"> <div className=" flex items-center mt-[10px] h-[24px]">
<Checkbox <Checkbox
onChange={(value: boolean) => { value; }} onChange={(value: boolean) => {
value;
}}
className="p-0 w-fit m-[2.75px]" className="p-0 w-fit m-[2.75px]"
size="md" size="md"
color="secondary" color="secondary"
variant="default" /> variant="default"
/>
<span className="text-[14px] font-medium text-liquid-light h-[18px] ml-[10px]"> <span className="text-[14px] font-medium text-liquid-light h-[18px] ml-[10px]">
Я принимаю <Link Я принимаю{' '}
to={"/home"} <Link to={'/home'} className={' underline'}>
className={" underline"}
>
политику конфиденциальности политику конфиденциальности
</Link> </Link>
</span> </span>
</div> </div>
<div className="mt-[10px]"> <div className="mt-[10px]">
<PrimaryButton <PrimaryButton
className="w-full mb-[8px]" className="w-full mb-[8px]"
onClick={() => handleRegister()} onClick={() => handleRegister()}
text={status === "loading" ? "Регистрация..." : "Регистрация"} text={
disabled={status === "loading"} status === 'loading'
? 'Регистрация...'
: 'Регистрация'
}
disabled={status === 'loading'}
/> />
<SecondaryButton <SecondaryButton className="w-full" onClick={() => {}}>
className="w-full"
onClick={() => { }}
>
<div className="flex items-center"> <div className="flex items-center">
<img src={googleLogo} className="h-[24px] w-[24px] mr-[15px]" /> <img
src={googleLogo}
className="h-[24px] w-[24px] mr-[15px]"
/>
Регистрация с Google Регистрация с Google
</div> </div>
</SecondaryButton> </SecondaryButton>
</div> </div>
<div className="flex justify-center mt-[10px]"> <div className="flex justify-center mt-[10px]">
<span> <span>
Уже есть аккаунт? <Link Уже есть аккаунт?{' '}
to={"/home/login"} <Link
className={"text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline "}> to={'/home/login'}
className={
'text-liquid-brightmain text-[16px] h-[20px] transition-all hover:underline '
}
>
Авторизация Авторизация
</Link> </Link>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { FC } from "react"; import { FC } from 'react';
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { useParams, Navigate } from "react-router-dom"; import { useParams, Navigate } from 'react-router-dom';
interface GroupsBlockProps {} interface GroupsBlockProps {}
@@ -15,7 +15,7 @@ const Group: FC<GroupsBlockProps> = () => {
return ( return (
<div <div
className={cn( className={cn(
"border-b-[1px] border-b-liquid-lighter rounded-[10px]" 'border-b-[1px] border-b-liquid-lighter rounded-[10px]',
)} )}
> >
{groupIdNumber} {groupIdNumber}

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import { FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from 'react';
import { Modal } from "../../../components/modal/Modal"; import { Modal } from '../../../components/modal/Modal';
import { PrimaryButton } from "../../../components/button/PrimaryButton"; import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Input } from "../../../components/input/Input"; import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { createGroup } from "../../../redux/slices/groups"; import { createGroup } from '../../../redux/slices/groups';
interface ModalCreateProps { interface ModalCreateProps {
active: boolean; active: boolean;
@@ -12,27 +12,63 @@ interface ModalCreateProps {
} }
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => { const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
const [name, setName] = useState<string>(""); const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>(""); const [description, setDescription] = useState<string>('');
const status = useAppSelector((state) => state.groups.statuses.create); const status = useAppSelector((state) => state.groups.statuses.create);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
if (status == "successful") { if (status == 'successful') {
setActive(false); setActive(false);
} }
}, [status]); }, [status]);
return ( return (
<Modal className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white" onOpenChange={setActive} open={active} backdrop="blur" > <Modal
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="w-[500px]"> <div className="w-[500px]">
<div className="font-bold text-[30px]">Создать группу</div> <div className="font-bold text-[30px]">Создать группу</div>
<Input name="name" autocomplete="name" className="mt-[10px]" type="text" label="Название" onChange={(v) => { setName(v) }} placeholder="login" /> <Input
<Input name="description" autocomplete="description" className="mt-[10px]" type="text" label="Описание" onChange={(v) => { setDescription(v) }} placeholder="login" /> name="name"
autocomplete="name"
className="mt-[10px]"
type="text"
label="Название"
onChange={(v) => {
setName(v);
}}
placeholder="login"
/>
<Input
name="description"
autocomplete="description"
className="mt-[10px]"
type="text"
label="Описание"
onChange={(v) => {
setDescription(v);
}}
placeholder="login"
/>
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]"> <div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton onClick={() => { dispatch(createGroup({ name, description })) }} text="Создать" disabled={status == "loading"} /> <PrimaryButton
<SecondaryButton onClick={() => { setActive(false); }} text="Отмена" /> onClick={() => {
dispatch(createGroup({ name, description }));
}}
text="Создать"
disabled={status == 'loading'}
/>
<SecondaryButton
onClick={() => {
setActive(false);
}}
text="Отмена"
/>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -40,4 +76,3 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
}; };
export default ModalCreate; export default ModalCreate;

View File

@@ -1,10 +1,10 @@
import { FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from 'react';
import { Modal } from "../../../components/modal/Modal"; import { Modal } from '../../../components/modal/Modal';
import { PrimaryButton } from "../../../components/button/PrimaryButton"; import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Input } from "../../../components/input/Input"; import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { deleteGroup, updateGroup } from "../../../redux/slices/groups"; import { deleteGroup, updateGroup } from '../../../redux/slices/groups';
interface ModalUpdateProps { interface ModalUpdateProps {
active: boolean; active: boolean;
@@ -14,36 +14,95 @@ interface ModalUpdateProps {
groupDescription: string; groupDescription: string;
} }
const ModalUpdate: FC<ModalUpdateProps> = ({ active, setActive, groupName, groupId, groupDescription }) => { const ModalUpdate: FC<ModalUpdateProps> = ({
const [name, setName] = useState<string>(""); active,
const [description, setDescription] = useState<string>(""); setActive,
const statusUpdate = useAppSelector((state) => state.groups.statuses.update); groupName,
const statusDelete = useAppSelector((state) => state.groups.statuses.delete); groupId,
groupDescription,
}) => {
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const statusUpdate = useAppSelector(
(state) => state.groups.statuses.update,
);
const statusDelete = useAppSelector(
(state) => state.groups.statuses.delete,
);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
if (statusUpdate == "successful"){ if (statusUpdate == 'successful') {
setActive(false); setActive(false);
} }
}, [statusUpdate]); }, [statusUpdate]);
useEffect(() => { useEffect(() => {
if (statusDelete == "successful"){ if (statusDelete == 'successful') {
setActive(false); setActive(false);
} }
}, [statusDelete]); }, [statusDelete]);
return ( return (
<Modal className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white" onOpenChange={setActive} open={active} backdrop="blur" > <Modal
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="w-[500px]"> <div className="w-[500px]">
<div className="font-bold text-[30px]">Изменить группу {groupName} #{groupId}</div> <div className="font-bold text-[30px]">
<Input name="name" autocomplete="name" className="mt-[10px]" type="text" label="Новое название" defaultState={groupName} onChange={(v) => { setName(v)}} placeholder="login"/> Изменить группу {groupName} #{groupId}
<Input name="description" autocomplete="description" className="mt-[10px]" type="text" label="Описание" onChange={(v) => { setDescription(v)}} placeholder="login" defaultState={groupDescription}/> </div>
<Input
name="name"
autocomplete="name"
className="mt-[10px]"
type="text"
label="Новое название"
defaultState={groupName}
onChange={(v) => {
setName(v);
}}
placeholder="login"
/>
<Input
name="description"
autocomplete="description"
className="mt-[10px]"
type="text"
label="Описание"
onChange={(v) => {
setDescription(v);
}}
placeholder="login"
defaultState={groupDescription}
/>
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]"> <div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton onClick={() => {dispatch(deleteGroup(groupId))}} text="Удалить" disabled={statusDelete=="loading"} color="error"/> <PrimaryButton
<PrimaryButton onClick={() => {dispatch(updateGroup({name, description, groupId}))}} text="Обновить" disabled={statusUpdate=="loading"}/> onClick={() => {
<SecondaryButton onClick={() => {setActive(false);}} text="Отмена" /> dispatch(deleteGroup(groupId));
}}
text="Удалить"
disabled={statusDelete == 'loading'}
color="error"
/>
<PrimaryButton
onClick={() => {
dispatch(
updateGroup({ name, description, groupId }),
);
}}
text="Обновить"
disabled={statusUpdate == 'loading'}
/>
<SecondaryButton
onClick={() => {
setActive(false);
}}
text="Отмена"
/>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -51,4 +110,3 @@ const ModalUpdate: FC<ModalUpdateProps> = ({ active, setActive, groupName, group
}; };
export default ModalUpdate; export default ModalUpdate;

View File

@@ -1,16 +1,43 @@
import { Logo } from "../../../assets/logos"; import { Logo } from '../../../assets/logos';
import {Account, Clipboard, Cup, Home, Openbook, Users} from "../../../assets/icons/menu"; import {
import MenuItem from "./MenuItem"; Account,
import { useAppSelector } from "../../../redux/hooks"; Clipboard,
Cup,
Home,
Openbook,
Users,
} from '../../../assets/icons/menu';
import MenuItem from './MenuItem';
import { useAppSelector } from '../../../redux/hooks';
const Menu = () => { const Menu = () => {
const menuItems = [ const menuItems = [
{text: "Главная", href: "/home", icon: Home, page: "home" }, { text: 'Главная', href: '/home', icon: Home, page: 'home' },
{text: "Задачи", href: "/home/missions", icon: Clipboard, page: "missions" }, {
{text: "Статьи", href: "/home/articles", icon: Openbook, page: "articles" }, text: 'Задачи',
{text: "Группы", href: "/home/groups", icon: Users, page: "groups" }, href: '/home/missions',
{text: "Контесты", href: "/home/contests", icon: Cup, page: "contests" }, icon: Clipboard,
{text: "Аккаунт", href: "/home/account", icon: Account, page: "account" }, page: 'missions',
},
{
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 activePage = useAppSelector((state) => state.store.menu.activePage);
@@ -19,7 +46,14 @@ const Menu = () => {
<img src={Logo} className="w-[173px]" /> <img src={Logo} className="w-[173px]" />
<div className=""> <div className="">
{menuItems.map((v, i) => ( {menuItems.map((v, i) => (
<MenuItem key={i} icon={v.icon} text={v.text} href={v.href} active={v.page == activePage} page={v.page}/> <MenuItem
key={i}
icon={v.icon}
text={v.text}
href={v.href}
active={v.page == activePage}
page={v.page}
/>
))} ))}
</div> </div>
</div> </div>

View File

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

View File

@@ -1,19 +1,19 @@
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { IconError, IconSuccess } from "../../../assets/icons/missions"; import { IconError, IconSuccess } from '../../../assets/icons/missions';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
export interface MissionItemProps { export interface MissionItemProps {
id: number; id: number;
authorId: number; authorId: number;
name: string; name: string;
difficulty: "Easy" | "Medium" | "Hard"; difficulty: 'Easy' | 'Medium' | 'Hard';
tags: string[]; tags: string[];
timeLimit: number; timeLimit: number;
memoryLimit: number; memoryLimit: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
type: "first" | "second"; type: 'first' | 'second';
status: "empty" | "success" | "error"; status: 'empty' | 'success' | 'error';
} }
export function formatMilliseconds(ms: number): string { export function formatMilliseconds(ms: number): string {
@@ -28,44 +28,51 @@ export function formatBytesToMB(bytes: number): string {
} }
const MissionItem: React.FC<MissionItemProps> = ({ const MissionItem: React.FC<MissionItemProps> = ({
id, name, difficulty, timeLimit, memoryLimit, type, status id,
name,
difficulty,
timeLimit,
memoryLimit,
type,
status,
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div className={cn("h-[44px] w-full relative rounded-[10px] text-liquid-white", <div
type == "first" ? "bg-liquid-lighter" : "bg-liquid-background", className={cn(
"grid grid-cols-[80px,1fr,1fr,60px,24px] grid-flow-col gap-[20px] px-[20px] box-border items-center", 'h-[44px] w-full relative rounded-[10px] text-liquid-white',
status == "error" && "border-l-[11px] border-l-liquid-red pl-[9px]", type == 'first' ? 'bg-liquid-lighter' : 'bg-liquid-background',
status == "success" && "border-l-[11px] border-l-liquid-green pl-[9px]", 'grid grid-cols-[80px,1fr,1fr,60px,24px] grid-flow-col gap-[20px] px-[20px] box-border items-center',
"cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300", status == 'error' &&
'border-l-[11px] border-l-liquid-red pl-[9px]',
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}`)}} onClick={() => {
navigate(`/mission/${id}`);
}}
> >
<div className="text-[18px] font-bold"> <div className="text-[18px] font-bold">#{id}</div>
#{id} <div className="text-[18px] font-bold">{name}</div>
</div>
<div className="text-[18px] font-bold">
{name}
</div>
<div className="text-[12px] text-right"> <div className="text-[12px] text-right">
стандартный ввод/вывод {formatMilliseconds(timeLimit)}, {formatBytesToMB(memoryLimit)} стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '}
{formatBytesToMB(memoryLimit)}
</div> </div>
<div className={cn( <div
"text-center text-[18px]", className={cn(
difficulty == "Hard" && "text-liquid-red", 'text-center text-[18px]',
difficulty == "Medium" && "text-liquid-orange", difficulty == 'Hard' && 'text-liquid-red',
difficulty == "Easy" && "text-liquid-green", difficulty == 'Medium' && 'text-liquid-orange',
)}> difficulty == 'Easy' && 'text-liquid-green',
)}
>
{difficulty} {difficulty}
</div> </div>
<div className="h-[24px] w-[24px]"> <div className="h-[24px] w-[24px]">
{ {status == 'error' && <img src={IconError} />}
status == "error" && <img src={IconError}/> {status == 'success' && <img src={IconSuccess} />}
}
{
status == "success" && <img src={IconSuccess}/>
}
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -59,6 +59,10 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
} }
}, [status]); }, [status]);
useEffect(() => {
dispatch(setMissionsStatus({ key: 'upload', status: 'idle' }));
}, [active]);
return ( return (
<Modal <Modal
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white" className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
@@ -152,6 +156,8 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
text="Отмена" text="Отмена"
/> />
</div> </div>
{status == 'failed' && <div>error</div>}
</div> </div>
</Modal> </Modal>
); );

View File

@@ -1,17 +1,17 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from 'react';
import Editor from "@monaco-editor/react"; import Editor from '@monaco-editor/react';
import { upload } from "../../../assets/icons/input"; import { upload } from '../../../assets/icons/input';
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import { DropDownList } from "../../../components/drop-down-list/DropDownList"; import { DropDownList } from '../../../components/drop-down-list/DropDownList';
const languageMap: Record<string, string> = { const languageMap: Record<string, string> = {
c: "cpp", c: 'cpp',
"C++": "cpp", 'C++': 'cpp',
java: "java", java: 'java',
python: "python", python: 'python',
pascal: "pascal", pascal: 'pascal',
kotlin: "kotlin", kotlin: 'kotlin',
csharp: "csharp" csharp: 'csharp',
}; };
export interface CodeEditorProps { export interface CodeEditorProps {
@@ -19,28 +19,30 @@ export interface CodeEditorProps {
onChangeLanguage: (value: string) => void; onChangeLanguage: (value: string) => void;
} }
const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) => { const CodeEditor: React.FC<CodeEditorProps> = ({
const [language, setLanguage] = useState<string>("C++"); onChange,
const [code, setCode] = useState<string>(""); onChangeLanguage,
}) => {
const [language, setLanguage] = useState<string>('C++');
const [code, setCode] = useState<string>('');
const [isDragging, setIsDragging] = useState<boolean>(false); const [isDragging, setIsDragging] = useState<boolean>(false);
const items = [ const items = [
{ value: "c", text: "C" }, { value: 'c', text: 'C' },
{ value: "C++", text: "C++" }, { value: 'C++', text: 'C++' },
{ value: "java", text: "Java" }, { value: 'java', text: 'Java' },
{ value: "python", text: "Python" }, { value: 'python', text: 'Python' },
{ value: "pascal", text: "Pascal" }, { value: 'pascal', text: 'Pascal' },
{ value: "kotlin", text: "Kotlin" }, { value: 'kotlin', text: 'Kotlin' },
{ value: "csharp", text: "C#" }, { value: 'csharp', text: 'C#' },
]; ];
useEffect(() => { useEffect(() => {
onChange(code); onChange(code);
}, [code]) }, [code]);
useEffect(() => { useEffect(() => {
onChangeLanguage(language); onChangeLanguage(language);
}, [language]) }, [language]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -49,10 +51,10 @@ const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) =>
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
const text = event.target?.result; const text = event.target?.result;
if (typeof text === "string") setCode(text); if (typeof text === 'string') setCode(text);
}; };
reader.readAsText(file); reader.readAsText(file);
e.target.value = ""; e.target.value = '';
}; };
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => { const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
@@ -64,7 +66,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) =>
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
const text = event.target?.result; const text = event.target?.result;
if (typeof text === "string") setCode(text); if (typeof text === 'string') setCode(text);
}; };
reader.readAsText(droppedFile); reader.readAsText(droppedFile);
}; };
@@ -88,11 +90,18 @@ const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) =>
{/* Панель выбора языка и загрузки файла */} {/* Панель выбора языка и загрузки файла */}
<div className="flex items-center justify-between py-3 "> <div className="flex items-center justify-between py-3 ">
<div className="flex items-center gap-[20px]"> <div className="flex items-center gap-[20px]">
<DropDownList items={items} onChange={(v) => { setLanguage(v) }} defaultState={{ value: "C++", text: "C++" }}/> <DropDownList
items={items}
onChange={(v) => {
setLanguage(v);
}}
defaultState={{ value: 'C++', text: 'C++' }}
/>
<label <label
className={cn("h-[40px] w-[250px] rounded-[10px] px-[16px] relative flex items-center cursor-pointer transition-all bg-liquid-lighter outline-dashed outline-[2px] outline-transparent active:scale-[95%]", className={cn(
isDragging && "outline-blue-500 " 'h-[40px] w-[250px] rounded-[10px] px-[16px] relative flex items-center cursor-pointer transition-all bg-liquid-lighter outline-dashed outline-[2px] outline-transparent active:scale-[95%]',
isDragging && 'outline-blue-500 ',
)} )}
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
@@ -100,9 +109,12 @@ const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) =>
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
> >
<span className="text-[18px] text-liquid-white font-bold pointer-events-none"> <span className="text-[18px] text-liquid-white font-bold pointer-events-none">
{"Загрузить решение"} {'Загрузить решение'}
</span> </span>
<img src={upload} className="absolute right-[16px] pointer-events-none" /> <img
src={upload}
className="absolute right-[16px] pointer-events-none"
/>
<input <input
type="file" type="file"
onChange={(e) => handleFileUpload(e)} onChange={(e) => handleFileUpload(e)}
@@ -119,7 +131,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) =>
height="100%" height="100%"
language={languageMap[language]} language={languageMap[language]}
value={code} value={code}
onChange={(value) => setCode(value ?? "")} onChange={(value) => setCode(value ?? '')}
theme="vs-dark" theme="vs-dark"
options={{ options={{
fontSize: 14, fontSize: 14,
@@ -130,7 +142,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) =>
tabSize: 4, tabSize: 4,
insertSpaces: true, insertSpaces: true,
detectIndentation: false, detectIndentation: false,
autoIndent: "full", autoIndent: 'full',
}} }}
/> />
</div> </div>

View File

@@ -1,28 +1,57 @@
import React from "react"; import React from 'react';
import { chevroneLeft, chevroneRight, arrowLeft } from "../../../assets/icons/header"; import {
import { Logo } from "../../../assets/logos"; chevroneLeft,
import { useNavigate } from "react-router-dom"; chevroneRight,
arrowLeft,
} from '../../../assets/icons/header';
import { Logo } from '../../../assets/logos';
import { useNavigate } from 'react-router-dom';
interface HeaderProps { interface HeaderProps {
missionId: number; missionId: number;
} }
const Header: React.FC<HeaderProps> = ({ const Header: React.FC<HeaderProps> = ({ missionId }) => {
missionId
}) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]"> <header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
<img src={Logo} alt="Logo" className="h-[28px] w-auto cursor-pointer" onClick={() => { navigate("/home") }} /> <img
src={Logo}
alt="Logo"
className="h-[28px] w-auto cursor-pointer"
onClick={() => {
navigate('/home');
}}
/>
<img src={arrowLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate("/home/missions") }} /> <img
src={arrowLeft}
alt="back"
className="h-[24px] w-[24px] cursor-pointer"
onClick={() => {
navigate('/home/missions');
}}
/>
<div className="flex gap-[10px]"> <div className="flex gap-[10px]">
<img src={chevroneLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId - 1}`) }} /> <img
src={chevroneLeft}
alt="back"
className="h-[24px] w-[24px] cursor-pointer"
onClick={() => {
navigate(`/mission/${missionId - 1}`);
}}
/>
<span>{missionId}</span> <span>{missionId}</span>
<img src={chevroneRight} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId + 1}`) }} /> <img
src={chevroneRight}
alt="back"
className="h-[24px] w-[24px] cursor-pointer"
onClick={() => {
navigate(`/mission/${missionId + 1}`);
}}
/>
</div> </div>
</header> </header>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
declare global { declare global {
interface Window { interface Window {
@@ -35,19 +35,27 @@ const loadMathJax = () => {
(window as any).MathJax = { (window as any).MathJax = {
tex: { tex: {
inlineMath: [["$$$", "$$$"]], inlineMath: [['$$$', '$$$']],
displayMath: [["$$$$$$", "$$$$$$"]], displayMath: [['$$$$$$', '$$$$$$']],
processEscapes: true, processEscapes: true,
}, },
options: { options: {
skipHtmlTags: ["script", "noscript", "style", "textarea", "pre", "code"], skipHtmlTags: [
'script',
'noscript',
'style',
'textarea',
'pre',
'code',
],
}, },
startup: { typeset: false }, startup: { typeset: false },
}; };
const script = document.createElement("script"); const script = document.createElement('script');
script.id = "mathjax-script"; script.id = 'mathjax-script';
script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"; script.src =
'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js';
script.async = true; script.async = true;
script.onload = () => { script.onload = () => {
@@ -61,15 +69,19 @@ const loadMathJax = () => {
return mathJaxPromise; return mathJaxPromise;
}; };
const replaceImages = (html: string, latex: string, mediaFiles?: MediaFile[]) => { const replaceImages = (
html: string,
latex: string,
mediaFiles?: MediaFile[],
) => {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html"); const doc = parser.parseFromString(html, 'text/html');
const latexImageNames = Array.from(latex.matchAll(/\\includegraphics\{(.+?)\}/g)).map( const latexImageNames = Array.from(
(match) => match[1] latex.matchAll(/\\includegraphics\{(.+?)\}/g),
); ).map((match) => match[1]);
const imgs = doc.querySelectorAll<HTMLImageElement>("img.tex-graphics"); const imgs = doc.querySelectorAll<HTMLImageElement>('img.tex-graphics');
imgs.forEach((img, idx) => { imgs.forEach((img, idx) => {
const imageName = latexImageNames[idx]; const imageName = latexImageNames[idx];
@@ -81,7 +93,11 @@ const replaceImages = (html: string, latex: string, mediaFiles?: MediaFile[]) =>
return doc.body.innerHTML; return doc.body.innerHTML;
}; };
const LaTextContainer: React.FC<LaTextContainerProps> = ({ html, latex, mediaFiles }) => { const LaTextContainer: React.FC<LaTextContainerProps> = ({
html,
latex,
mediaFiles,
}) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [processedHtml, setProcessedHtml] = useState<string>(html); const [processedHtml, setProcessedHtml] = useState<string>(html);
@@ -94,7 +110,9 @@ const LaTextContainer: React.FC<LaTextContainerProps> = ({ html, latex, mediaFil
useEffect(() => { useEffect(() => {
const renderMath = () => { const renderMath = () => {
if (containerRef.current && window.MathJax?.typesetPromise) { if (containerRef.current && window.MathJax?.typesetPromise) {
window.MathJax.typesetPromise([containerRef.current]).catch(console.error); window.MathJax.typesetPromise([containerRef.current]).catch(
console.error,
);
} }
}; };

View File

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

View File

@@ -1,12 +1,10 @@
import React, { FC } from "react"; import React, { FC } from 'react';
import { cn } from "../../../lib/cn"; import { cn } from '../../../lib/cn';
import LaTextContainer from "./LaTextContainer"; import LaTextContainer from './LaTextContainer';
import { CopyIcon } from "../../../assets/icons/missions"; import { CopyIcon } from '../../../assets/icons/missions';
// import FullLatexRenderer from "./FullLatexRenderer"; // import FullLatexRenderer from "./FullLatexRenderer";
import { useState } from 'react';
import { useState } from "react";
interface CopyableDivPropd { interface CopyableDivPropd {
content: string; content: string;
@@ -18,9 +16,9 @@ const CopyableDiv: FC<CopyableDivPropd> = ({ content }) => {
const handleCopy = async () => { const handleCopy = async () => {
try { try {
await navigator.clipboard.writeText(content); await navigator.clipboard.writeText(content);
alert("Скопировано!"); alert('Скопировано!');
} catch (err) { } catch (err) {
console.error("Ошибка копирования:", err); console.error('Ошибка копирования:', err);
} }
}; };
@@ -32,22 +30,18 @@ const CopyableDiv: FC<CopyableDivPropd> = ({ content }) => {
> >
{content} {content}
<img <img
src={CopyIcon} src={CopyIcon}
alt="copy" 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]", className={cn(
hovered && " opacity-100" '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} onClick={handleCopy}
/> />
</div> </div>
); );
} };
export interface StatementData { export interface StatementData {
id?: number; id?: number;
@@ -66,9 +60,9 @@ export interface StatementData {
function extractDivByClass(html: string, className: string): string { function extractDivByClass(html: string, className: string): string {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html"); const doc = parser.parseFromString(html, 'text/html');
const div = doc.querySelector(`div.${className}`); const div = doc.querySelector(`div.${className}`);
return div ? div.outerHTML : ""; return div ? div.outerHTML : '';
} }
const Statement: React.FC<StatementData> = ({ const Statement: React.FC<StatementData> = ({
@@ -77,62 +71,109 @@ const Statement: React.FC<StatementData> = ({
tags, tags,
timeLimit = 1000, timeLimit = 1000,
memoryLimit = 256 * 1024 * 1024, memoryLimit = 256 * 1024 * 1024,
legend = "", legend = '',
input = "", input = '',
output = "", output = '',
sampleTests = [], sampleTests = [],
notes = "", notes = '',
html = "", html = '',
mediaFiles, mediaFiles,
}) => { }) => {
return ( return (
<div className="flex flex-col w-full h-full medium-scrollbar pl-[20px] pr-[12px] gap-[20px] text-liquid-white overflow-y-scroll thin-dark-scrollbar [scrollbar-gutter:stable]"> <div className="flex flex-col w-full h-full medium-scrollbar pl-[20px] pr-[12px] gap-[20px] text-liquid-white overflow-y-scroll thin-dark-scrollbar [scrollbar-gutter:stable]">
<div> <div>
<p className="h-[50px] text-[40px] font-bold text-liquid-white">{name}</p> <p className="h-[50px] text-[40px] font-bold text-liquid-white">
<p className="h-[23px] text-[18px] font-bold text-liquid-light">Задача #{id}</p> {name}
</p>
<p className="h-[23px] text-[18px] font-bold text-liquid-light">
Задача #{id}
</p>
</div> </div>
<div className="flex gap-[10px] w-full flex-wrap"> <div className="flex gap-[10px] w-full flex-wrap">
{tags && tags.map((v, i) => <div key={i} className="px-[16px] py-[8px] rounded-full bg-liquid-lighter ">{v}</div>)} {tags &&
tags.map((v, i) => (
<div
key={i}
className="px-[16px] py-[8px] rounded-full bg-liquid-lighter "
>
{v}
</div>
))}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ограничение по времени на тест:</span> {timeLimit / 1000} секунда</p> <p className="text-liquid-white h-[20px] text-[18px] font-bold">
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ограничение по памяти на тест:</span> {memoryLimit / 1024 / 1024} мегабайт</p> <span className="text-liquid-light">
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">ввод:</span> стандартный ввод</p> ограничение по времени на тест:
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">вывод:</span> стандартный вывод</p> </span>{' '}
{timeLimit / 1000} секунда
</p>
<p className="text-liquid-white h-[20px] text-[18px] font-bold">
<span className="text-liquid-light">
ограничение по памяти на тест:
</span>{' '}
{memoryLimit / 1024 / 1024} мегабайт
</p>
<p className="text-liquid-white h-[20px] text-[18px] font-bold">
<span className="text-liquid-light">ввод:</span> стандартный
ввод
</p>
<p className="text-liquid-white h-[20px] text-[18px] font-bold">
<span className="text-liquid-light">вывод:</span>{' '}
стандартный вывод
</p>
</div> </div>
<div className="flex flex-col gap-[10px] mt-[20px]"> <div className="flex flex-col gap-[10px] mt-[20px]">
<LaTextContainer html={extractDivByClass(html, "legend")} latex={legend} mediaFiles={mediaFiles}/> <LaTextContainer
html={extractDivByClass(html, 'legend')}
latex={legend}
mediaFiles={mediaFiles}
/>
</div> </div>
<div className="flex flex-col gap-[10px]"> <div className="flex flex-col gap-[10px]">
<LaTextContainer html={extractDivByClass(html, "input-specification")} latex={input} mediaFiles={mediaFiles}/> <LaTextContainer
html={extractDivByClass(html, 'input-specification')}
latex={input}
mediaFiles={mediaFiles}
/>
</div> </div>
<div className="flex flex-col gap-[10px]"> <div className="flex flex-col gap-[10px]">
<LaTextContainer html={extractDivByClass(html, "output-specification")} latex={output} mediaFiles={mediaFiles}/> <LaTextContainer
html={extractDivByClass(html, 'output-specification')}
latex={output}
mediaFiles={mediaFiles}
/>
</div> </div>
<div className="flex flex-col gap-[10px]"> <div className="flex flex-col gap-[10px]">
<div className="text-[18px] font-bold">{sampleTests.length == 1 ? "Пример" : "Примеры"}</div> <div className="text-[18px] font-bold">
{sampleTests.length == 1 ? 'Пример' : 'Примеры'}
</div>
{sampleTests.map((v, i) => (
{sampleTests.map((v, i) =>
<div key={i} className="flex flex-col gap-[10px]"> <div key={i} className="flex flex-col gap-[10px]">
<div className="text-[14px] font-bold">Входные данные</div> <div className="text-[14px] font-bold">
Входные данные
</div>
<CopyableDiv content={v.input} /> <CopyableDiv content={v.input} />
<div className="text-[14px] font-bold">Выходные данные</div> <div className="text-[14px] font-bold">
Выходные данные
</div>
<CopyableDiv content={v.output} /> <CopyableDiv content={v.output} />
</div> </div>
)} ))}
</div> </div>
<div className="flex flex-col gap-[10px]"> <div className="flex flex-col gap-[10px]">
<LaTextContainer html={extractDivByClass(html, "note")} latex={notes} mediaFiles={mediaFiles}/> <LaTextContainer
html={extractDivByClass(html, 'note')}
latex={notes}
mediaFiles={mediaFiles}
/>
<div>Автор: Jacks</div> <div>Автор: Jacks</div>
</div> </div>
</div> </div>
); );
}; };

View File

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