feat: add styled PWA update modal with auto cache purge

This commit is contained in:
2026-07-24 19:35:13 +04:00
parent f760dfb72c
commit 7f1bdaa2ab
14 changed files with 331 additions and 52 deletions
+30 -6
View File
@@ -192,7 +192,8 @@ Toutes ces sources sont modifiables dans `includes/config.local.php` (voir <<fr-
│ ├── search.js # Cartes cliquables de la recherche
│ ├── mastodon-config.php # Configuration JS de la timeline (servie en JS)
│ ├── mastodon-timeline.umd.js # mastodon-embed-timeline v4.7.0 (vendored)
── pleroma-adapter.js # Adaptateur Pleroma → API Mastodon
── pleroma-adapter.js # Adaptateur Pleroma → API Mastodon
│ └── pwa-update.js # Enregistrement SW + modal de mise à jour
├── uploads/ # Images d'annonces (non versionné, hors .gitkeep)
├── index.php # Page d'accueil (agrégateur)
├── video.php # Page de lecture d'une vidéo
@@ -806,7 +807,7 @@ WARNING: Le bloc `location ~* \.(php|inc|conf|config|local)$ { deny all; }` de l
- 📲 *Installation native* : bouton « Installer » dans le header à la première visite
- 🌐 *Mode hors ligne* : cache des pages visitées et des ressources statiques
- 📡 *Détection de connexion* : indicateur visuel en cas de perte réseau
- 🔄 *Mise à jour* : proposition de rechargement quand une nouvelle version est détectée
- 🔄 *Mise à jour sans friction* : quand une nouvelle version est déployée, un modal stylé la propose ; en acceptant, l'utilisateur la reçoit immédiatement — *aucune purge manuelle du cache navigateur nécessaire*
==== Stratégies de cache du Service Worker (`sw.js`)
@@ -827,7 +828,18 @@ WARNING: Le bloc `location ~* \.(php|inc|conf|config|local)$ { deny all; }` de l
| Jamais de cache ; réponse JSON 503 synthétique hors ligne
|===
TIP: Pour déployer une nouvelle version des assets, changez le suffixe de version des noms de cache dans `sw.js` (format date, ex. `08072026-0720`) : les anciens caches sont supprimés à l'activation.
==== Cycle de mise à jour automatique
Le mécanisme (`js/pwa-update.js` + `sw.js`) garantit que les visiteurs reçoivent les nouvelles versions *sans purger manuellement le cache de leur navigateur* :
. Au déploiement, *bumpez* le suffixe de version des noms de cache dans `sw.js` (format `JJMMAAAA-HHMM`, ex. `24072026-0612`)
. Au chargement d'une page, le navigateur détecte le nouveau `sw.js` et installe le nouveau Service Worker en arrière-plan ; il reste *en attente* (pas de `skipWaiting()` automatique)
. Un *modal* propose la mise à jour à l'utilisateur :
* *« Mettre à jour »* → le SW en attente reçoit `SKIP_WAITING`, purge les anciens caches à l'activation, prend le contrôle (`controllerchange`) et la page se recharge sur la nouvelle version
* *« Plus tard »* → le modal se ferme ; la mise à jour est reproposée au prochain chargement
. Une mise à jour déjà installée lors d'une visite précédente mais jamais activée est également signalée
WARNING: Pour que la détection fonctionne, `sw.js` ne doit *pas* être caché longtemps côté serveur : les configurations fournies (`conf/nginx.conf.sample`, `conf/.htaccess.sample`) le servent en `no-cache` (ainsi que `site.webmanifest`). Si vous utilisez votre propre configuration serveur, reproduisez cette règle.
==== Installation par les visiteurs
@@ -1084,7 +1096,8 @@ All of these sources can be changed in `includes/config.local.php` (see <<en-con
│ ├── search.js # Clickable search result cards
│ ├── mastodon-config.php # Timeline JS config (served as JavaScript)
│ ├── mastodon-timeline.umd.js # mastodon-embed-timeline v4.7.0 (vendored)
── pleroma-adapter.js # Pleroma → Mastodon API adapter
── pleroma-adapter.js # Pleroma → Mastodon API adapter
│ └── pwa-update.js # SW registration + update modal
├── uploads/ # Announcement images (not versioned, except .gitkeep)
├── index.php # Homepage (aggregator)
├── video.php # Video playback page
@@ -1698,7 +1711,7 @@ WARNING: The sample's `location ~* \.(php|inc|conf|config|local)$ { deny all; }`
- 📲 *Native installation*: an "Install" button in the header on first visit
- 🌐 *Offline mode*: visited pages and static assets cached
- 📡 *Connection detection*: visual indicator when the network is lost
- 🔄 *Updates*: reload prompt when a new version is detected
- 🔄 *Frictionless updates*: when a new version is deployed, a styled modal offers it; on accept, the user gets it immediately — *no manual browser-cache purge needed*
==== Service Worker caching strategies (`sw.js`)
@@ -1719,7 +1732,18 @@ WARNING: The sample's `location ~* \.(php|inc|conf|config|local)$ { deny all; }`
| Never cached; synthetic 503 JSON response when offline
|===
TIP: To deploy a new version of the assets, change the version suffix of the cache names in `sw.js` (date format, e.g. `08072026-0720`): old caches are deleted on activation.
==== Automatic update cycle
This mechanism (`js/pwa-update.js` + `sw.js`) ensures visitors receive new versions *without manually purging their browser cache*:
. When deploying, *bump* the version suffix of the cache names in `sw.js` (format `DDMMYYYY-HHMM`, e.g. `24072026-0612`)
. On page load, the browser detects the new `sw.js` and installs the new Service Worker in the background; it stays *waiting* (no automatic `skipWaiting()`)
. A *modal* offers the update to the user:
* *"Mettre à jour"* → the waiting SW receives `SKIP_WAITING`, purges old caches on activation, takes control (`controllerchange`) and the page reloads onto the new version
* *"Plus tard"* → the modal closes; the update is offered again on the next page load
. An update already installed during a previous visit but never activated is also reported
WARNING: For detection to work, `sw.js` must *not* be cached for long by the server: the provided configurations (`conf/nginx.conf.sample`, `conf/.htaccess.sample`) serve it with `no-cache` (as well as `site.webmanifest`). If you use your own server configuration, reproduce this rule.
==== Installation by visitors
+1
View File
@@ -168,5 +168,6 @@ if ($categoryId && isset($allCategories[$categoryId])) {
<script src="js/main.js"></script>
<script src="js/categories.js"></script>
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
</body>
</html>
+12
View File
@@ -55,3 +55,15 @@ RewriteRule ^([^/]+)$ $1.php [L]
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteCond %{HTTPS} !on
RewriteRule ^(.*) https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# ======================
# CACHE
# ======================
# Le Service Worker et le manifest ne doivent pas être cachés longtemps :
# le navigateur doit pouvoir détecter les nouvelles versions du site.
<FilesMatch "^(sw\.js|site\.webmanifest)$">
<IfModule mod_headers.c>
Header set Cache-Control "no-cache, must-revalidate"
</IfModule>
</FilesMatch>
+12
View File
@@ -75,6 +75,18 @@ server {
# OPTIMISATIONS
# ======================
# Le Service Worker et le manifest ne doivent pas être cachés longtemps :
# le navigateur doit pouvoir détecter les nouvelles versions du site.
# (location exacte = prioritaire sur les regex ci-dessous ; "expires -1"
# émet Cache-Control: no-cache sans annuler les add_header de sécurité)
location = /sw.js {
expires -1;
}
location = /site.webmanifest {
expires -1;
}
# Cache des fichiers statiques
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
+1
View File
@@ -183,5 +183,6 @@ setSecurityHeaders();
<!-- Scripts -->
<script src="js/countdown.js"></script>
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
</body>
</html>
+115 -1
View File
@@ -3411,4 +3411,118 @@ i.icon-mastodon,
}
}
/* ==========================================
Modal de mise à jour PWA (js/pwa-update.js)
========================================== */
.pwa-update-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 10000;
opacity: 0;
transition: opacity 0.25s ease;
}
.pwa-update-overlay.pwa-update-visible {
opacity: 1;
}
.pwa-update-modal {
background-color: var(--card-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
max-width: 420px;
width: 100%;
padding: 28px 24px 24px;
text-align: center;
transform: translateY(12px) scale(0.97);
transition: transform 0.25s ease;
}
.pwa-update-visible .pwa-update-modal {
transform: translateY(0) scale(1);
}
.pwa-update-logo {
width: 72px;
height: 72px;
border-radius: 50%;
margin-bottom: 12px;
}
.pwa-update-title {
margin: 0 0 8px;
font-size: 1.25rem;
font-weight: 700;
}
.pwa-update-text {
margin: 0 0 20px;
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
}
.pwa-update-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.pwa-update-btn {
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, opacity 0.2s ease;
}
.pwa-update-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.pwa-update-btn-primary {
background-color: var(--primary-red);
color: #ffffff;
}
.pwa-update-btn-primary:hover:not(:disabled) {
filter: brightness(0.9);
}
.pwa-update-btn-secondary {
background-color: transparent;
color: var(--text-color);
border: 1px solid var(--border-color);
}
.pwa-update-btn-secondary:hover:not(:disabled) {
background-color: var(--hover-bg);
}
.pwa-update-btn:focus-visible {
outline: 2px solid var(--primary-red);
outline-offset: 2px;
}
@media (max-width: 480px) {
.pwa-update-actions {
flex-direction: column;
}
.pwa-update-btn {
width: 100%;
}
}
+1
View File
@@ -274,5 +274,6 @@ $liveStream = getLiveStream();
<?php include 'includes/footer.php'; ?>
<?php include 'includes/mobile-menu.php'; ?>
<script src="js/main.js"></script>
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
</body>
</html>
+1
View File
@@ -291,5 +291,6 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
</script>
<script src="js/main.js"></script>
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
</body>
</html>
+4 -27
View File
@@ -678,34 +678,11 @@ setSecurityHeaders();
<script src="js/mastodon-timeline.umd.js"></script>
<script src="js/mastodon-config.php?v=<?php echo md5(MASTODON_INSTANCE_URL . MASTODON_DATE_FORMAT . MASTODON_BTN_SEE_MORE . MASTODON_BTN_RELOAD . MASTODON_MAX_POST_FETCH . MASTODON_MAX_POST_SHOW); ?>"></script>
<!-- PWA Service Worker -->
<!-- PWA : enregistrement du Service Worker + modal de mise à jour -->
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
<!-- PWA : bouton d'installation -->
<script nonce="<?php echo getCspNonce(); ?>">
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('Service Worker enregistré avec succès:', registration.scope);
// Écouter les mises à jour
registration.addEventListener('updatefound', function() {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', function() {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Nouvelle version disponible
console.log('Nouvelle version disponible');
if (confirm('Une nouvelle version est disponible. Voulez-vous recharger la page ?')) {
window.location.reload();
}
}
});
});
})
.catch(function(err) {
console.log('Échec de l\'enregistrement du Service Worker:', err);
});
});
}
// Gestion de l'installation PWA
let deferredPrompt;
const installButton = document.getElementById('install-pwa');
+138
View File
@@ -0,0 +1,138 @@
/**
* Enregistrement du Service Worker et gestion des mises à jour.
*
* Quand une nouvelle version du site est déployée (sw.js modifié, suffixe de
* version des caches bumpé), le nouveau Service Worker s'installe en arrière-
* plan et reste en attente. Un modal propose alors la mise à jour :
* - « Mettre à jour » : le SW en attente prend le contrôle (SKIP_WAITING),
* les anciens caches sont purgés à l'activation, puis la page se recharge
* sur la nouvelle version (controllerchange).
* - « Plus tard » : le modal se ferme ; la mise à jour sera reproposée au
* prochain chargement de page.
*
* Aucune purge manuelle du cache navigateur n'est nécessaire.
*/
(function () {
'use strict';
if (!('serviceWorker' in navigator)) {
return;
}
let modalShown = false;
let refreshing = false;
// Recharge la page quand le nouveau Service Worker prend le contrôle
navigator.serviceWorker.addEventListener('controllerchange', function () {
if (refreshing) {
return;
}
refreshing = true;
window.location.reload();
});
/**
* Construit et affiche le modal de mise à jour (une seule fois).
* @param {ServiceWorker} waitingWorker Le SW en attente d'activation
*/
function showUpdateModal(waitingWorker) {
if (modalShown || !waitingWorker) {
return;
}
modalShown = true;
const overlay = document.createElement('div');
overlay.className = 'pwa-update-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'pwa-update-title');
overlay.innerHTML =
'<div class="pwa-update-modal">' +
'<img class="pwa-update-logo" src="img/logo.png" alt="" aria-hidden="true">' +
'<h2 class="pwa-update-title" id="pwa-update-title">Nouvelle version disponible</h2>' +
'<p class="pwa-update-text">' +
'Une mise à jour du site est prête à être installée. ' +
'Elle est rapide et ne supprime aucune de vos données.' +
'</p>' +
'<div class="pwa-update-actions">' +
'<button type="button" class="pwa-update-btn pwa-update-btn-primary">' +
'Mettre à jour' +
'</button>' +
'<button type="button" class="pwa-update-btn pwa-update-btn-secondary">' +
'Plus tard' +
'</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
// Force le reflow pour que la transition CSS d'entrée joue
void overlay.offsetWidth;
overlay.classList.add('pwa-update-visible');
const primaryBtn = overlay.querySelector('.pwa-update-btn-primary');
const secondaryBtn = overlay.querySelector('.pwa-update-btn-secondary');
function closeModal() {
overlay.classList.remove('pwa-update-visible');
overlay.addEventListener('transitionend', function () {
overlay.remove();
}, { once: true });
}
primaryBtn.addEventListener('click', function () {
primaryBtn.disabled = true;
secondaryBtn.disabled = true;
primaryBtn.textContent = 'Mise à jour…';
// Le rechargement est déclenché par l'event controllerchange
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
});
secondaryBtn.addEventListener('click', closeModal);
document.addEventListener('keydown', function onEscape(event) {
if (event.key === 'Escape' && document.body.contains(overlay)) {
closeModal();
document.removeEventListener('keydown', onEscape);
}
});
// Accessibilité : focus sur l'action principale
primaryBtn.focus();
}
/**
* Surveille l'installation d'un nouveau Service Worker.
* @param {ServiceWorkerRegistration} registration
*/
function trackInstalling(registration) {
registration.addEventListener('updatefound', function () {
const newWorker = registration.installing;
if (!newWorker) {
return;
}
newWorker.addEventListener('statechange', function () {
// installed + un contrôleur actif = mise à jour en attente
// (sans contrôleur, c'est la toute première installation)
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateModal(registration.waiting);
}
});
});
}
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js')
.then(function (registration) {
// Cas où une mise à jour est déjà en attente (installée lors
// d'une visite précédente, jamais activée)
if (registration.waiting && navigator.serviceWorker.controller) {
showUpdateModal(registration.waiting);
}
trackInstalling(registration);
})
.catch(function (err) {
console.log('Échec de l\'enregistrement du Service Worker:', err);
});
});
})();
+1
View File
@@ -243,5 +243,6 @@
<?php include 'includes/footer.php'; ?>
<?php include 'includes/mobile-menu.php'; ?>
<script src="js/main.js"></script>
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
</body>
</html>
+1
View File
@@ -261,5 +261,6 @@ if ($resultsCount > 0) {
<script src="js/main.js"></script>
<script src="js/search.js"></script>
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
</body>
</html>
+13 -18
View File
@@ -1,6 +1,9 @@
const CACHE_NAME = 'fediverse-oki-08072026-0720';
const STATIC_CACHE_NAME = 'fediverse-oki-static-08072026-0720';
const DYNAMIC_CACHE_NAME = 'fediverse-oki-dynamic-08072026-0720';
// Version du cache : à bumper à chaque déploiement (format JJMMAAAA-HHMM).
// Tout changement de ce fichier déclenche l'installation d'un nouveau
// Service Worker chez les visiteurs ; les anciens caches sont purgés à
// l'activation (voir l'event 'activate' plus bas).
const STATIC_CACHE_NAME = 'annu-kute-ced-static-24072026-0612';
const DYNAMIC_CACHE_NAME = 'annu-kute-ced-dynamic-24072026-0612';
// Ressources à mettre en cache immédiatement
const STATIC_ASSETS = [
@@ -46,13 +49,15 @@ self.addEventListener('install', event => {
console.log('Service Worker: Mise en cache des assets statiques');
return cache.addAll(STATIC_ASSETS);
})
.then(() => {
return self.skipWaiting();
})
.catch(err => {
console.error('Service Worker: Erreur lors de la mise en cache:', err);
})
);
// Note : pas de skipWaiting() ici. Lors d'une mise à jour, le nouveau SW
// reste en attente jusqu'à ce que l'utilisateur accepte la mise à jour
// via le modal (message SKIP_WAITING envoyé par js/pwa-update.js).
// Lors de la toute première visite (aucun SW actif), l'activation est
// immédiate.
});
// Activation du Service Worker
@@ -202,20 +207,10 @@ function isApiRequest(url) {
url.includes('mastodon-config.php');
}
// Gestion des messages du client
// Gestion des messages du client : l'utilisateur a accepté la mise à jour,
// le SW en attente prend le contrôle (purge des anciens caches à l'activation).
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Notification de mise à jour
self.addEventListener('message', event => {
if (event.data && event.data.type === 'CHECK_UPDATE') {
// Vérifier s'il y a une mise à jour
event.ports[0].postMessage({
type: 'UPDATE_AVAILABLE',
version: CACHE_NAME
});
}
});
+1
View File
@@ -668,5 +668,6 @@ if (empty($videoData) || isset($videoData['error'])) {
});
</script>
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
</body>
</html>