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`
|
||||
- Kartenwahl via OpenStreetMap (Leaflet), inkl. Klickauswahl und Ortssuche
|
||||
- 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-API fuer Uebersicht und Event-Verwaltung (`/admin/*`)
|
||||
- Anti-Spam ohne Account: lokaler Token + serverseitiger HMAC-Hash
|
||||
|
|
@ -43,6 +44,9 @@ Nicht im MVP:
|
|||
- `python3 -m http.server 8080`
|
||||
7. App oeffnen:
|
||||
- `http://localhost:8080`
|
||||
8. PWA-Test:
|
||||
- iOS Safari: Seite aufrufen -> Teilen -> "Zum Home-Bildschirm"
|
||||
- Danach aus Home-Screen starten (Standalone-Modus)
|
||||
|
||||
## OpenData Connector Config
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@
|
|||
<html lang="de">
|
||||
<head>
|
||||
<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>
|
||||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -105,6 +112,7 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<script src="pwa.js"></script>
|
||||
<script>
|
||||
document.getElementById("privacy-date").textContent = new Date().toLocaleDateString("de-DE");
|
||||
</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">
|
||||
<head>
|
||||
<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>
|
||||
<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="vendor/leaflet/leaflet.css" />
|
||||
</head>
|
||||
|
|
@ -14,6 +21,7 @@
|
|||
<header class="top">
|
||||
<h1>StaySense</h1>
|
||||
<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="data-status">Datenstand: -</small>
|
||||
</header>
|
||||
|
|
@ -190,6 +198,7 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<script src="pwa.js"></script>
|
||||
<script src="vendor/leaflet/leaflet.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</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">
|
||||
<head>
|
||||
<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>
|
||||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -72,6 +79,7 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<script src="pwa.js"></script>
|
||||
<script>
|
||||
const DEFAULT_API_BASE =
|
||||
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;
|
||||
color: var(--ink);
|
||||
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 {
|
||||
|
|
@ -72,6 +77,11 @@ body {
|
|||
color: #486270;
|
||||
}
|
||||
|
||||
#ios-install-hint {
|
||||
display: block;
|
||||
color: #486270;
|
||||
}
|
||||
|
||||
.grid {
|
||||
max-width: 1120px;
|
||||
margin: 8px auto 32px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue