noteJS-react/src/pages/SettingsPage.tsx

1375 lines
51 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

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;