feat: PWA-Updates erkennen und Nutzer zum Reload auffordern.
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,17 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
client_max_body_size 50M;
|
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 / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
|
|||||||
@@ -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 {
|
.app-version-footer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||||
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
||||||
|
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
||||||
import AppFooter from './components/AppFooter.tsx'
|
import AppFooter from './components/AppFooter.tsx'
|
||||||
import { db } from './services/db.js'
|
import { db } from './services/db.js'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
@@ -318,6 +319,7 @@ function App() {
|
|||||||
export default function AppWrapper() {
|
export default function AppWrapper() {
|
||||||
return (
|
return (
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
|
<PwaUpdatePrompt />
|
||||||
<App />
|
<App />
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="pwa-update-banner" role="alert" aria-live="polite">
|
||||||
|
<div className="pwa-update-icon" aria-hidden="true">
|
||||||
|
<RefreshCw size={22} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pwa-update-body">
|
||||||
|
<p className="pwa-update-title">{t('pwa.update_title')}</p>
|
||||||
|
<p className="pwa-update-text">{t('pwa.update_desc')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pwa-update-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary pwa-update-btn"
|
||||||
|
onClick={handleUpdate}
|
||||||
|
disabled={updating}
|
||||||
|
>
|
||||||
|
{updating ? t('pwa.update_reloading') : t('pwa.update_now')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pwa-update-link"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
>
|
||||||
|
{t('pwa.later')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pwa-update-close"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
aria-label={t('pwa.later')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -66,7 +66,11 @@
|
|||||||
"platform_ios": "Installation über Safari",
|
"platform_ios": "Installation über Safari",
|
||||||
"platform_android": "Installation über den Browser",
|
"platform_android": "Installation über den Browser",
|
||||||
"platform_desktop": "Installation als Desktop-App",
|
"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": {
|
"sync": {
|
||||||
"status_synced": "Synchronisiert",
|
"status_synced": "Synchronisiert",
|
||||||
|
|||||||
@@ -66,7 +66,11 @@
|
|||||||
"platform_ios": "Install via Safari",
|
"platform_ios": "Install via Safari",
|
||||||
"platform_android": "Install via browser",
|
"platform_android": "Install via browser",
|
||||||
"platform_desktop": "Install as desktop app",
|
"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": {
|
"sync": {
|
||||||
"status_synced": "Synced",
|
"status_synced": "Synced",
|
||||||
|
|||||||
Vendored
+1
@@ -1,3 +1,4 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/react" />
|
||||||
|
|
||||||
declare const __APP_VERSION__: string
|
declare const __APP_VERSION__: string
|
||||||
|
|||||||
@@ -38,8 +38,12 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'prompt',
|
||||||
includeAssets: ['favicon.ico', 'logo.png'],
|
includeAssets: ['favicon.ico', 'logo.png'],
|
||||||
|
workbox: {
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
|
||||||
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Kapteins Daagbok',
|
name: 'Kapteins Daagbok',
|
||||||
short_name: 'Daagbok',
|
short_name: 'Daagbok',
|
||||||
|
|||||||
Reference in New Issue
Block a user