1990 lines
56 KiB
Markdown
1990 lines
56 KiB
Markdown
# План миграции 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}>
|
||
×
|
||
</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. Не спешить - качество важнее скорости
|