396 lines
10 KiB
TypeScript
396 lines
10 KiB
TypeScript
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 = 100,
|
|
authoredPage = 0,
|
|
authoredPageSize = 100,
|
|
}: {
|
|
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 = 100,
|
|
}: { 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 = 100,
|
|
pastPage = 0,
|
|
pastPageSize = 100,
|
|
minePage = 0,
|
|
minePageSize = 100,
|
|
}: {
|
|
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;
|