Добавлены новые функции для получения версии данных заметок из IndexedDB и сервера. Реализована логика отображения информации о версиях данных в настройках, включая возможность принудительной синхронизации. Обновлены компоненты и стили для поддержки новых функций, а также добавлены методы для очистки локального кэша IndexedDB.

This commit is contained in:
Fovway 2025-11-04 01:54:50 +07:00
parent 5e16f6d4f0
commit a5f4e87056
7 changed files with 847 additions and 160 deletions

View File

@ -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(", ")}`;

View File

@ -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"), {

View File

@ -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 {

View File

@ -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));
}

View File

@ -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;
}

View File

@ -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>
);
};

View File

@ -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();