noteJS-react/src/pages/SettingsPage.tsx

783 lines
28 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 { 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;