diff --git a/index.html b/index.html index 99e2c70..2a622ab 100644 --- a/index.html +++ b/index.html @@ -83,6 +83,33 @@ + + + + + + + + + + + + + + + diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..5b87cf0 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,60 @@ + + + + + + TrustyNotes - Offline + + + +
+
📱
+

You're Offline

+

Don't worry! Your notes are safely stored and will sync when you're back online.

+ +
+ + \ No newline at end of file diff --git a/public/site.webmanifest b/public/site.webmanifest index 45dc8a2..264175d 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1 +1,41 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "TrustyNotes", + "short_name": "TrustyNotes", + "description": "Secure, encrypted note-taking app", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1e1e1e", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "New Note", + "short_name": "New", + "description": "Create a new note", + "url": "/?new=true", + "icons": [{ "src": "/icons/new-note.png", "sizes": "192x192" }] + } + ], + "screenshots": [ + { + "src": "/screenshots/app.png", + "sizes": "1280x720", + "type": "image/png", + "platform": "wide", + "label": "TrustyNotes App Interface" + } + ] +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 72a9680..a6f926d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,6 +41,8 @@ import { SyncSettings as SyncSettingsType } from './types/sync'; import { WebStorageService } from './services/webStorage'; import './styles/richtext.css'; import { MobileNav } from './components/MobileNav'; +import { notifications } from '@mantine/notifications'; +import { InstallPrompt } from './components/InstallPrompt'; interface Note { id?: number; @@ -373,6 +375,26 @@ async function deleteNote(noteId: number) { } } + useEffect(() => { + WebStorageService.initializeOfflineSupport(); + + window.addEventListener('online', () => { + notifications.show({ + title: 'Back Online', + message: 'Your changes will be synced automatically.', + color: 'green' + }); + }); + + window.addEventListener('offline', () => { + notifications.show({ + title: 'Offline Mode', + message: 'Changes will be saved locally and synced when online.', + color: 'yellow' + }); + }); + }, []); + return ( + {isMobile && } Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +export function InstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [showIOSPrompt, setShowIOSPrompt] = useState(false); + + useEffect(() => { + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !/CriOS/.test(navigator.userAgent); + const isStandalone = window.matchMedia('(display-mode: standalone)').matches; + + if (isIOS && !isStandalone) { + setShowIOSPrompt(true); + } + + const handleBeforeInstall = (e: Event) => { + e.preventDefault(); + setDeferredPrompt(e as BeforeInstallPromptEvent); + }; + + window.addEventListener('beforeinstallprompt', handleBeforeInstall); + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstall); + }; + }, []); + + const handleInstall = async () => { + if (!deferredPrompt) return; + + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + + if (outcome === 'accepted') { + setDeferredPrompt(null); + } + }; + + if (!deferredPrompt && !showIOSPrompt) return null; + + return ( + + + + + Install TrustyNotes + + + {showIOSPrompt ? ( + <> + + Install TrustyNotes on your iOS device: tap the share button + and select "Add to Home Screen" + + + + ) : ( + <> + + Install TrustyNotes for quick access and offline use + + + + )} + + + ); +} \ No newline at end of file diff --git a/src/components/MobileNav.tsx b/src/components/MobileNav.tsx index 51d948d..5ac494b 100644 --- a/src/components/MobileNav.tsx +++ b/src/components/MobileNav.tsx @@ -1,4 +1,18 @@ -import { Group, ActionIcon, Drawer, Stack, Button, TextInput, Box, Text, Paper, Image, MantineColorScheme, Anchor } from '@mantine/core'; +import { useState, useEffect } from 'react'; +import { + Group, + ActionIcon, + Drawer, + Stack, + Button, + TextInput, + Box, + Text, + Paper, + Image, + MantineColorScheme, + Anchor +} from '@mantine/core'; import { IconPlus, IconSearch, @@ -8,7 +22,8 @@ import { IconDownload, IconUpload, IconBrandGithub, - IconTrash + IconTrash, + IconWifiOff } from '@tabler/icons-react'; import { format } from 'date-fns'; @@ -53,64 +68,93 @@ export function MobileNav({ onSelectNote, onDeleteNote }: MobileNavProps) { - const isDark = colorScheme === 'dark' || (colorScheme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches); + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + const handleNoteSelect = (note: Note) => { + onSelectNote(note); + onClose(); + }; return ( + + + + Logo + TrustyNotes + + × + + - Logo - TrustyNotes + - } - > - + } value={searchQuery} onChange={(e) => onSearch(e.currentTarget.value)} + leftSection={} /> - - + {!isOnline && ( + + + + Offline Mode - Changes will sync when online + + + )} + {notes.map((note) => ( { - onSelectNote(note); - onClose(); - }} + onClick={() => handleNoteSelect(note)} style={{ cursor: 'pointer', backgroundColor: selectedNote?.id === note.id ? - 'var(--mantine-color-blue-light)' : undefined, + 'var(--mantine-color-blue-light)' : undefined }} > - + {note.title || 'Untitled'} - + {format(note.updated_at, 'MMM d, yyyy HH:mm')} @@ -131,54 +175,53 @@ export function MobileNav({ - - - - - - - + + + + + - + + + + + + + { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(ASSETS_TO_CACHE); + }) + ); +}); + +self.addEventListener('fetch', (event) => { + if (API_ROUTES.some(route => event.request.url.startsWith(route))) { + return; + } + + event.respondWith( + caches.match(event.request) + .then((response) => { + if (response) { + return response; + } + + return fetch(event.request.clone()) + .then((response) => { + if (!response || response.status !== 200 || response.type !== 'basic') { + return response; + } + + const responseToCache = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseToCache); + }); + + return response; + }) + .catch(() => { + if (event.request.mode === 'navigate') { + return caches.match('/offline.html'); + } + return new Response('', { + status: 408, + statusText: 'Request timed out.' + }); + }); + }) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }) + ); +}); + +self.addEventListener('message', (event) => { + if (event.data === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); \ No newline at end of file diff --git a/src/services/webStorage.ts b/src/services/webStorage.ts index 5a996bf..4b0eb03 100644 --- a/src/services/webStorage.ts +++ b/src/services/webStorage.ts @@ -108,6 +108,11 @@ export class WebStorageService { } static async syncWithServer(serverUrl: string, retries = 3): Promise { + if (!navigator.onLine) { + console.log('Device is offline, skipping sync'); + return; + } + if (!this.crypto) { throw new Error('Crypto not initialized'); } @@ -231,4 +236,49 @@ export class WebStorageService { localStorage.setItem(this.NOTES_KEY, JSON.stringify(filteredNotes)); } + static async isOnline(): Promise { + return navigator.onLine; + } + + static async saveNoteWithOfflineSupport(note: Partial): Promise { + await this.saveNote(note); + + if (!navigator.onLine) { + const offlinePendingSync = JSON.parse( + localStorage.getItem('offline_pending_sync') || '[]' + ); + offlinePendingSync.push(note.id); + localStorage.setItem('offline_pending_sync', JSON.stringify(offlinePendingSync)); + } + } + + static async syncOfflineChanges(): Promise { + if (!navigator.onLine) return; + + const pendingSync = JSON.parse( + localStorage.getItem('offline_pending_sync') || '[]' + ); + + if (pendingSync.length === 0) return; + + const settings = await this.getSyncSettings(); + if (settings?.auto_sync && settings?.seed_phrase) { + try { + await this.syncWithServer(settings.server_url); + localStorage.setItem('offline_pending_sync', '[]'); + } catch (error) { + console.error('Failed to sync offline changes:', error); + } + } + } + + static initializeOfflineSupport(): void { + window.addEventListener('online', () => { + this.syncOfflineChanges(); + }); + + window.addEventListener('offline', () => { + console.log('App is offline. Changes will be synced when online.'); + }); + } } \ No newline at end of file