From 1f089fdaa7dbb4e38b2b19445e05e8a6ab396b4e Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 29 May 2026 17:40:23 +0200 Subject: [PATCH] feat: PWA-Updates erkennen und Nutzer zum Reload auffordern. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wechselt auf prompt-Modus mit Update-Banner, periodischer SW-Prüfung und no-cache-Headern für Service Worker und index.html. Co-authored-by: Cursor --- client/nginx.conf | 11 +++ client/src/App.css | 108 ++++++++++++++++++++++ client/src/App.tsx | 2 + client/src/components/PwaUpdatePrompt.tsx | 62 +++++++++++++ client/src/hooks/usePwaUpdate.ts | 37 ++++++++ client/src/i18n/locales/de.json | 6 +- client/src/i18n/locales/en.json | 6 +- client/src/vite-env.d.ts | 1 + client/vite.config.ts | 6 +- 9 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 client/src/components/PwaUpdatePrompt.tsx create mode 100644 client/src/hooks/usePwaUpdate.ts diff --git a/client/nginx.conf b/client/nginx.conf index 60ff798..8fa2891 100644 --- a/client/nginx.conf +++ b/client/nginx.conf @@ -3,6 +3,17 @@ server { server_name localhost; client_max_body_size 50M; + # Service worker and app shell must revalidate so PWA updates are detected + location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ { + root /usr/share/nginx/html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + location = /index.html { + root /usr/share/nginx/html; + add_header Cache-Control "no-cache, must-revalidate"; + } + location / { root /usr/share/nginx/html; index index.html index.htm; diff --git a/client/src/App.css b/client/src/App.css index 2fde769..4eebde7 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -2396,6 +2396,114 @@ html.theme-cupertino .events-scroll-container { } } +/* PWA update prompt */ +.pwa-update-banner { + position: fixed; + top: calc(12px + env(safe-area-inset-top, 0px)); + left: 16px; + right: 16px; + z-index: 1300; + display: grid; + grid-template-columns: auto 1fr auto; + gap: 14px; + align-items: center; + padding: 14px 44px 14px 14px; + border-radius: 14px; + border: 1px solid var(--app-accent-border); + background: var(--app-surface); + box-shadow: var(--app-card-shadow); + animation: fadeIn 0.35s ease-out; + max-width: 640px; + margin: 0 auto; +} + +.pwa-update-icon { + color: var(--app-accent-light); + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 10px; + background: var(--app-accent-bg); + flex-shrink: 0; +} + +.pwa-update-body { + min-width: 0; +} + +.pwa-update-title { + margin: 0 0 4px 0; + font-size: 15px; + font-weight: 600; + color: var(--app-text-heading); +} + +.pwa-update-text { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: var(--app-text-muted); +} + +.pwa-update-actions { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; +} + +.pwa-update-btn { + white-space: nowrap; + padding: 10px 14px; + font-size: 14px; +} + +.pwa-update-link { + background: none; + border: none; + color: var(--app-text-muted); + font-size: 12px; + cursor: pointer; + padding: 2px 4px; +} + +.pwa-update-link:hover { + color: var(--app-text); +} + +.pwa-update-close { + position: absolute; + top: 10px; + right: 10px; + background: none; + border: none; + color: var(--app-text-muted); + cursor: pointer; + padding: 4px; + border-radius: 6px; +} + +.pwa-update-close:hover { + color: var(--app-text); + background: var(--app-surface-inset); +} + +@media (max-width: 720px) { + .pwa-update-banner { + grid-template-columns: auto 1fr; + padding-right: 40px; + } + + .pwa-update-actions { + grid-column: 1 / -1; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + } +} + .app-version-footer { position: fixed; left: 0; diff --git a/client/src/App.tsx b/client/src/App.tsx index bff50d0..cc2e64a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,6 +20,7 @@ import { import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js' import ReadOnlyViewer from './components/ReadOnlyViewer.tsx' import PwaInstallPrompt from './components/PwaInstallPrompt.tsx' +import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx' import AppFooter from './components/AppFooter.tsx' import { db } from './services/db.js' import { useLiveQuery } from 'dexie-react-hooks' @@ -318,6 +319,7 @@ function App() { export default function AppWrapper() { return ( + diff --git a/client/src/components/PwaUpdatePrompt.tsx b/client/src/components/PwaUpdatePrompt.tsx new file mode 100644 index 0000000..dc1e575 --- /dev/null +++ b/client/src/components/PwaUpdatePrompt.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RefreshCw, X } from 'lucide-react' +import { usePwaUpdate } from '../hooks/usePwaUpdate.js' + +export default function PwaUpdatePrompt() { + const { t } = useTranslation() + const { needRefresh, updateApp } = usePwaUpdate() + const [updating, setUpdating] = useState(false) + const [dismissed, setDismissed] = useState(false) + + if (!needRefresh || dismissed) return null + + const handleUpdate = async () => { + setUpdating(true) + try { + await updateApp() + } finally { + setUpdating(false) + } + } + + return ( +
+ + +
+

{t('pwa.update_title')}

+

{t('pwa.update_desc')}

+
+ +
+ + +
+ + +
+ ) +} diff --git a/client/src/hooks/usePwaUpdate.ts b/client/src/hooks/usePwaUpdate.ts new file mode 100644 index 0000000..6c9408e --- /dev/null +++ b/client/src/hooks/usePwaUpdate.ts @@ -0,0 +1,37 @@ +import { useRegisterSW } from 'virtual:pwa-register/react' + +const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000 + +function scheduleUpdateChecks(registration: ServiceWorkerRegistration) { + const checkForUpdate = () => { + registration.update().catch(() => {}) + } + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + checkForUpdate() + } + }) + + window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS) +} + +export function usePwaUpdate() { + const { + needRefresh: [needRefresh], + updateServiceWorker + } = useRegisterSW({ + immediate: true, + onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) { + if (registration) { + scheduleUpdateChecks(registration) + } + } + }) + + const updateApp = async () => { + await updateServiceWorker(true) + } + + return { needRefresh, updateApp } +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 229acc9..1a7e59c 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -66,7 +66,11 @@ "platform_ios": "Installation über Safari", "platform_android": "Installation über den Browser", "platform_desktop": "Installation als Desktop-App", - "settings_section": "App-Installation" + "settings_section": "App-Installation", + "update_title": "Update verfügbar", + "update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.", + "update_now": "Jetzt aktualisieren", + "update_reloading": "Wird geladen…" }, "sync": { "status_synced": "Synchronisiert", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 9c3e2f9..055d36f 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -66,7 +66,11 @@ "platform_ios": "Install via Safari", "platform_android": "Install via browser", "platform_desktop": "Install as desktop app", - "settings_section": "App installation" + "settings_section": "App installation", + "update_title": "Update available", + "update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.", + "update_now": "Reload now", + "update_reloading": "Reloading…" }, "sync": { "status_synced": "Synced", diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts index 54eaa07..c98a80e 100644 --- a/client/src/vite-env.d.ts +++ b/client/src/vite-env.d.ts @@ -1,3 +1,4 @@ /// +/// declare const __APP_VERSION__: string diff --git a/client/vite.config.ts b/client/vite.config.ts index c59e065..d22b4ec 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -38,8 +38,12 @@ export default defineConfig({ plugins: [ react(), VitePWA({ - registerType: 'autoUpdate', + registerType: 'prompt', includeAssets: ['favicon.ico', 'logo.png'], + workbox: { + cleanupOutdatedCaches: true, + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}'] + }, manifest: { name: 'Kapteins Daagbok', short_name: 'Daagbok',