add contests
This commit is contained in:
@@ -273,7 +273,7 @@ export const fetchParticipatingContests = createAsyncThunk(
|
|||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const { page = 0, pageSize = 10 } = params;
|
const { page = 0, pageSize = 100 } = params;
|
||||||
const response = await axios.get<ContestsResponse>(
|
const response = await axios.get<ContestsResponse>(
|
||||||
'/contests/participating',
|
'/contests/participating',
|
||||||
{ params: { page, pageSize } },
|
{ params: { page, pageSize } },
|
||||||
@@ -315,7 +315,7 @@ export const fetchContests = createAsyncThunk(
|
|||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const { page = 0, pageSize = 10, groupId } = params;
|
const { page = 0, pageSize = 100, groupId } = params;
|
||||||
const response = await axios.get<ContestsResponse>('/contests', {
|
const response = await axios.get<ContestsResponse>('/contests', {
|
||||||
params: { page, pageSize, groupId },
|
params: { page, pageSize, groupId },
|
||||||
});
|
});
|
||||||
@@ -417,7 +417,7 @@ export const fetchRegisteredContests = createAsyncThunk(
|
|||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const { page = 0, pageSize = 10 } = params;
|
const { page = 0, pageSize = 100 } = params;
|
||||||
const response = await axios.get<ContestsResponse>(
|
const response = await axios.get<ContestsResponse>(
|
||||||
'/contests/registered',
|
'/contests/registered',
|
||||||
{ params: { page, pageSize } },
|
{ params: { page, pageSize } },
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export const fetchGroupPosts = createAsyncThunk(
|
|||||||
{
|
{
|
||||||
groupId,
|
groupId,
|
||||||
page = 0,
|
page = 0,
|
||||||
pageSize = 20,
|
pageSize = 100,
|
||||||
}: { groupId: number; page?: number; pageSize?: number },
|
}: { groupId: number; page?: number; pageSize?: number },
|
||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
395
src/redux/slices/profile.ts
Normal file
395
src/redux/slices/profile.ts
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import axios from '../../axios';
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Типы
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||||
|
|
||||||
|
// Основной профиль
|
||||||
|
export interface ProfileIdentity {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileSolutions {
|
||||||
|
totalSolved: number;
|
||||||
|
solvedLast7Days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileContestsInfo {
|
||||||
|
totalParticipations: number;
|
||||||
|
participationsLast7Days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileCreationStats {
|
||||||
|
missions: { total: number; last7Days: number };
|
||||||
|
contests: { total: number; last7Days: number };
|
||||||
|
articles: { total: number; last7Days: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileResponse {
|
||||||
|
identity: ProfileIdentity;
|
||||||
|
solutions: ProfileSolutions;
|
||||||
|
contests: ProfileContestsInfo;
|
||||||
|
creation: ProfileCreationStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missions
|
||||||
|
export interface MissionsBucket {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
solved: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionItem {
|
||||||
|
missionId: number;
|
||||||
|
missionName: string;
|
||||||
|
difficultyLabel: string;
|
||||||
|
difficultyValue: number;
|
||||||
|
createdAt: string;
|
||||||
|
timeLimitMilliseconds: number;
|
||||||
|
memoryLimitBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionsResponse {
|
||||||
|
summary: {
|
||||||
|
total: MissionsBucket;
|
||||||
|
buckets: MissionsBucket[];
|
||||||
|
};
|
||||||
|
recent: {
|
||||||
|
items: MissionItem[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
};
|
||||||
|
authored: {
|
||||||
|
items: MissionItem[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Articles
|
||||||
|
export interface ProfileArticleItem {
|
||||||
|
articleId: number;
|
||||||
|
title: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
export interface ProfileArticlesResponse {
|
||||||
|
articles: {
|
||||||
|
items: ProfileArticleItem[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contests
|
||||||
|
export interface ContestItem {
|
||||||
|
contestId: number;
|
||||||
|
name: string;
|
||||||
|
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||||||
|
visibility: string;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
attemptDurationMinutes: number;
|
||||||
|
role: 'None' | 'Participant' | 'Organizer';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContestsList {
|
||||||
|
items: ContestItem[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileContestsResponse {
|
||||||
|
upcoming: ContestsList;
|
||||||
|
past: ContestsList;
|
||||||
|
mine: ContestsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Состояние
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
interface ProfileState {
|
||||||
|
profile: {
|
||||||
|
data?: ProfileResponse;
|
||||||
|
status: Status;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
missions: {
|
||||||
|
data?: MissionsResponse;
|
||||||
|
status: Status;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
articles: {
|
||||||
|
data?: ProfileArticlesResponse;
|
||||||
|
status: Status;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
contests: {
|
||||||
|
data?: ProfileContestsResponse;
|
||||||
|
status: Status;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ProfileState = {
|
||||||
|
profile: {
|
||||||
|
data: undefined,
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
missions: {
|
||||||
|
data: undefined,
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
articles: {
|
||||||
|
data: undefined,
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
contests: {
|
||||||
|
data: undefined,
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Async Thunks
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
// Основной профиль
|
||||||
|
export const fetchProfile = createAsyncThunk(
|
||||||
|
'profile/fetch',
|
||||||
|
async (username: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get<ProfileResponse>(
|
||||||
|
`/profile/${username}`,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка загрузки профиля',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Missions
|
||||||
|
export const fetchProfileMissions = createAsyncThunk(
|
||||||
|
'profile/fetchMissions',
|
||||||
|
async (
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
recentPage = 0,
|
||||||
|
recentPageSize = 15,
|
||||||
|
authoredPage = 0,
|
||||||
|
authoredPageSize = 25,
|
||||||
|
}: {
|
||||||
|
username: string;
|
||||||
|
recentPage?: number;
|
||||||
|
recentPageSize?: number;
|
||||||
|
authoredPage?: number;
|
||||||
|
authoredPageSize?: number;
|
||||||
|
},
|
||||||
|
{ rejectWithValue },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get<MissionsResponse>(
|
||||||
|
`/profile/${username}/missions`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
recentPage,
|
||||||
|
recentPageSize,
|
||||||
|
authoredPage,
|
||||||
|
authoredPageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка загрузки задач',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Articles
|
||||||
|
export const fetchProfileArticles = createAsyncThunk(
|
||||||
|
'profile/fetchArticles',
|
||||||
|
async (
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
page = 0,
|
||||||
|
pageSize = 25,
|
||||||
|
}: { username: string; page?: number; pageSize?: number },
|
||||||
|
{ rejectWithValue },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get<ProfileArticlesResponse>(
|
||||||
|
`/profile/${username}/articles`,
|
||||||
|
{ params: { page, pageSize } },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка загрузки статей',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Contests
|
||||||
|
export const fetchProfileContests = createAsyncThunk(
|
||||||
|
'profile/fetchContests',
|
||||||
|
async (
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
upcomingPage = 0,
|
||||||
|
upcomingPageSize = 10,
|
||||||
|
pastPage = 0,
|
||||||
|
pastPageSize = 10,
|
||||||
|
minePage = 0,
|
||||||
|
minePageSize = 10,
|
||||||
|
}: {
|
||||||
|
username: string;
|
||||||
|
upcomingPage?: number;
|
||||||
|
upcomingPageSize?: number;
|
||||||
|
pastPage?: number;
|
||||||
|
pastPageSize?: number;
|
||||||
|
minePage?: number;
|
||||||
|
minePageSize?: number;
|
||||||
|
},
|
||||||
|
{ rejectWithValue },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get<ProfileContestsResponse>(
|
||||||
|
`/profile/${username}/contests`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
upcomingPage,
|
||||||
|
upcomingPageSize,
|
||||||
|
pastPage,
|
||||||
|
pastPageSize,
|
||||||
|
minePage,
|
||||||
|
minePageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message || 'Ошибка загрузки контестов',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Slice
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
const profileSlice = createSlice({
|
||||||
|
name: 'profile',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setProfileStatus: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
key: keyof ProfileState;
|
||||||
|
status: Status;
|
||||||
|
}>,
|
||||||
|
) => {
|
||||||
|
state[action.payload.key].status = action.payload.status;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
// PROFILE
|
||||||
|
builder.addCase(fetchProfile.pending, (state) => {
|
||||||
|
state.profile.status = 'loading';
|
||||||
|
state.profile.error = undefined;
|
||||||
|
});
|
||||||
|
builder.addCase(
|
||||||
|
fetchProfile.fulfilled,
|
||||||
|
(state, action: PayloadAction<ProfileResponse>) => {
|
||||||
|
state.profile.status = 'successful';
|
||||||
|
state.profile.data = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(fetchProfile.rejected, (state, action: any) => {
|
||||||
|
state.profile.status = 'failed';
|
||||||
|
state.profile.error = action.payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
// MISSIONS
|
||||||
|
builder.addCase(fetchProfileMissions.pending, (state) => {
|
||||||
|
state.missions.status = 'loading';
|
||||||
|
state.missions.error = undefined;
|
||||||
|
});
|
||||||
|
builder.addCase(
|
||||||
|
fetchProfileMissions.fulfilled,
|
||||||
|
(state, action: PayloadAction<MissionsResponse>) => {
|
||||||
|
state.missions.status = 'successful';
|
||||||
|
state.missions.data = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(fetchProfileMissions.rejected, (state, action: any) => {
|
||||||
|
state.missions.status = 'failed';
|
||||||
|
state.missions.error = action.payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ARTICLES
|
||||||
|
builder.addCase(fetchProfileArticles.pending, (state) => {
|
||||||
|
state.articles.status = 'loading';
|
||||||
|
state.articles.error = undefined;
|
||||||
|
});
|
||||||
|
builder.addCase(
|
||||||
|
fetchProfileArticles.fulfilled,
|
||||||
|
(state, action: PayloadAction<ProfileArticlesResponse>) => {
|
||||||
|
state.articles.status = 'successful';
|
||||||
|
state.articles.data = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(fetchProfileArticles.rejected, (state, action: any) => {
|
||||||
|
state.articles.status = 'failed';
|
||||||
|
state.articles.error = action.payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
// CONTESTS
|
||||||
|
builder.addCase(fetchProfileContests.pending, (state) => {
|
||||||
|
state.contests.status = 'loading';
|
||||||
|
state.contests.error = undefined;
|
||||||
|
});
|
||||||
|
builder.addCase(
|
||||||
|
fetchProfileContests.fulfilled,
|
||||||
|
(state, action: PayloadAction<ProfileContestsResponse>) => {
|
||||||
|
state.contests.status = 'successful';
|
||||||
|
state.contests.data = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(fetchProfileContests.rejected, (state, action: any) => {
|
||||||
|
state.contests.status = 'failed';
|
||||||
|
state.contests.error = action.payload;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setProfileStatus } = profileSlice.actions;
|
||||||
|
export const profileReducer = profileSlice.reducer;
|
||||||
@@ -7,6 +7,9 @@ interface StorState {
|
|||||||
activeProfilePage: string;
|
activeProfilePage: string;
|
||||||
activeGroupPage: string;
|
activeGroupPage: string;
|
||||||
};
|
};
|
||||||
|
group: {
|
||||||
|
groupFilter: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация состояния
|
// Инициализация состояния
|
||||||
@@ -16,6 +19,9 @@ const initialState: StorState = {
|
|||||||
activeProfilePage: '',
|
activeProfilePage: '',
|
||||||
activeGroupPage: '',
|
activeGroupPage: '',
|
||||||
},
|
},
|
||||||
|
group: {
|
||||||
|
groupFilter: '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Slice
|
// Slice
|
||||||
@@ -38,6 +44,9 @@ const storeSlice = createSlice({
|
|||||||
) => {
|
) => {
|
||||||
state.menu.activeGroupPage = activeGroupPage.payload;
|
state.menu.activeGroupPage = activeGroupPage.payload;
|
||||||
},
|
},
|
||||||
|
setGroupFilter: (state, groupFilter: PayloadAction<string>) => {
|
||||||
|
state.group.groupFilter = groupFilter.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +54,7 @@ export const {
|
|||||||
setMenuActivePage,
|
setMenuActivePage,
|
||||||
setMenuActiveProfilePage,
|
setMenuActiveProfilePage,
|
||||||
setMenuActiveGroupPage,
|
setMenuActiveGroupPage,
|
||||||
|
setGroupFilter,
|
||||||
} = storeSlice.actions;
|
} = storeSlice.actions;
|
||||||
|
|
||||||
export const storeReducer = storeSlice.reducer;
|
export const storeReducer = storeSlice.reducer;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { groupsReducer } from './slices/groups';
|
|||||||
import { articlesReducer } from './slices/articles';
|
import { articlesReducer } from './slices/articles';
|
||||||
import { groupFeedReducer } from './slices/groupfeed';
|
import { groupFeedReducer } from './slices/groupfeed';
|
||||||
import { groupChatReducer } from './slices/groupChat';
|
import { groupChatReducer } from './slices/groupChat';
|
||||||
|
import { profileReducer } from './slices/profile';
|
||||||
|
|
||||||
// использование
|
// использование
|
||||||
// import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
// import { useAppDispatch, useAppSelector } from '../redux/hooks';
|
||||||
@@ -29,6 +30,7 @@ export const store = configureStore({
|
|||||||
articles: articlesReducer,
|
articles: articlesReducer,
|
||||||
groupfeed: groupFeedReducer,
|
groupfeed: groupFeedReducer,
|
||||||
groupchat: groupChatReducer,
|
groupchat: groupChatReducer,
|
||||||
|
profile: profileReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,47 @@ import RightPanel from './RightPanel';
|
|||||||
import Missions from './missions/Missions';
|
import Missions from './missions/Missions';
|
||||||
import Contests from './contests/Contests';
|
import Contests from './contests/Contests';
|
||||||
import ArticlesBlock from './articles/ArticlesBlock';
|
import ArticlesBlock from './articles/ArticlesBlock';
|
||||||
import { useAppDispatch } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
|
import { useQuery } from '../../../hooks/useQuery';
|
||||||
|
import {
|
||||||
|
fetchProfile,
|
||||||
|
fetchProfileArticles,
|
||||||
|
fetchProfileContests,
|
||||||
|
fetchProfileMissions,
|
||||||
|
} from '../../../redux/slices/profile';
|
||||||
|
|
||||||
const Account = () => {
|
const Account = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const myname = useAppSelector((state) => state.auth.username);
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const username = query.get('username') ?? myname ?? '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('account'));
|
if (username == myname) dispatch(setMenuActivePage('account'));
|
||||||
}, []);
|
dispatch(
|
||||||
|
fetchProfileMissions({
|
||||||
|
username: username,
|
||||||
|
recentPageSize: 1,
|
||||||
|
authoredPageSize: 100,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
dispatch(fetchProfileArticles({ username: username, pageSize: 100 }));
|
||||||
|
dispatch(
|
||||||
|
fetchProfileContests({
|
||||||
|
username: username,
|
||||||
|
pastPageSize: 100,
|
||||||
|
minePageSize: 100,
|
||||||
|
upcomingPageSize: 100,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
dispatch(fetchProfile(username));
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[calc(100%+250px)] box-border grid grid-cols-[1fr,520px] relative divide-x-[1px] divide-liquid-lighter">
|
<div className="h-full w-[calc(100%+250px)] box-border grid grid-cols-[1fr,430px] relative divide-x-[1px] divide-liquid-lighter">
|
||||||
<div className=" h-full min-h-0 flex flex-col">
|
<div className=" h-full min-h-0 flex flex-col">
|
||||||
<div className=" h-full grid grid-rows-[80px,1fr] ">
|
<div className=" h-full grid grid-rows-[80px,1fr] ">
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
|||||||
import { logout } from '../../../redux/slices/auth';
|
import { logout } from '../../../redux/slices/auth';
|
||||||
import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account';
|
import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
|
import { useQuery } from '../../../hooks/useQuery';
|
||||||
|
|
||||||
interface StatisticItemProps {
|
interface StatisticItemProps {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -34,32 +35,55 @@ const StatisticItem: FC<StatisticItemProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatDate = (isoDate?: string): string => {
|
||||||
|
if (!isoDate) return '';
|
||||||
|
const date = new Date(isoDate);
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
};
|
||||||
|
|
||||||
const RightPanel = () => {
|
const RightPanel = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const name = useAppSelector((state) => state.auth.username);
|
|
||||||
const email = useAppSelector((state) => state.auth.email);
|
const { data: profileData } = useAppSelector(
|
||||||
|
(state) => state.profile.profile,
|
||||||
|
);
|
||||||
|
|
||||||
|
const myname = useAppSelector((state) => state.auth.username);
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const username = query.get('username') ?? myname ?? '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full relative flex flex-col p-[20px] pt-[35px] gap-[20px]">
|
<div className="h-full w-full relative flex flex-col p-[20px] pt-[35px] gap-[20px]">
|
||||||
<div className="grid grid-cols-[150px,1fr] h-[150px] gap-[20px]">
|
<div className="grid grid-cols-[150px,1fr] h-[150px] gap-[20px]">
|
||||||
<div className="-hfull w-full bg-[#B8B8B8] rounded-[10px]"></div>
|
<div className="-hfull w-full bg-[#B8B8B8] rounded-[10px]"></div>
|
||||||
<div className=" relative">
|
<div className=" relative">
|
||||||
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
||||||
{name}
|
{profileData?.identity.username}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-liquid-light text-[18px] leading-[23px] font-medium">
|
<div className="text-liquid-light text-[18px] leading-[23px] font-medium">
|
||||||
{email}
|
{profileData?.identity.email}
|
||||||
</div>
|
|
||||||
<div className=" absolute bottom-0 text-liquid-light text-[24px] leading-[30px] font-bold">
|
|
||||||
Топ 50%
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PrimaryButton
|
<div className=" text-liquid-light text-[18px] leading-[30px] font-bold">
|
||||||
onClick={() => {}}
|
{`Зарегистрирован ${formatDate(
|
||||||
text="Редактировать"
|
profileData?.identity.createdAt,
|
||||||
className="w-full"
|
)}`}
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
{username == myname && (
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {}}
|
||||||
|
text="Редактировать"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
<div className="h-[1px] w-full bg-liquid-lighter"></div>
|
||||||
|
|
||||||
@@ -70,14 +94,14 @@ const RightPanel = () => {
|
|||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Clipboard}
|
icon={Clipboard}
|
||||||
title={'Задачи'}
|
title={'Задачи'}
|
||||||
count={14}
|
count={profileData?.solutions.totalSolved}
|
||||||
countLastWeek={5}
|
countLastWeek={profileData?.solutions.solvedLast7Days}
|
||||||
/>
|
/>
|
||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Cup}
|
icon={Cup}
|
||||||
title={'Контесты'}
|
title={'Контесты'}
|
||||||
count={8}
|
count={profileData?.contests.totalParticipations}
|
||||||
countLastWeek={2}
|
countLastWeek={profileData?.contests.participationsLast7Days}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
|
||||||
@@ -87,30 +111,32 @@ const RightPanel = () => {
|
|||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Clipboard}
|
icon={Clipboard}
|
||||||
title={'Задачи'}
|
title={'Задачи'}
|
||||||
count={4}
|
count={profileData?.creation.missions.total}
|
||||||
countLastWeek={2}
|
countLastWeek={profileData?.creation.missions.last7Days}
|
||||||
/>
|
/>
|
||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={OpenBook}
|
icon={OpenBook}
|
||||||
title={'Статьи'}
|
title={'Статьи'}
|
||||||
count={12}
|
count={profileData?.creation.articles.total}
|
||||||
countLastWeek={4}
|
countLastWeek={profileData?.creation.articles.last7Days}
|
||||||
/>
|
/>
|
||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={Cup}
|
icon={Cup}
|
||||||
title={'Контесты'}
|
title={'Контесты'}
|
||||||
count={2}
|
count={profileData?.creation.contests.total}
|
||||||
countLastWeek={0}
|
countLastWeek={profileData?.creation.contests.last7Days}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReverseButton
|
{username == myname && (
|
||||||
className="absolute bottom-[20px] right-[20px]"
|
<ReverseButton
|
||||||
onClick={() => {
|
className="absolute bottom-[20px] right-[20px]"
|
||||||
dispatch(logout());
|
onClick={() => {
|
||||||
}}
|
dispatch(logout());
|
||||||
text="Выход"
|
}}
|
||||||
color="error"
|
text="Выход"
|
||||||
/>
|
color="error"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,16 +3,25 @@ import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
|||||||
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
||||||
import { cn } from '../../../../lib/cn';
|
import { cn } from '../../../../lib/cn';
|
||||||
import { ChevroneDown, Edit } from '../../../../assets/icons/groups';
|
import { ChevroneDown, Edit } from '../../../../assets/icons/groups';
|
||||||
import { fetchMyArticles } from '../../../../redux/slices/articles';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export interface ArticleItemProps {
|
export interface ArticleItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
tags: string[];
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ArticleItem: FC<ArticleItemProps> = ({ id, name, tags }) => {
|
export const formatDate = (isoDate?: string): string => {
|
||||||
|
if (!isoDate) return '';
|
||||||
|
const date = new Date(isoDate);
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ArticleItem: FC<ArticleItemProps> = ({ id, name, createdAt }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,18 +44,8 @@ const ArticleItem: FC<ArticleItemProps> = ({ id, name, tags }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
|
<div className="text-[18px] flex text-liquid-light gap-[10px] mt-[20px]">
|
||||||
{tags.map((v, i) => (
|
{`Опубликована ${formatDate(createdAt)}`}
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={cn(
|
|
||||||
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
|
|
||||||
v === 'Sertificated' && 'text-liquid-green',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
@@ -72,20 +71,12 @@ const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [active, setActive] = useState<boolean>(true);
|
const [active, setActive] = useState<boolean>(true);
|
||||||
|
|
||||||
// ✅ Берём только "мои статьи"
|
const { data: articleData } = useAppSelector(
|
||||||
const articles = useAppSelector(
|
(state) => state.profile.articles,
|
||||||
(state) => state.articles.fetchMyArticles.articles,
|
|
||||||
);
|
|
||||||
const status = useAppSelector(
|
|
||||||
(state) => state.articles.fetchMyArticles.status,
|
|
||||||
);
|
|
||||||
const error = useAppSelector(
|
|
||||||
(state) => state.articles.fetchMyArticles.error,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActiveProfilePage('articles'));
|
dispatch(setMenuActiveProfilePage('articles'));
|
||||||
dispatch(fetchMyArticles());
|
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,19 +121,21 @@ const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'failed' && (
|
{status === 'failed' && (
|
||||||
<div className="text-liquid-red">
|
<div className="text-liquid-red">Ошибка: </div>
|
||||||
Ошибка:{' '}
|
|
||||||
{error || 'Не удалось загрузить статьи'}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{status === 'successful' &&
|
{status === 'successful' &&
|
||||||
articles.length === 0 && (
|
articleData?.articles.items.length === 0 && (
|
||||||
<div className="text-liquid-light">
|
<div className="text-liquid-light">
|
||||||
У вас пока нет статей
|
У вас пока нет статей
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{articles.map((v) => (
|
{articleData?.articles.items.map((v, i) => (
|
||||||
<ArticleItem key={v.id} {...v} />
|
<ArticleItem
|
||||||
|
key={i}
|
||||||
|
id={v.articleId}
|
||||||
|
name={v.title}
|
||||||
|
createdAt={v.createdAt}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
||||||
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
|
||||||
import {
|
|
||||||
fetchMyContests,
|
|
||||||
fetchRegisteredContests,
|
|
||||||
} from '../../../../redux/slices/contests';
|
|
||||||
import ContestsBlock from './ContestsBlock';
|
import ContestsBlock from './ContestsBlock';
|
||||||
|
|
||||||
const Contests = () => {
|
const Contests = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
// Redux-состояния
|
const { data: constestData } = useAppSelector(
|
||||||
const myContestsState = useAppSelector(
|
(state) => state.profile.contests,
|
||||||
(state) => state.contests.fetchMyContests,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// При загрузке страницы — выставляем вкладку и подгружаем контесты
|
// При загрузке страницы — выставляем вкладку и подгружаем контесты
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActiveProfilePage('contests'));
|
dispatch(setMenuActiveProfilePage('contests'));
|
||||||
dispatch(fetchMyContests());
|
|
||||||
dispatch(fetchRegisteredContests({}));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,30 +22,38 @@ const Contests = () => {
|
|||||||
<ContestsBlock
|
<ContestsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Предстоящие контесты"
|
title="Предстоящие контесты"
|
||||||
type="reg"
|
type="upcoming"
|
||||||
// contests={regContestsState.contests}
|
contests={constestData?.upcoming.items
|
||||||
contests={[]}
|
.filter((v) => v.role != 'Organizer')
|
||||||
|
.filter((v) => v.scheduleType != 'AlwaysOpen')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ContestsBlock
|
||||||
|
className="mb-[20px]"
|
||||||
|
title="Прошедшие контесты"
|
||||||
|
type="past"
|
||||||
|
contests={[
|
||||||
|
...(constestData?.past.items.filter(
|
||||||
|
(v) => v.role != 'Organizer',
|
||||||
|
) ?? []),
|
||||||
|
...(constestData?.upcoming.items
|
||||||
|
.filter((v) => v.role != 'Organizer')
|
||||||
|
.filter((v) => v.scheduleType == 'AlwaysOpen') ??
|
||||||
|
[]),
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Контесты, которые я создал */}
|
{/* Контесты, которые я создал */}
|
||||||
<div>
|
<div>
|
||||||
{myContestsState.status === 'loading' ? (
|
<ContestsBlock
|
||||||
<div className="text-liquid-white p-4 text-[24px]">
|
className="mb-[20px]"
|
||||||
Загрузка ваших контестов...
|
title="Созданные контесты"
|
||||||
</div>
|
type="edit"
|
||||||
) : myContestsState.error ? (
|
contests={constestData?.mine.items}
|
||||||
<div className="text-red-500 p-4 text-[24px]">
|
/>
|
||||||
Ошибка: {myContestsState.error}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ContestsBlock
|
|
||||||
className="mb-[20px]"
|
|
||||||
title="Мои контесты"
|
|
||||||
type="my"
|
|
||||||
contests={myContestsState.contests}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import { useState, FC } from 'react';
|
import { useState, FC } from 'react';
|
||||||
import { cn } from '../../../../lib/cn';
|
import { cn } from '../../../../lib/cn';
|
||||||
import { ChevroneDown } from '../../../../assets/icons/groups';
|
import { ChevroneDown } from '../../../../assets/icons/groups';
|
||||||
import MyContestItem from './MyContestItem';
|
import { ContestItem } from '../../../../redux/slices/profile';
|
||||||
import RegisterContestItem from './RegisterContestItem';
|
import PastContestItem from './PastContestItem';
|
||||||
import { Contest } from '../../../../redux/slices/contests';
|
import UpcoingContestItem from './UpcomingContestItem';
|
||||||
|
import EditContestItem from './EditContestItem';
|
||||||
|
|
||||||
interface ContestsBlockProps {
|
interface ContestsBlockProps {
|
||||||
contests: Contest[];
|
contests?: ContestItem[];
|
||||||
title: string;
|
title: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
type?: 'my' | 'reg';
|
type?: 'edit' | 'upcoming' | 'past';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContestsBlock: FC<ContestsBlockProps> = ({
|
const ContestsBlock: FC<ContestsBlockProps> = ({
|
||||||
contests,
|
contests,
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
type = 'my',
|
type = 'edit',
|
||||||
}) => {
|
}) => {
|
||||||
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
||||||
|
|
||||||
@@ -36,11 +37,11 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
|
|||||||
setActive(!active);
|
setActive(!active);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{title}</span>
|
<span className=" select-none">{title}</span>
|
||||||
<img
|
<img
|
||||||
src={ChevroneDown}
|
src={ChevroneDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
'transition-all duration-300',
|
'transition-all duration-300 select-none',
|
||||||
active && 'rotate-180',
|
active && 'rotate-180',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -53,35 +54,38 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="pb-[10px] pt-[20px]">
|
<div className="pb-[10px] pt-[20px]">
|
||||||
{contests.map((v, i) => {
|
{contests?.map((v, i) => {
|
||||||
return type == 'my' ? (
|
if (type == 'past') {
|
||||||
<MyContestItem
|
return (
|
||||||
key={i}
|
<PastContestItem
|
||||||
id={v.id}
|
key={i}
|
||||||
name={v.name}
|
{...v}
|
||||||
startAt={v.startsAt ?? ''}
|
type={i % 2 ? 'second' : 'first'}
|
||||||
duration={
|
/>
|
||||||
new Date(v.endsAt ?? '').getTime() -
|
);
|
||||||
new Date(v.startsAt ?? '').getTime()
|
}
|
||||||
}
|
|
||||||
members={(v.members??[]).length}
|
if (type == 'upcoming') {
|
||||||
type={i % 2 ? 'second' : 'first'}
|
return (
|
||||||
/>
|
<UpcoingContestItem
|
||||||
) : (
|
key={i}
|
||||||
<RegisterContestItem
|
{...v}
|
||||||
key={i}
|
type={i % 2 ? 'second' : 'first'}
|
||||||
id={v.id}
|
/>
|
||||||
name={v.name}
|
);
|
||||||
startAt={v.startsAt ?? ''}
|
}
|
||||||
statusRegister={'reg'}
|
|
||||||
duration={
|
if (type == 'edit') {
|
||||||
new Date(v.endsAt ?? '').getTime() -
|
return (
|
||||||
new Date(v.startsAt ?? '').getTime()
|
<EditContestItem
|
||||||
}
|
key={i}
|
||||||
members={(v.members??[]).length}
|
{...v}
|
||||||
type={i % 2 ? 'second' : 'first'}
|
type={i % 2 ? 'second' : 'first'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <></>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
146
src/views/home/account/contests/EditContestItem.tsx
Normal file
146
src/views/home/account/contests/EditContestItem.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { cn } from '../../../../lib/cn';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppSelector } from '../../../../redux/hooks';
|
||||||
|
import { useQuery } from '../../../../hooks/useQuery';
|
||||||
|
import { toastWarning } from '../../../../lib/toastNotification';
|
||||||
|
import { Edit } from '../../../../assets/icons/input';
|
||||||
|
|
||||||
|
export interface EditContestItemProps {
|
||||||
|
name: string;
|
||||||
|
contestId: number;
|
||||||
|
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||||||
|
visibility: string;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
attemptDurationMinutes: number;
|
||||||
|
role: string;
|
||||||
|
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} мин`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditContestItem: React.FC<EditContestItemProps> = ({
|
||||||
|
name,
|
||||||
|
contestId,
|
||||||
|
scheduleType,
|
||||||
|
startsAt,
|
||||||
|
endsAt,
|
||||||
|
attemptDurationMinutes,
|
||||||
|
type,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const myname = useAppSelector((state) => state.auth.username);
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const username = query.get('username') ?? myname ?? '';
|
||||||
|
|
||||||
|
const started = new Date(startsAt) <= new Date();
|
||||||
|
|
||||||
|
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',
|
||||||
|
type == 'first'
|
||||||
|
? ' bg-liquid-lighter'
|
||||||
|
: ' bg-liquid-background',
|
||||||
|
username == myname
|
||||||
|
? 'grid-cols-[1fr,150px,190px,110px,130px,24px]'
|
||||||
|
: 'grid-cols-[1fr,150px,190px,110px,130px]',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!started) {
|
||||||
|
toastWarning('Контест еще не начался');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
back: '/home/account/contests',
|
||||||
|
});
|
||||||
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-left font-bold text-[18px]">{name}</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">
|
||||||
|
{new Date() < new Date(startsAt) ? (
|
||||||
|
<>{'Не начался'}</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{scheduleType == 'AlwaysOpen'
|
||||||
|
? 'Открыт'
|
||||||
|
: new Date() < new Date(endsAt)
|
||||||
|
? 'Идет'
|
||||||
|
: 'Завершен'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{username == myname && (
|
||||||
|
<img
|
||||||
|
className=" h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
|
||||||
|
src={Edit}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(
|
||||||
|
`/contest/create?back=/home/account/contests&contestId=${contestId}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditContestItem;
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { cn } from '../../../../lib/cn';
|
|
||||||
import { Account } from '../../../../assets/icons/auth';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Edit } from '../../../../assets/icons/input';
|
|
||||||
|
|
||||||
export interface ContestItemProps {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
startAt: string;
|
|
||||||
duration: number;
|
|
||||||
members: 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 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,
|
|
||||||
startAt,
|
|
||||||
duration,
|
|
||||||
members,
|
|
||||||
type,
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid grid-cols-[1fr,1fr,110px,110px,110px,24px] items-center font-bold',
|
|
||||||
type == 'first'
|
|
||||||
? ' bg-liquid-lighter'
|
|
||||||
: ' bg-liquid-background',
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/contest/${id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>)} */}
|
|
||||||
valavshonok
|
|
||||||
</div>
|
|
||||||
<div className="text-center text-nowrap whitespace-pre-line">
|
|
||||||
{formatDate(startAt)}
|
|
||||||
</div>
|
|
||||||
<div className="text-center">{formatWaitTime(duration)}</div>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<img
|
|
||||||
className=" h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
|
|
||||||
src={Edit}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(
|
|
||||||
`/contest/create?back=/home/account/contests&contestId=${id}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContestItem;
|
|
||||||
112
src/views/home/account/contests/PastContestItem.tsx
Normal file
112
src/views/home/account/contests/PastContestItem.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { cn } from '../../../../lib/cn';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppSelector } from '../../../../redux/hooks';
|
||||||
|
import { useQuery } from '../../../../hooks/useQuery';
|
||||||
|
|
||||||
|
export interface PastContestItemProps {
|
||||||
|
name: string;
|
||||||
|
contestId: number;
|
||||||
|
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||||||
|
visibility: string;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
attemptDurationMinutes: number;
|
||||||
|
role: string;
|
||||||
|
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} мин`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PastContestItem: React.FC<PastContestItemProps> = ({
|
||||||
|
name,
|
||||||
|
contestId,
|
||||||
|
scheduleType,
|
||||||
|
startsAt,
|
||||||
|
endsAt,
|
||||||
|
attemptDurationMinutes,
|
||||||
|
type,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const myname = useAppSelector((state) => state.auth.username);
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const username = query.get('username') ?? myname ?? '';
|
||||||
|
|
||||||
|
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 grid-cols-[1fr,150px,190px,120px,150px] items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
|
||||||
|
type == 'first'
|
||||||
|
? ' bg-liquid-lighter'
|
||||||
|
: ' bg-liquid-background',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
back: '/home/account/contests',
|
||||||
|
});
|
||||||
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-left font-bold text-[18px]">{name}</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PastContestItem;
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { cn } from '../../../../lib/cn';
|
|
||||||
import { Account } from '../../../../assets/icons/auth';
|
|
||||||
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
|
|
||||||
import { ReverseButton } from '../../../../components/button/ReverseButton';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
export interface ContestItemProps {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
startAt: string;
|
|
||||||
duration: number;
|
|
||||||
members: number;
|
|
||||||
statusRegister: 'reg' | 'nonreg';
|
|
||||||
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 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,
|
|
||||||
startAt,
|
|
||||||
duration,
|
|
||||||
members,
|
|
||||||
statusRegister,
|
|
||||||
type,
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
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 text-[16px] leading-[20px] cursor-pointer',
|
|
||||||
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',
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/contest/${id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>)} */}
|
|
||||||
valavshonok
|
|
||||||
</div>
|
|
||||||
<div className="text-center text-nowrap whitespace-pre-line">
|
|
||||||
{formatDate(startAt)}
|
|
||||||
</div>
|
|
||||||
<div className="text-center">{formatWaitTime(duration)}</div>
|
|
||||||
{waitTime > 0 && (
|
|
||||||
<div className="text-center whitespace-pre-line ">
|
|
||||||
{'До начала\n' + formatWaitTime(waitTime)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<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="flex items-center justify-end">
|
|
||||||
{statusRegister == 'reg' ? (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<PrimaryButton onClick={() => {}} text="Регистрация" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<ReverseButton onClick={() => {}} text="Вы записаны" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContestItem;
|
|
||||||
160
src/views/home/account/contests/UpcomingContestItem.tsx
Normal file
160
src/views/home/account/contests/UpcomingContestItem.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { cn } from '../../../../lib/cn';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppSelector } from '../../../../redux/hooks';
|
||||||
|
import { useQuery } from '../../../../hooks/useQuery';
|
||||||
|
import { toastWarning } from '../../../../lib/toastNotification';
|
||||||
|
|
||||||
|
export interface UpcoingContestItemProps {
|
||||||
|
name: string;
|
||||||
|
contestId: number;
|
||||||
|
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||||||
|
visibility: string;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
attemptDurationMinutes: number;
|
||||||
|
role: string;
|
||||||
|
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} мин`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 UpcoingContestItem: React.FC<UpcoingContestItemProps> = ({
|
||||||
|
name,
|
||||||
|
contestId,
|
||||||
|
scheduleType,
|
||||||
|
startsAt,
|
||||||
|
endsAt,
|
||||||
|
attemptDurationMinutes,
|
||||||
|
type,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const myname = useAppSelector((state) => state.auth.username);
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const username = query.get('username') ?? myname ?? '';
|
||||||
|
|
||||||
|
const started = new Date(startsAt) <= new Date();
|
||||||
|
const finished = new Date(endsAt) <= new Date();
|
||||||
|
const waitTime = !started
|
||||||
|
? new Date(startsAt).getTime() - new Date().getTime()
|
||||||
|
: new Date(endsAt).getTime() - new Date().getTime();
|
||||||
|
|
||||||
|
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 grid-cols-[1fr,150px,190px,110px,110px,130px] items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
|
||||||
|
type == 'first'
|
||||||
|
? ' bg-liquid-lighter'
|
||||||
|
: ' bg-liquid-background',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!started) {
|
||||||
|
toastWarning('Контест еще не начался');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
back: '/home/account/contests',
|
||||||
|
});
|
||||||
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-left font-bold text-[18px]">{name}</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>
|
||||||
|
|
||||||
|
{!started ? (
|
||||||
|
<div className="text-center whitespace-pre-line ">
|
||||||
|
{'До начала\n' + formatWaitTime(waitTime)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!finished && (
|
||||||
|
<div className="text-center whitespace-pre-line ">
|
||||||
|
{'До конца\n' + formatWaitTime(waitTime)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
|
||||||
|
{new Date() < new Date(startsAt) ? (
|
||||||
|
<>{'Не начался'}</>
|
||||||
|
) : (
|
||||||
|
<>{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Идет'}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpcoingContestItem;
|
||||||
@@ -5,7 +5,6 @@ import { cn } from '../../../../lib/cn';
|
|||||||
import MissionsBlock from './MissionsBlock';
|
import MissionsBlock from './MissionsBlock';
|
||||||
import {
|
import {
|
||||||
deleteMission,
|
deleteMission,
|
||||||
fetchMyMissions,
|
|
||||||
setMissionsStatus,
|
setMissionsStatus,
|
||||||
} from '../../../../redux/slices/missions';
|
} from '../../../../redux/slices/missions';
|
||||||
import ConfirmModal from '../../../../components/modal/ConfirmModal';
|
import ConfirmModal from '../../../../components/modal/ConfirmModal';
|
||||||
@@ -43,14 +42,16 @@ const Item: FC<ItemProps> = ({
|
|||||||
|
|
||||||
const Missions = () => {
|
const Missions = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const missions = useAppSelector((state) => state.missions.missions);
|
|
||||||
const status = useAppSelector((state) => state.missions.statuses.fetchMy);
|
|
||||||
const [modalDeleteTask, setModalDeleteTask] = useState<boolean>(false);
|
const [modalDeleteTask, setModalDeleteTask] = useState<boolean>(false);
|
||||||
const [taskdeleteId, setTaskDeleteId] = useState<number>(0);
|
const [taskdeleteId, setTaskDeleteId] = useState<number>(0);
|
||||||
|
|
||||||
|
const { data: missionData } = useAppSelector(
|
||||||
|
(state) => state.profile.missions,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActiveProfilePage('missions'));
|
dispatch(setMenuActiveProfilePage('missions'));
|
||||||
dispatch(fetchMyMissions());
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,42 +67,39 @@ const Missions = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row justify-between items-start">
|
<div className="flex flex-row justify-between items-start">
|
||||||
<div className="flex gap-[10px]">
|
<div className="flex gap-[10px]">
|
||||||
<Item count={14} totalCount={123} title="Задачи" />
|
<Item
|
||||||
|
count={missionData?.summary?.total?.solved ?? 0}
|
||||||
|
totalCount={
|
||||||
|
missionData?.summary?.total?.total ?? 0
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
missionData?.summary?.total?.label ??
|
||||||
|
'Задачи'
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-[20px]">
|
<div className="flex gap-[20px]">
|
||||||
<Item
|
{missionData?.summary?.buckets?.map((bucket) => (
|
||||||
count={14}
|
<Item
|
||||||
totalCount={123}
|
key={bucket.key}
|
||||||
title="Easy"
|
count={bucket.solved}
|
||||||
color="green"
|
totalCount={bucket.total}
|
||||||
/>
|
title={bucket.label}
|
||||||
<Item
|
color={
|
||||||
count={14}
|
bucket.key === 'easy'
|
||||||
totalCount={123}
|
? 'green'
|
||||||
title="Medium"
|
: bucket.key === 'medium'
|
||||||
color="orange"
|
? 'orange'
|
||||||
/>
|
: 'red'
|
||||||
<Item
|
}
|
||||||
count={14}
|
/>
|
||||||
totalCount={123}
|
))}
|
||||||
title="Hard"
|
|
||||||
color="red"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[24px] font-bold text-liquid-white">
|
|
||||||
Компетенции
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-[10px]">
|
|
||||||
<Item count={14} totalCount={123} title="Массивы" />
|
|
||||||
<Item count={14} totalCount={123} title="Списки" />
|
|
||||||
<Item count={14} totalCount={123} title="Стэк" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-[20px]">
|
<div className="p-[20px]">
|
||||||
<MissionsBlock
|
<MissionsBlock
|
||||||
missions={missions ?? []}
|
missions={missionData?.authored.items ?? []}
|
||||||
title="Мои миссии"
|
title="Мои миссии"
|
||||||
setTastDeleteId={setTaskDeleteId}
|
setTastDeleteId={setTaskDeleteId}
|
||||||
setDeleteModalActive={setModalDeleteTask}
|
setDeleteModalActive={setModalDeleteTask}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { useState, FC } from 'react';
|
|||||||
import { cn } from '../../../../lib/cn';
|
import { cn } from '../../../../lib/cn';
|
||||||
import { ChevroneDown } from '../../../../assets/icons/groups';
|
import { ChevroneDown } from '../../../../assets/icons/groups';
|
||||||
import MyMissionItem from './MyMissionItem';
|
import MyMissionItem from './MyMissionItem';
|
||||||
import { Mission } from '../../../../redux/slices/missions';
|
import { MissionItem } from '../../../../redux/slices/profile';
|
||||||
|
|
||||||
interface MissionsBlockProps {
|
interface MissionsBlockProps {
|
||||||
missions: Mission[];
|
missions: MissionItem[];
|
||||||
title: string;
|
title: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
setTastDeleteId: (v: number) => void;
|
setTastDeleteId: (v: number) => void;
|
||||||
@@ -58,11 +58,11 @@ const MissionsBlock: FC<MissionsBlockProps> = ({
|
|||||||
{missions.map((v, i) => (
|
{missions.map((v, i) => (
|
||||||
<MyMissionItem
|
<MyMissionItem
|
||||||
key={i}
|
key={i}
|
||||||
id={v.id}
|
id={v.missionId}
|
||||||
name={v.name}
|
name={v.missionName}
|
||||||
timeLimit={v.timeLimit}
|
timeLimit={v.timeLimitMilliseconds}
|
||||||
memoryLimit={v.memoryLimit}
|
memoryLimit={v.memoryLimitBytes}
|
||||||
difficulty={v.difficulty}
|
difficulty={v.difficultyValue}
|
||||||
type={i % 2 ? 'second' : 'first'}
|
type={i % 2 ? 'second' : 'first'}
|
||||||
setTastDeleteId={setTastDeleteId}
|
setTastDeleteId={setTastDeleteId}
|
||||||
setDeleteModalActive={setDeleteModalActive}
|
setDeleteModalActive={setDeleteModalActive}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
|||||||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { arrowLeft } from '../../../assets/icons/header';
|
import { arrowLeft } from '../../../assets/icons/header';
|
||||||
|
import { useQuery } from '../../../hooks/useQuery';
|
||||||
|
|
||||||
export interface Article {
|
export interface Article {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -25,6 +26,10 @@ interface ContestMissionsProps {
|
|||||||
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const url = query.get('back') ?? '/home/contests';
|
||||||
|
|
||||||
const { status } = useAppSelector(
|
const { status } = useAppSelector(
|
||||||
(state) => state.contests.fetchMySubmissions,
|
(state) => state.contests.fetchMySubmissions,
|
||||||
);
|
);
|
||||||
@@ -113,7 +118,7 @@ const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
|
|||||||
src={arrowLeft}
|
src={arrowLeft}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/home/contests`);
|
navigate(url);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-liquid-light font-bold text-[18px]">
|
<span className="text-liquid-light font-bold text-[18px]">
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
import { cn } from '../../../lib/cn';
|
|
||||||
import { Account } from '../../../assets/icons/auth';
|
|
||||||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
|
||||||
import { ReverseButton } from '../../../components/button/ReverseButton';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { toastWarning } from '../../../lib/toastNotification';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
|
||||||
import { addOrUpdateContestMember } from '../../../redux/slices/contests';
|
|
||||||
|
|
||||||
export type Role = 'None' | 'Participant' | 'Organizer';
|
|
||||||
|
|
||||||
export interface ContestItemProps {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
startAt: string;
|
|
||||||
duration: number;
|
|
||||||
members: 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 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,
|
|
||||||
startAt,
|
|
||||||
duration,
|
|
||||||
members,
|
|
||||||
type,
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const waitTime = new Date(startAt).getTime() - now.getTime();
|
|
||||||
|
|
||||||
const [myRole, setMyRole] = useState<Role>('None');
|
|
||||||
|
|
||||||
const userId = useAppSelector((state) => state.auth.id);
|
|
||||||
const { contests: contestsRegistered } = useAppSelector(
|
|
||||||
(state) => state.contests.fetchParticipating,
|
|
||||||
);
|
|
||||||
const { contests: contestsMy } = useAppSelector(
|
|
||||||
(state) => state.contests.fetchMyContests,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!contestsRegistered || contestsRegistered.length === 0) {
|
|
||||||
setMyRole('None');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reg = contestsRegistered.find((c) => c.id === id);
|
|
||||||
const my = contestsMy.find((c) => c.id === id);
|
|
||||||
|
|
||||||
if (my) setMyRole('Organizer');
|
|
||||||
else if (reg) setMyRole('Participant');
|
|
||||||
else setMyRole('None');
|
|
||||||
}, [contestsRegistered]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px] cursor-pointer',
|
|
||||||
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',
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
if (myRole == 'None') {
|
|
||||||
toastWarning('Зарегистрируйтесь на контест');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(`/contest/${id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>)} */}
|
|
||||||
valavshonok
|
|
||||||
</div>
|
|
||||||
<div className="text-center text-nowrap whitespace-pre-line">
|
|
||||||
{formatDate(startAt)}
|
|
||||||
</div>
|
|
||||||
<div className="text-center">{formatWaitTime(duration)}</div>
|
|
||||||
{waitTime > 0 && (
|
|
||||||
<div className="text-center whitespace-pre-line ">
|
|
||||||
{'До начала\n' + formatWaitTime(waitTime)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<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="flex items-center justify-end">
|
|
||||||
{myRole == 'None' ? (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={() => {
|
|
||||||
dispatch(
|
|
||||||
addOrUpdateContestMember({
|
|
||||||
contestId: id,
|
|
||||||
member: {
|
|
||||||
userId: Number(userId),
|
|
||||||
role: 'Participant',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
text="Регистрация"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<ReverseButton
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/contest/${id}`);
|
|
||||||
}}
|
|
||||||
text="Войти"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContestItem;
|
|
||||||
@@ -4,31 +4,28 @@ import { cn } from '../../../lib/cn';
|
|||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import ContestsBlock from './ContestsBlock';
|
import ContestsBlock from './ContestsBlock';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { fetchContests, fetchMyContests, fetchParticipatingContests } from '../../../redux/slices/contests';
|
import {
|
||||||
|
fetchContests,
|
||||||
|
fetchMyContests,
|
||||||
|
fetchParticipatingContests,
|
||||||
|
} from '../../../redux/slices/contests';
|
||||||
import ModalCreateContest from './ModalCreate';
|
import ModalCreateContest from './ModalCreate';
|
||||||
import Filters from './Filter';
|
import Filters from './Filter';
|
||||||
|
|
||||||
const Contests = () => {
|
const Contests = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const [modalActive, setModalActive] = useState<boolean>(false);
|
const [modalActive, setModalActive] = useState<boolean>(false);
|
||||||
|
|
||||||
// Берём данные из Redux
|
const { contests, status } = useAppSelector(
|
||||||
const contests = useAppSelector(
|
(state) => state.contests.fetchContests,
|
||||||
(state) => state.contests.fetchContests.contests,
|
|
||||||
);
|
);
|
||||||
const status = useAppSelector(
|
|
||||||
(state) => state.contests.fetchContests.status,
|
|
||||||
);
|
|
||||||
const error = useAppSelector((state) => state.contests.fetchContests.error);
|
|
||||||
|
|
||||||
|
|
||||||
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
|
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActivePage('contests'));
|
dispatch(setMenuActivePage('contests'));
|
||||||
dispatch(fetchContests({}));
|
dispatch(fetchContests({}));
|
||||||
dispatch(fetchParticipatingContests({pageSize:100}));
|
dispatch(fetchParticipatingContests({ pageSize: 100 }));
|
||||||
dispatch(fetchMyContests());
|
dispatch(fetchMyContests());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -58,31 +55,24 @@ const Contests = () => {
|
|||||||
Загрузка контестов...
|
Загрузка контестов...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status == 'failed' && (
|
|
||||||
<div className="text-red-500 p-4">Ошибка: {error}</div>
|
|
||||||
)}
|
|
||||||
{status == 'successful' && (
|
{status == 'successful' && (
|
||||||
<>
|
<>
|
||||||
<ContestsBlock
|
<ContestsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Текущие"
|
title="Текущие"
|
||||||
contests={contests.filter((contest) => {
|
contests={contests.filter(
|
||||||
const endTime = new Date(
|
(c) => c.scheduleType != 'AlwaysOpen',
|
||||||
contest.endsAt ?? new Date().toDateString(),
|
)}
|
||||||
).getTime();
|
type="upcoming"
|
||||||
return endTime >= now.getTime();
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ContestsBlock
|
<ContestsBlock
|
||||||
className="mb-[20px]"
|
className="mb-[20px]"
|
||||||
title="Прошедшие"
|
title="Постоянные"
|
||||||
contests={contests.filter((contest) => {
|
contests={contests.filter(
|
||||||
const endTime = new Date(
|
(c) => c.scheduleType == 'AlwaysOpen',
|
||||||
contest.endsAt ?? new Date().toDateString(),
|
)}
|
||||||
).getTime();
|
type="past"
|
||||||
return endTime < now.getTime();
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { useState, FC } from 'react';
|
import { useState, FC } from 'react';
|
||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { ChevroneDown } from '../../../assets/icons/groups';
|
import { ChevroneDown } from '../../../assets/icons/groups';
|
||||||
import ContestItem from './ContestItem';
|
|
||||||
import { Contest } from '../../../redux/slices/contests';
|
import { Contest } from '../../../redux/slices/contests';
|
||||||
|
import PastContestItem from './PastContestItem';
|
||||||
|
import UpcoingContestItem from './UpcomingContestItem';
|
||||||
|
|
||||||
interface ContestsBlockProps {
|
interface ContestsBlockProps {
|
||||||
contests: Contest[];
|
contests: Contest[];
|
||||||
title: string;
|
title: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
type: 'upcoming' | 'past';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContestsBlock: FC<ContestsBlockProps> = ({
|
const ContestsBlock: FC<ContestsBlockProps> = ({
|
||||||
contests,
|
contests,
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
|
type,
|
||||||
}) => {
|
}) => {
|
||||||
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
||||||
|
|
||||||
@@ -33,11 +36,11 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
|
|||||||
setActive(!active);
|
setActive(!active);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{title}</span>
|
<span className=" select-none">{title}</span>
|
||||||
<img
|
<img
|
||||||
src={ChevroneDown}
|
src={ChevroneDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
'transition-all duration-300',
|
'transition-all duration-300 select-none',
|
||||||
active && 'rotate-180',
|
active && 'rotate-180',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -50,24 +53,51 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="pb-[10px] pt-[20px]">
|
<div className="pb-[10px] pt-[20px]">
|
||||||
{contests.map((v, i) => (
|
{contests.map((v, i) => {
|
||||||
<ContestItem
|
if (type == 'past') {
|
||||||
key={i}
|
return (
|
||||||
id={v.id}
|
<PastContestItem
|
||||||
name={v.name}
|
key={i}
|
||||||
startAt={v.startsAt ?? new Date().toString()}
|
contestId={v.id}
|
||||||
duration={
|
scheduleType={v.scheduleType}
|
||||||
new Date(
|
name={v.name}
|
||||||
v.endsAt ?? new Date().toString(),
|
startsAt={
|
||||||
).getTime() -
|
v.startsAt ?? new Date().toString()
|
||||||
new Date(
|
}
|
||||||
v.startsAt ?? new Date().toString(),
|
endsAt={
|
||||||
).getTime()
|
v.endsAt ?? new Date().toString()
|
||||||
}
|
}
|
||||||
members={v.members?.length ?? 0}
|
attemptDurationMinutes={
|
||||||
type={i % 2 ? 'second' : 'first'}
|
v.attemptDurationMinutes ?? 0
|
||||||
/>
|
}
|
||||||
))}
|
type={i % 2 ? 'second' : 'first'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == 'upcoming') {
|
||||||
|
return (
|
||||||
|
<UpcoingContestItem
|
||||||
|
key={i}
|
||||||
|
contestId={v.id}
|
||||||
|
scheduleType={v.scheduleType}
|
||||||
|
name={v.name}
|
||||||
|
startsAt={
|
||||||
|
v.startsAt ?? new Date().toString()
|
||||||
|
}
|
||||||
|
endsAt={
|
||||||
|
v.endsAt ?? new Date().toString()
|
||||||
|
}
|
||||||
|
attemptDurationMinutes={
|
||||||
|
v.attemptDurationMinutes ?? 0
|
||||||
|
}
|
||||||
|
type={i % 2 ? 'second' : 'first'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
189
src/views/home/contests/PastContestItem.tsx
Normal file
189
src/views/home/contests/PastContestItem.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
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]">{name}</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;
|
||||||
233
src/views/home/contests/UpcomingContestItem.tsx
Normal file
233
src/views/home/contests/UpcomingContestItem.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { cn } from '../../../lib/cn';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
|
import { useQuery } from '../../../hooks/useQuery';
|
||||||
|
import { toastWarning } from '../../../lib/toastNotification';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ReverseButton } from '../../../components/button/ReverseButton';
|
||||||
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
|
import {
|
||||||
|
addOrUpdateContestMember,
|
||||||
|
fetchParticipatingContests,
|
||||||
|
} from '../../../redux/slices/contests';
|
||||||
|
|
||||||
|
type Role = 'None' | 'Participant' | 'Organizer';
|
||||||
|
|
||||||
|
export interface UpcoingContestItemProps {
|
||||||
|
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} мин`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 UpcoingContestItem: React.FC<UpcoingContestItemProps> = ({
|
||||||
|
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 { contests: myContests } = useAppSelector(
|
||||||
|
(state) => state.contests.fetchMyContests,
|
||||||
|
);
|
||||||
|
const { contests: participantContests } = useAppSelector(
|
||||||
|
(state) => state.contests.fetchParticipating,
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const username = query.get('username') ?? myname ?? '';
|
||||||
|
|
||||||
|
const userId = useAppSelector((state) => state.auth.id);
|
||||||
|
|
||||||
|
const started = new Date(startsAt) <= new Date();
|
||||||
|
const finished = new Date(endsAt) <= new Date();
|
||||||
|
const waitTime = !started
|
||||||
|
? new Date(startsAt).getTime() - new Date().getTime()
|
||||||
|
: new Date(endsAt).getTime() - new Date().getTime();
|
||||||
|
|
||||||
|
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,1fr,220px,130px,130px,140px,150px]'
|
||||||
|
: 'grid-cols-[1fr,1fr,220px,130px,130px,130px]',
|
||||||
|
type == 'first'
|
||||||
|
? ' bg-liquid-lighter'
|
||||||
|
: ' bg-liquid-background',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!started) {
|
||||||
|
toastWarning('Контест еще не начался');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
back: '/home/contests',
|
||||||
|
});
|
||||||
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-left font-bold text-[18px]">{name}</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>
|
||||||
|
|
||||||
|
{!started ? (
|
||||||
|
<div className="text-center whitespace-pre-line ">
|
||||||
|
{'До начала\n' + formatWaitTime(waitTime)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!finished && (
|
||||||
|
<div className="text-center whitespace-pre-line ">
|
||||||
|
{'До конца\n' + formatWaitTime(waitTime)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
|
||||||
|
{new Date() < new Date(startsAt) ? (
|
||||||
|
<>{'Не начался'}</>
|
||||||
|
) : (
|
||||||
|
<>{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 UpcoingContestItem;
|
||||||
@@ -36,7 +36,10 @@ const Group: FC<GroupsBlockProps> = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="home" element={<Posts groupId={groupId} />} />
|
<Route path="home" element={<Posts groupId={groupId} />} />
|
||||||
<Route path="chat" element={<Chat groupId={groupId} />} />
|
<Route path="chat" element={<Chat groupId={groupId} />} />
|
||||||
<Route path="contests" element={<Contests />} />
|
<Route
|
||||||
|
path="contests"
|
||||||
|
element={<Contests groupId={groupId} />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={<Navigate to={`/group/${groupId}/home`} />}
|
element={<Navigate to={`/group/${groupId}/home`} />}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
sendGroupMessage,
|
sendGroupMessage,
|
||||||
setGroupChatStatus,
|
setGroupChatStatus,
|
||||||
} from '../../../../redux/slices/groupChat';
|
} from '../../../../redux/slices/groupChat';
|
||||||
import { SearchInput } from '../../../../components/input/SearchInput';
|
|
||||||
import { MessageItem } from './MessageItem';
|
import { MessageItem } from './MessageItem';
|
||||||
import { Send } from '../../../../assets/icons/input';
|
import { Send } from '../../../../assets/icons/input';
|
||||||
|
|
||||||
@@ -169,15 +168,7 @@ export const Chat: FC<GroupChatProps> = ({ groupId }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full relative">
|
<div className="h-full relative">
|
||||||
<div className="grid grid-rows-[40px,1fr,40px] h-full relative min-h-0 gap-[20px]">
|
<div className="grid grid-rows-[1fr,40px] h-full relative min-h-0 gap-[20px]">
|
||||||
<div className="relative">
|
|
||||||
<SearchInput
|
|
||||||
className="w-[216px]"
|
|
||||||
onChange={() => {}}
|
|
||||||
placeholder="Поиск сообщений"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="min-h-0 overflow-y-scroll thin-dark-scrollbar"
|
className="min-h-0 overflow-y-scroll thin-dark-scrollbar"
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
|
|||||||
@@ -1,17 +1,75 @@
|
|||||||
import { useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { useAppDispatch } from '../../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
||||||
|
import ContestsBlock from './ContestsBlock';
|
||||||
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
|
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
|
||||||
|
import {
|
||||||
|
fetchContests,
|
||||||
|
fetchMyContests,
|
||||||
|
fetchParticipatingContests,
|
||||||
|
} from '../../../../redux/slices/contests';
|
||||||
|
|
||||||
export const Contests = () => {
|
interface ContestsProps {
|
||||||
|
groupId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Contests: FC<ContestsProps> = ({ groupId }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { contests, status } = useAppSelector(
|
||||||
|
(state) => state.contests.fetchContests,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setMenuActiveGroupPage('contests'));
|
dispatch(setMenuActiveGroupPage('contests'));
|
||||||
|
dispatch(fetchContests({ groupId }));
|
||||||
|
dispatch(fetchParticipatingContests({ pageSize: 100 }));
|
||||||
|
dispatch(fetchMyContests());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-scroll thin-dark-scrollbar flex items-center justify-center font-bold text-liquid-white text-[50px]">
|
<div className="h-full relative">
|
||||||
{' '}
|
<div className="grid grid-rows-[1fr] h-full relative min-h-0 gap-[20px]">
|
||||||
Пока пусто :(
|
<div className="min-h-0 overflow-y-scroll thin-dark-scrollbar pr-[20px]">
|
||||||
|
{status == 'loading' && (
|
||||||
|
<div className="text-liquid-white p-4">
|
||||||
|
Загрузка контестов...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status == 'successful' && (
|
||||||
|
<div className="flex flex-col gap-[20px] min-h-0 h-0 px-[16px]">
|
||||||
|
<ContestsBlock
|
||||||
|
groupId={groupId}
|
||||||
|
className="mb-[20px]"
|
||||||
|
title="Текущие"
|
||||||
|
contests={contests
|
||||||
|
.filter(
|
||||||
|
(c) => c.scheduleType != 'AlwaysOpen',
|
||||||
|
)
|
||||||
|
.filter((c) =>
|
||||||
|
c.endsAt
|
||||||
|
? new Date() < new Date(c.endsAt)
|
||||||
|
: false,
|
||||||
|
)}
|
||||||
|
type="upcoming"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContestsBlock
|
||||||
|
groupId={groupId}
|
||||||
|
className="mb-[20px]"
|
||||||
|
title="Прошедшие"
|
||||||
|
contests={contests.filter(
|
||||||
|
(c) =>
|
||||||
|
c.scheduleType == 'AlwaysOpen' ||
|
||||||
|
!(c.endsAt
|
||||||
|
? new Date() < new Date(c.endsAt)
|
||||||
|
: false),
|
||||||
|
)}
|
||||||
|
type="past"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
112
src/views/home/group/contests/ContestsBlock.tsx
Normal file
112
src/views/home/group/contests/ContestsBlock.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState, FC } from 'react';
|
||||||
|
import { cn } from '../../../../lib/cn';
|
||||||
|
import { ChevroneDown } from '../../../../assets/icons/groups';
|
||||||
|
import { Contest } from '../../../../redux/slices/contests';
|
||||||
|
import PastContestItem from './PastContestItem';
|
||||||
|
import UpcoingContestItem from './UpcomingContestItem';
|
||||||
|
|
||||||
|
interface ContestsBlockProps {
|
||||||
|
contests: Contest[];
|
||||||
|
title: string;
|
||||||
|
className?: string;
|
||||||
|
type: 'upcoming' | 'past';
|
||||||
|
groupId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContestsBlock: FC<ContestsBlockProps> = ({
|
||||||
|
contests,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
groupId,
|
||||||
|
}) => {
|
||||||
|
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
' border-b-[1px] border-b-liquid-lighter rounded-[10px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
' h-[40px] text-[24px] font-bold flex gap-[10px] items-center cursor-pointer border-b-[1px] border-b-transparent transition-all duration-300',
|
||||||
|
active && 'border-b-liquid-lighter',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setActive(!active);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className=" select-none">{title}</span>
|
||||||
|
<img
|
||||||
|
src={ChevroneDown}
|
||||||
|
className={cn(
|
||||||
|
'transition-all duration-300 select-none',
|
||||||
|
active && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
|
||||||
|
active && 'grid-rows-[1fr] opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="pb-[10px] pt-[20px]">
|
||||||
|
{contests.map((v, i) => {
|
||||||
|
if (type == 'past') {
|
||||||
|
return (
|
||||||
|
<PastContestItem
|
||||||
|
groupId={groupId}
|
||||||
|
key={i}
|
||||||
|
contestId={v.id}
|
||||||
|
scheduleType={v.scheduleType}
|
||||||
|
name={v.name}
|
||||||
|
startsAt={
|
||||||
|
v.startsAt ?? new Date().toString()
|
||||||
|
}
|
||||||
|
endsAt={
|
||||||
|
v.endsAt ?? new Date().toString()
|
||||||
|
}
|
||||||
|
attemptDurationMinutes={
|
||||||
|
v.attemptDurationMinutes ?? 0
|
||||||
|
}
|
||||||
|
type={i % 2 ? 'second' : 'first'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == 'upcoming') {
|
||||||
|
return (
|
||||||
|
<UpcoingContestItem
|
||||||
|
groupId={groupId}
|
||||||
|
key={i}
|
||||||
|
contestId={v.id}
|
||||||
|
scheduleType={v.scheduleType}
|
||||||
|
name={v.name}
|
||||||
|
startsAt={
|
||||||
|
v.startsAt ?? new Date().toString()
|
||||||
|
}
|
||||||
|
endsAt={
|
||||||
|
v.endsAt ?? new Date().toString()
|
||||||
|
}
|
||||||
|
attemptDurationMinutes={
|
||||||
|
v.attemptDurationMinutes ?? 0
|
||||||
|
}
|
||||||
|
type={i % 2 ? 'second' : 'first'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContestsBlock;
|
||||||
222
src/views/home/group/contests/ModalCreate.tsx
Normal file
222
src/views/home/group/contests/ModalCreate.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { FC, useEffect, useState } from 'react';
|
||||||
|
import { Modal } from '../../../../components/modal/Modal';
|
||||||
|
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
|
||||||
|
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
|
||||||
|
import { Input } from '../../../../components/input/Input';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
||||||
|
import {
|
||||||
|
createContest,
|
||||||
|
setContestStatus,
|
||||||
|
} from '../../../../redux/slices/contests';
|
||||||
|
import { CreateContestBody } from '../../../../redux/slices/contests';
|
||||||
|
import DateRangeInput from '../../../../components/input/DateRangeInput';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
function toUtc(localDateTime?: string): string {
|
||||||
|
if (!localDateTime) return '';
|
||||||
|
|
||||||
|
// Создаём дату (она автоматически считается как локальная)
|
||||||
|
const date = new Date(localDateTime);
|
||||||
|
|
||||||
|
// Возвращаем ISO-строку с 'Z' (всегда в UTC)
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalCreateContestProps {
|
||||||
|
active: boolean;
|
||||||
|
setActive: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalCreateContest: FC<ModalCreateContestProps> = ({
|
||||||
|
active,
|
||||||
|
setActive,
|
||||||
|
}) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const status = useAppSelector(
|
||||||
|
(state) => state.contests.createContest.status,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<CreateContestBody>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
scheduleType: 'AlwaysOpen',
|
||||||
|
visibility: 'Public',
|
||||||
|
startsAt: '',
|
||||||
|
endsAt: '',
|
||||||
|
attemptDurationMinutes: 0,
|
||||||
|
maxAttempts: 0,
|
||||||
|
allowEarlyFinish: false,
|
||||||
|
missionIds: [],
|
||||||
|
articleIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const contest = useAppSelector(
|
||||||
|
(state) => state.contests.createContest.contest,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'successful') {
|
||||||
|
dispatch(
|
||||||
|
setContestStatus({ key: 'createContest', status: 'idle' }),
|
||||||
|
);
|
||||||
|
navigate(
|
||||||
|
`/contest/create?back=/home/account/contests&contestId=${contest.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const handleChange = (key: keyof CreateContestBody, value: any) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
dispatch(
|
||||||
|
createContest({
|
||||||
|
...form,
|
||||||
|
endsAt: toUtc(form.endsAt),
|
||||||
|
startsAt: toUtc(form.startsAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
|
||||||
|
onOpenChange={setActive}
|
||||||
|
open={active}
|
||||||
|
backdrop="blur"
|
||||||
|
>
|
||||||
|
<div className="w-[550px]">
|
||||||
|
<div className="font-bold text-[30px] mb-[10px]">
|
||||||
|
Создать контест
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
label="Название"
|
||||||
|
className="mt-[10px]"
|
||||||
|
placeholder="Введите название"
|
||||||
|
onChange={(v) => handleChange('name', v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
label="Описание"
|
||||||
|
className="mt-[10px]"
|
||||||
|
placeholder="Введите описание"
|
||||||
|
onChange={(v) => handleChange('description', v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">
|
||||||
|
Тип расписания
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
|
||||||
|
value={form.scheduleType}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange(
|
||||||
|
'scheduleType',
|
||||||
|
e.target
|
||||||
|
.value as CreateContestBody['scheduleType'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="AlwaysOpen">Всегда открыт</option>
|
||||||
|
<option value="FixedWindow">
|
||||||
|
Фиксированные даты
|
||||||
|
</option>
|
||||||
|
<option value="RollingWindow">
|
||||||
|
Скользящее окно
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">Видимость</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
|
||||||
|
value={form.visibility}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange(
|
||||||
|
'visibility',
|
||||||
|
e.target
|
||||||
|
.value as CreateContestBody['visibility'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="Public">Публичный</option>
|
||||||
|
<option value="GroupPrivate">Групповой</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Даты начала и конца */}
|
||||||
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
|
<DateRangeInput
|
||||||
|
startValue={form.startsAt || ''}
|
||||||
|
endValue={form.endsAt || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Продолжительность и лимиты */}
|
||||||
|
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
|
||||||
|
<Input
|
||||||
|
name="attemptDurationMinutes"
|
||||||
|
type="number"
|
||||||
|
label="Длительность попытки (мин)"
|
||||||
|
placeholder="Например: 60"
|
||||||
|
onChange={(v) =>
|
||||||
|
handleChange('attemptDurationMinutes', Number(v))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="maxAttempts"
|
||||||
|
type="number"
|
||||||
|
label="Макс. попыток"
|
||||||
|
placeholder="Например: 3"
|
||||||
|
onChange={(v) => handleChange('maxAttempts', Number(v))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Разрешить раннее завершение */}
|
||||||
|
<div className="flex items-center gap-[10px] mt-[15px]">
|
||||||
|
<input
|
||||||
|
id="allowEarlyFinish"
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!form.allowEarlyFinish}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('allowEarlyFinish', e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label htmlFor="allowEarlyFinish">
|
||||||
|
Разрешить раннее завершение
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки */}
|
||||||
|
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
handleSubmit();
|
||||||
|
}}
|
||||||
|
text="Создать"
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
/>
|
||||||
|
<SecondaryButton
|
||||||
|
onClick={() => setActive(false)}
|
||||||
|
text="Отмена"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalCreateContest;
|
||||||
191
src/views/home/group/contests/PastContestItem.tsx
Normal file
191
src/views/home/group/contests/PastContestItem.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
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';
|
||||||
|
groupId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
groupId,
|
||||||
|
}) => {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
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: `/group/${groupId}/contests`,
|
||||||
|
});
|
||||||
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-left font-bold text-[18px]">{name}</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: `/group/${groupId}/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;
|
||||||
228
src/views/home/group/contests/UpcomingContestItem.tsx
Normal file
228
src/views/home/group/contests/UpcomingContestItem.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { cn } from '../../../../lib/cn';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
|
||||||
|
import { useQuery } from '../../../../hooks/useQuery';
|
||||||
|
import { toastWarning } from '../../../../lib/toastNotification';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ReverseButton } from '../../../../components/button/ReverseButton';
|
||||||
|
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
|
||||||
|
import {
|
||||||
|
addOrUpdateContestMember,
|
||||||
|
fetchParticipatingContests,
|
||||||
|
} from '../../../../redux/slices/contests';
|
||||||
|
|
||||||
|
type Role = 'None' | 'Participant' | 'Organizer';
|
||||||
|
|
||||||
|
export interface UpcoingContestItemProps {
|
||||||
|
name: string;
|
||||||
|
contestId: number;
|
||||||
|
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
attemptDurationMinutes: number;
|
||||||
|
type: 'first' | 'second';
|
||||||
|
groupId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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} мин`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 UpcoingContestItem: React.FC<UpcoingContestItemProps> = ({
|
||||||
|
name,
|
||||||
|
contestId,
|
||||||
|
scheduleType,
|
||||||
|
startsAt,
|
||||||
|
endsAt,
|
||||||
|
attemptDurationMinutes,
|
||||||
|
type,
|
||||||
|
groupId,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [role, setRole] = useState<Role>('None');
|
||||||
|
|
||||||
|
const myname = useAppSelector((state) => state.auth.username);
|
||||||
|
|
||||||
|
const { contests: myContests } = useAppSelector(
|
||||||
|
(state) => state.contests.fetchMyContests,
|
||||||
|
);
|
||||||
|
const { contests: participantContests } = useAppSelector(
|
||||||
|
(state) => state.contests.fetchParticipating,
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const username = query.get('username') ?? myname ?? '';
|
||||||
|
|
||||||
|
const userId = useAppSelector((state) => state.auth.id);
|
||||||
|
|
||||||
|
const started = new Date(startsAt) <= new Date();
|
||||||
|
const finished = new Date(endsAt) <= new Date();
|
||||||
|
const waitTime = !started
|
||||||
|
? new Date(startsAt).getTime() - new Date().getTime()
|
||||||
|
: new Date(endsAt).getTime() - new Date().getTime();
|
||||||
|
|
||||||
|
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 items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300 grid grid-flow-col',
|
||||||
|
userId
|
||||||
|
? 'grid-cols-[1fr,1fr,190px,130px,130px,150px]'
|
||||||
|
: 'grid-cols-[1fr,1fr,190px,130px,130px]',
|
||||||
|
|
||||||
|
type == 'first'
|
||||||
|
? ' bg-liquid-lighter'
|
||||||
|
: ' bg-liquid-background',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!started) {
|
||||||
|
toastWarning('Контест еще не начался');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
back: `/group/${groupId}/contests`,
|
||||||
|
});
|
||||||
|
navigate(`/contest/${contestId}?${params}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-left font-bold text-[18px]">{name}</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>
|
||||||
|
|
||||||
|
{!started ? (
|
||||||
|
<div className="text-center whitespace-pre-line ">
|
||||||
|
{'До начала\n' + formatWaitTime(waitTime)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!finished && (
|
||||||
|
<div className="text-center whitespace-pre-line ">
|
||||||
|
{'До конца\n' + formatWaitTime(waitTime)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{userId && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{role == 'Organizer' || role == 'Participant' ? (
|
||||||
|
<ReverseButton
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
back: `/group/${groupId}/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 UpcoingContestItem;
|
||||||
@@ -54,47 +54,55 @@ export const Posts: FC<PostsProps> = ({ groupId }) => {
|
|||||||
const page0 = pages[0];
|
const page0 = pages[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-scroll thin-dark-scrollbar">
|
<div className="h-full relative">
|
||||||
<div className="h-[40px] mb-[20px] relative">
|
<div className="grid grid-rows-[40px,1fr,40px] h-full relative min-h-0 gap-[20px]">
|
||||||
<SearchInput
|
<div className="h-[40px] mb-[20px] relative">
|
||||||
className="w-[216px]"
|
<SearchInput
|
||||||
onChange={(v) => {
|
className="w-[216px]"
|
||||||
v;
|
onChange={(v) => {
|
||||||
}}
|
v;
|
||||||
placeholder="Поиск сообщений"
|
}}
|
||||||
/>
|
placeholder="Поиск сообщений"
|
||||||
{isAdmin && (
|
/>
|
||||||
<div className=" h-[40px] w-[180px] absolute top-0 right-0 flex items-center">
|
{isAdmin && (
|
||||||
<SecondaryButton
|
<div className=" h-[40px] w-[180px] absolute top-0 right-0 flex items-center">
|
||||||
onClick={() => {
|
<SecondaryButton
|
||||||
setModalCreateActive(true);
|
onClick={() => {
|
||||||
}}
|
setModalCreateActive(true);
|
||||||
text="Создать пост"
|
}}
|
||||||
/>
|
text="Создать пост"
|
||||||
</div>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{status === 'loading' && <div>Загрузка...</div>}
|
|
||||||
{status === 'failed' && <div>Ошибка загрузки постов</div>}
|
|
||||||
|
|
||||||
{status == 'successful' &&
|
|
||||||
page0?.items &&
|
|
||||||
page0.items.length > 0 ? (
|
|
||||||
<div className="flex flex-col gap-[20px]">
|
|
||||||
{page0.items.map((post, i) => (
|
|
||||||
<PostItem
|
|
||||||
{...post}
|
|
||||||
key={i}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
setModalUpdateActive={setModalUpdateActive}
|
|
||||||
setUpdatePostId={setUpdatePostId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : status === 'successful' ? (
|
|
||||||
<div>Постов пока нет</div>
|
<>
|
||||||
) : null}
|
{status === 'loading' && <div>Загрузка...</div>}
|
||||||
|
{status === 'failed' && <div>Ошибка загрузки постов</div>}
|
||||||
|
|
||||||
|
{status == 'successful' &&
|
||||||
|
page0?.items &&
|
||||||
|
page0.items.length > 0 ? (
|
||||||
|
<div className="min-h-0 overflow-y-scroll thin-dark-scrollbar">
|
||||||
|
<div className="flex flex-col gap-[20px] min-h-0 h-0 px-[16px]">
|
||||||
|
{page0.items.map((post, i) => (
|
||||||
|
<PostItem
|
||||||
|
{...post}
|
||||||
|
key={i}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
setModalUpdateActive={
|
||||||
|
setModalUpdateActive
|
||||||
|
}
|
||||||
|
setUpdatePostId={setUpdatePostId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : status === 'successful' ? (
|
||||||
|
<div>Постов пока нет</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ModalCreate
|
<ModalCreate
|
||||||
active={modalCreateActive}
|
active={modalCreateActive}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { SearchInput } from '../../../components/input/SearchInput';
|
import { SearchInput } from '../../../components/input/SearchInput';
|
||||||
|
import { useAppDispatch } from '../../../redux/hooks';
|
||||||
|
import { setGroupFilter } from '../../../redux/slices/store';
|
||||||
|
|
||||||
const Filters = () => {
|
const Filters = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
return (
|
return (
|
||||||
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
|
||||||
<SearchInput onChange={() => {}} placeholder="Поиск группы" />
|
<SearchInput
|
||||||
|
onChange={(v: string) => {
|
||||||
|
dispatch(setGroupFilter(v));
|
||||||
|
}}
|
||||||
|
placeholder="Поиск группы"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import {
|
import { Book, UserAdd, Edit } from '../../../assets/icons/groups';
|
||||||
Book,
|
|
||||||
UserAdd,
|
|
||||||
Edit,
|
|
||||||
EyeClosed,
|
|
||||||
EyeOpen,
|
|
||||||
} from '../../../assets/icons/groups';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { GroupInvite, GroupUpdate } from './Groups';
|
import { GroupInvite, GroupUpdate } from './Groups';
|
||||||
|
import { useAppSelector } from '../../../redux/hooks';
|
||||||
|
|
||||||
export interface GroupItemProps {
|
export interface GroupItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
role: 'menager' | 'member' | 'owner' | 'viewer';
|
role: 'menager' | 'member' | 'owner' | 'viewer';
|
||||||
visible: boolean;
|
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
setUpdateActive: (value: any) => void;
|
setUpdateActive: (value: any) => void;
|
||||||
@@ -43,7 +37,6 @@ const IconComponent: React.FC<IconComponentProps> = ({ src, onClick }) => {
|
|||||||
const GroupItem: React.FC<GroupItemProps> = ({
|
const GroupItem: React.FC<GroupItemProps> = ({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
visible,
|
|
||||||
description,
|
description,
|
||||||
setUpdateGroup,
|
setUpdateGroup,
|
||||||
setUpdateActive,
|
setUpdateActive,
|
||||||
@@ -53,6 +46,61 @@ const GroupItem: React.FC<GroupItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const filter = useAppSelector(
|
||||||
|
(state) => state.store.group.groupFilter,
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -66,7 +114,10 @@ const GroupItem: React.FC<GroupItemProps> = ({
|
|||||||
className="bg-liquid-brightmain rounded-[10px]"
|
className="bg-liquid-brightmain rounded-[10px]"
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-flow-row grid-rows-[1fr,24px]">
|
<div className="grid grid-flow-row grid-rows-[1fr,24px]">
|
||||||
<div className="text-[18px] font-bold">{name}</div>
|
<div className="text-[18px] font-bold">
|
||||||
|
{highlightZ(name, filter)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className=" flex gap-[10px]">
|
<div className=" flex gap-[10px]">
|
||||||
{type == 'manage' && (
|
{type == 'manage' && (
|
||||||
<IconComponent
|
<IconComponent
|
||||||
@@ -86,8 +137,6 @@ const GroupItem: React.FC<GroupItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{visible == false && <IconComponent src={EyeOpen} />}
|
|
||||||
{visible == true && <IconComponent src={EyeClosed} />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cn } from '../../../lib/cn';
|
|||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import GroupsBlock from './GroupsBlock';
|
import GroupsBlock from './GroupsBlock';
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
import { fetchMyGroups } from '../../../redux/slices/groups';
|
import { fetchMyGroups, Group } from '../../../redux/slices/groups';
|
||||||
import ModalCreate from './ModalCreate';
|
import ModalCreate from './ModalCreate';
|
||||||
import ModalUpdate from './ModalUpdate';
|
import ModalUpdate from './ModalUpdate';
|
||||||
import Filters from './Filter';
|
import Filters from './Filter';
|
||||||
@@ -45,6 +45,7 @@ const Groups = () => {
|
|||||||
const groupsError = useAppSelector(
|
const groupsError = useAppSelector(
|
||||||
(store) => store.groups.fetchMyGroups.error,
|
(store) => store.groups.fetchMyGroups.error,
|
||||||
);
|
);
|
||||||
|
const filter = useAppSelector((state) => state.store.group.groupFilter);
|
||||||
|
|
||||||
// Берём текущего пользователя
|
// Берём текущего пользователя
|
||||||
const currentUserName = useAppSelector((store) => store.auth.username);
|
const currentUserName = useAppSelector((store) => store.auth.username);
|
||||||
@@ -54,17 +55,21 @@ const Groups = () => {
|
|||||||
dispatch(fetchMyGroups());
|
dispatch(fetchMyGroups());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
// Разделяем группы
|
const applyFilter = (groups: Group[], filter: string) => {
|
||||||
const { managedGroups, currentGroups, hiddenGroups } = useMemo(() => {
|
if (!filter || filter.trim() === '') return groups;
|
||||||
|
const normalized = filter.toLowerCase();
|
||||||
|
|
||||||
|
return groups.filter((g) => g.name.toLowerCase().includes(normalized));
|
||||||
|
};
|
||||||
|
const { managedGroups, currentGroups } = useMemo(() => {
|
||||||
if (!groups || !currentUserName) {
|
if (!groups || !currentUserName) {
|
||||||
return { managedGroups: [], currentGroups: [], hiddenGroups: [] };
|
return { managedGroups: [], currentGroups: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const managed: typeof groups = [];
|
const managed: typeof groups = [];
|
||||||
const current: typeof groups = [];
|
const current: typeof groups = [];
|
||||||
const hidden: typeof groups = []; // пока пустые, без логики
|
|
||||||
|
|
||||||
groups.forEach((group) => {
|
applyFilter(groups, filter).forEach((group) => {
|
||||||
const me = group.members.find(
|
const me = group.members.find(
|
||||||
(m) => m.username === currentUserName,
|
(m) => m.username === currentUserName,
|
||||||
);
|
);
|
||||||
@@ -80,9 +85,8 @@ const Groups = () => {
|
|||||||
return {
|
return {
|
||||||
managedGroups: managed,
|
managedGroups: managed,
|
||||||
currentGroups: current,
|
currentGroups: current,
|
||||||
hiddenGroups: hidden,
|
|
||||||
};
|
};
|
||||||
}, [groups, currentUserName]);
|
}, [groups, currentUserName, filter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20px]">
|
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20px]">
|
||||||
@@ -137,16 +141,6 @@ const Groups = () => {
|
|||||||
setInviteGroup={setInviteGroup}
|
setInviteGroup={setInviteGroup}
|
||||||
type="member"
|
type="member"
|
||||||
/>
|
/>
|
||||||
<GroupsBlock
|
|
||||||
className="mb-[20px]"
|
|
||||||
title="Скрытые"
|
|
||||||
groups={hiddenGroups} // пока пусто
|
|
||||||
setUpdateActive={setModalUpdateActive}
|
|
||||||
setUpdateGroup={setUpdateGroup}
|
|
||||||
setInviteActive={setModalInviteActive}
|
|
||||||
setInviteGroup={setInviteGroup}
|
|
||||||
type="member"
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const GroupsBlock: FC<GroupsBlockProps> = ({
|
|||||||
<GroupItem
|
<GroupItem
|
||||||
key={i}
|
key={i}
|
||||||
id={v.id}
|
id={v.id}
|
||||||
visible={true}
|
|
||||||
description={v.description}
|
description={v.description}
|
||||||
setUpdateActive={setUpdateActive}
|
setUpdateActive={setUpdateActive}
|
||||||
setUpdateGroup={setUpdateGroup}
|
setUpdateGroup={setUpdateGroup}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "src/views/home/account/contests/.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user