This commit is contained in:
Виталий Лавшонок
2025-11-03 11:57:24 +03:00
parent fbe441c654
commit db8828e32b
9 changed files with 368 additions and 130 deletions

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.7105 12.71C16.6909 11.9387 17.4065 10.8809 17.7577 9.68394C18.109 8.48697 18.0784 7.21027 17.6703 6.03147C17.2621 4.85267 16.4967 3.83039 15.4806 3.10686C14.4644 2.38332 13.2479 1.99451 12.0005 1.99451C10.753 1.99451 9.5366 2.38332 8.52041 3.10686C7.50423 3.83039 6.73883 4.85267 6.3307 6.03147C5.92257 7.21027 5.892 8.48697 6.24325 9.68394C6.59449 10.8809 7.31009 11.9387 8.29048 12.71C6.61056 13.383 5.14477 14.4994 4.04938 15.9399C2.95398 17.3805 2.27005 19.0913 2.07048 20.89C2.05604 21.0213 2.0676 21.1542 2.10451 21.2811C2.14142 21.4079 2.20295 21.5263 2.2856 21.6293C2.4525 21.8375 2.69527 21.9708 2.96049 22C3.2257 22.0292 3.49164 21.9518 3.69981 21.7849C3.90798 21.618 4.04131 21.3752 4.07049 21.11C4.29007 19.1552 5.22217 17.3498 6.6887 16.0388C8.15524 14.7278 10.0534 14.003 12.0205 14.003C13.9876 14.003 15.8857 14.7278 17.3523 16.0388C18.8188 17.3498 19.7509 19.1552 19.9705 21.11C19.9977 21.3557 20.1149 21.5827 20.2996 21.747C20.4843 21.9114 20.7233 22.0015 20.9705 22H21.0805C21.3426 21.9698 21.5822 21.8373 21.747 21.6313C21.9119 21.4252 21.9886 21.1624 21.9605 20.9C21.76 19.0962 21.0724 17.381 19.9713 15.9382C18.8703 14.4954 17.3974 13.3795 15.7105 12.71ZM12.0005 12C11.2094 12 10.436 11.7654 9.7782 11.3259C9.12041 10.8864 8.60772 10.2616 8.30497 9.53074C8.00222 8.79983 7.923 7.99557 8.07734 7.21964C8.23168 6.44372 8.61265 5.73099 9.17206 5.17158C9.73147 4.61217 10.4442 4.2312 11.2201 4.07686C11.996 3.92252 12.8003 4.00173 13.5312 4.30448C14.2621 4.60724 14.8868 5.11993 15.3264 5.77772C15.7659 6.43552 16.0005 7.20888 16.0005 8C16.0005 9.06087 15.5791 10.0783 14.8289 10.8284C14.0788 11.5786 13.0614 12 12.0005 12Z" fill="#EDF6F7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,3 +1,4 @@
import Balloon from "./balloon.svg";
import Account from "./account.svg"
export {Balloon};
export {Balloon, Account};

View File

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

View File

