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:
@@ -17,6 +17,7 @@ import HistoryPage from './pages/HistoryPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import PublicProfilePage from './pages/PublicProfilePage';
|
||||
import VerificationBanner from './components/common/VerificationBanner';
|
||||
import InstallPWA from './components/pwa/InstallPWA';
|
||||
|
||||
// Protected Route Component with Verification Banner
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
@@ -65,6 +66,9 @@ function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
{/* PWA Install Prompt */}
|
||||
<InstallPWA />
|
||||
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route
|
||||
|
||||
177
frontend/src/components/pwa/InstallPWA.jsx
Normal file
177
frontend/src/components/pwa/InstallPWA.jsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user