From 499bc3edd5d3fabb5868200766f15a19947d32b6 Mon Sep 17 00:00:00 2001
From: Link <131011403+LinkF2kkk@users.noreply.github.com>
Date: Tue, 21 Apr 2026 11:27:05 +0300
Subject: [PATCH 1/2] TITANIC
---
client/src/App.css | 45 +--
client/src/App.js | 75 +++--
client/src/components/Editor.jsx | 135 +++-----
client/src/components/EditorToolbar.jsx | 268 ---------------
.../EditorToolbar/ToolbarButton.jsx | 32 ++
.../EditorToolbar/ToolbarDivider.jsx | 14 +
.../EditorToolbar/ToolbarGroups.jsx | 114 +++++++
.../EditorToolbar/ToolbarSelect.jsx | 28 ++
client/src/components/EditorToolbar/index.jsx | 136 ++++++++
client/src/components/ReportModal.jsx | 314 +++++++++---------
client/src/components/SidePanel.jsx | 241 ++++++++++++++
client/src/contexts/ThemeContext.js | 30 ++
client/src/hooks/useTheme.js | 12 -
client/src/index.js | 1 -
client/src/styles/theme.js | 65 ++++
15 files changed, 938 insertions(+), 572 deletions(-)
delete mode 100644 client/src/components/EditorToolbar.jsx
create mode 100644 client/src/components/EditorToolbar/ToolbarButton.jsx
create mode 100644 client/src/components/EditorToolbar/ToolbarDivider.jsx
create mode 100644 client/src/components/EditorToolbar/ToolbarGroups.jsx
create mode 100644 client/src/components/EditorToolbar/ToolbarSelect.jsx
create mode 100644 client/src/components/EditorToolbar/index.jsx
create mode 100644 client/src/components/SidePanel.jsx
create mode 100644 client/src/contexts/ThemeContext.js
delete mode 100644 client/src/hooks/useTheme.js
create mode 100644 client/src/styles/theme.js
diff --git a/client/src/App.css b/client/src/App.css
index 74b5e05..e4cfeec 100644
--- a/client/src/App.css
+++ b/client/src/App.css
@@ -1,38 +1,27 @@
-.App {
- text-align: center;
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
}
-.App-logo {
- height: 40vmin;
- pointer-events: none;
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ transition: background-color 0.3s, color 0.3s;
}
-@media (prefers-reduced-motion: no-preference) {
- .App-logo {
- animation: App-logo-spin infinite 20s linear;
- }
+::-webkit-scrollbar {
+ width: 6px;
}
-.App-header {
- background-color: #282c34;
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- font-size: calc(10px + 2vmin);
- color: white;
+::-webkit-scrollbar-track {
+ background: transparent;
}
-.App-link {
- color: #61dafb;
+::-webkit-scrollbar-thumb {
+ background: rgba(91, 155, 213, 0.3);
+ border-radius: 3px;
}
-@keyframes App-logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
+::-webkit-scrollbar-thumb:hover {
+ background: rgba(91, 155, 213, 0.5);
+}
\ No newline at end of file
diff --git a/client/src/App.js b/client/src/App.js
index d3a9bab..b1d7dc3 100644
--- a/client/src/App.js
+++ b/client/src/App.js
@@ -1,14 +1,19 @@
-import { useState } from 'react'
-import Editor from './components/Editor'
-import ReportModal from './components/ReportModal'
-import { useAnalysis } from './hooks/useAnalysis'
-import { useTheme } from './hooks/useTheme'
+import { useState, useRef, useCallback } from 'react'
+import { ThemeProvider } from './contexts/ThemeContext'
+import Editor from './components/Editor'
+import SidePanel from './components/SidePanel'
+import ReportModal from './components/ReportModal'
+import { useAnalysis } from './hooks/useAnalysis'
-export default function App() {
+function AppContent() {
const { result, loading, error, analyze, exportReport, reset } = useAnalysis()
- const { isDark, toggle } = useTheme()
- const [showReport, setShowReport] = useState(false)
- const [editorText, setEditorText] = useState('')
+ const [showReport, setShowReport] = useState(false)
+ const [editorText, setEditorText] = useState('')
+ const [detectedLang, setDetectedLang] = useState('ru')
+
+ // Refs для вызова функций редактора из боковой панели
+ const analyzeRef = useRef(null)
+ const clearRef = useRef(null)
const handleAnalyze = async (text, language) => {
setEditorText(text)
@@ -19,29 +24,53 @@ export default function App() {
exportReport(format, editorText, result)
}
+ const triggerAnalyze = useCallback(() => {
+ analyzeRef.current?.()
+ }, [])
+
+ const triggerClear = useCallback(() => {
+ clearRef.current?.()
+ reset()
+ }, [reset])
+
return (
-
-
- {/* Тулбар */}
+
- {/* Рабочая область */}
- {/* Лист A4 */}
- setPopup(null)} isDark={isDark} />
+ setPopup(null)} />
-
- {/* Нижняя панель */}
-
-
- {LANG_LABELS[detectedLang]}
-
-
-
-
- {issues?.length > 0 && (
-
- {[...new Set(issues.map(i => i.category))].map(cat => (
-
- {cat}
-
- ))}
-
- )}
-
)
}
\ No newline at end of file
diff --git a/client/src/components/EditorToolbar.jsx b/client/src/components/EditorToolbar.jsx
deleted file mode 100644
index 248dbdd..0000000
--- a/client/src/components/EditorToolbar.jsx
+++ /dev/null
@@ -1,268 +0,0 @@
-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, onImport }) {
-
- // Хуки идут первыми — до любых return и условий!
- const setFontFamily = useCallback((font) => {
- editor?.chain().focus().setFontFamily(font).run()
- }, [editor])
-
- const setFontSize = useCallback((size) => {
- editor?.chain().focus().setFontSize(`${size}px`).run()
- }, [editor])
-
- const handleImport = useCallback(async (e) => {
- const file = e.target.files?.[0]
- if (!file) return
-
- const formData = new FormData()
- formData.append('file', file)
-
- try {
- const response = await fetch('https://localhost:7076/import/docx', {
- method: 'POST',
- body: formData
- })
- if (!response.ok) throw new Error('Ошибка импорта')
- const data = await response.json()
- onImport(data.text)
- } catch (err) {
- console.error('Ошибка импорта:', err)
- }
-
- e.target.value = ''
- }, [onImport])
-
- // Только после всех хуков
- 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)">↪
-
-
-
- {/* Импорт DOCX */}
-
-
-
-
- {/* Шрифт */}
-
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/EditorToolbar/ToolbarButton.jsx b/client/src/components/EditorToolbar/ToolbarButton.jsx
new file mode 100644
index 0000000..1730a9a
--- /dev/null
+++ b/client/src/components/EditorToolbar/ToolbarButton.jsx
@@ -0,0 +1,32 @@
+import { useTheme } from '../../contexts/ThemeContext'
+
+export default function ToolbarButton({ onClick, active, disabled, title, children }) {
+ const { theme } = useTheme()
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/EditorToolbar/ToolbarDivider.jsx b/client/src/components/EditorToolbar/ToolbarDivider.jsx
new file mode 100644
index 0000000..af62381
--- /dev/null
+++ b/client/src/components/EditorToolbar/ToolbarDivider.jsx
@@ -0,0 +1,14 @@
+import { useTheme } from '../../contexts/ThemeContext'
+
+export default function ToolbarDivider() {
+ const { theme } = useTheme()
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/EditorToolbar/ToolbarGroups.jsx b/client/src/components/EditorToolbar/ToolbarGroups.jsx
new file mode 100644
index 0000000..0415623
--- /dev/null
+++ b/client/src/components/EditorToolbar/ToolbarGroups.jsx
@@ -0,0 +1,114 @@
+import ToolbarButton from './ToolbarButton'
+import ToolbarSelect from './ToolbarSelect'
+import ToolbarDivider from './ToolbarDivider'
+import { useTheme } from '../../contexts/ThemeContext'
+
+const FONTS = ['Arial', 'Times New Roman', 'Courier New', 'Georgia', 'Verdana']
+const SIZES = ['10', '12', '14', '16', '18', '20', '24', '28', '32', '36', '48']
+
+export function HistoryGroup({ editor }) {
+ 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)">↪
+
+ >
+ )
+}
+
+export function FontGroup({ editor }) {
+ return (
+ <>
+
editor.chain().focus().setFontFamily(e.target.value).run()} defaultValue="Arial">
+ {FONTS.map(f => )}
+
+
+
editor.chain().focus().setFontSize(`${e.target.value}px`).run()} defaultValue="14" width="58px">
+ {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'
+ }
+ width="140px"
+ >
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export function FormattingGroup({ editor }) {
+ return (
+ <>
+
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
+
+ >
+ )
+}
+
+export function ColorGroup({ editor }) {
+ const { theme } = useTheme()
+ return (
+ <>
+
+ A
+ editor.chain().focus().setColor(e.target.value).run()}
+ style={{ width: '20px', height: '20px', border: 'none', cursor: 'pointer', borderRadius: '3px', padding: 0 }}
+ />
+
+
+ >
+ )
+}
+
+export function ListGroup({ editor }) {
+ return (
+ <>
+
editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Маркированный список">☰
+
editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Нумерованный список">≡
+
+ >
+ )
+}
+
+export function AlignGroup({ editor }) {
+ return (
+ <>
+ {[
+ { 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}
+ ))}
+ >
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/EditorToolbar/ToolbarSelect.jsx b/client/src/components/EditorToolbar/ToolbarSelect.jsx
new file mode 100644
index 0000000..946955a
--- /dev/null
+++ b/client/src/components/EditorToolbar/ToolbarSelect.jsx
@@ -0,0 +1,28 @@
+import { useTheme } from '../../contexts/ThemeContext'
+
+export default function ToolbarSelect({ onChange, value, defaultValue, children, width }) {
+ const { theme } = useTheme()
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/EditorToolbar/index.jsx b/client/src/components/EditorToolbar/index.jsx
new file mode 100644
index 0000000..0d1e670
--- /dev/null
+++ b/client/src/components/EditorToolbar/index.jsx
@@ -0,0 +1,136 @@
+import { useCallback } from 'react'
+import { useTheme } from '../../contexts/ThemeContext'
+import {
+ HistoryGroup,
+ FontGroup,
+ FormattingGroup,
+ ColorGroup,
+ ListGroup,
+ AlignGroup
+} from './ToolbarGroups'
+import ToolbarDivider from './ToolbarDivider'
+
+export default function EditorToolbar({ editor, wordCount, onImport }) {
+ const { theme, isDark, toggle } = useTheme()
+
+ const handleImport = useCallback(async (e) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ const formData = new FormData()
+ formData.append('file', file)
+
+ try {
+ const response = await fetch('https://localhost:7076/import/docx', {
+ method: 'POST',
+ body: formData
+ })
+ if (!response.ok) throw new Error('Ошибка импорта')
+ const data = await response.json()
+ onImport?.(data.text)
+ } catch (err) {
+ console.error('Ошибка импорта:', err)
+ }
+ e.target.value = ''
+ }, [onImport])
+
+ if (!editor) return null
+
+ return (
+
+
+ {/* Импорт */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Правая часть */}
+
+
+
+ {wordCount} слов
+
+
+ {/* Переключатель темы */}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/ReportModal.jsx b/client/src/components/ReportModal.jsx
index 4da4b16..b36f86c 100644
--- a/client/src/components/ReportModal.jsx
+++ b/client/src/components/ReportModal.jsx
@@ -1,19 +1,12 @@
-import { useState } from 'react'
+import { useState } from 'react'
+import { useTheme } from '../contexts/ThemeContext'
import { CATEGORY_COLORS } from '../constants/categories'
-const CATEGORY_ICONS = {
- 'Орфография': '🔤',
- 'Пунктуация': '✍️',
- 'Грамматика': '📖',
- 'Тавтология': '🔁',
- 'Стиль': '🎨',
- 'Канцеляризм': '📋',
-}
-
-function HighlightedText({ text, issues }) {
- if (!text || !issues?.length) return
{text}
+function HighlightedText({ text, issues, theme }) {
+ if (!text || !issues?.length) return (
+
{text}
+ )
- // Собираем все вхождения с их индексами
const marks = []
issues.forEach((issue, issueIdx) => {
if (!issue.original) return
@@ -21,90 +14,67 @@ function HighlightedText({ text, issues }) {
while (true) {
const idx = text.indexOf(issue.original, start)
if (idx === -1) break
- marks.push({
- from: idx,
- to: idx + issue.original.length,
- color: CATEGORY_COLORS[issue.category] || '#E2E8F0',
- issueIdx
- })
+ marks.push({ from: idx, to: idx + issue.original.length, color: CATEGORY_COLORS[issue.category] || '#E2E8F0', issueIdx })
start = idx + 1
}
})
- // Сортируем по позиции
marks.sort((a, b) => a.from - b.from)
- // Строим части текста
const parts = []
let cursor = 0
marks.forEach(({ from, to, color, issueIdx }) => {
- if (from < cursor) return // перекрытие — пропускаем
- if (cursor < from) {
- parts.push(
-
{text.slice(cursor, from)}
- )
- }
+ if (from < cursor) return
+ if (cursor < from) parts.push(
{text.slice(cursor, from)})
parts.push(
-
+
{text.slice(from, to)}
)
cursor = to
})
- if (cursor < text.length) {
- parts.push({text.slice(cursor)})
- }
+ if (cursor < text.length) parts.push({text.slice(cursor)})
return (
-
+
{parts}
)
}
-export default function ReportModal({ result, text, language, onClose, onExport }) {
+export default function ReportModal({ result, text, onClose, onExport }) {
+ const { theme } = useTheme()
const [selectedFormat, setSelectedFormat] = useState('docx')
const [activeCategory, setActiveCategory] = useState(null)
if (!result) return null
- const issues = result.issues || []
- const stats = result.statistics || {}
- const grouped = issues.reduce((acc, issue) => {
+ const issues = result.issues || []
+ const stats = result.statistics || {}
+ const grouped = issues.reduce((acc, issue) => {
if (!acc[issue.category]) acc[issue.category] = []
acc[issue.category].push(issue)
return acc
}, {})
- const categories = Object.keys(grouped)
- const filteredIssues = activeCategory
- ? grouped[activeCategory] || []
- : issues
+ const categories = Object.keys(grouped)
+ const filteredIssues = activeCategory ? grouped[activeCategory] || [] : issues
return (
- // Затемнённый фон
e.target === e.currentTarget && onClose()}
style={{
position: 'fixed',
inset: 0,
- backgroundColor: 'rgba(0,0,0,0.6)',
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ backdropFilter: 'blur(4px)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
@@ -112,17 +82,17 @@ export default function ReportModal({ result, text, language, onClose, onExport
padding: '24px'
}}
>
- {/* Основное окно */}
{/* Шапка */}
@@ -131,26 +101,32 @@ export default function ReportModal({ result, text, language, onClose, onExport
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 24px',
- backgroundColor: '#fff',
- borderBottom: '1px solid #e2e8f0'
+ backgroundColor: theme.toolbar,
+ backdropFilter: theme.blur,
+ borderBottom: `1px solid ${theme.border}`
}}>
-
+
Отчёт анализа текста
-
- Найдено проблем: {issues.length} · Слов: {stats.wordCount} · Предложений: {stats.sentenceCount}
+
+ Проблем: {issues.length} · Слов: {stats.wordCount} · Предложений: {stats.sentenceCount}
@@ -158,19 +134,19 @@ export default function ReportModal({ result, text, language, onClose, onExport
{/* Три колонки */}
- {/* ===== Левая панель — список ошибок ===== */}
+ {/* Левая — ошибки */}
- {/* Фильтр по категории */}
+ {/* Фильтры */}
Все ({issues.length})
@@ -196,91 +173,116 @@ export default function ReportModal({ result, text, language, onClose, onExport
style={{
padding: '3px 10px',
borderRadius: '999px',
- border: 'none',
+ border: `1px solid ${activeCategory === cat ? theme.borderStrong : theme.border}`,
cursor: 'pointer',
- fontSize: '12px',
- backgroundColor: activeCategory === cat
- ? CATEGORY_COLORS[cat]
- : '#f1f5f9',
- color: '#1e293b'
+ fontSize: '11px',
+ backgroundColor: activeCategory === cat ? CATEGORY_COLORS[cat] : 'transparent',
+ color: theme.textPrimary
}}
>
- {CATEGORY_ICONS[cat]} {cat} ({grouped[cat].length})
+ {cat} ({grouped[cat].length})
))}
- {/* Список ошибок */}
+ {/* Список */}
{filteredIssues.map((issue, i) => (
-
+
-
-
- {issue.category}
-
-
-
+ display: 'inline-block'
+ }}>
+ {issue.category}
+
+
{issue.description}
-
-
- ✗ {issue.original}
-
-
- ✓ {issue.suggestion}
-
+
+
✗ {issue.original}
+
✓ {issue.suggestion}
))}
- {/* ===== Центральная часть — текст с подсветкой ===== */}
+ {/* Центр — предпросмотр */}
-
- ПРЕДПРОСМОТР ТЕКСТА
-
-
+
+
+ Предпросмотр текста
+
+
+
- {/* ===== Правая панель — экспорт ===== */}
+ {/* Правая — экспорт */}
+ {/* Статистика */}
+
+
+ Статистика
+
+
+ {[
+ { label: 'Слов', value: stats.wordCount },
+ { label: 'Предл.', value: stats.sentenceCount },
+ { label: 'Индекс', value: stats.readabilityScore },
+ { label: 'Вода', value: `${stats.waterScore}%` },
+ ].map((s, i) => (
+
+
{s.value ?? '—'}
+
{s.label}
+
+ ))}
+
+
+
+ {/* Экспорт */}
-
- ЭКСПОРТ
-
+
+ Экспорт
+
- {/* Выбор формата */}
-
+
{[
{ format: 'docx', label: 'Word (.docx)', icon: '📄', color: '#2563eb' },
{ format: 'pdf', label: 'PDF (.pdf)', icon: '📕', color: '#dc2626' },
@@ -293,9 +295,9 @@ export default function ReportModal({ result, text, language, onClose, onExport
gap: '10px',
padding: '10px 12px',
borderRadius: '8px',
- border: `2px solid ${selectedFormat === format ? color : '#e2e8f0'}`,
+ border: `2px solid ${selectedFormat === format ? color : theme.border}`,
cursor: 'pointer',
- backgroundColor: selectedFormat === format ? `${color}11` : '#fff',
+ backgroundColor: selectedFormat === format ? `${color}11` : 'transparent',
transition: 'all 0.15s'
}}
>
@@ -307,50 +309,48 @@ export default function ReportModal({ result, text, language, onClose, onExport
onChange={() => setSelectedFormat(format)}
style={{ display: 'none' }}
/>
- {icon}
-
- {label}
-
+ {icon}
+ {label}
))}
- {/* Кнопка скачать */}
- {/* Место для будущего функционала */}
+ {/* Место под будущий функционал */}
- Здесь будет дополнительный функционал
+ Дополнительный функционал
-
diff --git a/client/src/components/SidePanel.jsx b/client/src/components/SidePanel.jsx
new file mode 100644
index 0000000..7640c82
--- /dev/null
+++ b/client/src/components/SidePanel.jsx
@@ -0,0 +1,241 @@
+import { useState } from 'react'
+import { useTheme } from '../contexts/ThemeContext'
+import { CATEGORY_COLORS, LANG_LABELS } from '../constants/categories'
+
+export default function SidePanel({ loading, hasResult, result, detectedLang, onAnalyze, onClear, onOpenReport }) {
+ const { theme } = useTheme()
+ const [open, setOpen] = useState(true)
+
+ const issues = result?.issues || []
+
+ const panelWidth = open ? '260px' : '0px'
+
+ return (
+ <>
+ {/* Кнопка выдвижения */}
+
+
+ {/* Сама панель */}
+
+
+ {open && (
+ <>
+ {/* Заголовок панели */}
+
+
+ Анализ текста
+
+
+ {LANG_LABELS[detectedLang]}
+
+
+
+ {/* Кнопки */}
+
+
+
+
+
+
+ {hasResult && (
+
+ )}
+
+
+
+ {/* Статистика */}
+ {result && (
+
+
+ {[
+ { label: 'Слов', value: result.statistics?.wordCount },
+ { label: 'Предложений', value: result.statistics?.sentenceCount },
+ { label: 'Читаемость', value: result.statistics?.readabilityScore },
+ { label: 'Водность', value: `${result.statistics?.waterScore}%` },
+ ].map((s, i) => (
+
+
+ {s.value ?? '—'}
+
+
+ {s.label}
+
+
+ ))}
+
+
+ )}
+
+ {/* Список проблем */}
+
+ {issues.length === 0 && result && (
+
+ Проблем не найдено!
+
+ )}
+
+ {issues.length > 0 && (
+ <>
+ {/* Легенда категорий */}
+
+ {[...new Set(issues.map(i => i.category))].map(cat => (
+
+ {cat} ({issues.filter(i => i.category === cat).length})
+
+ ))}
+
+
+ {/* Карточки ошибок */}
+ {issues.map((issue, i) => (
+
+
+ {issue.category}
+
+
+ {issue.description}
+
+
+
✗ {issue.original}
+
✓ {issue.suggestion}
+
+
+ ))}
+ >
+ )}
+
+ >
+ )}
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/client/src/contexts/ThemeContext.js b/client/src/contexts/ThemeContext.js
new file mode 100644
index 0000000..266d003
--- /dev/null
+++ b/client/src/contexts/ThemeContext.js
@@ -0,0 +1,30 @@
+import { createContext, useContext, useState, useEffect } from 'react'
+import { lightTheme, darkTheme } from '../styles/theme'
+
+const ThemeContext = createContext(null)
+
+export function ThemeProvider({ children }) {
+ const [isDark, setIsDark] = useState(false)
+
+ const theme = isDark ? darkTheme : lightTheme
+ const toggle = () => setIsDark(v => !v)
+
+ // Обновляем body
+ useEffect(() => {
+ document.body.style.backgroundColor = theme.workspace
+ document.body.style.color = theme.textPrimary
+ document.body.style.transition = 'background-color 0.3s, color 0.3s'
+ }, [isDark])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useTheme() {
+ const ctx = useContext(ThemeContext)
+ if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
+ return ctx
+}
\ No newline at end of file
diff --git a/client/src/hooks/useTheme.js b/client/src/hooks/useTheme.js
deleted file mode 100644
index deac797..0000000
--- a/client/src/hooks/useTheme.js
+++ /dev/null
@@ -1,12 +0,0 @@
-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/index.js b/client/src/index.js
index d563c0f..5d4fdcf 100644
--- a/client/src/index.js
+++ b/client/src/index.js
@@ -1,6 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
-import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
diff --git a/client/src/styles/theme.js b/client/src/styles/theme.js
new file mode 100644
index 0000000..51d895b
--- /dev/null
+++ b/client/src/styles/theme.js
@@ -0,0 +1,65 @@
+export const lightTheme = {
+ // Фон
+ workspace: '#EEF2F7',
+ sheet: '#FFFFFF',
+ toolbar: 'rgba(255, 255, 255, 0.75)',
+ sidePanel: 'rgba(255, 255, 255, 0.80)',
+
+ // Текст
+ textPrimary: '#1A2332',
+ textSecondary:'#64748B',
+ textMuted: '#94A3B8',
+
+ // Акцент
+ accent: '#5B9BD5',
+ accentHover: '#4A8AC4',
+ accentLight: '#EBF3FB',
+
+ // Границы
+ border: 'rgba(91, 155, 213, 0.2)',
+ borderStrong: 'rgba(91, 155, 213, 0.4)',
+
+ // Кнопки
+ btnSecondary: 'rgba(91, 155, 213, 0.08)',
+ btnHover: 'rgba(91, 155, 213, 0.15)',
+
+ // Тень
+ shadow: '0 2px 12px rgba(91, 155, 213, 0.12)',
+ shadowStrong: '0 8px 32px rgba(91, 155, 213, 0.18)',
+
+ // Blur
+ blur: 'blur(12px)',
+}
+
+export const darkTheme = {
+ // Фон
+ workspace: '#1A1F2E',
+ sheet: '#242938',
+ toolbar: 'rgba(28, 34, 50, 0.80)',
+ sidePanel: 'rgba(28, 34, 50, 0.85)',
+
+ // Текст
+ textPrimary: '#E8EDF5',
+ textSecondary:'#8896AA',
+ textMuted: '#566070',
+
+ // Акцент
+ accent: '#7DAFDF',
+ accentHover: '#6B9ECE',
+ accentLight: 'rgba(125, 175, 223, 0.12)',
+
+ // Границы
+ border: 'rgba(125, 175, 223, 0.15)',
+ borderStrong: 'rgba(125, 175, 223, 0.30)',
+
+ // Кнопки
+ btnSecondary: 'rgba(125, 175, 223, 0.08)',
+ btnHover: 'rgba(125, 175, 223, 0.15)',
+
+ // Тень
+ shadow: '0 2px 12px rgba(0, 0, 0, 0.3)',
+ shadowStrong: '0 8px 32px rgba(0, 0, 0, 0.4)',
+
+ // Blur
+ blur: 'blur(12px)',
+}
\ No newline at end of file
From e0def3bb234eb984ec0c97f908a54fcd9a510cf9 Mon Sep 17 00:00:00 2001
From: Link <131011403+LinkF2kkk@users.noreply.github.com>
Date: Tue, 21 Apr 2026 12:37:09 +0300
Subject: [PATCH 2/2] more bugs and editing AI settings (PAIN< EVIL<
BRAINDAMAGE)
---
Controllers/TextRequest.cs | 5 +-
Endpoints/AnalysisEndpoints.cs | 6 +-
Services/OpenAiService.cs | 101 ++++++++++++++--------------
Services/Promts/SystemPrompt.txt | 43 ++++++++++++
WebEditor.csproj | 6 ++
client/src/App.css | 6 ++
client/src/App.js | 14 ++--
client/src/components/Editor.jsx | 25 +++++--
client/src/components/SidePanel.jsx | 51 ++++++++++++--
client/src/hooks/useAnalysis.js | 26 +++++--
client/src/utils/highlight.js | 91 +++++++++++++++++++------
11 files changed, 276 insertions(+), 98 deletions(-)
create mode 100644 Services/Promts/SystemPrompt.txt
diff --git a/Controllers/TextRequest.cs b/Controllers/TextRequest.cs
index 0d90a7e..8906acf 100644
--- a/Controllers/TextRequest.cs
+++ b/Controllers/TextRequest.cs
@@ -1,4 +1,7 @@
namespace WebEditor.Controllers.Requests
{
- public record TextRequest(string Text, string? Language = "ru");
+ public record TextRequest(
+ string Text,
+ string? Language = "ru",
+ string? UserPrompt = null);
}
\ No newline at end of file
diff --git a/Endpoints/AnalysisEndpoints.cs b/Endpoints/AnalysisEndpoints.cs
index 2a6a1f2..d96a08b 100644
--- a/Endpoints/AnalysisEndpoints.cs
+++ b/Endpoints/AnalysisEndpoints.cs
@@ -12,7 +12,11 @@ public static void MapAnalysisEndpoints(this WebApplication app)
if (string.IsNullOrWhiteSpace(request.Text))
return Results.BadRequest("Текст не может быть пустым");
- var result = await openAi.AnalyzeTextAsync(request.Text, request.Language ?? "ru");
+ var result = await openAi.AnalyzeTextAsync(
+ request.Text,
+ request.Language ?? "ru",
+ request.UserPrompt // ← новое поле
+ );
return Results.Ok(result);
});
}
diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs
index 468e108..1942be5 100644
--- a/Services/OpenAiService.cs
+++ b/Services/OpenAiService.cs
@@ -9,6 +9,7 @@ public class OpenAiService
private readonly HttpClient _httpClient;
private readonly string _model;
private readonly ILogger
_logger;
+ private readonly string _systemPromptTemplate;
public OpenAiService(IConfiguration config, ILogger logger)
{
@@ -20,79 +21,73 @@ public OpenAiService(IConfiguration config, ILogger logger)
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", config["OpenAI:ApiKey"]);
_httpClient.Timeout = TimeSpan.FromSeconds(60);
+
+ // Читаем промпт из файла при старте
+ var promptPath = Path.Combine(AppContext.BaseDirectory, "Prompts", "SystemPrompt.txt");
+ _systemPromptTemplate = File.Exists(promptPath)
+ ? File.ReadAllText(promptPath)
+ : GetFallbackPrompt(); // если файл не найден — используем запасной
}
- public async Task AnalyzeTextAsync(string text, string language = "ru")
+ public async Task AnalyzeTextAsync(
+ string text,
+ string language = "ru",
+ string? userPrompt = null) // ← опциональный промпт от пользователя
{
var langInstruction = language switch
{
"ru" => "Текст на русском языке. Строго следуй правилам русской орфографии, пунктуации и грамматики.",
"en" => "Текст на английском языке. Строго следуй правилам английской орфографии и грамматики.",
- "mixed" => """
- Текст содержит русский и английский языки одновременно.
- Англицизмы которые стали общепринятыми (например: интернет, файл, сайт) — допустимы.
- Проверяй каждый язык по его собственным правилам.
- """,
+ "mixed" => "Текст содержит русский и английский языки. Общепринятые англицизмы допустимы. Проверяй каждый язык по его правилам.",
_ => "Определи язык текста самостоятельно и применяй соответствующие правила."
};
- var systemPrompt = $$$"""
- Ты — профессиональный редактор текста с глубоким знанием русского и английского языков.
-
- {langInstruction}
-
- Твоя задача:
- 1. Найти орфографические ошибки
- 2. Найти пунктуационные ошибки
- 3. Найти грамматические ошибки
- 4. Найти тавтологии и смысловые повторы
- 5. Найти стилистические проблемы
- 6. Найти канцеляризмы и штампы
- 7. Учитывать КОНТЕКСТ предложения — одинаково написанные слова могут иметь разный смысл
- 8. Посчитать статистику текста
-
- ВАЖНО: Верни ТОЛЬКО валидный JSON без пояснений, без markdown, без ```json.
- Формат ответа:
- {{
- "issues": [
- {{
- "category": "Орфография|Пунктуация|Грамматика|Тавтология|Стиль|Канцеляризм",
- "original": "исходный фрагмент из текста",
- "suggestion": "предлагаемая замена",
- "description": "краткое объяснение проблемы"
- }}
- ],
- "statistics": {{
- "wordCount": 0,
- "sentenceCount": 0,
- "readabilityScore": 0,
- "waterScore": 0
- }}
- }}
+ // Подставляем язык в шаблон
+ var systemPrompt = _systemPromptTemplate
+ .Replace("{LANG_INSTRUCTION}", langInstruction);
+
+ // Если пользователь задал свои требования — добавляем их
+ if (!string.IsNullOrWhiteSpace(userPrompt))
+ {
+ systemPrompt += $"""
+
+
+ ДОПОЛНИТЕЛЬНЫЕ ТРЕБОВАНИЯ ПОЛЬЗОВАТЕЛЯ:
+ {userPrompt}
+
+ Эти требования имеют приоритет над общими правилами.
""";
+ }
try
{
var requestBody = new
{
model = _model,
- temperature = 0.1, // TEMPERATURE MODELS (0.1 = focused and deterministic, 0.9 = creative and random)
+ temperature = 0.1,
messages = new[]
{
new { role = "system", content = systemPrompt },
- new { role = "user", content = text }
+ new {
+ role = "user",
+ content = $"""
+ Проанализируй текст. Помни: category должна быть СТРОГО одним словом из списка: Орфография, Пунктуация, Грамматика, Тавтология, Стиль, Канцеляризм.
+
+ ТЕКСТ:
+ {text}
+ """
+ }
}
};
var json = JsonSerializer.Serialize(requestBody);
var content = new StringContent(json, Encoding.UTF8, "application/json");
-
var response = await _httpClient.PostAsync("chat/completions", content);
var responseJson = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
- _logger.LogError("Groq API error: {StatusCode} {Body}", response.StatusCode, responseJson);
+ _logger.LogError("Groq error: {Code} {Body}", response.StatusCode, responseJson);
throw new Exception($"API вернул ошибку: {response.StatusCode}");
}
@@ -103,19 +98,27 @@ 8. Посчитать статистику текста
.GetProperty("content")
.GetString();
- // DELETE MARKDOWN
- resultText = resultText?
+ return resultText?
.Replace("```json", "")
.Replace("```", "")
- .Trim();
-
- return resultText ?? "{}";
+ .Trim() ?? "{}";
}
catch (Exception ex)
{
- _logger.LogError(ex, "Ошибка при обращении к Groq API");
+ _logger.LogError(ex, "Ошибка Groq API");
throw;
}
}
+
+ // Запасной промпт если файл не найден
+ private static string GetFallbackPrompt() => """
+ Ты — профессиональный редактор текста.
+ ЯЗЫК: {LANG_INSTRUCTION}
+ Найди все ошибки и верни ТОЛЬКО валидный JSON:
+ {
+ "issues": [{"category": "...", "original": "...", "suggestion": "...", "description": "..."}],
+ "statistics": {"wordCount": 0, "sentenceCount": 0, "readabilityScore": 0, "waterScore": 0}
+ }
+ """;
}
}
\ No newline at end of file
diff --git a/Services/Promts/SystemPrompt.txt b/Services/Promts/SystemPrompt.txt
new file mode 100644
index 0000000..4c4bdb1
--- /dev/null
+++ b/Services/Promts/SystemPrompt.txt
@@ -0,0 +1,43 @@
+Ты — профессиональный редактор текста. Анализируй строго и возвращай ТОЛЬКО валидный JSON.
+
+ЯЗЫК: {LANG_INSTRUCTION}
+
+КАТЕГОРИИ — используй СТРОГО одну из списка, никаких комбинаций:
+- Орфография — опечатки, неверное написание слов
+- Пунктуация — запятые, точки, тире, знаки препинания
+- Грамматика — согласование слов, падежи, глагольные формы
+- Тавтология — повтор одного слова или смысла рядом
+- Стиль — нарушение сочетаемости, смешение стилей, длинные предложения
+- Канцеляризм — бюрократические штампы, отглагольные существительные
+
+ПРАВИЛА:
+1. Каждая проблема — отдельный объект в массиве
+2. Если проблема затрагивает и орфографию и пунктуацию — создай ДВА отдельных объекта
+3. "category" — ТОЛЬКО одно слово из списка выше. Никаких "Орфография и пунктуация", "Орфография/Пунктуация" и подобного
+4. "original" — точный фрагмент из текста, который надо исправить
+5. "suggestion" — исправленный вариант
+6. "description" — краткое объяснение на русском, 1 предложение
+
+СТАТИСТИКА:
+- wordCount: количество слов
+- sentenceCount: количество предложений
+- readabilityScore: от 0 до 100, где 100 = очень легко читать
+- waterScore: процент воды и штампов от 0 до 100
+
+ФОРМАТ — верни ТОЛЬКО это, без markdown, без пояснений:
+{
+ "issues": [
+ {
+ "category": "Орфография",
+ "original": "точный фрагмент",
+ "suggestion": "исправление",
+ "description": "объяснение"
+ }
+ ],
+ "statistics": {
+ "wordCount": 0,
+ "sentenceCount": 0,
+ "readabilityScore": 0,
+ "waterScore": 0
+ }
+}
\ No newline at end of file
diff --git a/WebEditor.csproj b/WebEditor.csproj
index d8fadbe..57cedea 100644
--- a/WebEditor.csproj
+++ b/WebEditor.csproj
@@ -29,4 +29,10 @@
+
+
+ Always
+
+
+
diff --git a/client/src/App.css b/client/src/App.css
index e4cfeec..3097bb0 100644
--- a/client/src/App.css
+++ b/client/src/App.css
@@ -9,6 +9,12 @@ body {
transition: background-color 0.3s, color 0.3s;
}
+.issue-highlight {
+ border-radius: 3px !important;
+ padding: 1px 2px !important;
+ cursor: pointer !important;
+}
+
::-webkit-scrollbar {
width: 6px;
}
diff --git a/client/src/App.js b/client/src/App.js
index b1d7dc3..26c3a9b 100644
--- a/client/src/App.js
+++ b/client/src/App.js
@@ -15,19 +15,19 @@ function AppContent() {
const analyzeRef = useRef(null)
const clearRef = useRef(null)
- const handleAnalyze = async (text, language) => {
- setEditorText(text)
- return await analyze(text, language)
+ const handleAnalyze = async (text, language, userPrompt) => {
+ setEditorText(text)
+ return await analyze(text, language, userPrompt)
}
+ const triggerAnalyze = useCallback((userPrompt) => {
+ analyzeRef.current?.(userPrompt)
+ }, [])
+
const handleExport = (format) => {
exportReport(format, editorText, result)
}
- const triggerAnalyze = useCallback(() => {
- analyzeRef.current?.()
- }, [])
-
const triggerClear = useCallback(() => {
clearRef.current?.()
reset()
diff --git a/client/src/components/Editor.jsx b/client/src/components/Editor.jsx
index a507b57..5473e9d 100644
--- a/client/src/components/Editor.jsx
+++ b/client/src/components/Editor.jsx
@@ -51,11 +51,17 @@ export default function Editor({
}
})
- const handleAnalyze = useCallback(async () => {
- const text = editor?.getText()?.trim()
- if (!text) return
- const data = await onAnalyze(text, detectedLang)
- if (data?.issues) applyHighlights(editor, data.issues)
+ const handleAnalyze = useCallback(async (userPrompt) => {
+ const text = editor?.getText()?.trim()
+ if (!text) return
+
+ // Сначала очищаем старые подсветки
+ clearHighlights(editor)
+
+ const data = await onAnalyze(text, detectedLang, userPrompt)
+ if (data?.issues?.length > 0) {
+ applyHighlights(editor, data.issues)
+ }
}, [editor, detectedLang, onAnalyze])
const handleClear = useCallback(() => {
@@ -72,6 +78,15 @@ export default function Editor({
if (clearRef) clearRef.current = handleClear
}, [handleAnalyze, handleClear, analyzeRef, clearRef])
+ // Добавь этот useEffect после других хуков
+ useEffect(() => {
+ if (!editor) return
+ const editorDom = editorRef.current?.querySelector('.ProseMirror')
+ if (editorDom) {
+ editorDom.setAttribute('lang', detectedLang)
+ }
+ }, [detectedLang, editor])
+
const handleImport = useCallback((text) => {
if (!text || !editor) return
const html = text
diff --git a/client/src/components/SidePanel.jsx b/client/src/components/SidePanel.jsx
index 7640c82..c1bf9fb 100644
--- a/client/src/components/SidePanel.jsx
+++ b/client/src/components/SidePanel.jsx
@@ -1,10 +1,12 @@
import { useState } from 'react'
import { useTheme } from '../contexts/ThemeContext'
-import { CATEGORY_COLORS, LANG_LABELS } from '../constants/categories'
+import { CATEGORY_COLORS } from '../constants/categories'
export default function SidePanel({ loading, hasResult, result, detectedLang, onAnalyze, onClear, onOpenReport }) {
const { theme } = useTheme()
const [open, setOpen] = useState(true)
+ const [userPrompt, setUserPrompt] = useState('')
+ const [showPrompt, setShowPrompt] = useState(false)
const issues = result?.issues || []
@@ -68,15 +70,52 @@ export default function SidePanel({ loading, hasResult, result, detectedLang, on
Анализ текста
-
- {LANG_LABELS[detectedLang]}
-
- {/* Кнопки */}
+
+
+
+ {showPrompt && (
+
+