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).
@@ -2,9 +2,35 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>frontend</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
4135
frontend/package-lock.json
generated
@@ -29,6 +29,8 @@
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"vite": "^7.2.2"
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"workbox-window": "^7.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
21
frontend/public/icons/apple-touch-icon.png
Normal 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 |
21
frontend/public/icons/apple-touch-icon.svg
Normal 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 |
20
frontend/public/icons/icon-192x192.png
Normal 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 |
20
frontend/public/icons/icon-192x192.svg
Normal 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 |
21
frontend/public/icons/icon-512x512.png
Normal 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 |
21
frontend/public/icons/icon-512x512.svg
Normal 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 |
9
frontend/public/splash/iphone-8.png
Normal 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 |
9
frontend/public/splash/iphone-8.svg
Normal 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 |
9
frontend/public/splash/iphone-x.png
Normal 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 |
9
frontend/public/splash/iphone-x.svg
Normal 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 |
9
frontend/public/splash/iphone-xr.png
Normal 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 |
9
frontend/public/splash/iphone-xr.svg
Normal 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 |
9
frontend/public/splash/iphone-xsmax.png
Normal 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 |
9
frontend/public/splash/iphone-xsmax.svg
Normal 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 |
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// Parse allowed hosts from environment variable
|
||||
const getAllowedHosts = () => {
|
||||
@@ -21,7 +22,68 @@ const getAllowedHosts = () => {
|
||||
|
||||
// https://vite.dev/config/
|
||||
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: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
|
||||