import React, { useState, useEffect, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { Icon } from "@iconify/react"; import { useAppSelector, useAppDispatch } from "../store/hooks"; import { userApi } from "../api/userApi"; import { authApi } from "../api/authApi"; import { notesApi, logsApi, Log } from "../api/notesApi"; import { Note } from "../types/note"; import { setUser, setAiSettings } from "../store/slices/profileSlice"; import { setAccentColor as setAccentColorAction } from "../store/slices/uiSlice"; import { clearAuth } from "../store/slices/authSlice"; import { setAccentColor } from "../utils/colorUtils"; import { useNotification } from "../hooks/useNotification"; import { Modal } from "../components/common/Modal"; import { ThemeToggle } from "../components/common/ThemeToggle"; import { parseMarkdown } from "../utils/markdown"; import { dbManager } from "../utils/indexedDB"; import { syncService } from "../services/syncService"; import { offlineNotesApi } from "../api/offlineNotesApi"; type SettingsTab = "appearance" | "ai" | "archive" | "logs" | "offline"; const SettingsPage: React.FC = () => { const navigate = useNavigate(); const dispatch = useAppDispatch(); const { showNotification } = useNotification(); // @ts-expect-error - переменная может использоваться в будущем const _user = useAppSelector((state) => state.profile.user); const userId = useAppSelector((state) => state.auth.userId); // @ts-expect-error - переменная может использоваться в будущем const _accentColor = useAppSelector((state) => state.ui.accentColor); const [activeTab, setActiveTab] = useState(() => { // Восстанавливаем активную вкладку из localStorage при инициализации const savedTab = localStorage.getItem("settings_active_tab") as SettingsTab | null; if (savedTab && ["appearance", "ai", "archive", "logs", "offline"].includes(savedTab)) { return savedTab; } return "appearance"; }); // Сохраняем активную вкладку в localStorage при изменении useEffect(() => { localStorage.setItem("settings_active_tab", activeTab); }, [activeTab]); // Appearance settings const [selectedAccentColor, setSelectedAccentColor] = useState("#007bff"); const [showEditDate, setShowEditDate] = useState(true); const [coloredIcons, setColoredIcons] = useState(true); const [floatingToolbarEnabled, setFloatingToolbarEnabled] = useState(true); // AI settings const [apiKey, setApiKey] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [model, setModel] = useState(""); const [aiEnabled, setAiEnabled] = useState(false); // Archive const [archivedNotes, setArchivedNotes] = useState([]); const [isLoadingArchived, setIsLoadingArchived] = useState(false); // Logs const [logs, setLogs] = useState([]); const [logsOffset, setLogsOffset] = useState(0); const [hasMoreLogs, setHasMoreLogs] = useState(true); const [logTypeFilter, setLogTypeFilter] = useState(""); const [isLoadingLogs, setIsLoadingLogs] = useState(false); // Delete all archived modal const [isDeleteAllModalOpen, setIsDeleteAllModalOpen] = useState(false); const [deleteAllPassword, setDeleteAllPassword] = useState(""); const [isDeletingAll, setIsDeletingAll] = useState(false); // Clear IndexedDB modal const [isClearIndexedDBModalOpen, setIsClearIndexedDBModalOpen] = useState(false); const [isClearingIndexedDB, setIsClearingIndexedDB] = useState(false); // Data version info const [serverVersion, setServerVersion] = useState<{ last_updated_at: string | null; last_created_at: string | null; total_notes: number; timestamp: number; } | null>(null); const [indexedDBVersion, setIndexedDBVersion] = useState<{ last_updated_at: string | null; last_created_at: string | null; total_notes: number; } | null>(null); const [isLoadingVersion, setIsLoadingVersion] = useState(false); const [isForceSyncing, setIsForceSyncing] = useState(false); const logsLimit = 50; const colorOptions = [ { color: "#007bff", title: "Синий" }, { color: "#28a745", title: "Зеленый" }, { color: "#dc3545", title: "Красный" }, { color: "#fd7e14", title: "Оранжевый" }, { color: "#6f42c1", title: "Фиолетовый" }, { color: "#e83e8c", title: "Розовый" }, ]; useEffect(() => { loadUserInfo(); }, []); useEffect(() => { if (activeTab === "archive") { loadArchivedNotes(); } else if (activeTab === "logs") { loadLogs(true); } else if (activeTab === "ai") { loadAiSettings(); } else if (activeTab === "offline") { loadDataVersions(); } }, [activeTab]); const loadUserInfo = async () => { try { const userData = await userApi.getProfile(); dispatch(setUser(userData)); const accent = userData.accent_color || "#007bff"; setSelectedAccentColor(accent); dispatch(setAccentColorAction(accent)); setAccentColor(accent); setShowEditDate( userData.show_edit_date !== undefined ? userData.show_edit_date === 1 : true ); const coloredIconsValue = userData.colored_icons !== undefined ? userData.colored_icons === 1 : true; setColoredIcons(coloredIconsValue); updateColoredIconsClass(coloredIconsValue); const floatingToolbarValue = userData.floating_toolbar_enabled !== undefined ? userData.floating_toolbar_enabled === 1 : true; setFloatingToolbarEnabled(floatingToolbarValue); // Загружаем AI настройки try { const aiSettings = await userApi.getAiSettings(); dispatch(setAiSettings(aiSettings)); } catch (aiError) { console.error("Ошибка загрузки AI настроек:", aiError); } } catch (error) { console.error("Ошибка загрузки информации о пользователе:", error); } }; const loadAiSettings = async () => { try { const settings = await userApi.getAiSettings(); setApiKey(settings.openai_api_key || ""); setBaseUrl(settings.openai_base_url || ""); setModel(settings.openai_model || ""); setAiEnabled(settings.ai_enabled === 1); localStorage.setItem("ai_enabled", settings.ai_enabled ? "1" : "0"); } catch (error) { console.error("Ошибка загрузки AI настроек:", error); } }; const handleUpdateAppearance = async () => { try { await userApi.updateProfile({ accent_color: selectedAccentColor, show_edit_date: showEditDate, colored_icons: coloredIcons, floating_toolbar_enabled: floatingToolbarEnabled, }); dispatch(setAccentColorAction(selectedAccentColor)); setAccentColor(selectedAccentColor); await loadUserInfo(); // Обновляем класс на body для управления цветными иконками updateColoredIconsClass(coloredIcons); showNotification("Настройки внешнего вида успешно обновлены", "success"); } catch (error: any) { console.error("Ошибка обновления настроек внешнего вида:", error); showNotification( error.response?.data?.error || "Ошибка обновления", "error" ); } }; const updateColoredIconsClass = (enabled: boolean) => { if (enabled) { document.body.classList.add("colored-icons"); } else { document.body.classList.remove("colored-icons"); } }; const handleUpdateAiSettings = async () => { if (!apiKey.trim()) { showNotification("API ключ обязателен", "error"); return; } if (!baseUrl.trim()) { showNotification("Base URL обязателен", "error"); return; } if (!model.trim()) { showNotification("Название модели обязательно", "error"); return; } try { await userApi.updateAiSettings({ openai_api_key: apiKey, openai_base_url: baseUrl, openai_model: model, }); showNotification("AI настройки успешно сохранены", "success"); updateAiToggleState(); } catch (error: any) { console.error("Ошибка сохранения AI настроек:", error); showNotification( error.response?.data?.error || "Ошибка сохранения", "error" ); } }; const handleAiToggleChange = async (checked: boolean) => { if (checked && !checkAiSettingsFilled()) { showNotification("Сначала заполните все AI настройки", "warning"); return; } try { await userApi.updateAiSettings({ ai_enabled: checked ? 1 : 0, }); setAiEnabled(checked); localStorage.setItem("ai_enabled", checked ? "1" : "0"); showNotification( checked ? "Помощь ИИ включена" : "Помощь ИИ отключена", "success" ); } catch (error: any) { console.error("Ошибка сохранения настройки AI:", error); showNotification( error.response?.data?.error || "Ошибка сохранения", "error" ); setAiEnabled(!checked); } }; const checkAiSettingsFilled = () => { return apiKey.trim() && baseUrl.trim() && model.trim(); }; const updateAiToggleState = () => { const isFilled = checkAiSettingsFilled(); if (!isFilled) { setAiEnabled(false); } }; const loadArchivedNotes = async () => { setIsLoadingArchived(true); try { const notes = await notesApi.getArchived(); setArchivedNotes(notes); } catch (error) { console.error("Ошибка загрузки архивных заметок:", error); showNotification("Ошибка загрузки архивных заметок", "error"); } finally { setIsLoadingArchived(false); } }; const handleRestoreNote = async (id: number | string) => { try { await notesApi.unarchive(Number(id)); await loadArchivedNotes(); showNotification("Заметка восстановлена!", "success"); } catch (error: any) { console.error("Ошибка восстановления заметки:", error); showNotification( error.response?.data?.error || "Ошибка восстановления", "error" ); } }; const handleDeletePermanent = async (id: number | string) => { try { await notesApi.deleteArchived(Number(id)); await loadArchivedNotes(); showNotification("Заметка удалена окончательно", "success"); } catch (error: any) { console.error("Ошибка удаления заметки:", error); showNotification( error.response?.data?.error || "Ошибка удаления", "error" ); } }; const handleDeleteAllArchived = async () => { if (!deleteAllPassword.trim()) { showNotification("Введите пароль", "warning"); return; } setIsDeletingAll(true); try { await notesApi.deleteAllArchived(deleteAllPassword); showNotification("Все архивные заметки удалены", "success"); setIsDeleteAllModalOpen(false); setDeleteAllPassword(""); await loadArchivedNotes(); } catch (error: any) { console.error("Ошибка:", error); showNotification( error.response?.data?.error || "Ошибка удаления", "error" ); } finally { setIsDeletingAll(false); } }; const loadLogs = useCallback( async (reset = false) => { setIsLoadingLogs(true); try { const offset = reset ? 0 : logsOffset; const newLogs = await logsApi.getLogs({ action_type: logTypeFilter || undefined, limit: logsLimit, offset: offset, }); if (reset) { setLogs(newLogs); setLogsOffset(newLogs.length); } else { setLogs((prevLogs) => [...prevLogs, ...newLogs]); setLogsOffset((prevOffset) => prevOffset + newLogs.length); } setHasMoreLogs(newLogs.length === logsLimit); } catch (error) { console.error("Ошибка загрузки логов:", error); showNotification("Ошибка загрузки логов", "error"); } finally { setIsLoadingLogs(false); } }, [logTypeFilter, logsLimit, showNotification, logsOffset] ); const handleLogTypeFilterChange = (value: string) => { setLogTypeFilter(value); setLogsOffset(0); setHasMoreLogs(true); }; useEffect(() => { if (activeTab === "logs") { loadLogs(true); } }, [logTypeFilter, activeTab, loadLogs]); const formatLogAction = (actionType: string) => { const actionTypes: Record = { login: "Вход", logout: "Выход", register: "Регистрация", note_create: "Создание заметки", note_update: "Редактирование", note_delete: "Удаление", note_pin: "Закрепление", note_archive: "Архивирование", note_unarchive: "Восстановление", note_delete_permanent: "Окончательное удаление", profile_update: "Обновление профиля", ai_improve: "Улучшение через AI", }; return actionTypes[actionType] || actionType; }; const handleClearIndexedDB = async () => { setIsClearingIndexedDB(true); try { // Очищаем все заметки из IndexedDB await dbManager.clearAllNotes(); // Очищаем очередь синхронизации await dbManager.clearSyncQueue(); showNotification("Локальный кэш IndexedDB успешно очищен", "success"); setIsClearIndexedDBModalOpen(false); // Обновляем версии данных await loadDataVersions(); } catch (error) { console.error("Ошибка очистки IndexedDB:", error); showNotification("Ошибка очистки IndexedDB", "error"); } finally { setIsClearingIndexedDB(false); } }; const loadDataVersions = async () => { setIsLoadingVersion(true); try { // Загружаем версию с сервера try { const serverVer = await notesApi.getDataVersion(); setServerVersion(serverVer); } catch (error) { console.error("Ошибка загрузки версии с сервера:", error); setServerVersion(null); } // Загружаем версию из IndexedDB try { const localVer = userId ? await dbManager.getDataVersionByUserId(userId) : await dbManager.getDataVersion(); setIndexedDBVersion(localVer); } catch (error) { console.error("Ошибка загрузки версии из IndexedDB:", error); setIndexedDBVersion(null); } } catch (error) { console.error("Ошибка загрузки версий данных:", error); } finally { setIsLoadingVersion(false); } }; const handleForceSync = async () => { if (!navigator.onLine) { showNotification("Нет подключения к интернету", "error"); return; } setIsForceSyncing(true); try { showNotification("Начинаем принудительную синхронизацию...", "info"); // Шаг 1: Сначала отправляем изменения на сервер (если есть) await syncService.startSync(); // Шаг 2: Затем загружаем все данные с сервера для обновления IndexedDB console.log("[ForceSync] Loading all notes from server..."); await offlineNotesApi.getAll(); // Шаг 3: Обновляем версии данных await loadDataVersions(); showNotification("Синхронизация завершена успешно", "success"); } catch (error) { console.error("Ошибка принудительной синхронизации:", error); showNotification("Ошибка при синхронизации", "error"); } finally { setIsForceSyncing(false); } }; const formatDateTime = (dateStr: string | null): string => { if (!dateStr) return "Нет данных"; try { // Парсим дату в формате "YYYY-MM-DD HH:MM:SS" const date = new Date(dateStr.replace(" ", "T") + "Z"); return new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", }).format(date); } catch (error) { return dateStr; } }; const getSyncStatus = (): { status: string; color: string } => { if (!serverVersion || !indexedDBVersion) { return { status: "Неизвестно", color: "#999" }; } // Проверяем количество заметок if (serverVersion.total_notes !== indexedDBVersion.total_notes) { return { status: "Не синхронизировано", color: "#dc3545" }; } // Проверяем время последнего обновления const serverTime = serverVersion.last_updated_at ? new Date( serverVersion.last_updated_at.replace(" ", "T") + "Z" ).getTime() : 0; const localTime = indexedDBVersion.last_updated_at ? new Date( indexedDBVersion.last_updated_at.replace(" ", "T") + "Z" ).getTime() : 0; if (serverTime === 0 && localTime === 0) { return { status: "Нет данных", color: "#999" }; } // Если разница менее 2 минут - считаем синхронизированным if (Math.abs(serverTime - localTime) < 120000) { return { status: "Синхронизировано", color: "#28a745" }; } return { status: "Не синхронизировано", color: "#dc3545" }; }; return (
Настройки
{/* Табы навигации */}
{/* Контент табов */}
{/* Внешний вид */} {activeTab === "appearance" && (

Внешний вид

{colorOptions.map((option) => (
setSelectedAccentColor(option.color)} /> ))}
)} {/* AI настройки */} {activeTab === "ai" && (

Настройки AI

{ setApiKey(e.target.value); updateAiToggleState(); }} />

Введите ваш OpenAI API ключ

{ setBaseUrl(e.target.value); updateAiToggleState(); }} />

URL для API запросов (например, https://api.openai.com/v1)

{ setModel(e.target.value); updateAiToggleState(); }} />

Название модели (например, gpt-4, deepseek/deepseek-chat). {" "} Список доступных моделей

)} {/* Архив заметок */} {activeTab === "archive" && (

Архивные заметки

Архивированные заметки можно восстановить или удалить окончательно

{isLoadingArchived ? (

Загрузка...

) : archivedNotes.length === 0 ? (

Архив пуст

) : ( archivedNotes.map((note) => { const created = new Date( note.created_at.replace(" ", "T") + "Z" ); const dateStr = new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }).format(created); const htmlContent = parseMarkdown(note.content); const preview = htmlContent.substring(0, 200) + (htmlContent.length > 200 ? "..." : ""); return (
{dateStr}
{note.images && note.images.length > 0 && (
{note.images.length} изображений
)}
); }) )}
)} {/* История действий */} {activeTab === "logs" && (

История действий

{/* Фильтры */}
{/* Таблица логов */}
{isLoadingLogs && logs.length === 0 ? ( ) : logs.length === 0 ? ( ) : ( logs.map((log) => { const created = new Date( log.created_at.replace(" ", "T") + "Z" ); const dateStr = new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", }).format(created); return ( ); }) )}
Дата и время Действие Детали
Загрузка...
Логов пока нет
{dateStr} {formatLogAction(log.action_type)} {log.details || "-"}
{hasMoreLogs && logs.length > 0 && (
)}
)} {/* Оффлайн режим */} {activeTab === "offline" && (

Оффлайн режим

{/* Плашка с версиями данных */}

Версии данных

{isLoadingVersion ? (

Загрузка...

) : ( <> {/* Версия на сервере */}
Сервер: {serverVersion?.total_notes || 0} заметок
Обновлено:{" "} {formatDateTime( serverVersion?.last_updated_at || null )}
Создано:{" "} {formatDateTime( serverVersion?.last_created_at || null )}
{/* Версия в IndexedDB */}
IndexedDB (локально): {indexedDBVersion?.total_notes || 0} заметок
Обновлено:{" "} {formatDateTime( indexedDBVersion?.last_updated_at || null )}
Создано:{" "} {formatDateTime( indexedDBVersion?.last_created_at || null )}
{/* Статус синхронизации */}
Статус синхронизации: {getSyncStatus().status}
{/* Кнопка принудительной синхронизации */}

Запустить немедленную синхронизацию данных с сервером

)}

Очистка локального кэша IndexedDB. Это удалит все заметки, сохраненные в браузере для оффлайн-режима. Данные на сервере не будут затронуты.

)}
{/* Модальное окно подтверждения удаления всех архивных заметок */} { setIsDeleteAllModalOpen(false); setDeleteAllPassword(""); }} onConfirm={handleDeleteAllArchived} title="Подтверждение удаления" message={ <>

⚠️ ВНИМАНИЕ: Это действие нельзя отменить!

Вы действительно хотите удалить ВСЕ архивные заметки? Все заметки и их изображения будут удалены навсегда.

setDeleteAllPassword(e.target.value)} onKeyPress={(e) => { if (e.key === "Enter" && !isDeletingAll) { handleDeleteAllArchived(); } }} />
} confirmText={isDeletingAll ? "Удаление..." : "Удалить все"} cancelText="Отмена" confirmType="danger" /> {/* Модальное окно подтверждения очистки IndexedDB */} { setIsClearIndexedDBModalOpen(false); }} onConfirm={handleClearIndexedDB} title="Подтверждение очистки IndexedDB" message={ <>

⚠️ ВНИМАНИЕ: Это действие нельзя отменить!

Вы действительно хотите очистить локальный кэш IndexedDB? Все заметки, сохраненные в браузере для оффлайн-режима, будут удалены.

Данные на сервере не будут затронуты. После очистки данные будут автоматически загружены с сервера при следующем подключении к интернету.

} confirmText={isClearingIndexedDB ? "Очистка..." : "Очистить"} cancelText="Отмена" confirmType="danger" />
); }; export default SettingsPage;