feat: PWA-Installation aktiv anbieten, besonders für iPad/iOS.

Zeigt nach dem Login ein Installations-Banner mit Safari-Anleitung oder nativer Install-Schaltfläche und ergänzt iOS-Meta-Tags sowie einen dauerhaften Hinweis in den Einstellungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 15:04:32 +02:00
parent 5d11dbacea
commit 44652d4699
8 changed files with 400 additions and 0 deletions
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="mobile-web-app-capable" content="yes" />
<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="Daagbox" />
<meta name="theme-color" content="#1e293b" />
<link rel="apple-touch-icon" href="/logo.png" />
<title>Kapteins Daagbok</title>
</head>
<body>
+162
View File
@@ -1944,5 +1944,167 @@ body:has(.theme-cupertino) {
margin-bottom: 12px;
}
/* PWA install prompt */
.pwa-install-banner {
position: fixed;
left: 16px;
right: 16px;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
z-index: 1200;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 14px;
align-items: start;
padding: 16px 44px 16px 16px;
border-radius: 16px;
border: 1px solid rgba(212, 175, 55, 0.25);
background: rgba(11, 12, 16, 0.92);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
animation: fadeIn 0.35s ease-out;
max-width: 560px;
margin: 0 auto;
}
.pwa-install-inline {
display: grid;
grid-template-columns: auto 1fr;
gap: 14px;
align-items: start;
padding: 16px;
border-radius: 14px;
border: 1px solid rgba(212, 175, 55, 0.2);
background: rgba(255, 255, 255, 0.03);
margin-bottom: 16px;
}
.pwa-install-icon {
color: #fbbf24;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(251, 191, 36, 0.1);
flex-shrink: 0;
}
.pwa-install-body {
min-width: 0;
}
.pwa-install-title {
margin: 0 0 6px 0;
font-size: 16px;
font-weight: 600;
color: #f8fafc;
}
.pwa-install-text {
margin: 0;
font-size: 13.5px;
line-height: 1.45;
color: #94a3b8;
}
.pwa-install-steps {
list-style: none;
margin: 12px 0 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.pwa-install-steps li {
display: flex;
align-items: center;
gap: 10px;
font-size: 13.5px;
color: #e2e8f0;
}
.pwa-ios-add-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.6);
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.pwa-install-hint {
margin: 10px 0 0 0;
font-size: 12px;
color: #64748b;
}
.pwa-install-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.pwa-install-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
padding: 10px 14px !important;
font-size: 14px !important;
}
.pwa-install-dismiss-row {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.pwa-install-link {
background: transparent;
border: none;
color: #cbd5e1;
font-size: 12px;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.pwa-install-link.muted {
color: #64748b;
}
.pwa-install-close {
position: absolute;
top: 10px;
right: 10px;
background: transparent;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px;
border-radius: 8px;
}
.pwa-install-banner .pwa-install-actions {
grid-column: 1 / -1;
}
@media (max-width: 640px) {
.pwa-install-banner {
grid-template-columns: auto 1fr;
padding-right: 40px;
}
.pwa-install-actions {
grid-column: 1 / -1;
}
}
+5
View File
@@ -12,6 +12,7 @@ import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import { db } from './services/db.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
@@ -190,9 +191,12 @@ function App() {
)
}
const pwaInstallBanner = <PwaInstallPrompt variant="banner" />
if (!activeLogbookId) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
{pwaInstallBanner}
<LogbookDashboard
onSelectLogbook={handleSelectLogbook}
onLogout={handleLogout}
@@ -203,6 +207,7 @@ function App() {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
{pwaInstallBanner}
{isSyncing && <div className="sync-progress-bar" />}
<div className="app-layout">
{/* Active Logbook Header */}
+104
View File
@@ -0,0 +1,104 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Download, Share, Smartphone, X } from 'lucide-react'
import { usePwaInstall, isIosDevice, type PwaInstallPlatform } from '../hooks/usePwaInstall.js'
interface PwaInstallPromptProps {
variant?: 'banner' | 'inline'
}
function platformLabel(platform: PwaInstallPlatform | null, t: (key: string) => string): string {
if (platform === 'ios') return t('pwa.platform_ios')
if (platform === 'android') return t('pwa.platform_android')
return t('pwa.platform_desktop')
}
export default function PwaInstallPrompt({ variant = 'banner' }: PwaInstallPromptProps) {
const { t } = useTranslation()
const { canPrompt, platform, install, dismissLater, dismissForever, isStandalone, hasNativeInstall } =
usePwaInstall()
const [installing, setInstalling] = useState(false)
if (isStandalone) return null
if (variant === 'banner' && !canPrompt) return null
if (variant === 'inline' && !isIosDevice() && !hasNativeInstall) return null
const handleInstall = async () => {
setInstalling(true)
try {
await install()
} finally {
setInstalling(false)
}
}
const rootClass = variant === 'banner' ? 'pwa-install-banner glass' : 'pwa-install-inline glass'
return (
<div className={rootClass} role="region" aria-label={t('pwa.title')}>
<div className="pwa-install-icon">
<Smartphone size={variant === 'banner' ? 28 : 24} />
</div>
<div className="pwa-install-body">
<h3 className="pwa-install-title">{t('pwa.title')}</h3>
<p className="pwa-install-text">
{platform === 'ios' ? t('pwa.ios_instructions') : t('pwa.generic_benefit')}
</p>
{platform === 'ios' && (
<ol className="pwa-install-steps">
<li>
<Share size={16} aria-hidden />
<span>{t('pwa.ios_step_share')}</span>
</li>
<li>
<span className="pwa-ios-add-icon" aria-hidden>+</span>
<span>{t('pwa.ios_step_add')}</span>
</li>
</ol>
)}
{hasNativeInstall && (
<p className="pwa-install-hint">{platformLabel(platform, t)}</p>
)}
</div>
<div className="pwa-install-actions">
{hasNativeInstall && (
<button
type="button"
className="btn primary pwa-install-btn"
onClick={handleInstall}
disabled={installing}
>
<Download size={16} />
{installing ? t('pwa.installing') : t('pwa.install_now')}
</button>
)}
{variant === 'banner' && (
<div className="pwa-install-dismiss-row">
<button type="button" className="pwa-install-link" onClick={dismissLater}>
{t('pwa.later')}
</button>
<button type="button" className="pwa-install-link muted" onClick={dismissForever}>
{t('pwa.never')}
</button>
</div>
)}
</div>
{variant === 'banner' && (
<button
type="button"
className="pwa-install-close"
onClick={dismissLater}
aria-label={t('pwa.later')}
>
<X size={18} />
</button>
)}
</div>
)
}
+3
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, AlertTriangle } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import { useDialog } from './ModalDialog.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import { deleteAccount } from '../services/auth.js'
interface SettingsFormProps {
@@ -299,6 +300,8 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
</div>
<form onSubmit={handleSubmit} className="vessel-form mt-6">
<PwaInstallPrompt variant="inline" />
{/* Weather Integration card */}
<div className="member-editor-card glass">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
+90
View File
@@ -0,0 +1,90 @@
import { useState, useEffect, useCallback } from 'react'
const DISMISS_KEY = 'pwa_install_dismissed_until'
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
export type PwaInstallPlatform = 'ios' | 'android' | 'desktop'
export function isRunningStandalone(): boolean {
return (
window.matchMedia('(display-mode: standalone)').matches ||
window.matchMedia('(display-mode: fullscreen)').matches ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
)
}
export function isIosDevice(): boolean {
const ua = navigator.userAgent
if (/iPad|iPhone|iPod/.test(ua)) return true
// iPadOS 13+ may report as Mac with touch support
return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1
}
function readDismissed(): boolean {
try {
const until = localStorage.getItem(DISMISS_KEY)
if (!until) return false
if (until === 'forever') return true
return Date.now() < Number(until)
} catch {
return false
}
}
export function usePwaInstall() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [dismissed, setDismissed] = useState(readDismissed)
const isStandalone = isRunningStandalone()
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e as BeforeInstallPromptEvent)
}
window.addEventListener('beforeinstallprompt', handler)
return () => window.removeEventListener('beforeinstallprompt', handler)
}, [])
const platform: PwaInstallPlatform | null = isIosDevice()
? 'ios'
: deferredPrompt
? /Android/i.test(navigator.userAgent)
? 'android'
: 'desktop'
: null
const canPrompt = !isStandalone && !dismissed && (platform === 'ios' || !!deferredPrompt)
const install = useCallback(async (): Promise<boolean> => {
if (!deferredPrompt) return false
await deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
setDeferredPrompt(null)
return outcome === 'accepted'
}, [deferredPrompt])
const dismissLater = useCallback(() => {
const until = Date.now() + 3 * 24 * 60 * 60 * 1000
localStorage.setItem(DISMISS_KEY, String(until))
setDismissed(true)
}, [])
const dismissForever = useCallback(() => {
localStorage.setItem(DISMISS_KEY, 'forever')
setDismissed(true)
}, [])
return {
canPrompt,
platform,
install,
dismissLater,
dismissForever,
isStandalone,
hasNativeInstall: !!deferredPrompt
}
}
+15
View File
@@ -53,6 +53,21 @@
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
},
"pwa": {
"title": "App installieren",
"generic_benefit": "Installieren Sie Kapteins Daagbox auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
"ios_instructions": "Auf dem iPad/iPhone: Fügen Sie die App zum Home-Bildschirm hinzu, damit Ihre Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
"ios_step_share": "Teilen-Symbol in der Safari-Leiste antippen",
"ios_step_add": "„Zum Home-Bildschirm“ wählen",
"install_now": "Jetzt installieren",
"installing": "Installation…",
"later": "Später",
"never": "Nicht mehr anzeigen",
"platform_ios": "Installation über Safari",
"platform_android": "Installation über den Browser",
"platform_desktop": "Installation als Desktop-App",
"settings_section": "App-Installation"
},
"sync": {
"status_synced": "Synchronisiert",
"status_offline": "Offline-Cache",
+15
View File
@@ -53,6 +53,21 @@
"use_recovery_instead": "Use recovery phrase instead",
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
},
"pwa": {
"title": "Install app",
"generic_benefit": "Install Kapteins Daagbox on your device for faster access, offline use, and persistent data storage.",
"ios_instructions": "On iPad/iPhone: Add the app to your Home Screen so your logbook data stays protected and the app launches like a native app.",
"ios_step_share": "Tap the Share button in the Safari toolbar",
"ios_step_add": "Choose “Add to Home Screen”",
"install_now": "Install now",
"installing": "Installing…",
"later": "Later",
"never": "Don't show again",
"platform_ios": "Install via Safari",
"platform_android": "Install via browser",
"platform_desktop": "Install as desktop app",
"settings_section": "App installation"
},
"sync": {
"status_synced": "Synced",
"status_offline": "Offline Cache",