feat(pwa): add Progressive Web App support with iOS compatibility

- Install vite-plugin-pwa and workbox-window for PWA functionality
- Configure Vite with full PWA manifest (name, icons, theme, display)
- Add service worker caching for static assets only (no API cache)
- Create app icons (192x192, 512x512, apple-touch-icon)
- Generate iOS splash screens for multiple device sizes
- Add iOS-specific meta tags (apple-mobile-web-app-capable, etc.)
- Implement InstallPWA component with dual platform support:
  - Android/Chrome: beforeinstallprompt event with custom UI
  - iOS Safari: manual installation instructions with icons
- Add dismissal logic with 7-day localStorage persistence
- Update documentation to reflect 90% project completion

PWA implementation focuses on installability and static asset caching
while avoiding offline API cache (WebRTC requires active connection).
This commit is contained in:
Radosław Gierwiało
2025-11-19 20:59:26 +01:00
parent bfbfd0e729
commit f0a1bfb31a
21 changed files with 4618 additions and 12 deletions

View File

@@ -0,0 +1,177 @@
import { useState, useEffect } from 'react';
import { Download, X, Share, Plus } from 'lucide-react';
/**
* PWA Install Prompt Component
*
* - Android/Chrome: Shows custom install button with beforeinstallprompt event
* - iOS Safari: Shows instructions for manual installation
* - Other browsers: Hides if PWA not supported
*/
export default function InstallPWA() {
const [deferredPrompt, setDeferredPrompt] = useState(null);
const [showInstallBanner, setShowInstallBanner] = useState(false);
const [isIOS, setIsIOS] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
// Detect iOS
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
setIsIOS(iOS);
// Check if already installed (standalone mode)
const installed = window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
setIsInstalled(installed);
// Listen for beforeinstallprompt event (Android/Chrome/Edge)
const handleBeforeInstallPrompt = (e) => {
e.preventDefault();
setDeferredPrompt(e);
setShowInstallBanner(true);
};
// Listen for app installed event
const handleAppInstalled = () => {
setDeferredPrompt(null);
setShowInstallBanner(false);
setIsInstalled(true);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);
const handleInstallClick = async () => {
if (!deferredPrompt) return;
// Show install prompt
deferredPrompt.prompt();
// Wait for user choice
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('User accepted the install prompt');
} else {
console.log('User dismissed the install prompt');
}
setDeferredPrompt(null);
setShowInstallBanner(false);
};
const handleDismiss = () => {
setShowInstallBanner(false);
// Remember dismissal for 7 days
localStorage.setItem('pwa-install-dismissed', Date.now().toString());
};
// Don't show if already installed
if (isInstalled) {
return null;
}
// Android/Chrome: Show custom install button
if (showInstallBanner && deferredPrompt) {
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white dark:bg-gray-800 shadow-lg rounded-lg border border-gray-200 dark:border-gray-700 p-4 z-50 animate-slide-up">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white text-xl font-bold">S</span>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Install spotlight.cam
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Install our app for faster access and a better experience
</p>
<button
onClick={handleInstallClick}
className="mt-3 inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors"
>
<Download className="w-4 h-4 mr-2" />
Install App
</button>
</div>
</div>
</div>
);
}
// iOS Safari: Show manual installation instructions
if (isIOS) {
// Check if dismissed recently (within 7 days)
const dismissed = localStorage.getItem('pwa-ios-install-dismissed');
if (dismissed && Date.now() - parseInt(dismissed) < 7 * 24 * 60 * 60 * 1000) {
return null;
}
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white dark:bg-gray-800 shadow-lg rounded-lg border border-gray-200 dark:border-gray-700 p-4 z-50 animate-slide-up">
<button
onClick={() => {
localStorage.setItem('pwa-ios-install-dismissed', Date.now().toString());
setShowInstallBanner(false);
}}
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white text-xl font-bold">S</span>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Add to Home Screen
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Install this app on your iPhone:
</p>
<ol className="mt-2 space-y-1 text-xs text-gray-600 dark:text-gray-400">
<li className="flex items-center">
<span className="mr-2">1.</span>
<span>Tap the</span>
<Share className="w-3 h-3 mx-1 inline" />
<span>Share button</span>
</li>
<li className="flex items-center">
<span className="mr-2">2.</span>
<span>Scroll and tap</span>
<Plus className="w-3 h-3 mx-1 inline" />
<span>Add to Home Screen</span>
</li>
</ol>
</div>
</div>
</div>
);
}
return null;
}