diff --git a/src/assets/icons/auth/account.svg b/src/assets/icons/auth/account.svg new file mode 100644 index 0000000..b4d5858 --- /dev/null +++ b/src/assets/icons/auth/account.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/auth/index.ts b/src/assets/icons/auth/index.ts index e308253..70db5e3 100644 --- a/src/assets/icons/auth/index.ts +++ b/src/assets/icons/auth/index.ts @@ -1,3 +1,4 @@ import Balloon from "./balloon.svg"; +import Account from "./account.svg" -export {Balloon}; \ No newline at end of file +export {Balloon, Account}; \ No newline at end of file diff --git a/src/components/button/ReverseButton.tsx b/src/components/button/ReverseButton.tsx new file mode 100644 index 0000000..393787a --- /dev/null +++ b/src/components/button/ReverseButton.tsx @@ -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 = ({ + disabled = false, + text = "", + className, + onClick, + children, +}) => { + return ( + + ); +}; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 4058f4a..f0fbefb 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -36,7 +36,14 @@ const Home = () => { } /> } /> } /> - {name} {dispatch(logout())}}>выйти} /> + +

{jwt}

+ {if (jwt) navigator.clipboard.writeText(jwt);}} text="скопировать токен" className="pt-[20px]"/> +

{name}

+ {dispatch(logout())}}>выйти + + } + /> { diff --git a/src/redux/slices/contests.ts b/src/redux/slices/contests.ts new file mode 100644 index 0000000..687b228 --- /dev/null +++ b/src/redux/slices/contests.ts @@ -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("/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(`/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("/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) => { + state.status = "successful"; + state.contests = action.payload.contests; + state.hasNextPage = action.payload.hasNextPage; + }); + builder.addCase(fetchContests.rejected, (state, action: PayloadAction) => { + 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) => { + state.status = "successful"; + state.selectedContest = action.payload; + }); + builder.addCase(fetchContestById.rejected, (state, action: PayloadAction) => { + 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) => { + state.status = "successful"; + state.contests.unshift(action.payload); + }); + builder.addCase(createContest.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + }, +}); + +// ===================== +// Экспорты +// ===================== +export const { clearSelectedContest } = contestsSlice.actions; +export const contestsReducer = contestsSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 6ea89a8..872a139 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -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, }, }); diff --git a/src/views/home/contests/ContestItem.tsx b/src/views/home/contests/ContestItem.tsx index 5451ae7..78319cf 100644 --- a/src/views/home/contests/ContestItem.tsx +++ b/src/views/home/contests/ContestItem.tsx @@ -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 = ({ - 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 ( -
-
+
{name}
-
- {authors.map((v, i) =>

{v}

)} +
+ {/* {authors.map((v, i) =>

{v}

)} */} + valavshonok
-
+
{formatDate(startAt)}
- {duration} + {formatWaitTime(duration)}
{ waitTime > 0 && -
- {waitTime} +
+ + {"До начала\n" + formatWaitTime(waitTime)}
} -
- {members} +
+
{members}
+
-
- {statusRegister} +
+ { + statusRegister == "reg" ? + <> {}} text="Регистрация"/> + : + <> {}} text="Вы записаны"/> + }
diff --git a/src/views/home/contests/Contests.tsx b/src/views/home/contests/Contests.tsx index 2d68158..d704521 100644 --- a/src/views/home/contests/Contests.tsx +++ b/src/views/home/contests/Contests.tsx @@ -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 ( -
-
+ if (loading == "loading") { + return
Загрузка контестов...
; + } + if (error) { + return
Ошибка: {error}
; + } + + return ( +
+
Контесты
{ }} + onClick={() => {}} text="Создать группу" className="absolute right-0" />
-
+
-
+ { + const endTime = + new Date(contest.endsAt).getTime() + return endTime >= now.getTime(); + })} + /> - - { - const endTime = new Date(contest.startAt).getTime() + contest.duration * 60 * 1000; - return endTime >= now.getTime(); - })} /> - { - const endTime = new Date(contest.startAt).getTime() + contest.duration * 60 * 1000; - return endTime < now.getTime(); - })} /> + { + const endTime = + new Date(contest.endsAt).getTime() + return endTime < now.getTime(); + })} + />
); diff --git a/src/views/home/contests/ContestsBlock.tsx b/src/views/home/contests/ContestsBlock.tsx index ec14e55..ec9642c 100644 --- a/src/views/home/contests/ContestsBlock.tsx +++ b/src/views/home/contests/ContestsBlock.tsx @@ -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 = ({ contests, title, className }) => { +const ContestsBlock: FC = ({ contests, title, className }) => { const [active, setActive] = useState(title != "Скрытые"); @@ -50,7 +42,14 @@ const GroupsBlock: FC = ({ contests, title, className }) => {
{ - contests.map((v, i) => ) + contests.map((v, i) => ) }
@@ -60,4 +59,4 @@ const GroupsBlock: FC = ({ contests, title, className }) => { ); }; -export default GroupsBlock; +export default ContestsBlock;