feat: implement offline-first functionality with local storage
- Add IndexedDB storage layer for notes (src/db/localDB.ts) - Implement sync manager with queue and conflict resolution (src/services/syncManager.ts) - Add online/offline detection hook (src/hooks/useOnlineStatus.ts) - Load notes from local storage immediately on app startup - Add sync status UI indicators (offline badge, pending count) - Auto-sync every 5 minutes when online - Queue operations when offline, sync when connection restored - Fix note content update when synced from server while viewing - Retry failed sync operations up to 5 times - Temporary IDs for offline-created notes
This commit is contained in:
@@ -38,6 +38,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const previousNoteIdRef = useRef<number | null>(null);
|
||||
const previousNoteContentRef = useRef<string>('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -131,12 +132,12 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
useEffect(() => {
|
||||
const loadNewNote = () => {
|
||||
if (note) {
|
||||
console.log(`[Note ${note.id}] Loading note. Title: "${note.title}", Content length: ${note.content.length}`);
|
||||
setLocalTitle(note.title);
|
||||
setLocalContent(note.content);
|
||||
setLocalCategory(note.category || '');
|
||||
setLocalFavorite(note.favorite);
|
||||
setHasUnsavedChanges(false);
|
||||
setIsPreviewMode(false);
|
||||
setProcessedContent(''); // Clear preview content immediately
|
||||
|
||||
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||
@@ -144,21 +145,29 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
setTitleManuallyEdited(!titleMatchesFirstLine);
|
||||
|
||||
previousNoteIdRef.current = note.id;
|
||||
previousNoteContentRef.current = note.content;
|
||||
}
|
||||
};
|
||||
|
||||
// Switching to a different note
|
||||
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
|
||||
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
|
||||
// Clear preview content immediately when switching notes
|
||||
setProcessedContent('');
|
||||
if (hasUnsavedChanges) {
|
||||
handleSave();
|
||||
}
|
||||
loadNewNote();
|
||||
} else {
|
||||
}
|
||||
// Same note but content changed from server (and no unsaved local changes)
|
||||
else if (note && previousNoteIdRef.current === note.id && !hasUnsavedChanges && previousNoteContentRef.current !== note.content) {
|
||||
console.log(`Note ${note.id} content changed from server (prev: ${previousNoteContentRef.current.length} chars, new: ${note.content.length} chars)`);
|
||||
loadNewNote();
|
||||
}
|
||||
}, [note?.id]);
|
||||
// Initial load
|
||||
else if (!note || previousNoteIdRef.current === null) {
|
||||
loadNewNote();
|
||||
}
|
||||
}, [note?.id, note?.content, note?.modified]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!note || !hasUnsavedChanges) return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Note } from '../types';
|
||||
import { SyncStatus } from '../services/syncManager';
|
||||
|
||||
interface NotesListProps {
|
||||
notes: Note[];
|
||||
@@ -13,6 +14,9 @@ interface NotesListProps {
|
||||
showFavoritesOnly: boolean;
|
||||
onToggleFavorites: () => void;
|
||||
hasUnsavedChanges: boolean;
|
||||
syncStatus: SyncStatus;
|
||||
pendingSyncCount: number;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
export function NotesList({
|
||||
@@ -27,6 +31,9 @@ export function NotesList({
|
||||
showFavoritesOnly,
|
||||
onToggleFavorites,
|
||||
hasUnsavedChanges,
|
||||
syncStatus: _syncStatus,
|
||||
pendingSyncCount,
|
||||
isOnline,
|
||||
}: NotesListProps) {
|
||||
const [isSyncing, setIsSyncing] = React.useState(false);
|
||||
const [deleteClickedId, setDeleteClickedId] = React.useState<number | null>(null);
|
||||
@@ -117,7 +124,22 @@ export function NotesList({
|
||||
>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Notes</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Notes</h2>
|
||||
{!isOnline && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300 rounded-full flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3" />
|
||||
</svg>
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
{pendingSyncCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded-full">
|
||||
{pendingSyncCount} pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
|
||||
Reference in New Issue
Block a user