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

@@ -21,8 +21,8 @@
- Phase 2 (Matches & Ratings API) - ✅ COMPLETED - Phase 2 (Matches & Ratings API) - ✅ COMPLETED
- Phase 1.6 (Competition Heats) - ✅ COMPLETED - Phase 1.6 (Competition Heats) - ✅ COMPLETED
- Phase 1.5 (Email & WSDC & Profiles & Security & QR Check-in) - ✅ COMPLETED - Phase 1.5 (Email & WSDC & Profiles & Security & QR Check-in) - ✅ COMPLETED
**Progress:** ~85% overall **Progress:** ~90% overall
**Next Goal:** PWA features, Production deployment **Next Goal:** Production deployment, monitoring, improved test coverage
### What Works Now ### What Works Now
- ✅ Docker Compose (nginx:8080 + frontend + backend + PostgreSQL) - ✅ Docker Compose (nginx:8080 + frontend + backend + PostgreSQL)
@@ -48,10 +48,10 @@
-**Landing page with hero section and features showcase - Phase 3** -**Landing page with hero section and features showcase - Phase 3**
-**WebRTC test suite (7 backend tests passing) - Phase 3** -**WebRTC test suite (7 backend tests passing) - Phase 3**
-**Security hardening (CSRF protection, Account Lockout, Rate Limiting) - Phase 3** -**Security hardening (CSRF protection, Account Lockout, Rate Limiting) - Phase 3**
-**PWA features (manifest, icons, service worker, iOS support, install prompt) - Phase 3**
- ✅ Real-time chat (Socket.IO for event & match rooms) - ✅ Real-time chat (Socket.IO for event & match rooms)
### What's Missing ### What's Missing
- ⏳ PWA features (manifest, service worker, offline support)
- ⏳ Production deployment & monitoring - ⏳ Production deployment & monitoring
- ⏳ Competition heats UI integration improvements - ⏳ Competition heats UI integration improvements
- ⏳ Improved test coverage (currently ~43% backend) - ⏳ Improved test coverage (currently ~43% backend)
@@ -70,6 +70,7 @@
- React Router - React Router
- Context API for state - Context API for state
- socket.io-client for real-time chat - socket.io-client for real-time chat
- PWA (vite-plugin-pwa, Workbox for service worker)
**Backend:** **Backend:**
- Node.js 20 + Express 4.18.2 - Node.js 20 + Express 4.18.2
@@ -138,6 +139,7 @@
- `frontend/src/utils/webrtcDetection.js` - **NEW: WebRTC browser detection - Phase 2.5** - `frontend/src/utils/webrtcDetection.js` - **NEW: WebRTC browser detection - Phase 2.5**
- `frontend/src/components/WebRTCWarning.jsx` - **NEW: WebRTC blocked warning component - Phase 2.5** - `frontend/src/components/WebRTCWarning.jsx` - **NEW: WebRTC blocked warning component - Phase 2.5**
- `frontend/src/components/heats/HeatsBanner.jsx` - **NEW: Heats declaration form component - Phase 1.6** - `frontend/src/components/heats/HeatsBanner.jsx` - **NEW: Heats declaration form component - Phase 1.6**
- `frontend/src/components/pwa/InstallPWA.jsx` - **NEW: PWA install prompt (Android + iOS) - Phase 3**
- `frontend/src/components/common/PasswordStrengthIndicator.jsx` - Password strength indicator - `frontend/src/components/common/PasswordStrengthIndicator.jsx` - Password strength indicator
- `frontend/src/components/common/VerificationBanner.jsx` - Email verification banner - `frontend/src/components/common/VerificationBanner.jsx` - Email verification banner
- `frontend/src/contexts/AuthContext.jsx` - JWT authentication integration - `frontend/src/contexts/AuthContext.jsx` - JWT authentication integration
@@ -146,6 +148,10 @@
- `frontend/src/data/countries.js` - **NEW: List of 195 countries - Phase 1.5** - `frontend/src/data/countries.js` - **NEW: List of 195 countries - Phase 1.5**
- `frontend/src/utils/__tests__/webrtcDetection.test.js` - **NEW: WebRTC detection tests - Phase 3** - `frontend/src/utils/__tests__/webrtcDetection.test.js` - **NEW: WebRTC detection tests - Phase 3**
- `frontend/src/components/__tests__/WebRTCWarning.test.jsx` - **NEW: WebRTC warning tests - Phase 3** - `frontend/src/components/__tests__/WebRTCWarning.test.jsx` - **NEW: WebRTC warning tests - Phase 3**
- `frontend/vite.config.js` - **UPDATED: PWA plugin configuration - Phase 3**
- `frontend/index.html` - **UPDATED: iOS PWA meta tags, app icons, splash screens - Phase 3**
- `frontend/public/icons/` - **NEW: App icons (192x192, 512x512, apple-touch-icon) - Phase 3**
- `frontend/public/splash/` - **NEW: iOS splash screens for various devices - Phase 3**
**Backend:** **Backend:**
- `backend/src/app.js` - **UPDATED: CSRF protection, cookie-parser middleware - Phase 3** - `backend/src/app.js` - **UPDATED: CSRF protection, cookie-parser middleware - Phase 3**
@@ -432,7 +438,7 @@ RUN apk add --no-cache openssl
--- ---
**Last Updated:** 2025-11-15 **Last Updated:** 2025-11-19
**Phase 1 Status:** ✅ COMPLETED - Backend Foundation (Express + PostgreSQL + JWT + Socket.IO) **Phase 1 Status:** ✅ COMPLETED - Backend Foundation (Express + PostgreSQL + JWT + Socket.IO)
**Phase 1.5 Status:** ✅ COMPLETED - Email Verification & WSDC Integration & User Profiles & Security **Phase 1.5 Status:** ✅ COMPLETED - Email Verification & WSDC Integration & User Profiles & Security
- AWS SES email verification (link + PIN) - AWS SES email verification (link + PIN)
@@ -464,6 +470,6 @@ RUN apk add --no-cache openssl
- ✅ Landing page with hero section - ✅ Landing page with hero section
- ✅ WebRTC test suite (7 backend tests passing) - ✅ WebRTC test suite (7 backend tests passing)
- ✅ Security hardening (CSRF, Account Lockout, env variables, comprehensive tests) - ✅ Security hardening (CSRF, Account Lockout, env variables, comprehensive tests)
- PWA features (manifest, service worker) - PWA features (manifest, service worker, icons, iOS support, install prompts)
- ⏳ Production deployment - ⏳ Production deployment
**Next Goal:** PWA features, Production deployment **Next Goal:** Production deployment, monitoring, improved test coverage

