From a5f4e87056b73536303c2f0bea257931042c8d7a Mon Sep 17 00:00:00 2001 From: Fovway Date: Tue, 4 Nov 2025 01:54:50 +0700 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=84=D1=83?= =?UTF-8?q?=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B8=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BE=D0=BA=20=D0=B8=D0=B7=20?= =?UTF-8?q?IndexedDB=20=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0?= =?UTF-8?q?.=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=BE=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=D1=85=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B2=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D1=85,=20=D0=B2=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B0=D1=8F=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE?= =?UTF-8?q?=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=83=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=8B=20=D0=B8=20=D1=81=D1=82=D0=B8=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D1=84=D1=83?= =?UTF-8?q?=D0=BD=D0=BA=D1=86=D0=B8=D0=B9,=20=D0=B0=20=D1=82=D0=B0=D0=BA?= =?UTF-8?q?=D0=B6=D0=B5=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D1=8B=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B8=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA?= =?UTF-8?q?=D1=8D=D1=88=D0=B0=20IndexedDB.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/server.js | 44 ++- dev-dist/sw.js | 2 +- src/api/notesApi.ts | 10 + src/api/offlineNotesApi.ts | 328 +++++++++------- src/components/common/InstallPrompt.tsx | 41 +- src/pages/SettingsPage.tsx | 499 +++++++++++++++++++++++- src/utils/indexedDB.ts | 83 ++++ 7 files changed, 847 insertions(+), 160 deletions(-) diff --git a/backend/server.js b/backend/server.js index 600852e..b93897a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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(", ")}`; diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 9c41415..ec8c3ed 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -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"), { diff --git a/src/api/notesApi.ts b/src/api/notesApi.ts index 51c1d13..93ff961 100644 --- a/src/api/notesApi.ts +++ b/src/api/notesApi.ts @@ -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 { diff --git a/src/api/offlineNotesApi.ts b/src/api/offlineNotesApi.ts index 79b4348..981b36c 100644 --- a/src/api/offlineNotesApi.ts +++ b/src/api/offlineNotesApi.ts @@ -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"; @@ -59,10 +62,10 @@ export const offlineNotesApi = { getAll: async (): Promise => { const online = await isOnline(); const userId = getUserId(); - + 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("/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,31 +139,33 @@ 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(); - + return filterNotesLocally(allNotes, params); } // Online: ищем на сервере try { - console.log('[Online] Searching notes on server'); - const { data } = await axiosClient.get("/notes/search", { params }); + console.log("[Online] Searching notes on server"); + const { data } = await axiosClient.get("/notes/search", { + params, + }); const notesWithSyncStatus = data.map((note) => ({ ...note, - syncStatus: 'synced' as const, + syncStatus: "synced" as const, })); - + // Кэшируем результаты в IndexedDB if (userId) { await dbManager.saveNotes(notesWithSyncStatus); } - + 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 => { + create: async (note: { + content: string; + date: string; + time: string; + }): Promise => { 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,15 +199,15 @@ export const offlineNotesApi = { is_archived: 0, images: [], files: [], - syncStatus: 'pending', + syncStatus: "pending", }; // Сохраняем в IndexedDB await dbManager.saveNote(newNote); - + // Добавляем в очередь синхронизации 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 => { + update: async ( + id: number | string, + content: string, + skipTimestamp?: boolean + ): Promise => { 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 => { 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); } }, @@ -320,32 +360,32 @@ export const offlineNotesApi = { */ pin: async (id: number | string): Promise => { const online = await isOnline(); - + 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_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(), retries: 0, }); - + store.dispatch(updateNote(updatedNote)); await updatePendingSyncCount(); @@ -354,22 +394,22 @@ 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`); - + // Получаем текущую заметку и обновляем её 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; } }, @@ -387,31 +427,31 @@ export const offlineNotesApi = { */ archive: async (id: number | string): Promise => { const online = await isOnline(); - + 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(), retries: 0, }); - + store.dispatch(updateNote(updatedNote)); await updatePendingSyncCount(); @@ -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; } }, @@ -454,31 +494,31 @@ export const offlineNotesApi = { */ unarchive: async (id: number | string): Promise => { const online = await isOnline(); - + 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(), retries: 0, }); - + store.dispatch(updateNote(updatedNote)); await updatePendingSyncCount(); @@ -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 => { + uploadImages: async ( + noteId: number | string, + files: File[] + ): Promise => { 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,15 +591,15 @@ export const offlineNotesApi = { const updatedNote: Note = { ...note, images: [...note.images, ...images], - syncStatus: 'pending', + syncStatus: "pending", }; await dbManager.saveNote(updatedNote); - + // Добавляем в очередь синхронизации 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, { - headers: { "Content-Type": "multipart/form-data" }, - }); + 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 => { + uploadFiles: async ( + noteId: number | string, + files: File[] + ): Promise => { 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,15 +685,15 @@ export const offlineNotesApi = { const updatedNote: Note = { ...note, files: [...note.files, ...noteFiles], - syncStatus: 'pending', + syncStatus: "pending", }; await dbManager.saveNote(updatedNote); - + // Добавляем в очередь синхронизации 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, { - headers: { "Content-Type": "multipart/form-data" }, - }); + 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,16 +745,19 @@ export const offlineNotesApi = { /** * Удаление изображения */ - deleteImage: async (noteId: number | string, imageId: number | string): Promise => { + deleteImage: async ( + noteId: number | string, + imageId: number | string + ): Promise => { const online = await isOnline(); - + if (!online) { const note = await dbManager.getNote(noteId); if (note) { 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,16 +787,19 @@ export const offlineNotesApi = { /** * Удаление файла */ - deleteFile: async (noteId: number | string, fileId: number | string): Promise => { + deleteFile: async ( + noteId: number | string, + fileId: number | string + ): Promise => { const online = await isOnline(); - + if (!online) { const note = await dbManager.getNote(noteId); if (note) { 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("/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(); @@ -799,7 +858,7 @@ export const offlineNotesApi = { deleteArchived: async (id: number | string) => { const online = await isOnline(); - + if (!online) { await offlineNotesApi.delete(id); return; @@ -809,16 +868,16 @@ 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; } }, deleteAllArchived: async (password: string) => { 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 { const count = await dbManager.getPendingSyncCount(); store.dispatch(setPendingSyncCount(count)); } - diff --git a/src/components/common/InstallPrompt.tsx b/src/components/common/InstallPrompt.tsx index ba343b4..7373b5e 100644 --- a/src/components/common/InstallPrompt.tsx +++ b/src/components/common/InstallPrompt.tsx @@ -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; } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6679387..157b8f2 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -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("appearance"); + const [activeTab, setActiveTab] = useState(() => { + // Восстанавливаем активную вкладку из 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 (
@@ -411,6 +588,12 @@ const SettingsPage: React.FC = () => { > История действий +
{/* Контент табов */} @@ -441,7 +624,9 @@ const SettingsPage: React.FC = () => {
{/* Модальное окно подтверждения удаления всех архивных заметок */} @@ -842,6 +1296,41 @@ const SettingsPage: React.FC = () => { cancelText="Отмена" confirmType="danger" /> + + {/* Модальное окно подтверждения очистки IndexedDB */} + { + setIsClearIndexedDBModalOpen(false); + }} + onConfirm={handleClearIndexedDB} + title="Подтверждение очистки IndexedDB" + message={ + <> +

+ ⚠️ ВНИМАНИЕ: Это действие нельзя отменить! +

+

+ Вы действительно хотите очистить локальный кэш IndexedDB? Все + заметки, сохраненные в браузере для оффлайн-режима, будут удалены. +
+
+ Данные на сервере не будут затронуты. После + очистки данные будут автоматически загружены с сервера при + следующем подключении к интернету. +

+ + } + confirmText={isClearingIndexedDB ? "Очистка..." : "Очистить"} + cancelText="Отмена" + confirmType="danger" + /> ); }; diff --git a/src/utils/indexedDB.ts b/src/utils/indexedDB.ts index 0eb568e..916c471 100644 --- a/src/utils/indexedDB.ts +++ b/src/utils/indexedDB.ts @@ -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();