1375 lines
51 KiB
TypeScript
1375 lines
51 KiB
TypeScript
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<SettingsTab>(() => {
|
||
// Восстанавливаем активную вкладку из 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<Note[]>([]);
|
||
const [isLoadingArchived, setIsLoadingArchived] = useState(false);
|
||
|
||
// Logs
|
||
const [logs, setLogs] = useState<Log[]>([]);
|
||
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<string, string> = {
|
||
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 (
|
||
<div className="container">
|
||
<header className="notes-header">
|
||
<span>
|
||
<Icon icon="mdi:cog" /> Настройки
|
||
</span>
|
||
<div className="user-info">
|
||
<ThemeToggle />
|
||
<button
|
||
className="notes-btn"
|
||
onClick={() => navigate("/notes")}
|
||
title="К заметкам"
|
||
>
|
||
<Icon icon="mdi:note-text" />
|
||
</button>
|
||
<button
|
||
className="profile-btn"
|
||
onClick={() => navigate("/profile")}
|
||
title="Профиль"
|
||
>
|
||
<Icon icon="mdi:account" />
|
||
</button>
|
||
<button
|
||
className="logout-btn"
|
||
title="Выйти"
|
||
onClick={async () => {
|
||
try {
|
||
await authApi.logout();
|
||
} catch (error) {
|
||
console.error("Ошибка выхода:", error);
|
||
} finally {
|
||
// Очищаем IndexedDB в фоне (не блокируем выход)
|
||
dbManager.clearAll().catch((err) => {
|
||
console.error("Ошибка очистки IndexedDB при выходе:", err);
|
||
});
|
||
dispatch(clearAuth());
|
||
navigate("/");
|
||
}
|
||
}}
|
||
>
|
||
<Icon icon="mdi:logout" />
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Табы навигации */}
|
||
<div className="settings-tabs">
|
||
<button
|
||
className={`settings-tab ${
|
||
activeTab === "appearance" ? "active" : ""
|
||
}`}
|
||
onClick={() => setActiveTab("appearance")}
|
||
>
|
||
<Icon icon="mdi:palette" /> Внешний вид
|
||
</button>
|
||
<button
|
||
className={`settings-tab ${activeTab === "ai" ? "active" : ""}`}
|
||
onClick={() => setActiveTab("ai")}
|
||
>
|
||
<Icon icon="mdi:robot" /> AI настройки
|
||
</button>
|
||
<button
|
||
className={`settings-tab ${activeTab === "archive" ? "active" : ""}`}
|
||
onClick={() => setActiveTab("archive")}
|
||
>
|
||
<Icon icon="mdi:archive" /> Архив заметок
|
||
</button>
|
||
<button
|
||
className={`settings-tab ${activeTab === "logs" ? "active" : ""}`}
|
||
onClick={() => setActiveTab("logs")}
|
||
>
|
||
<Icon icon="mdi:history" /> История действий
|
||
</button>
|
||
<button
|
||
className={`settings-tab ${activeTab === "offline" ? "active" : ""}`}
|
||
onClick={() => setActiveTab("offline")}
|
||
>
|
||
<Icon icon="mdi:database-off" /> Оффлайн режим
|
||
</button>
|
||
</div>
|
||
|
||
{/* Контент табов */}
|
||
<div className="settings-content">
|
||
{/* Внешний вид */}
|
||
{activeTab === "appearance" && (
|
||
<div className="tab-content active">
|
||
<h3>Внешний вид</h3>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="settings-accentColor">Цветовой акцент</label>
|
||
<div className="accent-color-picker">
|
||
{colorOptions.map((option) => (
|
||
<div
|
||
key={option.color}
|
||
className={`color-option ${
|
||
selectedAccentColor === option.color ? "selected" : ""
|
||
}`}
|
||
data-color={option.color}
|
||
style={{ backgroundColor: option.color }}
|
||
title={option.title}
|
||
onClick={() => setSelectedAccentColor(option.color)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-group ai-toggle-group">
|
||
<label className="ai-toggle-label">
|
||
<div className="toggle-label-content">
|
||
<span className="toggle-text-main">
|
||
Показывать дату редактирования
|
||
</span>
|
||
<span className="toggle-text-desc">
|
||
{showEditDate
|
||
? "Отображать дату последнего редактирования заметки рядом с датой создания"
|
||
: "Показывать только иконку карандаша без даты редактирования"}
|
||
</span>
|
||
</div>
|
||
<div className="toggle-switch-wrapper">
|
||
<input
|
||
type="checkbox"
|
||
id="show-edit-date-toggle"
|
||
className="toggle-checkbox"
|
||
checked={showEditDate}
|
||
onChange={(e) => setShowEditDate(e.target.checked)}
|
||
/>
|
||
<span className="toggle-slider"></span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-group ai-toggle-group">
|
||
<label className="ai-toggle-label">
|
||
<div className="toggle-label-content">
|
||
<span className="toggle-text-main">Цветные иконки</span>
|
||
<span className="toggle-text-desc">
|
||
{coloredIcons
|
||
? "Иконки отображаются разными цветами для лучшей визуальной дифференциации"
|
||
: "Все иконки отображаются в монохромном стиле"}
|
||
</span>
|
||
</div>
|
||
<div className="toggle-switch-wrapper">
|
||
<input
|
||
type="checkbox"
|
||
id="colored-icons-toggle"
|
||
className="toggle-checkbox"
|
||
checked={coloredIcons}
|
||
onChange={(e) => {
|
||
setColoredIcons(e.target.checked);
|
||
updateColoredIconsClass(e.target.checked);
|
||
}}
|
||
/>
|
||
<span className="toggle-slider"></span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-group ai-toggle-group">
|
||
<label className="ai-toggle-label">
|
||
<div className="toggle-label-content">
|
||
<span className="toggle-text-main">
|
||
Плавающая панель редактирования
|
||
</span>
|
||
<span className="toggle-text-desc">
|
||
{floatingToolbarEnabled
|
||
? "Показывать плавающую панель инструментов при выделении текста в редакторе"
|
||
: "Скрывать плавающую панель инструментов при выделении текста"}
|
||
</span>
|
||
</div>
|
||
<div className="toggle-switch-wrapper">
|
||
<input
|
||
type="checkbox"
|
||
id="floating-toolbar-toggle"
|
||
className="toggle-checkbox"
|
||
checked={floatingToolbarEnabled}
|
||
onChange={(e) => setFloatingToolbarEnabled(e.target.checked)}
|
||
/>
|
||
<span className="toggle-slider"></span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<button className="btnSave" onClick={handleUpdateAppearance}>
|
||
Сохранить изменения
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* AI настройки */}
|
||
{activeTab === "ai" && (
|
||
<div className="tab-content active">
|
||
<h3>Настройки AI</h3>
|
||
|
||
<div className="form-group ai-toggle-group">
|
||
<label
|
||
className={`ai-toggle-label ${
|
||
!checkAiSettingsFilled() ? "disabled" : ""
|
||
}`}
|
||
>
|
||
<div className="toggle-label-content">
|
||
<span className="toggle-text-main">Включить помощь ИИ</span>
|
||
<span className="toggle-text-desc">
|
||
{checkAiSettingsFilled()
|
||
? 'Показывать кнопку "Помощь ИИ" в редакторах заметок'
|
||
: "Сначала заполните API Key, Base URL и Модель ниже"}
|
||
</span>
|
||
</div>
|
||
<div className="toggle-switch-wrapper">
|
||
<input
|
||
type="checkbox"
|
||
id="ai-enabled-toggle"
|
||
className="toggle-checkbox"
|
||
checked={aiEnabled}
|
||
disabled={!checkAiSettingsFilled()}
|
||
onChange={(e) => handleAiToggleChange(e.target.checked)}
|
||
/>
|
||
<span className="toggle-slider"></span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="openai-api-key">OpenAI API Key</label>
|
||
<input
|
||
type="password"
|
||
id="openai-api-key"
|
||
placeholder="sk-..."
|
||
className="form-input"
|
||
value={apiKey}
|
||
onChange={(e) => {
|
||
setApiKey(e.target.value);
|
||
updateAiToggleState();
|
||
}}
|
||
/>
|
||
<p style={{ color: "#666", fontSize: "12px", marginTop: "5px" }}>
|
||
Введите ваш OpenAI API ключ
|
||
</p>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="openai-base-url">OpenAI Base URL</label>
|
||
<input
|
||
type="text"
|
||
id="openai-base-url"
|
||
placeholder="https://api.openai.com/v1"
|
||
className="form-input"
|
||
value={baseUrl}
|
||
onChange={(e) => {
|
||
setBaseUrl(e.target.value);
|
||
updateAiToggleState();
|
||
}}
|
||
/>
|
||
<p style={{ color: "#666", fontSize: "12px", marginTop: "5px" }}>
|
||
URL для API запросов (например, https://api.openai.com/v1)
|
||
</p>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="openai-model">Модель</label>
|
||
<input
|
||
type="text"
|
||
id="openai-model"
|
||
placeholder="gpt-3.5-turbo"
|
||
className="form-input"
|
||
value={model}
|
||
onChange={(e) => {
|
||
setModel(e.target.value);
|
||
updateAiToggleState();
|
||
}}
|
||
/>
|
||
<p style={{ color: "#666", fontSize: "12px", marginTop: "5px" }}>
|
||
Название модели (например, gpt-4, deepseek/deepseek-chat).
|
||
<a
|
||
href="https://openrouter.ai/models"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ color: "var(--accent-color)" }}
|
||
>
|
||
{" "}
|
||
Список доступных моделей
|
||
</a>
|
||
</p>
|
||
</div>
|
||
|
||
<button className="btnSave" onClick={handleUpdateAiSettings}>
|
||
Сохранить AI настройки
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Архив заметок */}
|
||
{activeTab === "archive" && (
|
||
<div className="tab-content active">
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
marginBottom: "10px",
|
||
}}
|
||
>
|
||
<h3>Архивные заметки</h3>
|
||
<button
|
||
className="btn-danger"
|
||
style={{ fontSize: "14px", padding: "8px 16px" }}
|
||
onClick={() => setIsDeleteAllModalOpen(true)}
|
||
>
|
||
<Icon icon="mdi:delete-sweep" /> Удалить все
|
||
</button>
|
||
</div>
|
||
<p
|
||
style={{ color: "#666", fontSize: "14px", marginBottom: "20px" }}
|
||
>
|
||
Архивированные заметки можно восстановить или удалить окончательно
|
||
</p>
|
||
<div className="archived-notes-list">
|
||
{isLoadingArchived ? (
|
||
<p style={{ textAlign: "center", color: "#999" }}>
|
||
Загрузка...
|
||
</p>
|
||
) : archivedNotes.length === 0 ? (
|
||
<p style={{ textAlign: "center", color: "#999" }}>Архив пуст</p>
|
||
) : (
|
||
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 (
|
||
<div key={note.id} className="archived-note-item">
|
||
<div className="archived-note-header">
|
||
<span className="archived-note-date">{dateStr}</span>
|
||
<div className="archived-note-actions">
|
||
<button
|
||
className="btn-restore"
|
||
onClick={() => handleRestoreNote(Number(note.id))}
|
||
title="Восстановить"
|
||
>
|
||
<Icon icon="mdi:restore" /> Восстановить
|
||
</button>
|
||
<button
|
||
className="btn-delete-permanent"
|
||
onClick={() => handleDeletePermanent(Number(note.id))}
|
||
title="Удалить навсегда"
|
||
>
|
||
<Icon icon="mdi:delete-forever" /> Удалить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="archived-note-content"
|
||
dangerouslySetInnerHTML={{ __html: preview }}
|
||
/>
|
||
{note.images && note.images.length > 0 && (
|
||
<div className="archived-note-images">
|
||
{note.images.length} изображений
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* История действий */}
|
||
{activeTab === "logs" && (
|
||
<div className="tab-content active">
|
||
<h3>История действий</h3>
|
||
|
||
{/* Фильтры */}
|
||
<div className="logs-filters">
|
||
<select
|
||
id="logTypeFilter"
|
||
className="log-filter-select"
|
||
value={logTypeFilter}
|
||
onChange={(e) => handleLogTypeFilterChange(e.target.value)}
|
||
>
|
||
<option value="">Все действия</option>
|
||
<option value="login">Вход</option>
|
||
<option value="logout">Выход</option>
|
||
<option value="register">Регистрация</option>
|
||
<option value="note_create">Создание заметки</option>
|
||
<option value="note_update">Редактирование заметки</option>
|
||
<option value="note_delete">Удаление заметки</option>
|
||
<option value="note_pin">Закрепление</option>
|
||
<option value="note_archive">Архивирование</option>
|
||
<option value="note_unarchive">Восстановление</option>
|
||
<option value="note_delete_permanent">
|
||
Окончательное удаление
|
||
</option>
|
||
<option value="profile_update">Обновление профиля</option>
|
||
<option value="ai_improve">Улучшение через AI</option>
|
||
</select>
|
||
<button className="btnSave" onClick={() => loadLogs(true)}>
|
||
<Icon icon="mdi:refresh" /> Обновить
|
||
</button>
|
||
</div>
|
||
|
||
{/* Таблица логов */}
|
||
<div className="logs-table-container">
|
||
<table className="logs-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Дата и время</th>
|
||
<th>Действие</th>
|
||
<th>Детали</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{isLoadingLogs && logs.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={3} style={{ textAlign: "center" }}>
|
||
Загрузка...
|
||
</td>
|
||
</tr>
|
||
) : logs.length === 0 ? (
|
||
<tr>
|
||
<td
|
||
colSpan={3}
|
||
style={{ textAlign: "center", color: "#999" }}
|
||
>
|
||
Логов пока нет
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
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 (
|
||
<tr key={log.id}>
|
||
<td>{dateStr}</td>
|
||
<td>
|
||
<span
|
||
className={`log-action-badge log-action-${log.action_type}`}
|
||
>
|
||
{formatLogAction(log.action_type)}
|
||
</span>
|
||
</td>
|
||
<td>{log.details || "-"}</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{hasMoreLogs && logs.length > 0 && (
|
||
<div className="load-more-container">
|
||
<button className="btnSave" onClick={() => loadLogs(false)}>
|
||
Загрузить еще
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Оффлайн режим */}
|
||
{activeTab === "offline" && (
|
||
<div className="tab-content active">
|
||
<h3>Оффлайн режим</h3>
|
||
|
||
{/* Плашка с версиями данных */}
|
||
<div
|
||
style={{
|
||
backgroundColor: "var(--card-bg)",
|
||
border: "1px solid var(--border-color)",
|
||
borderRadius: "8px",
|
||
padding: "20px",
|
||
marginBottom: "20px",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
marginBottom: "15px",
|
||
}}
|
||
>
|
||
<h4
|
||
style={{ margin: 0, fontSize: "16px", fontWeight: "600" }}
|
||
>
|
||
<Icon
|
||
icon="mdi:database-sync"
|
||
style={{ marginRight: "8px", verticalAlign: "middle" }}
|
||
/>
|
||
Версии данных
|
||
</h4>
|
||
<button
|
||
onClick={loadDataVersions}
|
||
disabled={isLoadingVersion}
|
||
style={{
|
||
padding: "6px 12px",
|
||
fontSize: "12px",
|
||
border: "1px solid var(--border-color)",
|
||
borderRadius: "4px",
|
||
backgroundColor: "transparent",
|
||
cursor: isLoadingVersion ? "not-allowed" : "pointer",
|
||
opacity: isLoadingVersion ? 0.6 : 1,
|
||
}}
|
||
title="Обновить"
|
||
>
|
||
<Icon
|
||
icon={isLoadingVersion ? "mdi:loading" : "mdi:refresh"}
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
{isLoadingVersion ? (
|
||
<p
|
||
style={{
|
||
textAlign: "center",
|
||
color: "#999",
|
||
margin: "20px 0",
|
||
}}
|
||
>
|
||
Загрузка...
|
||
</p>
|
||
) : (
|
||
<>
|
||
{/* Версия на сервере */}
|
||
<div style={{ marginBottom: "15px" }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
marginBottom: "5px",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontWeight: "600",
|
||
color: "var(--text-color)",
|
||
}}
|
||
>
|
||
<Icon
|
||
icon="mdi:server"
|
||
style={{
|
||
marginRight: "6px",
|
||
verticalAlign: "middle",
|
||
}}
|
||
/>
|
||
Сервер:
|
||
</span>
|
||
<span style={{ fontSize: "12px", color: "#666" }}>
|
||
{serverVersion?.total_notes || 0} заметок
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: "13px",
|
||
color: "#666",
|
||
marginLeft: "24px",
|
||
}}
|
||
>
|
||
<div>
|
||
Обновлено:{" "}
|
||
{formatDateTime(
|
||
serverVersion?.last_updated_at || null
|
||
)}
|
||
</div>
|
||
<div>
|
||
Создано:{" "}
|
||
{formatDateTime(
|
||
serverVersion?.last_created_at || null
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Версия в IndexedDB */}
|
||
<div style={{ marginBottom: "15px" }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
marginBottom: "5px",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontWeight: "600",
|
||
color: "var(--text-color)",
|
||
}}
|
||
>
|
||
<Icon
|
||
icon="mdi:database"
|
||
style={{
|
||
marginRight: "6px",
|
||
verticalAlign: "middle",
|
||
}}
|
||
/>
|
||
IndexedDB (локально):
|
||
</span>
|
||
<span style={{ fontSize: "12px", color: "#666" }}>
|
||
{indexedDBVersion?.total_notes || 0} заметок
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: "13px",
|
||
color: "#666",
|
||
marginLeft: "24px",
|
||
}}
|
||
>
|
||
<div>
|
||
Обновлено:{" "}
|
||
{formatDateTime(
|
||
indexedDBVersion?.last_updated_at || null
|
||
)}
|
||
</div>
|
||
<div>
|
||
Создано:{" "}
|
||
{formatDateTime(
|
||
indexedDBVersion?.last_created_at || null
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Статус синхронизации */}
|
||
<div
|
||
style={{
|
||
padding: "10px",
|
||
backgroundColor: "var(--bg-color)",
|
||
borderRadius: "6px",
|
||
border: "1px solid var(--border-color)",
|
||
marginTop: "15px",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<span style={{ fontWeight: "600", fontSize: "14px" }}>
|
||
Статус синхронизации:
|
||
</span>
|
||
<span
|
||
style={{
|
||
color: getSyncStatus().color,
|
||
fontWeight: "600",
|
||
fontSize: "13px",
|
||
}}
|
||
>
|
||
{getSyncStatus().status}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Кнопка принудительной синхронизации */}
|
||
<div
|
||
style={{
|
||
marginTop: "15px",
|
||
paddingTop: "15px",
|
||
borderTop: "1px solid var(--border-color)",
|
||
}}
|
||
>
|
||
<button
|
||
onClick={handleForceSync}
|
||
disabled={isForceSyncing}
|
||
style={{
|
||
width: "100%",
|
||
padding: "10px",
|
||
fontSize: "14px",
|
||
fontWeight: "600",
|
||
border: "1px solid var(--border-color)",
|
||
borderRadius: "6px",
|
||
backgroundColor: "var(--accent-color)",
|
||
color: "#fff",
|
||
cursor: isForceSyncing ? "not-allowed" : "pointer",
|
||
opacity: isForceSyncing ? 0.6 : 1,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: "8px",
|
||
}}
|
||
>
|
||
<Icon
|
||
icon={isForceSyncing ? "mdi:loading" : "mdi:sync"}
|
||
style={{ fontSize: "18px" }}
|
||
/>
|
||
{isForceSyncing
|
||
? "Синхронизация..."
|
||
: "Принудительная синхронизация"}
|
||
</button>
|
||
<p
|
||
style={{
|
||
color: "#666",
|
||
fontSize: "12px",
|
||
marginTop: "8px",
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
Запустить немедленную синхронизацию данных с сервером
|
||
</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<p
|
||
style={{
|
||
color: "#666",
|
||
fontSize: "14px",
|
||
marginBottom: "20px",
|
||
}}
|
||
>
|
||
Очистка локального кэша IndexedDB. Это удалит все заметки,
|
||
сохраненные в браузере для оффлайн-режима. Данные на сервере не
|
||
будут затронуты.
|
||
</p>
|
||
<button
|
||
className="btn-danger"
|
||
onClick={() => setIsClearIndexedDBModalOpen(true)}
|
||
style={{ fontSize: "14px", padding: "10px 20px" }}
|
||
>
|
||
<Icon icon="mdi:database-remove" /> Очистить локальный кэш
|
||
(IndexedDB)
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Модальное окно подтверждения удаления всех архивных заметок */}
|
||
<Modal
|
||
isOpen={isDeleteAllModalOpen}
|
||
onClose={() => {
|
||
setIsDeleteAllModalOpen(false);
|
||
setDeleteAllPassword("");
|
||
}}
|
||
onConfirm={handleDeleteAllArchived}
|
||
title="Подтверждение удаления"
|
||
message={
|
||
<>
|
||
<p
|
||
style={{
|
||
color: "#dc3545",
|
||
fontWeight: "bold",
|
||
marginBottom: "15px",
|
||
}}
|
||
>
|
||
⚠️ ВНИМАНИЕ: Это действие нельзя отменить!
|
||
</p>
|
||
<p style={{ marginBottom: "20px" }}>
|
||
Вы действительно хотите удалить ВСЕ архивные заметки? Все заметки
|
||
и их изображения будут удалены навсегда.
|
||
</p>
|
||
<div style={{ marginBottom: "15px" }}>
|
||
<label
|
||
htmlFor="deleteAllPassword"
|
||
style={{
|
||
display: "block",
|
||
marginBottom: "5px",
|
||
fontWeight: "bold",
|
||
}}
|
||
>
|
||
Введите пароль для подтверждения:
|
||
</label>
|
||
<input
|
||
type="password"
|
||
id="deleteAllPassword"
|
||
placeholder="Пароль от аккаунта"
|
||
className="modal-password-input"
|
||
value={deleteAllPassword}
|
||
onChange={(e) => setDeleteAllPassword(e.target.value)}
|
||
onKeyPress={(e) => {
|
||
if (e.key === "Enter" && !isDeletingAll) {
|
||
handleDeleteAllArchived();
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</>
|
||
}
|
||
confirmText={isDeletingAll ? "Удаление..." : "Удалить все"}
|
||
cancelText="Отмена"
|
||
confirmType="danger"
|
||
/>
|
||
|
||
{/* Модальное окно подтверждения очистки IndexedDB */}
|
||
<Modal
|
||
isOpen={isClearIndexedDBModalOpen}
|
||
onClose={() => {
|
||
setIsClearIndexedDBModalOpen(false);
|
||
}}
|
||
onConfirm={handleClearIndexedDB}
|
||
title="Подтверждение очистки IndexedDB"
|
||
message={
|
||
<>
|
||
<p
|
||
style={{
|
||
color: "#dc3545",
|
||
fontWeight: "bold",
|
||
marginBottom: "15px",
|
||
}}
|
||
>
|
||
⚠️ ВНИМАНИЕ: Это действие нельзя отменить!
|
||
</p>
|
||
<p style={{ marginBottom: "20px" }}>
|
||
Вы действительно хотите очистить локальный кэш IndexedDB? Все
|
||
заметки, сохраненные в браузере для оффлайн-режима, будут удалены.
|
||
<br />
|
||
<br />
|
||
<strong>Данные на сервере не будут затронуты.</strong> После
|
||
очистки данные будут автоматически загружены с сервера при
|
||
следующем подключении к интернету.
|
||
</p>
|
||
</>
|
||
}
|
||
confirmText={isClearingIndexedDB ? "Очистка..." : "Очистить"}
|
||
cancelText="Отмена"
|
||
confirmType="danger"
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SettingsPage;
|