611 lines
26 KiB
TypeScript
611 lines
26 KiB
TypeScript
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;
|