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

1990 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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