[feature/frontend] markdown editor
This commit is contained in:
parent
086c9761a9
commit
6e1be3d513
11 changed files with 2265 additions and 12 deletions
|
@ -126,5 +126,45 @@
|
|||
"passwordMismatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 8 characters long"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"heading1": "Heading 1",
|
||||
"heading2": "Heading 2",
|
||||
"heading3": "Heading 3",
|
||||
"bold": "Bold",
|
||||
"italic": "Italic",
|
||||
"orderedList": "Ordered List",
|
||||
"unorderedList": "Unordered List",
|
||||
"quote": "Quote",
|
||||
"link": "Link",
|
||||
"image": "Image",
|
||||
"inlineCode": "Inline Code",
|
||||
"codeBlock": "Code Block",
|
||||
"table": "Table",
|
||||
"togglePreview": "Toggle Preview",
|
||||
"toggleFullscreen": "Toggle Fullscreen",
|
||||
"selectLanguage": "Select Language",
|
||||
"plainText": "Plain Text",
|
||||
"insertTable": "Insert Table",
|
||||
"addRowAbove": "Add Row Above",
|
||||
"addRowBelow": "Add Row Below",
|
||||
"addColumnLeft": "Add Column Left",
|
||||
"addColumnRight": "Add Column Right",
|
||||
"deleteRow": "Delete Row",
|
||||
"deleteColumn": "Delete Column",
|
||||
"deleteTable": "Delete Table",
|
||||
"dragAndDrop": "Drop images here",
|
||||
"dropToUpload": "Drop files here to upload",
|
||||
"uploading": "Uploading...",
|
||||
"uploadProgress": "Upload progress: {{progress}}%",
|
||||
"uploadError": "Failed to upload image: {{error}}",
|
||||
"uploadSuccess": "Image uploaded successfully",
|
||||
"codeBlockShortcut": "Ctrl+Shift+K",
|
||||
"boldShortcut": "Ctrl+B",
|
||||
"italicShortcut": "Ctrl+I",
|
||||
"linkShortcut": "Ctrl+K",
|
||||
"heading1Shortcut": "Shift+1",
|
||||
"heading2Shortcut": "Shift+2",
|
||||
"heading3Shortcut": "Shift+3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -126,5 +126,45 @@
|
|||
"passwordMismatch": "两次输入的密码不一致",
|
||||
"passwordTooShort": "密码长度不能少于8个字符"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"heading1": "一级标题",
|
||||
"heading2": "二级标题",
|
||||
"heading3": "三级标题",
|
||||
"bold": "粗体",
|
||||
"italic": "斜体",
|
||||
"orderedList": "有序列表",
|
||||
"unorderedList": "无序列表",
|
||||
"quote": "引用",
|
||||
"link": "链接",
|
||||
"image": "图片",
|
||||
"inlineCode": "行内代码",
|
||||
"codeBlock": "代码块",
|
||||
"table": "表格",
|
||||
"togglePreview": "切换预览",
|
||||
"toggleFullscreen": "切换全屏",
|
||||
"selectLanguage": "选择语言",
|
||||
"plainText": "纯文本",
|
||||
"insertTable": "插入表格",
|
||||
"addRowAbove": "在上方插入行",
|
||||
"addRowBelow": "在下方插入行",
|
||||
"addColumnLeft": "在左侧插入列",
|
||||
"addColumnRight": "在右侧插入列",
|
||||
"deleteRow": "删除行",
|
||||
"deleteColumn": "删除列",
|
||||
"deleteTable": "删除表格",
|
||||
"dragAndDrop": "拖放图片到此处",
|
||||
"dropToUpload": "拖放文件到此处上传",
|
||||
"uploading": "上传中...",
|
||||
"uploadProgress": "上传进度:{{progress}}%",
|
||||
"uploadError": "图片上传失败:{{error}}",
|
||||
"uploadSuccess": "图片上传成功",
|
||||
"codeBlockShortcut": "Ctrl+Shift+K",
|
||||
"boldShortcut": "Ctrl+B",
|
||||
"italicShortcut": "Ctrl+I",
|
||||
"linkShortcut": "Ctrl+K",
|
||||
"heading1Shortcut": "Shift+1",
|
||||
"heading2Shortcut": "Shift+2",
|
||||
"heading3Shortcut": "Shift+3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -112,5 +112,45 @@
|
|||
"passwordMismatch": "兩次輸入的密碼不一致",
|
||||
"passwordTooShort": "密碼長度不能少於8個字符"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"heading1": "一級標題",
|
||||
"heading2": "二級標題",
|
||||
"heading3": "三級標題",
|
||||
"bold": "粗體",
|
||||
"italic": "斜體",
|
||||
"orderedList": "有序列表",
|
||||
"unorderedList": "無序列表",
|
||||
"quote": "引用",
|
||||
"link": "連結",
|
||||
"image": "圖片",
|
||||
"inlineCode": "行內程式碼",
|
||||
"codeBlock": "程式碼區塊",
|
||||
"table": "表格",
|
||||
"togglePreview": "切換預覽",
|
||||
"toggleFullscreen": "切換全螢幕",
|
||||
"selectLanguage": "選擇語言",
|
||||
"plainText": "純文字",
|
||||
"insertTable": "插入表格",
|
||||
"addRowAbove": "在上方插入列",
|
||||
"addRowBelow": "在下方插入列",
|
||||
"addColumnLeft": "在左側插入欄",
|
||||
"addColumnRight": "在右側插入欄",
|
||||
"deleteRow": "刪除列",
|
||||
"deleteColumn": "刪除欄",
|
||||
"deleteTable": "刪除表格",
|
||||
"dragAndDrop": "拖放圖片到此處",
|
||||
"dropToUpload": "拖放檔案到此處上傳",
|
||||
"uploading": "上傳中...",
|
||||
"uploadProgress": "上傳進度:{{progress}}%",
|
||||
"uploadError": "圖片上傳失敗:{{error}}",
|
||||
"uploadSuccess": "圖片上傳成功",
|
||||
"codeBlockShortcut": "Ctrl+Shift+K",
|
||||
"boldShortcut": "Ctrl+B",
|
||||
"italicShortcut": "Ctrl+I",
|
||||
"linkShortcut": "Ctrl+K",
|
||||
"heading1Shortcut": "Shift+1",
|
||||
"heading2Shortcut": "Shift+2",
|
||||
"heading3Shortcut": "Shift+3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,10 +12,14 @@
|
|||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@tss-rocks/api": "workspace:*",
|
||||
"@types/axios": "^0.14.4",
|
||||
"@types/classnames": "^2.3.4",
|
||||
"@types/highlight.js": "^10.1.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"axios": "^1.7.9",
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"lucide-react": "^0.474.0",
|
||||
|
@ -24,7 +28,13 @@
|
|||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-router-dom": "^7.1.5"
|
||||
"react-markdown": "^10.0.0",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"react-toastify": "^11.0.3",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
|
|
|
@ -4,16 +4,21 @@ import { AuthProvider } from './contexts/AuthContext';
|
|||
import { UserProvider } from './contexts/UserContext';
|
||||
import router from './router';
|
||||
import LoadingSpinner from './components/LoadingSpinner';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||
<AuthProvider>
|
||||
<UserProvider>
|
||||
<RouterProvider router={router} />
|
||||
</UserProvider>
|
||||
</AuthProvider>
|
||||
</Suspense>
|
||||
<>
|
||||
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||
<AuthProvider>
|
||||
<UserProvider>
|
||||
<RouterProvider router={router} />
|
||||
</UserProvider>
|
||||
</AuthProvider>
|
||||
</Suspense>
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
958
frontend/src/components/admin/MarkdownEditor.tsx
Normal file
958
frontend/src/components/admin/MarkdownEditor.tsx
Normal file
|
@ -0,0 +1,958 @@
|
|||
import { FC, useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
import classNames from 'classnames';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
RiH1,
|
||||
RiH2,
|
||||
RiH3,
|
||||
RiBold,
|
||||
RiItalic,
|
||||
RiListOrdered,
|
||||
RiListUnordered,
|
||||
RiLink,
|
||||
RiImage2Line,
|
||||
RiCodeLine,
|
||||
RiCodeBoxLine,
|
||||
RiTableLine,
|
||||
RiDoubleQuotesL,
|
||||
RiEyeLine,
|
||||
RiEyeOffLine,
|
||||
RiFullscreenLine,
|
||||
} from 'react-icons/ri';
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
type CodeBlockProps = {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLElement>;
|
||||
|
||||
const MarkdownEditor: FC<MarkdownEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const tableMenuRef = useRef<HTMLDivElement>(null);
|
||||
const langSelectorRef = useRef<HTMLDivElement>(null);
|
||||
const [showTableMenu, setShowTableMenu] = useState(false);
|
||||
const [showLangSelector, setShowLangSelector] = useState(false);
|
||||
const [isPreview, setIsPreview] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
// 检查当前行是否为空
|
||||
const isCurrentLineEmpty = (textarea: HTMLTextAreaElement): boolean => {
|
||||
const text = textarea.value;
|
||||
const lines = text.split('\n');
|
||||
const currentLine = getCurrentLine(textarea);
|
||||
return !lines[currentLine].trim();
|
||||
};
|
||||
|
||||
// 获取当前行号
|
||||
const getCurrentLine = (textarea: HTMLTextAreaElement): number => {
|
||||
const text = textarea.value.substring(0, textarea.selectionStart);
|
||||
return text.split('\n').length - 1;
|
||||
};
|
||||
|
||||
// 在当前位置插入文本
|
||||
const insertText = (textarea: HTMLTextAreaElement, text: string) => {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const before = textarea.value.substring(0, start);
|
||||
const after = textarea.value.substring(end);
|
||||
|
||||
const needNewLine = !isCurrentLineEmpty(textarea) &&
|
||||
(text.startsWith('#') || text.startsWith('>'));
|
||||
|
||||
const newText = needNewLine ? `\n${text}` : text;
|
||||
const newValue = before + newText + after;
|
||||
|
||||
onChange(newValue);
|
||||
|
||||
textarea.value = newValue;
|
||||
const newCursorPos = start + newText.length;
|
||||
textarea.selectionStart = newCursorPos;
|
||||
textarea.selectionEnd = newCursorPos;
|
||||
textarea.focus();
|
||||
};
|
||||
|
||||
const insertTable = (rows: number, cols: number) => {
|
||||
if (!textareaRef.current) return;
|
||||
|
||||
const headers = Array(cols).fill('header').join(' | ');
|
||||
const separators = Array(cols).fill('---').join(' | ');
|
||||
const cells = Array(cols).fill('content').join(' | ');
|
||||
const rows_content = Array(rows).fill(cells).join('\n| ');
|
||||
|
||||
insertText(
|
||||
textareaRef.current,
|
||||
`\n| ${headers} |\n| ${separators} |\n| ${rows_content} |\n\n`
|
||||
);
|
||||
setShowTableMenu(false);
|
||||
};
|
||||
|
||||
const findTableAtCursor = (): { table: string; start: number; end: number; rows: string[][]; alignments: string[] } | null => {
|
||||
if (!textareaRef.current) return null;
|
||||
|
||||
const text = textareaRef.current.value;
|
||||
const cursorPos = textareaRef.current.selectionStart;
|
||||
|
||||
const tableRegex = /\|[^\n]+\|[\s]*\n\|[- |:]+\|[\s]*\n(\|[^\n]+\|[\s]*\n?)+/g;
|
||||
let match;
|
||||
while ((match = tableRegex.exec(text)) !== null) {
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
if (cursorPos >= start && cursorPos <= end) {
|
||||
const rows = match[0]
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(row =>
|
||||
row
|
||||
.trim()
|
||||
.replace(/^\||\|$/g, '')
|
||||
.split('|')
|
||||
.map(cell => cell.trim())
|
||||
);
|
||||
|
||||
const alignments = rows[1].map(cell => {
|
||||
if (cell.startsWith(':') && cell.endsWith(':')) return 'center';
|
||||
if (cell.endsWith(':')) return 'right';
|
||||
return 'left';
|
||||
});
|
||||
|
||||
return {
|
||||
table: match[0],
|
||||
start,
|
||||
end,
|
||||
rows: [rows[0], ...rows.slice(2)],
|
||||
alignments,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const generateTableText = (rows: string[][], alignments: string[]): string => {
|
||||
const colWidths = rows[0].map((_, colIndex) =>
|
||||
Math.max(...rows.map(row => row[colIndex]?.length || 0))
|
||||
);
|
||||
|
||||
const separator = alignments.map((align, i) => {
|
||||
const width = Math.max(3, colWidths[i]);
|
||||
switch (align) {
|
||||
case 'center':
|
||||
return ':' + '-'.repeat(width - 2) + ':';
|
||||
case 'right':
|
||||
return '-'.repeat(width - 1) + ':';
|
||||
default:
|
||||
return '-'.repeat(width);
|
||||
}
|
||||
});
|
||||
|
||||
const tableRows = [
|
||||
rows[0],
|
||||
separator,
|
||||
...rows.slice(1)
|
||||
].map(row =>
|
||||
'| ' + row.map((cell, i) => cell.padEnd(colWidths[i])).join(' | ') + ' |'
|
||||
);
|
||||
|
||||
return tableRows.join('\n') + '\n';
|
||||
};
|
||||
|
||||
const updateTable = (pos: { table: string; start: number; end: number; rows: string[][]; alignments: string[] }, newRows?: string[][]) => {
|
||||
if (!textareaRef.current) return;
|
||||
|
||||
const text = textareaRef.current.value;
|
||||
const newTable = generateTableText(newRows || pos.rows, pos.alignments);
|
||||
const newText = text.substring(0, pos.start) + newTable + text.substring(pos.end);
|
||||
onChange(newText);
|
||||
};
|
||||
|
||||
const tableActions = [
|
||||
{
|
||||
label: t('editor.insertTable'),
|
||||
action: () => insertTable(2, 2),
|
||||
},
|
||||
{
|
||||
label: t('editor.addRowAbove'),
|
||||
action: () => {
|
||||
const tablePos = findTableAtCursor();
|
||||
if (!tablePos) return;
|
||||
|
||||
const cursorPos = textareaRef.current?.selectionStart || 0;
|
||||
let rowIndex = 0;
|
||||
let currentPos = tablePos.start;
|
||||
|
||||
for (let i = 0; i < tablePos.rows.length; i++) {
|
||||
const rowText = generateTableText([tablePos.rows[i]], tablePos.alignments);
|
||||
if (cursorPos <= currentPos + rowText.length) {
|
||||
rowIndex = i;
|
||||
break;
|
||||
}
|
||||
currentPos += rowText.length;
|
||||
}
|
||||
|
||||
const newRows = [...tablePos.rows];
|
||||
newRows.splice(rowIndex, 0, Array(tablePos.rows[0].length).fill(''));
|
||||
updateTable(tablePos, newRows);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('editor.addRowBelow'),
|
||||
action: () => {
|
||||
const tablePos = findTableAtCursor();
|
||||
if (!tablePos) return;
|
||||
|
||||
const cursorPos = textareaRef.current?.selectionStart || 0;
|
||||
let rowIndex = 0;
|
||||
let currentPos = tablePos.start;
|
||||
|
||||
for (let i = 0; i < tablePos.rows.length; i++) {
|
||||
const rowText = generateTableText([tablePos.rows[i]], tablePos.alignments);
|
||||
if (cursorPos <= currentPos + rowText.length) {
|
||||
rowIndex = i;
|
||||
break;
|
||||
}
|
||||
currentPos += rowText.length;
|
||||
}
|
||||
|
||||
const newRows = [...tablePos.rows];
|
||||
newRows.splice(rowIndex + 1, 0, Array(tablePos.rows[0].length).fill(''));
|
||||
updateTable(tablePos, newRows);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('editor.addColumnLeft'),
|
||||
action: () => {
|
||||
const tablePos = findTableAtCursor();
|
||||
if (!tablePos) return;
|
||||
|
||||
const cursorPos = textareaRef.current?.selectionStart || 0;
|
||||
const line = textareaRef.current?.value
|
||||
.substring(tablePos.start, cursorPos)
|
||||
.split('\n')
|
||||
.pop() || '';
|
||||
const colIndex = (line.match(/\|/g) || []).length - 1;
|
||||
|
||||
const newRows = tablePos.rows.map(row => {
|
||||
const newRow = [...row];
|
||||
newRow.splice(colIndex, 0, '');
|
||||
return newRow;
|
||||
});
|
||||
|
||||
const newAlignments = [...tablePos.alignments];
|
||||
newAlignments.splice(colIndex, 0, 'left');
|
||||
|
||||
tablePos.alignments = newAlignments;
|
||||
updateTable(tablePos, newRows);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('editor.addColumnRight'),
|
||||
action: () => {
|
||||
const tablePos = findTableAtCursor();
|
||||
if (!tablePos) return;
|
||||
|
||||
const cursorPos = textareaRef.current?.selectionStart || 0;
|
||||
const line = textareaRef.current?.value
|
||||
.substring(tablePos.start, cursorPos)
|
||||
.split('\n')
|
||||
.pop() || '';
|
||||
const colIndex = (line.match(/\|/g) || []).length - 1;
|
||||
|
||||
const newRows = tablePos.rows.map(row => {
|
||||
const newRow = [...row];
|
||||
newRow.splice(colIndex + 1, 0, '');
|
||||
return newRow;
|
||||
});
|
||||
|
||||
const newAlignments = [...tablePos.alignments];
|
||||
newAlignments.splice(colIndex + 1, 0, 'left');
|
||||
|
||||
tablePos.alignments = newAlignments;
|
||||
updateTable(tablePos, newRows);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('editor.deleteRow'),
|
||||
action: () => {
|
||||
const tablePos = findTableAtCursor();
|
||||
if (!tablePos) return;
|
||||
|
||||
const cursorPos = textareaRef.current?.selectionStart || 0;
|
||||
let rowIndex = 0;
|
||||
let currentPos = tablePos.start;
|
||||
|
||||
for (let i = 0; i < tablePos.rows.length; i++) {
|
||||
const rowText = generateTableText([tablePos.rows[i]], tablePos.alignments);
|
||||
if (cursorPos <= currentPos + rowText.length) {
|
||||
rowIndex = i;
|
||||
break;
|
||||
}
|
||||
currentPos += rowText.length;
|
||||
}
|
||||
|
||||
const newRows = [...tablePos.rows];
|
||||
newRows.splice(rowIndex, 1);
|
||||
updateTable(tablePos, newRows);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('editor.deleteColumn'),
|
||||
action: () => {
|
||||
const tablePos = findTableAtCursor();
|
||||
if (!tablePos) return;
|
||||
|
||||
const cursorPos = textareaRef.current?.selectionStart || 0;
|
||||
const line = textareaRef.current?.value
|
||||
.substring(tablePos.start, cursorPos)
|
||||
.split('\n')
|
||||
.pop() || '';
|
||||
const colIndex = (line.match(/\|/g) || []).length - 1;
|
||||
|
||||
const newRows = tablePos.rows.map(row => {
|
||||
const newRow = [...row];
|
||||
newRow.splice(colIndex, 1);
|
||||
return newRow;
|
||||
});
|
||||
|
||||
const newAlignments = [...tablePos.alignments];
|
||||
newAlignments.splice(colIndex, 1);
|
||||
|
||||
tablePos.alignments = newAlignments;
|
||||
updateTable(tablePos, newRows);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('editor.deleteTable'),
|
||||
action: () => {
|
||||
const tablePos = findTableAtCursor();
|
||||
if (!tablePos) return;
|
||||
|
||||
const text = textareaRef.current?.value || '';
|
||||
const newText = text.substring(0, tablePos.start) + text.substring(tablePos.end);
|
||||
onChange(newText);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const toolbarButtons = [
|
||||
{
|
||||
icon: RiH1,
|
||||
label: t('editor.heading1'),
|
||||
shortcut: t('editor.heading1Shortcut'),
|
||||
action: (textarea: HTMLTextAreaElement) => {
|
||||
insertText(textarea, '# ');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: RiH2,
|
||||
label: t('editor.heading2'),
|
||||
shortcut: t('editor.heading2Shortcut'),
|
||||
action: (textarea: HTMLTextAreaElement) => {
|
||||
insertText(textarea, '## ');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: RiH3,
|
||||
label: t('editor.heading3'),
|
||||
shortcut: t('editor.heading3Shortcut'),
|
||||
action: (textarea: HTMLTextAreaElement) => {
|
||||
insertText(textarea, '### ');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: RiBold,
|
||||
label: t('editor.bold'),
|
||||
shortcut: t('editor.boldShortcut'),
|
||||
action: (textarea: HTMLTextAreaElement) => {
|
||||
const text = textarea.value.substring(
|
||||
textarea.selectionStart,
|
||||
textarea.selectionEnd
|
||||
);
|
||||
insertText(textarea, `**${text || t('editor.bold')}**`);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: RiItalic,
|
||||
label: t('editor.italic'),
|
||||
shortcut: t('editor.italicShortcut'),
|
||||
action: (textarea: HTMLTextAreaElement) => {
|
||||
const text = textarea.value.substring(
|
||||
textarea.selectionStart,
|
||||
textarea.selectionEnd
|
||||
);
|
||||
insertText(textarea, `_${text || t('editor.italic')}_`);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: RiListUnordered,
|
||||
label: t('editor.unorderedList'),
|
||||
action: (textarea: HTMLTextAreaElement) => {
|
||||
insertText(textarea, '- ');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: RiListOrdered,
|
||||
label: t('editor.orderedList'),
|
||||
action: (textarea: HTMLTextAreaElement) => {
|
||||
insertText(textarea, '1. ');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: RiDoubleQuotesL,
|
||||
label: t('editor.quote'),
|
||||
action: (textarea: HTMLTextAreaElement) => {
|
||||
insertText(textarea, '> ');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: RiLink,
|
||||
label: t('editor.link'),
|
||||
shortcut: t('editor.linkShortcut'),
|
||||
action: (textarea: HTMLTextAreaElement) => {
|
||||
const text = textarea.value.substring(
|
||||
textarea.selectionStart,
|
||||
textarea.selectionEnd
|
||||
);
|
||||
insertText(textarea, `[${text || t('editor.link')}](url)`);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: RiImage2Line,
|
||||
label: t('editor.image'),
|
||||
action: () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: RiCodeLine,
|
||||
label: t('editor.inlineCode'),
|
||||
action: (textarea: HTMLTextAreaElement) => {
|
||||
const text = textarea.value.substring(
|
||||
textarea.selectionStart,
|
||||
textarea.selectionEnd
|
||||
);
|
||||
insertText(textarea, `\`${text || t('editor.inlineCode')}\``);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const commonLanguages = [
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'jsx', label: 'JSX' },
|
||||
{ value: 'tsx', label: 'TSX' },
|
||||
{ value: 'css', label: 'CSS' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'java', label: 'Java' },
|
||||
{ value: 'c', label: 'C' },
|
||||
{ value: 'cpp', label: 'C++' },
|
||||
{ value: 'csharp', label: 'C#' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'rust', label: 'Rust' },
|
||||
{ value: 'php', label: 'PHP' },
|
||||
{ value: 'ruby', label: 'Ruby' },
|
||||
{ value: 'swift', label: 'Swift' },
|
||||
{ value: 'kotlin', label: 'Kotlin' },
|
||||
{ value: 'sql', label: 'SQL' },
|
||||
{ value: 'shell', label: 'Shell' },
|
||||
{ value: 'yaml', label: 'YAML' },
|
||||
{ value: 'xml', label: 'XML' },
|
||||
];
|
||||
|
||||
const insertCodeBlock = (language?: string) => {
|
||||
if (!textareaRef.current) return;
|
||||
const lang = language || '';
|
||||
insertText(textareaRef.current, `\n\`\`\`${lang}\n\n\`\`\`\n`);
|
||||
const cursorPos = textareaRef.current.selectionStart - 4;
|
||||
textareaRef.current.setSelectionRange(cursorPos, cursorPos);
|
||||
setShowLangSelector(false);
|
||||
};
|
||||
|
||||
// 处理文件上传
|
||||
const handleFileUpload = async (file: File) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showToast('error', t('editor.uploadError', { error: 'Not an image file' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/v1/media', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
const imageUrl = response.data.data.url;
|
||||
const imageMarkdown = ``;
|
||||
|
||||
if (textareaRef.current) {
|
||||
const start = textareaRef.current.selectionStart;
|
||||
const end = textareaRef.current.selectionEnd;
|
||||
const before = value.substring(0, start);
|
||||
const after = value.substring(end);
|
||||
const newValue = before + imageMarkdown + after;
|
||||
onChange(newValue);
|
||||
showToast('success', t('editor.uploadSuccess'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Upload error:', error);
|
||||
const errorMessage = error.response?.data?.error?.message || error.message;
|
||||
showToast('error', t('editor.uploadError', { error: errorMessage }));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理拖放事件
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
for (const file of files) {
|
||||
await handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理粘贴事件
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
await handleFileUpload(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const key = e.key.toLowerCase();
|
||||
switch (key) {
|
||||
case 'b': // 粗体
|
||||
e.preventDefault();
|
||||
if (textareaRef.current) {
|
||||
const text = textareaRef.current.value.substring(
|
||||
textareaRef.current.selectionStart,
|
||||
textareaRef.current.selectionEnd
|
||||
);
|
||||
insertText(textareaRef.current, `**${text}**`);
|
||||
}
|
||||
break;
|
||||
case 'i': // 斜体
|
||||
e.preventDefault();
|
||||
if (textareaRef.current) {
|
||||
const text = textareaRef.current.value.substring(
|
||||
textareaRef.current.selectionStart,
|
||||
textareaRef.current.selectionEnd
|
||||
);
|
||||
insertText(textareaRef.current, `_${text}_`);
|
||||
}
|
||||
break;
|
||||
case 'k': // 链接
|
||||
e.preventDefault();
|
||||
if (textareaRef.current) {
|
||||
const text = textareaRef.current.value.substring(
|
||||
textareaRef.current.selectionStart,
|
||||
textareaRef.current.selectionEnd
|
||||
);
|
||||
insertText(textareaRef.current, `[${text}](url)`);
|
||||
}
|
||||
break;
|
||||
case '1': // 标题 1
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (textareaRef.current) {
|
||||
const text = textareaRef.current.value.substring(
|
||||
textareaRef.current.selectionStart,
|
||||
textareaRef.current.selectionEnd
|
||||
);
|
||||
insertText(textareaRef.current, `# ${text}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '2': // 标题 2
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (textareaRef.current) {
|
||||
const text = textareaRef.current.value.substring(
|
||||
textareaRef.current.selectionStart,
|
||||
textareaRef.current.selectionEnd
|
||||
);
|
||||
insertText(textareaRef.current, `## ${text}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '3': // 标题 3
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (textareaRef.current) {
|
||||
const text = textareaRef.current.value.substring(
|
||||
textareaRef.current.selectionStart,
|
||||
textareaRef.current.selectionEnd
|
||||
);
|
||||
insertText(textareaRef.current, `### ${text}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'e': // 预览
|
||||
e.preventDefault();
|
||||
setIsPreview(!isPreview);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
tableMenuRef.current &&
|
||||
!tableMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowTableMenu(false);
|
||||
}
|
||||
if (
|
||||
langSelectorRef.current &&
|
||||
!langSelectorRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowLangSelector(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-col border border-slate-300 dark:border-slate-600 rounded-lg',
|
||||
{ 'fixed inset-0 z-50 bg-white dark:bg-slate-900': isFullscreen }
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 p-2 border-b border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800">
|
||||
{/* 左侧编辑工具栏 */}
|
||||
<div className="flex-1 flex items-center gap-1 border-r border-slate-300 dark:border-slate-600 pr-2">
|
||||
{toolbarButtons.map((button, index) => {
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => textareaRef.current && button.action(textareaRef.current)}
|
||||
className="p-1.5 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
|
||||
title={`${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}`}
|
||||
>
|
||||
<button.icon className="text-lg" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 表格按钮 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowTableMenu(!showTableMenu)}
|
||||
className="p-1.5 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
|
||||
title={t('editor.table')}
|
||||
>
|
||||
<RiTableLine className="text-lg" />
|
||||
</button>
|
||||
{showTableMenu && (
|
||||
<div
|
||||
ref={tableMenuRef}
|
||||
className="absolute bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg shadow-lg z-50"
|
||||
style={{
|
||||
width: '200px',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginTop: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{tableActions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
action.action();
|
||||
setShowTableMenu(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
{t(action.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 代码块按钮 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowLangSelector(!showLangSelector)}
|
||||
className="p-1.5 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
|
||||
title={`${t('editor.codeBlock')} (${t('editor.codeBlockShortcut')})`}
|
||||
>
|
||||
<RiCodeBoxLine className="text-lg" />
|
||||
</button>
|
||||
{showLangSelector && (
|
||||
<div
|
||||
ref={langSelectorRef}
|
||||
className="absolute bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg shadow-lg z-50"
|
||||
style={{
|
||||
width: '200px',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginTop: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div className="px-4 py-1 text-sm font-medium text-slate-900 dark:text-white border-b border-slate-200 dark:border-slate-700">
|
||||
{t('editor.selectLanguage')}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => {
|
||||
insertCodeBlock();
|
||||
setShowLangSelector(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
{t('editor.plainText')}
|
||||
</button>
|
||||
{commonLanguages.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
insertCodeBlock(value);
|
||||
setShowLangSelector(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧预览和全屏按钮 */}
|
||||
<div className="flex items-center gap-1 pl-2">
|
||||
<button
|
||||
onClick={() => setIsPreview(!isPreview)}
|
||||
className={classNames(
|
||||
'p-1.5 rounded',
|
||||
isPreview
|
||||
? 'text-slate-900 dark:text-white bg-slate-200 dark:bg-slate-700'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-700'
|
||||
)}
|
||||
title={t('editor.togglePreview')}
|
||||
>
|
||||
{isPreview ? (
|
||||
<RiEyeOffLine className="text-lg" />
|
||||
) : (
|
||||
<RiEyeLine className="text-lg" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-1.5 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
|
||||
title={t('editor.toggleFullscreen')}
|
||||
>
|
||||
<RiFullscreenLine className="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex">
|
||||
<div
|
||||
className={classNames(
|
||||
'flex-1 relative',
|
||||
{ hidden: isPreview },
|
||||
{ 'before:absolute before:inset-0 before:bg-slate-900/10 before:z-10 before:pointer-events-none': isDraggingOver }
|
||||
)}
|
||||
>
|
||||
{isDraggingOver && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-lg px-4 py-2">
|
||||
{t('editor.dragAndDrop')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full h-full p-4 bg-white dark:bg-slate-900 text-slate-900 dark:text-white resize-vertical focus:outline-none"
|
||||
style={{ minHeight: '200px' }}
|
||||
placeholder={placeholder}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{isPreview && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="prose dark:prose-invert max-w-none p-4">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
|
||||
components={{
|
||||
code: ({ inline, className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const lang = match ? match[1] : '';
|
||||
if (inline) {
|
||||
return (
|
||||
<code className="bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100 px-1 py-0.5 rounded" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="not-prose">
|
||||
<pre className={classNames(
|
||||
'bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100 p-4 rounded-lg overflow-x-auto',
|
||||
lang && `language-${lang}`
|
||||
)}>
|
||||
<code className={lang ? `language-${lang}` : ''} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
pre: ({ children, ...props }) => children,
|
||||
p: ({ children, ...props }) => (
|
||||
<p className="text-slate-900 dark:text-slate-100 mb-4" {...props}>{children}</p>
|
||||
),
|
||||
h1: ({ children, ...props }) => (
|
||||
<h1 className="text-slate-900 dark:text-slate-100 text-4xl font-bold mt-6 mb-4" {...props}>{children}</h1>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<h2 className="text-slate-900 dark:text-slate-100 text-3xl font-bold mt-5 mb-3" {...props}>{children}</h2>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<h3 className="text-slate-900 dark:text-slate-100 text-2xl font-bold mt-4 mb-3" {...props}>{children}</h3>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<h4 className="text-slate-900 dark:text-slate-100 text-xl font-bold mt-4 mb-2" {...props}>{children}</h4>
|
||||
),
|
||||
h5: ({ children, ...props }) => (
|
||||
<h5 className="text-slate-900 dark:text-slate-100 text-lg font-bold mt-3 mb-2" {...props}>{children}</h5>
|
||||
),
|
||||
h6: ({ children, ...props }) => (
|
||||
<h6 className="text-slate-900 dark:text-slate-100 text-base font-bold mt-3 mb-2" {...props}>{children}</h6>
|
||||
),
|
||||
a: ({ href, children, ...props }) => (
|
||||
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" {...props}>{children}</a>
|
||||
),
|
||||
ul: ({ children, ...props }) => (
|
||||
<ul className="text-slate-900 dark:text-slate-100 list-disc pl-5 mb-4 space-y-1" {...props}>{children}</ul>
|
||||
),
|
||||
ol: ({ children, ...props }) => (
|
||||
<ol className="text-slate-900 dark:text-slate-100 list-decimal pl-5 mb-4 space-y-1" {...props}>{children}</ol>
|
||||
),
|
||||
li: ({ children, ...props }) => (
|
||||
<li className="text-slate-900 dark:text-slate-100" {...props}>{children}</li>
|
||||
),
|
||||
blockquote: ({ children, ...props }) => (
|
||||
<blockquote className="border-l-4 border-slate-300 dark:border-slate-600 pl-4 italic text-slate-700 dark:text-slate-300 my-4" {...props}>{children}</blockquote>
|
||||
),
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="min-w-full divide-y divide-slate-300 dark:divide-slate-600" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children, ...props }) => (
|
||||
<thead className="bg-slate-50 dark:bg-slate-800" {...props}>{children}</thead>
|
||||
),
|
||||
tbody: ({ children, ...props }) => (
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-slate-700" {...props}>{children}</tbody>
|
||||
),
|
||||
tr: ({ children, ...props }) => (
|
||||
<tr {...props}>{children}</tr>
|
||||
),
|
||||
th: ({ children, ...props }) => (
|
||||
<th className="px-3 py-2 text-left text-sm font-semibold text-slate-900 dark:text-slate-100" {...props}>{children}</th>
|
||||
),
|
||||
td: ({ children, ...props }) => (
|
||||
<td className="px-3 py-2 text-sm text-slate-900 dark:text-slate-100" {...props}>{children}</td>
|
||||
),
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<img src={src} alt={alt} className="max-w-full h-auto rounded-lg my-4" {...props} />
|
||||
),
|
||||
hr: (props) => (
|
||||
<hr className="border-t border-slate-300 dark:border-slate-600 my-8" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 text-xs text-slate-500 dark:text-slate-400 border-t border-slate-300 dark:border-slate-600">
|
||||
{t('editor.dropToUpload')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownEditor;
|
30
frontend/src/hooks/useToast.ts
Normal file
30
frontend/src/hooks/useToast.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useCallback } from 'react';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
const darkModeToastStyle: ToastOptions = {
|
||||
theme: 'dark',
|
||||
style: {
|
||||
background: '#1e293b', // slate-800
|
||||
color: '#f1f5f9', // slate-100
|
||||
},
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
|
||||
const showToast = useCallback((type: ToastType, message: string) => {
|
||||
toast[type](message, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 3000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
...(isDarkMode ? darkModeToastStyle : {}),
|
||||
});
|
||||
}, [isDarkMode]);
|
||||
|
||||
return { showToast };
|
||||
};
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useNavigate, useParams, useBlocker } from 'react-router-dom';
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner';
|
||||
import { usePageTitle } from '../../../contexts/PageTitleContext';
|
||||
import MarkdownEditor from '../../../components/admin/MarkdownEditor';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface PostContent {
|
||||
|
@ -218,11 +219,10 @@ const PostEditor: FC = () => {
|
|||
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||
{t('admin.posts.content')}
|
||||
</label>
|
||||
<textarea
|
||||
<MarkdownEditor
|
||||
value={activeContent.content_markdown}
|
||||
onChange={e => updateContent({ content_markdown: e.target.value })}
|
||||
rows={10}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400"
|
||||
onChange={(value) => updateContent({ content_markdown: value })}
|
||||
placeholder={t('admin.posts.content')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
|
@ -8,6 +9,11 @@ export default defineConfig({
|
|||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
|
|
1119
pnpm-lock.yaml
generated
1119
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue