Вернул старый оффлайн режим когда он не работал

This commit is contained in:
Fovway 2025-11-04 11:45:46 +07:00
parent 5b3e41d1b6
commit 87a01629ae
25 changed files with 181 additions and 396 deletions

View File

@ -81,34 +81,15 @@ define(['./workbox-9dc17825'], (function (workbox) { 'use strict';
"url": "registerSW.js", "url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "/index.html", "url": "index.html",
"revision": "0.ri1maclacqo" "revision": "0.fijdulj6fg"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/], allowlist: [/^\/$/]
denylist: [/^\/api/, /^\/_/]
})); }));
workbox.registerRoute(({
request
}) => request.destination === "document" || request.url.endsWith("/") || request.url.endsWith("/index.html"), new workbox.NetworkFirst({
"cacheName": "html-cache",
"networkTimeoutSeconds": 3,
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 86400
})]
}), 'GET');
workbox.registerRoute(/\.(?:js|css|woff|woff2|ttf|eot)$/, new workbox.CacheFirst({
"cacheName": "static-resources-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 31536000
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/api\./, new workbox.NetworkFirst({ workbox.registerRoute(/^https:\/\/api\./, new workbox.NetworkFirst({
"cacheName": "api-cache", "cacheName": "api-cache",
"networkTimeoutSeconds": 3,
plugins: [new workbox.ExpirationPlugin({ plugins: [new workbox.ExpirationPlugin({
maxEntries: 50, maxEntries: 50,
maxAgeSeconds: 3600 maxAgeSeconds: 3600
@ -116,7 +97,7 @@ define(['./workbox-9dc17825'], (function (workbox) { 'use strict';
}), 'GET'); }), 'GET');
workbox.registerRoute(/\/api\//, new workbox.NetworkFirst({ workbox.registerRoute(/\/api\//, new workbox.NetworkFirst({
"cacheName": "api-cache-local", "cacheName": "api-cache-local",
"networkTimeoutSeconds": 3, "networkTimeoutSeconds": 10,
plugins: [new workbox.ExpirationPlugin({ plugins: [new workbox.ExpirationPlugin({
maxEntries: 100, maxEntries: 100,
maxAgeSeconds: 86400 maxAgeSeconds: 86400

View File

@ -19,12 +19,7 @@ const AppContent: React.FC = () => {
<> <>
<NotificationStack /> <NotificationStack />
<InstallPrompt /> <InstallPrompt />
<BrowserRouter <BrowserRouter>
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<Routes> <Routes>
<Route path="/" element={<LoginPage />} /> <Route path="/" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />

View File

@ -34,31 +34,22 @@ axiosClient.interceptors.response.use(
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Список URL, где 401 означает неправильный пароль, а не истечение сессии // Список URL, где 401 означает неправильный пароль, а не истечение сессии
// или где не нужно делать редирект (например, проверка статуса)
const passwordProtectedUrls = [ const passwordProtectedUrls = [
"/login", // Страница входа "/login", // Страница входа
"/register", // Страница регистрации "/register", // Страница регистрации
"/notes/archived/all", // Удаление всех архивных заметок "/notes/archived/all", // Удаление всех архивных заметок
"/user/delete-account", // Удаление аккаунта "/user/delete-account", // Удаление аккаунта
"/auth/status", // Проверка статуса (не нужно делать редирект при ошибке)
]; ];
// Проверяем, является ли это запросом с проверкой пароля или статуса // Проверяем, является ли это запросом с проверкой пароля
const isPasswordProtected = passwordProtectedUrls.some((url) => const isPasswordProtected = passwordProtectedUrls.some((url) =>
error.config?.url?.includes(url) error.config?.url?.includes(url)
); );
// Разлогиниваем только если это НЕ запрос с проверкой пароля/статуса // Разлогиниваем только если это НЕ запрос с проверкой пароля
// и только если есть ответ от сервера (не offline режим) if (!isPasswordProtected) {
if (!isPasswordProtected && error.response) {
localStorage.removeItem("isAuthenticated"); localStorage.removeItem("isAuthenticated");
localStorage.removeItem("userId"); window.location.href = "/";
localStorage.removeItem("username");
// Не делаем редирект, если нет интернета (error.response будет undefined)
// ProtectedRoute сам обработает это
if (navigator.onLine) {
window.location.href = "/";
}
} }
} }

View File

@ -43,7 +43,7 @@ export const notesApi = {
return data; return data;
}, },
unarchive: async (id: number | string) => { unarchive: async (id: number) => {
const { data } = await axiosClient.put(`/notes/${id}/unarchive`); const { data } = await axiosClient.put(`/notes/${id}/unarchive`);
return data; return data;
}, },
@ -89,7 +89,7 @@ export const notesApi = {
return data; return data;
}, },
deleteArchived: async (id: number | string) => { deleteArchived: async (id: number) => {
await axiosClient.delete(`/notes/archived/${id}`); await axiosClient.delete(`/notes/archived/${id}`);
}, },

View File

@ -12,8 +12,8 @@ export const userApi = {
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
accent_color?: string; accent_color?: string;
show_edit_date?: boolean | number; show_edit_date?: boolean;
colored_icons?: boolean | number; colored_icons?: boolean;
} }
) => { ) => {
const { data } = await axiosClient.put("/user/profile", profile); const { data } = await axiosClient.put("/user/profile", profile);

View File

@ -4,7 +4,6 @@ import { useAppSelector, useAppDispatch } from "../store/hooks";
import { setAuth, clearAuth } from "../store/slices/authSlice"; import { setAuth, clearAuth } from "../store/slices/authSlice";
import { authApi } from "../api/authApi"; import { authApi } from "../api/authApi";
import { LoadingOverlay } from "./common/LoadingOverlay"; import { LoadingOverlay } from "./common/LoadingOverlay";
import { checkNetworkStatus } from "../utils/offlineManager";
export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
children, children,
@ -16,84 +15,26 @@ export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
useEffect(() => { useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
try { try {
// Проверяем, есть ли интернет const authStatus = await authApi.checkStatus();
const isOnline = await checkNetworkStatus(); if (authStatus.authenticated) {
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( dispatch(
setAuth({ setAuth({
userId: parseInt(savedUserId, 10), userId: authStatus.userId!,
username: savedUsername, username: authStatus.username!,
}) })
); );
} else { } else {
dispatch(clearAuth()); dispatch(clearAuth());
} }
} catch {
dispatch(clearAuth());
} finally { } finally {
setIsChecking(false); setIsChecking(false);
} }
}; };
// Всегда проверяем статус аутентификации при монтировании,
// независимо от начального состояния Redux (localStorage может быть устаревшим)
checkAuth(); checkAuth();
}, [dispatch]); }, [dispatch]);

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { import {
format, format,
startOfMonth, startOfMonth,

View File

@ -2,6 +2,7 @@ import React from "react";
import { MiniCalendar } from "../calendar/MiniCalendar"; import { MiniCalendar } from "../calendar/MiniCalendar";
import { SearchBar } from "../search/SearchBar"; import { SearchBar } from "../search/SearchBar";
import { TagsFilter } from "../search/TagsFilter"; import { TagsFilter } from "../search/TagsFilter";
import { useAppSelector } from "../../store/hooks";
import { Note } from "../../types/note"; import { Note } from "../../types/note";
interface SidebarProps { interface SidebarProps {

View File

@ -113,14 +113,7 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
}; };
}, [isDragging]); }, [isDragging]);
const buttons: Array<{ const buttons = [];
id: string;
action?: () => void;
before?: string;
after?: string;
title?: string;
icon?: string;
}> = [];
return ( return (
<div <div
@ -149,7 +142,7 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
}} }}
title={btn.title} title={btn.title}
> >
{btn.icon && <Icon icon={btn.icon} />} <Icon icon={btn.icon} />
</button> </button>
))} ))}