@@ -36,7 +36,14 @@ const Home = () => {
<Route path="articles/*" element={<Articles/>} />
<Route path="groups/*" element={<Groups/>} />
<Route path="contests/*" element={<Contests/>} />
<Route path="*" element={<>{name}<PrimaryButton onClick={() => {dispatch(logout())}}>выйти</PrimaryButton></>} />
<Route path="*" element={<>
<p>{jwt}</p>
<PrimaryButton onClick={() => {if (jwt) navigator.clipboard.writeText(jwt);}} text="скопировать токен" className="pt-[20px]"/>
<p className="py-[20px]">{name}</p>
<PrimaryButton onClick={() => {dispatch(logout())}}>выйти</PrimaryButton>
</>
}
/>
</Routes>
</div>
{

View File

@@ -0,0 +1,189 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import axios from "../../axios";
// =====================
// Типы
// =====================
export interface Mission {
missionId: number;
name: string;
sortOrder: number;
}
export interface Member {
userId: number;
username: string;
role: string;
}
export interface Contest {
id: number;
name: string;
description: string;
scheduleType: string;
startsAt: string;
endsAt: string;
availableFrom: string | null;
availableUntil: string | null;
attemptDurationMinutes: number | null;
groupId: number | null;
groupName: string | null;
missions: Mission[];
articles: any[];
members: Member[];
}
interface ContestsResponse {
hasNextPage: boolean;
contests: Contest[];
}
export interface CreateContestBody {
name: string;
description: string;
scheduleType: "FixedWindow" | "Flexible";
startsAt: string;
endsAt: string;
availableFrom: string | null;
availableUntil: string | null;
attemptDurationMinutes: number | null;
groupId: number | null;
missionIds: number[];
articleIds: number[];
participantIds: number[];
organizerIds: number[];
}
// =====================
// Состояние
// =====================
interface ContestsState {
contests: Contest[];
selectedContest: Contest | null;
hasNextPage: boolean;
status: "idle" | "loading" | "successful" | "failed";
error: string | null;
}
const initialState: ContestsState = {
contests: [],
selectedContest: null,
hasNextPage: false,
status: "idle",
error: null,
};
// =====================
// Async Thunks
// =====================
// Получение списка контестов
export const fetchContests = createAsyncThunk(
"contests/fetchAll",
async (
params: { page?: number; pageSize?: number; groupId?: number | null } = {},
{ rejectWithValue }
) => {
try {
const { page = 0, pageSize = 10, groupId } = params;
const response = await axios.get<ContestsResponse>("/contests", {
params: { page, pageSize, groupId },
});
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch contests");
}
}
);
// Получение одного контеста по ID
export const fetchContestById = createAsyncThunk(
"contests/fetchById",
async (id: number, { rejectWithValue }) => {
try {
const response = await axios.get<Contest>(`/contests/${id}`);
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch contest");
}
}
);
// Создание нового контеста
export const createContest = createAsyncThunk(
"contests/create",
async (contestData: CreateContestBody, { rejectWithValue }) => {
try {
const response = await axios.post<Contest>("/contests", contestData);
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to create contest");
}
}
);
// =====================
// Slice
// =====================
const contestsSlice = createSlice({
name: "contests",
initialState,
reducers: {
clearSelectedContest: (state) => {
state.selectedContest = null;
},
},
extraReducers: (builder) => {
// fetchContests
builder.addCase(fetchContests.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(fetchContests.fulfilled, (state, action: PayloadAction<ContestsResponse>) => {
state.status = "successful";
state.contests = action.payload.contests;
state.hasNextPage = action.payload.hasNextPage;
});
builder.addCase(fetchContests.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
state.error = action.payload;
});
// fetchContestById
builder.addCase(fetchContestById.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(fetchContestById.fulfilled, (state, action: PayloadAction<Contest>) => {
state.status = "successful";
state.selectedContest = action.payload;
});
builder.addCase(fetchContestById.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
state.error = action.payload;
});
// createContest
builder.addCase(createContest.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(createContest.fulfilled, (state, action: PayloadAction<Contest>) => {
state.status = "successful";
state.contests.unshift(action.payload);
});
builder.addCase(createContest.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
state.error = action.payload;
});
},
});
// =====================
// Экспорты
// =====================
export const { clearSelectedContest } = contestsSlice.actions;
export const contestsReducer = contestsSlice.reducer;

View File

@@ -3,6 +3,7 @@ import { authReducer } from "./slices/auth";
import { storeReducer } from "./slices/store";
import { missionsReducer } from "./slices/missions";
import { submitReducer } from "./slices/submit";
import { contestsReducer } from "./slices/contests";
// использование
@@ -21,6 +22,7 @@ export const store = configureStore({
store: storeReducer,
missions: missionsReducer,
submin: submitReducer,
contests: contestsReducer,
},
});

View File

@@ -1,11 +1,12 @@
import { cn } from "../../../lib/cn";
import { Account } from "../../../assets/icons/auth";
import { registerUser } from "../../../redux/slices/auth";
import { PrimaryButton } from "../../../components/button/PrimaryButton";
import { ReverseButton } from "../../../components/button/ReverseButton";
export interface ContestItemProps {
id: number;
name: string;
authors: string[];
startAt: string;
registerAt: string;
duration: number;
members: number;
statusRegister: "reg" | "nonreg";
@@ -25,44 +26,69 @@ function formatDate(dateString: string): string {
return `${day}/${month}/${year}\n${hours}:${minutes}`;
}
function formatWaitTime(ms: number): string {
const minutes = Math.floor(ms / 60000);
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} мин`;
}
}
const ContestItem: React.FC<ContestItemProps> = ({
id, name, authors, startAt, registerAt, duration, members, statusRegister, type
name, startAt, duration, members, statusRegister, type
}) => {
const now = new Date();
const waitTime = new Date(startAt).getTime() - now.getTime();
return (
<div className={cn("w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white",
<div className={cn("w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px]",
waitTime <= 0 ? "grid grid-cols-6" : "grid grid-cols-7",
"items-center font-bold text-liquid-white",
type == "first" ? " bg-liquid-lighter" : " bg-liquid-background"
)}>
<div className="text-left">
<div className="text-left font-bold text-[18px]">
{name}
</div>
<div className="text-center text-liquid-brightmain font-normal">
{authors.map((v, i) => <p key={i}>{v}</p>)}
<div className="text-center text-liquid-brightmain font-normal ">
{/* {authors.map((v, i) => <p key={i}>{v}</p>)} */}
valavshonok
</div>
<div className="text-center text-nowrap">
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(startAt)}
</div>
<div className="text-center">
{duration}
{formatWaitTime(duration)}
</div>
{
waitTime > 0 &&
<div className="text-center">
{waitTime}
<div className="text-center whitespace-pre-line ">
{"До начала\n" + formatWaitTime(waitTime)}
</div>
}
<div className="text-center">
{members}
<div className="items-center justify-center flex gap-[10px] flex-row w-full">
<div>{members}</div>
<img src={Account} className="h-[24px] w-[24px]"/>
</div>
<div className="text-center">
{statusRegister}
<div className="flex items-center justify-end">
{
statusRegister == "reg" ?
<> <PrimaryButton onClick={() => {}} text="Регистрация"/></>
:
<> <ReverseButton onClick={() => {}} text="Вы записаны"/></>
}
</div>
</div>

View File

@@ -1,128 +1,69 @@
import { useEffect } from "react";
import { SecondaryButton } from "../../../components/button/SecondaryButton";
import { cn } from "../../../lib/cn";
import { useAppDispatch } from "../../../redux/hooks";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import ContestsBlock from "./ContestsBlock";
import { setMenuActivePage } from "../../../redux/slices/store";
interface Contest {
id: number;
name: string;
authors: string[];
startAt: string;
registerAt: string;
duration: number;
members: number;
statusRegister: "reg" | "nonreg";
}
import { fetchContests } from "../../../redux/slices/contests";
const Contests = () => {
const dispatch = useAppDispatch();
const now = new Date();
const contests: Contest[] = [
// === Прошедшие контесты ===
{
id: 1,
name: "Code Marathon 2025",
authors: ["tourist", "Petr", "Semen", "Rotar"],
startAt: "2025-09-15T10:00:00.000Z",
registerAt: "2025-09-10T10:00:00.000Z",
duration: 180,
members: 4821,
statusRegister: "reg",
},
{
id: 2,
name: "Autumn Cup 2025",
authors: ["awoo", "Benq"],
startAt: "2025-09-25T17:00:00.000Z",
registerAt: "2025-09-20T17:00:00.000Z",
duration: 150,
members: 3670,
statusRegister: "nonreg",
},
// === Контесты, которые сейчас идут ===
{
id: 3,
name: "Halloween Challenge",
authors: ["Errichto", "Radewoosh"],
startAt: "2025-10-29T10:00:00.000Z", // начался сегодня
registerAt: "2025-10-25T10:00:00.000Z",
duration: 240,
members: 5123,
statusRegister: "reg",
},
{
id: 4,
name: "October Blitz",
authors: ["neal", "Um_nik"],
startAt: "2025-10-29T12:00:00.000Z",
registerAt: "2025-10-24T12:00:00.000Z",
duration: 300,
members: 2890,
statusRegister: "nonreg",
},
// === Контесты, которые еще не начались ===
{
id: 5,
name: "Winter Warmup",
authors: ["tourist", "rng_58"],
startAt: "2025-11-05T18:00:00.000Z",
registerAt: "2025-11-01T18:00:00.000Z",
duration: 180,
members: 2100,
statusRegister: "reg",
},
{
id: 6,
name: "Global Coding Cup",
authors: ["maroonrk", "kostka"],
startAt: "2025-11-12T15:00:00.000Z",
registerAt: "2025-11-08T15:00:00.000Z",
duration: 240,
members: 1520,
statusRegister: "nonreg",
},
];
// Берём данные из Redux
const contests = useAppSelector((state) => state.contests.contests);
const loading = useAppSelector((state) => state.contests.status);
const error = useAppSelector((state) => state.contests.error);
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
useEffect(() => {
dispatch(setMenuActivePage("contests"))
dispatch(setMenuActivePage("contests"));
dispatch(fetchContests({}));
}, []);
return (
<div className=" h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
<div className="h-full box-border">
if (loading == "loading") {
return <div className="text-liquid-white p-4">Загрузка контестов...</div>;
}
if (error) {
return <div className="text-red-500 p-4">Ошибка: {error}</div>;
}
return (
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
<div className="h-full box-border">
<div className="relative flex items-center mb-[20px]">
<div className={cn("h-[50px] text-[40px] font-bold text-liquid-white flex items-center")}>
Контесты
</div>
<SecondaryButton
onClick={() => { }}
onClick={() => {}}
text="Создать группу"
className="absolute right-0"
/>
</div>
<div className="bg-liquid-lighter h-[50px] mb-[20px]">
<div className="bg-liquid-lighter h-[50px] mb-[20px]" />
</div>
<ContestsBlock
className="mb-[20px]"
title="Текущие"
contests={contests.filter((contest) => {
const endTime =
new Date(contest.endsAt).getTime()
return endTime >= now.getTime();
})}
/>
<ContestsBlock className="mb-[20px]" title="Текущие" contests={contests.filter(contest => {
const endTime = new Date(contest.startAt).getTime() + contest.duration * 60 * 1000;
return endTime >= now.getTime();
})} />
<ContestsBlock className="mb-[20px]" title="Прошедшие" contests={contests.filter(contest => {
const endTime = new Date(contest.startAt).getTime() + contest.duration * 60 * 1000;
return endTime < now.getTime();
})} />
<ContestsBlock
className="mb-[20px]"
title="Прошедшие"
contests={contests.filter((contest) => {
const endTime =
new Date(contest.endsAt).getTime()
return endTime < now.getTime();
})}
/>
</div>
</div>
);

View File

@@ -2,27 +2,19 @@ import { useState, FC } from "react";
import { cn } from "../../../lib/cn";
import { ChevroneDown } from "../../../assets/icons/groups";
import ContestItem from "./ContestItem";
import { Contest } from "../../../redux/slices/contests";
interface Contest {
id: number;
name: string;
authors: string[];
startAt: string;
registerAt: string;
duration: number;
members: number;
statusRegister: "reg" | "nonreg";
}
interface GroupsBlockProps {
interface ContestsBlockProps {
contests: Contest[];
title: string;
className?: string;
}
const GroupsBlock: FC<GroupsBlockProps> = ({ contests, title, className }) => {
const ContestsBlock: FC<ContestsBlockProps> = ({ contests, title, className }) => {
const [active, setActive] = useState<boolean>(title != "Скрытые");
@@ -50,7 +42,14 @@ const GroupsBlock: FC<GroupsBlockProps> = ({ contests, title, className }) => {
<div className="overflow-hidden">
<div className="pb-[10px] pt-[20px]">
{
contests.map((v, i) => <ContestItem key={i} {...v} type={i % 2 ? "second" : "first"} />)
contests.map((v, i) => <ContestItem
key={i}
name={v.name}
startAt={v.startsAt}
statusRegister={"reg"}
duration={new Date(v.endsAt).getTime() - new Date(v.startsAt).getTime()}
members={v.members.length}
type={i % 2 ? "second" : "first"} />)
}
</div>
@@ -60,4 +59,4 @@ const GroupsBlock: FC<GroupsBlockProps> = ({ contests, title, className }) => {
);
};
export default GroupsBlock;
export default ContestsBlock;