article form creator
This commit is contained in:
@@ -1,34 +1,17 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import axios from "../../axios";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import Header from "../mission/statement/Header";
|
||||
|
||||
import { defaultSchema } from "hast-util-sanitize";
|
||||
|
||||
const schema = {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
div: [
|
||||
...(defaultSchema.attributes?.div || []),
|
||||
["style"] // разрешаем атрибут style на div
|
||||
]
|
||||
}
|
||||
};
|
||||
import MarkdownPreview from "./MarckDownPreview";
|
||||
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
defaultValue?: string;
|
||||
onChange: (value: string) => void;
|
||||
defaultValue?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const MarkdownEditor: FC<MarkdownEditorProps> = ({ defaultValue, onChange }) => {
|
||||
const [markdown, setMarkdown] = useState<string>(defaultValue || `# 🌙 Добро пожаловать в Markdown-редактор
|
||||
const [markdown, setMarkdown] = useState<string>(defaultValue || `# 🌙 Добро пожаловать в Markdown-редактор
|
||||
|
||||
Добро пожаловать в **Markdown-редактор**!
|
||||
Здесь ты можешь писать в формате Markdown и видеть результат **в реальном времени** 👇
|
||||
@@ -224,87 +207,72 @@ print(greet("Мир"))
|
||||
|
||||
`);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(markdown);
|
||||
}, [markdown]);
|
||||
useEffect(() => {
|
||||
onChange(markdown);
|
||||
}, [markdown]);
|
||||
|
||||
// Обработчик вставки
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData.items;
|
||||
// Обработчик вставки
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData.items;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
e.preventDefault(); // предотвращаем вставку картинки как текста
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
e.preventDefault(); // предотвращаем вставку картинки как текста
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const response = await axios.post("/media/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
try {
|
||||
const response = await axios.post("/media/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
|
||||
const imageUrl = response.data.url;
|
||||
// Вставляем ссылку на картинку в текст
|
||||
const cursorPos = (e.target as HTMLTextAreaElement).selectionStart;
|
||||
const newText =
|
||||
markdown.slice(0, cursorPos) +
|
||||
`<img src=\"${imageUrl}\" alt=\"img\"/>` +
|
||||
markdown.slice(cursorPos);
|
||||
const imageUrl = response.data.url;
|
||||
// Вставляем ссылку на картинку в текст
|
||||
const cursorPos = (e.target as HTMLTextAreaElement).selectionStart;
|
||||
const newText =
|
||||
markdown.slice(0, cursorPos) +
|
||||
`<img src=\"${imageUrl}\" alt=\"img\"/>` +
|
||||
markdown.slice(cursorPos);
|
||||
|
||||
setMarkdown(newText);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки изображения:", err);
|
||||
setMarkdown(newText);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки изображения:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen grid grid-rows-[60px,1fr]">
|
||||
<div>
|
||||
<Header missionId={1} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 h-full min-h-0">
|
||||
{/* Предпросмотр */}
|
||||
<div className="overflow-y-auto min-h-0 overflow-hidden">
|
||||
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
|
||||
<h2 className="text-lg font-semibold mb-3 text-gray-100">👀 Предпросмотр</h2>
|
||||
<div className="flex-1 bg-[#161b22] rounded-lg shadow-lg p-6 h-[calc(100%-40px)]">
|
||||
<div className="prose prose-invert max-w-none h-full overflow-auto pr-4 medium-scrollbar">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, schema], rehypeHighlight]}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
return (
|
||||
<div className="grid grid-cols-2 h-full min-h-0">
|
||||
{/* Предпросмотр */}
|
||||
<div className="overflow-y-auto min-h-0 overflow-hidden">
|
||||
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
|
||||
<h2 className="text-lg font-semibold mb-3 text-gray-100">👀 Предпросмотр</h2>
|
||||
<MarkdownPreview content={markdown} className="h-[calc(100%-40px)]"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Редактор */}
|
||||
<div className="overflow-y-auto min-h-0 overflow-hidden">
|
||||
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
|
||||
<h2 className="text-lg font-semibold mb-3 text-gray-100">📝 Редактор</h2>
|
||||
<textarea
|
||||
value={markdown}
|
||||
onChange={(e) => setMarkdown(e.target.value)}
|
||||
onPaste={handlePaste} // <-- вот сюда обработчик вставки
|
||||
className="flex-1 w-full bg-[#0d1117] text-gray-200 border border-gray-700
|
||||
{/* Редактор */}
|
||||
<div className="overflow-y-auto min-h-0 overflow-hidden">
|
||||
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
|
||||
<h2 className="text-lg font-semibold mb-3 text-gray-100">📝 Редактор</h2>
|
||||
<textarea
|
||||
value={markdown}
|
||||
onChange={(e) => setMarkdown(e.target.value)}
|
||||
onPaste={handlePaste} // <-- вот сюда обработчик вставки
|
||||
className="flex-1 w-full bg-[#0d1117] text-gray-200 border border-gray-700
|
||||
rounded-lg p-5 font-mono text-sm resize-none focus:outline-none focus:ring-2
|
||||
medium-scrollbar"
|
||||
placeholder="Пиши в формате Markdown..."
|
||||
/>
|
||||
</div>
|
||||
placeholder="Пиши в формате Markdown..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownEditor;
|
||||
|
||||
31
src/views/articleeditor/Header.tsx
Normal file
31
src/views/articleeditor/Header.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import { arrowLeft } from "../../assets/icons/header";
|
||||
import { Logo } from "../../assets/logos";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface HeaderProps {
|
||||
backUrl?: string;
|
||||
}
|
||||
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
backUrl="/home/articles",
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
|
||||
<img src={Logo} alt="Logo" className="h-[28px] w-auto cursor-pointer" onClick={() => { navigate("/home") }} />
|
||||
|
||||
<img src={arrowLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(backUrl) }} />
|
||||
|
||||
{/* <div className="flex gap-[10px]">
|
||||
<img src={chevroneLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId - 1}`) }} />
|
||||
<span>{missionId}</span>
|
||||
<img src={chevroneRight} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId + 1}`) }} />
|
||||
</div> */}
|
||||
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
43
src/views/articleeditor/MarckDownPreview.tsx
Normal file
43
src/views/articleeditor/MarckDownPreview.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { FC } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
|
||||
import { defaultSchema } from "hast-util-sanitize";
|
||||
import { cn } from "../../lib/cn";
|
||||
|
||||
const schema = {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
div: [
|
||||
...(defaultSchema.attributes?.div || []),
|
||||
["style"] // разрешаем атрибут style на div
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
interface MarkdownPreviewProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MarkdownPreview: FC<MarkdownPreviewProps> = ({ content, className="" }) => {
|
||||
return (
|
||||
<div 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">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, schema], rehypeHighlight]}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownPreview;
|
||||
@@ -3,6 +3,7 @@ import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
||||
import { useAppDispatch } from "../../../redux/hooks";
|
||||
import ArticleItem from "./ArticleItem";
|
||||
import { setMenuActivePage } from "../../../redux/slices/store";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
|
||||
export interface Article {
|
||||
@@ -15,6 +16,7 @@ export interface Article {
|
||||
const Articles = () => {
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const articles: Article[] = [
|
||||
{
|
||||
@@ -142,7 +144,7 @@ const Articles = () => {
|
||||
Статьи
|
||||
</div>
|
||||
<SecondaryButton
|
||||
onClick={() => { }}
|
||||
onClick={() => {navigate("/article/create")}}
|
||||
text="Создать статью"
|
||||
className="absolute right-0"
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ const Login = () => {
|
||||
// После успешного логина
|
||||
useEffect(() => {
|
||||
dispatch(setMenuActivePage("account"))
|
||||
console.log(submitClicked);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -35,6 +35,7 @@ const Register = () => {
|
||||
if (jwt) {
|
||||
navigate("/home");
|
||||
}
|
||||
console.log(submitClicked);
|
||||
}, [jwt]);
|
||||
|
||||
const handleRegister = () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cn } from "../../../lib/cn";
|
||||
import { Account } from "../../../assets/icons/auth";
|
||||
import { registerUser } from "../../../redux/slices/auth";
|
||||
import { PrimaryButton } from "../../../components/button/PrimaryButton";
|
||||
import { ReverseButton } from "../../../components/button/ReverseButton";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, deleteGroup, updateGroup } from "../../../redux/slices/groups";
|
||||
import { deleteGroup, updateGroup } from "../../../redux/slices/groups";
|
||||
|
||||
interface ModalUpdateProps {
|
||||
active: boolean;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import SubmissionItem from "./SubmissionItem";
|
||||
import { SecondaryButton } from "../../../components/button/SecondaryButton";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
||||
import { useAppSelector } from "../../../redux/hooks";
|
||||
import { FC, useEffect } from "react";
|
||||
import { setMenuActivePage } from "../../../redux/slices/store";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchMissions } from "../../../redux/slices/missions";
|
||||
|
||||
|
||||
|
||||
export interface Mission {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from "../../../lib/cn";
|
||||
import { IconError, IconSuccess } from "../../../assets/icons/missions";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
// import { IconError, IconSuccess } from "../../../assets/icons/missions";
|
||||
// import { useNavigate } from "react-router-dom";
|
||||
|
||||
export interface SubmissionItemProps {
|
||||
id: number;
|
||||
@@ -43,7 +43,7 @@ const SubmissionItem: React.FC<SubmissionItemProps> = ({
|
||||
type,
|
||||
status,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
// const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className={cn(" w-full relative rounded-[10px] text-liquid-white",
|
||||
|
||||
Reference in New Issue
Block a user