auth + groups invite
This commit is contained in:
21
src/App.tsx
21
src/App.tsx
@@ -9,22 +9,27 @@ import Mission from './pages/Mission';
|
|||||||
import ArticleEditor from './pages/ArticleEditor';
|
import ArticleEditor from './pages/ArticleEditor';
|
||||||
import Article from './pages/Article';
|
import Article from './pages/Article';
|
||||||
import ContestEditor from './pages/ContestEditor';
|
import ContestEditor from './pages/ContestEditor';
|
||||||
|
import ProtectedRoute from './components/router/ProtectedRoute';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full bg-liquid-background flex justify-center">
|
<div className="w-full h-full bg-liquid-background flex justify-center">
|
||||||
<div className="relative w-full max-w-[1600px] h-full ">
|
<div className="relative w-full max-w-[1600px] h-full ">
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route
|
||||||
|
path="/article/create/*"
|
||||||
|
element={<ArticleEditor />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/contest/create/*"
|
||||||
|
element={<ContestEditor />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="/home/*" element={<Home />} />
|
<Route path="/home/*" element={<Home />} />
|
||||||
<Route path="/mission/:missionId" element={<Mission />} />
|
<Route path="/mission/:missionId" element={<Mission />} />
|
||||||
<Route
|
|
||||||
path="/article/create/*"
|
|
||||||
element={<ArticleEditor />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/contest/create/*"
|
|
||||||
element={<ContestEditor />}
|
|
||||||
/>
|
|
||||||
<Route path="/article/:articleId" element={<Article />} />
|
<Route path="/article/:articleId" element={<Article />} />
|
||||||
<Route path="*" element={<Home />} />
|
<Route path="*" element={<Home />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
// src/routes/ProtectedRoute.tsx
|
// src/routes/ProtectedRoute.tsx
|
||||||
import { Navigate, Outlet } from 'react-router-dom';
|
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||||
import { useAppSelector } from '../../redux/hooks';
|
import { useAppSelector } from '../../redux/hooks';
|
||||||
|
|
||||||
export default function ProtectedRoute() {
|
export default function ProtectedRoute() {
|
||||||
const isAuthenticated = useAppSelector((state) => !!state.auth.jwt);
|
const isAuthenticated = useAppSelector((state) => !!state.auth.jwt);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
console.log('location', location);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/home/login" replace />;
|
return <Navigate to="/home/login" replace state={{ from: location }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// import React from "react";
|
// src/pages/Home.tsx
|
||||||
import { Route, Routes } from 'react-router-dom';
|
import { Route, Routes } from 'react-router-dom';
|
||||||
import Login from '../views/home/auth/Login';
|
import Login from '../views/home/auth/Login';
|
||||||
import Register from '../views/home/auth/Register';
|
import Register from '../views/home/auth/Register';
|
||||||
@@ -18,6 +18,7 @@ import ProtectedRoute from '../components/router/ProtectedRoute';
|
|||||||
import { MissionsRightPanel } from '../views/home/rightpanel/Missions';
|
import { MissionsRightPanel } from '../views/home/rightpanel/Missions';
|
||||||
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
|
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
|
||||||
import { GroupRightPanel } from '../views/home/rightpanel/Group';
|
import { GroupRightPanel } from '../views/home/rightpanel/Group';
|
||||||
|
import GroupInvite from '../views/home/groupinviter/GroupInvite';
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const name = useAppSelector((state) => state.auth.username);
|
const name = useAppSelector((state) => state.auth.username);
|
||||||
@@ -37,14 +38,18 @@ const Home = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route path="account/*" element={<Account />} />
|
<Route path="account/*" element={<Account />} />
|
||||||
|
<Route
|
||||||
|
path="group-invite/*"
|
||||||
|
element={<GroupInvite />}
|
||||||
|
/>
|
||||||
|
<Route path="group/:groupId" element={<Group />} />
|
||||||
|
<Route path="groups/*" element={<Groups />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="login" element={<Login />} />
|
<Route path="login" element={<Login />} />
|
||||||
<Route path="register" element={<Register />} />
|
<Route path="register" element={<Register />} />
|
||||||
<Route path="missions/*" element={<Missions />} />
|
<Route path="missions/*" element={<Missions />} />
|
||||||
<Route path="articles/*" element={<Articles />} />
|
<Route path="articles/*" element={<Articles />} />
|
||||||
<Route path="group/:groupId" element={<Group />} />
|
|
||||||
<Route path="groups/*" element={<Groups />} />
|
|
||||||
<Route path="contests/*" element={<Contests />} />
|
<Route path="contests/*" element={<Contests />} />
|
||||||
<Route path="contest/:contestId/*" element={<Contest />} />
|
<Route path="contest/:contestId/*" element={<Contest />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -121,11 +121,26 @@ export const refreshToken = createAsyncThunk(
|
|||||||
|
|
||||||
export const fetchWhoAmI = createAsyncThunk(
|
export const fetchWhoAmI = createAsyncThunk(
|
||||||
'auth/whoami',
|
'auth/whoami',
|
||||||
async (_, { rejectWithValue }) => {
|
async (_, { dispatch, getState, rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/authentication/whoami');
|
const response = await axios.get('/authentication/whoami');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
const state: any = getState();
|
||||||
|
const refresh = state.auth.refreshToken;
|
||||||
|
|
||||||
|
if (refresh) {
|
||||||
|
// пробуем refresh
|
||||||
|
const result = await dispatch(
|
||||||
|
refreshToken({ refreshToken: refresh }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// если успешный, повторить whoami
|
||||||
|
if (refreshToken.fulfilled.match(result)) {
|
||||||
|
const retry = await axios.get('/authentication/whoami');
|
||||||
|
return retry.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
return rejectWithValue(
|
return rejectWithValue(
|
||||||
err.response?.data?.message || 'Failed to fetch user info',
|
err.response?.data?.message || 'Failed to fetch user info',
|
||||||
);
|
);
|
||||||
@@ -269,6 +284,23 @@ const authSlice = createSlice({
|
|||||||
builder.addCase(fetchWhoAmI.rejected, (state, action) => {
|
builder.addCase(fetchWhoAmI.rejected, (state, action) => {
|
||||||
state.status = 'failed';
|
state.status = 'failed';
|
||||||
state.error = action.payload as string;
|
state.error = action.payload as string;
|
||||||
|
|
||||||
|
// Если пользователь не авторизован (401), делаем logout и пытаемся refresh
|
||||||
|
console.log(action);
|
||||||
|
if (
|
||||||
|
action.payload === 'Unauthorized' ||
|
||||||
|
action.payload === 'Failed to fetch user info'
|
||||||
|
) {
|
||||||
|
// Вызов logout
|
||||||
|
state.jwt = null;
|
||||||
|
state.refreshToken = null;
|
||||||
|
state.username = null;
|
||||||
|
state.email = null;
|
||||||
|
state.id = null;
|
||||||
|
localStorage.removeItem('jwt');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
delete axios.defaults.headers.common['Authorization'];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export interface Submission {
|
|||||||
sourceType: string;
|
sourceType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface Mission {
|
export interface Mission {
|
||||||
id: number;
|
id: number;
|
||||||
authorId: number;
|
authorId: number;
|
||||||
@@ -124,8 +122,6 @@ interface ContestsState {
|
|||||||
status: Status;
|
status: Status;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 Добавляем updateContest и deleteContest
|
|
||||||
updateContest: {
|
updateContest: {
|
||||||
contest: Contest;
|
contest: Contest;
|
||||||
status: Status;
|
status: Status;
|
||||||
@@ -176,7 +172,7 @@ const initialState: ContestsState = {
|
|||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: undefined,
|
error: undefined,
|
||||||
},
|
},
|
||||||
fetchMySubmissions: {
|
fetchMySubmissions: {
|
||||||
submissions: [],
|
submissions: [],
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: undefined,
|
error: undefined,
|
||||||
@@ -262,7 +258,6 @@ export const fetchMySubmissions = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// Все контесты
|
// Все контесты
|
||||||
export const fetchContests = createAsyncThunk(
|
export const fetchContests = createAsyncThunk(
|
||||||
'contests/fetchAll',
|
'contests/fetchAll',
|
||||||
@@ -435,8 +430,6 @@ const contestsSlice = createSlice({
|
|||||||
state.fetchMySubmissions.error = action.payload;
|
state.fetchMySubmissions.error = action.payload;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// fetchContests
|
// fetchContests
|
||||||
builder.addCase(fetchContests.pending, (state) => {
|
builder.addCase(fetchContests.pending, (state) => {
|
||||||
state.fetchContests.status = 'loading';
|
state.fetchContests.status = 'loading';
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import axios from '../../axios';
|
import axios from '../../axios';
|
||||||
|
|
||||||
// ─── Типы ────────────────────────────────────────────
|
// =====================
|
||||||
|
// Типы
|
||||||
|
// =====================
|
||||||
|
|
||||||
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
type Status = 'idle' | 'loading' | 'successful' | 'failed';
|
||||||
|
|
||||||
@@ -19,39 +21,106 @@ export interface Group {
|
|||||||
contests: any[];
|
contests: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Состояние
|
||||||
|
// =====================
|
||||||
|
|
||||||
interface GroupsState {
|
interface GroupsState {
|
||||||
groups: Group[];
|
fetchMyGroups: {
|
||||||
currentGroup: Group | null;
|
groups: Group[];
|
||||||
statuses: {
|
status: Status;
|
||||||
create: Status;
|
error?: string;
|
||||||
update: Status;
|
};
|
||||||
delete: Status;
|
fetchGroupById: {
|
||||||
fetchMy: Status;
|
group?: Group;
|
||||||
fetchById: Status;
|
status: Status;
|
||||||
addMember: Status;
|
error?: string;
|
||||||
removeMember: Status;
|
};
|
||||||
|
createGroup: {
|
||||||
|
group?: Group;
|
||||||
|
status: Status;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
updateGroup: {
|
||||||
|
group?: Group;
|
||||||
|
status: Status;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
deleteGroup: {
|
||||||
|
deletedId?: number;
|
||||||
|
status: Status;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
addGroupMember: {
|
||||||
|
status: Status;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
removeGroupMember: {
|
||||||
|
status: Status;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
fetchGroupJoinLink: {
|
||||||
|
joinLink?: { token: string; expiresAt: string };
|
||||||
|
status: Status;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
joinGroupByToken: {
|
||||||
|
group?: Group;
|
||||||
|
status: Status;
|
||||||
|
error?: string;
|
||||||
};
|
};
|
||||||
error: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: GroupsState = {
|
const initialState: GroupsState = {
|
||||||
groups: [],
|
fetchMyGroups: {
|
||||||
currentGroup: null,
|
groups: [],
|
||||||
statuses: {
|
status: 'idle',
|
||||||
create: 'idle',
|
error: undefined,
|
||||||
update: 'idle',
|
},
|
||||||
delete: 'idle',
|
fetchGroupById: {
|
||||||
fetchMy: 'idle',
|
group: undefined,
|
||||||
fetchById: 'idle',
|
status: 'idle',
|
||||||
addMember: 'idle',
|
error: undefined,
|
||||||
removeMember: 'idle',
|
},
|
||||||
|
createGroup: {
|
||||||
|
group: undefined,
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
updateGroup: {
|
||||||
|
group: undefined,
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
deleteGroup: {
|
||||||
|
deletedId: undefined,
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
addGroupMember: {
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
removeGroupMember: {
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
fetchGroupJoinLink: {
|
||||||
|
joinLink: undefined,
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
joinGroupByToken: {
|
||||||
|
group: undefined,
|
||||||
|
status: 'idle',
|
||||||
|
error: undefined,
|
||||||
},
|
},
|
||||||
error: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Async Thunks ─────────────────────────────────────
|
// =====================
|
||||||
|
// Async Thunks
|
||||||
|
// =====================
|
||||||
|
|
||||||
// POST /groups
|
|
||||||
export const createGroup = createAsyncThunk(
|
export const createGroup = createAsyncThunk(
|
||||||
'groups/createGroup',
|
'groups/createGroup',
|
||||||
async (
|
async (
|
||||||
@@ -69,7 +138,6 @@ export const createGroup = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// PUT /groups/{groupId}
|
|
||||||
export const updateGroup = createAsyncThunk(
|
export const updateGroup = createAsyncThunk(
|
||||||
'groups/updateGroup',
|
'groups/updateGroup',
|
||||||
async (
|
async (
|
||||||
@@ -94,7 +162,6 @@ export const updateGroup = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE /groups/{groupId}
|
|
||||||
export const deleteGroup = createAsyncThunk(
|
export const deleteGroup = createAsyncThunk(
|
||||||
'groups/deleteGroup',
|
'groups/deleteGroup',
|
||||||
async (groupId: number, { rejectWithValue }) => {
|
async (groupId: number, { rejectWithValue }) => {
|
||||||
@@ -109,7 +176,6 @@ export const deleteGroup = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /groups/my
|
|
||||||
export const fetchMyGroups = createAsyncThunk(
|
export const fetchMyGroups = createAsyncThunk(
|
||||||
'groups/fetchMyGroups',
|
'groups/fetchMyGroups',
|
||||||
async (_, { rejectWithValue }) => {
|
async (_, { rejectWithValue }) => {
|
||||||
@@ -124,7 +190,6 @@ export const fetchMyGroups = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /groups/{groupId}
|
|
||||||
export const fetchGroupById = createAsyncThunk(
|
export const fetchGroupById = createAsyncThunk(
|
||||||
'groups/fetchGroupById',
|
'groups/fetchGroupById',
|
||||||
async (groupId: number, { rejectWithValue }) => {
|
async (groupId: number, { rejectWithValue }) => {
|
||||||
@@ -139,16 +204,22 @@ export const fetchGroupById = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /groups/members
|
|
||||||
export const addGroupMember = createAsyncThunk(
|
export const addGroupMember = createAsyncThunk(
|
||||||
'groups/addGroupMember',
|
'groups/addGroupMember',
|
||||||
async (
|
async (
|
||||||
{ userId, role }: { userId: number; role: string },
|
{
|
||||||
|
groupId,
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
}: { groupId: number; userId: number; role: string },
|
||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/groups/members', { userId, role });
|
const response = await axios.post(`/groups/${groupId}/members`, {
|
||||||
return { userId, role };
|
userId,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return rejectWithValue(
|
return rejectWithValue(
|
||||||
err.response?.data?.message ||
|
err.response?.data?.message ||
|
||||||
@@ -158,7 +229,6 @@ export const addGroupMember = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE /groups/{groupId}/members/{memberId}
|
|
||||||
export const removeGroupMember = createAsyncThunk(
|
export const removeGroupMember = createAsyncThunk(
|
||||||
'groups/removeGroupMember',
|
'groups/removeGroupMember',
|
||||||
async (
|
async (
|
||||||
@@ -176,147 +246,169 @@ export const removeGroupMember = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Slice ────────────────────────────────────────────
|
// =====================
|
||||||
|
// Новые Async Thunks
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
// Получение актуальной ссылки для присоединения к группе
|
||||||
|
export const fetchGroupJoinLink = createAsyncThunk(
|
||||||
|
'groups/fetchGroupJoinLink',
|
||||||
|
async (groupId: number, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/groups/${groupId}/join-link`);
|
||||||
|
return response.data as { token: string; expiresAt: string };
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Ошибка при получении ссылки для присоединения',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Присоединение к группе по токену приглашения
|
||||||
|
export const joinGroupByToken = createAsyncThunk(
|
||||||
|
'groups/joinGroupByToken',
|
||||||
|
async (token: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/groups/join/${token}`);
|
||||||
|
return response.data as Group;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Ошибка при присоединении к группе по ссылке',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Slice
|
||||||
|
// =====================
|
||||||
|
|
||||||
const groupsSlice = createSlice({
|
const groupsSlice = createSlice({
|
||||||
name: 'groups',
|
name: 'groups',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
clearCurrentGroup: (state) => {
|
setGroupsStatus: (
|
||||||
state.currentGroup = null;
|
state,
|
||||||
|
action: PayloadAction<{ key: keyof GroupsState; status: Status }>,
|
||||||
|
) => {
|
||||||
|
const { key, status } = action.payload;
|
||||||
|
if (state[key]) {
|
||||||
|
(state[key] as any).status = status;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
// ─── CREATE GROUP ───
|
// fetchMyGroups
|
||||||
builder.addCase(createGroup.pending, (state) => {
|
|
||||||
state.statuses.create = 'loading';
|
|
||||||
state.error = null;
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
createGroup.fulfilled,
|
|
||||||
(state, action: PayloadAction<Group>) => {
|
|
||||||
state.statuses.create = 'successful';
|
|
||||||
state.groups.push(action.payload);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
createGroup.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.create = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── UPDATE GROUP ───
|
|
||||||
builder.addCase(updateGroup.pending, (state) => {
|
|
||||||
state.statuses.update = 'loading';
|
|
||||||
state.error = null;
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
updateGroup.fulfilled,
|
|
||||||
(state, action: PayloadAction<Group>) => {
|
|
||||||
state.statuses.update = 'successful';
|
|
||||||
const index = state.groups.findIndex(
|
|
||||||
(g) => g.id === action.payload.id,
|
|
||||||
);
|
|
||||||
if (index !== -1) state.groups[index] = action.payload;
|
|
||||||
if (state.currentGroup?.id === action.payload.id) {
|
|
||||||
state.currentGroup = action.payload;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
updateGroup.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.update = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── DELETE GROUP ───
|
|
||||||
builder.addCase(deleteGroup.pending, (state) => {
|
|
||||||
state.statuses.delete = 'loading';
|
|
||||||
state.error = null;
|
|
||||||
});
|
|
||||||
builder.addCase(
|
|
||||||
deleteGroup.fulfilled,
|
|
||||||
(state, action: PayloadAction<number>) => {
|
|
||||||
state.statuses.delete = 'successful';
|
|
||||||
state.groups = state.groups.filter(
|
|
||||||
(g) => g.id !== action.payload,
|
|
||||||
);
|
|
||||||
if (state.currentGroup?.id === action.payload)
|
|
||||||
state.currentGroup = null;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
deleteGroup.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.delete = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── FETCH MY GROUPS ───
|
|
||||||
builder.addCase(fetchMyGroups.pending, (state) => {
|
builder.addCase(fetchMyGroups.pending, (state) => {
|
||||||
state.statuses.fetchMy = 'loading';
|
state.fetchMyGroups.status = 'loading';
|
||||||
state.error = null;
|
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchMyGroups.fulfilled,
|
fetchMyGroups.fulfilled,
|
||||||
(state, action: PayloadAction<Group[]>) => {
|
(state, action: PayloadAction<Group[]>) => {
|
||||||
state.statuses.fetchMy = 'successful';
|
state.fetchMyGroups.status = 'successful';
|
||||||
state.groups = action.payload;
|
state.fetchMyGroups.groups = action.payload;
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
fetchMyGroups.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.fetchMy = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
builder.addCase(fetchMyGroups.rejected, (state, action: any) => {
|
||||||
|
state.fetchMyGroups.status = 'failed';
|
||||||
|
state.fetchMyGroups.error = action.payload;
|
||||||
|
});
|
||||||
|
|
||||||
// ─── FETCH GROUP BY ID ───
|
// fetchGroupById
|
||||||
builder.addCase(fetchGroupById.pending, (state) => {
|
builder.addCase(fetchGroupById.pending, (state) => {
|
||||||
state.statuses.fetchById = 'loading';
|
state.fetchGroupById.status = 'loading';
|
||||||
state.error = null;
|
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
fetchGroupById.fulfilled,
|
fetchGroupById.fulfilled,
|
||||||
(state, action: PayloadAction<Group>) => {
|
(state, action: PayloadAction<Group>) => {
|
||||||
state.statuses.fetchById = 'successful';
|
state.fetchGroupById.status = 'successful';
|
||||||
state.currentGroup = action.payload;
|
state.fetchGroupById.group = action.payload;
|
||||||
},
|
|
||||||
);
|
|
||||||
builder.addCase(
|
|
||||||
fetchGroupById.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.fetchById = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
builder.addCase(fetchGroupById.rejected, (state, action: any) => {
|
||||||
|
state.fetchGroupById.status = 'failed';
|
||||||
|
state.fetchGroupById.error = action.payload;
|
||||||
|
});
|
||||||
|
|
||||||
// ─── ADD MEMBER ───
|
// createGroup
|
||||||
|
builder.addCase(createGroup.pending, (state) => {
|
||||||
|
state.createGroup.status = 'loading';
|
||||||
|
});
|
||||||
|
builder.addCase(
|
||||||
|
createGroup.fulfilled,
|
||||||
|
(state, action: PayloadAction<Group>) => {
|
||||||
|
state.createGroup.status = 'successful';
|
||||||
|
state.createGroup.group = action.payload;
|
||||||
|
state.fetchMyGroups.groups.push(action.payload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(createGroup.rejected, (state, action: any) => {
|
||||||
|
state.createGroup.status = 'failed';
|
||||||
|
state.createGroup.error = action.payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
// updateGroup
|
||||||
|
builder.addCase(updateGroup.pending, (state) => {
|
||||||
|
state.updateGroup.status = 'loading';
|
||||||
|
});
|
||||||
|
builder.addCase(
|
||||||
|
updateGroup.fulfilled,
|
||||||
|
(state, action: PayloadAction<Group>) => {
|
||||||
|
state.updateGroup.status = 'successful';
|
||||||
|
state.updateGroup.group = action.payload;
|
||||||
|
const index = state.fetchMyGroups.groups.findIndex(
|
||||||
|
(g) => g.id === action.payload.id,
|
||||||
|
);
|
||||||
|
if (index !== -1)
|
||||||
|
state.fetchMyGroups.groups[index] = action.payload;
|
||||||
|
if (state.fetchGroupById.group?.id === action.payload.id)
|
||||||
|
state.fetchGroupById.group = action.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(updateGroup.rejected, (state, action: any) => {
|
||||||
|
state.updateGroup.status = 'failed';
|
||||||
|
state.updateGroup.error = action.payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
// deleteGroup
|
||||||
|
builder.addCase(deleteGroup.pending, (state) => {
|
||||||
|
state.deleteGroup.status = 'loading';
|
||||||
|
});
|
||||||
|
builder.addCase(
|
||||||
|
deleteGroup.fulfilled,
|
||||||
|
(state, action: PayloadAction<number>) => {
|
||||||
|
state.deleteGroup.status = 'successful';
|
||||||
|
state.deleteGroup.deletedId = action.payload;
|
||||||
|
state.fetchMyGroups.groups = state.fetchMyGroups.groups.filter(
|
||||||
|
(g) => g.id !== action.payload,
|
||||||
|
);
|
||||||
|
if (state.fetchGroupById.group?.id === action.payload)
|
||||||
|
state.fetchGroupById.group = undefined;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(deleteGroup.rejected, (state, action: any) => {
|
||||||
|
state.deleteGroup.status = 'failed';
|
||||||
|
state.deleteGroup.error = action.payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
// addGroupMember
|
||||||
builder.addCase(addGroupMember.pending, (state) => {
|
builder.addCase(addGroupMember.pending, (state) => {
|
||||||
state.statuses.addMember = 'loading';
|
state.addGroupMember.status = 'loading';
|
||||||
state.error = null;
|
|
||||||
});
|
});
|
||||||
builder.addCase(addGroupMember.fulfilled, (state) => {
|
builder.addCase(addGroupMember.fulfilled, (state) => {
|
||||||
state.statuses.addMember = 'successful';
|
state.addGroupMember.status = 'successful';
|
||||||
|
});
|
||||||
|
builder.addCase(addGroupMember.rejected, (state, action: any) => {
|
||||||
|
state.addGroupMember.status = 'failed';
|
||||||
|
state.addGroupMember.error = action.payload;
|
||||||
});
|
});
|
||||||
builder.addCase(
|
|
||||||
addGroupMember.rejected,
|
|
||||||
(state, action: PayloadAction<any>) => {
|
|
||||||
state.statuses.addMember = 'failed';
|
|
||||||
state.error = action.payload;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── REMOVE MEMBER ───
|
// removeGroupMember
|
||||||
builder.addCase(removeGroupMember.pending, (state) => {
|
builder.addCase(removeGroupMember.pending, (state) => {
|
||||||
state.statuses.removeMember = 'loading';
|
state.removeGroupMember.status = 'loading';
|
||||||
state.error = null;
|
|
||||||
});
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
removeGroupMember.fulfilled,
|
removeGroupMember.fulfilled,
|
||||||
@@ -324,27 +416,60 @@ const groupsSlice = createSlice({
|
|||||||
state,
|
state,
|
||||||
action: PayloadAction<{ groupId: number; memberId: number }>,
|
action: PayloadAction<{ groupId: number; memberId: number }>,
|
||||||
) => {
|
) => {
|
||||||
state.statuses.removeMember = 'successful';
|
state.removeGroupMember.status = 'successful';
|
||||||
if (
|
if (
|
||||||
state.currentGroup &&
|
state.fetchGroupById.group &&
|
||||||
state.currentGroup.id === action.payload.groupId
|
state.fetchGroupById.group.id === action.payload.groupId
|
||||||
) {
|
) {
|
||||||
state.currentGroup.members =
|
state.fetchGroupById.group.members =
|
||||||
state.currentGroup.members.filter(
|
state.fetchGroupById.group.members.filter(
|
||||||
(m) => m.userId !== action.payload.memberId,
|
(m) => m.userId !== action.payload.memberId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
builder.addCase(removeGroupMember.rejected, (state, action: any) => {
|
||||||
|
state.removeGroupMember.status = 'failed';
|
||||||
|
state.removeGroupMember.error = action.payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
// fetchGroupJoinLink
|
||||||
|
builder.addCase(fetchGroupJoinLink.pending, (state) => {
|
||||||
|
state.fetchGroupJoinLink.status = 'loading';
|
||||||
|
});
|
||||||
builder.addCase(
|
builder.addCase(
|
||||||
removeGroupMember.rejected,
|
fetchGroupJoinLink.fulfilled,
|
||||||
(state, action: PayloadAction<any>) => {
|
(
|
||||||
state.statuses.removeMember = 'failed';
|
state,
|
||||||
state.error = action.payload;
|
action: PayloadAction<{ token: string; expiresAt: string }>,
|
||||||
|
) => {
|
||||||
|
state.fetchGroupJoinLink.status = 'successful';
|
||||||
|
state.fetchGroupJoinLink.joinLink = action.payload;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
builder.addCase(fetchGroupJoinLink.rejected, (state, action: any) => {
|
||||||
|
state.fetchGroupJoinLink.status = 'failed';
|
||||||
|
state.fetchGroupJoinLink.error = action.payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
// joinGroupByToken
|
||||||
|
builder.addCase(joinGroupByToken.pending, (state) => {
|
||||||
|
state.joinGroupByToken.status = 'loading';
|
||||||
|
});
|
||||||
|
builder.addCase(
|
||||||
|
joinGroupByToken.fulfilled,
|
||||||
|
(state, action: PayloadAction<Group>) => {
|
||||||
|
state.joinGroupByToken.status = 'successful';
|
||||||
|
state.joinGroupByToken.group = action.payload;
|
||||||
|
state.fetchMyGroups.groups.push(action.payload); // добавим новую группу в список
|
||||||
|
},
|
||||||
|
);
|
||||||
|
builder.addCase(joinGroupByToken.rejected, (state, action: any) => {
|
||||||
|
state.joinGroupByToken.status = 'failed';
|
||||||
|
state.joinGroupByToken.error = action.payload;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { clearCurrentGroup } = groupsSlice.actions;
|
export const { setGroupsStatus } = groupsSlice.actions;
|
||||||
export const groupsReducer = groupsSlice.reducer;
|
export const groupsReducer = groupsSlice.reducer;
|
||||||
|
|||||||
@@ -147,9 +147,6 @@ const missionsSlice = createSlice({
|
|||||||
name: 'missions',
|
name: 'missions',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
clearCurrentMission: (state) => {
|
|
||||||
state.currentMission = null;
|
|
||||||
},
|
|
||||||
setMissionsStatus: (
|
setMissionsStatus: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
@@ -251,5 +248,5 @@ const missionsSlice = createSlice({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { clearCurrentMission, setMissionsStatus } = missionsSlice.actions;
|
export const { setMissionsStatus } = missionsSlice.actions;
|
||||||
export const missionsReducer = missionsSlice.reducer;
|
export const missionsReducer = missionsSlice.reducer;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
// src/views/home/auth/Login.tsx
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { Input } from '../../../components/input/Input';
|
import { Input } from '../../../components/input/Input';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { loginUser } from '../../../redux/slices/auth';
|
import { loginUser } from '../../../redux/slices/auth';
|
||||||
// import { cn } from "../../../lib/cn";
|
// import { cn } from "../../../lib/cn";
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
@@ -13,6 +14,7 @@ import { googleLogo } from '../../../assets/icons/input';
|
|||||||
const Login = () => {
|
const Login = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const [username, setUsername] = useState<string>('');
|
const [username, setUsername] = useState<string>('');
|
||||||
const [password, setPassword] = useState<string>('');
|
const [password, setPassword] = useState<string>('');
|
||||||
@@ -30,7 +32,9 @@ const Login = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
navigate('/home/account'); // или другая страница после входа
|
const from = location.state?.from;
|
||||||
|
const path = from ? from.pathname + from.search : '/home/account';
|
||||||
|
navigate(path, { replace: true });
|
||||||
}
|
}
|
||||||
}, [jwt]);
|
}, [jwt]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
// src/views/home/auth/Register.tsx
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
import { Input } from '../../../components/input/Input';
|
import { Input } from '../../../components/input/Input';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { registerUser } from '../../../redux/slices/auth';
|
import { registerUser } from '../../../redux/slices/auth';
|
||||||
// import { cn } from "../../../lib/cn";
|
// import { cn } from "../../../lib/cn";
|
||||||
import { setMenuActivePage } from '../../../redux/slices/store';
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
@@ -15,6 +16,7 @@ import { googleLogo } from '../../../assets/icons/input';
|
|||||||
const Register = () => {
|
const Register = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const [username, setUsername] = useState<string>('');
|
const [username, setUsername] = useState<string>('');
|
||||||
const [email, setEmail] = useState<string>('');
|
const [email, setEmail] = useState<string>('');
|
||||||
@@ -32,7 +34,9 @@ const Register = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
navigate('/home/account');
|
const from = location.state?.from;
|
||||||
|
const path = from ? from.pathname + from.search : '/home/account';
|
||||||
|
navigate(path, { replace: true });
|
||||||
}
|
}
|
||||||
console.log(submitClicked);
|
console.log(submitClicked);
|
||||||
}, [jwt]);
|
}, [jwt]);
|
||||||
|
|||||||
111
src/views/home/groupinviter/GroupInvite.tsx
Normal file
111
src/views/home/groupinviter/GroupInvite.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
|
import { setMenuActivePage } from '../../../redux/slices/store';
|
||||||
|
import { useQuery } from '../../../hooks/useQuery';
|
||||||
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
|
import {
|
||||||
|
joinGroupByToken,
|
||||||
|
setGroupsStatus,
|
||||||
|
} from '../../../redux/slices/groups';
|
||||||
|
|
||||||
|
const GroupInvite = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
const token = query.get('token') ?? undefined;
|
||||||
|
const expiresAt = query.get('expiresAt') ?? undefined;
|
||||||
|
const groupName = query.get('groupName') ?? undefined;
|
||||||
|
const groupId = Number(query.get('groupId') ?? undefined);
|
||||||
|
|
||||||
|
const username = useAppSelector((state) => state.auth.username);
|
||||||
|
const joinStatus = useAppSelector(
|
||||||
|
(state) => state.groups.joinGroupByToken.status,
|
||||||
|
);
|
||||||
|
const joinError = useAppSelector(
|
||||||
|
(state) => state.groups.joinGroupByToken.error,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(setMenuActivePage('groups'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (joinStatus == 'successful') {
|
||||||
|
dispatch(
|
||||||
|
setGroupsStatus({ key: 'joinGroupByToken', status: 'idle' }),
|
||||||
|
);
|
||||||
|
navigate(`/group/${groupId}`);
|
||||||
|
}
|
||||||
|
}, [joinStatus]);
|
||||||
|
|
||||||
|
if (!token || !expiresAt || !groupName || !groupId) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full box-border p-[20px] pt-[20p] flex items-center justify-center text-bold text-[36px]">
|
||||||
|
Приглашение признано недействительным.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpired = new Date(expiresAt) < new Date();
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full box-border p-[20px] pt-[20px] flex items-center justify-center text-bold text-[36px]">
|
||||||
|
Период действия приглашения истек.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJoin = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await dispatch(joinGroupByToken(token)).unwrap();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to join group', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigate('/home/account');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full box-border flex">
|
||||||
|
<div className="p-[25px] text-liquid-white w-full">
|
||||||
|
<div className="font-bold text-[30px] mb-2">
|
||||||
|
Привет, {username}!
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-[25px]">
|
||||||
|
Вы действительно хотите присоединиться к группе:
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-[25px] mb-[20px]">
|
||||||
|
"{groupName}"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{joinError && (
|
||||||
|
<div className="text-red-500 mb-[10px]">
|
||||||
|
Ошибка присоединения: {joinError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-row w-full items-center justify-center mt-[30px] gap-[20px]">
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleJoin}
|
||||||
|
text={
|
||||||
|
joinStatus === 'loading'
|
||||||
|
? 'Присоединяемся...'
|
||||||
|
: 'Присоединиться'
|
||||||
|
}
|
||||||
|
disabled={joinStatus === 'loading'}
|
||||||
|
/>
|
||||||
|
<SecondaryButton onClick={handleCancel} text="Отмена" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupInvite;
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
EyeOpen,
|
EyeOpen,
|
||||||
} from '../../../assets/icons/groups';
|
} from '../../../assets/icons/groups';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { GroupUpdate } from './Groups';
|
import { GroupInvite, GroupUpdate } from './Groups';
|
||||||
|
|
||||||
export interface GroupItemProps {
|
export interface GroupItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -17,6 +17,9 @@ export interface GroupItemProps {
|
|||||||
description: string;
|
description: string;
|
||||||
setUpdateActive: (value: any) => void;
|
setUpdateActive: (value: any) => void;
|
||||||
setUpdateGroup: (value: GroupUpdate) => void;
|
setUpdateGroup: (value: GroupUpdate) => void;
|
||||||
|
setInviteActive: (value: any) => void;
|
||||||
|
setInviteGroup: (value: GroupInvite) => void;
|
||||||
|
type: 'manage' | 'member';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IconComponentProps {
|
interface IconComponentProps {
|
||||||
@@ -45,6 +48,9 @@ const GroupItem: React.FC<GroupItemProps> = ({
|
|||||||
description,
|
description,
|
||||||
setUpdateGroup,
|
setUpdateGroup,
|
||||||
setUpdateActive,
|
setUpdateActive,
|
||||||
|
setInviteActive,
|
||||||
|
setInviteGroup,
|
||||||
|
type,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -63,10 +69,16 @@ const GroupItem: React.FC<GroupItemProps> = ({
|
|||||||
<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">{name}</div>
|
||||||
<div className=" flex gap-[10px]">
|
<div className=" flex gap-[10px]">
|
||||||
{(role == 'menager' || role == 'owner') && (
|
{type == 'manage' && (
|
||||||
<IconComponent src={UserAdd} />
|
<IconComponent
|
||||||
|
src={UserAdd}
|
||||||
|
onClick={() => {
|
||||||
|
setInviteActive(true);
|
||||||
|
setInviteGroup({ id, name });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{(role == 'menager' || role == 'owner') && (
|
{type == 'manage' && (
|
||||||
<IconComponent
|
<IconComponent
|
||||||
src={Edit}
|
src={Edit}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { fetchMyGroups } 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';
|
||||||
|
import ModalInvite from './ModalInvite';
|
||||||
|
|
||||||
export interface GroupUpdate {
|
export interface GroupUpdate {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -15,19 +16,35 @@ export interface GroupUpdate {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupInvite {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
const Groups = () => {
|
const Groups = () => {
|
||||||
const [modalActive, setModalActive] = useState<boolean>(false);
|
const [modalActive, setModalActive] = useState(false);
|
||||||
const [modelUpdateActive, setModalUpdateActive] = useState<boolean>(false);
|
const [modalUpdateActive, setModalUpdateActive] = useState(false);
|
||||||
const [updateGroup, setUpdateGroup] = useState<GroupUpdate>({
|
const [updateGroup, setUpdateGroup] = useState<GroupUpdate>({
|
||||||
id: 0,
|
id: 0,
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
});
|
});
|
||||||
|
const [modalInviteActive, setModalInviteActive] = useState(false);
|
||||||
|
const [inviteGroup, setInviteGroup] = useState<GroupInvite>({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
});
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
// Берём группы из стора
|
// ✅ Берём группы и статус из нового слайса
|
||||||
const groups = useAppSelector((store) => store.groups.groups);
|
const groups = useAppSelector((store) => store.groups.fetchMyGroups.groups);
|
||||||
|
const groupsStatus = useAppSelector(
|
||||||
|
(store) => store.groups.fetchMyGroups.status,
|
||||||
|
);
|
||||||
|
const groupsError = useAppSelector(
|
||||||
|
(store) => store.groups.fetchMyGroups.error,
|
||||||
|
);
|
||||||
|
|
||||||
// Берём текущего пользователя
|
// Берём текущего пользователя
|
||||||
const currentUserName = useAppSelector((store) => store.auth.username);
|
const currentUserName = useAppSelector((store) => store.auth.username);
|
||||||
@@ -52,8 +69,8 @@ const Groups = () => {
|
|||||||
(m) => m.username === currentUserName,
|
(m) => m.username === currentUserName,
|
||||||
);
|
);
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
|
const roles = me.role.split(',').map((r) => r.trim());
|
||||||
if (me.role === 'Administrator') {
|
if (roles.includes('Administrator')) {
|
||||||
managed.push(group);
|
managed.push(group);
|
||||||
} else {
|
} else {
|
||||||
current.push(group);
|
current.push(group);
|
||||||
@@ -68,7 +85,7 @@ const Groups = () => {
|
|||||||
}, [groups, currentUserName]);
|
}, [groups, currentUserName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]">
|
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20px]">
|
||||||
<div className="h-full box-border">
|
<div className="h-full box-border">
|
||||||
<div className="relative flex items-center mb-[20px]">
|
<div className="relative flex items-center mb-[20px]">
|
||||||
<div
|
<div
|
||||||
@@ -79,9 +96,7 @@ const Groups = () => {
|
|||||||
Группы
|
Группы
|
||||||
</div>
|
</div>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
onClick={() => {
|
onClick={() => setModalActive(true)}
|
||||||
setModalActive(true);
|
|
||||||
}}
|
|
||||||
text="Создать группу"
|
text="Создать группу"
|
||||||
className="absolute right-0"
|
className="absolute right-0"
|
||||||
/>
|
/>
|
||||||
@@ -89,37 +104,67 @@ const Groups = () => {
|
|||||||
|
|
||||||
<Filters />
|
<Filters />
|
||||||
|
|
||||||
<GroupsBlock
|
{groupsStatus === 'loading' && (
|
||||||
className="mb-[20px]"
|
<div className="text-liquid-white mt-4">
|
||||||
title="Управляемые"
|
Загрузка групп...
|
||||||
groups={managedGroups}
|
</div>
|
||||||
setUpdateActive={setModalUpdateActive}
|
)}
|
||||||
setUpdateGroup={setUpdateGroup}
|
{groupsStatus === 'failed' && (
|
||||||
/>
|
<div className="text-red-400 mt-4">
|
||||||
<GroupsBlock
|
Ошибка: {groupsError || 'Не удалось загрузить группы'}
|
||||||
className="mb-[20px]"
|
</div>
|
||||||
title="Текущие"
|
)}
|
||||||
groups={currentGroups}
|
|
||||||
setUpdateActive={setModalUpdateActive}
|
{groupsStatus === 'successful' && (
|
||||||
setUpdateGroup={setUpdateGroup}
|
<>
|
||||||
/>
|
<GroupsBlock
|
||||||
<GroupsBlock
|
className="mb-[20px]"
|
||||||
className="mb-[20px]"
|
title="Управляемые"
|
||||||
title="Скрытые"
|
groups={managedGroups}
|
||||||
groups={hiddenGroups} // пока пусто
|
setUpdateActive={setModalUpdateActive}
|
||||||
setUpdateActive={setModalUpdateActive}
|
setUpdateGroup={setUpdateGroup}
|
||||||
setUpdateGroup={setUpdateGroup}
|
setInviteActive={setModalInviteActive}
|
||||||
/>
|
setInviteGroup={setInviteGroup}
|
||||||
|
type="manage"
|
||||||
|
/>
|
||||||
|
<GroupsBlock
|
||||||
|
className="mb-[20px]"
|
||||||
|
title="Текущие"
|
||||||
|
groups={currentGroups}
|
||||||
|
setUpdateActive={setModalUpdateActive}
|
||||||
|
setUpdateGroup={setUpdateGroup}
|
||||||
|
setInviteActive={setModalInviteActive}
|
||||||
|
setInviteGroup={setInviteGroup}
|
||||||
|
type="member"
|
||||||
|
/>
|
||||||
|
<GroupsBlock
|
||||||
|
className="mb-[20px]"
|
||||||
|
title="Скрытые"
|
||||||
|
groups={hiddenGroups} // пока пусто
|
||||||
|
setUpdateActive={setModalUpdateActive}
|
||||||
|
setUpdateGroup={setUpdateGroup}
|
||||||
|
setInviteActive={setModalInviteActive}
|
||||||
|
setInviteGroup={setInviteGroup}
|
||||||
|
type="member"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalCreate setActive={setModalActive} active={modalActive} />
|
<ModalCreate setActive={setModalActive} active={modalActive} />
|
||||||
<ModalUpdate
|
<ModalUpdate
|
||||||
setActive={setModalUpdateActive}
|
setActive={setModalUpdateActive}
|
||||||
active={modelUpdateActive}
|
active={modalUpdateActive}
|
||||||
groupId={updateGroup.id}
|
groupId={updateGroup.id}
|
||||||
groupName={updateGroup.name}
|
groupName={updateGroup.name}
|
||||||
groupDescription={updateGroup.description}
|
groupDescription={updateGroup.description}
|
||||||
/>
|
/>
|
||||||
|
<ModalInvite
|
||||||
|
setActive={setModalInviteActive}
|
||||||
|
active={modalInviteActive}
|
||||||
|
groupId={inviteGroup.id}
|
||||||
|
groupName={inviteGroup.name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import GroupItem from './GroupItem';
|
|||||||
import { cn } from '../../../lib/cn';
|
import { cn } from '../../../lib/cn';
|
||||||
import { ChevroneDown } from '../../../assets/icons/groups';
|
import { ChevroneDown } from '../../../assets/icons/groups';
|
||||||
import { Group } from '../../../redux/slices/groups';
|
import { Group } from '../../../redux/slices/groups';
|
||||||
import { GroupUpdate } from './Groups';
|
import { GroupInvite, GroupUpdate } from './Groups';
|
||||||
|
|
||||||
interface GroupsBlockProps {
|
interface GroupsBlockProps {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
@@ -11,6 +11,9 @@ interface GroupsBlockProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
setUpdateActive: (value: any) => void;
|
setUpdateActive: (value: any) => void;
|
||||||
setUpdateGroup: (value: GroupUpdate) => void;
|
setUpdateGroup: (value: GroupUpdate) => void;
|
||||||
|
setInviteActive: (value: any) => void;
|
||||||
|
setInviteGroup: (value: GroupInvite) => void;
|
||||||
|
type: 'manage' | 'member';
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupsBlock: FC<GroupsBlockProps> = ({
|
const GroupsBlock: FC<GroupsBlockProps> = ({
|
||||||
@@ -19,6 +22,9 @@ const GroupsBlock: FC<GroupsBlockProps> = ({
|
|||||||
className,
|
className,
|
||||||
setUpdateActive,
|
setUpdateActive,
|
||||||
setUpdateGroup,
|
setUpdateGroup,
|
||||||
|
setInviteActive,
|
||||||
|
setInviteGroup,
|
||||||
|
type,
|
||||||
}) => {
|
}) => {
|
||||||
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
||||||
|
|
||||||
@@ -63,8 +69,11 @@ const GroupsBlock: FC<GroupsBlockProps> = ({
|
|||||||
description={v.description}
|
description={v.description}
|
||||||
setUpdateActive={setUpdateActive}
|
setUpdateActive={setUpdateActive}
|
||||||
setUpdateGroup={setUpdateGroup}
|
setUpdateGroup={setUpdateGroup}
|
||||||
|
setInviteActive={setInviteActive}
|
||||||
|
setInviteGroup={setInviteGroup}
|
||||||
role={'owner'}
|
role={'owner'}
|
||||||
name={v.name}
|
name={v.name}
|
||||||
|
type={type}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface ModalCreateProps {
|
|||||||
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
|
||||||
const [name, setName] = useState<string>('');
|
const [name, setName] = useState<string>('');
|
||||||
const [description, setDescription] = useState<string>('');
|
const [description, setDescription] = useState<string>('');
|
||||||
const status = useAppSelector((state) => state.groups.statuses.create);
|
const status = useAppSelector((state) => state.groups.createGroup.status);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
102
src/views/home/groups/ModalInvite.tsx
Normal file
102
src/views/home/groups/ModalInvite.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { FC, useEffect, useMemo } from 'react';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
|
||||||
|
import { fetchGroupJoinLink } from '../../../redux/slices/groups';
|
||||||
|
import { Modal } from '../../../components/modal/Modal';
|
||||||
|
import { PrimaryButton } from '../../../components/button/PrimaryButton';
|
||||||
|
import { SecondaryButton } from '../../../components/button/SecondaryButton';
|
||||||
|
import { Input } from '../../../components/input/Input';
|
||||||
|
|
||||||
|
interface ModalInviteProps {
|
||||||
|
active: boolean;
|
||||||
|
setActive: (value: boolean) => void;
|
||||||
|
groupId: number;
|
||||||
|
groupName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalInvite: FC<ModalInviteProps> = ({
|
||||||
|
active,
|
||||||
|
setActive,
|
||||||
|
groupId,
|
||||||
|
groupName,
|
||||||
|
}) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
|
||||||
|
// Получаем токен и дату из Redux
|
||||||
|
const { joinLink, status } = useAppSelector(
|
||||||
|
(state) => state.groups.fetchGroupJoinLink,
|
||||||
|
);
|
||||||
|
|
||||||
|
// При открытии модалки запрашиваем join link
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) {
|
||||||
|
dispatch(fetchGroupJoinLink(groupId));
|
||||||
|
}
|
||||||
|
}, [active, groupId, dispatch]);
|
||||||
|
|
||||||
|
// Генерация полной ссылки с query параметрами
|
||||||
|
const inviteLink = useMemo(() => {
|
||||||
|
if (!joinLink) return '';
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
token: joinLink.token,
|
||||||
|
expiresAt: joinLink.expiresAt,
|
||||||
|
groupName,
|
||||||
|
groupId: `${groupId}`,
|
||||||
|
});
|
||||||
|
return `${baseUrl}/home/group-invite?${params.toString()}`;
|
||||||
|
}, [joinLink, groupName, baseUrl, groupId]);
|
||||||
|
|
||||||
|
// Копирование и закрытие модалки
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!inviteLink) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteLink);
|
||||||
|
setActive(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Не удалось скопировать ссылку:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-[500px]">
|
||||||
|
<div className="font-bold text-[30px] mb-[20px]">
|
||||||
|
Приглашение в группу "{groupName}"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="">
|
||||||
|
<div className="font-bold text-[18px] mb-[5px]">
|
||||||
|
Ссылка для приглашения
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className=" break-all break-words text-[#5d96ff] hover:underline cursor-pointer"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{inviteLink}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row w-full items-center justify-end mt-[30px] gap-[20px]">
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleCopy}
|
||||||
|
text={
|
||||||
|
status === 'loading' ? 'Загрузка...' : 'Скопировать'
|
||||||
|
}
|
||||||
|
disabled={status === 'loading' || !inviteLink}
|
||||||
|
/>
|
||||||
|
<SecondaryButton
|
||||||
|
onClick={() => setActive(false)}
|
||||||
|
text="Отмена"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalInvite;
|
||||||
@@ -24,10 +24,10 @@ const ModalUpdate: FC<ModalUpdateProps> = ({
|
|||||||
const [name, setName] = useState<string>('');
|
const [name, setName] = useState<string>('');
|
||||||
const [description, setDescription] = useState<string>('');
|
const [description, setDescription] = useState<string>('');
|
||||||
const statusUpdate = useAppSelector(
|
const statusUpdate = useAppSelector(
|
||||||
(state) => state.groups.statuses.update,
|
(state) => state.groups.updateGroup.status,
|
||||||
);
|
);
|
||||||
const statusDelete = useAppSelector(
|
const statusDelete = useAppSelector(
|
||||||
(state) => state.groups.statuses.delete,
|
(state) => state.groups.deleteGroup.status,
|
||||||
);
|
);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user