feat: add PWA support with install-to-homescreen banner

- manifest.json with app metadata and icons
- Service worker (network-first for pages, skip APIs)
- PWAInstall component with native install prompt + fallback instructions
- Generated 192/512 PNG icons and apple-touch-icon
- PWA meta tags in layout (apple-web-app, theme-color, manifest link)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 08:36:32 -07:00
parent d7a2372a56
commit 463cb99888
7 changed files with 242 additions and 1 deletions

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

39
public/manifest.json Normal file
View File

@ -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"
}
]
}

43
public/sw.js Normal file
View File

@ -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))
);
});

View File

@ -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 (
<html lang="en">
<head>
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
</head>
<body className={`${inter.variable} font-sans antialiased`}>
<AuthProvider>
{children}
<PWAInstall />
</AuthProvider>
</body>
</html>

View File

@ -0,0 +1,144 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function PWAInstall() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(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 (
<div className="fixed bottom-4 left-4 right-4 z-50 max-w-lg mx-auto">
<div className="bg-slate-800 border border-slate-700 rounded-xl p-4 shadow-2xl flex items-start gap-3">
<span className="text-2xl flex-shrink-0">📲</span>
<div className="flex-1 min-w-0">
{showInstructions ? (
isIOS ? (
<div>
<p className="text-sm font-semibold text-white mb-1">Add to Home Screen</p>
<p className="text-xs text-slate-400">
Tap{' '}
<span className="bg-slate-700 px-1.5 py-0.5 rounded text-slate-300 font-mono">
Share
</span>{' '}
then{' '}
<span className="bg-slate-700 px-1.5 py-0.5 rounded text-slate-300 font-mono">
Add to Home Screen
</span>
</p>
</div>
) : (
<div>
<p className="text-sm font-semibold text-white mb-1">Install rNotes</p>
<p className="text-xs text-slate-400 leading-relaxed">
1. Tap{' '}
<span className="bg-slate-700 px-1.5 py-0.5 rounded text-slate-300 font-mono">
</span>{' '}
(three dots) at top-right
<br />
2. Tap{' '}
<span className="bg-slate-700 px-1.5 py-0.5 rounded text-slate-300 font-mono">
Add to Home screen
</span>
<br />
3. Tap{' '}
<span className="bg-slate-700 px-1.5 py-0.5 rounded text-slate-300 font-mono">
Install
</span>
</p>
</div>
)
) : (
<div>
<p className="text-sm font-semibold text-white">Install rNotes</p>
<p className="text-xs text-slate-400">Add to your home screen for quick access</p>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{!showInstructions && (
<button
onClick={handleInstall}
className="px-4 py-1.5 rounded-full bg-amber-500 hover:bg-amber-400 text-black text-sm font-semibold transition-colors"
>
Install
</button>
)}
<button
onClick={handleDismiss}
className="text-slate-500 hover:text-slate-300 text-lg leading-none transition-colors"
title="Dismiss"
>
&times;
</button>
</div>
</div>
</div>
);
}