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

{title}

+ + × + +
+
+

{message}

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