- {/* Язык */}
-
-
Редактор
+ {/* Тулбар */}
+
+
+ {/* Рабочая область */}
+
+ {/* Лист A4 */}
+
+
+ setPopup(null)} isDark={isDark} />
+
+
+
+ {/* Нижняя панель */}
+
{LANG_LABELS[detectedLang]}
-
- {/* Редактор */}
-
-
+
+
+ {issues?.length > 0 && (
+
+ {[...new Set(issues.map(i => i.category))].map(cat => (
+
+ {cat}
+
+ ))}
+
+ )}
-
- {/* Тулбар */}
-
-
- {/* Легенда */}
- {issues?.length > 0 && (
-
- {[...new Set(issues.map(i => i.category))].map(cat => (
-
- {cat}
-
- ))}
-
- )}
-
- {/* Popup */}
-
setPopup(null)} />
)
}
\ No newline at end of file
diff --git a/client/src/components/EditorToolbar.jsx b/client/src/components/EditorToolbar.jsx
new file mode 100644
index 0000000..7338771
--- /dev/null
+++ b/client/src/components/EditorToolbar.jsx
@@ -0,0 +1,213 @@
+import { useCallback } from 'react'
+
+const FONTS = ['Arial', 'Times New Roman', 'Courier New', 'Georgia', 'Verdana']
+const SIZES = ['10', '12', '14', '16', '18', '20', '24', '28', '32', '36', '48']
+
+function ToolbarButton({ onClick, active, disabled, title, children, isDark }) {
+ return (
+
+ )
+}
+
+function Divider() {
+ return
+}
+
+function StyledSelect({ onChange, value, defaultValue, children, width }) {
+ return (
+
+ )
+}
+
+export default function EditorToolbar({ editor, wordCount, isDark, onToggleTheme }) {
+ const setFontFamily = useCallback((font) => {
+ editor?.chain().focus().setFontFamily(font).run()
+ }, [editor])
+
+ const setFontSize = useCallback((size) => {
+ editor?.chain().focus().setFontSize(`${size}px`).run()
+ }, [editor])
+
+ if (!editor) return null
+
+ return (
+
+
+ {/* Отмена / Повтор */}
+
editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Отменить (Ctrl+Z)">↩
+
editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Повторить (Ctrl+Y)">↪
+
+
+
+ {/* Шрифт */}
+
setFontFamily(e.target.value)} defaultValue="Arial">
+ {FONTS.map(f => )}
+
+
+ {/* Размер */}
+
setFontSize(e.target.value)} defaultValue="14" width="56px">
+ {SIZES.map(s => )}
+
+
+
+
+ {/* Стиль абзаца */}
+
{
+ const val = e.target.value
+ if (val === 'p') editor.chain().focus().setParagraph().run()
+ else editor.chain().focus().setHeading({ level: parseInt(val) }).run()
+ }}
+ value={
+ editor.isActive('heading', { level: 1 }) ? '1' :
+ editor.isActive('heading', { level: 2 }) ? '2' :
+ editor.isActive('heading', { level: 3 }) ? '3' : 'p'
+ }
+ >
+
+
+
+
+
+
+
+
+ {/* Форматирование */}
+
editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Жирный (Ctrl+B)">B
+
editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Курсив (Ctrl+I)">I
+
editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Подчёркнутый (Ctrl+U)">U
+
editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Зачёркнутый">S
+
+
+
+ {/* Цвет текста */}
+
+ A
+ editor.chain().focus().setColor(e.target.value).run()}
+ style={{ width: '20px', height: '20px', border: 'none', cursor: 'pointer', borderRadius: '2px', padding: 0 }}
+ />
+
+
+
+
+ {/* Списки */}
+
editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Маркированный список">☰
+
editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Нумерованный список">≡
+
+
+
+ {/* Выравнивание */}
+ {[
+ { align: 'left', icon: '⬜', title: 'По левому краю' },
+ { align: 'center', icon: '▣', title: 'По центру' },
+ { align: 'right', icon: '⬛', title: 'По правому краю' },
+ { align: 'justify', icon: '▤', title: 'По ширине' },
+ ].map(({ align, icon, title }) => (
+
editor.chain().focus().setTextAlign(align).run()}
+ active={editor.isActive({ textAlign: align })}
+ title={title}
+ >{icon}
+ ))}
+
+ {/* Правая часть */}
+
+
+ {/* Счётчик слов */}
+
+ {wordCount} слов
+
+
+ {/* Тёмная тема */}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/IssuePopup.jsx b/client/src/components/IssuePopup.jsx
index 44a0472..ca49c71 100644
--- a/client/src/components/IssuePopup.jsx
+++ b/client/src/components/IssuePopup.jsx
@@ -1,9 +1,9 @@
import { CATEGORY_COLORS } from '../constants/categories'
-export default function IssuePopup({ popup, onClose }) {
+export default function IssuePopup({ popup, onClose, isDark }) {
if (!popup) return null
- const colors = CATEGORY_COLORS[popup.issue.category] || '#E2E8F0'
+ const color = CATEGORY_COLORS[popup.issue.category] || '#E2E8F0'
return (
-
+
{popup.issue.description}
-
- ✗ {popup.issue.original}
-
-
- ✓ {popup.issue.suggestion}
-
+
✗ {popup.issue.original}
+
✓ {popup.issue.suggestion}
)
diff --git a/client/src/components/Toolbar.jsx b/client/src/components/Toolbar.jsx
index 401e837..634cf97 100644
--- a/client/src/components/Toolbar.jsx
+++ b/client/src/components/Toolbar.jsx
@@ -1,105 +1,49 @@
-import { useState } from 'react'
-
-export default function Toolbar({ loading, hasResult, onAnalyze, onClear, onOpenReport, onExport }) {
- const [showExportMenu, setShowExportMenu] = useState(false)
+export default function Toolbar({ loading, hasResult, onAnalyze, onClear, onOpenReport, isDark }) {
+ const btn = (bg, color, extra = {}) => ({
+ padding: '8px 16px',
+ backgroundColor: bg,
+ color,
+ border: 'none',
+ borderRadius: '8px',
+ cursor: 'pointer',
+ fontSize: '14px',
+ fontWeight: 500,
+ ...extra
+ })
return (
-
-
- {/* Анализировать */}
+
- {/* Очистить */}
- {/* Кнопка отчета — только если есть результат */}
{hasResult && (
)}
-
- {/* Выпадающее меню экспорта */}
- {showExportMenu && (
-
- {[
- { format: 'docx', label: '📄 Word (.docx)', color: '#2563eb' },
- { format: 'pdf', label: '📕 PDF (.pdf)', color: '#dc2626' },
- ].map(({ format, label, color }) => (
-
- ))}
-
- )}
)
}
\ No newline at end of file
diff --git a/client/src/hooks/useAnalysis.js b/client/src/hooks/useAnalysis.js
index 90e1830..1084195 100644
--- a/client/src/hooks/useAnalysis.js
+++ b/client/src/hooks/useAnalysis.js
@@ -23,20 +23,16 @@ export function useAnalysis() {
setError('Текст слишком короткий')
return null
}
-
setLoading(true)
setError(null)
setResult(null)
-
try {
const response = await fetch(`${API_URL}/analyze`, {
- method: 'POST',
+ method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ text, language })
+ body: JSON.stringify({ text, language })
})
-
if (!response.ok) throw new Error(`Ошибка сервера: ${response.status}`)
-
const raw = await response.json()
const data = typeof raw === 'string' ? JSON.parse(raw) : raw
setResult(data)
@@ -50,29 +46,26 @@ export function useAnalysis() {
}, [])
const exportReport = useCallback(async (format, text, analysisResult) => {
- try {
- const response = await fetch(`${API_URL}/export/${format}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- text: text,
- analysisJson: JSON.stringify(analysisResult) // весь объект с issues и statistics
- })
- })
-
- if (!response.ok) throw new Error(`Ошибка: ${response.status}`)
-
- const blob = await response.blob()
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `report.${format}`
- a.click()
- URL.revokeObjectURL(url)
-
- } catch (err) {
- setError(`Ошибка экспорта: ${err.message}`)
- }
+ try {
+ const response = await fetch(`${API_URL}/export/${format}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ text,
+ analysisJson: JSON.stringify(analysisResult)
+ })
+ })
+ if (!response.ok) throw new Error(`Ошибка: ${response.status}`)
+ const blob = await response.blob()
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `report.${format}`
+ a.click()
+ URL.revokeObjectURL(url)
+ } catch (err) {
+ setError(`Ошибка экспорта: ${err.message}`)
+ }
}, [])
const reset = useCallback(() => {
diff --git a/client/src/hooks/useTheme.js b/client/src/hooks/useTheme.js
new file mode 100644
index 0000000..deac797
--- /dev/null
+++ b/client/src/hooks/useTheme.js
@@ -0,0 +1,12 @@
+import { useState, useEffect } from 'react'
+
+export function useTheme() {
+ const [isDark, setIsDark] = useState(false)
+
+ useEffect(() => {
+ document.body.style.backgroundColor = isDark ? '#1e1e2e' : '#f0f0f0'
+ document.body.style.color = isDark ? '#e2e8f0' : '#1e293b'
+ }, [isDark])
+
+ return { isDark, toggle: () => setIsDark(v => !v) }
+}
\ No newline at end of file
diff --git a/client/src/utils/highlight.js b/client/src/utils/highlight.js
index 471261d..6fdb1b9 100644
--- a/client/src/utils/highlight.js
+++ b/client/src/utils/highlight.js
@@ -1,7 +1,7 @@
-import { Extension } from '@tiptap/core'
-import { Plugin, PluginKey } from 'prosemirror-state'
+import { Extension } from '@tiptap/core'
+import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
-import { CATEGORY_COLORS } from '../constants/categories'
+import { CATEGORY_COLORS } from '../constants/categories'
export const highlightKey = new PluginKey('issueHighlights')
@@ -12,7 +12,7 @@ export const HighlightExtension = Extension.create({
new Plugin({
key: highlightKey,
state: {
- init: () => DecorationSet.empty,
+ init: () => DecorationSet.empty,
apply(tr, old) {
const meta = tr.getMeta(highlightKey)
if (meta !== undefined) return meta
@@ -32,43 +32,38 @@ export const HighlightExtension = Extension.create({
export function findTextRanges(doc, searchText) {
const results = []
if (!searchText) return results
-
doc.descendants((node, pos) => {
if (!node.isText) return
- const text = node.text
let idx = 0
while (true) {
- const found = text.indexOf(searchText, idx)
+ const found = node.text.indexOf(searchText, idx)
if (found === -1) break
results.push({ from: pos + found, to: pos + found + searchText.length })
idx = found + 1
}
})
-
return results
}
export function applyHighlights(editor, issueList) {
const { state, view } = editor
- const decorations = []
+ const decorations = []
issueList.forEach((issue, issueIdx) => {
- const ranges = findTextRanges(state.doc, issue.original)
- const color = CATEGORY_COLORS[issue.category] || '#E2E8F0'
-
- ranges.forEach(({ from, to }) => {
+ const color = CATEGORY_COLORS[issue.category] || '#E2E8F0'
+ findTextRanges(state.doc, issue.original).forEach(({ from, to }) => {
decorations.push(
Decoration.inline(from, to, {
- style: `background-color: ${color}; border-radius: 3px; padding: 1px 2px; cursor: pointer;`,
- class: 'issue-highlight',
+ style: `background-color: ${color}; border-radius: 3px; padding: 1px 2px; cursor: pointer;`,
+ class: 'issue-highlight',
'data-issue-idx': String(issueIdx)
})
)
})
})
- const decoSet = DecorationSet.create(state.doc, decorations)
- view.dispatch(state.tr.setMeta(highlightKey, decoSet))
+ view.dispatch(state.tr.setMeta(highlightKey,
+ DecorationSet.create(state.doc, decorations)))
}
export function clearHighlights(editor) {