group posts

This commit is contained in:
Виталий Лавшонок
2025-11-16 19:46:32 +03:00
parent b949837e13
commit 9cbfd88a23
9 changed files with 364 additions and 60 deletions

View File

@@ -27,7 +27,7 @@ const DateRangeInput: React.FC<DateRangeInputProps> = ({
type="datetime-local" type="datetime-local"
value={startValue} value={startValue}
onChange={(e) => onChange('startsAt', e.target.value)} onChange={(e) => onChange('startsAt', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" className="mt-1 block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/> />
</div> </div>
<div> <div>
@@ -38,7 +38,7 @@ const DateRangeInput: React.FC<DateRangeInputProps> = ({
type="datetime-local" type="datetime-local"
value={endValue} value={endValue}
onChange={(e) => onChange('endsAt', e.target.value)} onChange={(e) => onChange('endsAt', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" className="mt-1 block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/> />
</div> </div>
</div> </div>

View File

@@ -215,7 +215,17 @@ export const deletePost = createAsyncThunk(
const postsSlice = createSlice({ const postsSlice = createSlice({
name: 'posts', name: 'posts',
initialState, initialState,
reducers: {}, reducers: {
setGroupFeedStatus: (
state,
action: PayloadAction<{ key: keyof PostsState; status: Status }>,
) => {
const { key, status } = action.payload;
if (state[key]) {
(state[key] as any).status = status;
}
},
},
extraReducers: (builder) => { extraReducers: (builder) => {
// fetchGroupPosts // fetchGroupPosts
builder.addCase(fetchGroupPosts.pending, (state) => { builder.addCase(fetchGroupPosts.pending, (state) => {
@@ -333,4 +343,5 @@ const postsSlice = createSlice({
}, },
}); });
export const { setGroupFeedStatus } = postsSlice.actions;
export const groupFeedReducer = postsSlice.reducer; export const groupFeedReducer = postsSlice.reducer;

View File

@@ -4,18 +4,7 @@ import 'highlight.js/styles/github-dark.css';
import MarkdownPreview from './MarckDownPreview'; import MarkdownPreview from './MarckDownPreview';
interface MarkdownEditorProps { export const MarkDownPattern = `# 🌙 Добро пожаловать в Markdown-редактор
defaultValue?: string;
onChange: (value: string) => void;
}
const MarkdownEditor: FC<MarkdownEditorProps> = ({
defaultValue,
onChange,
}) => {
const [markdown, setMarkdown] = useState<string>(
defaultValue ||
`# 🌙 Добро пожаловать в Markdown-редактор
Добро пожаловать в **Markdown-редактор**! Добро пожаловать в **Markdown-редактор**!
Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇 Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇
@@ -209,13 +198,29 @@ print(greet("Мир"))
**🖤 Конец демонстрации. Спасибо, что используешь Markdown-редактор!** **🖤 Конец демонстрации. Спасибо, что используешь Markdown-редактор!**
`, `;
interface MarkdownEditorProps {
defaultValue?: string;
onChange: (value: string) => void;
}
const MarkdownEditor: FC<MarkdownEditorProps> = ({
defaultValue,
onChange,
}) => {
const [markdown, setMarkdown] = useState<string>(
defaultValue || MarkDownPattern,
); );
useEffect(() => { useEffect(() => {
onChange(markdown); onChange(markdown);
}, [markdown]); }, [markdown]);
useEffect(() => {
setMarkdown(defaultValue || MarkDownPattern);
}, [defaultValue]);
// Обработчик вставки // Обработчик вставки
const handlePaste = async ( const handlePaste = async (
e: React.ClipboardEvent<HTMLTextAreaElement>, e: React.ClipboardEvent<HTMLTextAreaElement>,

View File

@@ -30,12 +30,7 @@ const MarkdownPreview: FC<MarkdownPreviewProps> = ({
className = '', className = '',
}) => { }) => {
return ( return (
<div <div className={cn('flex-1 bg-[#161b22] rounded-lg p-6', className)}>
className={cn(
'flex-1 bg-[#161b22] rounded-lg shadow-lg p-6',
className,
)}
>
<div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar"> <div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}

View File

@@ -0,0 +1,72 @@
import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../../components/modal/Modal';
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
import { Input } from '../../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { createGroup } from '../../../../redux/slices/groups';
import MarkdownEditor from '../../../articleeditor/Editor';
import {
createPost,
setGroupFeedStatus,
} from '../../../../redux/slices/groupfeed';
interface ModalCreateProps {
groupId: number;
active: boolean;
setActive: (value: boolean) => void;
}
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive, groupId }) => {
// const [name, setName] = useState<string>('');
const [content, setContent] = useState<string>('');
const status = useAppSelector((state) => state.groupfeed.createPost.status);
const dispatch = useAppDispatch();
useEffect(() => {
if (status == 'successful') {
setActive(false);
dispatch(setGroupFeedStatus({ key: 'createPost', status: 'idle' }));
}
}, [status]);
return (
<Modal
className="bg-liquid-background h-[calc(100vh-30%)] border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white overflow-hidden"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="max-w-[1400px] h-full overflow-hidden">
<div className="font-bold text-[30px]">Создать пост</div>
<div className="h-[calc(100%-45px-60px)]">
<MarkdownEditor
onChange={(v) => {
setContent(v);
}}
/>
</div>
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={() => {
dispatch(
createPost({ name: '', content, groupId }),
);
}}
text={status == 'idle' ? 'Опубликовать' : 'Загрузка...'}
disabled={status == 'loading'}
/>
<SecondaryButton
onClick={() => {
setActive(false);
}}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ModalCreate;

View File

@@ -0,0 +1,140 @@
import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../../components/modal/Modal';
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
import { Input } from '../../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { createGroup } from '../../../../redux/slices/groups';
import MarkdownEditor, { MarkDownPattern } from '../../../articleeditor/Editor';
import {
createPost,
deletePost,
fetchPostById,
setGroupFeedStatus,
updatePost,
} from '../../../../redux/slices/groupfeed';
import { ReverseButton } from '../../../../components/button/ReverseButton';
import { cn } from '../../../../lib/cn';
interface ModalUpdateProps {
groupId: number;
postId: number;
active: boolean;
setActive: (value: boolean) => void;
}
const ModalUpdate: FC<ModalUpdateProps> = ({
active,
setActive,
groupId,
postId,
}) => {
// const [name, setName] = useState<string>('');
const [content, setContent] = useState<string>('');
const status = useAppSelector((state) => state.groupfeed.updatePost.status);
const statusDelete = useAppSelector(
(state) => state.groupfeed.deletePost.status,
);
const { post, status: statusPost } = useAppSelector(
(state) => state.groupfeed.fetchPostById,
);
const dispatch = useAppDispatch();
useEffect(() => {
if (status == 'successful') {
setActive(false);
dispatch(setGroupFeedStatus({ key: 'updatePost', status: 'idle' }));
}
}, [status]);
useEffect(() => {
if (statusDelete == 'successful') {
setActive(false);
dispatch(setGroupFeedStatus({ key: 'deletePost', status: 'idle' }));
}
}, [statusDelete]);
useEffect(() => {
dispatch(fetchPostById({ groupId, postId }));
}, [postId]);
return (
<Modal
className="bg-liquid-background h-[calc(100vh-30%)] border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white overflow-hidden"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="max-w-[1400px] h-full overflow-hidden transition-all duratoin-300">
<div className="font-bold text-[30px]">
Обновить пост #{post?.id}
</div>
<div
className={cn(
' absolute z-10 h-[calc(100%-100px)] w-[calc(100%-50px)] flex items-center justify-center text-transparent transition-all pointer-events-none ',
statusPost == 'loading' && 'text-liquid-white',
)}
>
<div>Загрузка...</div>
</div>
<div
className={cn(
'h-[calc(100%-45px-60px)] opacity-50 pointer-events-none transition-all ',
statusPost == 'successful' &&
'text-liquid-white pointer-events-auto opacity-100',
)}
>
<MarkdownEditor
defaultValue={
statusPost == 'successful'
? post?.content
: MarkDownPattern
}
onChange={(v) => {
setContent(v);
}}
/>
</div>
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={() => {
dispatch(
updatePost({
name: '',
content,
groupId,
postId,
}),
);
}}
text={status == 'idle' ? 'Сохранить' : 'Загрузка...'}
disabled={
status == 'loading' || statusPost != 'successful'
}
/>
<ReverseButton
onClick={() => {
dispatch(deletePost({ groupId, postId }));
}}
color="error"
text={
statusDelete == 'idle' ? 'Удалить' : 'Загрузка...'
}
disabled={
statusDelete == 'loading' ||
statusPost != 'successful'
}
/>
<SecondaryButton
onClick={() => {
setActive(false);
}}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ModalUpdate;

View File

@@ -0,0 +1,86 @@
import { FC } from 'react';
import { useAppSelector } from '../../../../redux/hooks';
import MarkdownPreview from '../../../articleeditor/MarckDownPreview';
import { Edit } from '../../../../assets/icons/input';
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 PostItemProps {
id: number;
groupId: number;
authorId: number;
authorUsername: string;
name: string;
content: string;
createdAt: string;
updatedAt: string;
isAdmin: boolean;
setModalUpdateActive: (v: boolean) => void;
setUpdatePostId: (v: number) => void;
}
export const PostItem: FC<PostItemProps> = ({
id,
groupId,
authorId,
authorUsername,
name,
content,
createdAt,
updatedAt,
isAdmin,
setModalUpdateActive,
setUpdatePostId,
}) => {
const members = useAppSelector(
(state) => state.groups.fetchGroupById.group?.members,
);
const member = members?.find((m) => m.userId === authorId);
return (
<div className="rounded-[10px] flex flex-col gap-[20px]">
<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>
{isAdmin && (
<div
className=" h-[40px] w-[40px] absolute top-0 right-0 flex items-center justify-center cursor-pointer
rounded-[10px] hover:bg-liquid-lighter transition-all duration-300 active:scale-90"
onClick={() => {
setUpdatePostId(id);
setModalUpdateActive(true);
}}
>
<img src={Edit} />
</div>
)}
</div>
<div>
<MarkdownPreview className="bg-transparent" content={content} />
</div>
</div>
);
};

View File

@@ -6,6 +6,9 @@ import { SearchInput } from '../../../../components/input/SearchInput';
import { setMenuActiveGroupPage } from '../../../../redux/slices/store'; import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
import { fetchGroupById } from '../../../../redux/slices/groups'; import { fetchGroupById } from '../../../../redux/slices/groups';
import { SecondaryButton } from '../../../../components/button/SecondaryButton'; import { SecondaryButton } from '../../../../components/button/SecondaryButton';
import ModalCreate from './ModalCreate';
import { PostItem } from './PostItem';
import ModalUpdate from './ModalUpdate';
interface PostsProps { interface PostsProps {
groupId: number; groupId: number;
@@ -14,15 +17,16 @@ interface PostsProps {
export const Posts: FC<PostsProps> = ({ groupId }) => { export const Posts: FC<PostsProps> = ({ groupId }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [modalCreateActive, setModalCreateActive] = useState<boolean>(false);
const [modalUpdateActive, setModalUpdateActive] = useState<boolean>(false);
const [updatePostId, setUpdatePostId] = useState<number>(0);
const [isAdmin, setIsAdmin] = useState<boolean>(false); const [isAdmin, setIsAdmin] = useState<boolean>(false);
const { pages, status } = useAppSelector( const { pages, status } = useAppSelector(
(state) => state.groupfeed.fetchPosts, (state) => state.groupfeed.fetchPosts,
); );
const { id: userId } = useAppSelector((state) => state.auth); const { id: userId } = useAppSelector((state) => state.auth);
const { group, status: statusGroup } = useAppSelector( const { group } = useAppSelector((state) => state.groups.fetchGroupById);
(state) => state.groups.fetchGroupById,
);
// Загружаем только первую страницу // Загружаем только первую страницу
useEffect(() => { useEffect(() => {
@@ -58,12 +62,12 @@ export const Posts: FC<PostsProps> = ({ groupId }) => {
placeholder="Поиск сообщений" placeholder="Поиск сообщений"
/> />
{isAdmin && ( {isAdmin && (
<div className=" h-[40px] w-[200px] absolute top-0 right-0 flex items-center"> <div className=" h-[40px] w-[180px] absolute top-0 right-0 flex items-center">
<SecondaryButton <SecondaryButton
onClick={() => { onClick={() => {
// setModalActive(true); setModalCreateActive(true);
}} }}
text="Добавить задачу" text="Создать пост"
/> />
</div> </div>
)} )}
@@ -75,42 +79,33 @@ export const Posts: FC<PostsProps> = ({ groupId }) => {
{status == 'successful' && {status == 'successful' &&
page0?.items && page0?.items &&
page0.items.length > 0 ? ( page0.items.length > 0 ? (
<div className="space-y-4"> <div className="flex flex-col gap-[20px]">
{page0.items.map((post) => ( {page0.items.map((post, i) => (
<div <PostItem
key={post.id} {...post}
className="border border-gray-700 rounded p-3" key={i}
> isAdmin={isAdmin}
<div> setModalUpdateActive={setModalUpdateActive}
<b>ID:</b> {post.id} setUpdatePostId={setUpdatePostId}
</div> />
<div>
<b>Название:</b> {post.name}
</div>
<div>
<b>Содержимое:</b> {post.content}
</div>
<div>
<b>Автор:</b> {post.authorUsername}
</div>
<div>
<b>Автор ID:</b> {post.authorId}
</div>
<div>
<b>Group ID:</b> {post.groupId}
</div>
<div>
<b>Создан:</b> {post.createdAt}
</div>
<div>
<b>Обновлён:</b> {post.updatedAt}
</div>
</div>
))} ))}
</div> </div>
) : status === 'successful' ? ( ) : status === 'successful' ? (
<div>Постов пока нет</div> <div>Постов пока нет</div>
) : null} ) : null}
<ModalCreate
active={modalCreateActive}
setActive={setModalCreateActive}
groupId={groupId}
/>
<ModalUpdate
active={modalUpdateActive}
setActive={setModalUpdateActive}
groupId={groupId}
postId={updatePostId}
/>
</div> </div>
); );
}; };

View File

@@ -97,7 +97,7 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
<div className="mt-4"> <div className="mt-4">
<label className="block mb-2">Файл задачи</label> <label className="block mb-2">Файл задачи</label>
<label className="cursor-pointer inline-flex items-center justify-center px-4 py-2 bg-liquid-lighter hover:bg-liquid-dark transition-colors rounded-[10px] text-liquid-white font-medium shadow-md"> <label className="cursor-pointer inline-flex items-center justify-center px-4 py-2 bg-liquid-lighter hover:bg-liquid-dark transition-colors rounded-[10px] text-liquid-white font-medium">
{file ? file.name : 'Выбрать файл'} {file ? file.name : 'Выбрать файл'}
<input <input
type="file" type="file"