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:
parent
d7a2372a56
commit
463cb99888
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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))
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue