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>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"rimraf": "^5.0.9",
|
||||
"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 { ProtectedRoute } from './components/layout/ProtectedRoute';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
import { PwaUpdatePrompt } from './components/PwaUpdatePrompt';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { SetupPage } from './pages/SetupPage';
|
||||
import { MapDashboardPage } from './pages/MapDashboardPage';
|
||||
|
|
@ -48,6 +49,7 @@ export default function App() {
|
|||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
<PwaUpdatePrompt />
|
||||
</BrowserRouter>
|
||||
</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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
opacity: 0.6;
|
||||
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 react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
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: {
|
||||
port: 5173,
|
||||
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((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.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api') || req.path.startsWith('/uploads')) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue