783 lines
28 KiB
TypeScript
783 lines
28 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 { formatDateFromTimestamp } from "../utils/dateFormat";
|
||
import { parseMarkdown } from "../utils/markdown";
|
||
|
||
type SettingsTab = "appearance" | "ai" | "archive" | "logs";
|
||
|
||
const SettingsPage: React.FC = () => {
|
||
const navigate = useNavigate();
|
||
const dispatch = useAppDispatch();
|
||
const { showNotification } = useNotification();
|
||
const user = useAppSelector((state) => state.profile.user);
|
||
const accentColor = useAppSelector((state) => state.ui.accentColor);
|
||
|
||
const [activeTab, setActiveTab] = useState<SettingsTab>("appearance");
|
||
|
||
// Appearance settings
|
||
const [selectedAccentColor, setSelectedAccentColor] = useState("#007bff");
|
||
|
||
// 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);
|
||
|
||
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();
|
||
}
|
||
}, [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);
|
||
|
||
// Загружаем 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,
|
||
});
|
||
dispatch(setAccentColorAction(selectedAccentColor));
|
||
setAccentColor(selectedAccentColor);
|
||
await loadUserInfo();
|
||
showNotification("Цветовой акцент успешно обновлен", "success");
|
||
} catch (error: any) {
|
||
console.error("Ошибка обновления цветового акцента:", error);
|
||
showNotification(
|
||
error.response?.data?.error || "Ошибка обновления",
|
||
"error"
|
||
);
|
||
}
|
||
};
|
||
|
||
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) => {
|
||
try {
|
||
await notesApi.unarchive(id);
|
||
await loadArchivedNotes();
|
||
showNotification("Заметка восстановлена!", "success");
|
||
} catch (error: any) {
|
||
console.error("Ошибка восстановления заметки:", error);
|
||
showNotification(
|
||
error.response?.data?.error || "Ошибка восстановления",
|
||
"error"
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleDeletePermanent = async (id: number) => {
|
||
try {
|
||
await notesApi.deleteArchived(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;
|
||
};
|
||
|
||
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();
|
||
dispatch(clearAuth());
|
||
navigate("/");
|
||
} catch (error) {
|
||
console.error("Ошибка выхода:", error);
|
||
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>
|
||
</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>
|
||
|
||
<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(note.id)}
|
||
title="Восстановить"
|
||
>
|
||
<Icon icon="mdi:restore" /> Восстановить
|
||
</button>
|
||
<button
|
||
className="btn-delete-permanent"
|
||
onClick={() => handleDeletePermanent(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>
|
||
)}
|
||
</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"
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SettingsPage;
|