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

This commit is contained in:
Fovway 2025-11-04 00:55:05 +07:00
parent 2ec0fd4496
commit 5e16f6d4f0
16 changed files with 6806 additions and 46 deletions

View File

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

File diff suppressed because it is too large Load Diff

884
src/api/offlineNotesApi.ts Normal file
View 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));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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