Добавлена поддержка оффлайн-режима и синхронизации заметок. Реализована логика инициализации оффлайн-функционала, включая управление состоянием сети и синхронизацию данных. Обновлены компоненты для отображения статуса синхронизации и индикаторов оффлайн-режима. Оптимизированы API для работы с оффлайн-данными и добавлены новые стили для улучшения пользовательского опыта.
This commit is contained in:
parent
2ec0fd4496
commit
5e16f6d4f0
@ -67,13 +67,10 @@ if (!self.define) {
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-8cfb3eb5'], (function (workbox) { 'use strict';
|
||||
define(['./workbox-9dc17825'], (function (workbox) { 'use strict';
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
@ -85,7 +82,7 @@ define(['./workbox-8cfb3eb5'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.cbp336fi3v8"
|
||||
"revision": "0.86r401s0b7"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
@ -98,6 +95,21 @@ define(['./workbox-8cfb3eb5'], (function (workbox) { 'use strict';
|
||||
maxAgeSeconds: 3600
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/\/api\//, new workbox.NetworkFirst({
|
||||
"cacheName": "api-cache-local",
|
||||
"networkTimeoutSeconds": 10,
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 86400
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/\/uploads\//, new workbox.CacheFirst({
|
||||
"cacheName": "uploads-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 200,
|
||||
maxAgeSeconds: 2592000
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/\.(?:png|jpg|jpeg|svg|gif|webp)$/, new workbox.CacheFirst({
|
||||
"cacheName": "images-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
|
||||
4618
dev-dist/workbox-9dc17825.js
Normal file
4618
dev-dist/workbox-9dc17825.js
Normal file
File diff suppressed because it is too large
Load Diff
884
src/api/offlineNotesApi.ts
Normal file
884
src/api/offlineNotesApi.ts
Normal file
@ -0,0 +1,884 @@
|
||||
import axiosClient from "./axiosClient";
|
||||
import { Note } from "../types/note";
|
||||
import { dbManager } from "../utils/indexedDB";
|
||||
import { generateTempId, isTempId, fileToBase64 } from "../utils/offlineManager";
|
||||
import { checkNetworkStatus } from "../utils/offlineManager";
|
||||
import { store } from "../store/index";
|
||||
import {
|
||||
setOfflineMode,
|
||||
updateNote,
|
||||
addNote,
|
||||
setPendingSyncCount,
|
||||
updateNoteSyncStatus,
|
||||
} from "../store/slices/notesSlice";
|
||||
import { NoteImage, NoteFile } from "../types/note";
|
||||
|
||||
/**
|
||||
* Проверка состояния сети с кэшированием
|
||||
*/
|
||||
let lastNetworkCheck: { time: number; status: boolean } | null = null;
|
||||
const NETWORK_CHECK_CACHE_MS = 5000;
|
||||
|
||||
async function isOnline(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
lastNetworkCheck &&
|
||||
now - lastNetworkCheck.time < NETWORK_CHECK_CACHE_MS
|
||||
) {
|
||||
return lastNetworkCheck.status;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await checkNetworkStatus();
|
||||
lastNetworkCheck = { time: now, status };
|
||||
store.dispatch(setOfflineMode(!status));
|
||||
return status;
|
||||
} catch (error) {
|
||||
const status = navigator.onLine;
|
||||
lastNetworkCheck = { time: now, status };
|
||||
store.dispatch(setOfflineMode(!status));
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение userId из store
|
||||
*/
|
||||
function getUserId(): number | null {
|
||||
const state = store.getState();
|
||||
return state.auth.userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline-aware API обертка
|
||||
*/
|
||||
export const offlineNotesApi = {
|
||||
/**
|
||||
* Получение всех заметок
|
||||
*/
|
||||
getAll: async (): Promise<Note[]> => {
|
||||
const online = await isOnline();
|
||||
const userId = getUserId();
|
||||
|
||||
if (!online) {
|
||||
// Offline: загружаем из IndexedDB
|
||||
console.log('[Offline] Loading notes from IndexedDB');
|
||||
const localNotes = userId
|
||||
? await dbManager.getNotesByUserId(userId)
|
||||
: await dbManager.getAllNotes();
|
||||
return localNotes;
|
||||
}
|
||||
|
||||
// Online: загружаем с сервера и кэшируем
|
||||
try {
|
||||
console.log('[Online] Loading notes from server');
|
||||
const { data } = await axiosClient.get<Note[]>("/notes");
|
||||
const notesWithSyncStatus = data.map((note) => ({
|
||||
...note,
|
||||
syncStatus: 'synced' as const,
|
||||
}));
|
||||
|
||||
// Кэшируем в IndexedDB
|
||||
if (userId) {
|
||||
await dbManager.saveNotes(notesWithSyncStatus);
|
||||
}
|
||||
|
||||
return notesWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes from server, falling back to cache:', error);
|
||||
// Fallback на IndexedDB при ошибке
|
||||
const localNotes = userId
|
||||
? await dbManager.getNotesByUserId(userId)
|
||||
: await dbManager.getAllNotes();
|
||||
return localNotes;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Поиск заметок
|
||||
*/
|
||||
search: async (params: {
|
||||
q?: string;
|
||||
tag?: string;
|
||||
date?: string;
|
||||
}): Promise<Note[]> => {
|
||||
const online = await isOnline();
|
||||
const userId = getUserId();
|
||||
|
||||
if (!online) {
|
||||
// Offline: загружаем из IndexedDB и фильтруем локально
|
||||
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<Note[]>("/notes/search", { params });
|
||||
const notesWithSyncStatus = data.map((note) => ({
|
||||
...note,
|
||||
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);
|
||||
const allNotes = userId
|
||||
? await dbManager.getNotesByUserId(userId)
|
||||
: await dbManager.getAllNotes();
|
||||
return filterNotesLocally(allNotes, params);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Создание заметки
|
||||
*/
|
||||
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');
|
||||
const tempId = generateTempId();
|
||||
const now = new Date().toISOString();
|
||||
const newNote: Note = {
|
||||
...note,
|
||||
id: tempId,
|
||||
user_id: userId || 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
is_pinned: 0,
|
||||
is_archived: 0,
|
||||
images: [],
|
||||
files: [],
|
||||
syncStatus: 'pending',
|
||||
};
|
||||
|
||||
// Сохраняем в IndexedDB
|
||||
await dbManager.saveNote(newNote);
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'create',
|
||||
noteId: tempId,
|
||||
data: note,
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
});
|
||||
|
||||
// Обновляем UI
|
||||
store.dispatch(addNote(newNote));
|
||||
await updatePendingSyncCount();
|
||||
|
||||
return newNote;
|
||||
}
|
||||
|
||||
// Online: создаем на сервере
|
||||
try {
|
||||
console.log('[Online] Creating note on server');
|
||||
const { data } = await axiosClient.post("/notes", note);
|
||||
const noteWithSyncStatus = {
|
||||
...data,
|
||||
syncStatus: 'synced' as const,
|
||||
};
|
||||
|
||||
// Кэшируем
|
||||
if (userId) {
|
||||
await dbManager.saveNote(noteWithSyncStatus);
|
||||
}
|
||||
|
||||
// Обновляем UI
|
||||
store.dispatch(addNote(noteWithSyncStatus));
|
||||
|
||||
return noteWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error creating note, falling back to local:', error);
|
||||
// Fallback на локальное создание
|
||||
return offlineNotesApi.create(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');
|
||||
const existingNote = await dbManager.getNote(id);
|
||||
if (!existingNote) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
const updatedNote: Note = {
|
||||
...existingNote,
|
||||
content,
|
||||
updated_at: new Date().toISOString(),
|
||||
syncStatus: 'pending',
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'update',
|
||||
noteId: id,
|
||||
data: { content, skipTimestamp },
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
});
|
||||
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
await updatePendingSyncCount();
|
||||
|
||||
return updatedNote;
|
||||
}
|
||||
|
||||
// Online: обновляем на сервере
|
||||
try {
|
||||
console.log('[Online] Updating note on server');
|
||||
if (isTempId(id)) {
|
||||
throw new Error('Cannot update temp note online');
|
||||
}
|
||||
|
||||
const { data } = await axiosClient.put(`/notes/${id}`, {
|
||||
content,
|
||||
skipTimestamp,
|
||||
});
|
||||
|
||||
const noteWithSyncStatus = {
|
||||
...data,
|
||||
syncStatus: 'synced' as const,
|
||||
};
|
||||
|
||||
await dbManager.saveNote(noteWithSyncStatus);
|
||||
store.dispatch(updateNote(noteWithSyncStatus));
|
||||
|
||||
return noteWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error updating note, falling back to local:', error);
|
||||
return offlineNotesApi.update(id, content, skipTimestamp);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Удаление заметки
|
||||
*/
|
||||
delete: async (id: number | string): Promise<void> => {
|
||||
const online = await isOnline();
|
||||
const userId = getUserId();
|
||||
|
||||
if (!online) {
|
||||
// Offline: помечаем для удаления
|
||||
console.log('[Offline] Queuing note for deletion');
|
||||
const note = await dbManager.getNote(id);
|
||||
if (note) {
|
||||
// Оставляем заметку в БД с пометкой для удаления
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'delete',
|
||||
noteId: id,
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
});
|
||||
}
|
||||
|
||||
await updatePendingSyncCount();
|
||||
return;
|
||||
}
|
||||
|
||||
// Online: удаляем на сервере
|
||||
try {
|
||||
console.log('[Online] Deleting note on server');
|
||||
if (isTempId(id)) {
|
||||
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);
|
||||
await offlineNotesApi.delete(id);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Закрепление заметки
|
||||
*/
|
||||
pin: async (id: number | string): Promise<Note> => {
|
||||
const online = await isOnline();
|
||||
|
||||
if (!online) {
|
||||
const note = await dbManager.getNote(id);
|
||||
if (!note) {
|
||||
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',
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'update',
|
||||
noteId: id,
|
||||
data: { content: note.content, is_pinned: updatedNote.is_pinned },
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
});
|
||||
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
await updatePendingSyncCount();
|
||||
|
||||
return updatedNote;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isTempId(id)) {
|
||||
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');
|
||||
}
|
||||
|
||||
const noteWithSyncStatus = {
|
||||
...currentNote,
|
||||
is_pinned: data.is_pinned,
|
||||
pinned_at: data.is_pinned ? new Date().toISOString() : undefined,
|
||||
syncStatus: 'synced' as const,
|
||||
};
|
||||
|
||||
await dbManager.saveNote(noteWithSyncStatus);
|
||||
store.dispatch(updateNote(noteWithSyncStatus));
|
||||
|
||||
return noteWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error pinning note:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Архивирование заметки
|
||||
*/
|
||||
archive: async (id: number | string): Promise<Note> => {
|
||||
const online = await isOnline();
|
||||
|
||||
if (!online) {
|
||||
const note = await dbManager.getNote(id);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
is_archived: 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
syncStatus: 'pending',
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'update',
|
||||
noteId: id,
|
||||
data: { content: note.content, is_archived: 1 },
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
});
|
||||
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
await updatePendingSyncCount();
|
||||
|
||||
return updatedNote;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isTempId(id)) {
|
||||
throw new Error('Cannot archive temp note online');
|
||||
}
|
||||
|
||||
const { data } = await axiosClient.put(`/notes/${id}/archive`);
|
||||
|
||||
// Получаем текущую заметку и обновляем её
|
||||
const currentNote = await dbManager.getNote(id);
|
||||
if (!currentNote) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
const noteWithSyncStatus = {
|
||||
...currentNote,
|
||||
is_archived: 1,
|
||||
is_pinned: 0,
|
||||
pinned_at: undefined,
|
||||
syncStatus: 'synced' as const,
|
||||
};
|
||||
|
||||
await dbManager.saveNote(noteWithSyncStatus);
|
||||
store.dispatch(updateNote(noteWithSyncStatus));
|
||||
|
||||
return noteWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error archiving note:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Разархивирование заметки
|
||||
*/
|
||||
unarchive: async (id: number | string): Promise<Note> => {
|
||||
const online = await isOnline();
|
||||
|
||||
if (!online) {
|
||||
const note = await dbManager.getNote(id);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
is_archived: 0,
|
||||
updated_at: new Date().toISOString(),
|
||||
syncStatus: 'pending',
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'update',
|
||||
noteId: id,
|
||||
data: { content: note.content, is_archived: 0 },
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
});
|
||||
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
await updatePendingSyncCount();
|
||||
|
||||
return updatedNote;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isTempId(id)) {
|
||||
throw new Error('Cannot unarchive temp note online');
|
||||
}
|
||||
|
||||
const { data } = await axiosClient.put(`/notes/${id}/unarchive`);
|
||||
|
||||
// Получаем текущую заметку и обновляем её
|
||||
const currentNote = await dbManager.getNote(id);
|
||||
if (!currentNote) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
const noteWithSyncStatus = {
|
||||
...currentNote,
|
||||
is_archived: 0,
|
||||
syncStatus: 'synced' as const,
|
||||
};
|
||||
|
||||
await dbManager.saveNote(noteWithSyncStatus);
|
||||
store.dispatch(updateNote(noteWithSyncStatus));
|
||||
|
||||
return noteWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error unarchiving note:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Загрузка изображений
|
||||
*/
|
||||
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();
|
||||
const note = await dbManager.getNote(noteId);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
const images: NoteImage[] = [];
|
||||
for (const file of files) {
|
||||
const base64 = await fileToBase64(file);
|
||||
const image: NoteImage = {
|
||||
id: generateTempId(),
|
||||
note_id: noteId,
|
||||
filename: file.name,
|
||||
original_name: file.name,
|
||||
file_path: '',
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
created_at: new Date().toISOString(),
|
||||
base64Data: base64,
|
||||
};
|
||||
images.push(image);
|
||||
}
|
||||
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
images: [...note.images, ...images],
|
||||
syncStatus: 'pending',
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
for (const image of images) {
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'uploadImage',
|
||||
noteId: noteId,
|
||||
data: { imageId: image.id },
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
});
|
||||
}
|
||||
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
await updatePendingSyncCount();
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
// Online: загружаем на сервер
|
||||
try {
|
||||
if (isTempId(noteId)) {
|
||||
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 note = await dbManager.getNote(noteId);
|
||||
if (note) {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
images: [...note.images, ...data],
|
||||
syncStatus: 'synced',
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error uploading images:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Загрузка файлов
|
||||
*/
|
||||
uploadFiles: async (noteId: number | string, files: File[]): Promise<NoteFile[]> => {
|
||||
const online = await isOnline();
|
||||
|
||||
if (!online) {
|
||||
// Offline: конвертируем в base64 и сохраняем локально
|
||||
console.log('[Offline] Converting files to base64');
|
||||
const note = await dbManager.getNote(noteId);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
const noteFiles: NoteFile[] = [];
|
||||
for (const file of files) {
|
||||
const base64 = await fileToBase64(file);
|
||||
const noteFile: NoteFile = {
|
||||
id: generateTempId(),
|
||||
note_id: noteId,
|
||||
filename: file.name,
|
||||
original_name: file.name,
|
||||
file_path: '',
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
created_at: new Date().toISOString(),
|
||||
base64Data: base64,
|
||||
};
|
||||
noteFiles.push(noteFile);
|
||||
}
|
||||
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
files: [...note.files, ...noteFiles],
|
||||
syncStatus: 'pending',
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
|
||||
// Добавляем в очередь синхронизации
|
||||
for (const file of noteFiles) {
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'uploadFile',
|
||||
noteId: noteId,
|
||||
data: { fileId: file.id },
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
});
|
||||
}
|
||||
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
await updatePendingSyncCount();
|
||||
|
||||
return noteFiles;
|
||||
}
|
||||
|
||||
// Online: загружаем на сервер
|
||||
try {
|
||||
if (isTempId(noteId)) {
|
||||
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 note = await dbManager.getNote(noteId);
|
||||
if (note) {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
files: [...note.files, ...data],
|
||||
syncStatus: 'synced',
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error uploading files:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Удаление изображения
|
||||
*/
|
||||
deleteImage: async (noteId: number | string, imageId: number | string): Promise<void> => {
|
||||
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',
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
await updatePendingSyncCount();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axiosClient.delete(`/notes/${noteId}/images/${imageId}`);
|
||||
const note = await dbManager.getNote(noteId);
|
||||
if (note) {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
images: note.images.filter((img) => img.id !== imageId),
|
||||
syncStatus: 'synced',
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting image:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Удаление файла
|
||||
*/
|
||||
deleteFile: async (noteId: number | string, fileId: number | string): Promise<void> => {
|
||||
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',
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
await updatePendingSyncCount();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axiosClient.delete(`/notes/${noteId}/files/${fileId}`);
|
||||
const note = await dbManager.getNote(noteId);
|
||||
if (note) {
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
files: note.files.filter((file) => file.id !== fileId),
|
||||
syncStatus: 'synced',
|
||||
};
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Получение архивированных заметок
|
||||
*/
|
||||
getArchived: async (): Promise<Note[]> => {
|
||||
const online = await isOnline();
|
||||
const userId = getUserId();
|
||||
|
||||
if (!online) {
|
||||
const allNotes = userId
|
||||
? await dbManager.getNotesByUserId(userId)
|
||||
: await dbManager.getAllNotes();
|
||||
return allNotes.filter((note) => note.is_archived === 1);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axiosClient.get<Note[]>("/notes/archived");
|
||||
const notesWithSyncStatus = data.map((note) => ({
|
||||
...note,
|
||||
syncStatus: 'synced' as const,
|
||||
}));
|
||||
return notesWithSyncStatus;
|
||||
} catch (error) {
|
||||
console.error('Error fetching archived notes:', error);
|
||||
const allNotes = userId
|
||||
? await dbManager.getNotesByUserId(userId)
|
||||
: await dbManager.getAllNotes();
|
||||
return allNotes.filter((note) => note.is_archived === 1);
|
||||
}
|
||||
},
|
||||
|
||||
deleteArchived: async (id: number | string) => {
|
||||
const online = await isOnline();
|
||||
|
||||
if (!online) {
|
||||
await offlineNotesApi.delete(id);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axiosClient.delete(`/notes/archived/${id}`);
|
||||
await dbManager.deleteNote(id);
|
||||
} catch (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');
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axiosClient.delete("/notes/archived/all", {
|
||||
data: { password },
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting all archived:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Вспомогательная функция для локальной фильтрации
|
||||
*/
|
||||
function filterNotesLocally(
|
||||
notes: Note[],
|
||||
params: { q?: string; tag?: string; date?: string }
|
||||
): Note[] {
|
||||
let filtered = notes;
|
||||
|
||||
if (params.q) {
|
||||
const query = params.q.toLowerCase();
|
||||
filtered = filtered.filter((note) =>
|
||||
note.content.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
if (params.tag) {
|
||||
const tag = params.tag.toLowerCase();
|
||||
filtered = filtered.filter((note) => {
|
||||
const tags = extractTags(note.content);
|
||||
return tags.some((t) => t.toLowerCase() === tag);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.date) {
|
||||
filtered = filtered.filter((note) => note.date === params.date);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function extractTags(content: string): string[] {
|
||||
const tagRegex = /#(\w+)/g;
|
||||
const tags: string[] = [];
|
||||
let match;
|
||||
while ((match = tagRegex.exec(content)) !== null) {
|
||||
tags.push(match[1]);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление счетчика ожидающих синхронизацию
|
||||
*/
|
||||
async function updatePendingSyncCount(): Promise<void> {
|
||||
const count = await dbManager.getPendingSyncCount();
|
||||
store.dispatch(setPendingSyncCount(count));
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ import { ThemeToggle } from "../common/ThemeToggle";
|
||||
import { setUser, setAiSettings } from "../../store/slices/profileSlice";
|
||||
import { setAccentColor as setAccentColorAction } from "../../store/slices/uiSlice";
|
||||
import { setAccentColor } from "../../utils/colorUtils";
|
||||
import { syncService } from "../../services/syncService";
|
||||
import { setSyncStatus } from "../../store/slices/uiSlice";
|
||||
|
||||
interface HeaderProps {
|
||||
onFilterChange?: (hasFilters: boolean) => void;
|
||||
@ -23,6 +25,9 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const selectedDate = useAppSelector((state) => state.notes.selectedDate);
|
||||
const selectedTag = useAppSelector((state) => state.notes.selectedTag);
|
||||
const searchQuery = useAppSelector((state) => state.notes.searchQuery);
|
||||
const offlineMode = useAppSelector((state) => state.notes.offlineMode);
|
||||
const pendingSyncCount = useAppSelector((state) => state.notes.pendingSyncCount);
|
||||
const syncStatus = useAppSelector((state) => state.ui.syncStatus);
|
||||
|
||||
useEffect(() => {
|
||||
loadUserInfo();
|
||||
@ -63,6 +68,11 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualSync = async () => {
|
||||
dispatch(setSyncStatus('syncing'));
|
||||
await syncService.startSync();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Кнопка мобильного меню */}
|
||||
@ -79,6 +89,33 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="user-info">
|
||||
{/* Offline/Sync индикатор */}
|
||||
{offlineMode ? (
|
||||
<div className="offline-indicator" title="Работаем в offline режиме">
|
||||
<Icon icon="mdi:wifi-off" style={{ color: '#ff9800' }} />
|
||||
</div>
|
||||
) : pendingSyncCount > 0 ? (
|
||||
<button
|
||||
className="sync-indicator"
|
||||
title={`Синхронизировать ${pendingSyncCount} заметок`}
|
||||
onClick={handleManualSync}
|
||||
disabled={syncStatus === 'syncing'}
|
||||
>
|
||||
{syncStatus === 'syncing' ? (
|
||||
<Icon icon="mdi:loading" className="spinning" />
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="mdi:cloud-upload" style={{ color: '#007bff' }} />
|
||||
<span className="sync-badge">{pendingSyncCount}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : syncStatus === 'syncing' ? (
|
||||
<div className="sync-indicator">
|
||||
<Icon icon="mdi:loading" className="spinning" style={{ color: '#007bff' }} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{user?.avatar ? (
|
||||
<div
|
||||
className="user-avatar-mini"
|
||||
|
||||
@ -6,7 +6,7 @@ import { ImageUpload } from "./ImageUpload";
|
||||
import { FileUpload } from "./FileUpload";
|
||||
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
||||
import { useNotification } from "../../hooks/useNotification";
|
||||
import { notesApi } from "../../api/notesApi";
|
||||
import { offlineNotesApi } from "../../api/offlineNotesApi";
|
||||
import { aiApi } from "../../api/aiApi";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
@ -47,16 +47,16 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const note = await notesApi.create({ content, date, time });
|
||||
const note = await offlineNotesApi.create({ content, date, time });
|
||||
|
||||
// Загружаем изображения
|
||||
if (images.length > 0) {
|
||||
await notesApi.uploadImages(note.id, images);
|
||||
await offlineNotesApi.uploadImages(note.id, images);
|
||||
}
|
||||
|
||||
// Загружаем файлы
|
||||
if (files.length > 0) {
|
||||
await notesApi.uploadFiles(note.id, files);
|
||||
await offlineNotesApi.uploadFiles(note.id, files);
|
||||
}
|
||||
|
||||
showNotification("Заметка сохранена!", "success");
|
||||
|
||||
@ -10,7 +10,7 @@ import { parseSQLiteUtc, formatLocalDateTime } from "../../utils/dateFormat";
|
||||
import { useMarkdown } from "../../hooks/useMarkdown";
|
||||
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
||||
import { Modal } from "../common/Modal";
|
||||
import { notesApi } from "../../api/notesApi";
|
||||
import { offlineNotesApi } from "../../api/offlineNotesApi";
|
||||
import { useNotification } from "../../hooks/useNotification";
|
||||
import { setSelectedTag } from "../../store/slices/notesSlice";
|
||||
import { getImageUrl, getFileUrl } from "../../utils/filePaths";
|
||||
@ -23,9 +23,9 @@ import { aiApi } from "../../api/aiApi";
|
||||
|
||||
interface NoteItemProps {
|
||||
note: Note;
|
||||
onDelete: (id: number) => void;
|
||||
onPin: (id: number) => void;
|
||||
onArchive: (id: number) => void;
|
||||
onDelete: (id: number | string) => void;
|
||||
onPin: (id: number | string) => void;
|
||||
onArchive: (id: number | string) => void;
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
@ -93,26 +93,26 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
await notesApi.update(note.id, editContent);
|
||||
await offlineNotesApi.update(note.id, editContent);
|
||||
|
||||
// Удаляем выбранные изображения
|
||||
for (const imageId of deletedImageIds) {
|
||||
await notesApi.deleteImage(note.id, imageId);
|
||||
await offlineNotesApi.deleteImage(note.id, imageId);
|
||||
}
|
||||
|
||||
// Удаляем выбранные файлы
|
||||
for (const fileId of deletedFileIds) {
|
||||
await notesApi.deleteFile(note.id, fileId);
|
||||
await offlineNotesApi.deleteFile(note.id, fileId);
|
||||
}
|
||||
|
||||
// Загружаем новые изображения
|
||||
if (editImages.length > 0) {
|
||||
await notesApi.uploadImages(note.id, editImages);
|
||||
await offlineNotesApi.uploadImages(note.id, editImages);
|
||||
}
|
||||
|
||||
// Загружаем новые файлы
|
||||
if (editFiles.length > 0) {
|
||||
await notesApi.uploadFiles(note.id, editFiles);
|
||||
await offlineNotesApi.uploadFiles(note.id, editFiles);
|
||||
}
|
||||
|
||||
showNotification("Заметка обновлена!", "success");
|
||||
@ -1067,6 +1067,16 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
Закреплено
|
||||
</span>
|
||||
) : null}
|
||||
{note.syncStatus === 'pending' && (
|
||||
<span className="sync-indicator" title="Ожидает синхронизации">
|
||||
<Icon icon="mdi:cloud-upload" />
|
||||
</span>
|
||||
)}
|
||||
{note.syncStatus === 'error' && (
|
||||
<span className="sync-error-indicator" title="Ошибка синхронизации">
|
||||
<Icon icon="mdi:cloud-alert" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="note-actions">
|
||||
<div
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useImperativeHandle, forwardRef } from "react";
|
||||
import { NoteItem } from "./NoteItem";
|
||||
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
||||
import { notesApi } from "../../api/notesApi";
|
||||
import { offlineNotesApi } from "../../api/offlineNotesApi";
|
||||
import { setNotes, setAllNotes } from "../../store/slices/notesSlice";
|
||||
import { useNotification } from "../../hooks/useNotification";
|
||||
import { extractTags } from "../../utils/markdown";
|
||||
@ -23,7 +23,7 @@ export const NotesList = forwardRef<NotesListRef>((props, ref) => {
|
||||
const loadNotes = async () => {
|
||||
try {
|
||||
// Всегда загружаем все заметки для тегов и календаря
|
||||
const allData = await notesApi.getAll();
|
||||
const allData = await offlineNotesApi.getAll();
|
||||
let filteredAllData = allData;
|
||||
if (userId) {
|
||||
filteredAllData = allData.filter((note) => note.user_id === userId);
|
||||
@ -33,7 +33,7 @@ export const NotesList = forwardRef<NotesListRef>((props, ref) => {
|
||||
// Для списка заметок: если есть фильтры - делаем поисковый запрос, иначе используем все заметки
|
||||
let notesData;
|
||||
if (searchQuery || selectedDate || selectedTag) {
|
||||
notesData = await notesApi.search({
|
||||
notesData = await offlineNotesApi.search({
|
||||
q: searchQuery || undefined,
|
||||
date: selectedDate || undefined,
|
||||
tag: selectedTag || undefined,
|
||||
@ -57,6 +57,9 @@ export const NotesList = forwardRef<NotesListRef>((props, ref) => {
|
||||
notesData = filteredAllData;
|
||||
}
|
||||
|
||||
// Фильтруем архивные заметки из основного списка
|
||||
notesData = notesData.filter((note) => note.is_archived === 0);
|
||||
|
||||
dispatch(setNotes(notesData));
|
||||
} catch (error) {
|
||||
console.error("Ошибка загрузки заметок:", error);
|
||||
@ -76,9 +79,9 @@ export const NotesList = forwardRef<NotesListRef>((props, ref) => {
|
||||
reloadNotes: loadNotes,
|
||||
}));
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleDelete = async (id: number | string) => {
|
||||
try {
|
||||
await notesApi.delete(id);
|
||||
await offlineNotesApi.delete(id);
|
||||
showNotification("Заметка удалена", "success");
|
||||
loadNotes();
|
||||
} catch (error) {
|
||||
@ -87,9 +90,9 @@ export const NotesList = forwardRef<NotesListRef>((props, ref) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePin = async (id: number) => {
|
||||
const handlePin = async (id: number | string) => {
|
||||
try {
|
||||
await notesApi.pin(id);
|
||||
await offlineNotesApi.pin(id);
|
||||
loadNotes();
|
||||
} catch (error) {
|
||||
console.error("Ошибка закрепления заметки:", error);
|
||||
@ -97,9 +100,9 @@ export const NotesList = forwardRef<NotesListRef>((props, ref) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: number) => {
|
||||
const handleArchive = async (id: number | string) => {
|
||||
try {
|
||||
await notesApi.archive(id);
|
||||
await offlineNotesApi.archive(id);
|
||||
showNotification("Заметка архивирована", "success");
|
||||
loadNotes();
|
||||
} catch (error) {
|
||||
|
||||
76
src/main.tsx
76
src/main.tsx
@ -5,9 +5,85 @@ import "./styles/index.css";
|
||||
import "./styles/theme.css";
|
||||
import "./styles/style.css";
|
||||
import "./styles/style-calendar.css";
|
||||
import { dbManager } from "./utils/indexedDB";
|
||||
import { networkListener, checkNetworkStatus } from "./utils/offlineManager";
|
||||
import { syncService } from "./services/syncService";
|
||||
import { store } from "./store/index";
|
||||
import { setOfflineMode, setPendingSyncCount } from "./store/slices/notesSlice";
|
||||
import { addNotification } from "./store/slices/uiSlice";
|
||||
|
||||
// Регистрация PWA (vite-plugin-pwa автоматически внедряет регистрацию через injectRegister: "auto")
|
||||
|
||||
// Инициализация offline функционала
|
||||
async function initOfflineMode() {
|
||||
try {
|
||||
console.log('[Init] Initializing offline mode...');
|
||||
|
||||
// Инициализация IndexedDB
|
||||
await dbManager.init();
|
||||
console.log('[Init] IndexedDB initialized');
|
||||
|
||||
// Проверка состояния сети
|
||||
const isOnline = await checkNetworkStatus();
|
||||
store.dispatch(setOfflineMode(!isOnline));
|
||||
console.log(`[Init] Network status: ${isOnline ? 'online' : 'offline'}`);
|
||||
|
||||
// Установка listeners для событий сети
|
||||
networkListener.onOnline(async () => {
|
||||
console.log('[Network] Online event detected');
|
||||
const isOnline = await checkNetworkStatus();
|
||||
store.dispatch(setOfflineMode(!isOnline));
|
||||
|
||||
if (isOnline) {
|
||||
store.dispatch(
|
||||
addNotification({
|
||||
message: 'Подключение восстановлено, начинаем синхронизацию...',
|
||||
type: 'info',
|
||||
})
|
||||
);
|
||||
|
||||
// Запуск синхронизации
|
||||
await syncService.startSync();
|
||||
}
|
||||
});
|
||||
|
||||
networkListener.onOffline(() => {
|
||||
console.log('[Network] Offline event detected');
|
||||
store.dispatch(setOfflineMode(true));
|
||||
store.dispatch(
|
||||
addNotification({
|
||||
message: 'Работаем в offline режиме',
|
||||
type: 'warning',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Обновление счетчика ожидающих синхронизацию
|
||||
const pendingCount = await dbManager.getPendingSyncCount();
|
||||
store.dispatch(setPendingSyncCount(pendingCount));
|
||||
|
||||
if (pendingCount > 0) {
|
||||
console.log(`[Init] Found ${pendingCount} pending sync items`);
|
||||
}
|
||||
|
||||
// Автоматическая синхронизация при старте если есть что синхронизировать
|
||||
if (isOnline && pendingCount > 0) {
|
||||
console.log('[Init] Starting initial sync...');
|
||||
// Небольшая задержка для инициализации UI
|
||||
setTimeout(() => {
|
||||
syncService.startSync();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
console.log('[Init] Offline mode initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[Init] Error initializing offline mode:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Запуск инициализации
|
||||
initOfflineMode();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
563
src/services/syncService.ts
Normal file
563
src/services/syncService.ts
Normal file
@ -0,0 +1,563 @@
|
||||
import { dbManager } from '../utils/indexedDB';
|
||||
import axiosClient from '../api/axiosClient';
|
||||
import { base64ToBlob } from '../utils/offlineManager';
|
||||
import { Note, NoteImage, NoteFile } from '../types/note';
|
||||
import { store } from '../store/index';
|
||||
import {
|
||||
setSyncStatus,
|
||||
removeNotification,
|
||||
addNotification,
|
||||
} from '../store/slices/uiSlice';
|
||||
import {
|
||||
updateNote,
|
||||
setPendingSyncCount,
|
||||
setOfflineMode,
|
||||
} from '../store/slices/notesSlice';
|
||||
import { SyncQueueItem } from '../types/note';
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 5000;
|
||||
|
||||
class SyncService {
|
||||
private isSyncing = false;
|
||||
private syncTimer: NodeJS.Timeout | null = null;
|
||||
private listeners: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
* Запустить синхронизацию
|
||||
*/
|
||||
async startSync(): Promise<void> {
|
||||
if (this.isSyncing) {
|
||||
console.log('[SyncService] Already syncing, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
const isOnline = navigator.onLine;
|
||||
if (!isOnline) {
|
||||
console.log('[SyncService] Offline, skipping sync');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SyncService] Starting sync...');
|
||||
this.isSyncing = true;
|
||||
store.dispatch(setSyncStatus('syncing'));
|
||||
|
||||
try {
|
||||
const queue = await dbManager.getSyncQueue();
|
||||
console.log(`[SyncService] Found ${queue.length} items in queue`);
|
||||
|
||||
if (queue.length === 0) {
|
||||
console.log('[SyncService] Queue is empty, sync complete');
|
||||
this.isSyncing = false;
|
||||
store.dispatch(setSyncStatus('idle'));
|
||||
this.notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Map для отслеживания преобразования temp ID -> server ID
|
||||
const idMapping = new Map<string | number, number>();
|
||||
|
||||
// Сортируем по timestamp для обработки в порядке создания
|
||||
const sortedQueue = [...queue].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
for (const item of sortedQueue) {
|
||||
try {
|
||||
// Обновляем noteId если есть mapping
|
||||
let actualNoteId = item.noteId;
|
||||
if (idMapping.has(item.noteId)) {
|
||||
actualNoteId = idMapping.get(item.noteId)!;
|
||||
console.log(`[SyncService] Mapped temp ID ${item.noteId} to server ID ${actualNoteId}`);
|
||||
}
|
||||
|
||||
const itemWithUpdatedId = { ...item, noteId: actualNoteId };
|
||||
await this.processSyncItem(itemWithUpdatedId, idMapping);
|
||||
await dbManager.removeFromSyncQueue(item.id);
|
||||
successCount++;
|
||||
|
||||
// Обновляем счетчик
|
||||
await this.updatePendingCount();
|
||||
} catch (error) {
|
||||
console.error('[SyncService] Error processing item:', item, error);
|
||||
errorCount++;
|
||||
|
||||
// Увеличиваем счетчик попыток - используем оригинальный item (без обновленного ID)
|
||||
const updatedItem = {
|
||||
...item,
|
||||
retries: item.retries + 1,
|
||||
lastError: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
|
||||
if (updatedItem.retries < MAX_RETRIES) {
|
||||
// Сохраняем для повторной попытки
|
||||
await dbManager.updateSyncQueueItem(item.id, updatedItem);
|
||||
|
||||
// Запускаем отложенную синхронизацию
|
||||
this.scheduleRetry();
|
||||
} else {
|
||||
// Превысили максимум попыток - удаляем
|
||||
console.error('[SyncService] Max retries exceeded, removing item:', item);
|
||||
await dbManager.removeFromSyncQueue(item.id);
|
||||
|
||||
// Обновляем статус заметки на error - пытаемся найти по обоим ID
|
||||
if (item.type === 'create' || item.type === 'update') {
|
||||
let note = await dbManager.getNote(item.noteId);
|
||||
if (!note && idMapping.has(item.noteId)) {
|
||||
const serverId = idMapping.get(item.noteId);
|
||||
if (serverId) {
|
||||
note = await dbManager.getNote(serverId);
|
||||
}
|
||||
}
|
||||
if (note) {
|
||||
store.dispatch(
|
||||
updateNote({
|
||||
...note,
|
||||
syncStatus: 'error',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
store.dispatch(
|
||||
addNotification({
|
||||
message: 'Не удалось синхронизировать некоторые заметки',
|
||||
type: 'error',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Небольшая задержка между обработкой элементов
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SyncService] Sync complete. Success: ${successCount}, Errors: ${errorCount}`
|
||||
);
|
||||
|
||||
// Полная синхронизация данных после успешной синхронизации очереди
|
||||
if (successCount > 0) {
|
||||
// Полная синхронизация: загружаем все данные с сервера
|
||||
try {
|
||||
console.log('[SyncService] Performing full data sync...');
|
||||
const { data } = await axiosClient.get<Note[]>("/notes");
|
||||
const notesWithSyncStatus = data.map((note) => ({
|
||||
...note,
|
||||
syncStatus: 'synced' as const,
|
||||
}));
|
||||
|
||||
// Получаем текущие заметки из IndexedDB
|
||||
const userId = store.getState().auth.userId;
|
||||
const localNotes = userId
|
||||
? await dbManager.getNotesByUserId(userId)
|
||||
: await dbManager.getAllNotes();
|
||||
|
||||
// Создаем 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(`[SyncService] Removing deleted note from IndexedDB: ${localNote.id}`);
|
||||
await dbManager.deleteNote(localNote.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем/добавляем все актуальные заметки
|
||||
await dbManager.saveNotes(notesWithSyncStatus);
|
||||
console.log('[SyncService] Full data sync completed');
|
||||
} catch (error) {
|
||||
console.error('[SyncService] Error during full data sync:', error);
|
||||
// Не критично - очередь уже синхронизирована
|
||||
}
|
||||
|
||||
store.dispatch(
|
||||
addNotification({
|
||||
message: `Синхронизировано заметок: ${successCount}`,
|
||||
type: 'success',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (errorCount > 0 && errorCount === sortedQueue.length) {
|
||||
store.dispatch(setSyncStatus('error'));
|
||||
} else {
|
||||
store.dispatch(setSyncStatus('idle'));
|
||||
}
|
||||
|
||||
await this.updatePendingCount();
|
||||
this.notifyListeners();
|
||||
} catch (error) {
|
||||
console.error('[SyncService] Fatal sync error:', error);
|
||||
store.dispatch(setSyncStatus('error'));
|
||||
store.dispatch(
|
||||
addNotification({
|
||||
message: 'Ошибка синхронизации',
|
||||
type: 'error',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка одного элемента синхронизации
|
||||
*/
|
||||
private async processSyncItem(item: SyncQueueItem, idMapping?: Map<string | number, number>): Promise<void> {
|
||||
console.log(`[SyncService] Processing ${item.type} for note ${item.noteId}`);
|
||||
|
||||
switch (item.type) {
|
||||
case 'create':
|
||||
await this.syncCreate(item, idMapping);
|
||||
break;
|
||||
case 'update':
|
||||
await this.syncUpdate(item, idMapping);
|
||||
break;
|
||||
case 'delete':
|
||||
await this.syncDelete(item);
|
||||
break;
|
||||
case 'uploadImage':
|
||||
await this.syncUploadImage(item);
|
||||
break;
|
||||
case 'uploadFile':
|
||||
await this.syncUploadFile(item);
|
||||
break;
|
||||
default:
|
||||
console.warn(`[SyncService] Unknown sync type: ${item.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация создания заметки
|
||||
*/
|
||||
private async syncCreate(item: SyncQueueItem, idMapping?: Map<string | number, number>): Promise<void> {
|
||||
const note = await dbManager.getNote(item.noteId);
|
||||
if (!note) {
|
||||
throw new Error('Note not found in local database');
|
||||
}
|
||||
|
||||
// Создаем заметку на сервере
|
||||
const { data: serverNote } = await axiosClient.post('/notes', {
|
||||
content: note.content,
|
||||
date: note.date,
|
||||
time: note.time,
|
||||
});
|
||||
|
||||
// Сохраняем маппинг temp ID -> server ID
|
||||
if (idMapping && typeof item.noteId === 'string') {
|
||||
idMapping.set(item.noteId, serverNote.id);
|
||||
}
|
||||
|
||||
// Заменяем временные ID на серверные в локальных данных
|
||||
const updatedNote: Note = {
|
||||
...serverNote,
|
||||
images: await this.updateImageReferences(note, serverNote),
|
||||
files: await this.updateFileReferences(note, serverNote),
|
||||
syncStatus: 'synced',
|
||||
};
|
||||
|
||||
// Удаляем старую заметку с временным ID
|
||||
await dbManager.deleteNote(item.noteId);
|
||||
|
||||
// Сохраняем с серверным ID
|
||||
await dbManager.saveNote(updatedNote);
|
||||
|
||||
// Обновляем UI
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
|
||||
// Загружаем изображения и файлы если есть
|
||||
await this.syncAttachments(note, serverNote.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация обновления заметки
|
||||
*/
|
||||
private async syncUpdate(item: SyncQueueItem, idMapping?: Map<string | number, number>): Promise<void> {
|
||||
// Проверяем сначала, является ли это временной заметкой
|
||||
if (typeof item.noteId === 'string' && item.noteId.startsWith('temp-') && !idMapping?.has(item.noteId)) {
|
||||
// Если это временная заметка, нужно создать её на сервере
|
||||
await this.syncCreate(item, idMapping);
|
||||
return;
|
||||
}
|
||||
|
||||
const note = await dbManager.getNote(item.noteId);
|
||||
if (!note) {
|
||||
console.warn(`[SyncService] Note ${item.noteId} not found, skipping update`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Определяем, какой эндпоинт использовать в зависимости от изменений
|
||||
if (item.data.hasOwnProperty('is_pinned')) {
|
||||
// Специальный эндпоинт для закрепления
|
||||
await axiosClient.put(`/notes/${item.noteId}/pin`);
|
||||
} else if (item.data.hasOwnProperty('is_archived')) {
|
||||
// Специальный эндпоинт для архивирования
|
||||
if (item.data.is_archived === 1) {
|
||||
await axiosClient.put(`/notes/${item.noteId}/archive`);
|
||||
} else {
|
||||
await axiosClient.put(`/notes/${item.noteId}/unarchive`);
|
||||
}
|
||||
} else {
|
||||
// Обычное обновление содержимого
|
||||
await axiosClient.put(`/notes/${item.noteId}`, {
|
||||
content: note.content,
|
||||
skipTimestamp: item.data.skipTimestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Обновляем локальную заметку с новым статусом синхронизации
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
syncStatus: 'synced',
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация удаления заметки
|
||||
*/
|
||||
private async syncDelete(item: SyncQueueItem): Promise<void> {
|
||||
if (typeof item.noteId === 'string' && item.noteId.startsWith('temp-')) {
|
||||
// Временная заметка - просто удаляем локально
|
||||
await dbManager.deleteNote(item.noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Удаляем на сервере
|
||||
await axiosClient.delete(`/notes/${item.noteId}`);
|
||||
await dbManager.deleteNote(item.noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация загрузки изображения
|
||||
*/
|
||||
private async syncUploadImage(item: SyncQueueItem): Promise<void> {
|
||||
const note = await dbManager.getNote(item.noteId);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
const tempImageId = item.data.imageId;
|
||||
const tempImage = note.images.find((img) => img.id === tempImageId);
|
||||
|
||||
if (!tempImage || !tempImage.base64Data) {
|
||||
console.warn('[SyncService] Temp image not found or no base64 data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Конвертируем base64 в blob
|
||||
const blob = base64ToBlob(tempImage.base64Data, tempImage.mime_type);
|
||||
const file = new File([blob], tempImage.original_name, {
|
||||
type: tempImage.mime_type,
|
||||
});
|
||||
|
||||
// Загружаем на сервер
|
||||
const formData = new FormData();
|
||||
formData.append('images', file);
|
||||
|
||||
const { data: uploadedImages } = await axiosClient.post(
|
||||
`/notes/${item.noteId}/images`,
|
||||
formData,
|
||||
{
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}
|
||||
);
|
||||
|
||||
// Обновляем заметку с серверными данными
|
||||
const updatedImages = note.images.map((img) =>
|
||||
img.id === tempImageId ? uploadedImages[0] : img
|
||||
);
|
||||
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
images: updatedImages,
|
||||
syncStatus: 'synced',
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация загрузки файла
|
||||
*/
|
||||
private async syncUploadFile(item: SyncQueueItem): Promise<void> {
|
||||
const note = await dbManager.getNote(item.noteId);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
const tempFileId = item.data.fileId;
|
||||
const tempFile = note.files.find((file) => file.id === tempFileId);
|
||||
|
||||
if (!tempFile || !tempFile.base64Data) {
|
||||
console.warn('[SyncService] Temp file not found or no base64 data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Конвертируем base64 в blob
|
||||
const blob = base64ToBlob(tempFile.base64Data, tempFile.mime_type);
|
||||
const file = new File([blob], tempFile.original_name, {
|
||||
type: tempFile.mime_type,
|
||||
});
|
||||
|
||||
// Загружаем на сервер
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
|
||||
const { data: uploadedFiles } = await axiosClient.post(
|
||||
`/notes/${item.noteId}/files`,
|
||||
formData,
|
||||
{
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}
|
||||
);
|
||||
|
||||
// Обновляем заметку с серверными данными
|
||||
const updatedFiles = note.files.map((file) =>
|
||||
file.id === tempFileId ? uploadedFiles[0] : file
|
||||
);
|
||||
|
||||
const updatedNote: Note = {
|
||||
...note,
|
||||
files: updatedFiles,
|
||||
syncStatus: 'synced',
|
||||
};
|
||||
|
||||
await dbManager.saveNote(updatedNote);
|
||||
store.dispatch(updateNote(updatedNote));
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление ссылок на изображения после создания заметки на сервере
|
||||
*/
|
||||
private async updateImageReferences(
|
||||
localNote: Note,
|
||||
serverNote: Note
|
||||
): Promise<NoteImage[]> {
|
||||
// Если нет изображений с base64, возвращаем как есть
|
||||
const hasBase64Images = localNote.images.some((img) => img.base64Data);
|
||||
if (!hasBase64Images) {
|
||||
return localNote.images;
|
||||
}
|
||||
|
||||
// Возвращаем изображения с временными ID (будут обновлены при загрузке)
|
||||
return localNote.images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление ссылок на файлы после создания заметки на сервере
|
||||
*/
|
||||
private async updateFileReferences(
|
||||
localNote: Note,
|
||||
serverNote: Note
|
||||
): Promise<NoteFile[]> {
|
||||
// Если нет файлов с base64, возвращаем как есть
|
||||
const hasBase64Files = localNote.files.some((file) => file.base64Data);
|
||||
if (!hasBase64Files) {
|
||||
return localNote.files;
|
||||
}
|
||||
|
||||
// Возвращаем файлы с временными ID (будут обновлены при загрузке)
|
||||
return localNote.files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация вложений (изображения и файлы)
|
||||
*/
|
||||
private async syncAttachments(localNote: Note, serverNoteId: number): Promise<void> {
|
||||
const hasAttachments =
|
||||
localNote.images.some((img) => img.base64Data) ||
|
||||
localNote.files.some((file) => file.base64Data);
|
||||
|
||||
if (!hasAttachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем задачи синхронизации для вложений
|
||||
for (const image of localNote.images) {
|
||||
if (image.base64Data) {
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'uploadImage',
|
||||
noteId: serverNoteId,
|
||||
data: { imageId: image.id },
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of localNote.files) {
|
||||
if (file.base64Data) {
|
||||
await dbManager.addToSyncQueue({
|
||||
type: 'uploadFile',
|
||||
noteId: serverNoteId,
|
||||
data: { fileId: file.id },
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запланировать повторную попытку
|
||||
*/
|
||||
private scheduleRetry(): void {
|
||||
if (this.syncTimer) {
|
||||
clearTimeout(this.syncTimer);
|
||||
}
|
||||
|
||||
this.syncTimer = setTimeout(() => {
|
||||
console.log('[SyncService] Retrying sync...');
|
||||
this.startSync();
|
||||
}, RETRY_DELAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить счетчик ожидающих синхронизацию
|
||||
*/
|
||||
private async updatePendingCount(): Promise<void> {
|
||||
const count = await dbManager.getPendingSyncCount();
|
||||
store.dispatch(setPendingSyncCount(count));
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить listener для событий завершения синхронизации
|
||||
*/
|
||||
onSyncComplete(callback: () => void): void {
|
||||
this.listeners.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомить listeners
|
||||
*/
|
||||
private notifyListeners(): void {
|
||||
this.listeners.forEach((callback) => callback());
|
||||
}
|
||||
|
||||
/**
|
||||
* Остановить сервис
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.syncTimer) {
|
||||
clearTimeout(this.syncTimer);
|
||||
this.syncTimer = null;
|
||||
}
|
||||
this.listeners = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const syncService = new SyncService();
|
||||
|
||||
@ -10,6 +10,8 @@ interface NotesState {
|
||||
searchQuery: string;
|
||||
loading: boolean;
|
||||
editingNoteId: number | null;
|
||||
offlineMode: boolean;
|
||||
pendingSyncCount: number;
|
||||
}
|
||||
|
||||
const initialState: NotesState = {
|
||||
@ -21,6 +23,8 @@ const initialState: NotesState = {
|
||||
searchQuery: "",
|
||||
loading: false,
|
||||
editingNoteId: null,
|
||||
offlineMode: false,
|
||||
pendingSyncCount: 0,
|
||||
};
|
||||
|
||||
const notesSlice = createSlice({
|
||||
@ -65,6 +69,25 @@ const notesSlice = createSlice({
|
||||
setEditingNote: (state, action: PayloadAction<number | null>) => {
|
||||
state.editingNoteId = action.payload;
|
||||
},
|
||||
setOfflineMode: (state, action: PayloadAction<boolean>) => {
|
||||
state.offlineMode = action.payload;
|
||||
},
|
||||
setPendingSyncCount: (state, action: PayloadAction<number>) => {
|
||||
state.pendingSyncCount = action.payload;
|
||||
},
|
||||
updateNoteSyncStatus: (
|
||||
state,
|
||||
action: PayloadAction<{ id: number | string; syncStatus: 'synced' | 'pending' | 'error' }>
|
||||
) => {
|
||||
const index = state.notes.findIndex((n) => n.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.notes[index].syncStatus = action.payload.syncStatus;
|
||||
}
|
||||
const allIndex = state.allNotes.findIndex((n) => n.id === action.payload.id);
|
||||
if (allIndex !== -1) {
|
||||
state.allNotes[allIndex].syncStatus = action.payload.syncStatus;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -78,5 +101,8 @@ export const {
|
||||
setSelectedTag,
|
||||
setSearchQuery,
|
||||
setEditingNote,
|
||||
setOfflineMode,
|
||||
setPendingSyncCount,
|
||||
updateNoteSyncStatus,
|
||||
} = notesSlice.actions;
|
||||
export default notesSlice.reducer;
|
||||
|
||||
@ -12,6 +12,7 @@ interface UiState {
|
||||
notifications: Notification[];
|
||||
isMobileSidebarOpen: boolean;
|
||||
isPreviewMode: boolean;
|
||||
syncStatus: "idle" | "syncing" | "error";
|
||||
}
|
||||
|
||||
const getInitialTheme = (): "light" | "dark" => {
|
||||
@ -28,6 +29,7 @@ const initialState: UiState = {
|
||||
notifications: [],
|
||||
isMobileSidebarOpen: false,
|
||||
isPreviewMode: false,
|
||||
syncStatus: "idle",
|
||||
};
|
||||
|
||||
const uiSlice = createSlice({
|
||||
@ -69,6 +71,12 @@ const uiSlice = createSlice({
|
||||
togglePreviewMode: (state) => {
|
||||
state.isPreviewMode = !state.isPreviewMode;
|
||||
},
|
||||
setSyncStatus: (
|
||||
state,
|
||||
action: PayloadAction<"idle" | "syncing" | "error">
|
||||
) => {
|
||||
state.syncStatus = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -81,5 +89,6 @@ export const {
|
||||
toggleMobileSidebar,
|
||||
closeMobileSidebar,
|
||||
togglePreviewMode,
|
||||
setSyncStatus,
|
||||
} = uiSlice.actions;
|
||||
export default uiSlice.reducer;
|
||||
|
||||
@ -601,6 +601,100 @@ header {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Индикатор синхронизации */
|
||||
.sync-indicator {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sync-indicator:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.sync-indicator:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sync-indicator .iconify {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sync-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background: var(--accent-color, #007bff);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Offline индикатор */
|
||||
.offline-indicator {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.offline-indicator .iconify {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Анимация пульсации для offline индикатора */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Анимация вращения для иконки загрузки */
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@ -4308,16 +4402,6 @@ textarea:focus {
|
||||
}
|
||||
}
|
||||
|
||||
/* Анимация загрузки для AI кнопки */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.log-action-ai_improve {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export interface Note {
|
||||
id: number;
|
||||
id: number | string;
|
||||
user_id: number;
|
||||
content: string;
|
||||
date: string;
|
||||
@ -11,26 +11,49 @@ export interface Note {
|
||||
pinned_at?: string;
|
||||
images: NoteImage[];
|
||||
files: NoteFile[];
|
||||
syncStatus?: 'synced' | 'pending' | 'error';
|
||||
}
|
||||
|
||||
export interface NoteImage {
|
||||
id: number;
|
||||
note_id: number;
|
||||
id: number | string;
|
||||
note_id: number | string;
|
||||
filename: string;
|
||||
original_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
created_at: string;
|
||||
base64Data?: string;
|
||||
}
|
||||
|
||||
export interface NoteFile {
|
||||
id: number;
|
||||
note_id: number;
|
||||
id: number | string;
|
||||
note_id: number | string;
|
||||
filename: string;
|
||||
original_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
created_at: string;
|
||||
base64Data?: string;
|
||||
}
|
||||
|
||||
export type SyncActionType = 'create' | 'update' | 'delete' | 'uploadImage' | 'uploadFile';
|
||||
|
||||
export interface SyncQueueItem {
|
||||
id: string;
|
||||
type: SyncActionType;
|
||||
noteId: string | number;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
retries: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface PendingAction {
|
||||
id: string;
|
||||
type: SyncActionType;
|
||||
noteId: string | number;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
242
src/utils/indexedDB.ts
Normal file
242
src/utils/indexedDB.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { Note, SyncQueueItem } from '../types/note';
|
||||
|
||||
const DB_NAME = 'notesDB';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
interface NotesDB {
|
||||
notes: IDBObjectStore;
|
||||
syncQueue: IDBObjectStore;
|
||||
}
|
||||
|
||||
class IndexedDBManager {
|
||||
private db: IDBDatabase | null = null;
|
||||
private initPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
async init(): Promise<IDBDatabase> {
|
||||
if (this.db) {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
this.initPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.initPromise = null;
|
||||
resolve(this.db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Хранилище для заметок
|
||||
if (!db.objectStoreNames.contains('notes')) {
|
||||
const notesStore = db.createObjectStore('notes', { keyPath: 'id' });
|
||||
notesStore.createIndex('user_id', 'user_id', { unique: false });
|
||||
notesStore.createIndex('created_at', 'created_at', { unique: false });
|
||||
notesStore.createIndex('syncStatus', 'syncStatus', { unique: false });
|
||||
}
|
||||
|
||||
// Хранилище для очереди синхронизации
|
||||
if (!db.objectStoreNames.contains('syncQueue')) {
|
||||
const syncStore = db.createObjectStore('syncQueue', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
syncStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
syncStore.createIndex('noteId', 'noteId', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async getStore(
|
||||
storeName: keyof NotesDB,
|
||||
mode: IDBTransactionMode = 'readonly'
|
||||
): Promise<IDBObjectStore> {
|
||||
await this.init();
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const transaction = this.db.transaction([storeName], mode);
|
||||
return transaction.objectStore(storeName);
|
||||
}
|
||||
|
||||
// ===== CRUD операции для заметок =====
|
||||
|
||||
async getAllNotes(): Promise<Note[]> {
|
||||
const store = await this.getStore('notes');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getNotesByUserId(userId: number): Promise<Note[]> {
|
||||
const store = await this.getStore('notes');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.index('user_id').getAll(userId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getNote(id: number | string): Promise<Note | undefined> {
|
||||
const store = await this.getStore('notes');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveNote(note: Note): Promise<void> {
|
||||
const store = await this.getStore('notes', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.put(note);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveNotes(notes: Note[]): Promise<void> {
|
||||
if (notes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.init();
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const transaction = this.db.transaction(['notes'], 'readwrite');
|
||||
const store = transaction.objectStore('notes');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let completed = 0;
|
||||
for (const note of notes) {
|
||||
const request = store.put(note);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
completed++;
|
||||
if (completed === notes.length) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteNote(id: number | string): Promise<void> {
|
||||
const store = await this.getStore('notes', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearAllNotes(): Promise<void> {
|
||||
const store = await this.getStore('notes', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Операции с очередью синхронизации =====
|
||||
|
||||
async addToSyncQueue(item: Omit<SyncQueueItem, 'id'>): Promise<string> {
|
||||
const store = await this.getStore('syncQueue', 'readwrite');
|
||||
const id = `sync-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const syncItem: SyncQueueItem = {
|
||||
...item,
|
||||
id,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.add(syncItem);
|
||||
request.onsuccess = () => resolve(id);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getSyncQueue(): Promise<SyncQueueItem[]> {
|
||||
const store = await this.getStore('syncQueue');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.index('timestamp').getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getSyncQueueItem(id: string): Promise<SyncQueueItem | undefined> {
|
||||
const store = await this.getStore('syncQueue');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async removeFromSyncQueue(id: string): Promise<void> {
|
||||
const store = await this.getStore('syncQueue', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async updateSyncQueueItem(id: string, updates: Partial<SyncQueueItem>): Promise<void> {
|
||||
// Сначала получаем элемент
|
||||
const item = await this.getSyncQueueItem(id);
|
||||
if (!item) {
|
||||
throw new Error('Sync queue item not found');
|
||||
}
|
||||
|
||||
// Затем открываем новую транзакцию для обновления
|
||||
const store = await this.getStore('syncQueue', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.put({ ...item, ...updates });
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearSyncQueue(): Promise<void> {
|
||||
const store = await this.getStore('syncQueue', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Утилиты =====
|
||||
|
||||
async getPendingSyncCount(): Promise<number> {
|
||||
const queue = await this.getSyncQueue();
|
||||
return queue.length;
|
||||
}
|
||||
|
||||
async isNoteSynced(noteId: number | string): Promise<boolean> {
|
||||
const note = await this.getNote(noteId);
|
||||
return note?.syncStatus === 'synced';
|
||||
}
|
||||
}
|
||||
|
||||
export const dbManager = new IndexedDBManager();
|
||||
|
||||
147
src/utils/offlineManager.ts
Normal file
147
src/utils/offlineManager.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Генератор временных ID для offline заметок
|
||||
*/
|
||||
export function generateTempId(): string {
|
||||
return `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, является ли ID временным
|
||||
*/
|
||||
export function isTempId(id: number | string): boolean {
|
||||
return typeof id === 'string' && id.startsWith('temp-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ожидание доступности IndexedDB
|
||||
*/
|
||||
export function waitForIndexedDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('test-db', 1);
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
db.close();
|
||||
indexedDB.deleteDatabase('test-db');
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
db.createObjectStore('test');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка состояния сети (более надежный метод)
|
||||
*/
|
||||
export async function checkNetworkStatus(): Promise<boolean> {
|
||||
// Простая проверка navigator.onLine
|
||||
if (!navigator.onLine) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Дополнительная проверка через fetch с коротким таймаутом
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
||||
|
||||
// Используем /auth/status так как он всегда доступен при наличии сети
|
||||
const response = await fetch('/api/auth/status', {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
cache: 'no-cache',
|
||||
credentials: 'include', // Важно для cookie-based auth
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
// Если запрос не удался, но navigator.onLine = true, считаем что онлайн
|
||||
// (возможно, просто таймаут или другая проблема)
|
||||
return navigator.onLine;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, доступна ли БД на клиенте
|
||||
*/
|
||||
export function isDatabaseAvailable(): boolean {
|
||||
return typeof indexedDB !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение blob в base64
|
||||
*/
|
||||
export function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to convert file to base64'));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертация base64 обратно в Blob
|
||||
*/
|
||||
export function base64ToBlob(base64: string, mimeType: string): Blob {
|
||||
const byteCharacters = atob(base64.split(',')[1] || base64);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
return new Blob([byteArray], { type: mimeType });
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение размера base64 строки в байтах
|
||||
*/
|
||||
export function getBase64Size(base64: string): number {
|
||||
const base64String = base64.split(',')[1] || base64;
|
||||
return Math.ceil((base64String.length * 3) / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание Listener для событий сети
|
||||
*/
|
||||
export class NetworkListener {
|
||||
private onlineHandler: ((event: Event) => void) | null = null;
|
||||
private offlineHandler: ((event: Event) => void) | null = null;
|
||||
|
||||
onOnline(callback: (event: Event) => void): void {
|
||||
this.onlineHandler = callback;
|
||||
window.addEventListener('online', callback);
|
||||
}
|
||||
|
||||
onOffline(callback: (event: Event) => void): void {
|
||||
this.offlineHandler = callback;
|
||||
window.addEventListener('offline', callback);
|
||||
}
|
||||
|
||||
removeListeners(): void {
|
||||
if (this.onlineHandler) {
|
||||
window.removeEventListener('online', this.onlineHandler);
|
||||
this.onlineHandler = null;
|
||||
}
|
||||
if (this.offlineHandler) {
|
||||
window.removeEventListener('offline', this.offlineHandler);
|
||||
this.offlineHandler = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const networkListener = new NetworkListener();
|
||||
|
||||
@ -7,7 +7,6 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
injectRegister: "auto",
|
||||
includeAssets: [
|
||||
"icon.svg",
|
||||
@ -102,6 +101,29 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\//,
|
||||
handler: "NetworkFirst",
|
||||
options: {
|
||||
cacheName: "api-cache-local",
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
networkTimeoutSeconds: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/uploads\//,
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "uploads-cache",
|
||||
expiration: {
|
||||
maxEntries: 200,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
|
||||
handler: "CacheFirst",
|
||||
@ -114,7 +136,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanupOutdatedCaches: true,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
},
|
||||
registerType: "prompt",
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: "module",
|
||||
@ -123,7 +149,7 @@ export default defineConfig({
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
allowedHosts: ["notes.fovway.ru", "localhost"],
|
||||
allowedHosts: ["app.notejs.ru", "localhost"],
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3001",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user