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 { Inter } from 'next/font/google'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { AuthProvider } from '@/components/AuthProvider'
|
import { AuthProvider } from '@/components/AuthProvider'
|
||||||
|
import { PWAInstall } from '@/components/PWAInstall'
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
|
|
@ -11,6 +12,12 @@ const inter = Inter({
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'rNotes - Universal Knowledge Capture',
|
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.',
|
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: {
|
openGraph: {
|
||||||
title: 'rNotes - Universal Knowledge Capture',
|
title: 'rNotes - Universal Knowledge Capture',
|
||||||
description: 'Capture notes, clips, bookmarks, code, and files with a collaborative canvas.',
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|
@ -26,9 +37,13 @@ export default function RootLayout({
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
</head>
|
||||||
<body className={`${inter.variable} font-sans antialiased`}>
|
<body className={`${inter.variable} font-sans antialiased`}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
|
<PWAInstall />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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