56 KiB
План миграции NoteJS на React
Анализ текущего приложения
Архитектура (Vanilla JS)
- Backend: Express.js (2634 строки) - оставляем без изменений
- Frontend:
app.js(4887 строк) - основная логика заметокlogin.js(~120 строк) - авторизацияregister.js(~133 строк) - регистрацияprofile.js(~799 строк) - профиль пользователяsettings.js(~1121 строк) - настройки + архив + логи + AIpwa.js(~410 строк) - PWA менеджерsw.js(~237 строк) - Service Worker
- Styles:
style.css(3721+ строк),style-calendar.css - HTML: 5 страниц (index, register, notes, profile, settings)
Ключевые функции
- Аутентификация (login/register)
- CRUD операции заметок
- Markdown редактор с панелью инструментов
- Загрузка изображений и файлов
- Календарь с фильтрацией по датам
- Система тегов
- Поиск по заметкам
- Закрепление и архивирование заметок
- AI помощник (улучшение текста)
- Темная/светлая тема
- PWA функциональность
- Система уведомлений
- Управление профилем (аватар, email, пароль)
- Настройки (внешний вид, AI, архив, логи)
- Кастомный accent color
- Lazy loading изображений
- Markdown preview с спойлерами
- Внешние ссылки в 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}>
×
</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 Отладка
- Проверить работу с backend API
- Проверить сохранение состояния в localStorage
- Проверить работу Service Worker
- Проверить установку PWA на разных устройствах
- Проверить производительность (React DevTools Profiler)
- Проверить утечки памяти
Этап 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 Постепенный переход
- Развернуть React версию на поддомене (react.notejs.com)
- Тестировать параллельно с Vanilla версией
- Постепенно переводить пользователей
- После стабилизации заменить основной домен
Этап 13: Документация
13.1 README.md
Создать документацию для разработчиков:
- Установка и запуск
- Структура проекта
- Компоненты и их API
- Redux store структура
- API endpoints
- Добавление новых функций
13.2 Комментарии в коде
Добавить JSDoc комментарии для всех компонентов, хуков, утилит.
Ожидаемые результаты
После завершения миграции получим:
- Современная архитектура: компонентный подход, типизация TypeScript
- Лучшая поддерживаемость: модульная структура, переиспользуемые компоненты
- Улучшенная производительность: виртуализация, мемоизация, code splitting
- Масштабируемость: легко добавлять новые функции
- Лучший DX: TypeScript автодополнение, Redux DevTools
- Сохранение всех функций: 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 разработчика)
Риски и сложности
- Markdown редактор - самый сложный компонент, требует внимательной реализации
- PWA функциональность - нужно тщательно протестировать Service Worker
- Обратная совместимость - все API endpoints должны работать как раньше
- Производительность - большой список заметок может тормозить без виртуализации
- Мобильная версия - нужно тщательно проверить адаптивность всех компонентов
Рекомендации
- Начать с простых компонентов (Login, Register)
- Использовать TypeScript с самого начала
- Писать unit тесты для критичных функций
- Регулярно тестировать на реальных данных
- Использовать Storybook для разработки компонентов в изоляции
- Не спешить - качество важнее скорости