# План миграции NoteJS на React ## Анализ текущего приложения ### Архитектура (Vanilla JS) - **Backend**: Express.js (2634 строки) - оставляем без изменений - **Frontend**: - `app.js` (4887 строк) - основная логика заметок - `login.js` (~120 строк) - авторизация - `register.js` (~133 строк) - регистрация - `profile.js` (~799 строк) - профиль пользователя - `settings.js` (~1121 строк) - настройки + архив + логи + AI - `pwa.js` (~410 строк) - PWA менеджер - `sw.js` (~237 строк) - Service Worker - **Styles**: `style.css` (3721+ строк), `style-calendar.css` - **HTML**: 5 страниц (index, register, notes, profile, settings) ### Ключевые функции 1. Аутентификация (login/register) 2. CRUD операции заметок 3. Markdown редактор с панелью инструментов 4. Загрузка изображений и файлов 5. Календарь с фильтрацией по датам 6. Система тегов 7. Поиск по заметкам 8. Закрепление и архивирование заметок 9. AI помощник (улучшение текста) 10. Темная/светлая тема 11. PWA функциональность 12. Система уведомлений 13. Управление профилем (аватар, email, пароль) 14. Настройки (внешний вид, AI, архив, логи) 15. Кастомный accent color 16. Lazy loading изображений 17. Markdown preview с спойлерами 18. Внешние ссылки в PWA режиме --- ## Этап 1: Подготовка инфраструктуры React ### 1.1 Инициализация React проекта Создать новый React приложение в отдельной директории: ```bash npx create-react-app notejs-react --template typescript cd notejs-react ``` ### 1.2 Установка зависимостей ```bash # Роутинг npm install react-router-dom # Управление состоянием npm install @reduxjs/toolkit react-redux # HTTP клиент npm install axios # Markdown редактор и рендер npm install marked @uiw/react-md-editor npm install @codemirror/lang-markdown @codemirror/state @codemirror/view # Иконки npm install @iconify/react # Формы и валидация npm install react-hook-form zod @hookform/resolvers # Утилиты npm install clsx date-fns # PWA npm install workbox-webpack-plugin # TypeScript типы npm install -D @types/marked ``` ### 1.3 Структура директорий ``` src/ ├── api/ # API клиенты │ ├── axiosClient.ts │ ├── authApi.ts │ ├── notesApi.ts │ ├── userApi.ts │ └── aiApi.ts ├── components/ # Общие компоненты │ ├── common/ # UI компоненты │ │ ├── Button.tsx │ │ ├── Input.tsx │ │ ├── Modal.tsx │ │ ├── Notification.tsx │ │ ├── ThemeToggle.tsx │ │ ├── Loader.tsx │ │ └── Avatar.tsx │ ├── layout/ # Компоненты макета │ │ ├── Header.tsx │ │ ├── Sidebar.tsx │ │ ├── MobileSidebar.tsx │ │ └── Footer.tsx │ ├── notes/ # Компоненты заметок │ │ ├── NotesList.tsx │ │ ├── NoteItem.tsx │ │ ├── NoteEditor.tsx │ │ ├── MarkdownToolbar.tsx │ │ ├── NotePreview.tsx │ │ ├── ImageUpload.tsx │ │ ├── FileUpload.tsx │ │ └── ImageGallery.tsx │ ├── calendar/ # Компоненты календаря │ │ ├── MiniCalendar.tsx │ │ └── CalendarDay.tsx │ ├── search/ # Поиск и фильтры │ │ ├── SearchBar.tsx │ │ └── TagsFilter.tsx │ └── profile/ # Профиль │ ├── ProfileForm.tsx │ ├── AvatarUpload.tsx │ └── PasswordChange.tsx ├── pages/ # Страницы │ ├── LoginPage.tsx │ ├── RegisterPage.tsx │ ├── NotesPage.tsx │ ├── ProfilePage.tsx │ └── SettingsPage.tsx ├── store/ # Redux store │ ├── index.ts │ ├── slices/ │ │ ├── authSlice.ts │ │ ├── notesSlice.ts │ │ ├── uiSlice.ts │ │ ├── profileSlice.ts │ │ └── settingsSlice.ts │ └── hooks.ts ├── hooks/ # Кастомные хуки │ ├── useAuth.ts │ ├── useNotes.ts │ ├── useTheme.ts │ ├── useNotification.ts │ ├── useFileUpload.ts │ ├── useMarkdown.ts │ └── usePWA.ts ├── utils/ # Утилиты │ ├── markdown.ts │ ├── dateFormat.ts │ ├── validation.ts │ ├── storage.ts │ └── constants.ts ├── types/ # TypeScript типы │ ├── note.ts │ ├── user.ts │ ├── api.ts │ └── index.ts ├── styles/ # Стили │ ├── index.css │ ├── theme.css │ ├── components/ │ └── pages/ ├── App.tsx ├── index.tsx └── service-worker.ts ``` --- ## Этап 2: Базовая настройка и общие компоненты ### 2.1 Настройка Axios клиента `src/api/axiosClient.ts`: ```typescript import axios from "axios"; const axiosClient = axios.create({ baseURL: "/api", withCredentials: true, headers: { "Content-Type": "application/json", }, }); axiosClient.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { localStorage.removeItem("isAuthenticated"); window.location.href = "/"; } return Promise.reject(error); } ); export default axiosClient; ``` ### 2.2 TypeScript типы `src/types/note.ts`: ```typescript export interface Note { id: number; user_id: number; content: string; date: string; time: string; created_at: string; updated_at?: string; is_pinned: 0 | 1; is_archived: 0 | 1; pinned_at?: string; images: NoteImage[]; files: NoteFile[]; } export interface NoteImage { id: number; note_id: number; filename: string; original_name: string; file_path: string; file_size: number; mime_type: string; created_at: string; } export interface NoteFile { id: number; note_id: number; filename: string; original_name: string; file_path: string; file_size: number; mime_type: string; created_at: string; } ``` `src/types/user.ts`: ```typescript export interface User { username: string; email?: string; avatar?: string; accent_color: string; } export interface AuthResponse { authenticated: boolean; userId?: number; username?: string; } export interface AiSettings { openai_api_key: string; openai_base_url: string; openai_model: string; ai_enabled: 0 | 1; } ``` ### 2.3 Redux Store настройка `src/store/index.ts`: ```typescript import { configureStore } from "@reduxjs/toolkit"; import authReducer from "./slices/authSlice"; import notesReducer from "./slices/notesSlice"; import uiReducer from "./slices/uiSlice"; import profileReducer from "./slices/profileSlice"; export const store = configureStore({ reducer: { auth: authReducer, notes: notesReducer, ui: uiReducer, profile: profileReducer, }, }); export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; ``` `src/store/slices/authSlice.ts`: ```typescript import { createSlice, PayloadAction } from "@reduxjs/toolkit"; interface AuthState { isAuthenticated: boolean; userId: number | null; username: string | null; loading: boolean; } const initialState: AuthState = { isAuthenticated: localStorage.getItem("isAuthenticated") === "true", userId: null, username: localStorage.getItem("username"), loading: false, }; const authSlice = createSlice({ name: "auth", initialState, reducers: { setAuth: ( state, action: PayloadAction<{ userId: number; username: string }> ) => { state.isAuthenticated = true; state.userId = action.payload.userId; state.username = action.payload.username; localStorage.setItem("isAuthenticated", "true"); localStorage.setItem("username", action.payload.username); }, clearAuth: (state) => { state.isAuthenticated = false; state.userId = null; state.username = null; localStorage.removeItem("isAuthenticated"); localStorage.removeItem("username"); }, }, }); export const { setAuth, clearAuth } = authSlice.actions; export default authSlice.reducer; ``` `src/store/slices/notesSlice.ts`: ```typescript import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { Note } from "../../types/note"; interface NotesState { notes: Note[]; archivedNotes: Note[]; selectedDate: string | null; selectedTag: string | null; searchQuery: string; loading: boolean; editingNoteId: number | null; } const initialState: NotesState = { notes: [], archivedNotes: [], selectedDate: null, selectedTag: null, searchQuery: "", loading: false, editingNoteId: null, }; const notesSlice = createSlice({ name: "notes", initialState, reducers: { setNotes: (state, action: PayloadAction) => { state.notes = action.payload; }, addNote: (state, action: PayloadAction) => { state.notes.unshift(action.payload); }, updateNote: (state, action: PayloadAction) => { const index = state.notes.findIndex((n) => n.id === action.payload.id); if (index !== -1) { state.notes[index] = action.payload; } }, deleteNote: (state, action: PayloadAction) => { state.notes = state.notes.filter((n) => n.id !== action.payload); }, setSelectedDate: (state, action: PayloadAction) => { state.selectedDate = action.payload; }, setSelectedTag: (state, action: PayloadAction) => { state.selectedTag = action.payload; }, setSearchQuery: (state, action: PayloadAction) => { state.searchQuery = action.payload; }, setEditingNote: (state, action: PayloadAction) => { state.editingNoteId = action.payload; }, }, }); export const { setNotes, addNote, updateNote, deleteNote, setSelectedDate, setSelectedTag, setSearchQuery, setEditingNote, } = notesSlice.actions; export default notesSlice.reducer; ``` `src/store/slices/uiSlice.ts`: ```typescript import { createSlice, PayloadAction } from "@reduxjs/toolkit"; interface Notification { id: string; message: string; type: "info" | "success" | "error" | "warning"; } interface UiState { theme: "light" | "dark"; accentColor: string; notifications: Notification[]; isMobileSidebarOpen: boolean; isPreviewMode: boolean; } const getInitialTheme = (): "light" | "dark" => { const saved = localStorage.getItem("theme"); if (saved === "dark" || saved === "light") return saved; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; const initialState: UiState = { theme: getInitialTheme(), accentColor: localStorage.getItem("accentColor") || "#007bff", notifications: [], isMobileSidebarOpen: false, isPreviewMode: false, }; const uiSlice = createSlice({ name: "ui", initialState, reducers: { toggleTheme: (state) => { state.theme = state.theme === "light" ? "dark" : "light"; localStorage.setItem("theme", state.theme); }, setTheme: (state, action: PayloadAction<"light" | "dark">) => { state.theme = action.payload; localStorage.setItem("theme", state.theme); }, setAccentColor: (state, action: PayloadAction) => { state.accentColor = action.payload; localStorage.setItem("accentColor", action.payload); }, addNotification: ( state, action: PayloadAction> ) => { const id = `notification-${Date.now()}-${Math.random() .toString(36) .substr(2, 9)}`; state.notifications.push({ ...action.payload, id }); }, removeNotification: (state, action: PayloadAction) => { state.notifications = state.notifications.filter( (n) => n.id !== action.payload ); }, toggleMobileSidebar: (state) => { state.isMobileSidebarOpen = !state.isMobileSidebarOpen; }, closeMobileSidebar: (state) => { state.isMobileSidebarOpen = false; }, togglePreviewMode: (state) => { state.isPreviewMode = !state.isPreviewMode; }, }, }); export const { toggleTheme, setTheme, setAccentColor, addNotification, removeNotification, toggleMobileSidebar, closeMobileSidebar, togglePreviewMode, } = uiSlice.actions; export default uiSlice.reducer; ``` ### 2.4 Кастомные хуки `src/store/hooks.ts`: ```typescript import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import type { RootState, AppDispatch } from "./index"; export const useAppDispatch = () => useDispatch(); export const useAppSelector: TypedUseSelectorHook = useSelector; ``` `src/hooks/useNotification.ts`: ```typescript import { useCallback } from "react"; import { useAppDispatch } from "../store/hooks"; import { addNotification, removeNotification } from "../store/slices/uiSlice"; export const useNotification = () => { const dispatch = useAppDispatch(); const showNotification = useCallback( ( message: string, type: "info" | "success" | "error" | "warning" = "info" ) => { const id = dispatch(addNotification({ message, type })).payload.id; setTimeout(() => { dispatch(removeNotification(id)); }, 4000); }, [dispatch] ); return { showNotification }; }; ``` `src/hooks/useTheme.ts`: ```typescript import { useEffect } from "react"; import { useAppSelector, useAppDispatch } from "../store/hooks"; import { toggleTheme, setTheme } from "../store/slices/uiSlice"; export const useTheme = () => { const theme = useAppSelector((state) => state.ui.theme); const accentColor = useAppSelector((state) => state.ui.accentColor); const dispatch = useAppDispatch(); useEffect(() => { document.documentElement.setAttribute("data-theme", theme); document.documentElement.style.setProperty("--accent-color", accentColor); const themeColorMeta = document.querySelector('meta[name="theme-color"]'); if (themeColorMeta) { themeColorMeta.setAttribute( "content", theme === "dark" ? "#1a1a1a" : "#007bff" ); } }, [theme, accentColor]); useEffect(() => { const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handler = (e: MediaQueryListEvent) => { if (!localStorage.getItem("theme")) { dispatch(setTheme(e.matches ? "dark" : "light")); } }; mediaQuery.addEventListener("change", handler); return () => mediaQuery.removeEventListener("change", handler); }, [dispatch]); return { theme, accentColor, toggleTheme: () => dispatch(toggleTheme()), }; }; ``` ### 2.5 Общие UI компоненты `src/components/common/Notification.tsx`: ```typescript import React, { useEffect, useState } from "react"; import { useAppSelector, useAppDispatch } from "../../store/hooks"; import { removeNotification } from "../../store/slices/uiSlice"; export const NotificationStack: React.FC = () => { const notifications = useAppSelector((state) => state.ui.notifications); const dispatch = useAppDispatch(); return (
{notifications.map((notification, index) => ( dispatch(removeNotification(notification.id))} /> ))}
); }; const NotificationItem: React.FC<{ notification: { id: string; message: string; type: string }; index: number; onRemove: () => void; }> = ({ notification, index, onRemove }) => { const [isVisible, setIsVisible] = useState(false); useEffect(() => { setTimeout(() => setIsVisible(true), 100); }, []); const handleRemove = () => { setIsVisible(false); setTimeout(onRemove, 300); }; return (
{notification.message}
); }; ``` `src/components/common/Modal.tsx`: ```typescript import React, { useEffect } from "react"; interface ModalProps { isOpen: boolean; onClose: () => void; onConfirm: () => void; title: string; message: string; confirmText?: string; cancelText?: string; confirmType?: "primary" | "danger"; } export const Modal: React.FC = ({ isOpen, onClose, onConfirm, title, message, confirmText = "OK", cancelText = "Отмена", confirmType = "primary", }) => { useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; if (isOpen) { document.addEventListener("keydown", handleEscape); } return () => document.removeEventListener("keydown", handleEscape); }, [isOpen, onClose]); if (!isOpen) return null; return (
e.stopPropagation()}>