View File

@@ -2,9 +2,35 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- PWA Meta Tags -->
<title>frontend</title> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<meta name="description" content="P2P video exchange platform for dance event participants. Share videos securely with WebRTC." />
<meta name="theme-color" content="#6366f1" />
<!-- Favicon -->
<link rel="icon" type="image/png" href="/icons/icon-192x192.png" />
<!-- iOS Safari PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="spotlight" />
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<!-- iOS Splash Screens -->
<link rel="apple-touch-startup-image" href="/splash/iphone-x.png"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)" />
<link rel="apple-touch-startup-image" href="/splash/iphone-xr.png"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)" />
<link rel="apple-touch-startup-image" href="/splash/iphone-xsmax.png"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)" />
<link rel="apple-touch-startup-image" href="/splash/iphone-8.png"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" />
<title>spotlight.cam - Dance Event Video Exchange</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,8 @@
"globals": "^16.5.0", "globals": "^16.5.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.18", "tailwindcss": "^3.4.18",
"vite": "^7.2.2" "vite": "^7.2.2",
"vite-plugin-pwa": "^1.1.0",
"workbox-window": "^7.4.0"
} }
} }

View File

@@ -0,0 +1,21 @@
<svg width="180" height="180" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="180" height="180" fill="#6366f1"/>
<!-- Spotlight effect -->
<g transform="translate(90, 90)">
<circle r="54" fill="#ffffff" opacity="0.2"/>
<circle r="36" fill="#ffffff" opacity="0.3"/>
<circle r="18" fill="#ffffff"/>
<!-- Letter 'S' -->
<text
x="0"
y="27"
font-family="Arial, sans-serif"
font-size="72"
font-weight="bold"
fill="#ffffff"
text-anchor="middle">S</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -0,0 +1,21 @@
<svg width="180" height="180" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="180" height="180" fill="#6366f1"/>
<!-- Spotlight effect -->
<g transform="translate(90, 90)">
<circle r="54" fill="#ffffff" opacity="0.2"/>
<circle r="36" fill="#ffffff" opacity="0.3"/>
<circle r="18" fill="#ffffff"/>
<!-- Letter 'S' -->
<text
x="0"
y="27"
font-family="Arial, sans-serif"
font-size="72"
font-weight="bold"
fill="#ffffff"
text-anchor="middle">S</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -0,0 +1,20 @@
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="192" height="192" fill="#6366f1"/>
<!-- Spotlight effect -->
<g transform="translate(96, 96)">
<circle r="57" fill="#ffffff" opacity="0.2"/>
<circle r="38" fill="#ffffff" opacity="0.3"/>
<circle r="19" fill="#ffffff"/>
<!-- Letter 'S' -->
<text
x="0"
y="29" font-family="Arial, sans-serif"
font-size="77"
font-weight="bold"
fill="#ffffff"
text-anchor="middle">S</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -0,0 +1,20 @@
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="192" height="192" fill="#6366f1"/>
<!-- Spotlight effect -->
<g transform="translate(96, 96)">
<circle r="57" fill="#ffffff" opacity="0.2"/>
<circle r="38" fill="#ffffff" opacity="0.3"/>
<circle r="19" fill="#ffffff"/>
<!-- Letter 'S' -->
<text
x="0"
y="29" font-family="Arial, sans-serif"
font-size="77"
font-weight="bold"
fill="#ffffff"
text-anchor="middle">S</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -0,0 +1,21 @@
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="512" height="512" fill="#6366f1"/>
<!-- Spotlight effect -->
<g transform="translate(256, 256)">
<circle r="154" fill="#ffffff" opacity="0.2"/>
<circle r="102" fill="#ffffff" opacity="0.3"/>
<circle r="51" fill="#ffffff"/>
<!-- Letter 'S' -->
<text
x="0"
y="77"
font-family="Arial, sans-serif"
font-size="205"
font-weight="bold"
fill="#ffffff"
text-anchor="middle">S</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 558 B

