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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,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' }}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user