feat: add iOS-focused PWA shell with service worker and manifest
This commit is contained in:
parent
56b8825ca5
commit
fea5fe9cbb
9 changed files with 176 additions and 3 deletions
|
|
@ -10,6 +10,7 @@ Implementiert:
|
||||||
- API: `GET /spot/score`, `POST /spot/signal`
|
- API: `GET /spot/score`, `POST /spot/signal`
|
||||||
- Kartenwahl via OpenStreetMap (Leaflet), inkl. Klickauswahl und Ortssuche
|
- Kartenwahl via OpenStreetMap (Leaflet), inkl. Klickauswahl und Ortssuche
|
||||||
- API: `GET /geocode/search` (Nominatim-Proxy), `GET /map/tile/{z}/{x}/{y}.png` (OSM-Tile-Proxy)
|
- API: `GET /geocode/search` (Nominatim-Proxy), `GET /map/tile/{z}/{x}/{y}.png` (OSM-Tile-Proxy)
|
||||||
|
- PWA-Basis (Manifest, Service Worker, iOS-Standalone-Meta, Offline-Shell)
|
||||||
- Admin-Bereich (Setup/Login, geschuetzt per User/Passwort + Session-Token)
|
- Admin-Bereich (Setup/Login, geschuetzt per User/Passwort + Session-Token)
|
||||||
- Admin-API fuer Uebersicht und Event-Verwaltung (`/admin/*`)
|
- Admin-API fuer Uebersicht und Event-Verwaltung (`/admin/*`)
|
||||||
- Anti-Spam ohne Account: lokaler Token + serverseitiger HMAC-Hash
|
- Anti-Spam ohne Account: lokaler Token + serverseitiger HMAC-Hash
|
||||||
|
|
@ -43,6 +44,9 @@ Nicht im MVP:
|
||||||
- `python3 -m http.server 8080`
|
- `python3 -m http.server 8080`
|
||||||
7. App oeffnen:
|
7. App oeffnen:
|
||||||
- `http://localhost:8080`
|
- `http://localhost:8080`
|
||||||
|
8. PWA-Test:
|
||||||
|
- iOS Safari: Seite aufrufen -> Teilen -> "Zum Home-Bildschirm"
|
||||||
|
- Danach aus Home-Screen starten (Standalone-Modus)
|
||||||
|
|
||||||
## OpenData Connector Config
|
## OpenData Connector Config
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,15 @@
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#006680" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="StaySense" />
|
||||||
<title>Datenschutz | StaySense</title>
|
<title>Datenschutz | StaySense</title>
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="icons/icon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="icons/icon.svg" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -105,6 +112,7 @@
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script src="pwa.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById("privacy-date").textContent = new Date().toLocaleDateString("de-DE");
|
document.getElementById("privacy-date").textContent = new Date().toLocaleDateString("de-DE");
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
11
src/icons/icon.svg
Normal file
11
src/icons/icon.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<svg xmlns='http://www.w3.org/2000/svg' width='512' height='512' viewBox='0 0 512 512'>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>
|
||||||
|
<stop offset='0' stop-color='#006680'/>
|
||||||
|
<stop offset='1' stop-color='#1689a8'/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width='512' height='512' rx='96' fill='url(#g)'/>
|
||||||
|
<circle cx='256' cy='256' r='132' fill='none' stroke='#ffffff' stroke-width='30'/>
|
||||||
|
<path d='M170 314 L232 206 L284 276 L342 182' fill='none' stroke='#ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='28'/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 571 B |
|
|
@ -2,8 +2,15 @@
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#006680" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="StaySense" />
|
||||||
<title>StaySense NRW MVP</title>
|
<title>StaySense NRW MVP</title>
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="icons/icon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="icons/icon.svg" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
<link rel="stylesheet" href="vendor/leaflet/leaflet.css" />
|
<link rel="stylesheet" href="vendor/leaflet/leaflet.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -14,6 +21,7 @@
|
||||||
<header class="top">
|
<header class="top">
|
||||||
<h1>StaySense</h1>
|
<h1>StaySense</h1>
|
||||||
<p>Kreis Mettmann Pilot: ruhige Nacht in weniger als 10 Sekunden bewerten.</p>
|
<p>Kreis Mettmann Pilot: ruhige Nacht in weniger als 10 Sekunden bewerten.</p>
|
||||||
|
<small id="ios-install-hint" class="small hidden"></small>
|
||||||
<small id="network-status">Netzwerkstatus wird geprüft ...</small>
|
<small id="network-status">Netzwerkstatus wird geprüft ...</small>
|
||||||
<small id="data-status">Datenstand: -</small>
|
<small id="data-status">Datenstand: -</small>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -190,6 +198,7 @@
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script src="pwa.js"></script>
|
||||||
<script src="vendor/leaflet/leaflet.js"></script>
|
<script src="vendor/leaflet/leaflet.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
19
src/manifest.webmanifest
Normal file
19
src/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "StaySense NRW",
|
||||||
|
"short_name": "StaySense",
|
||||||
|
"description": "Night Safety Score für ruhige Nächte (22-06 Uhr) in NRW.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#f3f7f6",
|
||||||
|
"theme_color": "#006680",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
16
src/pwa.js
Normal file
16
src/pwa.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
(function () {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker.register("/service-worker.js").catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIos = /iphone|ipad|ipod/i.test(window.navigator.userAgent);
|
||||||
|
const isStandalone =
|
||||||
|
window.matchMedia("(display-mode: standalone)").matches || Boolean(window.navigator.standalone);
|
||||||
|
const hintEl = document.getElementById("ios-install-hint");
|
||||||
|
if (hintEl && isIos && !isStandalone) {
|
||||||
|
hintEl.textContent = "Tipp: Über Teilen > Zum Home-Bildschirm hinzufügen für App-Modus auf iOS.";
|
||||||
|
hintEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -2,8 +2,15 @@
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#006680" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="StaySense" />
|
||||||
<title>StaySense Quellen & Attribution</title>
|
<title>StaySense Quellen & Attribution</title>
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="icons/icon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="icons/icon.svg" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -72,6 +79,7 @@
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script src="pwa.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const DEFAULT_API_BASE =
|
const DEFAULT_API_BASE =
|
||||||
window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"
|
window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"
|
||||||
|
|
|
||||||
88
src/service-worker.js
Normal file
88
src/service-worker.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
const SW_VERSION = "staysense-v1.0.0";
|
||||||
|
const CORE_CACHE = `${SW_VERSION}-core`;
|
||||||
|
const RUNTIME_CACHE = `${SW_VERSION}-runtime`;
|
||||||
|
|
||||||
|
const CORE_ASSETS = [
|
||||||
|
"/",
|
||||||
|
"/index.html",
|
||||||
|
"/styles.css",
|
||||||
|
"/app.js",
|
||||||
|
"/pwa.js",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/icons/icon.svg",
|
||||||
|
"/vendor/leaflet/leaflet.css",
|
||||||
|
"/vendor/leaflet/leaflet.js",
|
||||||
|
"/datenschutz.html",
|
||||||
|
"/quellen.html",
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CORE_CACHE).then((cache) => cache.addAll(CORE_ASSETS)).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key !== CORE_CACHE && key !== RUNTIME_CACHE)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
)
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cacheFirst(request) {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
const response = await fetch(request);
|
||||||
|
const runtime = await caches.open(RUNTIME_CACHE);
|
||||||
|
runtime.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirst(request) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
const runtime = await caches.open(RUNTIME_CACHE);
|
||||||
|
runtime.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
throw new Error("offline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.method !== "GET") return;
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// Cache map tiles and static assets aggressively.
|
||||||
|
if (url.pathname.startsWith("/api/map/tile/")) {
|
||||||
|
event.respondWith(cacheFirst(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith("/vendor/") || url.pathname.endsWith(".css") || url.pathname.endsWith(".js") || url.pathname.endsWith(".svg")) {
|
||||||
|
event.respondWith(cacheFirst(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep score/health API usable in flaky networks.
|
||||||
|
if (url.pathname === "/api/health" || url.pathname.startsWith("/api/spot/score")) {
|
||||||
|
event.respondWith(networkFirst(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// App shell pages: network first with offline fallback.
|
||||||
|
if (url.pathname === "/" || url.pathname.endsWith(".html")) {
|
||||||
|
event.respondWith(
|
||||||
|
networkFirst(event.request).catch(async () => {
|
||||||
|
const fallback = await caches.match("/index.html");
|
||||||
|
return fallback || new Response("Offline", { status: 503, statusText: "Offline" });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -20,6 +20,11 @@ body {
|
||||||
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: linear-gradient(160deg, #f7fbfa 0%, #edf3fb 100%);
|
background: linear-gradient(160deg, #f7fbfa 0%, #edf3fb 100%);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
}
|
}
|
||||||
|
|
||||||
.background {
|
.background {
|
||||||
|
|
@ -72,6 +77,11 @@ body {
|
||||||
color: #486270;
|
color: #486270;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ios-install-hint {
|
||||||
|
display: block;
|
||||||
|
color: #486270;
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
max-width: 1120px;
|
max-width: 1120px;
|
||||||
margin: 8px auto 32px;
|
margin: 8px auto 32px;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue