From e56e492eb6da4766f127357734aaef6fa8918723 Mon Sep 17 00:00:00 2001 From: Link <131011403+LinkF2kkk@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:04:43 +0300 Subject: [PATCH] Dark theme added --- client/src/App.js | 46 ++--- client/src/components/Editor.jsx | 172 +++++++++++-------- client/src/components/EditorToolbar.jsx | 213 ++++++++++++++++++++++++ client/src/components/IssuePopup.jsx | 34 ++-- client/src/components/Toolbar.jsx | 104 +++--------- client/src/hooks/useAnalysis.js | 51 +++--- client/src/hooks/useTheme.js | 12 ++ client/src/utils/highlight.js | 29 ++-- 8 files changed, 418 insertions(+), 243 deletions(-) create mode 100644 client/src/components/EditorToolbar.jsx create mode 100644 client/src/hooks/useTheme.js diff --git a/client/src/App.js b/client/src/App.js index 57d310f..d3a9bab 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -1,13 +1,14 @@ -import { useState } from 'react' -import Editor from './components/Editor' -import Results from './components/Results' -import ReportModal from './components/ReportModal' -import { useAnalysis } from './hooks/useAnalysis' +import { useState } from 'react' +import Editor from './components/Editor' +import ReportModal from './components/ReportModal' +import { useAnalysis } from './hooks/useAnalysis' +import { useTheme } from './hooks/useTheme' export default function App() { const { result, loading, error, analyze, exportReport, reset } = useAnalysis() - const [showReport, setShowReport] = useState(false) - const [editorText, setEditorText] = useState('') + const { isDark, toggle } = useTheme() + const [showReport, setShowReport] = useState(false) + const [editorText, setEditorText] = useState('') const handleAnalyze = async (text, language) => { setEditorText(text) @@ -15,15 +16,11 @@ export default function App() { } const handleExport = (format) => { - exportReport(format, editorText, result) + exportReport(format, editorText, result) } return ( -
-

- Text Analyzer -

- +
setShowReport(true)} onClear={reset} + isDark={isDark} + onToggleTheme={toggle} /> {error && (
{error}
)} - - {showReport && ( - setShowReport(false)} - onExport={handleExport} - /> + setShowReport(false)} + onExport={handleExport} + /> )}
) diff --git a/client/src/components/Editor.jsx b/client/src/components/Editor.jsx index 27e5dd3..720eb65 100644 --- a/client/src/components/Editor.jsx +++ b/client/src/components/Editor.jsx @@ -1,40 +1,49 @@ import { useEditor, EditorContent } from '@tiptap/react' -import StarterKit from '@tiptap/starter-kit' +import StarterKit from '@tiptap/starter-kit' +import Underline from '@tiptap/extension-underline' +import TextAlign from '@tiptap/extension-text-align' +import { TextStyleKit } from '@tiptap/extension-text-style' import { useState, useCallback, useRef } from 'react' -import { CATEGORY_COLORS, LANG_LABELS } from '../constants/categories' -import { HighlightExtension, applyHighlights, clearHighlights, highlightKey } from '../utils/highlight' -import { detectLanguage } from '../hooks/useAnalysis' -import { DecorationSet } from 'prosemirror-view' -import IssuePopup from './IssuePopup' -import Toolbar from './Toolbar' +import { CATEGORY_COLORS, LANG_LABELS } from '../constants/categories' +import { HighlightExtension, applyHighlights, clearHighlights } from '../utils/highlight' +import { detectLanguage } from '../hooks/useAnalysis' +import IssuePopup from './IssuePopup' +import Toolbar from './Toolbar' +import EditorToolbar from './EditorToolbar' -export default function Editor({ onAnalyze, onExport, loading, issues, result, onOpenReport }) { +export default function Editor({ onAnalyze, loading, issues, result, onOpenReport, onClear, isDark, onToggleTheme }) { const [detectedLang, setDetectedLang] = useState('ru') const [langTimer, setLangTimer] = useState(null) const [popup, setPopup] = useState(null) + const [wordCount, setWordCount] = useState(0) const editorRef = useRef(null) const editor = useEditor({ - extensions: [StarterKit, HighlightExtension], + extensions: [ + StarterKit, + Underline, + TextStyleKit, + TextAlign.configure({ types: ['heading', 'paragraph'] }), + HighlightExtension, + ], content: '

Введите текст для анализа...

', editorProps: { attributes: { spellcheck: 'true', lang: detectedLang, - style: 'outline: none; min-height: 250px;' + style: 'outline: none; min-height: 500px; padding: 60px; font-size: 14px; line-height: 1.8; font-family: Arial;' } }, onUpdate: ({ editor }) => { + const text = editor.getText() + setWordCount(text.trim() ? text.trim().split(/\s+/).length : 0) if (langTimer) clearTimeout(langTimer) - const timer = setTimeout(() => { - setDetectedLang(detectLanguage(editor.getText())) - }, 1500) + const timer = setTimeout(() => setDetectedLang(detectLanguage(text)), 1500) setLangTimer(timer) } }) - // Передаём текст и язык наверх для анализа const handleAnalyze = useCallback(async () => { const text = editor?.getText()?.trim() if (!text) return @@ -46,25 +55,18 @@ export default function Editor({ onAnalyze, onExport, loading, issues, result, o editor?.commands.setContent('

') clearHighlights(editor) setPopup(null) + setWordCount(0) + onClear() } - const handleExport = (format) => { - const text = editor?.getText()?.trim() - onExport(format, text, detectedLang) - } - - // Клик по подсветке const handleClick = useCallback((e) => { const span = e.target.closest('.issue-highlight') if (!span) { setPopup(null); return } - const idx = parseInt(span.dataset.issueIdx) const issue = issues?.[idx] if (!issue) return - const rect = span.getBoundingClientRect() const containerRect = editorRef.current?.getBoundingClientRect() - setPopup({ x: rect.left - (containerRect?.left || 0), y: rect.bottom - (containerRect?.top || 0) + 8, @@ -73,63 +75,87 @@ export default function Editor({ onAnalyze, onExport, loading, issues, result, o }, [issues]) return ( -
+
- {/* Язык */} -
- Редактор + {/* Тулбар */} + + + {/* Рабочая область */} +
+ {/* Лист 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) {