NoteJS/plan.md
2025-10-30 08:03:30 +07:00

56 KiB
Raw Permalink Blame History

План миграции 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 приложение в отдельной директории:

npx create-react-app notejs-react --template typescript
cd notejs-react

1.2 Установка зависимостей

# Роутинг
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:

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:

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:

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:

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<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

src/store/slices/authSlice.ts:

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:

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<Note[]>) => {
      state.notes = action.payload;
    },
    addNote: (state, action: PayloadAction<Note>) => {
      state.notes.unshift(action.payload);
    },
    updateNote: (state, action: PayloadAction<Note>) => {
      const index = state.notes.findIndex((n) => n.id === action.payload.id);
      if (index !== -1) {
        state.notes[index] = action.payload;
      }
    },
    deleteNote: (state, action: PayloadAction<number>) => {
      state.notes = state.notes.filter((n) => n.id !== action.payload);
    },
    setSelectedDate: (state, action: PayloadAction<string | null>) => {
      state.selectedDate = action.payload;
    },
    setSelectedTag: (state, action: PayloadAction<string | null>) => {
      state.selectedTag = action.payload;
    },
    setSearchQuery: (state, action: PayloadAction<string>) => {
      state.searchQuery = action.payload;
    },
    setEditingNote: (state, action: PayloadAction<number | null>) => {
      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:

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<string>) => {
      state.accentColor = action.payload;
      localStorage.setItem("accentColor", action.payload);
    },
    addNotification: (
      state,
      action: PayloadAction<Omit<Notification, "id">>
    ) => {
      const id = `notification-${Date.now()}-${Math.random()
        .toString(36)
        .substr(2, 9)}`;
      state.notifications.push({ ...action.payload, id });
    },
    removeNotification: (state, action: PayloadAction<string>) => {
      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:

import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./index";

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

src/hooks/useNotification.ts:

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:

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:

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 (
    <div className="notification-stack">
      {notifications.map((notification, index) => (
        <NotificationItem
          key={notification.id}
          notification={notification}
          index={index}
          onRemove={() => dispatch(removeNotification(notification.id))}
        />
      ))}
    </div>
  );
};

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 (
    <div
      className={`notification notification-${notification.type} ${
        isVisible ? "visible" : ""
      }`}
      style={{ top: `${20 + index * 70}px` }}
      onClick={handleRemove}
    >
      {notification.message}
    </div>
  );
};

src/components/common/Modal.tsx:

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<ModalProps> = ({
  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 (
    <div className="modal" style={{ display: "block" }} onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <div className="modal-header">
          <h3>{title}</h3>
          <span className="modal-close" onClick={onClose}>
            &times;
          </span>
        </div>
        <div className="modal-body">
          <p>{message}</p>
        </div>
        <div className="modal-footer">
          <button
            className={confirmType === "danger" ? "btn-danger" : "btn-primary"}
            onClick={onConfirm}
            style={{ marginRight: "10px" }}
          >
            {confirmText}
          </button>
          <button className="btn-secondary" onClick={onClose}>
            {cancelText}
          </button>
        </div>
      </div>
    </div>
  );
};

src/components/common/ThemeToggle.tsx:

import React from "react";
import { Icon } from "@iconify/react";
import { useTheme } from "../../hooks/useTheme";

export const ThemeToggle: React.FC = () => {
  const { theme, toggleTheme } = useTheme();

  return (
    <button
      id="theme-toggle-btn"
      className="theme-toggle-btn"
      onClick={toggleTheme}
      title="Переключить тему"
    >
      <Icon
        icon={theme === "dark" ? "mdi:weather-sunny" : "mdi:weather-night"}
      />
    </button>
  );
};

Этап 3: API слой

3.1 API клиенты

src/api/authApi.ts:

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<AuthResponse> => {
    const { data } = await axiosClient.get<AuthResponse>("/auth/status");
    return data;
  },

  logout: async () => {
    await axiosClient.post("/logout");
  },
};

src/api/notesApi.ts:

import axiosClient from "./axiosClient";
import { Note } from "../types/note";

