add group chat

This commit is contained in:
Виталий Лавшонок
2025-11-23 10:30:31 +03:00
parent abb7301c16
commit 390f1f52c8
28 changed files with 414 additions and 217 deletions

View File

@@ -5,6 +5,7 @@ import upload from './upload.svg';
import chevroneDropDownList from './chevron-drop-down.svg'; import chevroneDropDownList from './chevron-drop-down.svg';
import checkMark from './check-mark.svg'; import checkMark from './check-mark.svg';
import Edit from './edit.svg'; import Edit from './edit.svg';
import Send from './send.svg';
export { export {
Edit, Edit,
@@ -14,4 +15,5 @@ export {
upload, upload,
chevroneDropDownList, chevroneDropDownList,
checkMark, checkMark,
Send,
}; };

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.4045 11.5763L6.32338 11.5763M5.06681 2.74198L20.8828 10.4103C21.8567 10.8826 21.8567 12.2701 20.8828 12.7423L5.06681 20.4107C3.98332 20.936 2.83166 19.8284 3.3143 18.7253L6.21474 12.0957C6.3596 11.7646 6.3596 11.388 6.21474 11.0569L3.3143 4.42737C2.83167 3.32419 3.98332 2.21665 5.06681 2.74198Z" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 472 B

View File

@@ -44,7 +44,6 @@ export const DropDownList: React.FC<DropDownListProps> = ({
setValue(defaultState != undefined ? defaultState : items[0]); setValue(defaultState != undefined ? defaultState : items[0]);
}, [defaultState]); }, [defaultState]);
console.log(defaultState, items);
return ( return (
<div className={cn('relative', className)} ref={ref}> <div className={cn('relative', className)} ref={ref}>
<div <div

View File

@@ -1,4 +1,4 @@
import { FC, useEffect } from 'react'; import { FC } from 'react';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { PrimaryButton } from '../../components/button/PrimaryButton'; import { PrimaryButton } from '../../components/button/PrimaryButton';
import { SecondaryButton } from '../../components/button/SecondaryButton'; import { SecondaryButton } from '../../components/button/SecondaryButton';

View File

@@ -99,7 +99,9 @@ const ContestEditor = () => {
})); }));
setMissionIdInput(''); setMissionIdInput('');
}) })
.catch((err) => {}); .catch((err) => {
err;
});
}; };
const removeMission = (removeId: number) => { const removeMission = (removeId: number) => {

View File

@@ -19,7 +19,8 @@ export interface ChatMessage {
interface FetchMessagesParams { interface FetchMessagesParams {
groupId: number; groupId: number;
limit?: number; limit?: number;
afterMessageId?: number | null; afterMessageId?: number;
timeoutSeconds?: number;
} }
interface SendMessageParams { interface SendMessageParams {
@@ -33,8 +34,7 @@ interface SendMessageParams {
interface GroupChatState { interface GroupChatState {
messages: Record<number, ChatMessage[]>; // по группам messages: Record<number, ChatMessage[]>; // по группам
hasMore: Record<number, boolean>; // есть ли ещё старые сообщения lastMessage: Record<number, number>;
isInitialLoaded: Record<number, boolean>; // загружена ли первая порция
fetchMessages: { fetchMessages: {
status: Status; status: Status;
@@ -48,8 +48,7 @@ interface GroupChatState {
const initialState: GroupChatState = { const initialState: GroupChatState = {
messages: {}, messages: {},
hasMore: {}, lastMessage: {},
isInitialLoaded: {},
fetchMessages: { fetchMessages: {
status: 'idle', status: 'idle',
error: undefined, error: undefined,
@@ -73,13 +72,13 @@ export const fetchGroupMessages = createAsyncThunk(
params: { params: {
limit: params.limit, limit: params.limit,
afterMessageId: params.afterMessageId, afterMessageId: params.afterMessageId,
timeoutSeconds: params.timeoutSeconds,
}, },
}); });
return { return {
groupId: params.groupId, groupId: params.groupId,
messages: response.data as ChatMessage[], messages: response.data as ChatMessage[],
afterMessageId: params.afterMessageId,
}; };
} catch (err: any) { } catch (err: any) {
return rejectWithValue( return rejectWithValue(
@@ -117,8 +116,18 @@ const groupChatSlice = createSlice({
reducers: { reducers: {
clearChat(state, action: PayloadAction<number>) { clearChat(state, action: PayloadAction<number>) {
delete state.messages[action.payload]; delete state.messages[action.payload];
delete state.hasMore[action.payload]; },
delete state.isInitialLoaded[action.payload]; setGroupChatStatus: (
state,
action: PayloadAction<{
key: keyof GroupChatState;
status: Status;
}>,
) => {
const { key, status } = action.payload;
if (state[key]) {
(state[key] as any).status = status;
}
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
@@ -134,28 +143,22 @@ const groupChatSlice = createSlice({
action: PayloadAction<{ action: PayloadAction<{
groupId: number; groupId: number;
messages: ChatMessage[]; messages: ChatMessage[];
afterMessageId?: number | null;
}>, }>,
) => { ) => {
const { groupId, messages, afterMessageId } = action.payload; const { groupId, messages } = action.payload;
const existing = state.messages[groupId] || []; const existing = state.messages[groupId] || [];
// первичная загрузка const ids = new Set(existing.map((m) => m.id));
if (!afterMessageId) { const filtered = messages.filter((m) => !ids.has(m.id));
state.messages[groupId] = messages;
state.isInitialLoaded[groupId] = true; state.messages[groupId] = [...existing, ...filtered].sort(
state.hasMore[groupId] = messages.length > 0; (a, b) => a.id - b.id,
} );
// догружаем старые (scroll up) if (state.messages[groupId].length) {
else if (afterMessageId) { state.lastMessage[groupId] =
const ids = new Set(existing.map((m) => m.id)); state.messages[groupId][
const filtered = messages.filter((m) => !ids.has(m.id)); state.messages[groupId].length - 1
console.log('messages', messages); ].id;
console.log('filtered', filtered);
console.log('ids', ids);
console.log('existing', existing);
state.messages[groupId] = [...existing, ...filtered];
state.hasMore[groupId] = filtered.length > 0;
} }
state.fetchMessages.status = 'successful'; state.fetchMessages.status = 'successful';
@@ -190,5 +193,5 @@ const groupChatSlice = createSlice({
}, },
}); });
export const { clearChat } = groupChatSlice.actions; export const { clearChat, setGroupChatStatus } = groupChatSlice.actions;
export const groupChatReducer = groupChatSlice.reducer; export const groupChatReducer = groupChatSlice.reducer;

View File

@@ -68,14 +68,14 @@ function greet(user: User) {
return \`Привет, \${user.name}! 👋 Роль: \${user.role}\`; return \`Привет, \${user.name}! 👋 Роль: \${user.role}\`;
} }
console.log(greet({ name: "Ты", role: "Разработчик" })); consol.log(greet({ name: "Ты", role: "Разработчик" }));
\`\`\` \`\`\`
Пример **JavaScript**: Пример **JavaScript**:
\`\`\`js \`\`\`js
const sum = (a, b) => a + b; const sum = (a, b) => a + b;
console.log(sum(2, 3)); // 5 consol.log(sum(2, 3)); // 5
\`\`\` \`\`\`
Пример **Python**: Пример **Python**:
@@ -256,9 +256,7 @@ const MarkdownEditor: FC<MarkdownEditorProps> = ({
markdown.slice(cursorPos); markdown.slice(cursorPos);
setMarkdown(newText); setMarkdown(newText);
} catch (err) { } catch (err) {}
console.error('Ошибка загрузки изображения:', err);
}
} }
} }
}; };

View File

@@ -36,13 +36,17 @@ const Filters = () => {
text: 'ID', text: 'ID',
}, },
]} ]}
onChange={(v) => {}} onChange={(v) => {
v;
}}
/> />
<FilterDropDown <FilterDropDown
items={items} items={items}
defaultState={[]} defaultState={[]}
onChange={(values) => {}} onChange={(values) => {
values;
}}
/> />
</div> </div>
); );

View File

@@ -1,124 +1,126 @@
import { FC, useEffect } from "react"; import { FC, useEffect } from 'react';
import MissionItem from "./MissionItem"; import MissionItem from './MissionItem';
import { import {
Contest, Contest,
fetchMySubmissions, fetchMySubmissions,
setContestStatus, setContestStatus,
} from "../../../redux/slices/contests"; } from '../../../redux/slices/contests';
import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; 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';
export interface Article { export interface Article {
id: number; id: number;
name: string; name: string;
tags: string[]; tags: string[];
} }
interface ContestMissionsProps { interface ContestMissionsProps {
contest?: Contest; contest?: Contest;
} }
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => { const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { submissions, status } = useAppSelector( const { submissions, status } = useAppSelector(
(state) => state.contests.fetchMySubmissions (state) => state.contests.fetchMySubmissions,
); );
useEffect(() => { useEffect(() => {
if (contest) dispatch(fetchMySubmissions(contest.id)); if (contest) dispatch(fetchMySubmissions(contest.id));
}, [contest]); }, [contest]);
useEffect(() => { useEffect(() => {
if (status == "successful") { if (status == 'successful') {
dispatch(setContestStatus({ key: "fetchMySubmissions", status: "idle" })); dispatch(
setContestStatus({ key: 'fetchMySubmissions', status: 'idle' }),
);
}
}, [status]);
if (!contest) {
return <></>;
} }
}, [status]);
if (!contest) { const solvedCount = (contest.missions ?? []).filter((mission) =>
return <></>; submissions.some(
} (s) =>
s.solution.missionId === mission.id &&
s.solution.status === 'Accepted: All tests passed',
),
).length;
const solvedCount = (contest.missions ?? []).filter((mission) => const totalCount = contest.missions?.length ?? 0;
submissions.some(
(s) =>
s.solution.missionId === mission.id &&
s.solution.status === "Accepted: All tests passed"
)
).length;
const totalCount = contest.missions?.length ?? 0; return (
<div className=" h-screen grid grid-rows-[74px,40px,1fr] p-[20px] gap-[20px]">
<div className="">
<div className="h-[50px] text-[40px] text-liquid-white font-bold">
{contest.name}
</div>
<div className="flex justify-between h-[24px] items-center gap-[10px]">
<div className="flex items-center">
<img
src={arrowLeft}
className="cursor-pointer"
onClick={() => {
navigate(`/home/contests`);
}}
/>
<span className="text-liquid-light font-bold text-[18px]">
Контест #{contest.id}
</span>
</div>
<div>{contest.attemptDurationMinutes ?? 0} минут</div>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
<PrimaryButton
onClick={() => {
navigate(`/contest/${contest.id}/submissions`);
}}
text="Мои посылки"
/>
</div>
return ( <div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]">
<div className=" h-screen grid grid-rows-[74px,40px,1fr] p-[20px] gap-[20px]"> <div className="w-full">
<div className=""> {(contest.missions ?? []).map((v, i) => {
<div className="h-[50px] text-[40px] text-liquid-white font-bold"> const missionSubmissions = submissions.filter(
{contest.name} (s) => s.solution.missionId === v.id,
);
const hasSuccess = missionSubmissions.some(
(s) =>
s.solution.status ==
'Accepted: All tests passed',
);
const status = hasSuccess
? 'success'
: missionSubmissions.length > 0
? 'error'
: undefined;
return (
<MissionItem
contestId={contest.id}
key={i}
id={v.id}
name={v.name}
timeLimit={v.timeLimitMilliseconds}
memoryLimit={v.memoryLimitBytes}
status={status}
type={i % 2 ? 'second' : 'first'}
/>
);
})}
</div>
</div>
</div> </div>
<div className="flex justify-between h-[24px] items-center gap-[10px]"> );
<div className="flex items-center">
<img
src={arrowLeft}
className="cursor-pointer"
onClick={() => {
navigate(`/home/contests`);
}}
/>
<span className="text-liquid-light font-bold text-[18px]">
Контест #{contest.id}
</span>
</div>
<div>{contest.attemptDurationMinutes ?? 0} минут</div>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
<PrimaryButton
onClick={() => {
navigate(`/contest/${contest.id}/submissions`);
}}
text="Мои посылки"
/>
</div>
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]">
<div className="w-full">
{(contest.missions ?? []).map((v, i) => {
const missionSubmissions = submissions.filter(
(s) => s.solution.missionId === v.id
);
const hasSuccess = missionSubmissions.some(
(s) => s.solution.status == "Accepted: All tests passed"
);
console.log(missionSubmissions);
const status = hasSuccess
? "success"
: missionSubmissions.length > 0
? "error"
: undefined;
return (
<MissionItem
contestId={contest.id}
key={i}
id={v.id}
name={v.name}
timeLimit={v.timeLimitMilliseconds}
memoryLimit={v.memoryLimitBytes}
status={status}
type={i % 2 ? "second" : "first"}
/>
);
})}
</div>
</div>
</div>
);
}; };
export default ContestMissions; export default ContestMissions;

View File

@@ -36,13 +36,17 @@ const Filters = () => {
text: 'ID', text: 'ID',
}, },
]} ]}
onChange={(v) => console.log(v)} onChange={(v) => {
v;
}}
/> />
<FilterDropDown <FilterDropDown
items={items} items={items}
defaultState={[]} defaultState={[]}
onChange={(values) => console.log(values)} onChange={(values) => {
values;
}}
/> />
</div> </div>
); );

View File

@@ -5,7 +5,6 @@ import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { fetchGroupById } from '../../../redux/slices/groups'; import { fetchGroupById } from '../../../redux/slices/groups';
import GroupMenu from './GroupMenu'; import GroupMenu from './GroupMenu';
import { Posts } from './posts/Posts'; import { Posts } from './posts/Posts';
import { SearchInput } from '../../../components/input/SearchInput';
import { Chat } from './chat/Chat'; import { Chat } from './chat/Chat';
import { Contests } from './contests/Contests'; import { Contests } from './contests/Contests';

View File

@@ -1,8 +1,14 @@
import { FC, useEffect, useRef } from 'react'; import { FC, useEffect, useRef, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { setMenuActiveGroupPage } from '../../../../redux/slices/store'; import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
import { fetchGroupMessages } from '../../../../redux/slices/groupChat'; import {
fetchGroupMessages,
sendGroupMessage,
setGroupChatStatus,
} from '../../../../redux/slices/groupChat';
import { SearchInput } from '../../../../components/input/SearchInput'; import { SearchInput } from '../../../../components/input/SearchInput';
import { MessageItem } from './MessageItem';
import { Send } from '../../../../assets/icons/input';
interface GroupChatProps { interface GroupChatProps {
groupId: number; groupId: number;
@@ -13,22 +19,27 @@ const CHUNK_SIZE = 10;
export const Chat: FC<GroupChatProps> = ({ groupId }) => { export const Chat: FC<GroupChatProps> = ({ groupId }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const messages = useAppSelector((s) => s.groupchat.messages[groupId] || []); const messages = useAppSelector((s) => s.groupchat.messages[groupId] || []);
const hasMore = useAppSelector((s) => s.groupchat.hasMore[groupId]); const messagesState = useAppSelector(
const isInitialLoaded = useAppSelector( (state) => state.groupchat.fetchMessages.status,
(s) => s.groupchat.isInitialLoaded[groupId],
); );
const lastMessageId = useAppSelector(
(state) => state.groupchat.lastMessage[groupId] || 0,
);
const user = useAppSelector((state) => state.auth);
const [text, setText] = useState<string>('');
const [firstMessagesFetch, setFirctMessagesFetch] = useState<boolean>(true);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
// добавлено: ref для хранения предыдущей высоты
const prevHeightRef = useRef(0);
// активируем таб // активируем таб
useEffect(() => { useEffect(() => {
dispatch(setMenuActiveGroupPage('chat')); dispatch(setMenuActiveGroupPage('chat'));
}, []); }, []);
useEffect(() => {
console.log(messages);
}, [messages]);
// первичная загрузка // первичная загрузка
useEffect(() => { useEffect(() => {
dispatch( dispatch(
@@ -39,23 +50,112 @@ export const Chat: FC<GroupChatProps> = ({ groupId }) => {
); );
}, [groupId]); }, [groupId]);
// автоскролл вниз после начальной загрузки // автоскролл вниз после начальной загрузки (но не при догрузке)
useEffect(() => { useEffect(() => {
if (!isInitialLoaded || !scrollRef.current) return; const div = scrollRef.current;
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; if (!div) return;
}, [isInitialLoaded, messages.length]);
// если prevHeightRef == 0 — значит это не догрузка, а обычная загрузка
if (prevHeightRef.current === 0) {
div.scrollTop = div.scrollHeight;
}
}, [messages.length]);
// добавлено: компенсирование скролла при догрузке
useEffect(() => {
const div = scrollRef.current;
if (!div) return;
if (prevHeightRef.current > 0) {
const diff = div.scrollHeight - prevHeightRef.current;
div.scrollTop = diff; // компенсируем смещение
prevHeightRef.current = 0; // сбрасываем
}
}, [messages]);
useEffect(() => {
if (messagesState == 'successful') {
dispatch(
setGroupChatStatus({ key: 'fetchMessages', status: 'idle' }),
);
}
if (messagesState == 'failed') {
dispatch(
setGroupChatStatus({ key: 'fetchMessages', status: 'idle' }),
);
}
}, [messagesState]);
const lastMessageIdRef = useRef<number | null>(null);
useEffect(() => {
lastMessageIdRef.current = lastMessageId;
if (firstMessagesFetch) {
setFirctMessagesFetch(false);
dispatch(
fetchGroupMessages({
groupId,
afterMessageId: lastMessageIdRef.current,
timeoutSeconds: 10,
}),
);
}
}, [messages]);
useEffect(() => {
const interval = setInterval(() => {
if (lastMessageIdRef.current === null) return;
dispatch(
fetchGroupMessages({
groupId,
afterMessageId: lastMessageIdRef.current,
timeoutSeconds: 10,
}),
);
}, 10000);
return () => clearInterval(interval);
}, [groupId]);
const handleSend = () => {
if (!text.trim()) return;
dispatch(
sendGroupMessage({
groupId,
content: text.trim(),
}),
).then(() => {
setText('');
setTimeout(() => {
const div = scrollRef.current;
if (div) div.scrollTop = div.scrollHeight;
}, 0);
});
};
const handleEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSend();
}
};
// догрузка старых сообщений при скролле вверх // догрузка старых сообщений при скролле вверх
const handleScroll = () => { const handleScroll = () => {
const div = scrollRef.current; const div = scrollRef.current;
if (!div || !hasMore) return; if (!div) return;
// если скролл в верхней точке
if (div.scrollTop === 0) {
prevHeightRef.current = div.scrollHeight; // запоминаем высоту до загрузки
if (div.scrollTop < 100) {
const first = messages[0]; const first = messages[0];
if (!first) return; if (!first || first.id == 1) return;
const beforeId = first.id - CHUNK_SIZE; const beforeId = first.id - CHUNK_SIZE;
console.log(beforeId);
dispatch( dispatch(
fetchGroupMessages({ fetchGroupMessages({
@@ -69,7 +169,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,50px] h-full relative min-h-0 gap-[20px]"> <div className="grid grid-rows-[40px,1fr,40px] h-full relative min-h-0 gap-[20px]">
<div className="relative"> <div className="relative">
<SearchInput <SearchInput
className="w-[216px]" className="w-[216px]"
@@ -83,25 +183,38 @@ export const Chat: FC<GroupChatProps> = ({ groupId }) => {
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} onScroll={handleScroll}
> >
<div className="flex flex-col gap-[20px] min-h-0 h-0"> <div className="flex flex-col gap-[20px] min-h-0 h-0 px-[16px]">
{messages.map((msg) => ( {messages.map((msg, i) => (
<div <MessageItem
key={msg.id} key={i}
className="bg-gray-800 text-white p-3 rounded-lg" message={msg.content}
> createdAt={msg.createdAt}
<div className="text-sm opacity-60"> id={msg.id}
{msg.authorUsername} {msg.id} groupId={msg.groupId}
</div> authorId={msg.authorId}
<div>{msg.content}</div> authorUsername={msg.authorUsername}
<div className="text-[10px] opacity-40 mt-1"> myMessage={msg.authorId == Number(user.id)}
{new Date(msg.createdAt).toLocaleString()} />
</div>
</div>
))} ))}
</div> </div>
</div> </div>
<div className="bg-red-300">footer / input bar</div> <label className="bg-liquid-lighter rounded-[10px] cursor-text flex items-center px-[16px]">
<input
className="w-[calc(100%-50px)] outline-none bg-transparent placeholder:text-[16px] placeholder:text-liquid-light placeholder:font-medium"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleEnter}
placeholder="Введите сообщение"
/>
<img
src={Send}
className=" absolute cursor-pointer right-[16px] active:scale-90 transition-all duration-300"
onClick={() => {
handleSend();
}}
/>
</label>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,81 @@
import { FC } from 'react';
import { useAppSelector } from '../../../../redux/hooks';
function convertDate(isoString: string) {
const date = new Date(isoString);
const dd = String(date.getUTCDate()).padStart(2, '0');
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const yyyy = date.getUTCFullYear();
const hh = String(date.getUTCHours()).padStart(2, '0');
const min = String(date.getUTCMinutes()).padStart(2, '0');
return `${dd}.${mm}.${yyyy} ${hh}:${min}`;
}
interface MessageItemProps {
id: number;
groupId: number;
authorId: number;
authorUsername: string;
createdAt: string;
message: string;
myMessage: boolean;
}
export const MessageItem: FC<MessageItemProps> = ({
authorId,
authorUsername,
createdAt,
message,
myMessage,
}) => {
const members = useAppSelector(
(state) => state.groups.fetchGroupById.group?.members,
);
const member = members?.find((m) => m.userId === authorId);
return myMessage ? (
<div className="flex flex-col gap-[20px] items-end leading-[20px] text-[16px]">
<div className="w-[50%] flex flex-col gap-[10px]">
<div className="h-[20px] w-full flex gap-[10px] relative justify-end ">
<div className="font-bold text-liquid-light">
{convertDate(createdAt)}
</div>
</div>
<div className="flex justify-end">
<div className="bg-liquid-lighter w-fit max-w-full break-words px-[16px] py-[8px] rounded-[10px] ">
{message}
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-[20px] ">
<div className="w-[50%] flex flex-col gap-[10px]">
<div className="h-[40px] w-full flex gap-[10px] relative ">
<div className="h-[40px] w-[40px] bg-[#D9D9D9] rounded-[10px]"></div>
<div className=" leading-[20px] font-bold text-[16px] ">
<div>{authorUsername} </div>
<div className="text-liquid-light">
{member ? member.role : 'роль не найдена'}
</div>
</div>
<div className=" leading-[20px] font-bold text-[16px] ">
<div className="text-liquid-light">
{convertDate(createdAt)}
</div>
</div>
</div>
<div className="flex">
<div className="bg-liquid-lighter w-fit max-w-full break-words px-[16px] py-[8px] rounded-[10px] ">
{message}
</div>
</div>
</div>
</div>
);
};

View File

@@ -2,9 +2,7 @@ import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../../components/modal/Modal'; import { Modal } from '../../../../components/modal/Modal';
import { PrimaryButton } from '../../../../components/button/PrimaryButton'; import { PrimaryButton } from '../../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../../components/button/SecondaryButton'; import { SecondaryButton } from '../../../../components/button/SecondaryButton';
import { Input } from '../../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { createGroup } from '../../../../redux/slices/groups';
import MarkdownEditor from '../../../articleeditor/Editor'; import MarkdownEditor from '../../../articleeditor/Editor';
import { import {
createPost, createPost,

View File

@@ -2,12 +2,9 @@ import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../../components/modal/Modal'; import { Modal } from '../../../../components/modal/Modal';
import { PrimaryButton } from '../../../../components/button/PrimaryButton'; import { PrimaryButton } from '../../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../../components/button/SecondaryButton'; import { SecondaryButton } from '../../../../components/button/SecondaryButton';
import { Input } from '../../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { createGroup } from '../../../../redux/slices/groups';
import MarkdownEditor, { MarkDownPattern } from '../../../articleeditor/Editor'; import MarkdownEditor, { MarkDownPattern } from '../../../articleeditor/Editor';
import { import {
createPost,
deletePost, deletePost,
fetchPostById, fetchPostById,
setGroupFeedStatus, setGroupFeedStatus,
@@ -55,7 +52,7 @@ const ModalUpdate: FC<ModalUpdateProps> = ({
}, [statusDelete]); }, [statusDelete]);
useEffect(() => { useEffect(() => {
dispatch(fetchPostById({ groupId, postId })); if (postId) dispatch(fetchPostById({ groupId, postId }));
}, [postId]); }, [postId]);
return ( return (

View File

@@ -32,13 +32,10 @@ interface PostItemProps {
export const PostItem: FC<PostItemProps> = ({ export const PostItem: FC<PostItemProps> = ({
id, id,
groupId,
authorId, authorId,
authorUsername, authorUsername,
name,
content, content,
createdAt, createdAt,
updatedAt,
isAdmin, isAdmin,
setModalUpdateActive, setModalUpdateActive,
setUpdatePostId, setUpdatePostId,

View File

@@ -58,7 +58,9 @@ export const Posts: FC<PostsProps> = ({ groupId }) => {
<div className="h-[40px] mb-[20px] relative"> <div className="h-[40px] mb-[20px] relative">
<SearchInput <SearchInput
className="w-[216px]" className="w-[216px]"
onChange={(v) => {}} onChange={(v) => {
v;
}}
placeholder="Поиск сообщений" placeholder="Поиск сообщений"
/> />
{isAdmin && ( {isAdmin && (

View File

@@ -36,13 +36,17 @@ const Filters = () => {
text: 'ID', text: 'ID',
}, },
]} ]}
onChange={(v) => {}} onChange={(v) => {
v;
}}
/> />
<FilterDropDown <FilterDropDown
items={items} items={items}
defaultState={[]} defaultState={[]}
onChange={(values) => {}} onChange={(values) => {
values;
}}
/> />
</div> </div>
); );

View File

@@ -44,7 +44,6 @@ const GroupItem: React.FC<GroupItemProps> = ({
id, id,
name, name,
visible, visible,
role,
description, description,
setUpdateGroup, setUpdateGroup,
setUpdateActive, setUpdateActive,

View File

@@ -4,7 +4,6 @@ import { fetchGroupJoinLink } from '../../../redux/slices/groups';
import { Modal } from '../../../components/modal/Modal'; import { Modal } from '../../../components/modal/Modal';
import { PrimaryButton } from '../../../components/button/PrimaryButton'; import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../components/button/SecondaryButton'; import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Input } from '../../../components/input/Input';
import { toastSuccess } from '../../../lib/toastNotification'; import { toastSuccess } from '../../../lib/toastNotification';
interface ModalInviteProps { interface ModalInviteProps {
@@ -54,9 +53,7 @@ const ModalInvite: FC<ModalInviteProps> = ({
await navigator.clipboard.writeText(inviteLink); await navigator.clipboard.writeText(inviteLink);
toastSuccess('Приглашение скопировано в буфер обмена!'); toastSuccess('Приглашение скопировано в буфер обмена!');
setActive(false); setActive(false);
} catch (err) { } catch (err) {}
console.error('Не удалось скопировать ссылку:', err);
}
}; };
return ( return (

View File

@@ -36,13 +36,17 @@ const Filters = () => {
text: 'ID', text: 'ID',
}, },
]} ]}
onChange={(v) => console.log(v)} onChange={(v) => {
v;
}}
/> />
<FilterDropDown <FilterDropDown
items={items} items={items}
defaultState={[]} defaultState={[]}
onChange={(values) => console.log(values)} onChange={(values) => {
values;
}}
/> />
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import { FC } from 'react'; import { FC, Fragment } from 'react';
export const ArticlesRightPanel: FC = () => { export const ArticlesRightPanel: FC = () => {
const items = [ const items = [
@@ -23,7 +23,7 @@ export const ArticlesRightPanel: FC = () => {
{items.map((v, i) => { {items.map((v, i) => {
return ( return (
<> <Fragment key={i}>
{ {
<div className="font-bold text-liquid-light text-[16px]"> <div className="font-bold text-liquid-light text-[16px]">
{v.name} {v.name}
@@ -32,7 +32,7 @@ export const ArticlesRightPanel: FC = () => {
{i + 1 != items.length && ( {i + 1 != items.length && (
<div className="h-[1px] w-full bg-liquid-lighter"></div> <div className="h-[1px] w-full bg-liquid-lighter"></div>
)} )}
</> </Fragment>
); );
})} })}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { FC } from 'react'; import { FC, Fragment } from 'react';
import { cn } from '../../../lib/cn'; import { cn } from '../../../lib/cn';
export const MissionsRightPanel: FC = () => { export const MissionsRightPanel: FC = () => {
@@ -32,7 +32,7 @@ export const MissionsRightPanel: FC = () => {
{items.map((v, i) => { {items.map((v, i) => {
return ( return (
<> <Fragment key={i}>
{ {
<div className="text-liquid-light text-[16px]"> <div className="text-liquid-light text-[16px]">
<div className="font-bold ">{v.name}</div> <div className="font-bold ">{v.name}</div>
@@ -60,7 +60,7 @@ export const MissionsRightPanel: FC = () => {
{i + 1 != items.length && ( {i + 1 != items.length && (
<div className="h-[1px] w-full bg-liquid-lighter"></div> <div className="h-[1px] w-full bg-liquid-lighter"></div>
)} )}
</> </Fragment>
); );
})} })}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { FC, useEffect, useState } from 'react'; import { FC, Fragment, useEffect, useState } from 'react';
import { Navigate, useParams } from 'react-router-dom'; import { Navigate, useParams } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { fetchGroupById, GroupMember } from '../../../../redux/slices/groups'; import { fetchGroupById, GroupMember } from '../../../../redux/slices/groups';
@@ -54,7 +54,7 @@ export const GroupRightPanel: FC = () => {
{group?.members.map((v, i) => { {group?.members.map((v, i) => {
return ( return (
<> <Fragment key={i}>
{ {
<div className="text-liquid-light text-[16px] grid grid-cols-[40px,1fr] gap-[10px] items-center cursor-pointer hover:bg-liquid-lighter transition-all duration-300 rounded-[10px] p-[5px] group"> <div className="text-liquid-light text-[16px] grid grid-cols-[40px,1fr] gap-[10px] items-center cursor-pointer hover:bg-liquid-lighter transition-all duration-300 rounded-[10px] p-[5px] group">
<div className="h-[40px] w-[40px] rounded-[10px] bg-[#D9D9D9]"></div> <div className="h-[40px] w-[40px] rounded-[10px] bg-[#D9D9D9]"></div>
@@ -101,7 +101,7 @@ export const GroupRightPanel: FC = () => {
{i + 1 != group?.members.length && ( {i + 1 != group?.members.length && (
<div className="h-[1px] w-full bg-liquid-lighter"></div> <div className="h-[1px] w-full bg-liquid-lighter"></div>
)} )}
</> </Fragment>
); );
})} })}

View File

@@ -2,13 +2,10 @@ import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../../components/modal/Modal'; import { Modal } from '../../../../components/modal/Modal';
import { PrimaryButton } from '../../../../components/button/PrimaryButton'; import { PrimaryButton } from '../../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../../components/button/SecondaryButton'; import { SecondaryButton } from '../../../../components/button/SecondaryButton';
import { Input } from '../../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { import {
deleteGroup,
removeGroupMember, removeGroupMember,
setGroupsStatus, setGroupsStatus,
updateGroup,
} from '../../../../redux/slices/groups'; } from '../../../../redux/slices/groups';
import ConfirmModal from '../../../../components/modal/ConfirmModal'; import ConfirmModal from '../../../../components/modal/ConfirmModal';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';

View File

@@ -2,16 +2,13 @@ import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../../components/modal/Modal'; import { Modal } from '../../../../components/modal/Modal';
import { PrimaryButton } from '../../../../components/button/PrimaryButton'; import { PrimaryButton } from '../../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../../components/button/SecondaryButton'; import { SecondaryButton } from '../../../../components/button/SecondaryButton';
import { Input } from '../../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { import {
addGroupMember, addGroupMember,
deleteGroup,
fetchGroupById, fetchGroupById,
GroupMember, GroupMember,
removeGroupMember, removeGroupMember,
setGroupsStatus, setGroupsStatus,
updateGroup,
} from '../../../../redux/slices/groups'; } from '../../../../redux/slices/groups';
import ConfirmModal from '../../../../components/modal/ConfirmModal'; import ConfirmModal from '../../../../components/modal/ConfirmModal';
import { DropDownList } from '../../../../components/drop-down-list/DropDownList'; import { DropDownList } from '../../../../components/drop-down-list/DropDownList';
@@ -31,7 +28,6 @@ const ModalUpdate: FC<ModalUpdateProps> = ({
active, active,
setActive, setActive,
groupId, groupId,
userId,
user, user,
adminUser, adminUser,
groupName, groupName,
@@ -76,7 +72,6 @@ const ModalUpdate: FC<ModalUpdateProps> = ({
}, [statusUpdate]); }, [statusUpdate]);
useEffect(() => { useEffect(() => {
console.log(user);
if (user) { if (user) {
setUserRole( setUserRole(
user?.role.includes('Creator') ? 'Creator' : user?.role, user?.role.includes('Creator') ? 'Creator' : user?.role,

View File

@@ -17,9 +17,7 @@ const CopyableDiv: FC<CopyableDivPropd> = ({ content }) => {
try { try {
await navigator.clipboard.writeText(content); await navigator.clipboard.writeText(content);
alert('Скопировано!'); alert('Скопировано!');
} catch (err) { } catch (err) {}
console.error('Ошибка копирования:', err);
}
}; };
return ( return (

View File

@@ -1 +0,0 @@
{"root":["./src/app.tsx","./src/axios.ts","./src/main.tsx","./src/vite-env.d.ts","./src/assets/icons/account/index.ts","./src/assets/icons/auth/index.ts","./src/assets/icons/filters/index.ts","./src/assets/icons/group/index.ts","./src/assets/icons/groups/index.ts","./src/assets/icons/header/index.ts","./src/assets/icons/input/index.ts","./src/assets/icons/menu/index.ts","./src/assets/icons/missions/index.ts","./src/assets/logos/index.ts","./src/components/button/primarybutton.tsx","./src/components/button/reversebutton.tsx","./src/components/button/secondarybutton.tsx","./src/components/checkbox/checkbox.tsx","./src/components/drop-down-list/dropdownlist.tsx","./src/components/drop-down-list/filter.tsx","./src/components/drop-down-list/sorter.tsx","./src/components/input/daterangeinput.tsx","./src/components/input/input.tsx","./src/components/input/searchinput.tsx","./src/components/modal/confirmmodal.tsx","./src/components/modal/modal.tsx","./src/components/router/protectedroute.tsx","./src/components/switch/switch.tsx","./src/config/colors.ts","./src/hooks/useclickoutside.ts","./src/hooks/usequery.ts","./src/lib/cn.ts","./src/lib/toastnotification.ts","./src/pages/article.tsx","./src/pages/articleeditor.tsx","./src/pages/contesteditor.tsx","./src/pages/home.tsx","./src/pages/mission.tsx","./src/redux/hooks.ts","./src/redux/store.ts","./src/redux/slices/articles.ts","./src/redux/slices/auth.ts","./src/redux/slices/contests.ts","./src/redux/slices/groupfeed.ts","./src/redux/slices/groups.ts","./src/redux/slices/missions.ts","./src/redux/slices/store.ts","./src/redux/slices/submit.ts","./src/views/article/header.tsx","./src/views/articleeditor/editor.tsx","./src/views/articleeditor/header.tsx","./src/views/articleeditor/marckdownpreview.tsx","./src/views/home/account/account.tsx","./src/views/home/account/accoutmenu.tsx","./src/views/home/account/rightpanel.tsx","./src/views/home/account/articles/articlesblock.tsx","./src/views/home/account/contests/contests.tsx","./src/views/home/account/contests/contestsblock.tsx","./src/views/home/account/contests/mycontestitem.tsx","./src/views/home/account/contests/registercontestitem.tsx","./src/views/home/account/missions/missions.tsx","./src/views/home/account/missions/missionsblock.tsx","./src/views/home/account/missions/mymissionitem.tsx","./src/views/home/articles/articleitem.tsx","./src/views/home/articles/articles.tsx","./src/views/home/articles/filter.tsx","./src/views/home/auth/login.tsx","./src/views/home/auth/register.tsx","./src/views/home/contest/contest.tsx","./src/views/home/contest/missionitem.tsx","./src/views/home/contest/missions.tsx","./src/views/home/contest/submissionitem.tsx","./src/views/home/contest/submissions.tsx","./src/views/home/contests/contestitem.tsx","./src/views/home/contests/contests.tsx","./src/views/home/contests/contestsblock.tsx","./src/views/home/contests/filter.tsx","./src/views/home/contests/modalcreate.tsx","./src/views/home/group/group.tsx","./src/views/home/group/groupmenu.tsx","./src/views/home/group/chat/chat.tsx","./src/views/home/group/contests/contests.tsx","./src/views/home/group/posts/modalcreate.tsx","./src/views/home/group/posts/modalupdate.tsx","./src/views/home/group/posts/postitem.tsx","./src/views/home/group/posts/posts.tsx","./src/views/home/groupinviter/groupinvite.tsx","./src/views/home/groups/filter.tsx","./src/views/home/groups/groupitem.tsx","./src/views/home/groups/groups.tsx","./src/views/home/groups/groupsblock.tsx","./src/views/home/groups/modalcreate.tsx","./src/views/home/groups/modalinvite.tsx","./src/views/home/groups/modalupdate.tsx","./src/views/home/menu/menu.tsx","./src/views/home/menu/menuitem.tsx","./src/views/home/missions/filter.tsx","./src/views/home/missions/missionitem.tsx","./src/views/home/missions/missions.tsx","./src/views/home/missions/modalcreate.tsx","./src/views/home/rightpanel/articles.tsx","./src/views/home/rightpanel/missions.tsx","./src/views/home/rightpanel/group/group.tsx","./src/views/home/rightpanel/group/modalleave.tsx","./src/views/home/rightpanel/group/modalupdate.tsx","./src/views/mission/codeeditor/codeeditor.tsx","./src/views/mission/statement/header.tsx","./src/views/mission/statement/latextcontainer.tsx","./src/views/mission/statement/missionsubmissions.tsx","./src/views/mission/statement/statement.tsx","./src/views/mission/statement/submissionitem.tsx","./src/views/mission/submission/submission.tsx"],"errors":true,"version":"5.6.2"}