View File

@ -4,7 +4,7 @@ import { FloatingToolbar } from "./FloatingToolbar";
import { NotePreview } from "./NotePreview"; import { NotePreview } from "./NotePreview";
import { ImageUpload } from "./ImageUpload"; import { ImageUpload } from "./ImageUpload";
import { FileUpload } from "./FileUpload"; import { FileUpload } from "./FileUpload";
import { useAppSelector } from "../../store/hooks"; import { useAppSelector, useAppDispatch } from "../../store/hooks";
import { useNotification } from "../../hooks/useNotification"; import { useNotification } from "../../hooks/useNotification";
import { offlineNotesApi } from "../../api/offlineNotesApi"; import { offlineNotesApi } from "../../api/offlineNotesApi";
import { aiApi } from "../../api/aiApi"; import { aiApi } from "../../api/aiApi";
@ -31,6 +31,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const isPreviewMode = useAppSelector((state) => state.ui.isPreviewMode); const isPreviewMode = useAppSelector((state) => state.ui.isPreviewMode);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const aiEnabled = useAppSelector((state) => state.profile.aiEnabled); const aiEnabled = useAppSelector((state) => state.profile.aiEnabled);
const dispatch = useAppDispatch();
const handleSave = async () => { const handleSave = async () => {
if (!content.trim()) { if (!content.trim()) {
@ -250,27 +251,36 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
// Определяем, есть ли уже такие маркеры на всех строках // Определяем, есть ли уже такие маркеры на всех строках
let allLinesHaveMarker = true; let allLinesHaveMarker = true;
let hasAnyMarker = false;
for (const line of lines) { for (const line of lines) {
const trimmedLine = line.trimStart(); const trimmedLine = line.trimStart();
if (before === "- ") { if (before === "- ") {
// Для маркированного списка проверяем различные варианты // Для маркированного списка проверяем различные варианты
if (!trimmedLine.match(/^[-*+]\s/)) { if (trimmedLine.match(/^[-*+]\s/)) {
hasAnyMarker = true;
} else {
allLinesHaveMarker = false; allLinesHaveMarker = false;
} }
} else if (before === "1. ") { } else if (before === "1. ") {
// Для нумерованного списка // Для нумерованного списка
if (!trimmedLine.match(/^\d+\.\s/)) { if (trimmedLine.match(/^\d+\.\s/)) {
hasAnyMarker = true;
} else {
allLinesHaveMarker = false; allLinesHaveMarker = false;
} }
} else if (before === "- [ ] ") { } else if (before === "- [ ] ") {
// Для чекбокса // Для чекбокса
if (!trimmedLine.match(/^-\s+\[[ xX]\]\s/)) { if (trimmedLine.match(/^-\s+\[[ xX]\]\s/)) {
hasAnyMarker = true;
} else {
allLinesHaveMarker = false; allLinesHaveMarker = false;
} }
} else if (before === "> ") { } else if (before === "> ") {
// Для цитаты // Для цитаты
if (!trimmedLine.startsWith("> ")) { if (trimmedLine.startsWith("> ")) {
hasAnyMarker = true;
} else {
allLinesHaveMarker = false; allLinesHaveMarker = false;
} }
} }
@ -520,12 +530,14 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const lines = text.split("\n"); const lines = text.split("\n");
// Определяем текущую строку // Определяем текущую строку
let currentLineIndex = 0;
let currentLineStart = 0; let currentLineStart = 0;
let currentLine = ""; let currentLine = "";
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length; const lineLength = lines[i].length;
if (currentLineStart + lineLength >= start) { if (currentLineStart + lineLength >= start) {
currentLineIndex = i;
currentLine = lines[i]; currentLine = lines[i];
break; break;
} }
@ -656,6 +668,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const lineHeight = parseInt(styles.lineHeight) || 20; const lineHeight = parseInt(styles.lineHeight) || 20;
const paddingTop = parseInt(styles.paddingTop) || 0; const paddingTop = parseInt(styles.paddingTop) || 0;
const paddingLeft = parseInt(styles.paddingLeft) || 0; const paddingLeft = parseInt(styles.paddingLeft) || 0;
const fontSize = parseInt(styles.fontSize) || 14;
// Более точный расчет ширины символа // Более точный расчет ширины символа
// Создаем временный элемент для измерения // Создаем временный элемент для измерения

View File

@ -31,7 +31,7 @@ interface NoteItemProps {
export const NoteItem: React.FC<NoteItemProps> = ({ export const NoteItem: React.FC<NoteItemProps> = ({
note, note,
onDelete: _onDelete, onDelete,
onPin, onPin,
onArchive, onArchive,
onReload, onReload,
@ -41,8 +41,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const [showArchiveModal, setShowArchiveModal] = useState(false); const [showArchiveModal, setShowArchiveModal] = useState(false);
const [editImages, setEditImages] = useState<File[]>([]); const [editImages, setEditImages] = useState<File[]>([]);
const [editFiles, setEditFiles] = useState<File[]>([]); const [editFiles, setEditFiles] = useState<File[]>([]);
const [deletedImageIds, setDeletedImageIds] = useState<(number | string)[]>([]); const [deletedImageIds, setDeletedImageIds] = useState<number[]>([]);
const [deletedFileIds, setDeletedFileIds] = useState<(number | string)[]>([]); const [deletedFileIds, setDeletedFileIds] = useState<number[]>([]);
const [isAiLoading, setIsAiLoading] = useState(false); const [isAiLoading, setIsAiLoading] = useState(false);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 }); const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
@ -140,19 +140,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
setLocalPreviewMode(false); setLocalPreviewMode(false);
}; };
const handleDeleteExistingImage = (imageId: number | string) => { const handleDeleteExistingImage = (imageId: number) => {
setDeletedImageIds([...deletedImageIds, imageId]); setDeletedImageIds([...deletedImageIds, imageId]);
}; };
const handleDeleteExistingFile = (fileId: number | string) => { const handleDeleteExistingFile = (fileId: number) => {
setDeletedFileIds([...deletedFileIds, fileId]); setDeletedFileIds([...deletedFileIds, fileId]);
}; };
const handleRestoreImage = (imageId: number | string) => { const handleRestoreImage = (imageId: number) => {
setDeletedImageIds(deletedImageIds.filter((id) => id !== imageId)); setDeletedImageIds(deletedImageIds.filter((id) => id !== imageId));
}; };
const handleRestoreFile = (fileId: number | string) => { const handleRestoreFile = (fileId: number) => {
setDeletedFileIds(deletedFileIds.filter((id) => id !== fileId)); setDeletedFileIds(deletedFileIds.filter((id) => id !== fileId));
}; };
@ -337,27 +337,36 @@ export const NoteItem: React.FC<NoteItemProps> = ({
// Определяем, есть ли уже такие маркеры на всех строках // Определяем, есть ли уже такие маркеры на всех строках
let allLinesHaveMarker = true; let allLinesHaveMarker = true;
let hasAnyMarker = false;
for (const line of lines) { for (const line of lines) {
const trimmedLine = line.trimStart(); const trimmedLine = line.trimStart();
if (before === "- ") { if (before === "- ") {
// Для маркированного списка проверяем различные варианты // Для маркированного списка проверяем различные варианты
if (!trimmedLine.match(/^[-*+]\s/)) { if (trimmedLine.match(/^[-*+]\s/)) {
hasAnyMarker = true;
} else {
allLinesHaveMarker = false; allLinesHaveMarker = false;
} }
} else if (before === "1. ") { } else if (before === "1. ") {
// Для нумерованного списка // Для нумерованного списка
if (!trimmedLine.match(/^\d+\.\s/)) { if (trimmedLine.match(/^\d+\.\s/)) {
hasAnyMarker = true;
} else {
allLinesHaveMarker = false; allLinesHaveMarker = false;
} }
} else if (before === "- [ ] ") { } else if (before === "- [ ] ") {
// Для чекбокса // Для чекбокса
if (!trimmedLine.match(/^-\s+\[[ xX]\]\s/)) { if (trimmedLine.match(/^-\s+\[[ xX]\]\s/)) {
hasAnyMarker = true;
} else {
allLinesHaveMarker = false; allLinesHaveMarker = false;
} }
} else if (before === "> ") { } else if (before === "> ") {
// Для цитаты // Для цитаты
if (!trimmedLine.startsWith("> ")) { if (trimmedLine.startsWith("> ")) {
hasAnyMarker = true;
} else {
allLinesHaveMarker = false; allLinesHaveMarker = false;
} }
} }
@ -619,6 +628,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const lineHeight = parseInt(styles.lineHeight) || 20; const lineHeight = parseInt(styles.lineHeight) || 20;
const paddingTop = parseInt(styles.paddingTop) || 0; const paddingTop = parseInt(styles.paddingTop) || 0;
const paddingLeft = parseInt(styles.paddingLeft) || 0; const paddingLeft = parseInt(styles.paddingLeft) || 0;
const fontSize = parseInt(styles.fontSize) || 14;
// Более точный расчет ширины символа // Более точный расчет ширины символа
// Создаем временный элемент для измерения // Создаем временный элемент для измерения
@ -741,12 +751,14 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const lines = text.split("\n"); const lines = text.split("\n");
// Определяем текущую строку // Определяем текущую строку
let currentLineIndex = 0;
let currentLineStart = 0; let currentLineStart = 0;
let currentLine = ""; let currentLine = "";
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length; const lineLength = lines[i].length;
if (currentLineStart + lineLength >= start) { if (currentLineStart + lineLength >= start) {
currentLineIndex = i;
currentLine = lines[i]; currentLine = lines[i];
break; break;
} }
@ -1374,6 +1386,11 @@ export const NoteItem: React.FC<NoteItemProps> = ({
{note.files {note.files
.filter((file) => !deletedFileIds.includes(file.id)) .filter((file) => !deletedFileIds.includes(file.id))
.map((file) => { .map((file) => {
const fileUrl = getFileUrl(
file.file_path,
note.id,
file.id
);
return ( return (
<div key={file.id} className="file-preview-item"> <div key={file.id} className="file-preview-item">
<Icon <Icon

View File

@ -1,4 +1,4 @@
import { useEffect, useImperativeHandle, forwardRef } from "react"; import React, { useEffect, useImperativeHandle, forwardRef } from "react";
import { NoteItem } from "./NoteItem"; import { NoteItem } from "./NoteItem";
import { useAppSelector, useAppDispatch } from "../../store/hooks"; import { useAppSelector, useAppDispatch } from "../../store/hooks";
import { offlineNotesApi } from "../../api/offlineNotesApi"; import { offlineNotesApi } from "../../api/offlineNotesApi";
@ -10,7 +10,7 @@ export interface NotesListRef {
reloadNotes: () => void; reloadNotes: () => void;
} }
export const NotesList = forwardRef<NotesListRef>((_props, ref) => { export const NotesList = forwardRef<NotesListRef>((props, ref) => {
const notes = useAppSelector((state) => state.notes.notes); const notes = useAppSelector((state) => state.notes.notes);
const userId = useAppSelector((state) => state.auth.userId); const userId = useAppSelector((state) => state.auth.userId);
const searchQuery = useAppSelector((state) => state.notes.searchQuery); const searchQuery = useAppSelector((state) => state.notes.searchQuery);

View File

@ -6,7 +6,7 @@ import { setSearchQuery } from "../../store/slices/notesSlice";
export const SearchBar: React.FC = () => { export const SearchBar: React.FC = () => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
// Debounce для поиска // Debounce для поиска

View File

@ -10,8 +10,7 @@ export const useNotification = () => {
message: string, message: string,
type: "info" | "success" | "error" | "warning" = "info" type: "info" | "success" | "error" | "warning" = "info"
) => { ) => {
const id = `notification-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const id = dispatch(addNotification({ message, type })).payload.id;
dispatch(addNotification({ id, message, type }));
setTimeout(() => { setTimeout(() => {
dispatch(removeNotification(id)); dispatch(removeNotification(id));
}, 4000); }, 4000);

View File

@ -14,7 +14,7 @@ import { addNotification } from "./store/slices/uiSlice";
// Регистрация PWA (vite-plugin-pwa автоматически внедряет регистрацию через injectRegister: "auto") // Регистрация PWA (vite-plugin-pwa автоматически внедряет регистрацию через injectRegister: "auto")
// Инициализация offline функционала (неблокирующая) // Инициализация offline функционала
async function initOfflineMode() { async function initOfflineMode() {
try { try {
console.log('[Init] Initializing offline mode...'); console.log('[Init] Initializing offline mode...');
@ -23,46 +23,28 @@ async function initOfflineMode() {
await dbManager.init(); await dbManager.init();
console.log('[Init] IndexedDB initialized'); console.log('[Init] IndexedDB initialized');
// Устанавливаем начальное состояние на основе navigator.onLine (мгновенно, без блокировки) // Проверка состояния сети
const initialOnlineState = navigator.onLine; const isOnline = await checkNetworkStatus();
store.dispatch(setOfflineMode(!initialOnlineState)); store.dispatch(setOfflineMode(!isOnline));
console.log(`[Init] Initial network status (navigator.onLine): ${initialOnlineState ? 'online' : 'offline'}`); console.log(`[Init] Network status: ${isOnline ? '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 для событий сети // Установка listeners для событий сети
networkListener.onOnline(async () => { networkListener.onOnline(async () => {
console.log('[Network] Online event detected'); console.log('[Network] Online event detected');
// Небольшая задержка перед проверкой для стабилизации соединения const isOnline = await checkNetworkStatus();
setTimeout(async () => { store.dispatch(setOfflineMode(!isOnline));
try {
const isOnline = await checkNetworkStatus();
store.dispatch(setOfflineMode(!isOnline));
if (isOnline) { if (isOnline) {
store.dispatch( store.dispatch(
addNotification({ addNotification({
message: 'Подключение восстановлено, начинаем синхронизацию...', message: 'Подключение восстановлено, начинаем синхронизацию...',
type: 'info', type: 'info',
}) })
); );
// Запуск синхронизации // Запуск синхронизации
await syncService.startSync(); await syncService.startSync();
} }
} catch (error) {
console.error('[Network] Error checking network status:', error);
store.dispatch(setOfflineMode(!navigator.onLine));
}
}, 500);
}); });
networkListener.onOffline(() => { networkListener.onOffline(() => {
@ -77,40 +59,29 @@ async function initOfflineMode() {
}); });
// Обновление счетчика ожидающих синхронизацию // Обновление счетчика ожидающих синхронизацию
dbManager.getPendingSyncCount().then((pendingCount) => { const pendingCount = await dbManager.getPendingSyncCount();
store.dispatch(setPendingSyncCount(pendingCount)); store.dispatch(setPendingSyncCount(pendingCount));
if (pendingCount > 0) { if (pendingCount > 0) {
console.log(`[Init] Found ${pendingCount} pending sync items`); console.log(`[Init] Found ${pendingCount} pending sync items`);
} }
// Автоматическая синхронизация при старте если есть что синхронизировать // Автоматическая синхронизация при старте если есть что синхронизировать
// Проверяем статус сети перед синхронизацией if (isOnline && pendingCount > 0) {
checkNetworkStatus().then((isOnline) => { console.log('[Init] Starting initial sync...');
if (isOnline && pendingCount > 0) { // Небольшая задержка для инициализации UI
console.log('[Init] Starting initial sync...'); setTimeout(() => {
// Небольшая задержка для инициализации UI syncService.startSync();
setTimeout(() => { }, 2000);
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'); console.log('[Init] Offline mode initialized successfully');
} catch (error) { } catch (error) {
console.error('[Init] Error initializing offline mode:', error); console.error('[Init] Error initializing offline mode:', error);
// Не блокируем запуск приложения даже при ошибке
store.dispatch(setOfflineMode(!navigator.onLine));
} }
} }
// Запуск инициализации (не блокирует рендеринг React) // Запуск инициализации
initOfflineMode(); initOfflineMode();
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useAppDispatch } from "../store/hooks"; import { useAppSelector, useAppDispatch } from "../store/hooks";
import { userApi } from "../api/userApi"; import { userApi } from "../api/userApi";
import { authApi } from "../api/authApi"; import { authApi } from "../api/authApi";
import { clearAuth } from "../store/slices/authSlice"; import { clearAuth } from "../store/slices/authSlice";
@ -16,6 +16,7 @@ const ProfilePage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const user = useAppSelector((state) => state.profile.user);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");

View File

@ -13,6 +13,7 @@ import { setAccentColor } from "../utils/colorUtils";
import { useNotification } from "../hooks/useNotification"; import { useNotification } from "../hooks/useNotification";
import { Modal } from "../components/common/Modal"; import { Modal } from "../components/common/Modal";
import { ThemeToggle } from "../components/common/ThemeToggle"; import { ThemeToggle } from "../components/common/ThemeToggle";
import { formatDateFromTimestamp } from "../utils/dateFormat";
import { parseMarkdown } from "../utils/markdown"; import { parseMarkdown } from "../utils/markdown";
import { dbManager } from "../utils/indexedDB"; import { dbManager } from "../utils/indexedDB";
import { syncService } from "../services/syncService"; import { syncService } from "../services/syncService";
@ -25,6 +26,7 @@ const SettingsPage: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const user = useAppSelector((state) => state.profile.user); const user = useAppSelector((state) => state.profile.user);
const accentColor = useAppSelector((state) => state.ui.accentColor);
const [activeTab, setActiveTab] = useState<SettingsTab>(() => { const [activeTab, setActiveTab] = useState<SettingsTab>(() => {
// Восстанавливаем активную вкладку из localStorage при инициализации // Восстанавливаем активную вкладку из localStorage при инициализации
@ -162,8 +164,8 @@ const SettingsPage: React.FC = () => {
try { try {
await userApi.updateProfile({ await userApi.updateProfile({
accent_color: selectedAccentColor, accent_color: selectedAccentColor,
show_edit_date: showEditDate ? 1 : 0, show_edit_date: showEditDate,
colored_icons: coloredIcons ? 1 : 0, colored_icons: coloredIcons,
}); });
dispatch(setAccentColorAction(selectedAccentColor)); dispatch(setAccentColorAction(selectedAccentColor));
setAccentColor(selectedAccentColor); setAccentColor(selectedAccentColor);
@ -271,7 +273,7 @@ const SettingsPage: React.FC = () => {
} }
}; };
const handleRestoreNote = async (id: number | string) => { const handleRestoreNote = async (id: number) => {
try { try {
await notesApi.unarchive(id); await notesApi.unarchive(id);
await loadArchivedNotes(); await loadArchivedNotes();
@ -285,7 +287,7 @@ const SettingsPage: React.FC = () => {
} }
}; };
const handleDeletePermanent = async (id: number | string) => { const handleDeletePermanent = async (id: number) => {
try { try {
await notesApi.deleteArchived(id); await notesApi.deleteArchived(id);
await loadArchivedNotes(); await loadArchivedNotes();
@ -418,8 +420,8 @@ const SettingsPage: React.FC = () => {
// Загружаем версию из IndexedDB // Загружаем версию из IndexedDB
try { try {
const userId = (user as any)?.id; const userId = user?.id;
const localVer = userId && typeof userId === 'number' const localVer = userId
? await dbManager.getDataVersionByUserId(userId) ? await dbManager.getDataVersionByUserId(userId)
: await dbManager.getDataVersion(); : await dbManager.getDataVersion();
setIndexedDBVersion(localVer); setIndexedDBVersion(localVer);

View File

@ -5,11 +5,13 @@ import { Note, NoteImage, NoteFile } from '../types/note';
import { store } from '../store/index'; import { store } from '../store/index';
import { import {
setSyncStatus, setSyncStatus,
removeNotification,
addNotification, addNotification,
} from '../store/slices/uiSlice'; } from '../store/slices/uiSlice';
import { import {
updateNote, updateNote,
setPendingSyncCount, setPendingSyncCount,
setOfflineMode,
} from '../store/slices/notesSlice'; } from '../store/slices/notesSlice';
import { SyncQueueItem } from '../types/note'; import { SyncQueueItem } from '../types/note';
@ -18,7 +20,7 @@ const RETRY_DELAY_MS = 5000;
class SyncService { class SyncService {
private isSyncing = false; private isSyncing = false;
private syncTimer: ReturnType<typeof setTimeout> | null = null; private syncTimer: NodeJS.Timeout | null = null;
private listeners: Array<() => void> = []; private listeners: Array<() => void> = [];
/** /**
@ -442,7 +444,7 @@ class SyncService {
*/ */
private async updateImageReferences( private async updateImageReferences(
localNote: Note, localNote: Note,
_serverNote: Note serverNote: Note
): Promise<NoteImage[]> { ): Promise<NoteImage[]> {
// Если нет изображений с base64, возвращаем как есть // Если нет изображений с base64, возвращаем как есть
const hasBase64Images = localNote.images.some((img) => img.base64Data); const hasBase64Images = localNote.images.some((img) => img.base64Data);
@ -459,7 +461,7 @@ class SyncService {
*/ */
private async updateFileReferences( private async updateFileReferences(
localNote: Note, localNote: Note,
_serverNote: Note serverNote: Note
): Promise<NoteFile[]> { ): Promise<NoteFile[]> {
// Если нет файлов с base64, возвращаем как есть // Если нет файлов с base64, возвращаем как есть
const hasBase64Files = localNote.files.some((file) => file.base64Data); const hasBase64Files = localNote.files.some((file) => file.base64Data);

View File

@ -9,7 +9,7 @@ interface AuthState {
const initialState: AuthState = { const initialState: AuthState = {
isAuthenticated: localStorage.getItem("isAuthenticated") === "true", isAuthenticated: localStorage.getItem("isAuthenticated") === "true",
userId: localStorage.getItem("userId") ? parseInt(localStorage.getItem("userId")!, 10) : null, userId: null,
username: localStorage.getItem("username") || null, username: localStorage.getItem("username") || null,
loading: false, loading: false,
}; };
@ -26,7 +26,6 @@ const authSlice = createSlice({
state.userId = action.payload.userId; state.userId = action.payload.userId;
state.username = action.payload.username; state.username = action.payload.username;
localStorage.setItem("isAuthenticated", "true"); localStorage.setItem("isAuthenticated", "true");
localStorage.setItem("userId", action.payload.userId.toString());
localStorage.setItem("username", action.payload.username); localStorage.setItem("username", action.payload.username);
}, },
clearAuth: (state) => { clearAuth: (state) => {
@ -34,7 +33,6 @@ const authSlice = createSlice({
state.userId = null; state.userId = null;
state.username = null; state.username = null;
localStorage.removeItem("isAuthenticated"); localStorage.removeItem("isAuthenticated");
localStorage.removeItem("userId");
localStorage.removeItem("username"); localStorage.removeItem("username");
}, },
}, },

View File

@ -50,11 +50,9 @@ const uiSlice = createSlice({
}, },
addNotification: ( addNotification: (
state, state,
action: PayloadAction<Omit<Notification, "id"> | Notification> action: PayloadAction<Omit<Notification, "id">>
) => { ) => {
const id = ('id' in action.payload && action.payload.id) const id = `notification-${Date.now()}-${Math.random()
? action.payload.id
: `notification-${Date.now()}-${Math.random()
.toString(36) .toString(36)
.substr(2, 9)}`; .substr(2, 9)}`;
state.notifications.push({ ...action.payload, id }); state.notifications.push({ ...action.payload, id });

View File

@ -118,6 +118,7 @@ body {
button { button {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
-moz-tap-highlight-color: transparent; -moz-tap-highlight-color: transparent;
tap-highlight-color: transparent;
outline: none; outline: none;
box-shadow: none; box-shadow: none;
} }
@ -136,6 +137,7 @@ a,
div[role="button"] { div[role="button"] {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
-moz-tap-highlight-color: transparent; -moz-tap-highlight-color: transparent;
tap-highlight-color: transparent;
outline: none; outline: none;
box-shadow: none; box-shadow: none;
} }

View File

@ -11,8 +11,8 @@
*/ */
export function getImageUrl( export function getImageUrl(
filePath: string, filePath: string,
noteId: number | string, noteId: number,
imageId: number | string imageId: number
): string { ): string {
// Если путь уже является полным URL (начинается с http:// или https://) // Если путь уже является полным URL (начинается с http:// или https://)
if (filePath.startsWith("http://") || filePath.startsWith("https://")) { if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
@ -47,8 +47,8 @@ export function getImageUrl(
*/ */
export function getFileUrl( export function getFileUrl(
filePath: string, filePath: string,
noteId: number | string, noteId: number,
fileId: number | string fileId: number
): string { ): string {
// Если путь уже является полным URL (начинается с http:// или https://) // Если путь уже является полным URL (начинается с http:// или https://)
if (filePath.startsWith("http://") || filePath.startsWith("https://")) { if (filePath.startsWith("http://") || filePath.startsWith("https://")) {

View File

@ -24,113 +24,50 @@ const spoilerExtension = {
}; };
// Кастомный renderer для внешних ссылок и чекбоксов // Кастомный renderer для внешних ссылок и чекбоксов
const renderer = new marked.Renderer(); const renderer: any = {
link(token: any) {
const href = token.href;
const title = token.title;
const text = token.text;
// Переопределяем link для внешних ссылок
const originalLink = renderer.link.bind(renderer);
renderer.link = function(token: any) {
const href = token.href;
const title = token.title;
const text = token.text;
try {
const url = new URL(href, window.location.href);
const isExternal = url.origin !== window.location.origin;
if (isExternal) {
return `<a href="${href}" title="${
title || ""
}" target="_blank" rel="noopener noreferrer" class="external-link">${text}</a>`;
}
} catch {}
return originalLink(token);
};
// Переопределяем listitem для поддержки чекбоксов
renderer.listitem = function(token: any) {
const task = token.task;
const checked = token.checked;
// Получаем токены для обработки
const tokens = token.tokens || [];
let text: string;
// Блоковые типы токенов, которые нельзя обрабатывать через parseInline
const blockTypes = ['list', 'blockquote', 'code', 'heading', 'paragraph', 'hr', 'table'];
// Обрабатываем токены вручную, избегая parseInline для блоковых элементов
if (tokens.length > 0) {
try { try {
// Разделяем токены на inline и блоковые const url = new URL(href, window.location.href);
const inlineTokens: any[] = []; const isExternal = url.origin !== window.location.origin;
const blockTokens: any[] = [];
tokens.forEach((t: any) => { if (isExternal) {
if (blockTypes.includes(t.type)) { return `<a href="${href}" title="${
blockTokens.push(t); title || ""
} else { }" target="_blank" rel="noopener noreferrer" class="external-link">${text}</a>`;
inlineTokens.push(t);
}
});
// Обрабатываем inline токены только если они есть
let inlineText = '';
if (inlineTokens.length > 0) {
try {
inlineText = this.parser.parseInline(inlineTokens);
} catch (inlineError) {
// Если ошибка при обработке inline, просто игнорируем их
console.warn('Error parsing inline tokens in listitem:', inlineError);
}
} }
} catch {}
// Обрабатываем блоковые токены через parser return `<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`;
let blockText = ''; },
if (blockTokens.length > 0) { // Кастомный renderer для элементов списка с чекбоксами
try { listitem(token: any) {
blockText = this.parser.parse(blockTokens); const task = token.task;
} catch (blockError) { const checked = token.checked;
// Если ошибка при обработке блоков, обрабатываем через стандартный renderer
console.warn('Error parsing block tokens in listitem:', blockError);
// Пытаемся обработать каждый блок отдельно
blockText = blockTokens.map((bt: any) => {
try {
return this.parser.parse([bt]);
} catch {
return '';
}
}).join('');
}
}
text = inlineText + blockText; // Используем tokens для правильной обработки форматирования внутри элементов списка
// token.tokens содержит массив токенов для вложенного содержимого
const tokens = token.tokens || [];
let text: string;
// Если после обработки текст пустой, используем fallback if (tokens.length > 0) {
if (!text || text.trim() === '') { // Используем this.parser.parseInline для правильной обработки вложенного форматирования
text = token.text || ''; // this указывает на экземпляр Parser в контексте renderer
} text = this.parser.parseInline(tokens);
} catch (error) { } else {
// Если общая ошибка, используем fallback - обрабатываем через стандартный parser // Fallback на token.text, если tokens отсутствуют
try { text = token.text || '';
text = this.parser.parse(tokens);
} catch (parseError) {
// Последний fallback - используем raw text
console.warn('Error parsing list item tokens:', parseError);
text = token.text || token.raw || '';
}
} }
} else {
text = token.text || '';
}
// Если это задача (чекбокс), добавляем чекбокс if (task) {
if (task) { const checkbox = `<input type="checkbox" ${checked ? "checked" : ""} />`;
const checkbox = `<input type="checkbox" ${checked ? "checked" : ""} disabled />`; return `<li class="task-list-item">${checkbox} ${text}</li>\n`;
return `<li class="task-list-item">${checkbox} ${text}</li>\n`; }
} return `<li>${text}</li>\n`;
},
return `<li>${text}</li>\n`;
}; };
// Настройка marked // Настройка marked

View File

@ -39,8 +39,6 @@ export function waitForIndexedDB(): Promise<IDBDatabase> {
/** /**
* Проверка состояния сети (более надежный метод) * Проверка состояния сети (более надежный метод)
* Не блокирует выполнение, всегда возвращает результат
* Различает "нет интернета" (offline) и "не авторизован" (401)
*/ */
export async function checkNetworkStatus(): Promise<boolean> { export async function checkNetworkStatus(): Promise<boolean> {
// Простая проверка navigator.onLine // Простая проверка navigator.onLine
@ -51,11 +49,10 @@ export async function checkNetworkStatus(): Promise<boolean> {
// Дополнительная проверка через fetch с коротким таймаутом // Дополнительная проверка через fetch с коротким таймаутом
try { try {
const controller = new AbortController(); const controller = new AbortController();
// Уменьшаем таймаут для более быстрого ответа const timeoutId = setTimeout(() => controller.abort(), 2000);
const timeoutId = setTimeout(() => controller.abort(), 1500);
// Используем /auth/status так как он всегда доступен при наличии сети // Используем /auth/status так как он всегда доступен при наличии сети
await fetch('/api/auth/status', { const response = await fetch('/api/auth/status', {
method: 'GET', method: 'GET',
signal: controller.signal, signal: controller.signal,
cache: 'no-cache', cache: 'no-cache',
@ -63,27 +60,10 @@ export async function checkNetworkStatus(): Promise<boolean> {
}); });
clearTimeout(timeoutId); clearTimeout(timeoutId);
return response.ok;
// Если получили ответ (даже 401), значит интернет есть
// 401 означает "не авторизован", но НЕ "нет интернета"
// Любой ответ (даже ошибка) означает, что сеть работает
return true;
} catch (error) { } 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; return navigator.onLine;
} }
} }

View File

@ -12,10 +12,7 @@ export default defineConfig({
"icon.svg", "icon.svg",
"icons/icon-192x192.png", "icons/icon-192x192.png",
"icons/icon-512x512.png", "icons/icon-512x512.png",
"manifest.json",
], ],
// Включаем стратегию для offline работы
strategies: "generateSW",
manifest: { manifest: {
name: "NoteJS - Система заметок", name: "NoteJS - Система заметок",
short_name: "NoteJS", short_name: "NoteJS",
@ -91,41 +88,8 @@ export default defineConfig({
], ],
}, },
workbox: { workbox: {
// Кэшируем все статические ресурсы для offline работы globPatterns: ["**/*.{js,css,html,ico,png,svg,woff,woff2,ttf,eot}"],
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff,woff2,ttf,eot,json}"],
// Стратегия для главной страницы - CacheFirst для offline работы
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api/, /^\/_/],
// Кэширование для offline работы приложения
runtimeCaching: [ 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\./, urlPattern: /^https:\/\/api\./,
handler: "NetworkFirst", handler: "NetworkFirst",
@ -135,7 +99,6 @@ export default defineConfig({
maxEntries: 50, maxEntries: 50,
maxAgeSeconds: 60 * 60, // 1 hour maxAgeSeconds: 60 * 60, // 1 hour
}, },
networkTimeoutSeconds: 3,
}, },
}, },
{ {
@ -147,7 +110,7 @@ export default defineConfig({
maxEntries: 100, maxEntries: 100,
maxAgeSeconds: 24 * 60 * 60, // 24 hours maxAgeSeconds: 24 * 60 * 60, // 24 hours
}, },
networkTimeoutSeconds: 3, networkTimeoutSeconds: 10,
}, },
}, },
{ {