Добавлены новые функции для получения версии данных заметок из IndexedDB и сервера. Реализована логика отображения информации о версиях данных в настройках, включая возможность принудительной синхронизации. Обновлены компоненты и стили для поддержки новых функций, а также добавлены методы для очистки локального кэша IndexedDB.
This commit is contained in:
parent
5e16f6d4f0
commit
a5f4e87056
@ -465,7 +465,9 @@ function runMigrations() {
|
||||
}
|
||||
|
||||
// Проверяем существование колонки show_edit_date
|
||||
const hasShowEditDate = columns.some((col) => col.name === "show_edit_date");
|
||||
const hasShowEditDate = columns.some(
|
||||
(col) => col.name === "show_edit_date"
|
||||
);
|
||||
if (!hasShowEditDate) {
|
||||
db.run(
|
||||
"ALTER TABLE users ADD COLUMN show_edit_date INTEGER DEFAULT 1",
|
||||
@ -1059,6 +1061,32 @@ app.put("/api/notes/:id", requireApiAuth, (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// API для получения версии данных (последний updated_at)
|
||||
app.get("/api/notes/version", requireApiAuth, (req, res) => {
|
||||
const sql = `
|
||||
SELECT
|
||||
MAX(updated_at) as last_updated_at,
|
||||
MAX(created_at) as last_created_at,
|
||||
COUNT(*) as total_notes
|
||||
FROM notes
|
||||
WHERE user_id = ? AND is_archived = 0
|
||||
`;
|
||||
|
||||
db.get(sql, [req.session.userId], (err, row) => {
|
||||
if (err) {
|
||||
console.error("Ошибка получения версии данных:", err.message);
|
||||
return res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
last_updated_at: row?.last_updated_at || null,
|
||||
last_created_at: row?.last_created_at || null,
|
||||
total_notes: row?.total_notes || 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// API для удаления заметки
|
||||
app.delete("/api/notes/:id", requireApiAuth, (req, res) => {
|
||||
const { id } = req.params;
|
||||
@ -1564,8 +1592,15 @@ app.get("/profile", requireAuth, (req, res) => {
|
||||
|
||||
// API для обновления профиля
|
||||
app.put("/api/user/profile", requireApiAuth, async (req, res) => {
|
||||
const { username, email, currentPassword, newPassword, accent_color, show_edit_date, colored_icons } =
|
||||
req.body;
|
||||
const {
|
||||
username,
|
||||
email,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
accent_color,
|
||||
show_edit_date,
|
||||
colored_icons,
|
||||
} = req.body;
|
||||
const userId = req.session.userId;
|
||||
|
||||
try {
|
||||
@ -1674,7 +1709,8 @@ app.put("/api/user/profile", requireApiAuth, async (req, res) => {
|
||||
if (username && username !== user.username) changes.push("логин");
|
||||
if (email !== undefined) changes.push("email");
|
||||
if (accent_color !== undefined) changes.push("цвет темы");
|
||||
if (show_edit_date !== undefined) changes.push("настройка показа даты редактирования");
|
||||
if (show_edit_date !== undefined)
|
||||
changes.push("настройка показа даты редактирования");
|
||||
if (colored_icons !== undefined) changes.push("цветные иконки");
|
||||
if (newPassword) changes.push("пароль");
|
||||
const details = `Обновлен профиль: ${changes.join(", ")}`;
|
||||
|
||||
@ -82,7 +82,7 @@ define(['./workbox-9dc17825'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.86r401s0b7"
|
||||
"revision": "0.nsn25edhihg"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
@ -99,6 +99,16 @@ export const notesApi = {
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
getDataVersion: async (): Promise<{
|
||||
last_updated_at: string | null;
|
||||
last_created_at: string | null;
|
||||
total_notes: number;
|
||||
timestamp: number;
|
||||
}> => {
|
||||
const { data } = await axiosClient.get("/notes/version");
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export interface Log {
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import axiosClient from "./axiosClient";
|
||||
import { Note } from "../types/note";
|
||||
import { dbManager } from "../utils/indexedDB";
|
||||
import { generateTempId, isTempId, fileToBase64 } from "../utils/offlineManager";
|
||||
import {
|
||||
generateTempId,
|
||||
isTempId,
|
||||
fileToBase64,
|
||||
} from "../utils/offlineManager";
|
||||
import { checkNetworkStatus } from "../utils/offlineManager";
|
||||
import { store } from "../store/index";
|
||||
import {
|
||||
@ -9,7 +13,6 @@ import {
|
||||
updateNote,
|
||||
addNote,
|
||||
setPendingSyncCount,
|
||||
updateNoteSyncStatus,
|
||||
} from "../store/slices/notesSlice";
|
||||
import { NoteImage, NoteFile } from "../types/note";
|
||||
|
||||
@ -62,7 +65,7 @@ export const offlineNotesApi = {
|
||||
|
||||
if (!online) {
|
||||
// Offline: загружаем из IndexedDB
|
||||
console.log('[Offline] Loading notes from IndexedDB');
|
||||
console.log("[Offline] Loading notes from IndexedDB");
|
||||
const localNotes = userId
|
||||
? await dbManager.getNotesByUserId(userId)
|
||||
: await dbManager.getAllNotes();
|
||||
@ -71,21 +74,50 @@ export const offlineNotesApi = {
|
||||
|
||||
// Online: загружаем с сервера и кэшируем
|
||||
try {
|
||||
console.log('[Online] Loading notes from server');
|
||||
console.log("[Online] Loading notes from server");
|
||||
const { data } = await axiosClient.get<Note[]>("/notes");
|
||||
const notesWithSyncStatus = data.map((note) => ({
|
||||
...note,
|
||||
syncStatus: 'synced' as const,
|
||||
syncStatus: "synced" as const,
|
||||
}));
|
||||
|
||||
// Кэшируем в IndexedDB
|
||||
if (userId) {
|
||||
// Получаем текущие заметки из IndexedDB
|
||||
const localNotes = await dbManager.getNotesByUserId(userId);
|
||||
|
||||
// Создаем Set серверных ID для быстрой проверки
|
||||
const serverNoteIds = new Set(notesWithSyncStatus.map((n) => n.id));
|
||||
|
||||
// Удаляем заметки, которых нет на сервере (кроме временных)
|
||||
for (const localNote of localNotes) {
|
||||
if (
|
||||
typeof localNote.id === "string" &&
|
||||
localNote.id.startsWith("temp-")
|
||||
) {
|
||||
// Временные заметки оставляем (они либо синхронизированы, либо в очереди)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!serverNoteIds.has(localNote.id as number)) {
|
||||
// Заметка удалена на сервере - удаляем из IndexedDB
|
||||
console.log(
|
||||
`[OfflineAPI] Removing deleted note from IndexedDB: ${localNote.id}`
|
||||
);
|
||||
await dbManager.deleteNote(localNote.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем/добавляем все актуальные заметки
|
||||
await dbManager.saveNotes(notesWithSyncStatus);
|
||||
}
|
||||
|
||||
return notesWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes from server, falling back to cache:', error);
|
||||
console.error(
|
||||
"Error fetching notes from server, falling back to cache:",
|
||||
error
|
||||
);
|
||||
// Fallback на IndexedDB при ошибке
|
||||
const localNotes = userId
|
||||
? await dbManager.getNotesByUserId(userId)
|
||||
@ -107,7 +139,7 @@ export const offlineNotesApi = {
|
||||
|
||||
if (!online) {
|
||||
// Offline: загружаем из IndexedDB и фильтруем локально
|
||||
console.log('[Offline] Searching notes locally');
|
||||
console.log("[Offline] Searching notes locally");
|
||||
const allNotes = userId
|
||||
? await dbManager.getNotesByUserId(userId)
|
||||
: await dbManager.getAllNotes();
|
||||
@ -117,11 +149,13 @@ export const offlineNotesApi = {
|
||||
|
||||
// Online: ищем на сервере
|
||||
try {
|
||||
console.log('[Online] Searching notes on server');
|
||||
const { data } = await axiosClient.get<Note[]>("/notes/search", { params });
|
||||
console.log("[Online] Searching notes on server");
|
||||
const { data } = await axiosClient.get<Note[]>("/notes/search", {
|
||||
params,
|
||||
});
|
||||
const notesWithSyncStatus = data.map((note) => ({
|
||||
...note,
|
||||
syncStatus: 'synced' as const,
|
||||
syncStatus: "synced" as const,
|
||||
}));
|
||||
|
||||
// Кэшируем результаты в IndexedDB
|
||||
@ -131,7 +165,7 @@ export const offlineNotesApi = {
|
||||
|
||||
return notesWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error searching notes, falling back to local:', error);
|
||||
console.error("Error searching notes, falling back to local:", error);
|
||||
const allNotes = userId
|
||||
? await dbManager.getNotesByUserId(userId)
|
||||
: await dbManager.getAllNotes();
|
||||
@ -142,13 +176,17 @@ export const offlineNotesApi = {
|
||||
/**
|
||||
* Создание заметки
|
||||
*/
|
||||
create: async (note: { content: string; date: string; time: string }): Promise<Note> => {
|
||||
create: async (note: {
|
||||
content: string;
|
||||
date: string;
|
||||
time: string;
|
||||
}): Promise<Note> => {
|
||||
const online = await isOnline();
|
||||
const userId = getUserId();
|
||||
|
||||
if (!online) {
|
||||
// Offline: создаем локально с временным ID
|
||||
console.log('[Offline] Creating note locally');
|
||||
console.log("[Offline] Creating note locally");
|
||||
const tempId = generateTempId();
|
||||
const now = new Date().toISOString();
|
||||
const newNote: Note = {
|
||||
@ -161,7 +199,7 @@ export const offlineNotesApi = {
|
||||
is_archived: 0,
|
||||
images: [],
|
||||
files: [],
|
||||
syncStatus: 'pending',
|
||||
syncStatus: "pending",
|
||||
};
|
||||
|
||||
// Сохраняем в IndexedDB
|
||||
@ -169,7 +207,7 @@ export const offlineNotesApi = {
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'create',
|
||||
type: "create",
|
||||
noteId: tempId,
|
||||
data: note,
|
||||
timestamp: Date.now(),
|
||||
@ -185,11 +223,11 @@ export const offlineNotesApi = {
|
||||
|
||||
// Online: создаем на сервере
|
||||
try {
|
||||
console.log('[Online] Creating note on server');
|
||||
console.log("[Online] Creating note on server");
|
||||
const { data } = await axiosClient.post("/notes", note);
|
||||
const noteWithSyncStatus = {
|
||||
...data,
|
||||
syncStatus: 'synced' as const,
|
||||
syncStatus: "synced" as const,
|
||||
};
|
||||
|
||||
// Кэшируем
|
||||
@ -202,7 +240,7 @@ export const offlineNotesApi = {
|
||||
|
||||
return noteWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error creating note, falling back to local:', error);
|
||||
console.error("Error creating note, falling back to local:", error);
|
||||
// Fallback на локальное создание
|
||||
return offlineNotesApi.create(note);
|
||||
}
|
||||
@ -211,30 +249,33 @@ export const offlineNotesApi = {
|
||||
/**
|
||||
* Обновление заметки
|
||||
*/
|
||||
update: async (id: number | string, content: string, skipTimestamp?: boolean): Promise<Note> => {
|
||||
update: async (
|
||||
id: number | string,
|
||||
content: string,
|
||||
skipTimestamp?: boolean
|
||||
): Promise<Note> => {
|
||||
const online = await isOnline();
|
||||
const userId = getUserId();
|
||||
|
||||
if (!online) {
|
||||
// Offline: обновляем локально
|
||||
console.log('[Offline] Updating note locally');
|
||||
console.log("[Offline] Updating note locally");
|
||||
const existingNote = await dbManager.getNote(id);
|
||||
if (!existingNote) {
|
||||
throw new Error('Note not found');
|
||||
throw new Error("Note not found");
|
||||
}
|
||||
|
||||
const updatedNote: Note = {
|
||||
...existingNote,
|
||||
content,
|
||||
updated_at: new Date().toISOString(),
|
||||
syncStatus: 'pending',
|
||||
syncStatus: "pending",
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'update',
|
||||
type: "update",
|
||||
noteId: id,
|
||||
data: { content, skipTimestamp },
|
||||
timestamp: Date.now(),
|
||||
@ -249,9 +290,9 @@ export const offlineNotesApi = {
|
||||
|
||||
// Online: обновляем на сервере
|
||||
try {
|
||||
console.log('[Online] Updating note on server');
|
||||
console.log("[Online] Updating note on server");
|
||||
if (isTempId(id)) {
|
||||
throw new Error('Cannot update temp note online');
|
||||
throw new Error("Cannot update temp note online");
|
||||
}
|
||||
|
||||
const { data } = await axiosClient.put(`/notes/${id}`, {
|
||||
@ -261,7 +302,7 @@ export const offlineNotesApi = {
|
||||
|
||||
const noteWithSyncStatus = {
|
||||
...data,
|
||||
syncStatus: 'synced' as const,
|
||||
syncStatus: "synced" as const,
|
||||
};
|
||||
|
||||
await dbManager.saveNote(noteWithSyncStatus);
|
||||
@ -269,7 +310,7 @@ export const offlineNotesApi = {
|
||||
|
||||
return noteWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error updating note, falling back to local:', error);
|
||||
console.error("Error updating note, falling back to local:", error);
|
||||
return offlineNotesApi.update(id, content, skipTimestamp);
|
||||
}
|
||||
},
|
||||
@ -279,16 +320,15 @@ export const offlineNotesApi = {
|
||||
*/
|
||||
delete: async (id: number | string): Promise<void> => {
|
||||
const online = await isOnline();
|
||||
const userId = getUserId();
|
||||
|
||||
if (!online) {
|
||||
// Offline: помечаем для удаления
|
||||
console.log('[Offline] Queuing note for deletion');
|
||||
console.log("[Offline] Queuing note for deletion");
|
||||
const note = await dbManager.getNote(id);
|
||||
if (note) {
|
||||
// Оставляем заметку в БД с пометкой для удаления
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'delete',
|
||||
type: "delete",
|
||||
noteId: id,
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
@ -302,15 +342,15 @@ export const offlineNotesApi = {
|
||||
|
||||
// Online: удаляем на сервере
|
||||
try {
|
||||
console.log('[Online] Deleting note on server');
|
||||
console.log("[Online] Deleting note on server");
|
||||
if (isTempId(id)) {
|
||||
throw new Error('Cannot delete temp note online');
|
||||
throw new Error("Cannot delete temp note online");
|
||||
}
|
||||
|
||||
await axiosClient.delete(`/notes/${id}`);
|
||||
await dbManager.deleteNote(id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting note, falling back to local:', error);
|
||||
console.error("Error deleting note, falling back to local:", error);
|
||||
await offlineNotesApi.delete(id);
|
||||
}
|
||||
},
|
||||
@ -324,7 +364,7 @@ export const offlineNotesApi = {
|
||||
if (!online) {
|
||||
const note = await dbManager.getNote(id);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
throw new Error("Note not found");
|
||||
}
|
||||
|
||||
const updatedNote: Note = {
|
||||
@ -332,14 +372,14 @@ export const offlineNotesApi = {
|
||||
is_pinned: note.is_pinned === 0 ? 1 : 0,
|
||||
pinned_at: note.is_pinned === 0 ? new Date().toISOString() : undefined,
|
||||
updated_at: new Date().toISOString(),
|
||||
syncStatus: 'pending',
|
||||
syncStatus: "pending",
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'update',
|
||||
type: "update",
|
||||
noteId: id,
|
||||
data: { content: note.content, is_pinned: updatedNote.is_pinned },
|
||||
timestamp: Date.now(),
|
||||
@ -354,7 +394,7 @@ export const offlineNotesApi = {
|
||||
|
||||
try {
|
||||
if (isTempId(id)) {
|
||||
throw new Error('Cannot pin temp note online');
|
||||
throw new Error("Cannot pin temp note online");
|
||||
}
|
||||
|
||||
const { data } = await axiosClient.put(`/notes/${id}/pin`);
|
||||
@ -362,14 +402,14 @@ export const offlineNotesApi = {
|
||||
// Получаем текущую заметку и обновляем её
|
||||
const currentNote = await dbManager.getNote(id);
|
||||
if (!currentNote) {
|
||||
throw new Error('Note not found');
|
||||
throw new Error("Note not found");
|
||||
}
|
||||
|
||||
const noteWithSyncStatus = {
|
||||
...currentNote,
|
||||
is_pinned: data.is_pinned,
|
||||
pinned_at: data.is_pinned ? new Date().toISOString() : undefined,
|
||||
syncStatus: 'synced' as const,
|
||||
syncStatus: "synced" as const,
|
||||
};
|
||||
|
||||
await dbManager.saveNote(noteWithSyncStatus);
|
||||
@ -377,7 +417,7 @@ export const offlineNotesApi = {
|
||||
|
||||
return noteWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error pinning note:', error);
|
||||
console.error("Error pinning note:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -391,21 +431,21 @@ export const offlineNotesApi = {
|
||||
if (!online) {
|
||||
const note = await dbManager.getNote(id);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
throw new Error("Note not found");
|
||||
}
|
||||
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
is_archived: 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
syncStatus: 'pending',
|
||||
syncStatus: "pending",
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'update',
|
||||
type: "update",
|
||||
noteId: id,
|
||||
data: { content: note.content, is_archived: 1 },
|
||||
timestamp: Date.now(),
|
||||
@ -420,23 +460,23 @@ export const offlineNotesApi = {
|
||||
|
||||
try {
|
||||
if (isTempId(id)) {
|
||||
throw new Error('Cannot archive temp note online');
|
||||
throw new Error("Cannot archive temp note online");
|
||||
}
|
||||
|
||||
const { data } = await axiosClient.put(`/notes/${id}/archive`);
|
||||
await axiosClient.put(`/notes/${id}/archive`);
|
||||
|
||||
// Получаем текущую заметку и обновляем её
|
||||
const currentNote = await dbManager.getNote(id);
|
||||
if (!currentNote) {
|
||||
throw new Error('Note not found');
|
||||
throw new Error("Note not found");
|
||||
}
|
||||
|
||||
const noteWithSyncStatus = {
|
||||
const noteWithSyncStatus: Note = {
|
||||
...currentNote,
|
||||
is_archived: 1,
|
||||
is_pinned: 0,
|
||||
is_archived: 1 as const,
|
||||
is_pinned: 0 as const,
|
||||
pinned_at: undefined,
|
||||
syncStatus: 'synced' as const,
|
||||
syncStatus: "synced" as const,
|
||||
};
|
||||
|
||||
await dbManager.saveNote(noteWithSyncStatus);
|
||||
@ -444,7 +484,7 @@ export const offlineNotesApi = {
|
||||
|
||||
return noteWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error archiving note:', error);
|
||||
console.error("Error archiving note:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -458,21 +498,21 @@ export const offlineNotesApi = {
|
||||
if (!online) {
|
||||
const note = await dbManager.getNote(id);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
throw new Error("Note not found");
|
||||
}
|
||||
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
is_archived: 0,
|
||||
updated_at: new Date().toISOString(),
|
||||
syncStatus: 'pending',
|
||||
syncStatus: "pending",
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'update',
|
||||
type: "update",
|
||||
noteId: id,
|
||||
data: { content: note.content, is_archived: 0 },
|
||||
timestamp: Date.now(),
|
||||
@ -487,21 +527,21 @@ export const offlineNotesApi = {
|
||||
|
||||
try {
|
||||
if (isTempId(id)) {
|
||||
throw new Error('Cannot unarchive temp note online');
|
||||
throw new Error("Cannot unarchive temp note online");
|
||||
}
|
||||
|
||||
const { data } = await axiosClient.put(`/notes/${id}/unarchive`);
|
||||
await axiosClient.put(`/notes/${id}/unarchive`);
|
||||
|
||||
// Получаем текущую заметку и обновляем её
|
||||
const currentNote = await dbManager.getNote(id);
|
||||
if (!currentNote) {
|
||||
throw new Error('Note not found');
|
||||
throw new Error("Note not found");
|
||||
}
|
||||
|
||||
const noteWithSyncStatus = {
|
||||
const noteWithSyncStatus: Note = {
|
||||
...currentNote,
|
||||
is_archived: 0,
|
||||
syncStatus: 'synced' as const,
|
||||
is_archived: 0 as const,
|
||||
syncStatus: "synced" as const,
|
||||
};
|
||||
|
||||
await dbManager.saveNote(noteWithSyncStatus);
|
||||
@ -509,7 +549,7 @@ export const offlineNotesApi = {
|
||||
|
||||
return noteWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error unarchiving note:', error);
|
||||
console.error("Error unarchiving note:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -517,16 +557,18 @@ export const offlineNotesApi = {
|
||||
/**
|
||||
* Загрузка изображений
|
||||
*/
|
||||
uploadImages: async (noteId: number | string, files: File[]): Promise<NoteImage[]> => {
|
||||
uploadImages: async (
|
||||
noteId: number | string,
|
||||
files: File[]
|
||||
): Promise<NoteImage[]> => {
|
||||
const online = await isOnline();
|
||||
|
||||
if (!online) {
|
||||
// Offline: конвертируем в base64 и сохраняем локально
|
||||
console.log('[Offline] Converting images to base64');
|
||||
const userId = getUserId();
|
||||
console.log("[Offline] Converting images to base64");
|
||||
const note = await dbManager.getNote(noteId);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
throw new Error("Note not found");
|
||||
}
|
||||
|
||||
const images: NoteImage[] = [];
|
||||
@ -537,7 +579,7 @@ export const offlineNotesApi = {
|
||||
note_id: noteId,
|
||||
filename: file.name,
|
||||
original_name: file.name,
|
||||
file_path: '',
|
||||
file_path: "",
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
created_at: new Date().toISOString(),
|
||||
@ -549,7 +591,7 @@ export const offlineNotesApi = {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
images: [...note.images, ...images],
|
||||
syncStatus: 'pending',
|
||||
syncStatus: "pending",
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
@ -557,7 +599,7 @@ export const offlineNotesApi = {
|
||||
// Добавляем в очередь синхронизации
|
||||
for (const image of images) {
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'uploadImage',
|
||||
type: "uploadImage",
|
||||
noteId: noteId,
|
||||
data: { imageId: image.id },
|
||||
timestamp: Date.now(),
|
||||
@ -574,22 +616,26 @@ export const offlineNotesApi = {
|
||||
// Online: загружаем на сервер
|
||||
try {
|
||||
if (isTempId(noteId)) {
|
||||
throw new Error('Cannot upload images for temp note online');
|
||||
throw new Error("Cannot upload images for temp note online");
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append("images", file));
|
||||
|
||||
const { data } = await axiosClient.post(`/notes/${noteId}/images`, formData, {
|
||||
const { data } = await axiosClient.post(
|
||||
`/notes/${noteId}/images`,
|
||||
formData,
|
||||
{
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const note = await dbManager.getNote(noteId);
|
||||
if (note) {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
images: [...note.images, ...data],
|
||||
syncStatus: 'synced',
|
||||
syncStatus: "synced",
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
@ -597,7 +643,7 @@ export const offlineNotesApi = {
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error uploading images:', error);
|
||||
console.error("Error uploading images:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -605,15 +651,18 @@ export const offlineNotesApi = {
|
||||
/**
|
||||
* Загрузка файлов
|
||||
*/
|
||||
uploadFiles: async (noteId: number | string, files: File[]): Promise<NoteFile[]> => {
|
||||
uploadFiles: async (
|
||||
noteId: number | string,
|
||||
files: File[]
|
||||
): Promise<NoteFile[]> => {
|
||||
const online = await isOnline();
|
||||
|
||||
if (!online) {
|
||||
// Offline: конвертируем в base64 и сохраняем локально
|
||||
console.log('[Offline] Converting files to base64');
|
||||
console.log("[Offline] Converting files to base64");
|
||||
const note = await dbManager.getNote(noteId);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
throw new Error("Note not found");
|
||||
}
|
||||
|
||||
const noteFiles: NoteFile[] = [];
|
||||
@ -624,7 +673,7 @@ export const offlineNotesApi = {
|
||||
note_id: noteId,
|
||||
filename: file.name,
|
||||
original_name: file.name,
|
||||
file_path: '',
|
||||
file_path: "",
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
created_at: new Date().toISOString(),
|
||||
@ -636,7 +685,7 @@ export const offlineNotesApi = {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
files: [...note.files, ...noteFiles],
|
||||
syncStatus: 'pending',
|
||||
syncStatus: "pending",
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
@ -644,7 +693,7 @@ export const offlineNotesApi = {
|
||||
// Добавляем в очередь синхронизации
|
||||
for (const file of noteFiles) {
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'uploadFile',
|
||||
type: "uploadFile",
|
||||
noteId: noteId,
|
||||
data: { fileId: file.id },
|
||||
timestamp: Date.now(),
|
||||
@ -661,22 +710,26 @@ export const offlineNotesApi = {
|
||||
// Online: загружаем на сервер
|
||||
try {
|
||||
if (isTempId(noteId)) {
|
||||
throw new Error('Cannot upload files for temp note online');
|
||||
throw new Error("Cannot upload files for temp note online");
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append("files", file));
|
||||
|
||||
const { data } = await axiosClient.post(`/notes/${noteId}/files`, formData, {
|
||||
const { data } = await axiosClient.post(
|
||||
`/notes/${noteId}/files`,
|
||||
formData,
|
||||
{
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const note = await dbManager.getNote(noteId);
|
||||
if (note) {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
files: [...note.files, ...data],
|
||||
syncStatus: 'synced',
|
||||
syncStatus: "synced",
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
@ -684,7 +737,7 @@ export const offlineNotesApi = {
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error uploading files:', error);
|
||||
console.error("Error uploading files:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -692,7 +745,10 @@ export const offlineNotesApi = {
|
||||
/**
|
||||
* Удаление изображения
|
||||
*/
|
||||
deleteImage: async (noteId: number | string, imageId: number | string): Promise<void> => {
|
||||
deleteImage: async (
|
||||
noteId: number | string,
|
||||
imageId: number | string
|
||||
): Promise<void> => {
|
||||
const online = await isOnline();
|
||||
|
||||
if (!online) {
|
||||
@ -701,7 +757,7 @@ export const offlineNotesApi = {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
images: note.images.filter((img) => img.id !== imageId),
|
||||
syncStatus: 'pending',
|
||||
syncStatus: "pending",
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
@ -717,13 +773,13 @@ export const offlineNotesApi = {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
images: note.images.filter((img) => img.id !== imageId),
|
||||
syncStatus: 'synced',
|
||||
syncStatus: "synced",
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting image:', error);
|
||||
console.error("Error deleting image:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -731,7 +787,10 @@ export const offlineNotesApi = {
|
||||
/**
|
||||
* Удаление файла
|
||||
*/
|
||||
deleteFile: async (noteId: number | string, fileId: number | string): Promise<void> => {
|
||||
deleteFile: async (
|
||||
noteId: number | string,
|
||||
fileId: number | string
|
||||
): Promise<void> => {
|
||||
const online = await isOnline();
|
||||
|
||||
if (!online) {
|
||||
@ -740,7 +799,7 @@ export const offlineNotesApi = {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
files: note.files.filter((file) => file.id !== fileId),
|
||||
syncStatus: 'pending',
|
||||
syncStatus: "pending",
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
@ -756,13 +815,13 @@ export const offlineNotesApi = {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
files: note.files.filter((file) => file.id !== fileId),
|
||||
syncStatus: 'synced',
|
||||
syncStatus: "synced",
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
console.error("Error deleting file:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -785,11 +844,11 @@ export const offlineNotesApi = {
|
||||
const { data } = await axiosClient.get<Note[]>("/notes/archived");
|
||||
const notesWithSyncStatus = data.map((note) => ({
|
||||
...note,
|
||||
syncStatus: 'synced' as const,
|
||||
syncStatus: "synced" as const,
|
||||
}));
|
||||
return notesWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error fetching archived notes:', error);
|
||||
console.error("Error fetching archived notes:", error);
|
||||
const allNotes = userId
|
||||
? await dbManager.getNotesByUserId(userId)
|
||||
: await dbManager.getAllNotes();
|
||||
@ -809,7 +868,7 @@ export const offlineNotesApi = {
|
||||
await axiosClient.delete(`/notes/archived/${id}`);
|
||||
await dbManager.deleteNote(id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting archived note:', error);
|
||||
console.error("Error deleting archived note:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -818,7 +877,7 @@ export const offlineNotesApi = {
|
||||
const online = await isOnline();
|
||||
|
||||
if (!online) {
|
||||
throw new Error('Cannot delete all archived in offline mode');
|
||||
throw new Error("Cannot delete all archived in offline mode");
|
||||
}
|
||||
|
||||
try {
|
||||
@ -827,7 +886,7 @@ export const offlineNotesApi = {
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting all archived:', error);
|
||||
console.error("Error deleting all archived:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -881,4 +940,3 @@ async function updatePendingSyncCount(): Promise<void> {
|
||||
const count = await dbManager.getPendingSyncCount();
|
||||
store.dispatch(setPendingSyncCount(count));
|
||||
}
|
||||
|
||||
|
||||
@ -14,18 +14,32 @@ export const InstallPrompt: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Проверяем, не установлено ли приложение уже
|
||||
if (window.matchMedia("(display-mode: standalone)").matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, не закрывал ли пользователь промпт недавно
|
||||
const dismissedDate = localStorage.getItem("pwa-install-dismissed");
|
||||
if (dismissedDate) {
|
||||
const dismissalTime = new Date(dismissedDate).getTime();
|
||||
const currentTime = new Date().getTime();
|
||||
const weekInMs = 7 * 24 * 60 * 60 * 1000; // 7 дней в миллисекундах
|
||||
|
||||
// Если прошло менее недели, не показываем промпт
|
||||
if (currentTime - dismissalTime < weekInMs) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем событие и показываем промпт
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
// Показываем промпт через небольшую задержку
|
||||
setTimeout(() => setShowPrompt(true), 2000);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handler);
|
||||
|
||||
// Проверяем, не установлено ли приложение уже
|
||||
if (window.matchMedia("(display-mode: standalone)").matches) {
|
||||
setShowPrompt(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", handler);
|
||||
};
|
||||
@ -39,6 +53,9 @@ export const InstallPrompt: React.FC = () => {
|
||||
|
||||
if (outcome === "accepted") {
|
||||
console.log("Пользователь принял предложение об установке");
|
||||
// При установке тоже скрываем на неделю
|
||||
const dismissalDate = new Date().toISOString();
|
||||
localStorage.setItem("pwa-install-dismissed", dismissalDate);
|
||||
} else {
|
||||
console.log("Пользователь отклонил предложение об установке");
|
||||
}
|
||||
@ -49,17 +66,11 @@ export const InstallPrompt: React.FC = () => {
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
// Сохраняем в localStorage, что пользователь закрыл промпт
|
||||
localStorage.setItem("pwa-install-dismissed", "true");
|
||||
// Сохраняем в localStorage дату закрытия промпта
|
||||
const dismissalDate = new Date().toISOString();
|
||||
localStorage.setItem("pwa-install-dismissed", dismissalDate);
|
||||
};
|
||||
|
||||
// Не показываем, если пользователь уже закрыл промпт
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem("pwa-install-dismissed") === "true") {
|
||||
setShowPrompt(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!showPrompt || !deferredPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -15,8 +15,11 @@ import { Modal } from "../components/common/Modal";
|
||||
import { ThemeToggle } from "../components/common/ThemeToggle";
|
||||
import { formatDateFromTimestamp } from "../utils/dateFormat";
|
||||
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";
|
||||
type SettingsTab = "appearance" | "ai" | "archive" | "logs" | "offline";
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -25,7 +28,19 @@ const SettingsPage: React.FC = () => {
|
||||
const user = useAppSelector((state) => state.profile.user);
|
||||
const accentColor = useAppSelector((state) => state.ui.accentColor);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("appearance");
|
||||
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");
|
||||
@ -54,6 +69,26 @@ const SettingsPage: React.FC = () => {
|
||||
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: "Синий" },
|
||||
@ -75,6 +110,8 @@ const SettingsPage: React.FC = () => {
|
||||
loadLogs(true);
|
||||
} else if (activeTab === "ai") {
|
||||
loadAiSettings();
|
||||
} else if (activeTab === "offline") {
|
||||
loadDataVersions();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
@ -86,8 +123,15 @@ const SettingsPage: React.FC = () => {
|
||||
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;
|
||||
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);
|
||||
|
||||
@ -341,6 +385,139 @@ const SettingsPage: React.FC = () => {
|
||||
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 userId = user?.id;
|
||||
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">
|
||||
@ -411,6 +588,12 @@ const SettingsPage: React.FC = () => {
|
||||
>
|
||||
<Icon icon="mdi:history" /> История действий
|
||||
</button>
|
||||
<button
|
||||
className={`settings-tab ${activeTab === "offline" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("offline")}
|
||||
>
|
||||
<Icon icon="mdi:database-off" /> Оффлайн режим
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Контент табов */}
|
||||
@ -441,7 +624,9 @@ const SettingsPage: React.FC = () => {
|
||||
<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-main">
|
||||
Показывать дату редактирования
|
||||
</span>
|
||||
<span className="toggle-text-desc">
|
||||
{showEditDate
|
||||
? "Отображать дату последнего редактирования заметки рядом с датой создания"
|
||||
@ -785,6 +970,275 @@ const SettingsPage: React.FC = () => {
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Модальное окно подтверждения удаления всех архивных заметок */}
|
||||
@ -842,6 +1296,41 @@ const SettingsPage: React.FC = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -236,6 +236,89 @@ class IndexedDBManager {
|
||||
const note = await this.getNote(noteId);
|
||||
return note?.syncStatus === 'synced';
|
||||
}
|
||||
|
||||
// ===== Методы для получения версии данных =====
|
||||
|
||||
/**
|
||||
* Получить версию данных из IndexedDB (последний updated_at)
|
||||
*/
|
||||
async getDataVersion(): Promise<{
|
||||
last_updated_at: string | null;
|
||||
last_created_at: string | null;
|
||||
total_notes: number;
|
||||
}> {
|
||||
const notes = await this.getAllNotes();
|
||||
|
||||
// Фильтруем архивные заметки (как на сервере)
|
||||
const activeNotes = notes.filter((note) => note.is_archived === 0);
|
||||
|
||||
if (activeNotes.length === 0) {
|
||||
return {
|
||||
last_updated_at: null,
|
||||
last_created_at: null,
|
||||
total_notes: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Находим максимальный updated_at и created_at
|
||||
let maxUpdatedAt: string | null = null;
|
||||
let maxCreatedAt: string | null = null;
|
||||
|
||||
for (const note of activeNotes) {
|
||||
if (note.updated_at && (!maxUpdatedAt || note.updated_at > maxUpdatedAt)) {
|
||||
maxUpdatedAt = note.updated_at;
|
||||
}
|
||||
if (note.created_at && (!maxCreatedAt || note.created_at > maxCreatedAt)) {
|
||||
maxCreatedAt = note.created_at;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
last_updated_at: maxUpdatedAt,
|
||||
last_created_at: maxCreatedAt,
|
||||
total_notes: activeNotes.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить версию данных для конкретного пользователя
|
||||
*/
|
||||
async getDataVersionByUserId(userId: number): Promise<{
|
||||
last_updated_at: string | null;
|
||||
last_created_at: string | null;
|
||||
total_notes: number;
|
||||
}> {
|
||||
const notes = await this.getNotesByUserId(userId);
|
||||
|
||||
// Фильтруем архивные заметки (как на сервере)
|
||||
const activeNotes = notes.filter((note) => note.is_archived === 0);
|
||||
|
||||
if (activeNotes.length === 0) {
|
||||
return {
|
||||
last_updated_at: null,
|
||||
last_created_at: null,
|
||||
total_notes: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let maxUpdatedAt: string | null = null;
|
||||
let maxCreatedAt: string | null = null;
|
||||
|
||||
for (const note of activeNotes) {
|
||||
if (note.updated_at && (!maxUpdatedAt || note.updated_at > maxUpdatedAt)) {
|
||||
maxUpdatedAt = note.updated_at;
|
||||
}
|
||||
if (note.created_at && (!maxCreatedAt || note.created_at > maxCreatedAt)) {
|
||||
maxCreatedAt = note.created_at;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
last_updated_at: maxUpdatedAt,
|
||||
last_created_at: maxCreatedAt,
|
||||
total_notes: activeNotes.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const dbManager = new IndexedDBManager();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user