247 lines
8.5 KiB
TypeScript
247 lines
8.5 KiB
TypeScript
import { cn } from '../../../lib/cn';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||
import { useQuery } from '../../../hooks/useQuery';
|
||
import { ReverseButton } from '../../../components/button/ReverseButton';
|
||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||
import { toastWarning } from '../../../lib/toastNotification';
|
||
import { useEffect, useState } from 'react';
|
||
import {
|
||
addOrUpdateContestMember,
|
||
fetchParticipatingContests,
|
||
} from '../../../redux/slices/contests';
|
||
|
||
export interface PastContestItemProps {
|
||
name: string;
|
||
contestId: number;
|
||
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||
startsAt: string;
|
||
endsAt: string;
|
||
attemptDurationMinutes: number;
|
||
type: 'first' | 'second';
|
||
}
|
||
|
||
function formatDate(dateString: string): string {
|
||
const date = new Date(dateString);
|
||
|
||
const day = date.getDate().toString().padStart(2, '0');
|
||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||
const year = date.getFullYear();
|
||
|
||
const hours = date.getHours().toString().padStart(2, '0');
|
||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||
|
||
return `${day}/${month}/${year}\n${hours}:${minutes}`;
|
||
}
|
||
|
||
function formatDurationTime(minutes: number): string {
|
||
const hours = Math.floor(minutes / 60);
|
||
const days = Math.floor(hours / 24);
|
||
|
||
if (days > 0) {
|
||
const remainder = days % 10;
|
||
let suffix = 'дней';
|
||
if (remainder === 1 && days !== 11) suffix = 'день';
|
||
else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20))
|
||
suffix = 'дня';
|
||
return `${days} ${suffix}`;
|
||
} else if (hours > 0) {
|
||
const mins = minutes % 60;
|
||
return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`;
|
||
} else {
|
||
return `${minutes} мин`;
|
||
}
|
||
}
|
||
|
||
type Role = 'None' | 'Participant' | 'Organizer';
|
||
|
||
const PastContestItem: React.FC<PastContestItemProps> = ({
|
||
name,
|
||
contestId,
|
||
scheduleType,
|
||
startsAt,
|
||
endsAt,
|
||
attemptDurationMinutes,
|
||
type,
|
||
}) => {
|
||
const navigate = useNavigate();
|
||
const dispatch = useAppDispatch();
|
||
|
||
const [role, setRole] = useState<Role>('None');
|
||
|
||
const myname = useAppSelector((state) => state.auth.username);
|
||
|
||
const userId = useAppSelector((state) => state.auth.id);
|
||
|
||
const query = useQuery();
|
||
const username = query.get('username') ?? myname ?? '';
|
||
|
||
const { contests: myContests } = useAppSelector(
|
||
(state) => state.contests.fetchMyContests,
|
||
);
|
||
const { contests: participantContests } = useAppSelector(
|
||
(state) => state.contests.fetchParticipating,
|
||
);
|
||
|
||
const nameFilter = useAppSelector(
|
||
(state) => state.store.contests.filterName,
|
||
);
|
||
|
||
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;
|
||
};
|
||
|
||
useEffect(() => {
|
||
setRole(
|
||
(() => {
|
||
if (myContests?.some((c) => c.id === contestId)) {
|
||
return 'Organizer';
|
||
}
|
||
if (participantContests?.some((c) => c.id === contestId)) {
|
||
return 'Participant';
|
||
}
|
||
return 'None';
|
||
})(),
|
||
);
|
||
}, [myContests, participantContests]);
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
|
||
userId
|
||
? 'grid-cols-[1fr,150px,190px,120px,150px,150px]'
|
||
: 'grid-cols-[1fr,150px,190px,120px,150px]',
|
||
type == 'first'
|
||
? ' bg-liquid-lighter'
|
||
: ' bg-liquid-background',
|
||
)}
|
||
onClick={() => {
|
||
if (role == 'None') {
|
||
toastWarning('Нужно зарегистрироваться на контест');
|
||
return;
|
||
}
|
||
const params = new URLSearchParams({
|
||
back: '/home/contests',
|
||
});
|
||
navigate(`/contest/${contestId}?${params}`);
|
||
}}
|
||
>
|
||
<div className="text-left font-bold text-[18px]">
|
||
{highlightZ(name, nameFilter)}
|
||
</div>
|
||
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
|
||
{username}
|
||
</div>
|
||
{scheduleType == 'AlwaysOpen' ? (
|
||
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
|
||
Всегда открыт
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-[5px] text-[14px]">
|
||
<div className="text-center text-nowrap whitespace-pre-line">
|
||
{formatDate(startsAt)}
|
||
</div>
|
||
<div>-</div>
|
||
<div className="text-center text-nowrap whitespace-pre-line">
|
||
{formatDate(endsAt)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="text-center">
|
||
{formatDurationTime(attemptDurationMinutes)}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
|
||
{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Завершен'}
|
||
</div>
|
||
{userId && (
|
||
<div className="flex items-center justify-center">
|
||
{role == 'Organizer' || role == 'Participant' ? (
|
||
<ReverseButton
|
||
onClick={() => {
|
||
const params = new URLSearchParams({
|
||
back: '/home/contests',
|
||
});
|
||
navigate(`/contest/${contestId}?${params}`);
|
||
}}
|
||
text="Войти"
|
||
/>
|
||
) : (
|
||
<PrimaryButton
|
||
onClick={() => {
|
||
dispatch(
|
||
addOrUpdateContestMember({
|
||
contestId: contestId,
|
||
member: {
|
||
userId: Number(userId),
|
||
role: 'Participant',
|
||
},
|
||
}),
|
||
)
|
||
.unwrap()
|
||
.then(() =>
|
||
dispatch(
|
||
fetchParticipatingContests({}),
|
||
),
|
||
);
|
||
}}
|
||
text="Регистрация"
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default PastContestItem;
|