{title}

×

{message}

); }; ``` `src/components/common/ThemeToggle.tsx`: ```typescript import React from "react"; import { Icon } from "@iconify/react"; import { useTheme } from "../../hooks/useTheme"; export const ThemeToggle: React.FC = () => { const { theme, toggleTheme } = useTheme(); return ( ); }; ``` --- ## Этап 3: API слой ### 3.1 API клиенты `src/api/authApi.ts`: ```typescript import axiosClient from "./axiosClient"; import { AuthResponse } from "../types/user"; export const authApi = { login: async (username: string, password: string) => { const { data } = await axiosClient.post("/login", { username, password }); return data; }, register: async ( username: string, password: string, confirmPassword: string ) => { const { data } = await axiosClient.post("/register", { username, password, confirmPassword, }); return data; }, checkStatus: async (): Promise => { const { data } = await axiosClient.get("/auth/status"); return data; }, logout: async () => { await axiosClient.post("/logout"); }, }; ``` `src/api/notesApi.ts`: ```typescript import axiosClient from "./axiosClient"; import { Note } from "../types/note"; export const notesApi = { getAll: async (): Promise => { const { data } = await axiosClient.get("/notes"); return data; }, search: async (params: { q?: string; tag?: string; date?: string; }): Promise => { const { data } = await axiosClient.get("/notes/search", { params }); return data; }, create: async (note: { content: string; date: string; time: string }) => { const { data } = await axiosClient.post("/notes", note); return data; }, update: async (id: number, content: string) => { const { data } = await axiosClient.put(`/notes/${id}`, { content }); return data; }, delete: async (id: number) => { await axiosClient.delete(`/notes/${id}`); }, pin: async (id: number) => { const { data } = await axiosClient.put(`/notes/${id}/pin`); return data; }, archive: async (id: number) => { const { data } = await axiosClient.put(`/notes/${id}/archive`); return data; }, unarchive: async (id: number) => { const { data } = await axiosClient.put(`/notes/${id}/unarchive`); return data; }, uploadImages: async (noteId: number, files: File[]) => { const formData = new FormData(); files.forEach((file) => formData.append("images", file)); const { data } = await axiosClient.post( `/notes/${noteId}/images`, formData, { headers: { "Content-Type": "multipart/form-data" }, } ); return data; }, uploadFiles: async (noteId: number, files: File[]) => { const formData = new FormData(); files.forEach((file) => formData.append("files", file)); const { data } = await axiosClient.post( `/notes/${noteId}/files`, formData, { headers: { "Content-Type": "multipart/form-data" }, } ); return data; }, deleteImage: async (noteId: number, imageId: number) => { await axiosClient.delete(`/notes/${noteId}/images/${imageId}`); }, deleteFile: async (noteId: number, fileId: number) => { await axiosClient.delete(`/notes/${noteId}/files/${fileId}`); }, }; ``` `src/api/userApi.ts`: ```typescript import axiosClient from "./axiosClient"; import { User, AiSettings } from "../types/user"; export const userApi = { getProfile: async (): Promise => { const { data } = await axiosClient.get("/user"); return data; }, updateProfile: async ( profile: Partial & { currentPassword?: string; newPassword?: string; } ) => { const { data } = await axiosClient.put("/user/profile", profile); return data; }, uploadAvatar: async (file: File) => { const formData = new FormData(); formData.append("avatar", file); const { data } = await axiosClient.post("/user/avatar", formData, { headers: { "Content-Type": "multipart/form-data" }, }); return data; }, deleteAvatar: async () => { await axiosClient.delete("/user/avatar"); }, deleteAccount: async (password: string) => { const { data } = await axiosClient.delete("/user/delete-account", { data: { password }, }); return data; }, getAiSettings: async (): Promise => { const { data } = await axiosClient.get("/user/ai-settings"); return data; }, updateAiSettings: async (settings: Partial) => { const { data } = await axiosClient.put("/user/ai-settings", settings); return data; }, }; ``` `src/api/aiApi.ts`: ```typescript import axiosClient from "./axiosClient"; export const aiApi = { improveText: async (text: string): Promise => { const { data } = await axiosClient.post<{ improvedText: string }>( "/ai/improve", { text } ); return data.improvedText; }, }; ``` --- ## Этап 4: Компоненты заметок (основная функциональность) ### 4.1 NoteEditor - главный редактор `src/components/notes/NoteEditor.tsx`: ```typescript import React, { useState, useRef, useCallback } from "react"; import { MarkdownToolbar } from "./MarkdownToolbar"; import { NotePreview } from "./NotePreview"; import { ImageUpload } from "./ImageUpload"; import { FileUpload } from "./FileUpload"; import { useAppSelector } from "../../store/hooks"; import { useNotification } from "../../hooks/useNotification"; import { notesApi } from "../../api/notesApi"; import { aiApi } from "../../api/aiApi"; interface NoteEditorProps { onSave: () => void; } export const NoteEditor: React.FC = ({ onSave }) => { const [content, setContent] = useState(""); const [images, setImages] = useState([]); const [files, setFiles] = useState([]); const [isAiLoading, setIsAiLoading] = useState(false); const textareaRef = useRef(null); const isPreviewMode = useAppSelector((state) => state.ui.isPreviewMode); const { showNotification } = useNotification(); const aiEnabled = useAppSelector((state) => state.profile.aiEnabled); const handleSave = async () => { if (!content.trim()) { showNotification("Введите текст заметки", "warning"); return; } try { const now = new Date(); const date = now.toLocaleDateString("ru-RU"); const time = now.toLocaleTimeString("ru-RU"); const note = await notesApi.create({ content, date, time }); // Загружаем изображения if (images.length > 0) { await notesApi.uploadImages(note.id, images); } // Загружаем файлы if (files.length > 0) { await notesApi.uploadFiles(note.id, files); } showNotification("Заметка сохранена!", "success"); setContent(""); setImages([]); setFiles([]); onSave(); } catch (error) { showNotification("Ошибка сохранения заметки", "error"); } }; const handleAiImprove = async () => { if (!content.trim()) { showNotification("Введите текст для улучшения", "warning"); return; } setIsAiLoading(true); try { const improvedText = await aiApi.improveText(content); setContent(improvedText); showNotification("Текст улучшен!", "success"); } catch (error) { showNotification("Ошибка улучшения текста", "error"); } finally { setIsAiLoading(false); } }; const insertMarkdown = useCallback( (before: string, after: string = "") => { const textarea = textareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selectedText = content.substring(start, end); const newText = content.substring(0, start) + before + selectedText + after + content.substring(end); setContent(newText); // Восстанавливаем фокус и выделение setTimeout(() => { textarea.focus(); textarea.setSelectionRange(start + before.length, end + before.length); }, 0); }, [content] ); // Ctrl/Alt + Enter для сохранения const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.altKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); handleSave(); } }; return (
{!isPreviewMode && (