Files
LiquidCode_Frontend/src/pages/ContestEditor.tsx
Виталий Лавшонок 0c41cc59b9 add contest mission input
2025-12-10 03:11:27 +03:00

611 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import Header from '../views/articleeditor/Header';
import { PrimaryButton } from '../components/button/PrimaryButton';
import { Input } from '../components/input/Input';
import { useAppDispatch, useAppSelector } from '../redux/hooks';
import {
CreateContestBody,
deleteContest,
fetchContestById,
setContestStatus,
updateContest,
} from '../redux/slices/contests';
import { useQuery } from '../hooks/useQuery';
import { Navigate, useNavigate } from 'react-router-dom';
import { fetchMissionById, fetchMissions } from '../redux/slices/missions';
import { ReverseButton } from '../components/button/ReverseButton';
import {
DropDownList,
DropDownListItem,
} from '../components/input/DropDownList';
import { NumberInput } from '../components/input/NumberInput';
import { cn } from '../lib/cn';
import DateInput from '../components/input/DateInput';
import { fetchMyGroups } from '../redux/slices/groups';
interface Mission {
id: number;
name: string;
}
const highlightZ = (name: string, filter: string) => {
if (!filter) return name;
const s = filter.toLowerCase();
const t = name.toLowerCase();
const n = t.length;
const m = s.length;
const mark = Array(n).fill(false);
// Проходимся с конца и ставим отметки
for (let i = n - 1; i >= 0; i--) {
if (i + m <= n && t.slice(i, i + m) === s) {
for (let j = i; j < i + m; j++) {
if (mark[j]) break;
mark[j] = true;
}
}
}
// === Формируем единые жёлтые блоки ===
const result: any[] = [];
let i = 0;
while (i < n) {
if (!mark[i]) {
// обычный символ
result.push(name[i]);
i++;
} else {
// начинаем жёлтый блок
let j = i;
while (j < n && mark[j]) j++;
const chunk = name.slice(i, j);
result.push(
<span key={i} className="bg-yellow-400 text-black rounded px-1">
{chunk}
</span>,
);
i = j;
}
}
return result;
};
function toUtc(localDateTime?: string): string {
if (!localDateTime) return '';
// Создаём дату (она автоматически считается как локальная)
const date = new Date(localDateTime);
// Возвращаем ISO-строку с 'Z' (всегда в UTC)
return date.toISOString();
}
/**
* Страница создания / редактирования контеста
*/
const ContestEditor = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const query = useQuery();
const back = query.get('back') ?? undefined;
const contestId = Number(query.get('contestId') ?? undefined);
const refactor = !!contestId;
if (!refactor) {
return <Navigate to="/home/account/acontest" />;
}
const status = useAppSelector(
(state) => state.contests.createContest.status,
);
const [missionFindInput, setMissionFindInput] = useState<string>('');
const now = new Date();
const plus60 = new Date(now.getTime() + 60 * 60 * 1000);
const toLocal = (d: Date) => {
const off = d.getTimezoneOffset();
const local = new Date(d.getTime() - off * 60000);
return local.toISOString().slice(0, 16);
};
const visibilityItems: DropDownListItem[] = [
{ value: 'Public', text: 'Публичный' },
{ value: 'GroupPrivate', text: 'Для группы' },
];
const scheduleTypeItems: DropDownListItem[] = [
{ value: 'AlwaysOpen', text: 'Всегда открыт' },
{ value: 'FixedWindow', text: 'Фиксированое окно' },
{ value: 'RollingWindow', text: 'Скользящее окно' },
];
const [contest, setContest] = useState<CreateContestBody>({
name: '',
description: '',
scheduleType: 'AlwaysOpen',
visibility: 'Public',
startsAt: toLocal(now),
endsAt: toLocal(plus60),
attemptDurationMinutes: 60,
maxAttempts: 1,
allowEarlyFinish: false,
missionIds: [],
articleIds: [],
});
const myname = useAppSelector((state) => state.auth.username);
const [missions, setMissions] = useState<Mission[]>([]);
const statusDelete = useAppSelector(
(state) => state.contests.deleteContest.status,
);
const statusUpdate = useAppSelector(
(state) => state.contests.updateContest.status,
);
const { contest: contestById, status: contestByIdstatus } = useAppSelector(
(state) => state.contests.fetchContestById,
);
const globalMissions = useAppSelector((state) => state.missions.missions);
const myGroups = useAppSelector(
(state) => state.groups.fetchMyGroups.groups,
).filter((group) =>
group.members.some(
(member) =>
member.username === myname &&
member.role.includes('Administrator'),
),
);
function toLocalInputValue(utcString: string) {
const d = new Date(utcString);
const pad = (n: number) => n.toString().padStart(2, '0');
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
`T${pad(d.getHours())}:${pad(d.getMinutes())}`
);
}
useEffect(() => {
if (status === 'successful') {
}
}, [status]);
const handleChange = (key: keyof CreateContestBody, value: any) => {
setContest((prev) => ({ ...prev, [key]: value }));
};
const handleUpdateContest = () => {
dispatch(
updateContest({
...contest,
endsAt: toUtc(contest.endsAt),
startsAt: toUtc(contest.startsAt),
contestId,
}),
);
};
const handleDeleteContest = () => {
dispatch(deleteContest(contestId));
};
const addMission = () => {
const mission = globalMissions
.filter((v) => !contest?.missionIds?.includes(v.id))
.filter((v) =>
(v.id + ' ' + v.name)
.toLocaleLowerCase()
.includes(missionFindInput.toLocaleLowerCase()),
)[0];
if (!mission) return;
const id = mission.id;
if (!id || contest.missionIds?.includes(id)) return;
dispatch(fetchMissionById(id))
.unwrap()
.then((mission) => {
setMissions((prev) => [...prev, mission]);
setContest((prev) => ({
...prev,
missionIds: [...(prev.missionIds ?? []), id],
}));
setMissionFindInput('');
})
.catch((err) => {
err;
});
};
const removeMission = (removeId: number) => {
setContest({
...contest,
missionIds: contest.missionIds?.filter((v) => v !== removeId),
});
setMissions(missions.filter((v) => v.id != removeId));
};
useEffect(() => {
if (statusDelete == 'successful') {
dispatch(
setContestStatus({ key: 'deleteContest', status: 'idle' }),
);
navigate('/home/account/contests');
}
}, [statusDelete]);
useEffect(() => {
if (statusUpdate == 'successful') {
dispatch(
setContestStatus({ key: 'updateContest', status: 'idle' }),
);
navigate('/home/account/contests');
}
}, [statusUpdate]);
useEffect(() => {
if (refactor) {
dispatch(fetchContestById(contestId));
dispatch(fetchMyGroups());
dispatch(fetchMissions({}));
}
}, [refactor]);
useEffect(() => {
if (refactor && contestByIdstatus == 'successful' && contestById) {
setContest({
...contestById,
// groupIds: contestById.groups.map(group => group.groupId),
missionIds: contestById.missions?.map((mission) => mission.id),
articleIds: contestById.articles?.map(
(article) => article.articleId,
),
});
setMissions(contestById.missions ?? []);
}
}, [contestById]);
const visibilityDefaultState =
visibilityItems.find(
(i) => contest && i.value === contest.visibility,
) ?? visibilityItems[0];
const scheduleTypeDefaultState =
scheduleTypeItems.find(
(i) => contest && i.value === contest.scheduleType,
) ?? scheduleTypeItems[0];
const groupItems = myGroups.map((v) => {
return {
value: '' + v.id,
text: v.name,
};
});
const groupIdDefaultState =
myGroups.find((g) => g.id == contest?.groupId) ??
myGroups[0] ??
undefined;
return (
<div className="h-screen grid grid-rows-[60px,1fr] text-liquid-white">
<Header backClick={() => navigate(back || '/home/contests')} />
<div className="grid grid-cols-2 h-full min-h-0">
{/* Левая панешь */}
<div className="overflow-y-auto min-h-0 overflow-hidden">
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
<h2 className="text-lg font-semibold mb-3 text-gray-100"></h2>
<div className="">
<div className="font-bold text-[30px] mb-[10px]">
{refactor
? `Редактирвоание контеста #${contestId} \"${contestById?.name}\"`
: 'Создать контест'}
</div>
<Input
name="name"
type="text"
label="Название"
className="mt-[10px]"
placeholder="Введите название"
onChange={(v) => handleChange('name', v)}
defaultState={contest.name ?? ''}
/>
<Input
name="description"
type="text"
label="Описание"
className="mt-[10px]"
placeholder="Введите описание"
onChange={(v) => handleChange('description', v)}
defaultState={contest.description ?? ''}
/>
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<div>
<label className="block text-sm mb-1">
Тип контеста
</label>
<DropDownList
items={scheduleTypeItems}
defaultState={scheduleTypeDefaultState}
onChange={(v) => {
handleChange('scheduleType', v);
}}
weight="w-full"
/>
</div>
<div>
<label className="block text-sm mb-1">
Видимость
</label>
<DropDownList
items={visibilityItems}
onChange={(v) => {
handleChange('visibility', v);
}}
defaultState={visibilityDefaultState}
weight="w-full"
/>
</div>
</div>
<div
className={cn(
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-200 mb-[10px]',
contest.visibility == 'GroupPrivate' &&
'grid-rows-[1fr] opacity-100',
)}
>
{groupIdDefaultState ? (
<div
className={cn(
contest.visibility !=
'GroupPrivate' &&
'overflow-hidden',
)}
>
<div>
<label className="block text-sm mb-2 mt-[10px]">
Группа для привязки
</label>
<DropDownList
items={groupItems}
defaultState={{
value:
'' +
groupIdDefaultState.id,
text: groupIdDefaultState.name,
}}
onChange={(v) => {
handleChange(
'groupId',
Number(v),
);
}}
weight="w-full"
/>
</div>
</div>
) : (
<div className="overflow-hidden">
<div className="text-liquid-red my-[20px]">
У вас нет группы вкоторой вы
являетесь Администратором!
</div>
</div>
)}
</div>
{/* Даты */}
<div
className={cn(
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-200',
contest.scheduleType != 'AlwaysOpen' &&
'grid-rows-[1fr] opacity-100',
)}
>
<div className="overflow-hidden">
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<DateInput
label="Дата начала"
value={
contest.startsAt
? toLocalInputValue(
contest.startsAt,
)
: ''
}
onChange={(v) =>
handleChange('startsAt', v)
}
/>
<DateInput
label="Дата окончания"
value={
contest.endsAt
? toLocalInputValue(
contest.endsAt,
)
: ''
}
onChange={(v) =>
handleChange('endsAt', v)
}
/>
</div>
</div>
</div>
{/* Продолжительность и лимиты */}
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<NumberInput
defaultState={
contest.attemptDurationMinutes
}
name="attemptDurationMinutes"
label="Длительность попытки (мин)"
placeholder="Например: 60"
minValue={1}
maxValue={365 * 24 * 60}
onChange={(v) =>
handleChange(
'attemptDurationMinutes',
Number(v),
)
}
/>
<NumberInput
defaultState={contest.maxAttempts}
name="maxAttempts"
label="Макс. попыток"
placeholder="Например: 3"
minValue={1}
maxValue={100}
onChange={(v) =>
handleChange('maxAttempts', Number(v))
}
/>
</div>
{/* Кнопки */}
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={handleUpdateContest}
text="Сохранить"
disabled={status === 'loading'}
/>
<ReverseButton
color="error"
onClick={handleDeleteContest}
text="Удалить"
disabled={statusDelete === 'loading'}
/>
</div>
</div>
</div>
</div>
{/* Правая панель */}
<div className="min-h-0 ">
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
{/* Блок для тегов */}
<div className="mt-[20px] max-w-[600px] relative">
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
<Input
name="missionId"
autocomplete="missionId"
className="mt-[20px] max-w-[600px]"
label="Введите название или ID миссии"
type="text"
onChange={(v) => {
setMissionFindInput(v);
}}
defaultState={missionFindInput}
placeholder={`Наприме: \"458\" или \"Поиск наименьшего\"`}
onKeyDown={(e) => {
if (e.key == 'Enter') addMission();
}}
/>
<PrimaryButton
onClick={addMission}
text="Добавить"
className="h-[40px] w-[140px]"
/>
{/* Выпадающие задачи */}
<div
className={cn(
'absolute rounded-[10px] bg-liquid-background w-[590px] left-0 top-[100px] z-50 transition-all duration-300',
'grid overflow-hidden border-liquid-lighter border-[3px] border-solid',
missionFindInput
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0 pointer-events-none',
)}
>
<div className="overflow-hidden p-[8px]">
<div className="overflow-y-scroll max-h-[250px] thin-scrollbar grid gap-[20px]">
{globalMissions
.filter(
(v) =>
!contest?.missionIds?.includes(
v.id,
),
)
.filter((v) =>
(v.id + ' ' + v.name)
.toLocaleLowerCase()
.includes(
missionFindInput.toLocaleLowerCase(),
),
)
.map((v, i) => (
<div
key={i}
className="hover:bg-liquid-lighter rounded-[10px] px-[12px] py-[4px] transition-colors duration-300 cursor-pointer"
onClick={() => {
setMissionFindInput(
v.id +
' ' +
v.name,
);
addMission();
}}
>
{highlightZ(
'#' +
v.id +
' ' +
v.name,
missionFindInput,
)}
</div>
))}
</div>
</div>
</div>
</div>
<div className="gap-[10px] mt-[20px]">
{missions.map((v, i) => (
<div
key={i}
className="grid grid-cols-[60px,1fr,24px] gap-1 bg-liquid-lighter px-[16px] py-[8px] rounded-[10px] relative mb-[10px] items-center"
>
<div>{'#' + v.id}</div>
<div>{v.name}</div>
<button
onClick={() => removeMission(v.id)}
className="text-liquid-red font-bold ml-[5px] absolute right-[16px]"
>
×
</button>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ContestEditor;