View File

@@ -0,0 +1,21 @@
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="512" height="512" fill="#6366f1"/>
<!-- Spotlight effect -->
<g transform="translate(256, 256)">
<circle r="154" fill="#ffffff" opacity="0.2"/>
<circle r="102" fill="#ffffff" opacity="0.3"/>
<circle r="51" fill="#ffffff"/>
<!-- Letter 'S' -->
<text
x="0"
y="77"
font-family="Arial, sans-serif"
font-size="205"
font-weight="bold"
fill="#ffffff"
text-anchor="middle">S</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 558 B

View File

@@ -0,0 +1,9 @@
<svg width="750" height="1334" xmlns="http://www.w3.org/2000/svg">
<rect width="750" height="1334" fill="#6366f1"/>
<g transform="translate(375, 667)">
<circle r="120" fill="#ffffff" opacity="0.1"/>
<circle r="80" fill="#ffffff" opacity="0.2"/>
<circle r="40" fill="#ffffff"/>
<text x="0" y="150" font-family="Arial, sans-serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle">spotlight.cam</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@@ -0,0 +1,9 @@
<svg width="750" height="1334" xmlns="http://www.w3.org/2000/svg">
<rect width="750" height="1334" fill="#6366f1"/>
<g transform="translate(375, 667)">
<circle r="120" fill="#ffffff" opacity="0.1"/>
<circle r="80" fill="#ffffff" opacity="0.2"/>
<circle r="40" fill="#ffffff"/>
<text x="0" y="150" font-family="Arial, sans-serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle">spotlight.cam</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@@ -0,0 +1,9 @@
<svg width="1125" height="2436" xmlns="http://www.w3.org/2000/svg">
<rect width="1125" height="2436" fill="#6366f1"/>
<g transform="translate(562.5, 1218)">
<circle r="120" fill="#ffffff" opacity="0.1"/>
<circle r="80" fill="#ffffff" opacity="0.2"/>
<circle r="40" fill="#ffffff"/>
<text x="0" y="150" font-family="Arial, sans-serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle">spotlight.cam</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 458 B

View File

