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 @@ public async Task AnalyzeTextAsync(string text, string language = "ru") .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 74b5e05..3097bb0 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1,38 +1,33 @@ -.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; - } +.issue-highlight { + border-radius: 3px !important; + padding: 1px 2px !important; + cursor: pointer !important; } -.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 { + width: 6px; } -.App-link { - color: #61dafb; +::-webkit-scrollbar-track { + background: transparent; } -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +::-webkit-scrollbar-thumb { + background: rgba(91, 155, 213, 0.3); + border-radius: 3px; } + +::-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..26c3a9b 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -1,47 +1,76 @@ -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) - 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 triggerClear = useCallback(() => { + clearRef.current?.() + reset() + }, [reset]) + return ( -
- +
+ setShowReport(true)} + onClear={reset} + onDetectLang={setDetectedLang} + analyzeRef={analyzeRef} + clearRef={clearRef} + /> +
+ + setShowReport(true)} - onClear={reset} - isDark={isDark} - onToggleTheme={toggle} /> {error && (
{error}
@@ -57,4 +86,12 @@ export default function App() { )}
) +} + +export default function App() { + return ( + + + + ) } \ No newline at end of file diff --git a/client/src/components/Editor.jsx b/client/src/components/Editor.jsx index f1ed487..5473e9d 100644 --- a/client/src/components/Editor.jsx +++ b/client/src/components/Editor.jsx @@ -3,16 +3,20 @@ 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 { useState, useCallback, useRef, useEffect } from 'react' -import { CATEGORY_COLORS, LANG_LABELS } from '../constants/categories' -import { HighlightExtension, applyHighlights, clearHighlights } from '../utils/highlight' -import { detectLanguage } from '../hooks/useAnalysis' +import { useTheme } from '../contexts/ThemeContext' +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, loading, issues, result, onOpenReport, onClear, isDark, onToggleTheme }) { +export default function Editor({ + onAnalyze, loading, issues, result, + onOpenReport, onClear, onDetectLang, + analyzeRef, clearRef +}) { + const { theme } = useTheme() const [detectedLang, setDetectedLang] = useState('ru') const [langTimer, setLangTimer] = useState(null) const [popup, setPopup] = useState(null) @@ -31,33 +35,68 @@ export default function Editor({ onAnalyze, loading, issues, result, onOpenRepor editorProps: { attributes: { spellcheck: 'true', - lang: detectedLang, - style: 'outline: none; min-height: 500px; padding: 60px; font-size: 14px; line-height: 1.8; font-family: Arial;' + style: 'outline: none; min-height: 700px; 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(text)), 1500) + const timer = setTimeout(() => { + const lang = detectLanguage(text) + setDetectedLang(lang) + onDetectLang?.(lang) + }, 1500) setLangTimer(timer) } }) - 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 = () => { + const handleClear = useCallback(() => { editor?.commands.setContent('

') clearHighlights(editor) setPopup(null) setWordCount(0) onClear() - } + }, [editor, onClear]) + + // Пробрасываем функции через refs — без рекурсии! + useEffect(() => { + if (analyzeRef) analyzeRef.current = handleAnalyze + 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 + .split('\n') + .filter(line => line.trim()) + .map(line => `

${line}

`) + .join('') + editor.commands.setContent(html) + setWordCount(text.trim().split(/\s+/).length) + }, [editor]) const handleClick = useCallback((e) => { const span = e.target.closest('.issue-highlight') @@ -74,40 +113,21 @@ export default function Editor({ onAnalyze, loading, issues, result, onOpenRepor }) }, [issues]) - const handleImport = useCallback((text) => { - if (!text || !editor) return - const html = text - .split('\n') - .filter(line => line.trim()) - .map(line => `

${line}

`) - .join('') - - editor.commands.setContent(html) - - // Обновляем счётчик слов - setWordCount(text.trim().split(/\s+/).length) - }, [editor]) - 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 */} -
- -
- 📄 Открыть 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..c1bf9fb --- /dev/null +++ b/client/src/components/SidePanel.jsx @@ -0,0 +1,280 @@ +import { useState } from 'react' +import { useTheme } from '../contexts/ThemeContext' +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 || [] + + const panelWidth = open ? '260px' : '0px' + + return ( + <> + {/* Кнопка выдвижения */} + + + {/* Сама панель */} +
+ + {open && ( + <> + {/* Заголовок панели */} +
+

+ Анализ текста +

+
+ +
+ + + {showPrompt && ( +