feat(pwa): make Mila app installable with PWA manifest, service worker and high-res icons
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#7c3aed"/>
|
||||||
|
<stop offset="55%" stop-color="#db2777"/>
|
||||||
|
<stop offset="100%" stop-color="#f59e0b"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#000000" flood-opacity="0.5"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||||
|
<rect x="30" y="30" width="452" height="452" rx="96" fill="#090a0f" fill-opacity="0.88" stroke="rgba(255,255,255,0.15)" stroke-width="8" filter="url(#shadow)"/>
|
||||||
|
<text x="256" y="335" font-size="260" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, Segoe UI Emoji, Apple Color Emoji, sans-serif">🧸</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 892 B |
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "Mila — Schlaf & Entwicklung",
|
||||||
|
"short_name": "Mila",
|
||||||
|
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
|
||||||
|
"start_url": "/family/mila.html",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#090A0F",
|
||||||
|
"theme_color": "#090A0F",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/family/mila-icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/family/mila-icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/family/mila-apple-touch-icon.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/family/mila-icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Minimal service worker for Mila PWA installability & offline resilience
|
||||||
|
const CACHE_NAME = 'mila-v1';
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys.map((key) => {
|
||||||
|
if (key !== CACHE_NAME) return caches.delete(key);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
if (event.request.method !== 'GET') return;
|
||||||
|
// Network-first strategy: Always fetch live real-time updates from MinIO
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request).catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -447,23 +447,45 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# PWA Manifest as inline Data URI
|
# PWA Manifest (RFC-compliant base64 data URI + external webmanifest)
|
||||||
|
import base64
|
||||||
manifest_dict = {
|
manifest_dict = {
|
||||||
"name": "Mila Tracker",
|
"name": "Mila — Schlaf & Entwicklung",
|
||||||
"short_name": "Mila",
|
"short_name": "Mila",
|
||||||
|
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
|
||||||
"start_url": "/family/mila.html",
|
"start_url": "/family/mila.html",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
"background_color": "#090A0F",
|
"background_color": "#090A0F",
|
||||||
"theme_color": "#090A0F",
|
"theme_color": "#090A0F",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='48' fill='%237C3AED'/%3E%3Ctext x='50' y='65' font-size='42' font-family='sans-serif' text-anchor='middle' fill='white'%3E%F0%9F%A7%B8%3C/text%3E%3C/svg%3E",
|
"src": "/family/mila-icon-192.png",
|
||||||
"sizes": "192x192 512x512",
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/family/mila-icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/family/mila-apple-touch-icon.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/family/mila-icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
"type": "image/svg+xml"
|
"type": "image/svg+xml"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
manifest_uri = "data:application/manifest+json," + json.dumps(manifest_dict)
|
manifest_json = json.dumps(manifest_dict)
|
||||||
|
manifest_uri = f"data:application/manifest+json;base64,{base64.b64encode(manifest_json.encode('utf-8')).decode('utf-8')}"
|
||||||
|
|
||||||
# Full HTML Document
|
# Full HTML Document
|
||||||
html = f"""<!DOCTYPE html>
|
html = f"""<!DOCTYPE html>
|
||||||
@@ -475,11 +497,20 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
|
|||||||
|
|
||||||
<!-- PWA & Mobile Web Capabilities -->
|
<!-- PWA & Mobile Web Capabilities -->
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Mila">
|
<meta name="apple-mobile-web-app-title" content="Mila">
|
||||||
|
<meta name="application-name" content="Mila">
|
||||||
<meta name="theme-color" content="#090A0F">
|
<meta name="theme-color" content="#090A0F">
|
||||||
|
<meta name="msapplication-TileColor" content="#090A0F">
|
||||||
|
|
||||||
|
<link rel="manifest" href="/family/mila.webmanifest">
|
||||||
<link rel="manifest" href="{manifest_uri}">
|
<link rel="manifest" href="{manifest_uri}">
|
||||||
<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='48' fill='%237C3AED'/%3E%3Ctext x='50' y='65' font-size='42' font-family='sans-serif' text-anchor='middle' fill='white'%3E%F0%9F%A7%B8%3C/text%3E%3C/svg%3E">
|
<link rel="apple-touch-icon" href="/family/mila-apple-touch-icon.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/family/mila-apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/family/mila-icon-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="/family/mila-icon-512.png">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/family/mila-icon.svg">
|
||||||
|
|
||||||
<!-- Typography & Styling -->
|
<!-- Typography & Styling -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
@@ -505,6 +536,42 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
|
|||||||
</head>
|
</head>
|
||||||
<body class="bg-[#090A0F] text-zinc-100 min-h-screen font-sans antialiased selection:bg-purple-500 selection:text-white pb-16">
|
<body class="bg-[#090A0F] text-zinc-100 min-h-screen font-sans antialiased selection:bg-purple-500 selection:text-white pb-16">
|
||||||
|
|
||||||
|
<!-- PWA Install Banner (Android / Desktop Chrome) -->
|
||||||
|
<div id="pwa-install-banner" class="hidden max-w-4xl mx-auto px-4 pt-3">
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-2xl bg-gradient-to-r from-purple-900/80 via-pink-900/50 to-zinc-900 border border-purple-500/40 shadow-xl backdrop-blur-md">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-purple-600 flex items-center justify-center text-lg shadow-md">🧸</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold text-white">Mila App installieren</p>
|
||||||
|
<p class="text-[11px] text-zinc-300">Direkt auf dem Homescreen nutzen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="pwa-install-btn" class="px-3.5 py-1.5 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-400 hover:to-pink-400 text-white text-xs font-bold shadow-lg shadow-purple-500/30 active:scale-95 transition">
|
||||||
|
Installieren
|
||||||
|
</button>
|
||||||
|
<button onclick="document.getElementById('pwa-install-banner').classList.add('hidden')" class="p-1 rounded-lg text-zinc-400 hover:text-white">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- iOS Safari Install Guidance Banner -->
|
||||||
|
<div id="ios-install-banner" class="hidden max-w-4xl mx-auto px-4 pt-3">
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-2xl bg-zinc-900/90 border border-purple-500/30 shadow-lg backdrop-blur-md">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span class="text-base">📱</span>
|
||||||
|
<p class="text-xs text-zinc-300">
|
||||||
|
Als App nutzen: Im Safari-Menü auf <span class="inline-flex items-center text-purple-400 font-semibold"><i data-lucide="share" class="w-3.5 h-3.5 inline mx-0.5"></i> Teilen</span> und <span class="text-white font-semibold">„Zum Home-Bildschirm“</span> (+) tippen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="dismissIosBanner()" class="p-1 rounded-lg text-zinc-400 hover:text-white">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Header & Live Status (Always sticky on mobile) -->
|
<!-- Header & Live Status (Always sticky on mobile) -->
|
||||||
<header class="border-b border-zinc-800/80 bg-zinc-950/80 backdrop-blur-xl sticky top-0 z-40 px-4 py-3 sm:px-8">
|
<header class="border-b border-zinc-800/80 bg-zinc-950/80 backdrop-blur-xl sticky top-0 z-40 px-4 py-3 sm:px-8">
|
||||||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||||
@@ -683,6 +750,52 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
|
|||||||
document.addEventListener('DOMContentLoaded', () => {{
|
document.addEventListener('DOMContentLoaded', () => {{
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
}});
|
}});
|
||||||
|
|
||||||
|
// Service Worker Registration for PWA installability
|
||||||
|
if ('serviceWorker' in navigator) {{
|
||||||
|
window.addEventListener('load', () => {{
|
||||||
|
navigator.serviceWorker.register('/family/sw.js').catch(() => {{
|
||||||
|
navigator.serviceWorker.register('sw.js').catch(console.error);
|
||||||
|
}});
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Chrome/Android PWA BeforeInstallPrompt
|
||||||
|
let deferredInstallPrompt = null;
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {{
|
||||||
|
e.preventDefault();
|
||||||
|
deferredInstallPrompt = e;
|
||||||
|
const banner = document.getElementById('pwa-install-banner');
|
||||||
|
if (banner) banner.classList.remove('hidden');
|
||||||
|
}});
|
||||||
|
|
||||||
|
const installBtn = document.getElementById('pwa-install-btn');
|
||||||
|
if (installBtn) {{
|
||||||
|
installBtn.addEventListener('click', async () => {{
|
||||||
|
if (!deferredInstallPrompt) return;
|
||||||
|
deferredInstallPrompt.prompt();
|
||||||
|
const {{ outcome }} = await deferredInstallPrompt.userChoice;
|
||||||
|
if (outcome === 'accepted') {{
|
||||||
|
const banner = document.getElementById('pwa-install-banner');
|
||||||
|
if (banner) banner.classList.add('hidden');
|
||||||
|
}}
|
||||||
|
deferredInstallPrompt = null;
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
// iOS Safari Detection & Guidance Banner
|
||||||
|
const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||||
|
const isStandalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches;
|
||||||
|
if (isIos && !isStandalone && !localStorage.getItem('dismissed_ios_mila_pwa')) {{
|
||||||
|
const iosBanner = document.getElementById('ios-install-banner');
|
||||||
|
if (iosBanner) iosBanner.classList.remove('hidden');
|
||||||
|
}}
|
||||||
|
|
||||||
|
function dismissIosBanner() {{
|
||||||
|
localStorage.setItem('dismissed_ios_mila_pwa', 'true');
|
||||||
|
const iosBanner = document.getElementById('ios-install-banner');
|
||||||
|
if (iosBanner) iosBanner.classList.add('hidden');
|
||||||
|
}}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -690,19 +803,54 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
|
|||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
def publish_to_s3(html_content: str) -> str:
|
def publish_to_s3(html_content: str, data_dir: Optional[Path] = None) -> str:
|
||||||
"""Uploads the rendered HTML to MinIO hosting bucket at family/mila.html."""
|
"""Uploads the rendered HTML, webmanifest, service worker and icons to MinIO hosting bucket."""
|
||||||
s3 = get_s3_client()
|
s3 = get_s3_client()
|
||||||
key = "family/mila.html"
|
assets_dir = SCRIPT_DIR.parent / "assets"
|
||||||
|
|
||||||
s3.put_object(
|
# HTML files (both in family/mila.html and mila.html)
|
||||||
Bucket=DEFAULT_BUCKET,
|
for key in ["family/mila.html", "mila.html"]:
|
||||||
Key=key,
|
s3.put_object(
|
||||||
Body=html_content.encode("utf-8"),
|
Bucket=DEFAULT_BUCKET,
|
||||||
ContentType="text/html; charset=utf-8",
|
Key=key,
|
||||||
CacheControl="no-cache, no-store, must-revalidate",
|
Body=html_content.encode("utf-8"),
|
||||||
)
|
ContentType="text/html; charset=utf-8",
|
||||||
url = f"{DEFAULT_BASE_URL}/{key}"
|
CacheControl="no-cache, no-store, must-revalidate",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static PWA Assets to upload
|
||||||
|
static_assets = [
|
||||||
|
("mila.webmanifest", "application/manifest+json; charset=utf-8", "no-cache, no-store, must-revalidate"),
|
||||||
|
("sw.js", "application/javascript; charset=utf-8", "no-cache, no-store, must-revalidate"),
|
||||||
|
("mila-icon-192.png", "image/png", "public, max-age=604800"),
|
||||||
|
("mila-icon-512.png", "image/png", "public, max-age=604800"),
|
||||||
|
("mila-apple-touch-icon.png", "image/png", "public, max-age=604800"),
|
||||||
|
("mila-icon.svg", "image/svg+xml", "public, max-age=604800"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for fname, ctype, cache_ctrl in static_assets:
|
||||||
|
candidates = [assets_dir / fname]
|
||||||
|
if data_dir:
|
||||||
|
candidates.extend([data_dir.parent / "public" / "family" / fname, data_dir.parent / "public" / fname])
|
||||||
|
body_bytes = None
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.is_file():
|
||||||
|
body_bytes = candidate.read_bytes()
|
||||||
|
break
|
||||||
|
if body_bytes:
|
||||||
|
for prefix in ["family/", ""]:
|
||||||
|
try:
|
||||||
|
s3.put_object(
|
||||||
|
Bucket=DEFAULT_BUCKET,
|
||||||
|
Key=f"{prefix}{fname}",
|
||||||
|
Body=body_bytes,
|
||||||
|
ContentType=ctype,
|
||||||
|
CacheControl=cache_ctrl,
|
||||||
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
print(f"Warning: could not upload {prefix}{fname}: {ex}", file=sys.stderr)
|
||||||
|
|
||||||
|
url = f"{DEFAULT_BASE_URL}/family/mila.html"
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
@@ -719,17 +867,25 @@ def main():
|
|||||||
print("Building Mila Dashboard from DuckDB...")
|
print("Building Mila Dashboard from DuckDB...")
|
||||||
html = generate_dashboard_html(engine)
|
html = generate_dashboard_html(engine)
|
||||||
|
|
||||||
# 1. Save locally to knowledge-wiki/public/family/mila.html (or specified file)
|
# 1. Save locally to knowledge-wiki/public/family/mila.html AND public/mila.html
|
||||||
wiki_public = data_dir.parent / "public" / "family"
|
wiki_public_family = data_dir.parent / "public" / "family"
|
||||||
wiki_public.mkdir(parents=True, exist_ok=True)
|
wiki_public_family.mkdir(parents=True, exist_ok=True)
|
||||||
out_file = Path(args.output_file) if args.output_file else (wiki_public / "mila.html")
|
out_file = Path(args.output_file) if args.output_file else (wiki_public_family / "mila.html")
|
||||||
out_file.write_text(html, encoding="utf-8")
|
out_file.write_text(html, encoding="utf-8")
|
||||||
print(f"✅ Saved locally: {out_file}")
|
print(f"✅ Saved locally: {out_file}")
|
||||||
|
|
||||||
|
# Mirror to public/mila.html
|
||||||
|
root_mila = data_dir.parent / "public" / "mila.html"
|
||||||
|
try:
|
||||||
|
root_mila.write_text(html, encoding="utf-8")
|
||||||
|
print(f"✅ Synced locally: {root_mila}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: could not sync to {root_mila}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
# 2. Upload to S3 hosting
|
# 2. Upload to S3 hosting
|
||||||
if not args.skip_upload:
|
if not args.skip_upload:
|
||||||
try:
|
try:
|
||||||
url = publish_to_s3(html)
|
url = publish_to_s3(html, data_dir)
|
||||||
print(f"🚀 Published successfully to: {url}")
|
print(f"🚀 Published successfully to: {url}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ S3 upload failed (local testing?): {e}", file=sys.stderr)
|
print(f"⚠️ S3 upload failed (local testing?): {e}", file=sys.stderr)
|
||||||
|
|||||||
Reference in New Issue
Block a user