@@ -0,0 +1,9 @@
<svg width="1125" height="2436" xmlns="http://www.w3.org/2000/svg">
<rect width="1125" height="2436" fill="#6366f1"/>
<g transform="translate(562.5, 1218)">
<circle r="120" fill="#ffffff" opacity="0.1"/>
<circle r="80" fill="#ffffff" opacity="0.2"/>
<circle r="40" fill="#ffffff"/>
<text x="0" y="150" font-family="Arial, sans-serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle">spotlight.cam</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 458 B

View File

@@ -0,0 +1,9 @@
<svg width="828" height="1792" xmlns="http://www.w3.org/2000/svg">
<rect width="828" height="1792" fill="#6366f1"/>
<g transform="translate(414, 896)">
<circle r="120" fill="#ffffff" opacity="0.1"/>
<circle r="80" fill="#ffffff" opacity="0.2"/>
<circle r="40" fill="#ffffff"/>
<text x="0" y="150" font-family="Arial, sans-serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle">spotlight.cam</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@@ -0,0 +1,9 @@
<svg width="828" height="1792" xmlns="http://www.w3.org/2000/svg">
<rect width="828" height="1792" fill="#6366f1"/>
<g transform="translate(414, 896)">
<circle r="120" fill="#ffffff" opacity="0.1"/>
<circle r="80" fill="#ffffff" opacity="0.2"/>
<circle r="40" fill="#ffffff"/>
<text x="0" y="150" font-family="Arial, sans-serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle">spotlight.cam</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@@ -0,0 +1,9 @@
<svg width="1242" height="2688" xmlns="http://www.w3.org/2000/svg">
<rect width="1242" height="2688" fill="#6366f1"/>
<g transform="translate(621, 1344)">
<circle r="120" fill="#ffffff" opacity="0.1"/>
<circle r="80" fill="#ffffff" opacity="0.2"/>
<circle r="40" fill="#ffffff"/>
<text x="0" y="150" font-family="Arial, sans-serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle">spotlight.cam</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 456 B

View File

@@ -0,0 +1,9 @@
<svg width="1242" height="2688" xmlns="http://www.w3.org/2000/svg">
<rect width="1242" height="2688" fill="#6366f1"/>
<g transform="translate(621, 1344)">
<circle r="120" fill="#ffffff" opacity="0.1"/>
<circle r="80" fill="#ffffff" opacity="0.2"/>
<circle r="40" fill="#ffffff"/>
<text x="0" y="150" font-family="Arial, sans-serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle">spotlight.cam</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 456 B

View File

@@ -17,6 +17,7 @@ import HistoryPage from './pages/HistoryPage';
import ProfilePage from './pages/ProfilePage'; import ProfilePage from './pages/ProfilePage';
import PublicProfilePage from './pages/PublicProfilePage'; import PublicProfilePage from './pages/PublicProfilePage';
import VerificationBanner from './components/common/VerificationBanner'; import VerificationBanner from './components/common/VerificationBanner';
import InstallPWA from './components/pwa/InstallPWA';
// Protected Route Component with Verification Banner // Protected Route Component with Verification Banner
const ProtectedRoute = ({ children }) => { const ProtectedRoute = ({ children }) => {
@@ -65,6 +66,9 @@ function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
{/* PWA Install Prompt */}
<InstallPWA />
<Routes> <Routes>
{/* Public Routes */} {/* Public Routes */}
<Route <Route

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

View File

@@ -1,5 +1,6 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
// Parse allowed hosts from environment variable // Parse allowed hosts from environment variable
const getAllowedHosts = () => { const getAllowedHosts = () => {
@@ -21,7 +22,68 @@ const getAllowedHosts = () => {
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
manifest: {
name: 'spotlight.cam - Dance Event Video Exchange',
short_name: 'spotlight',
description: 'P2P video exchange platform for dance event participants',
theme_color: '#6366f1',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any maskable',
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable',
},
{
src: '/icons/apple-touch-icon.png',
sizes: '180x180',
type: 'image/png',
},
],
},
workbox: {
// Cache only static assets (no API caching)
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
// Cache images from ui-avatars.com
urlPattern: /^https:\/\/ui-avatars\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'avatar-images',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
navigateFallback: null, // Disable offline fallback (app requires internet)
},
devOptions: {
enabled: false, // Disable in dev to avoid conflicts
},
}),
],
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5173, port: 5173,