All checks were successful
Build and Push Docker Image / build (push) Successful in 53s
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
import { FC, useEffect, useState } from 'react';
|
||
import { Modal } from '../../../components/modal/Modal';
|
||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||
import { Input } from '../../../components/input/Input';
|
||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||
import {
|
||
setMissionsStatus,
|
||
uploadMission,
|
||
} from '../../../redux/slices/missions';
|
||
import { toastSuccess } from '../../../lib/toastNotification';
|
||
import { cn } from '../../../lib/cn';
|
||
import { Link } from 'react-router-dom';
|
||
import { NumberInput } from '../../../components/input/NumberInput';
|
||
import { DropDownList, DropDownListItem } from '../../../components/input/DropDownList';
|
||
|
||
interface ModalCreateProps {
|
||
active: boolean;
|
||
setActive: (value: boolean) => void;
|
||
}
|
||
|
||
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
||
type TagsMode = 'fromArchive' | 'none' | 'custom';
|
||
const [difficulty, setDifficulty] = useState<number>(1);
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [tagInput, setTagInput] = useState<string>('');
|
||
const [tags, setTags] = useState<string[]>([]);
|
||
const [tagsMode, setTagsMode] = useState<TagsMode>('fromArchive');
|
||
|
||
const status = useAppSelector((state) => state.missions.statuses.upload);
|
||
const dispatch = useAppDispatch();
|
||
|
||
const [clickSubmit, setClickSubmit] = useState<boolean>(false);
|
||
|
||
const normalizeTags = (raw: string[]): string[] => {
|
||
const result: string[] = [];
|
||
const seen = new Set<string>();
|
||
for (const t of raw) {
|
||
const trimmed = t.trim();
|
||
if (!trimmed) continue;
|
||
const key = trimmed.toLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
result.push(trimmed);
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const addTag = () => {
|
||
const parts = tagInput.split(',').map((v) => v.trim());
|
||
const next = normalizeTags([...tags, ...parts]);
|
||
setTags(next);
|
||
setTagInput('');
|
||
};
|
||
|
||
const removeTag = (tagToRemove: string) => {
|
||
setTags(tags.filter((tag) => tag !== tagToRemove));
|
||
};
|
||
|
||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.files && e.target.files[0]) {
|
||
setFile(e.target.files[0]);
|
||
}
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
setClickSubmit(true);
|
||
if (!file) return;
|
||
|
||
const payloadTags =
|
||
tagsMode === 'custom' ? normalizeTags(tags) : undefined;
|
||
|
||
dispatch(
|
||
uploadMission({
|
||
missionFile: file,
|
||
difficulty,
|
||
tags: payloadTags,
|
||
}),
|
||
);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (status === 'successful') {
|
||
toastSuccess('Миссия создана!');
|
||
setDifficulty(1);
|
||
setTags([]);
|
||
setFile(null);
|
||
setTagInput('');
|
||
setTagsMode('fromArchive');
|
||
dispatch(setMissionsStatus({ key: 'upload', status: 'idle' }));
|
||
setActive(false);
|
||
}
|
||
}, [status]);
|
||
|
||
useEffect(() => {
|
||
if (active == true) {
|
||
setClickSubmit(false);
|
||
}
|
||
dispatch(setMissionsStatus({ key: 'upload', status: 'idle' }));
|
||
}, [active]);
|
||
|
||
const getDifficultyErrorMessage = (): string => {
|
||
if (!clickSubmit) return '';
|
||
if (!Number.isFinite(difficulty) || difficulty < 1)
|
||
return 'Укажите сложность (минимум 1)';
|
||
return '';
|
||
};
|
||
|
||
const tagsModeItems: DropDownListItem[] = [
|
||
{ text: 'Теги: из архива', value: 'fromArchive' },
|
||
{ text: 'Теги: без тегов', value: 'none' },
|
||
{ text: 'Теги: свои', value: 'custom' },
|
||
];
|
||
|
||
const isTagsMode = (v: string): v is TagsMode =>
|
||
v === 'fromArchive' || v === 'none' || v === 'custom';
|
||
|
||
const currentTagsModeItem =
|
||
tagsModeItems.find((i) => i.value === tagsMode) ?? tagsModeItems[0];
|
||
|
||
useEffect(() => {
|
||
if (tagsMode === 'none') {
|
||
setTags([]);
|
||
setTagInput('');
|
||
}
|
||
}, [tagsMode]);
|
||
|
||
return (
|
||
<Modal
|
||
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
||
onOpenChange={setActive}
|
||
open={active}
|
||
backdrop="blur"
|
||
>
|
||
<div className="w-[500px]">
|
||
<div className="font-bold text-[30px]">Добавить задачу</div>
|
||
|
||
<NumberInput
|
||
name="difficulty"
|
||
className="mt-[10px]"
|
||
label="Сложность"
|
||
defaultState={difficulty}
|
||
minValue={1}
|
||
maxValue={3500}
|
||
onChange={(v) => setDifficulty(v)}
|
||
placeholder="1"
|
||
error={getDifficultyErrorMessage()}
|
||
/>
|
||
|
||
<div className="mt-4">
|
||
<label className="block mb-2">Файл задачи</label>
|
||
<label className="cursor-pointer inline-flex items-center justify-center px-4 py-2 bg-liquid-lighter hover:bg-liquid-dark transition-colors rounded-[10px] text-liquid-white font-medium">
|
||
{file ? file.name : 'Выбрать файл'}
|
||
<input
|
||
type="file"
|
||
onChange={handleFileChange}
|
||
accept=".zip"
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
{
|
||
<div
|
||
className={cn(
|
||
'text-liquid-red text-[14px] h-auto text-left mt-[5px] whitespace-pre-line overflow-hidden ',
|
||
(!clickSubmit || file) && 'h-0 mt-0',
|
||
)}
|
||
>
|
||
Необходимо выбрать файл задачи
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
{/* Теги */}
|
||
<div className="mb-[50px] max-w-[600px]">
|
||
<div className="mt-[20px]">
|
||
<div className="text-[18px] text-liquid-white font-medium h-[23px] mb-[10px]">
|
||
Режим тегов
|
||
</div>
|
||
<DropDownList
|
||
items={tagsModeItems}
|
||
defaultState={currentTagsModeItem}
|
||
onChange={(v) => {
|
||
if (isTagsMode(v)) setTagsMode(v);
|
||
}}
|
||
weight="w-full"
|
||
/>
|
||
{tagsMode === 'none' && (
|
||
<div className="text-liquid-red text-[14px] mt-[8px] whitespace-pre-line">
|
||
Пустой список тегов сейчас не поддерживается в multipart.
|
||
Будет использован режим “из архива”.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{tagsMode === 'custom' && (
|
||
<>
|
||
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
|
||
<Input
|
||
name="missionTags"
|
||
autocomplete="missionTags"
|
||
className="mt-[20px] max-w-[600px]"
|
||
type="text"
|
||
label="Теги (через запятую)"
|
||
onChange={(v) => setTagInput(v)}
|
||
defaultState={tagInput}
|
||
placeholder="arrays, dp"
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') addTag();
|
||
}}
|
||
/>
|
||
<PrimaryButton
|
||
onClick={addTag}
|
||
text="Добавить"
|
||
className="h-[40px] w-[140px]"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-wrap gap-[10px] mt-2">
|
||
{tags.map((tag) => (
|
||
<div
|
||
key={tag}
|
||
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
|
||
>
|
||
<span>{tag}</span>
|
||
<button
|
||
onClick={() => removeTag(tag)}
|
||
className="text-liquid-red font-bold ml-[5px]"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
Создать пакет задачи можно на платформе{' '}
|
||
<Link
|
||
to={'https://polygon.codeforces.com'}
|
||
target="_blank"
|
||
className="text-[#7489ff] hover:text-[#8c9dfd] transition-color duration-300"
|
||
>
|
||
polygon
|
||
</Link>
|
||
</div>
|
||
|
||
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
||
<PrimaryButton
|
||
onClick={handleSubmit}
|
||
text={status === 'loading' ? 'Загрузка...' : 'Создать'}
|
||
disabled={status === 'loading'}
|
||
/>
|
||
<SecondaryButton
|
||
onClick={() => setActive(false)}
|
||
text="Отмена"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default ModalCreate;
|