Add PWA support for iOS home screen install

- Web app manifest (standalone display, NOMAD branding, icons)
- Service worker with Workbox caching (map tiles, API, uploads, CDN)
- SVG app icon + PNG generation script via sharp
- Apple meta tags (web-app-capable, black-translucent status bar)
- Dynamic theme-color for dark/light mode
- Safe area insets for notch devices
This commit is contained in:
Maurice
2026-03-21 22:21:23 +01:00
parent 544ac796d5
commit 557de4cd5a
9 changed files with 4749 additions and 9 deletions

3
.gitignore vendored
View File

@@ -4,6 +4,9 @@ node_modules/
# Build output
client/dist/
# Generated PWA icons (built from SVG via prebuild)
client/public/icons/*.png
# Database
*.db
*.db-shm

View File

@@ -2,9 +2,20 @@
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23111827' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 2c-2-2-4-2-5.5-.5L10 5 1.8 6.2c-.5.1-.9.6-.6 1.1l1.9 2.9 2.5-.9 4-4 2.7 2.7-4 4 .9 2.5 2.9 1.9c.5.3 1 0 1.1-.5z'/></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>NOMAD</title>
<!-- PWA / iOS -->
<meta name="theme-color" content="#111827" />
<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="NOMAD" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>

4589
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"preview": "vite preview"
},
@@ -30,7 +31,9 @@
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"sharp": "^0.33.0",
"tailwindcss": "^3.4.1",
"vite": "^5.1.4"
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0"
}
}

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1e293b"/>
<stop offset="100%" stop-color="#0f172a"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" fill="url(#bg)"/>
<!-- Navigation arrow (upper-left wing — brighter) -->
<polygon points="128,249 384,128 236,276" fill="#ffffff"/>
<!-- Navigation arrow (lower-right wing — subtle depth) -->
<polygon points="384,128 263,384 236,276" fill="#e2e8f0"/>
</svg>

After

Width:  |  Height:  |  Size: 582 B

View File

@@ -0,0 +1,29 @@
/**
* Generates PNG icons for PWA from the master SVG icon.
* Run: node scripts/generate-icons.mjs
* Called automatically via the "prebuild" npm script.
*/
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import sharp from 'sharp';
const __dirname = dirname(fileURLToPath(import.meta.url));
const iconsDir = join(__dirname, '..', 'public', 'icons');
const svgBuffer = readFileSync(join(iconsDir, 'icon.svg'));
const sizes = [
{ name: 'apple-touch-icon-180x180.png', size: 180 },
{ name: 'icon-192x192.png', size: 192 },
{ name: 'icon-512x512.png', size: 512 },
];
for (const { name, size } of sizes) {
await sharp(svgBuffer, { density: 300 })
.resize(size, size)
.png({ compressionLevel: 9 })
.toFile(join(iconsDir, name));
console.log(` \u2713 ${name} (${size}x${size})`);
}
console.log('PWA icons generated.');

View File

@@ -78,13 +78,17 @@ export default function App() {
}
}, [isAuthenticated])
// Apply dark mode class to <html>
// Apply dark mode class to <html> + update PWA theme-color
useEffect(() => {
if (settings.dark_mode) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) {
meta.setAttribute('content', settings.dark_mode ? '#121215' : '#ffffff')
}
}, [settings.dark_mode])
return (

View File

@@ -3,7 +3,17 @@
@tailwind utilities;
html { height: 100%; overflow: hidden; }
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
body {
height: 100%;
overflow: auto;
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
/* PWA safe areas (notch, home indicator) */
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.atlas-tooltip {
background: rgba(10, 10, 20, 0.6) !important;

View File

@@ -1,8 +1,91 @@
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',
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api/, /^\/uploads/],
runtimeCaching: [
{
// Carto map tiles (default provider)
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// OpenStreetMap tiles (fallback / alternative)
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// Leaflet CSS/JS from unpkg CDN
urlPattern: /^https:\/\/unpkg\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'cdn-libs',
expiration: { maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// API calls — prefer network, fall back to cache
urlPattern: /\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-data',
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
networkTimeoutSeconds: 5,
cacheableResponse: { statuses: [0, 200] },
},
},
{
// Uploaded files (photos, covers, documents)
urlPattern: /\/uploads\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'user-uploads',
expiration: { maxEntries: 300, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
],
},
manifest: {
name: 'NOMAD \u2014 Travel Planner',
short_name: 'NOMAD',
description: 'Navigation Organizer for Maps, Activities & Destinations',
theme_color: '#111827',
background_color: '#0f172a',
display: 'standalone',
scope: '/',
start_url: '/',
orientation: 'any',
categories: ['travel', 'navigation'],
icons: [
{ src: 'icons/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png' },
{ src: 'icons/icon-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icons/icon-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: 'icons/icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{ src: 'icons/icon.svg', sizes: 'any', type: 'image/svg+xml' },
],
},
}),
],
server: {
port: 5173,
proxy: {