Make CityTracker installable as a PWA
Adds a web app manifest, Workbox service worker (via vite-plugin-pwa), and placeholder icons so the app can be installed to a home screen / app window. App shell is precached for offline load; API and upload requests stay network-only so city data and photos are never served stale. Desktop browser access is unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
1cb9a2e133
commit
11b6d493e7
15 changed files with 3806 additions and 3 deletions
|
|
@ -3,6 +3,12 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#1971c2" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<link rel="icon" href="/favicon-32x32.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>CityTracker</title>
|
<title>CityTracker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"rimraf": "^5.0.9",
|
"rimraf": "^5.0.9",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^6.4.3"
|
"vite": "^6.4.3",
|
||||||
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
client/public/apple-touch-icon.png
Normal file
BIN
client/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
client/public/favicon-32x32.png
Normal file
BIN
client/public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 463 B |
6
client/public/icon-source.svg
Normal file
6
client/public/icon-source.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#1971c2"/>
|
||||||
|
<circle cx="256" cy="210" r="90" fill="#ffffff"/>
|
||||||
|
<path d="M196 270 L316 270 L256 380 Z" fill="#ffffff"/>
|
||||||
|
<circle cx="256" cy="205" r="38" fill="#1971c2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 282 B |
BIN
client/public/maskable-512x512.png
Normal file
BIN
client/public/maskable-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
client/public/pwa-192x192.png
Normal file
BIN
client/public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
client/public/pwa-512x512.png
Normal file
BIN
client/public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
import { ProtectedRoute } from './components/layout/ProtectedRoute';
|
import { ProtectedRoute } from './components/layout/ProtectedRoute';
|
||||||
import { AppShell } from './components/layout/AppShell';
|
import { AppShell } from './components/layout/AppShell';
|
||||||
|
import { PwaUpdatePrompt } from './components/PwaUpdatePrompt';
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
import { SetupPage } from './pages/SetupPage';
|
import { SetupPage } from './pages/SetupPage';
|
||||||
import { MapDashboardPage } from './pages/MapDashboardPage';
|
import { MapDashboardPage } from './pages/MapDashboardPage';
|
||||||
|
|
@ -48,6 +49,7 @@ export default function App() {
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
<PwaUpdatePrompt />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
16
client/src/components/PwaUpdatePrompt.tsx
Normal file
16
client/src/components/PwaUpdatePrompt.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { useRegisterSW } from 'virtual:pwa-register/react';
|
||||||
|
|
||||||
|
export function PwaUpdatePrompt() {
|
||||||
|
const { needRefresh: [needRefresh], updateServiceWorker } = useRegisterSW();
|
||||||
|
|
||||||
|
if (!needRefresh) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pwa-update-toast">
|
||||||
|
<span>A new version of CityTracker is available.</span>
|
||||||
|
<button className="btn btn-primary btn-small" onClick={() => updateServiceWorker(true)}>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -730,6 +730,23 @@ a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pwa-update-toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: var(--spacing-4);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
padding: var(--spacing-3) var(--spacing-4);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-disabled {
|
.btn-disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
|
||||||
2
client/src/vite-env.d.ts
vendored
Normal file
2
client/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/react" />
|
||||||
|
|
@ -1,8 +1,63 @@
|
||||||
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';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
injectRegister: false,
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
name: 'CityTracker',
|
||||||
|
short_name: 'CityTracker',
|
||||||
|
description: "Track cities you've visited or plan to visit on a map.",
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
theme_color: '#1971c2',
|
||||||
|
background_color: '#f7f7f5',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/maskable-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
// /api and /uploads are intentionally left uncached so city data and
|
||||||
|
// photos always come from the network, never a stale service worker cache.
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/[abc]\.tile\.openstreetmap\.org\/.*/,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'osm-tiles',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 500,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|
|
||||||
3694
package-lock.json
generated
3694
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -23,6 +23,12 @@ export function createApp() {
|
||||||
|
|
||||||
app.use('/uploads', express.static(config.uploadDir));
|
app.use('/uploads', express.static(config.uploadDir));
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path === '/sw.js' || req.path === '/manifest.webmanifest') {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
app.use(express.static(config.clientDistDir));
|
app.use(express.static(config.clientDistDir));
|
||||||
app.get('*', (req, res, next) => {
|
app.get('*', (req, res, next) => {
|
||||||
if (req.path.startsWith('/api') || req.path.startsWith('/uploads')) {
|
if (req.path.startsWith('/api') || req.path.startsWith('/uploads')) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue