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:
Claudio Schaad 2026-07-24 17:16:25 +02:00
parent 1cb9a2e133
commit 11b6d493e7
15 changed files with 3806 additions and 3 deletions

View file

@ -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>

View file

@ -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"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 B

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -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>
); );

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

View file

@ -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
View file

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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')) {