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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
4589
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
14
client/public/icons/icon.svg
Normal file
14
client/public/icons/icon.svg
Normal 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 |
29
client/scripts/generate-icons.mjs
Normal file
29
client/scripts/generate-icons.mjs
Normal 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.');
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user