diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..663a1d9 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..df14322 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..0a2c4e8 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..127797d --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,39 @@ +{ + "name": "rNotes - Universal Knowledge Capture", + "short_name": "rNotes", + "description": "Capture notes, clips, bookmarks, code, audio, and files. Organize in notebooks, tag freely.", + "start_url": "/", + "scope": "/", + "id": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "orientation": "portrait-primary", + "categories": ["productivity", "utilities"], + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..3fbbb0d --- /dev/null +++ b/public/sw.js @@ -0,0 +1,43 @@ +const CACHE_NAME = 'rnotes-v1'; +const PRECACHE = [ + '/', + '/manifest.json', + '/icon-192.png', + '/icon-512.png', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((names) => + Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Always go to network for API calls + if (url.pathname.startsWith('/api/')) return; + + // Network-first for pages, cache-first for static assets + event.respondWith( + fetch(event.request) + .then((response) => { + if (response.status === 200) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } + return response; + }) + .catch(() => caches.match(event.request)) + ); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ecb2bdb..df465bc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,8 @@ -import type { Metadata } from 'next' +import type { Metadata, Viewport } from 'next' import { Inter } from 'next/font/google' import './globals.css' import { AuthProvider } from '@/components/AuthProvider' +import { PWAInstall } from '@/components/PWAInstall' const inter = Inter({ subsets: ['latin'], @@ -11,6 +12,12 @@ const inter = Inter({ export const metadata: Metadata = { title: 'rNotes - Universal Knowledge Capture', description: 'Capture notes, clips, bookmarks, code, and files. Organize in notebooks, tag freely, and collaborate on a visual canvas shared across r*Spaces.', + manifest: '/manifest.json', + appleWebApp: { + capable: true, + statusBarStyle: 'black-translucent', + title: 'rNotes', + }, openGraph: { title: 'rNotes - Universal Knowledge Capture', description: 'Capture notes, clips, bookmarks, code, and files with a collaborative canvas.', @@ -19,6 +26,10 @@ export const metadata: Metadata = { }, } +export const viewport: Viewport = { + themeColor: '#0a0a0a', +} + export default function RootLayout({ children, }: Readonly<{ @@ -26,9 +37,13 @@ export default function RootLayout({ }>) { return ( + + + {children} + diff --git a/src/components/PWAInstall.tsx b/src/components/PWAInstall.tsx new file mode 100644 index 0000000..4ea7b69 --- /dev/null +++ b/src/components/PWAInstall.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +export function PWAInstall() { + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [showBanner, setShowBanner] = useState(false); + const [isIOS, setIsIOS] = useState(false); + const [showInstructions, setShowInstructions] = useState(false); + + useEffect(() => { + // Register service worker + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(console.error); + } + + // Check if already installed + const isStandalone = + window.matchMedia('(display-mode: standalone)').matches || + (navigator as unknown as { standalone?: boolean }).standalone === true; + + if (isStandalone) return; + + // Detect iOS + const ios = /iPad|iPhone|iPod/.test(navigator.userAgent); + setIsIOS(ios); + + // Check dismiss cooldown (24h) + const dismissedAt = localStorage.getItem('pwa_dismissed'); + if (dismissedAt && Date.now() - parseInt(dismissedAt) < 86400000) return; + + setShowBanner(true); + + // Capture the install prompt + const handler = (e: Event) => { + e.preventDefault(); + setDeferredPrompt(e as BeforeInstallPromptEvent); + }; + + window.addEventListener('beforeinstallprompt', handler); + window.addEventListener('appinstalled', () => { + setShowBanner(false); + setDeferredPrompt(null); + }); + + return () => window.removeEventListener('beforeinstallprompt', handler); + }, []); + + const handleInstall = useCallback(async () => { + if (deferredPrompt) { + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + if (outcome === 'accepted') { + setShowBanner(false); + } + setDeferredPrompt(null); + } else { + // No native prompt available — show manual instructions + setShowInstructions(true); + } + }, [deferredPrompt]); + + const handleDismiss = useCallback(() => { + setShowBanner(false); + localStorage.setItem('pwa_dismissed', Date.now().toString()); + }, []); + + if (!showBanner) return null; + + return ( +
+
+ 📲 +
+ {showInstructions ? ( + isIOS ? ( +
+

Add to Home Screen

+

+ Tap{' '} + + ⎋ Share + {' '} + then{' '} + + Add to Home Screen + +

+
+ ) : ( +
+

Install rNotes

+

+ 1. Tap{' '} + + ⋮ + {' '} + (three dots) at top-right +
+ 2. Tap{' '} + + Add to Home screen + +
+ 3. Tap{' '} + + Install + +

+
+ ) + ) : ( +
+

Install rNotes

+

Add to your home screen for quick access

+
+ )} +
+
+ {!showInstructions && ( + + )} + +
+
+
+ ); +}