export const notesApi = {
  getAll: async (): Promise<Note[]> => {
    const { data } = await axiosClient.get<Note[]>("/notes");
    return data;
  },

  search: async (params: {
    q?: string;
    tag?: string;
    date?: string;
  }): Promise<Note[]> => {
    const { data } = await axiosClient.get<Note[]>("/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:

import axiosClient from "./axiosClient";
import { User, AiSettings } from "../types/user";

export const userApi = {
  getProfile: async (): Promise<User> => {
    const { data } = await axiosClient.get<User>("/user");
    return data;
  },

  updateProfile: async (
    profile: Partial<User> & {
      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<AiSettings> => {
    const { data } = await axiosClient.get<AiSettings>("/user/ai-settings");
    return data;
  },

  updateAiSettings: async (settings: Partial<AiSettings>) => {
    const { data } = await axiosClient.put("/user/ai-settings", settings);
    return data;
  },
};

src/api/aiApi.ts:

import axiosClient from "./axiosClient";

export const aiApi = {
  improveText: async (text: string): Promise<string> => {
    const { data } = await axiosClient.post<{ improvedText: string }>(
      "/ai/improve",
      { text }
    );
    return data.improvedText;
  },
};

Этап 4: Компоненты заметок (основная функциональность)

4.1 NoteEditor - главный редактор

src/components/notes/NoteEditor.tsx:

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<NoteEditorProps> = ({ onSave }) => {
  const [content, setContent] = useState("");
  const [images, setImages] = useState<File[]>([]);
  const [files, setFiles] = useState<File[]>([]);
  const [isAiLoading, setIsAiLoading] = useState(false);
  const textareaRef = useRef<HTMLTextAreaElement>(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 (
    <div className="main">
      <MarkdownToolbar onInsert={insertMarkdown} />

      {!isPreviewMode && (
        <textarea
          ref={textareaRef}
          className="textInput"
          id="noteInput"
          placeholder="Ваша заметка..."
          value={content}
          onChange={(e) => setContent(e.target.value)}
          onKeyDown={handleKeyDown}
        />
      )}

      {isPreviewMode && <NotePreview content={content} />}

      <ImageUpload images={images} onChange={setImages} />

      <FileUpload files={files} onChange={setFiles} />

      <div className="save-button-container">
        <div className="action-buttons">
          {aiEnabled && (
            <button
              className="btnSave btnAI"
              onClick={handleAiImprove}
              disabled={isAiLoading}
              title="Улучшить или создать текст через ИИ"
            >
              <span className="iconify" data-icon="mdi:robot"></span>
              {isAiLoading ? "Обработка..." : "Помощь ИИ"}
            </button>
          )}
          <button className="btnSave" onClick={handleSave}>
            Сохранить
          </button>
        </div>
        <span className="save-hint">или нажмите Alt + Enter</span>
      </div>
    </div>
  );
};

4.2 MarkdownToolbar

src/components/notes/MarkdownToolbar.tsx:

import React, { useState } from "react";
import { Icon } from "@iconify/react";
import { useAppDispatch } from "../../store/hooks";
import { togglePreviewMode } from "../../store/slices/uiSlice";

interface MarkdownToolbarProps {
  onInsert: (before: string, after?: string) => void;
}

export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
  onInsert,
}) => {
  const [showHeaderDropdown, setShowHeaderDropdown] = useState(false);
  const dispatch = useAppDispatch();

  const buttons = [
    {
      id: "bold",
      icon: "mdi:format-bold",
      title: "Жирный текст",
      before: "**",
      after: "**",
    },
    {
      id: "italic",
      icon: "mdi:format-italic",
      title: "Курсив",
      before: "*",
      after: "*",
    },
    {
      id: "strikethrough",
      icon: "mdi:format-strikethrough",
      title: "Зачеркнутый",
      before: "~~",
      after: "~~",
    },
    {
      id: "color",
      icon: "mdi:palette",
      title: "Цвет текста",
      before: '<span style="color: red;">',
      after: "</span>",
    },
    {
      id: "spoiler",
      icon: "mdi:eye-off",
      title: "Скрытый текст",
      before: "||",
      after: "||",
    },
  ];

  return (
    <div className="markdown-buttons">
      {buttons.map((btn) => (
        <button
          key={btn.id}
          className="btnMarkdown"
          onClick={() => onInsert(btn.before, btn.after)}
          title={btn.title}
        >
          <Icon icon={btn.icon} />
        </button>
      ))}

      <div className="header-dropdown">
        <button
          className="btnMarkdown"
          onClick={() => setShowHeaderDropdown(!showHeaderDropdown)}
          title="Заголовок"
        >
          <Icon icon="mdi:format-header-pound" />
          <Icon
            icon="mdi:menu-down"
            style={{ fontSize: "10px", marginLeft: "-2px" }}
          />
        </button>
        {showHeaderDropdown && (
          <div className="header-dropdown-menu">
            {[1, 2, 3, 4, 5, 6].map((level) => (
              <button
                key={level}
                onClick={() => {
                  onInsert("#".repeat(level) + " ", "");
                  setShowHeaderDropdown(false);
                }}
              >
                H{level}
              </button>
            ))}
          </div>
        )}
      </div>

      <button
        className="btnMarkdown"
        onClick={() => onInsert("- ", "")}
        title="Список"
      >
        <Icon icon="mdi:format-list-bulleted" />
      </button>

      <button
        className="btnMarkdown"
        onClick={() => onInsert("1. ", "")}
        title="Нумерованный список"
      >
        <Icon icon="mdi:format-list-numbered" />
      </button>

      <button
        className="btnMarkdown"
        onClick={() => onInsert("> ", "")}
        title="Цитата"
      >
        <Icon icon="mdi:format-quote-close" />
      </button>

      <button
        className="btnMarkdown"
        onClick={() => onInsert("`", "`")}
        title="Код"
      >
        <Icon icon="mdi:code-tags" />
      </button>

      <button
        className="btnMarkdown"
        onClick={() => onInsert("[текст ссылки](", ")")}
        title="Ссылка"
      >
        <Icon icon="mdi:link" />
      </button>

      <button
        className="btnMarkdown"
        onClick={() => onInsert("- [ ] ", "")}
        title="To-Do список"
      >
        <Icon icon="mdi:checkbox-marked-outline" />
      </button>

      <button
        className="btnMarkdown"
        onClick={() => dispatch(togglePreviewMode())}
        title="Предпросмотр"
      >
        <Icon icon="mdi:eye" />
      </button>
    </div>
  );
};

4.3 NotesList и NoteItem

src/components/notes/NotesList.tsx:

import React, { useEffect } from "react";
import { NoteItem } from "./NoteItem";
import { useAppSelector, useAppDispatch } from "../../store/hooks";
import { notesApi } from "../../api/notesApi";
import { setNotes } from "../../store/slices/notesSlice";
import { useNotification } from "../../hooks/useNotification";

export const NotesList: React.FC = () => {
  const notes = useAppSelector((state) => state.notes.notes);
  const searchQuery = useAppSelector((state) => state.notes.searchQuery);
  const selectedDate = useAppSelector((state) => state.notes.selectedDate);
  const selectedTag = useAppSelector((state) => state.notes.selectedTag);
  const dispatch = useAppDispatch();
  const { showNotification } = useNotification();

  useEffect(() => {
    loadNotes();
  }, [searchQuery, selectedDate, selectedTag]);

  const loadNotes = async () => {
    try {
      let data;
      if (searchQuery || selectedDate || selectedTag) {
        data = await notesApi.search({
          q: searchQuery || undefined,
          date: selectedDate || undefined,
          tag: selectedTag || undefined,
        });
      } else {
        data = await notesApi.getAll();
      }
      dispatch(setNotes(data));
    } catch (error) {
      showNotification("Ошибка загрузки заметок", "error");
    }
  };

  const handleDelete = async (id: number) => {
    try {
      await notesApi.delete(id);
      showNotification("Заметка удалена", "success");
      loadNotes();
    } catch (error) {
      showNotification("Ошибка удаления заметки", "error");
    }
  };

  const handlePin = async (id: number) => {
    try {
      await notesApi.pin(id);
      loadNotes();
    } catch (error) {
      showNotification("Ошибка закрепления заметки", "error");
    }
  };

  const handleArchive = async (id: number) => {
    try {
      await notesApi.archive(id);
      showNotification("Заметка архивирована", "success");
      loadNotes();
    } catch (error) {
      showNotification("Ошибка архивирования заметки", "error");
    }
  };

  if (notes.length === 0) {
    return (
      <div className="notes-container">
        <p style={{ textAlign: "center", color: "#999", marginTop: "50px" }}>
          Заметок пока нет. Создайте первую!
        </p>
      </div>
    );
  }

  return (
    <div className="notes-container">
      {notes.map((note) => (
        <NoteItem
          key={note.id}
          note={note}
          onDelete={handleDelete}
          onPin={handlePin}
          onArchive={handleArchive}
          onReload={loadNotes}
        />
      ))}
    </div>
  );
};

src/components/notes/NoteItem.tsx - создать компонент для отображения отдельной заметки с кнопками редактирования, удаления, закрепления, архивации. Включить отображение изображений и файлов, поддержку markdown рендеринга с marked.js.

4.4 Календарь и поиск

src/components/calendar/MiniCalendar.tsx - реализовать календарь с выделением дат с заметками, навигацией по месяцам, выбором даты для фильтрации.

src/components/search/SearchBar.tsx - поисковая строка с debounce.

src/components/search/TagsFilter.tsx - извлечение тегов из заметок (регулярное выражение #тег), отображение облака тегов, фильтрация по клику.


Этап 5: Страницы

5.1 LoginPage

src/pages/LoginPage.tsx:

import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { setAuth } from "../store/slices/authSlice";
import { authApi } from "../api/authApi";
import { useNotification } from "../hooks/useNotification";
import { ThemeToggle } from "../components/common/ThemeToggle";
import { Icon } from "@iconify/react";

export const LoginPage: React.FC = () => {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const navigate = useNavigate();
  const dispatch = useAppDispatch();
  const { showNotification } = useNotification();
  const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);

  useEffect(() => {
    if (isAuthenticated) {
      navigate("/notes");
    }
  }, [isAuthenticated, navigate]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!username || !password) {
      showNotification("Логин и пароль обязательны", "error");
      return;
    }

    setIsLoading(true);
    try {
      const data = await authApi.login(username, password);

      if (data.success) {
        // Получаем информацию о пользователе
        const authStatus = await authApi.checkStatus();
        dispatch(
          setAuth({
            userId: authStatus.userId!,
            username: authStatus.username!,
          })
        );
        navigate("/notes");
      }
    } catch (error: any) {
      showNotification(error.response?.data?.error || "Ошибка входа", "error");
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="container">
      <header>
        <div
          style={{
            display: "flex",
            alignItems: "center",
            justifyContent: "space-between",
          }}
        >
          <span>
            <Icon icon="mdi:login" /> Вход в систему
          </span>
          <ThemeToggle />
        </div>
      </header>
      <div className="login-form">
        <form onSubmit={handleSubmit}>
          <div className="form-group">
            <label htmlFor="username">Логин:</label>
            <input
              type="text"
              id="username"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              required
              placeholder="Введите ваш логин"
            />
          </div>
          <div className="form-group">
            <label htmlFor="password">Пароль:</label>
            <input
              type="password"
              id="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              placeholder="Введите пароль"
            />
          </div>
          <button type="submit" className="btnSave" disabled={isLoading}>
            {isLoading ? "Вход..." : "Войти"}
          </button>
        </form>
        <p className="auth-link">
          Нет аккаунта? <a href="/register">Зарегистрируйтесь</a>
        </p>
      </div>
    </div>
  );
};

5.2 NotesPage - главная страница

src/pages/NotesPage.tsx - собрать все компоненты вместе: календарь, поиск, теги, редактор, список заметок, хедер с аватаром, кнопками настроек и выхода.

5.3 ProfilePage и SettingsPage

Реализовать страницы профиля и настроек с табами (внешний вид, AI настройки, архив, логи).


Этап 6: Роутинг

src/App.tsx:

import React from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Provider } from "react-redux";
import { store } from "./store";
import { LoginPage } from "./pages/LoginPage";
import { RegisterPage } from "./pages/RegisterPage";
import { NotesPage } from "./pages/NotesPage";
import { ProfilePage } from "./pages/ProfilePage";
import { SettingsPage } from "./pages/SettingsPage";
import { NotificationStack } from "./components/common/Notification";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { useTheme } from "./hooks/useTheme";

const AppContent: React.FC = () => {
  useTheme(); // Инициализируем тему

  return (
    <>
      <NotificationStack />
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<LoginPage />} />
          <Route path="/register" element={<RegisterPage />} />
          <Route
            path="/notes"
            element={
              <ProtectedRoute>
                <NotesPage />
              </ProtectedRoute>
            }
          />
          <Route
            path="/profile"
            element={
              <ProtectedRoute>
                <ProfilePage />
              </ProtectedRoute>
            }
          />
          <Route
            path="/settings"
            element={
              <ProtectedRoute>
                <SettingsPage />
              </ProtectedRoute>
            }
          />
          <Route path="*" element={<Navigate to="/" replace />} />
        </Routes>
      </BrowserRouter>
    </>
  );
};

export const App: React.FC = () => {
  return (
    <Provider store={store}>
      <AppContent />
    </Provider>
  );
};

src/components/ProtectedRoute.tsx:

import React, { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import { useAppSelector, useAppDispatch } from "../store/hooks";
import { setAuth, clearAuth } from "../store/slices/authSlice";
import { authApi } from "../api/authApi";

export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);
  const dispatch = useAppDispatch();
  const [isChecking, setIsChecking] = useState(true);

  useEffect(() => {
    const checkAuth = async () => {
      try {
        const authStatus = await authApi.checkStatus();
        if (authStatus.authenticated) {
          dispatch(
            setAuth({
              userId: authStatus.userId!,
              username: authStatus.username!,
            })
          );
        } else {
          dispatch(clearAuth());
        }
      } catch {
        dispatch(clearAuth());
      } finally {
        setIsChecking(false);
      }
    };

    if (isAuthenticated) {
      checkAuth();
    } else {
      setIsChecking(false);
    }
  }, [dispatch, isAuthenticated]);

  if (isChecking) {
    return <div>Загрузка...</div>;
  }

  return isAuthenticated ? <>{children}</> : <Navigate to="/" replace />;
};

Этап 7: Стили

7.1 Перенос CSS

Скопировать style.css и style-calendar.css из оригинального проекта в src/styles/.

Разделить на модули:

  • src/styles/index.css - глобальные стили
  • src/styles/theme.css - переменные темы
  • src/styles/components/ - стили компонентов
  • src/styles/pages/ - стили страниц

7.2 Адаптация стилей

Убедиться что CSS переменные работают с React компонентами. Проверить классы соответствуют компонентам.


Этап 8: PWA функциональность

8.1 Service Worker интеграция

Использовать workbox-webpack-plugin для автоматической генерации Service Worker или адаптировать существующий sw.js.

src/service-worker.ts - перенести логику из public/sw.js.

8.2 PWA хук

src/hooks/usePWA.ts:

import { useEffect, useState } from "react";

export const usePWA = () => {
  const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
  const [registration, setRegistration] =
    useState<ServiceWorkerRegistration | null>(null);

  useEffect(() => {
    if ("serviceWorker" in navigator) {
      navigator.serviceWorker
        .register("/service-worker.js")
        .then((reg) => {
          setRegistration(reg);

          reg.addEventListener("updatefound", () => {
            const newWorker = reg.installing;
            if (newWorker) {
              newWorker.addEventListener("statechange", () => {
                if (
                  newWorker.state === "installed" &&
                  navigator.serviceWorker.controller
                ) {
                  setIsUpdateAvailable(true);
                }
              });
            }
          });
        })
        .catch((error) => console.error("SW registration failed:", error));
    }
  }, []);

  const updateApp = () => {
    if (registration?.waiting) {
      registration.waiting.postMessage({ type: "SKIP_WAITING" });
      window.location.reload();
    }
  };

  return {
    isUpdateAvailable,
    updateApp,
  };
};

8.3 Манифест и иконки

Скопировать manifest.json, browserconfig.xml, папку icons/, icon.svg, logo.svg в public/ директорию React проекта.

Обновить public/index.html с мета-тегами PWA из public/index.html оригинального проекта.


Этап 9: Markdown и специальные функции

9.1 Markdown рендеринг с расширениями

src/utils/markdown.ts:

import { marked } from "marked";

// Расширение для спойлеров
const spoilerExtension = {
  name: "spoiler",
  level: "inline" as const,
  start(src: string) {
    return src.match(/\|\|/)?.index;
  },
  tokenizer(src: string) {
    const rule = /^\|\|(.*?)\|\|/;
    const match = rule.exec(src);
    if (match) {
      return {
        type: "spoiler",
        raw: match[0],
        text: match[1].trim(),
      };
    }
  },
  renderer(token: any) {
    return `<span class="spoiler" title="Нажмите, чтобы показать">${token.text}</span>`;
  },
};

// Настройка marked
marked.use({ extensions: [spoilerExtension] });

// Кастомный renderer для внешних ссылок
const renderer = new marked.Renderer();
const originalLink = renderer.link.bind(renderer);

renderer.link = function (href, title, text) {
  try {
    const url = new URL(href);
    const isExternal = url.origin !== window.location.origin;

    if (isExternal) {
      return `<a href="${href}" title="${
        title || ""
      }" target="_blank" rel="noopener noreferrer" class="external-link">${text}</a>`;
    }
  } catch {}

  return originalLink(href, title, text);
};

marked.setOptions({
  gfm: true,
  breaks: true,
  renderer,
});

export const parseMarkdown = (text: string): string => {
  return marked.parse(text) as string;
};

9.2 Обработчики спойлеров и внешних ссылок

src/hooks/useMarkdown.ts:

import { useEffect } from "react";

export const useMarkdown = () => {
  useEffect(() => {
    // Обработчики спойлеров
    const spoilers = document.querySelectorAll(".spoiler");
    spoilers.forEach((spoiler) => {
      if (!(spoiler as any)._clickHandler) {
        const handler = function (this: HTMLElement, event: Event) {
          if (this.classList.contains("revealed")) return;
          event.stopPropagation();
          this.classList.add("revealed");
        };
        (spoiler as any)._clickHandler = handler;
        spoiler.addEventListener("click", handler);
      }
    });

    // Обработчики внешних ссылок
    const externalLinks = document.querySelectorAll(".external-link");
    externalLinks.forEach((link) => {
      if (!(link as any)._externalClickHandler) {
        const handler = function (this: HTMLAnchorElement, event: Event) {
          if (
            window.matchMedia("(display-mode: standalone)").matches ||
            (window.navigator as any).standalone === true
          ) {
            event.preventDefault();
            window.open(this.href, "_blank", "noopener,noreferrer");
          }
        };
        (link as any)._externalClickHandler = handler;
        link.addEventListener("click", handler);
      }
    });
  });
};

Этап 10: Тестирование и отладка

10.1 Чек-лист функциональности

  • Авторизация и регистрация
  • Создание, редактирование, удаление заметок
  • Markdown форматирование (все кнопки панели инструментов)
  • Загрузка изображений (до 10 файлов, превью, удаление)
  • Загрузка файлов (pdf, doc, xlsx и т.д.)
  • Предпросмотр Markdown
  • Закрепление заметок
  • Архивирование заметок
  • Поиск по тексту
  • Фильтрация по дате (календарь)
  • Фильтрация по тегам
  • Извлечение и отображение тегов (#тег)
  • AI улучшение текста
  • Темная/светлая тема
  • Кастомный accent color
  • Мобильное меню (слайдер)
  • Адаптивность (мобильные, планшеты, десктоп)
  • Профиль (аватар, email, пароль)
  • Настройки (внешний вид, AI, архив, логи)
  • Удаление аккаунта
  • Удаление всех архивных заметок
  • Уведомления (стек)
  • PWA установка
  • Service Worker кэширование
  • Оффлайн функциональность (частичная)
  • Обновление приложения (модальное окно)
  • Lazy loading изображений
  • Спойлеры в Markdown
  • Внешние ссылки в PWA режиме
  • Alt+Enter для сохранения

10.2 Отладка

  1. Проверить работу с backend API
  2. Проверить сохранение состояния в localStorage
  3. Проверить работу Service Worker
  4. Проверить установку PWA на разных устройствах
  5. Проверить производительность (React DevTools Profiler)
  6. Проверить утечки памяти

Этап 11: Оптимизация

11.1 Code splitting

// Lazy loading страниц
const NotesPage = React.lazy(() => import("./pages/NotesPage"));
const ProfilePage = React.lazy(() => import("./pages/ProfilePage"));
const SettingsPage = React.lazy(() => import("./pages/SettingsPage"));

// Использовать с Suspense
<Suspense fallback={<Loader />}>
  <Routes>...</Routes>
</Suspense>;

11.2 Мемоизация

Использовать React.memo, useMemo, useCallback для предотвращения лишних ре-рендеров в NotesList, NoteItem, Calendar.

11.3 Виртуализация списка

Если заметок много (>100), использовать react-window или react-virtual для виртуализации NotesList.


Этап 12: Деплой и переход

12.1 Build конфигурация

// package.json
{
  "proxy": "http://localhost:3000",
  "homepage": "/"
}

12.2 Production build

npm run build

12.3 Интеграция с backend

Скопировать build папку в public-react/ на сервере. Обновить Express маршруты:

// server.js
app.use(express.static(path.join(__dirname, "public-react")));

app.get("*", (req, res) => {
  if (req.path.startsWith("/api/")) {
    return next();
  }
  res.sendFile(path.join(__dirname, "public-react", "index.html"));
});

12.4 Постепенный переход

  1. Развернуть React версию на поддомене (react.notejs.com)
  2. Тестировать параллельно с Vanilla версией
  3. Постепенно переводить пользователей
  4. После стабилизации заменить основной домен

Этап 13: Документация

13.1 README.md

Создать документацию для разработчиков:

  • Установка и запуск
  • Структура проекта
  • Компоненты и их API
  • Redux store структура
  • API endpoints
  • Добавление новых функций

13.2 Комментарии в коде

Добавить JSDoc комментарии для всех компонентов, хуков, утилит.


Ожидаемые результаты

После завершения миграции получим:

  1. Современная архитектура: компонентный подход, типизация TypeScript
  2. Лучшая поддерживаемость: модульная структура, переиспользуемые компоненты
  3. Улучшенная производительность: виртуализация, мемоизация, code splitting
  4. Масштабируемость: легко добавлять новые функции
  5. Лучший DX: TypeScript автодополнение, Redux DevTools
  6. Сохранение всех функций: 100% функциональная совместимость с Vanilla версией

Оценка времени

  • Этапы 1-2: 2-3 дня (инфраструктура, базовые компоненты)
  • Этапы 3-4: 5-7 дней (API, компоненты заметок)
  • Этапы 5-6: 3-4 дня (страницы, роутинг)
  • Этапы 7-9: 4-5 дней (стили, PWA, markdown)
  • Этапы 10-13: 3-5 дней (тестирование, оптимизация, документация)

Итого: 17-24 дня (для опытного React разработчика)

Риски и сложности

  1. Markdown редактор - самый сложный компонент, требует внимательной реализации
  2. PWA функциональность - нужно тщательно протестировать Service Worker
  3. Обратная совместимость - все API endpoints должны работать как раньше
  4. Производительность - большой список заметок может тормозить без виртуализации
  5. Мобильная версия - нужно тщательно проверить адаптивность всех компонентов

Рекомендации

  1. Начать с простых компонентов (Login, Register)
  2. Использовать TypeScript с самого начала
  3. Писать unit тесты для критичных функций
  4. Регулярно тестировать на реальных данных
  5. Использовать Storybook для разработки компонентов в изоляции
  6. Не спешить - качество важнее скорости