Add PWA native feel: manifest, meta tags, install prompt, SW optimization, dynamic theme-color
Configure manifest.json with scope, maskable icons, and categories. Add iOS/Android meta tags for standalone behavior. Create pwa.css for native touch/scroll handling and safe area insets. Add oikos-install-prompt Web Component with Chrome install flow and iOS guidance. Optimize service worker with network-first navigation and expanded precache (v19). Add dynamic theme-color per route and modal overlay dimming in standalone mode. Generate placeholder icons via sharp script. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -19,7 +19,7 @@
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"express-session": "^1.18.1",
|
||||
@@ -33,5 +33,8 @@
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ let activeOverlay = null;
|
||||
let previouslyFocused = null;
|
||||
let focusTrapHandler = null;
|
||||
|
||||
// Overlay-Dimming: theme-color abdunkeln im Standalone-Modus
|
||||
const OVERLAY_THEME_COLOR = '#1A1A1A';
|
||||
|
||||
const FOCUSABLE = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
@@ -126,6 +129,11 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
|
||||
|
||||
// Callback für Aufrufer (Form-Events binden etc.)
|
||||
if (typeof onSave === 'function') onSave(panel);
|
||||
|
||||
// Standalone: Statusbar abdunkeln (Overlay-Effekt)
|
||||
if (window.oikos?.setThemeColor) {
|
||||
window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -155,4 +163,9 @@ export function closeModal() {
|
||||
previouslyFocused.focus();
|
||||
previouslyFocused = null;
|
||||
}
|
||||
|
||||
// Standalone: Statusbar-Farbe zur aktuellen Route wiederherstellen
|
||||
if (window.oikos?.restoreThemeColor) {
|
||||
window.oikos.restoreThemeColor();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Modul: Install-Prompt Web Component
|
||||
* Zweck: Dezentes Banner für PWA-Installation (Chrome/Android) und iOS-Anleitung
|
||||
* Abhängigkeiten: Design Tokens aus tokens.css (via CSS custom properties)
|
||||
*
|
||||
* Verhalten:
|
||||
* - Chrome/Android: Fängt beforeinstallprompt ab, zeigt Install-Banner
|
||||
* - iOS (Safari): Zeigt Anleitung "Zum Home-Bildschirm"
|
||||
* - Standalone-Modus: Zeigt nichts an
|
||||
* - Dismiss: 30 Tage via localStorage gespeichert
|
||||
*/
|
||||
|
||||
const DISMISS_KEY = 'oikos-install-dismissed';
|
||||
const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage
|
||||
|
||||
class OikosInstallPrompt extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._deferredPrompt = null;
|
||||
this._shadow = this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Bereits im Standalone-Modus — nichts anzeigen
|
||||
if (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
navigator.standalone === true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismiss noch aktiv?
|
||||
const dismissed = localStorage.getItem(DISMISS_KEY);
|
||||
if (dismissed && Date.now() - Number(dismissed) < DISMISS_DURATION_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._isIOS()) {
|
||||
this._showIOSPrompt();
|
||||
} else {
|
||||
this._listenForInstallPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('beforeinstallprompt', this._onBeforeInstall);
|
||||
}
|
||||
|
||||
/** iOS Safari erkennen (kein beforeinstallprompt-Support) */
|
||||
_isIOS() {
|
||||
return (
|
||||
navigator.standalone === undefined &&
|
||||
/iPhone|iPad/.test(navigator.userAgent) &&
|
||||
!window.MSStream
|
||||
);
|
||||
}
|
||||
|
||||
/** Chrome/Android: beforeinstallprompt abfangen */
|
||||
_listenForInstallPrompt() {
|
||||
this._onBeforeInstall = (e) => {
|
||||
e.preventDefault();
|
||||
this._deferredPrompt = e;
|
||||
this._showBanner(false);
|
||||
};
|
||||
window.addEventListener('beforeinstallprompt', this._onBeforeInstall);
|
||||
}
|
||||
|
||||
/** Banner rendern */
|
||||
_showBanner(isIOS) {
|
||||
this._shadow.innerHTML = '';
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:host {
|
||||
display: block;
|
||||
position: fixed;
|
||||
bottom: calc(var(--nav-height-mobile, 56px) + env(safe-area-inset-bottom, 0px) + 8px);
|
||||
left: var(--space-3, 12px);
|
||||
right: var(--space-3, 12px);
|
||||
z-index: var(--z-toast, 300);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3, 12px);
|
||||
padding: var(--space-3, 12px) var(--space-4, 16px);
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e8e7e2);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
box-shadow: var(--shadow-md, 0 2px 8px rgba(0,0,0,0.08));
|
||||
pointer-events: auto;
|
||||
transform: translateY(calc(100% + 20px));
|
||||
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.banner--visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--font-sans, system-ui);
|
||||
font-size: var(--text-base, 0.875rem);
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
color: var(--color-text-primary, #1c1c1a);
|
||||
line-height: var(--line-height-tight, 1.25);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: var(--font-sans, system-ui);
|
||||
font-size: var(--text-sm, 0.8125rem);
|
||||
color: var(--color-text-secondary, #6c6b67);
|
||||
line-height: var(--line-height-base, 1.5);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.btn-install {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-2, 8px) var(--space-4, 16px);
|
||||
background: var(--color-btn-primary, #2554C7);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
font-family: var(--font-sans, system-ui);
|
||||
font-size: var(--text-sm, 0.8125rem);
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
cursor: pointer;
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-install:hover {
|
||||
background: var(--color-btn-primary-hover, #1E429A);
|
||||
}
|
||||
|
||||
.btn-dismiss {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-xs, 4px);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-tertiary, #737370);
|
||||
padding: 0;
|
||||
min-height: 32px;
|
||||
min-width: 32px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-dismiss:hover {
|
||||
background: var(--color-surface-3, #efeee9);
|
||||
}
|
||||
|
||||
.btn-dismiss svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* iOS share icon inline */
|
||||
.share-icon {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:host {
|
||||
/* Desktop: Sidebar statt Bottom-Nav, Banner unten rechts */
|
||||
bottom: calc(var(--space-4, 16px) + env(safe-area-inset-bottom, 0px));
|
||||
left: auto;
|
||||
right: var(--space-4, 16px);
|
||||
max-width: 380px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'banner';
|
||||
banner.setAttribute('role', 'alert');
|
||||
|
||||
// App-Icon
|
||||
const icon = document.createElement('img');
|
||||
icon.className = 'icon';
|
||||
icon.src = '/icons/icon-192.png';
|
||||
icon.alt = 'Oikos';
|
||||
icon.width = 40;
|
||||
icon.height = 40;
|
||||
banner.appendChild(icon);
|
||||
|
||||
// Text
|
||||
const text = document.createElement('div');
|
||||
text.className = 'text';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'title';
|
||||
title.textContent = 'Oikos installieren';
|
||||
|
||||
const subtitle = document.createElement('div');
|
||||
subtitle.className = 'subtitle';
|
||||
|
||||
if (isIOS) {
|
||||
// iOS: Teilen-Icon als SVG inline
|
||||
subtitle.innerHTML = '';
|
||||
subtitle.append(
|
||||
document.createTextNode('Tippe auf '),
|
||||
this._createShareIcon(),
|
||||
document.createTextNode(' → „Zum Home-Bildschirm"')
|
||||
);
|
||||
} else {
|
||||
subtitle.textContent = 'Zur App hinzufügen';
|
||||
}
|
||||
|
||||
text.appendChild(title);
|
||||
text.appendChild(subtitle);
|
||||
banner.appendChild(text);
|
||||
|
||||
// Install-Button (nur Chrome/Android)
|
||||
if (!isIOS) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-install';
|
||||
btn.textContent = 'Installieren';
|
||||
btn.addEventListener('click', () => this._onInstallClick());
|
||||
banner.appendChild(btn);
|
||||
}
|
||||
|
||||
// Dismiss-Button
|
||||
const dismiss = document.createElement('button');
|
||||
dismiss.className = 'btn-dismiss';
|
||||
dismiss.setAttribute('aria-label', 'Schließen');
|
||||
dismiss.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
dismiss.addEventListener('click', () => this._dismiss());
|
||||
banner.appendChild(dismiss);
|
||||
|
||||
this._shadow.appendChild(style);
|
||||
this._shadow.appendChild(banner);
|
||||
|
||||
// Slide-in Animation nach nächstem Frame
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
banner.classList.add('banner--visible');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** iOS Teilen-Icon (Box mit Pfeil nach oben) */
|
||||
_createShareIcon() {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
svg.classList.add('share-icon');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8');
|
||||
const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
||||
polyline.setAttribute('points', '16 6 12 2 8 6');
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '12');
|
||||
line.setAttribute('y1', '2');
|
||||
line.setAttribute('x2', '12');
|
||||
line.setAttribute('y2', '15');
|
||||
|
||||
svg.appendChild(path);
|
||||
svg.appendChild(polyline);
|
||||
svg.appendChild(line);
|
||||
return svg;
|
||||
}
|
||||
|
||||
/** Install-Button geklickt */
|
||||
async _onInstallClick() {
|
||||
if (!this._deferredPrompt) return;
|
||||
|
||||
try {
|
||||
this._deferredPrompt.prompt();
|
||||
const result = await this._deferredPrompt.userChoice;
|
||||
console.log('[oikos-install-prompt] Ergebnis:', result.outcome);
|
||||
|
||||
if (result.outcome === 'accepted') {
|
||||
this._remove();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[oikos-install-prompt] Fehler:', err);
|
||||
}
|
||||
|
||||
this._deferredPrompt = null;
|
||||
}
|
||||
|
||||
/** Dismiss: 30 Tage merken, Banner entfernen */
|
||||
_dismiss() {
|
||||
localStorage.setItem(DISMISS_KEY, String(Date.now()));
|
||||
this._remove();
|
||||
}
|
||||
|
||||
/** Banner mit Slide-out entfernen */
|
||||
_remove() {
|
||||
const banner = this._shadow.querySelector('.banner');
|
||||
if (!banner) return;
|
||||
|
||||
banner.classList.remove('banner--visible');
|
||||
banner.addEventListener('transitionend', () => this.remove(), { once: true });
|
||||
}
|
||||
|
||||
/** iOS: Banner direkt anzeigen */
|
||||
_showIOSPrompt() {
|
||||
this._showBanner(true);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('oikos-install-prompt', OikosInstallPrompt);
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 595 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
@@ -2,14 +2,24 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#2563EB" />
|
||||
<!-- Viewport: edge-to-edge, kein Auto-Zoom bei Inputs -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no" />
|
||||
|
||||
<!-- PWA / Theme -->
|
||||
<meta name="theme-color" content="#007AFF" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#1C1C1E" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<!-- iOS-spezifisch -->
|
||||
<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="Oikos" />
|
||||
<meta name="description" content="Oikos — Familienplaner" />
|
||||
<title>Oikos</title>
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||
|
||||
@@ -24,6 +34,7 @@
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="/styles/tokens.css" />
|
||||
<link rel="stylesheet" href="/styles/reset.css" />
|
||||
<link rel="stylesheet" href="/styles/pwa.css" />
|
||||
<link rel="stylesheet" href="/styles/layout.css" />
|
||||
<link rel="stylesheet" href="/styles/login.css" />
|
||||
<link rel="stylesheet" href="/styles/dashboard.css" />
|
||||
@@ -60,6 +71,10 @@
|
||||
<script type="module" src="/api.js"></script>
|
||||
<script type="module" src="/router.js"></script>
|
||||
|
||||
<!-- Install-Prompt (PWA) -->
|
||||
<oikos-install-prompt></oikos-install-prompt>
|
||||
<script type="module" src="/components/oikos-install-prompt.js"></script>
|
||||
|
||||
<!-- Service Worker registrieren -->
|
||||
<script src="/sw-register.js" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -1,43 +1,20 @@
|
||||
{
|
||||
"name": "Oikos Familienplaner",
|
||||
"short_name": "Oikos",
|
||||
"description": "Selbstgehosteter Familienplaner für Kalender, Aufgaben, Einkauf und mehr.",
|
||||
"description": "Selbstgehosteter Familienplaner",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#F5F4F1",
|
||||
"theme_color": "#2563EB",
|
||||
"orientation": "portrait-primary",
|
||||
"lang": "de",
|
||||
"theme_color": "#007AFF",
|
||||
"background_color": "#F5F5F7",
|
||||
"lang": "de-DE",
|
||||
"categories": ["productivity", "lifestyle"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"screenshots": []
|
||||
}
|
||||
|
||||
@@ -8,21 +8,61 @@ import { auth } from '/api.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Routen-Definitionen
|
||||
// Jede Route hat: path, page (dynamisch geladen), requiresAuth
|
||||
// Jede Route hat: path, page (dynamisch geladen), requiresAuth, module (für theme-color)
|
||||
// --------------------------------------------------------
|
||||
const ROUTES = [
|
||||
{ path: '/login', page: '/pages/login.js', requiresAuth: false },
|
||||
{ path: '/', page: '/pages/dashboard.js', requiresAuth: true },
|
||||
{ path: '/tasks', page: '/pages/tasks.js', requiresAuth: true },
|
||||
{ path: '/shopping', page: '/pages/shopping.js', requiresAuth: true },
|
||||
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true },
|
||||
{ path: '/calendar', page: '/pages/calendar.js', requiresAuth: true },
|
||||
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true },
|
||||
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true },
|
||||
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true },
|
||||
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true },
|
||||
{ path: '/login', page: '/pages/login.js', requiresAuth: false, module: null },
|
||||
{ path: '/', page: '/pages/dashboard.js', requiresAuth: true, module: 'dashboard' },
|
||||
{ path: '/tasks', page: '/pages/tasks.js', requiresAuth: true, module: 'tasks' },
|
||||
{ path: '/shopping', page: '/pages/shopping.js', requiresAuth: true, module: 'shopping' },
|
||||
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true, module: 'meals' },
|
||||
{ path: '/calendar', page: '/pages/calendar.js', requiresAuth: true, module: 'calendar' },
|
||||
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' },
|
||||
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
|
||||
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' },
|
||||
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' },
|
||||
];
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Standalone-Modus: Dynamische theme-color Anpassung
|
||||
// Statusbar-Farbe spiegelt aktuelle Seite / Modal-State wider
|
||||
// --------------------------------------------------------
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
|| navigator.standalone === true;
|
||||
|
||||
/**
|
||||
* Setzt die theme-color Meta-Tags (Light + Dark Variante).
|
||||
* @param {string} lightColor
|
||||
* @param {string} [darkColor] — Falls nicht angegeben, wird lightColor für beide gesetzt
|
||||
*/
|
||||
function setThemeColor(lightColor, darkColor) {
|
||||
if (!isStandalone) return;
|
||||
const metas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
if (metas.length >= 2) {
|
||||
metas[0].setAttribute('content', lightColor);
|
||||
metas[1].setAttribute('content', darkColor || lightColor);
|
||||
} else if (metas.length === 1) {
|
||||
metas[0].setAttribute('content', lightColor);
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest eine CSS Custom Property vom :root */
|
||||
function getCSSToken(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
/** Setzt theme-color passend zum aktuellen Modul */
|
||||
function updateThemeColorForRoute(route) {
|
||||
if (!route?.module) {
|
||||
setThemeColor('#007AFF', '#1C1C1E');
|
||||
return;
|
||||
}
|
||||
const color = getCSSToken(`--module-${route.module}`);
|
||||
if (color) {
|
||||
setThemeColor(color, color);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Modul-Cache: verhindert redundante dynamic imports bei Navigation
|
||||
// --------------------------------------------------------
|
||||
@@ -88,6 +128,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
|
||||
|
||||
await renderPage(route);
|
||||
updateNav(path);
|
||||
updateThemeColorForRoute(route);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,4 +389,12 @@ window.addEventListener('auth:expired', () => {
|
||||
navigate(location.pathname, false);
|
||||
|
||||
// Globale Exporte
|
||||
window.oikos = { navigate, showToast };
|
||||
window.oikos = {
|
||||
navigate,
|
||||
showToast,
|
||||
setThemeColor,
|
||||
restoreThemeColor: () => {
|
||||
const route = ROUTES.find((r) => r.path === currentPath);
|
||||
updateThemeColorForRoute(route);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Modul: PWA Native Feel
|
||||
* Zweck: Natives Touch- und Scrollverhalten, Safe Areas, Touch-Targets
|
||||
* Abhängigkeiten: tokens.css, layout.css
|
||||
*/
|
||||
|
||||
/* ── Kein Rubber-Banding / Pull-to-Refresh des Browsers ── */
|
||||
html, body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* ── Kein Tap-Highlight auf allen Elementen (Android Chrome) ──
|
||||
* reset.css setzt es nur auf html; hier global für alle Elemente */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Safe Area Insets (Notch, Dynamic Island, Gesture Bar) ── */
|
||||
body {
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── Bottom Nav über der Gesture Bar ──
|
||||
* layout.css nutzt --safe-area-inset-bottom Token;
|
||||
* hier als Fallback direkt via env() */
|
||||
.nav-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* ── Touch-Targets: min 44×44px (Apple HIG / WCAG 2.5.5) ── */
|
||||
button, a, [role="button"], input[type="checkbox"], input[type="radio"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* ── Smooth Momentum-Scrolling in scrollbaren Containern ── */
|
||||
.scroll-container,
|
||||
.nav-bottom__scroll {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
/* ── Kein Text-Selection in UI-Elementen (nur in Content-Bereichen) ── */
|
||||
nav,
|
||||
.nav-bottom,
|
||||
.nav-sidebar,
|
||||
.cal-toolbar,
|
||||
.tasks-toolbar,
|
||||
.notes-toolbar,
|
||||
.contacts-toolbar,
|
||||
.modal-panel__header {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Standalone-Modus: Status-Bar-Bereich berücksichtigen ── */
|
||||
@media (display-mode: standalone) {
|
||||
body {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Modul: Service Worker Registrierung
|
||||
* Zweck: Ausgelagert aus index.html um CSP-Inline-Script-Verletzung zu vermeiden
|
||||
* Zweck: Ausgelagert aus index.html um CSP-Inline-Script-Verletzung zu vermeiden.
|
||||
* Handhabt nahtlose Updates via controllerchange.
|
||||
* Abhängigkeiten: keine
|
||||
*/
|
||||
|
||||
@@ -10,4 +11,13 @@ if ('serviceWorker' in navigator) {
|
||||
console.warn('[SW] Registrierung fehlgeschlagen:', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Nahtloses Update: Neuer SW hat skipWaiting() + clients.claim() aufgerufen
|
||||
// → Controller wechselt → Seite neu laden für konsistenten Stand
|
||||
let refreshing = false;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
|
||||
*/
|
||||
|
||||
const SHELL_CACHE = 'oikos-shell-v18';
|
||||
const PAGES_CACHE = 'oikos-pages-v18';
|
||||
const ASSETS_CACHE = 'oikos-assets-v18';
|
||||
const SHELL_CACHE = 'oikos-shell-v19';
|
||||
const PAGES_CACHE = 'oikos-pages-v19';
|
||||
const ASSETS_CACHE = 'oikos-assets-v19';
|
||||
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
||||
|
||||
// App-Shell: sofort benötigt für ersten Render
|
||||
@@ -28,6 +28,7 @@ const APP_SHELL = [
|
||||
'/lucide.min.js',
|
||||
'/styles/tokens.css',
|
||||
'/styles/reset.css',
|
||||
'/styles/pwa.css',
|
||||
'/styles/layout.css',
|
||||
'/styles/login.css',
|
||||
'/styles/dashboard.css',
|
||||
@@ -39,10 +40,15 @@ const APP_SHELL = [
|
||||
'/styles/contacts.css',
|
||||
'/styles/budget.css',
|
||||
'/styles/settings.css',
|
||||
'/components/oikos-install-prompt.js',
|
||||
'/manifest.json',
|
||||
'/favicon.ico',
|
||||
'/icons/favicon-32.png',
|
||||
'/icons/apple-touch-icon.png',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon-maskable-192.png',
|
||||
'/icons/icon-maskable-512.png',
|
||||
];
|
||||
|
||||
// Seiten-Module: lazy geladen, aber vorab gecacht für Offline
|
||||
@@ -107,6 +113,12 @@ self.addEventListener('fetch', (event) => {
|
||||
// Nur GET cachen
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
// Navigation Requests: Network-first, Fallback auf gecachte Shell
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(networkFirst(request, SHELL_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Bilder + Fonts: Cache-First, langer TTL
|
||||
if (isAsset(url.pathname)) {
|
||||
event.respondWith(cacheFirst(request, ASSETS_CACHE));
|
||||
@@ -119,10 +131,39 @@ self.addEventListener('fetch', (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// App-Shell (HTML, JS, CSS): Stale-While-Revalidate
|
||||
// App-Shell (JS, CSS): Stale-While-Revalidate
|
||||
event.respondWith(staleWhileRevalidate(request, SHELL_CACHE));
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Strategie: Network-First (für Navigation Requests)
|
||||
// Versucht Netzwerk, fällt auf gecachte Shell zurück (Offline).
|
||||
// --------------------------------------------------------
|
||||
async function networkFirst(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok && response.type === 'basic') {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// Offline: gecachte Shell liefern
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
// Fallback auf index.html (SPA-Routing)
|
||||
const shell = await cache.match('/index.html');
|
||||
if (shell) return shell;
|
||||
|
||||
return new Response('Keine Verbindung', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Strategie: Stale-While-Revalidate
|
||||
// Liefert sofort aus Cache, aktualisiert im Hintergrund.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Icon Generator for Oikos PWA
|
||||
* Generates placeholder icons (accent color #007AFF with white "O")
|
||||
* Sizes: 192px and 512px, both "any" and "maskable" variants
|
||||
* Maskable icons include safe zone padding (min 10%)
|
||||
*
|
||||
* Usage: node scripts/generate-icons.js
|
||||
* Dependencies: sharp (devDependency)
|
||||
*/
|
||||
|
||||
import sharp from 'sharp';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ICONS_DIR = join(__dirname, '..', 'public', 'icons');
|
||||
const ACCENT = '#007AFF';
|
||||
const BG_LIGHT = '#F5F5F7';
|
||||
|
||||
mkdirSync(ICONS_DIR, { recursive: true });
|
||||
|
||||
/**
|
||||
* Create an SVG with a centered "O" on accent background.
|
||||
* @param {number} size - Icon dimension in px
|
||||
* @param {boolean} maskable - If true, add 20% padding for safe zone
|
||||
*/
|
||||
function createSvg(size, maskable) {
|
||||
const fontSize = maskable ? size * 0.4 : size * 0.55;
|
||||
const bgRadius = maskable ? 0 : size * 0.18;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" rx="${bgRadius}" fill="${ACCENT}"/>
|
||||
<text x="50%" y="52%" dominant-baseline="central" text-anchor="middle"
|
||||
font-family="system-ui, -apple-system, sans-serif" font-weight="700"
|
||||
font-size="${fontSize}" fill="white">O</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Apple Touch Icon (180x180) with slight rounding
|
||||
*/
|
||||
function createAppleTouchSvg() {
|
||||
const size = 180;
|
||||
const fontSize = size * 0.55;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" rx="${size * 0.18}" fill="${ACCENT}"/>
|
||||
<text x="50%" y="52%" dominant-baseline="central" text-anchor="middle"
|
||||
font-family="system-ui, -apple-system, sans-serif" font-weight="700"
|
||||
font-size="${fontSize}" fill="white">O</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create favicon (32x32)
|
||||
*/
|
||||
function createFaviconSvg() {
|
||||
const size = 32;
|
||||
const fontSize = size * 0.6;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" rx="6" fill="${ACCENT}"/>
|
||||
<text x="50%" y="52%" dominant-baseline="central" text-anchor="middle"
|
||||
font-family="system-ui, -apple-system, sans-serif" font-weight="700"
|
||||
font-size="${fontSize}" fill="white">O</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const icons = [
|
||||
{ name: 'icon-192.png', size: 192, maskable: false },
|
||||
{ name: 'icon-512.png', size: 512, maskable: false },
|
||||
{ name: 'icon-maskable-192.png', size: 192, maskable: true },
|
||||
{ name: 'icon-maskable-512.png', size: 512, maskable: true },
|
||||
{ name: 'apple-touch-icon.png', size: 180, svg: createAppleTouchSvg() },
|
||||
{ name: 'favicon-32.png', size: 32, svg: createFaviconSvg() },
|
||||
];
|
||||
|
||||
for (const icon of icons) {
|
||||
const svg = icon.svg || createSvg(icon.size, icon.maskable);
|
||||
const outputPath = join(ICONS_DIR, icon.name);
|
||||
|
||||
await sharp(Buffer.from(svg))
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
|
||||
console.log(` ✓ ${icon.name} (${icon.size}x${icon.size})`);
|
||||
}
|
||||
|
||||
console.log('\nIcons generated in public/icons/');
|
||||