diff --git a/src/api/axiosClient.ts b/src/api/axiosClient.ts index bd76eaa..92850a4 100644 --- a/src/api/axiosClient.ts +++ b/src/api/axiosClient.ts @@ -34,22 +34,31 @@ axiosClient.interceptors.response.use( if (error.response?.status === 401) { // Список URL, где 401 означает неправильный пароль, а не истечение сессии + // или где не нужно делать редирект (например, проверка статуса) const passwordProtectedUrls = [ "/login", // Страница входа "/register", // Страница регистрации "/notes/archived/all", // Удаление всех архивных заметок "/user/delete-account", // Удаление аккаунта + "/auth/status", // Проверка статуса (не нужно делать редирект при ошибке) ]; - // Проверяем, является ли это запросом с проверкой пароля + // Проверяем, является ли это запросом с проверкой пароля или статуса const isPasswordProtected = passwordProtectedUrls.some((url) => error.config?.url?.includes(url) ); - // Разлогиниваем только если это НЕ запрос с проверкой пароля - if (!isPasswordProtected) { + // Разлогиниваем только если это НЕ запрос с проверкой пароля/статуса + // и только если есть ответ от сервера (не offline режим) + if (!isPasswordProtected && error.response) { localStorage.removeItem("isAuthenticated"); - window.location.href = "/"; + localStorage.removeItem("userId"); + localStorage.removeItem("username"); + // Не делаем редирект, если нет интернета (error.response будет undefined) + // ProtectedRoute сам обработает это + if (navigator.onLine) { + window.location.href = "/"; + } } } diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 0310ba3..687cd17 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -4,6 +4,7 @@ import { useAppSelector, useAppDispatch } from "../store/hooks"; import { setAuth, clearAuth } from "../store/slices/authSlice"; import { authApi } from "../api/authApi"; import { LoadingOverlay } from "./common/LoadingOverlay"; +import { checkNetworkStatus } from "../utils/offlineManager"; export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children, @@ -15,26 +16,84 @@ export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ useEffect(() => { const checkAuth = async () => { try { - const authStatus = await authApi.checkStatus(); - if (authStatus.authenticated) { + // Проверяем, есть ли интернет + const isOnline = await checkNetworkStatus(); + + if (!isOnline) { + // Offline режим - используем данные из localStorage + const savedAuth = localStorage.getItem("isAuthenticated") === "true"; + const savedUserId = localStorage.getItem("userId"); + const savedUsername = localStorage.getItem("username"); + + if (savedAuth && savedUserId && savedUsername) { + // Восстанавливаем авторизацию из localStorage + dispatch( + setAuth({ + userId: parseInt(savedUserId, 10), + username: savedUsername, + }) + ); + console.log('[ProtectedRoute] Offline mode: restored auth from localStorage'); + } else { + // Нет сохраненных данных - не авторизован + dispatch(clearAuth()); + } + } else { + // Online режим - проверяем через API + try { + const authStatus = await authApi.checkStatus(); + if (authStatus.authenticated) { + dispatch( + setAuth({ + userId: authStatus.userId!, + username: authStatus.username!, + }) + ); + } else { + dispatch(clearAuth()); + } + } catch (apiError) { + // Если API запрос не удался, но есть данные в localStorage + // Используем их для offline работы + const savedAuth = localStorage.getItem("isAuthenticated") === "true"; + const savedUserId = localStorage.getItem("userId"); + const savedUsername = localStorage.getItem("username"); + + if (savedAuth && savedUserId && savedUsername) { + console.log('[ProtectedRoute] API failed, using cached auth'); + dispatch( + setAuth({ + userId: parseInt(savedUserId, 10), + username: savedUsername, + }) + ); + } else { + dispatch(clearAuth()); + } + } + } + } catch (error) { + console.error('[ProtectedRoute] Auth check error:', error); + // В случае ошибки проверяем localStorage + const savedAuth = localStorage.getItem("isAuthenticated") === "true"; + const savedUserId = localStorage.getItem("userId"); + const savedUsername = localStorage.getItem("username"); + + if (savedAuth && savedUserId && savedUsername) { dispatch( setAuth({ - userId: authStatus.userId!, - username: authStatus.username!, + userId: parseInt(savedUserId, 10), + username: savedUsername, }) ); } else { dispatch(clearAuth()); } - } catch { - dispatch(clearAuth()); } finally { setIsChecking(false); } }; - // Всегда проверяем статус аутентификации при монтировании, - // независимо от начального состояния Redux (localStorage может быть устаревшим) checkAuth(); }, [dispatch]); diff --git a/src/main.tsx b/src/main.tsx index bce2974..ea594df 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,7 +14,7 @@ import { addNotification } from "./store/slices/uiSlice"; // Регистрация PWA (vite-plugin-pwa автоматически внедряет регистрацию через injectRegister: "auto") -// Инициализация offline функционала +// Инициализация offline функционала (неблокирующая) async function initOfflineMode() { try { console.log('[Init] Initializing offline mode...'); @@ -23,28 +23,46 @@ async function initOfflineMode() { await dbManager.init(); console.log('[Init] IndexedDB initialized'); - // Проверка состояния сети - const isOnline = await checkNetworkStatus(); - store.dispatch(setOfflineMode(!isOnline)); - console.log(`[Init] Network status: ${isOnline ? 'online' : 'offline'}`); + // Устанавливаем начальное состояние на основе navigator.onLine (мгновенно, без блокировки) + const initialOnlineState = navigator.onLine; + store.dispatch(setOfflineMode(!initialOnlineState)); + console.log(`[Init] Initial network status (navigator.onLine): ${initialOnlineState ? 'online' : 'offline'}`); + + // Проверка состояния сети в фоне (не блокирует загрузку приложения) + checkNetworkStatus().then((isOnline) => { + store.dispatch(setOfflineMode(!isOnline)); + console.log(`[Init] Network status (after check): ${isOnline ? 'online' : 'offline'}`); + }).catch((error) => { + console.warn('[Init] Network check failed, using navigator.onLine:', error); + // В случае ошибки используем navigator.onLine + store.dispatch(setOfflineMode(!navigator.onLine)); + }); // Установка 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(); - } + // Небольшая задержка перед проверкой для стабилизации соединения + setTimeout(async () => { + try { + const isOnline = await checkNetworkStatus(); + store.dispatch(setOfflineMode(!isOnline)); + + if (isOnline) { + store.dispatch( + addNotification({ + message: 'Подключение восстановлено, начинаем синхронизацию...', + type: 'info', + }) + ); + + // Запуск синхронизации + await syncService.startSync(); + } + } catch (error) { + console.error('[Network] Error checking network status:', error); + store.dispatch(setOfflineMode(!navigator.onLine)); + } + }, 500); }); networkListener.onOffline(() => { @@ -59,29 +77,40 @@ async function initOfflineMode() { }); // Обновление счетчика ожидающих синхронизацию - const pendingCount = await dbManager.getPendingSyncCount(); - store.dispatch(setPendingSyncCount(pendingCount)); - - if (pendingCount > 0) { - console.log(`[Init] Found ${pendingCount} pending sync items`); - } + dbManager.getPendingSyncCount().then((pendingCount) => { + 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); - } + // Автоматическая синхронизация при старте если есть что синхронизировать + // Проверяем статус сети перед синхронизацией + checkNetworkStatus().then((isOnline) => { + if (isOnline && pendingCount > 0) { + console.log('[Init] Starting initial sync...'); + // Небольшая задержка для инициализации UI + setTimeout(() => { + syncService.startSync(); + }, 2000); + } + }).catch(() => { + // Если проверка сети не удалась, не запускаем синхронизацию + console.log('[Init] Skipping initial sync due to network check failure'); + }); + }).catch((error) => { + console.error('[Init] Error getting pending sync count:', error); + }); console.log('[Init] Offline mode initialized successfully'); } catch (error) { console.error('[Init] Error initializing offline mode:', error); + // Не блокируем запуск приложения даже при ошибке + store.dispatch(setOfflineMode(!navigator.onLine)); } } -// Запуск инициализации +// Запуск инициализации (не блокирует рендеринг React) initOfflineMode(); ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/store/slices/authSlice.ts b/src/store/slices/authSlice.ts index 138a0cb..8742eec 100644 --- a/src/store/slices/authSlice.ts +++ b/src/store/slices/authSlice.ts @@ -9,7 +9,7 @@ interface AuthState { const initialState: AuthState = { isAuthenticated: localStorage.getItem("isAuthenticated") === "true", - userId: null, + userId: localStorage.getItem("userId") ? parseInt(localStorage.getItem("userId")!, 10) : null, username: localStorage.getItem("username") || null, loading: false, }; @@ -26,6 +26,7 @@ const authSlice = createSlice({ state.userId = action.payload.userId; state.username = action.payload.username; localStorage.setItem("isAuthenticated", "true"); + localStorage.setItem("userId", action.payload.userId.toString()); localStorage.setItem("username", action.payload.username); }, clearAuth: (state) => { @@ -33,6 +34,7 @@ const authSlice = createSlice({ state.userId = null; state.username = null; localStorage.removeItem("isAuthenticated"); + localStorage.removeItem("userId"); localStorage.removeItem("username"); }, }, diff --git a/src/utils/offlineManager.ts b/src/utils/offlineManager.ts index 5bfb948..ec9aa8a 100644 --- a/src/utils/offlineManager.ts +++ b/src/utils/offlineManager.ts @@ -39,6 +39,8 @@ export function waitForIndexedDB(): Promise { /** * Проверка состояния сети (более надежный метод) + * Не блокирует выполнение, всегда возвращает результат + * Различает "нет интернета" (offline) и "не авторизован" (401) */ export async function checkNetworkStatus(): Promise { // Простая проверка navigator.onLine @@ -49,7 +51,8 @@ export async function checkNetworkStatus(): Promise { // Дополнительная проверка через fetch с коротким таймаутом try { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 2000); + // Уменьшаем таймаут для более быстрого ответа + const timeoutId = setTimeout(() => controller.abort(), 1500); // Используем /auth/status так как он всегда доступен при наличии сети const response = await fetch('/api/auth/status', { @@ -60,10 +63,27 @@ export async function checkNetworkStatus(): Promise { }); clearTimeout(timeoutId); - return response.ok; + + // Если получили ответ (даже 401), значит интернет есть + // 401 означает "не авторизован", но НЕ "нет интернета" + // Любой ответ (даже ошибка) означает, что сеть работает + return true; } catch (error) { - // Если запрос не удался, но navigator.onLine = true, считаем что онлайн - // (возможно, просто таймаут или другая проблема) + // Если запрос не удался из-за таймаута или сетевой ошибки + // (AbortError, NetworkError, TypeError и т.д.) + // Это означает, что интернета действительно нет + if (error instanceof Error) { + // AbortError означает таймаут - нет интернета + if (error.name === 'AbortError') { + return false; + } + // TypeError обычно означает, что запрос не может быть выполнен + if (error.name === 'TypeError' && error.message.includes('fetch')) { + return false; + } + } + // В остальных случаях считаем, что интернет есть + // (возможно, просто ошибка авторизации или другая проблема) return navigator.onLine; } } diff --git a/vite.config.ts b/vite.config.ts index d7e1ff5..5e1039e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,7 +12,10 @@ export default defineConfig({ "icon.svg", "icons/icon-192x192.png", "icons/icon-512x512.png", + "manifest.json", ], + // Включаем стратегию для offline работы + strategies: "generateSW", manifest: { name: "NoteJS - Система заметок", short_name: "NoteJS", @@ -88,8 +91,41 @@ export default defineConfig({ ], }, workbox: { - globPatterns: ["**/*.{js,css,html,ico,png,svg,woff,woff2,ttf,eot}"], + // Кэшируем все статические ресурсы для offline работы + globPatterns: ["**/*.{js,css,html,ico,png,svg,woff,woff2,ttf,eot,json}"], + // Стратегия для главной страницы - CacheFirst для offline работы + navigateFallback: "/index.html", + navigateFallbackDenylist: [/^\/api/, /^\/_/], + // Кэширование для offline работы приложения runtimeCaching: [ + { + // Стратегия для корневого пути и index.html + urlPattern: ({ request }) => + request.destination === 'document' || + request.url.endsWith('/') || + request.url.endsWith('/index.html'), + handler: "NetworkFirst", + options: { + cacheName: "html-cache", + expiration: { + maxEntries: 10, + maxAgeSeconds: 24 * 60 * 60, // 24 hours + }, + networkTimeoutSeconds: 3, + }, + }, + { + // Статические ресурсы (JS, CSS) - кэшируем для offline + urlPattern: /\.(?:js|css|woff|woff2|ttf|eot)$/, + handler: "CacheFirst", + options: { + cacheName: "static-resources-cache", + expiration: { + maxEntries: 200, + maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year + }, + }, + }, { urlPattern: /^https:\/\/api\./, handler: "NetworkFirst", @@ -99,6 +135,7 @@ export default defineConfig({ maxEntries: 50, maxAgeSeconds: 60 * 60, // 1 hour }, + networkTimeoutSeconds: 3, }, }, { @@ -110,7 +147,7 @@ export default defineConfig({ maxEntries: 100, maxAgeSeconds: 24 * 60 * 60, // 24 hours }, - networkTimeoutSeconds: 10, + networkTimeoutSeconds: 3, }, }, {