Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 646d316a36 | |||
| 593d1aea20 | |||
| f01c5dc86f | |||
| 1f089fdaa7 |
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<DialogProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppFooter />
|
||||
</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,54 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
||||
|
||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
|
||||
const checkForUpdate = () => {
|
||||
registration.update().catch(() => {})
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}
|
||||
|
||||
export function usePwaUpdate() {
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
updateServiceWorker
|
||||
} = useRegisterSW({
|
||||
immediate: true,
|
||||
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
||||
if (!registration) return
|
||||
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = scheduleUpdateChecks(registration)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateApp = async () => {
|
||||
await updateServiceWorker(true)
|
||||
}
|
||||
|
||||
return { needRefresh, updateApp }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user