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"
value={startValue}
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>
@@ -38,7 +38,7 @@ const DateRangeInput: React.FC<DateRangeInputProps> = ({
type="datetime-local"
value={endValue}
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>

View File

@@ -215,7 +215,17 @@ export const deletePost = createAsyncThunk(
const postsSlice = createSlice({
name: 'posts',
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) => {
// fetchGroupPosts
builder.addCase(fetchGroupPosts.pending, (state) => {
@@ -333,4 +343,5 @@ const postsSlice = createSlice({
},
});
export const { setGroupFeedStatus } = postsSlice.actions;
export const groupFeedReducer = postsSlice.reducer;

View File

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

View File

@@ -30,12 +30,7 @@ const MarkdownPreview: FC<MarkdownPreviewProps> = ({
className = '',
}) => {
return (
<div
className={cn(
'flex-1 bg-[#161b22] rounded-lg shadow-lg p-6',
className,
)}
>
<div className={cn('flex-1 bg-[#161b22] rounded-lg p-6', className)}>
<div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar">
<ReactMarkdown
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 { fetchGroupById } from '../../../../redux/slices/groups';
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
import ModalCreate from './ModalCreate';
import { PostItem } from './PostItem';
import ModalUpdate from './ModalUpdate';
interface PostsProps {
groupId: number;
@@ -14,15 +17,16 @@ interface PostsProps {
export const Posts: FC<PostsProps> = ({ groupId }) => {
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 { pages, status } = useAppSelector(
(state) => state.groupfeed.fetchPosts,
);
const { id: userId } = useAppSelector((state) => state.auth);
const { group, status: statusGroup } = useAppSelector(
(state) => state.groups.fetchGroupById,
);
const { group } = useAppSelector((state) => state.groups.fetchGroupById);
// Загружаем только первую страницу
useEffect(() => {
@@ -58,12 +62,12 @@ export const Posts: FC<PostsProps> = ({ groupId }) => {
placeholder="Поиск сообщений"
/>
{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
onClick={() => {
// setModalActive(true);
setModalCreateActive(true);
}}
text="Добавить задачу"
text="Создать пост"
/>
</div>
)}
@@ -75,42 +79,33 @@ export const Posts: FC<PostsProps> = ({ groupId }) => {
{status == 'successful' &&
page0?.items &&
page0.items.length > 0 ? (
<div className="space-y-4">
{page0.items.map((post) => (
<div
key={post.id}
className="border border-gray-700 rounded p-3"
>
<div>
<b>ID:</b> {post.id}
</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 className="flex flex-col gap-[20px]">
{page0.items.map((post, i) => (
<PostItem
{...post}
key={i}
isAdmin={isAdmin}
setModalUpdateActive={setModalUpdateActive}
setUpdatePostId={setUpdatePostId}
/>
))}
</div>
) : status === 'successful' ? (
<div>Постов пока нет</div>
) : null}
<ModalCreate
active={modalCreateActive}
setActive={setModalCreateActive}
groupId={groupId}
/>
<ModalUpdate
active={modalUpdateActive}
setActive={setModalUpdateActive}
groupId={groupId}
postId={updatePostId}
/>
</div>
);
};

View File

@@ -97,7 +97,7 @@ const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
<div className="mt-4">
<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 : 'Выбрать файл'}
<input
type="file"