diff --git a/client/index.html b/client/index.html index ddff8f0..ceb1063 100644 --- a/client/index.html +++ b/client/index.html @@ -4,6 +4,12 @@ + + + + + + Kapteins Daagbok diff --git a/client/src/App.css b/client/src/App.css index 666bb5e..c55b155 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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; + } +} diff --git a/client/src/App.tsx b/client/src/App.tsx index edf00c9..42d26f4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 = + if (!activeLogbookId) { return (
+ {pwaInstallBanner} + {pwaInstallBanner} {isSyncing &&
}
{/* Active Logbook Header */} diff --git a/client/src/components/PwaInstallPrompt.tsx b/client/src/components/PwaInstallPrompt.tsx new file mode 100644 index 0000000..95a586e --- /dev/null +++ b/client/src/components/PwaInstallPrompt.tsx @@ -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 ( +
+
+ +
+ +
+

{t('pwa.title')}

+

+ {platform === 'ios' ? t('pwa.ios_instructions') : t('pwa.generic_benefit')} +

+ + {platform === 'ios' && ( +
    +
  1. + + {t('pwa.ios_step_share')} +
  2. +
  3. + + + {t('pwa.ios_step_add')} +
  4. +
+ )} + + {hasNativeInstall && ( +

{platformLabel(platform, t)}

+ )} +
+ +
+ {hasNativeInstall && ( + + )} + + {variant === 'banner' && ( +
+ + +
+ )} +
+ + {variant === 'banner' && ( + + )} +
+ ) +} diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx index 6c1653f..3979bc4 100644 --- a/client/src/components/SettingsForm.tsx +++ b/client/src/components/SettingsForm.tsx @@ -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) {
+ + {/* Weather Integration card */}

diff --git a/client/src/hooks/usePwaInstall.ts b/client/src/hooks/usePwaInstall.ts new file mode 100644 index 0000000..d990fcb --- /dev/null +++ b/client/src/hooks/usePwaInstall.ts @@ -0,0 +1,90 @@ +import { useState, useEffect, useCallback } from 'react' + +const DISMISS_KEY = 'pwa_install_dismissed_until' + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + 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(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 => { + 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 + } +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 2db0f99..caaf826 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index ab8cc7c..b8880c9 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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",