3 Commits
Author SHA1 Message Date
mathieu e7fe9ed6a9 Dans la vue Série, lors d'une entrée de numéro de tome, focus directement sur la zone d'entrée.
Dans la vue Modifier une série, la dernière série enregistrée est la première montrée dans la sélection de la série mère. De plus, lors de l'enregistrement, on revient sur la page série et on ne reste plus bloquer sur la même page de sélection
2026-09-10 19:00:30 +02:00
mathieu dbb103f6db - Ajout d'un bouton pour forcer la mise à jour (si coupure internet pdt le dl, il faut tout désinstaller et re-instalter...).
- Pour les grandes bibli, ajouter un visuel que l'appli est en train de charger les données en cache
2026-09-10 17:58:27 +02:00
mathieu e2254e700d Ajout lot dans catalogue 2026-09-10 15:29:53 +02:00
9 changed files with 342 additions and 186 deletions
@@ -43,7 +43,8 @@
<ul class="liste-livres"> <ul class="liste-livres">
@foreach (var sous in Entree.SousEntrees) @foreach (var sous in Entree.SousEntrees)
{ {
<GroupeCatalogue Entree="sous" Ouverts="Ouverts" Totaux="Totaux" /> <GroupeCatalogue Entree="sous" Ouverts="Ouverts" Totaux="Totaux"
LotActif="LotActif" SelectionLot="SelectionLot" OnSelectionner="OnSelectionner" />
} }
</ul> </ul>
} }
@@ -52,11 +53,26 @@
{ {
<ul class="liste-livres"> <ul class="liste-livres">
@foreach (var livre in Entree.Livres) @foreach (var livre in Entree.Livres)
{
@if (LotActif)
{
<li class="carte-oeuvre" @key="livre.Id">
<label class="oeuvre-case">
<input type="checkbox"
checked="@(SelectionLot?.Contains(livre.Id) ?? false)"
@onchange="e => OnSelectionner.InvokeAsync((livre.Id, e.Value is true))" />
<span class="lecture-ecran">Sélectionner « @livre.Titre »</span>
</label>
<CarteLivre Livre="livre" />
</li>
}
else
{ {
<li @key="livre.Id"> <li @key="livre.Id">
<CarteLivre Livre="livre" /> <CarteLivre Livre="livre" />
</li> </li>
} }
}
</ul> </ul>
} }
} }
@@ -85,6 +101,19 @@
/// </remarks> /// </remarks>
[Parameter] public IReadOnlyDictionary<int, (int Possedes, int Total)>? Totaux { get; set; } [Parameter] public IReadOnlyDictionary<int, (int Possedes, int Total)>? Totaux { get; set; }
/// <summary>Modification en lot du catalogue : voir la remarque équivalente sur Catalogue.razor.</summary>
[Parameter] public bool LotActif { get; set; }
[Parameter] public ISet<int>? SelectionLot { get; set; }
/// <summary>
/// ⚠️ EventCallback, et non un Action brut : coché depuis un tome niché dans ce bloc, l'écran
/// du catalogue (le VRAI propriétaire de la sélection) ne serait sinon jamais prévenu du
/// changement, et sa barre de sélection resterait figée sur l'état d'avant — le second piège
/// Blazor de CLAUDE.md.
/// </summary>
[Parameter] public EventCallback<(int LivreId, bool Coche)> OnSelectionner { get; set; }
private (int Possedes, int Total) Compte => private (int Possedes, int Total) Compte =>
Totaux is not null && Totaux.TryGetValue(Entree.Serie!.Id, out var connu) Totaux is not null && Totaux.TryGetValue(Entree.Serie!.Id, out var connu)
? connu ? connu
+52
View File
@@ -1,6 +1,7 @@
@page "/a-propos" @page "/a-propos"
@inject ServiceLivresApi Api @inject ServiceLivresApi Api
@inject EtatReseau Reseau @inject EtatReseau Reseau
@inject IJSRuntime JS
@implements IDisposable @implements IDisposable
@* @*
@@ -93,6 +94,34 @@
celle qui y tourne en ce moment. celle qui y tourne en ce moment.
</p> </p>
} }
@*
⚠️ Ce numéro vient du SERVEUR (voir plus haut) : il ne prouve pas que le navigateur a
lui-même récupéré la nouvelle version. Le service worker n'active un nouveau cache
qu'une fois TOUS les onglets fermés — un appareil qui reste ouvert en arrière-plan
(téléphone surtout) peut rester bloqué sur l'ancienne version indéfiniment. Ce bouton
court-circuite cette attente : désinscrire le worker et vider son cache, recharger.
Hors-ligne il est désactivé — le déclencher sans réseau couperait l'accès hors-ligne
en cours, sans rien pouvoir retélécharger à la place.
*@
<p>
@if (Reseau.EnLigne)
{
<button type="button" class="bouton bouton-discret" disabled="@_forcageEnCours"
@onclick="ForcerMiseAJourAsync">
@(_forcageEnCours ? "Mise à jour…" : "Forcer la mise à jour")
</button>
}
else
{
<button type="button" class="bouton bouton-discret" disabled
title="@EtatReseau.MotifHorsLigne">Forcer la mise à jour</button>
}
</p>
<p class="message-discret">
Vide le cache de l'application et la recharge entièrement — à utiliser si une nouvelle
version tarde à apparaître malgré la fermeture de tous les onglets.
</p>
} }
else else
{ {
@@ -145,6 +174,7 @@
private const string Depot = "https://git.akbar.nohost.me/mathieu/mabibli"; private const string Depot = "https://git.akbar.nohost.me/mathieu/mabibli";
private VersionApplication? _version; private VersionApplication? _version;
private bool _forcageEnCours;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -161,6 +191,28 @@
StateHasChanged(); StateHasChanged();
}); });
/// <summary>
/// Désinscrit le service worker et vide son cache avant de recharger — voir js/forcer-maj.js.
/// Le bouton reste désactivé pendant l'opération : un double clic déclencherait deux
/// désinscriptions concurrentes pour un seul rechargement.
/// </summary>
private async Task ForcerMiseAJourAsync()
{
_forcageEnCours = true;
try
{
await using var module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/forcer-maj.js");
await module.InvokeVoidAsync("forcerMiseAJour");
}
catch
{
// Le rechargement forcé qui suit normalement l'appel JS ne survient pas ici : on
// rend la main pour que l'utilisateur puisse réessayer plutôt que de rester sur un
// bouton grisé pour rien.
_forcageEnCours = false;
}
}
private static string Quand(DateTimeOffset instant) private static string Quand(DateTimeOffset instant)
{ {
var local = instant.ToLocalTime(); var local = instant.ToLocalTime();
+187 -2
View File
@@ -40,6 +40,20 @@
</button> </button>
} }
@*
« Modifier en lot » n'apparaît qu'à partir de DEUX livres — même seuil que sur la fiche
série. Reste possible hors-ligne : l'écriture, elle, est bloquée par livre comme
partout ailleurs (⚠️ MotifBlocage).
*@
@if (Reseau.EnLigne && _livres is { Count: > 1 })
{
<button type="button" class="bouton bouton-discret bouton-compact"
aria-expanded="@Aria.Etat(_lotOuvert)" aria-controls="lot-catalogue"
@onclick="BasculerLot">
Modifier en lot
</button>
}
@if (Reseau.EnLigne) @if (Reseau.EnLigne)
{ {
<a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a> <a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a>
@@ -214,7 +228,7 @@
@if (_chargement && _livres is null) @if (_chargement && _livres is null)
{ {
<p class="message-discret">Chargement…</p> <Patience Message="Chargement du catalogue…" />
} }
else if (_livres is { Count: 0 }) else if (_livres is { Count: 0 })
{ {
@@ -226,6 +240,47 @@ else if (_livres is not null)
{ {
<p class="compteur">@_livres.Count livre@(_livres.Count > 1 ? "s" : "")</p> <p class="compteur">@_livres.Count livre@(_livres.Count > 1 ? "s" : "")</p>
@*
Modification en lot : même patron que la fiche série (barre collante, formulaire affiché
sous elle) — sauf que la sélection porte ici sur le catalogue ENTIER, groupé ou non, d'où
le bouton « Tout sélectionner » qui coche d'un coup tous les livres actuellement chargés
(donc déjà filtrés : cocher les 400 livres du fonds derrière un filtre à trois résultats
n'aurait aucun sens).
*@
@if (_lotOuvert)
{
<div id="lot-catalogue">
@if (_selectionLot.Count > 0)
{
<div class="barre-selection" role="group" aria-label="Actions sur la sélection">
<span class="selection-compte">
@_selectionLot.Count sélectionné@(_selectionLot.Count > 1 ? "s" : "")
</span>
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCoursLot" @onclick="ToutSelectionnerLot">
Tout sélectionner (@_livres!.Count)
</button>
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCoursLot" @onclick="() => _selectionLot.Clear()">
Tout décocher
</button>
</div>
<FormulaireLotLivres Saisie="_saisieLot" NombreLivres="_selectionLot.Count"
Erreur="@_erreurLot" EnCours="_enCoursLot" MessageBlocage="@MotifBlocageLot"
OnValider="EnregistrerLotAsync" OnAnnuler="AnnulerLot" />
}
else
{
<p class="message-discret">
Cochez les livres à modifier ensemble, ou
<button type="button" class="bouton bouton-discret bouton-compact"
@onclick="ToutSelectionnerLot">tout sélectionner (@_livres!.Count)</button>.
</p>
}
</div>
}
@* @*
Les tomes d'une série tiennent sous son nom, à la place qu'occuperait le premier d'entre Les tomes d'une série tiennent sous son nom, à la place qu'occuperait le premier d'entre
eux : l'ordre général du catalogue ne bouge pas, on ne perd donc pas un livre qu'on eux : l'ordre général du catalogue ne bouge pas, on ne perd donc pas un livre qu'on
@@ -247,7 +302,21 @@ else if (_livres is not null)
{ {
@if (entree.EstGroupe) @if (entree.EstGroupe)
{ {
<GroupeCatalogue Entree="entree" Ouverts="_ouverts" Totaux="_totauxSeries" /> <GroupeCatalogue Entree="entree" Ouverts="_ouverts" Totaux="_totauxSeries"
LotActif="_lotOuvert" SelectionLot="_selectionLot"
OnSelectionner="args => SelectionnerLot(args.LivreId, args.Coche)" />
}
else if (_lotOuvert)
{
<li class="carte-oeuvre" @key="entree.Livres[0].Id">
<label class="oeuvre-case">
<input type="checkbox"
checked="@_selectionLot.Contains(entree.Livres[0].Id)"
@onchange="e => SelectionnerLot(entree.Livres[0].Id, e.Value is true)" />
<span class="lecture-ecran">Sélectionner « @entree.Livres[0].Titre »</span>
</label>
<CarteLivre Livre="entree.Livres[0]" />
</li>
} }
else else
{ {
@@ -348,6 +417,18 @@ else if (_livres is not null)
private bool _filtresOuverts; private bool _filtresOuverts;
private string? _erreur; private string? _erreur;
/// <summary>Le panneau de modification en lot est déplié. Même patron que la fiche série.</summary>
private bool _lotOuvert;
/// <summary>Identifiants des livres cochés pour la modification en lot.</summary>
private readonly HashSet<int> _selectionLot = [];
private SaisieLot _saisieLot = new();
private bool _enCoursLot;
private string? _erreurLot;
private string? MotifBlocageLot => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
/// <summary> /// <summary>
/// Formats réellement présents dans le fonds, ou <c>null</c> tant qu'on ne le sait pas. /// Formats réellement présents dans le fonds, ou <c>null</c> tant qu'on ne le sait pas.
/// </summary> /// </summary>
@@ -606,6 +687,110 @@ else if (_livres is not null)
} }
} }
/// <summary>
/// Ouvre ou ferme le panneau de modification en lot. Le refermer vide aussi la sélection et
/// la saisie en cours — même règle que sur la fiche série : un panneau qui se rouvrirait sur
/// l'état d'avant laisserait croire à une saisie encore active.
/// </summary>
private void BasculerLot()
{
_lotOuvert = !_lotOuvert;
_erreurLot = null;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
}
private void SelectionnerLot(int livreId, bool selectionne)
{
if (selectionne)
{
_selectionLot.Add(livreId);
}
else
{
_selectionLot.Remove(livreId);
}
}
/// <summary>
/// Coche d'un coup tous les livres actuellement CHARGÉS — donc déjà filtrés. Un groupe replié
/// n'y échappe pas : la sélection porte sur <c>_livres</c>, pas sur ce que l'écran affiche.
/// </summary>
private void ToutSelectionnerLot()
{
foreach (var livre in _livres ?? [])
{
_selectionLot.Add(livre.Id);
}
}
private void AnnulerLot()
{
_selectionLot.Clear();
_saisieLot = new SaisieLot();
_erreurLot = null;
}
/// <summary>
/// Applique la saisie en lot à chacun des livres cochés, un appel à la fois — même patron que
/// la fiche série : pas de point d'entrée dédié, la boucle réutilise
/// <see cref="ServiceLivresApi.ModifierAsync"/>, en ne remplaçant que les champs cochés et en
/// reprenant tels quels ceux qui ne le sont pas.
/// </summary>
private async Task EnregistrerLotAsync()
{
_enCoursLot = true;
_erreurLot = null;
var erreurs = new List<string>();
foreach (var livreId in _selectionLot)
{
var livre = (_livres ?? []).FirstOrDefault(l => l.Id == livreId);
if (livre is null)
{
continue;
}
var saisie = new EnregistrementLivre
{
Isbn = livre.Isbn,
Titre = livre.Titre,
Auteurs = _saisieLot.ModifierAuteurs
? _saisieLot.Auteurs
: livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).ToList(),
Themes = _saisieLot.ModifierThemes ? _saisieLot.Themes : [.. livre.Themes],
Editeur = _saisieLot.ModifierEditeur ? _saisieLot.Editeur : livre.Editeur,
Format = _saisieLot.ModifierFormat ? _saisieLot.Format : livre.Format,
TypeDocument = _saisieLot.ModifierType ? _saisieLot.TypeDocument : livre.TypeDocument,
Statut = _saisieLot.ModifierStatut ? _saisieLot.Statut : livre.Statut,
NombrePages = livre.NombrePages,
CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
};
var resultat = await Api.ModifierAsync(livre.Id, saisie);
if (!resultat.EstOk)
{
erreurs.Add(resultat.Erreur!);
}
}
_enCoursLot = false;
if (erreurs.Count > 0)
{
_erreurLot = string.Join(" ", erreurs.Distinct());
await ChargerAsync();
return;
}
_lotOuvert = false;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
await ChargerAsync();
}
public void Dispose() public void Dispose()
{ {
Reseau.Change -= SurChangementReseau; Reseau.Change -= SurChangementReseau;
+15 -3
View File
@@ -520,6 +520,7 @@ else
<div class="barre-recherche"> <div class="barre-recherche">
<input class="champ-saisie" type="text" <input class="champ-saisie" type="text"
placeholder="7, Hors-série, 3.5…" placeholder="7, Hors-série, 3.5…"
@ref="_champNumero"
@bind="_numeroSaisi" @bind:event="oninput" @bind="_numeroSaisi" @bind:event="oninput"
@onkeydown="@(e => SurToucheNumero(e, element))" @onkeydown="@(e => SurToucheNumero(e, element))"
aria-label="@($"Numéro du tome « {element.Titre} »")" aria-label="@($"Numéro du tome « {element.Titre} »")"
@@ -745,6 +746,11 @@ else
private string _numeroSaisi = string.Empty; private string _numeroSaisi = string.Empty;
private ElementReference _champNumero;
/// <summary>Le champ « numéro » vient de s'ouvrir : à focaliser au prochain rendu.</summary>
private bool _focusNumero;
/// <summary> /// <summary>
/// Trier par numéro de tome plutôt que par ordre de lecture. /// Trier par numéro de tome plutôt que par ordre de lecture.
/// </summary> /// </summary>
@@ -869,6 +875,12 @@ else
protected override async Task OnAfterRenderAsync(bool premierRendu) protected override async Task OnAfterRenderAsync(bool premierRendu)
{ {
if (_focusNumero)
{
_focusNumero = false;
await _champNumero.FocusAsync();
}
if (_focusAFaire is not { } voie) if (_focusAFaire is not { } voie)
{ {
return; return;
@@ -894,6 +906,7 @@ else
{ {
_numeroOuvert = element.Id; _numeroOuvert = element.Id;
_numeroSaisi = element.Numero ?? string.Empty; _numeroSaisi = element.Numero ?? string.Empty;
_focusNumero = true;
} }
private async Task SurToucheNumero(KeyboardEventArgs e, ElementSerieDto element) private async Task SurToucheNumero(KeyboardEventArgs e, ElementSerieDto element)
@@ -1052,7 +1065,7 @@ else
/// refuse aussi — cette liste évite seulement de proposer un choix qui sera rejeté. /// refuse aussi — cette liste évite seulement de proposer un choix qui sera rejeté.
/// </remarks> /// </remarks>
private IEnumerable<SerieDto> Candidates => private IEnumerable<SerieDto> Candidates =>
_toutes.Where(s => s.Id != Id && !EstDescendante(s.Id)); _toutes.Where(s => s.Id != Id && !EstDescendante(s.Id)).Reverse();
private bool EstDescendante(int candidate) private bool EstDescendante(int candidate)
{ {
@@ -1522,10 +1535,9 @@ else
await EcrireAsync(() => Api.ModifierSerieAsync( await EcrireAsync(() => Api.ModifierSerieAsync(
Id, new EnregistrementSerie { Titre = _titre, SerieParenteId = _parenteId == 0 ? null : _parenteId })); Id, new EnregistrementSerie { Titre = _titre, SerieParenteId = _parenteId == 0 ? null : _parenteId }));
// Le rangement dans un cycle change la parenté des AUTRES séries affichées : on relit.
if (_erreur is null) if (_erreur is null)
{ {
await ChargerAsync(); Navigation.NavigateTo($"series/{Id}");
} }
} }
+5 -36
View File
@@ -1180,41 +1180,6 @@ body {
font-weight: 600; font-weight: 600;
} }
/* --- Mise à jour de l'application --- */
/* Injecté par js/mise-a-jour.js, hors composants Blazor : ce bandeau doit pouvoir s'afficher
même si l'application WebAssembly n'a pas démarré. D'où une règle globale et non scopée.
Il existe parce que les empreintes des assets WASM sont désactivées (CLAUDE.md) : le service
worker est le seul cache-busting du projet, et sa mise à jour attendrait sinon la fermeture
de tous les onglets. */
#mb-maj {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1100;
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: center;
flex-wrap: wrap;
padding: 0.7rem 1rem;
background: #1b3a5c;
color: #fff;
font-size: 0.9rem;
}
#mb-maj button {
min-height: 2.5rem;
padding: 0.4rem 0.9rem;
border: 0;
border-radius: 6px;
background: #f5d76e;
color: #4a3800;
font-weight: 600;
cursor: pointer;
}
/* Un ISBN se relit chiffre à chiffre : une chasse fixe évite de confondre 1 et l, /* Un ISBN se relit chiffre à chiffre : une chasse fixe évite de confondre 1 et l,
0 et O, et rend les tranches séparées par des tirets alignées d'une ligne à l'autre. */ 0 et O, et rend les tranches séparées par des tirets alignées d'une ligne à l'autre. */
/* ⚠️ PAS de « white-space: nowrap » ici, et ce n'est pas un oubli. Un ISBN à tirets mesure /* ⚠️ PAS de « white-space: nowrap » ici, et ce n'est pas un oubli. Un ISBN à tirets mesure
@@ -1263,7 +1228,11 @@ body {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4rem; gap: 0.4rem;
flex: 0 0 auto; /* flex-shrink à 0 empêchait ce bloc de rétrécir : sur un écran étroit, il gardait la
largeur de ses boutons mis bout à bout et débordait à droite au lieu de les enrouler
sur une seconde ligne, malgré le flex-wrap ci-dessus. */
flex: 1 1 auto;
min-width: 0;
} }
/* --- Liste d'envies : ordre choisi --- */ /* --- Liste d'envies : ordre choisi --- */
+2 -2
View File
@@ -43,8 +43,8 @@
</div> </div>
<script src="_framework/blazor.webassembly.js"></script> <script src="_framework/blazor.webassembly.js"></script>
<!-- Enregistrement du service worker déporté : il vérifie le support avant d'appeler <!-- Enregistrement du service worker déporté : il vérifie le support avant d'appeler
navigator.serviceWorker (absent hors contexte sécurisé), journalise un échec au lieu de navigator.serviceWorker (absent hors contexte sécurisé), et journalise un échec au lieu
l'avaler, et propose la mise à jour quand une nouvelle version est prête. --> de l'avaler. Comportement de mise à jour par défaut de Blazor : voir js/mise-a-jour.js. -->
<script src="js/mise-a-jour.js"></script> <script src="js/mise-a-jour.js"></script>
</body> </body>
+21
View File
@@ -0,0 +1,21 @@
// Échappatoire manuelle au comportement par défaut du service worker (qui attend la fermeture
// de TOUS les onglets avant d'activer une nouvelle version — voir js/mise-a-jour.js) : on
// désinscrit le worker et on vide le Cache Storage qu'il a rempli, puis on recharge. La page
// suivante repart de zéro et réenregistre un worker propre sur les fichiers actuels du serveur.
//
// ⚠️ Ne touche PAS à IndexedDB (cache-hors-ligne.js) : ces instantanés sont les données de
// lecture hors-ligne de l'utilisateur, pas du code périmé — les effacer n'aiderait à rien ici
// et ferait perdre la seule consultation possible sans réseau.
export async function forcerMiseAJour() {
if ('serviceWorker' in navigator) {
const enregistrements = await navigator.serviceWorker.getRegistrations();
await Promise.all(enregistrements.map(enregistrement => enregistrement.unregister()));
}
if ('caches' in self) {
const cles = await caches.keys();
await Promise.all(cles.map(cle => caches.delete(cle)));
}
window.location.reload();
}
+20 -126
View File
@@ -1,22 +1,24 @@
// Enregistrement du service worker, application automatique au démarrage, bandeau en secours. // Enregistrement du service worker — comportement par défaut de Blazor : un nouveau worker
// attend que tous les onglets de l'application soient fermés avant de prendre la main. Pas de
// SKIP_WAITING, pas de rechargement forcé, pas de bandeau : la mise à jour a lieu au prochain
// lancement de l'application, silencieusement.
// //
// Pourquoi ce fichier existe (et pourquoi il n'est pas qu'une ligne `register(...)`) : // ⚠️ Revenu en arrière le 2026-09-10 : le mécanisme précédent (application automatique dans les
// 10 premières secondes, sinon bandeau « Mettre à jour ») ne se montrait pas fiable en pratique
// (constaté sur l'application installée, PC comme téléphone : la version affichée dans « À
// propos » — qui vient d'un appel au serveur, donc toujours à jour — changeait sans que le reste
// de l'application suive). Le comportement par défaut est plus simple à raisonner ; s'il rebloque
// comme en août 2026, le correctif portera sur un diagnostic visible avant de complexifier à
// nouveau ce fichier.
// //
// 1. Les empreintes des assets WASM sont DÉSACTIVÉES (voir CLAUDE.md). `blazor.webassembly.js` // Ce qui reste, et pourquoi :
// et `dotnet.js` portent donc des noms stables, et c'est le service worker — lui seul — qui // 1. `navigator.serviceWorker` n'existe pas en contexte non sécurisé (http sur une IP locale).
// empêche de servir éternellement une version périmée. Le mécanisme de Blazor fonctionne,
// mais il est silencieux et différé : le nouveau worker attend que TOUS les onglets de
// l'application soient fermés. Sur mobile, un onglet oublié fige la mise à jour sans que
// personne comprenne pourquoi.
//
// 2. `navigator.serviceWorker` n'existe pas en contexte non sécurisé (http sur une IP locale).
// L'appeler sans vérification lève une TypeError qui casse le script — et fait croire à un // L'appeler sans vérification lève une TypeError qui casse le script — et fait croire à un
// défaut de la PWA alors que c'est le contexte qui n'est pas éligible. // défaut de la PWA alors que c'est le contexte qui n'est pas éligible.
// // 2. Chemin ABSOLU, et scope explicite. `register('service-worker.js')` se résout contre l'URL
// ⚠️ Le clic n'est plus obligatoire (décidé le 2026-08-20, après un blocage constaté sur // du DOCUMENT et non contre <base href="/"> : ouvrir l'application sur une route profonde
// l'application installée sur téléphone). CLAUDE.md actait « jamais tout seul » pour ne pas // (/souhaits, /livres/3) visait /souhaits/service-worker.js, que le serveur ne sert pas
// recharger sous une saisie en cours ; la raison reste valable, mais elle ne vaut que pour une // l'enregistrement échouait alors silencieusement, et avec lui tout le hors-ligne.
// session déjà entamée. Voir FENETRE_DEMARRAGE ci-dessous.
(function () { (function () {
'use strict'; 'use strict';
@@ -28,121 +30,13 @@
return; return;
} }
// Fenêtre pendant laquelle une nouvelle version s'applique TOUTE SEULE, sans rien demander. // updateViaCache: 'none' — sans lien avec le comportement d'application (ci-dessus) : ceci
// Au-delà, l'utilisateur est en train de faire quelque chose (une fiche à moitié saisie, un // évite seulement qu'un cache HTTP intermédiaire masque une nouvelle version du script lui-
// prêt en cours d'enregistrement) : on repasse par le bandeau, pour ne pas lui arracher son // même, y compris service-worker-assets.js qu'il importe.
// travail. Ouvrir l'application depuis l'écran d'accueil du téléphone tombe toujours dans
// cette fenêtre — c'est exactement le cas qui ne se mettait plus à jour.
var FENETRE_DEMARRAGE = 10000;
// Si la reprise en main n'a pas lieu, on recharge quand même : le pire scénario est de
// laisser l'utilisateur devant un bouton grisé qui ne fait rien.
var DELAI_REPRISE = 5000;
// Une seule reprise forcée par session : si la nouvelle version n'arrive pas à s'activer,
// recharger en boucle transformerait une mise à jour ratée en application inutilisable.
var CLE_FORCE = 'mb-maj-forcee';
var demandee = false;
var recharge = false;
function recharger() {
if (recharge) return;
recharge = true;
window.location.reload();
}
function appliquer(enAttente) {
demandee = true;
// Le worker en attente prend la main sans qu'on ait à fermer tous les onglets.
enAttente.postMessage({ type: 'SKIP_WAITING' });
// Filet. `controllerchange` devrait suivre ; s'il ne vient pas (worker en attente issu
// d'une version antérieure au gestionnaire SKIP_WAITING, reprise en main qui échoue),
// rien ne se passerait du tout et le bouton resterait grisé pour toujours.
window.setTimeout(function () {
if (recharge) return;
var dejaForcee = false;
try {
dejaForcee = window.sessionStorage.getItem(CLE_FORCE) === '1';
window.sessionStorage.setItem(CLE_FORCE, '1');
} catch (e) {
// Navigation privée ou stockage refusé : on préfère ne pas forcer plutôt que
// risquer une boucle de rechargement qu'on ne saurait plus arrêter.
dejaForcee = true;
}
if (!dejaForcee) recharger();
}, DELAI_REPRISE);
}
function proposerLaMiseAJour(enAttente) {
if (document.getElementById('mb-maj')) return;
var barre = document.createElement('div');
barre.id = 'mb-maj';
barre.setAttribute('role', 'status');
barre.textContent = 'Une nouvelle version de MaBibli est disponible. ';
var bouton = document.createElement('button');
bouton.type = 'button';
bouton.textContent = 'Mettre à jour';
bouton.addEventListener('click', function () {
bouton.disabled = true;
bouton.textContent = 'Mise à jour…';
appliquer(enAttente);
});
barre.appendChild(bouton);
document.body.appendChild(barre);
}
// Au démarrage : on applique sans demander. Plus tard : on propose.
function traiter(enAttente) {
if (performance.now() < FENETRE_DEMARRAGE) {
appliquer(enAttente);
} else {
proposerLaMiseAJour(enAttente);
}
}
// ⚠️ Chemin ABSOLU, et scope explicite. `register('service-worker.js')` se résout contre
// l'URL du DOCUMENT et non contre <base href="/"> : ouvrir l'application sur une route
// profonde (/souhaits, /livres/3) visait /souhaits/service-worker.js, que le serveur ne sert
// pas — l'enregistrement échouait alors silencieusement, et avec lui tout le hors-ligne.
navigator.serviceWorker.register('/service-worker.js', { scope: '/', updateViaCache: 'none' }) navigator.serviceWorker.register('/service-worker.js', { scope: '/', updateViaCache: 'none' })
.then(function (enregistrement) {
// Un worker déjà installé attendait peut-être depuis la visite précédente.
if (enregistrement.waiting && navigator.serviceWorker.controller) {
traiter(enregistrement.waiting);
}
enregistrement.addEventListener('updatefound', function () {
var nouveau = enregistrement.installing;
if (!nouveau) return;
nouveau.addEventListener('statechange', function () {
// `controller` non nul = ce n'est pas la première installation, donc il y a
// bien une version précédente à remplacer.
if (nouveau.state === 'installed' && navigator.serviceWorker.controller) {
traiter(nouveau);
}
});
});
// Vérification explicite à chaque chargement : ne pas dépendre du seul rythme
// interne du navigateur pour découvrir une version plus récente.
enregistrement.update().catch(function () { /* hors-ligne : sans objet */ });
})
.catch(function (erreur) { .catch(function (erreur) {
// Ne jamais laisser cet échec passer inaperçu : sans service worker, l'application // Ne jamais laisser cet échec passer inaperçu : sans service worker, l'application
// ne démarre pas hors-ligne, et le cache IndexedDB ne sert alors à rien. // ne démarre pas hors-ligne, et le cache IndexedDB ne sert alors à rien.
console.error("Échec de l'enregistrement du service worker :", erreur); console.error("Échec de l'enregistrement du service worker :", erreur);
}); });
// Le rechargement n'a lieu que si la mise à jour a été demandée — automatiquement ou par un
// clic. Un changement de contrôleur survient aussi à la toute première installation, et
// recharger la page à ce moment-là serait un clignotement inexplicable.
navigator.serviceWorker.addEventListener('controllerchange', function () {
if (demandee) recharger();
});
})(); })();
@@ -6,15 +6,10 @@ self.addEventListener('install', event => event.waitUntil(onInstall(event)));
self.addEventListener('activate', event => event.waitUntil(onActivate(event))); self.addEventListener('activate', event => event.waitUntil(onActivate(event)));
self.addEventListener('fetch', event => event.respondWith(onFetch(event))); self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
// Mise à jour à la demande. Sans cela, un nouveau worker attend que TOUS les onglets de // Comportement par défaut : pas de SKIP_WAITING. Un nouveau worker attend que TOUS les onglets
// l'application soient fermés — un onglet oublié fige indéfiniment l'utilisateur sur l'ancienne // de l'application soient fermés avant de prendre la main — la mise à jour a lieu au prochain
// version. C'est d'autant plus important ici que les empreintes des assets WASM sont désactivées // lancement, jamais au milieu d'une session. Revenu en arrière le 2026-09-10 après un mécanisme
// (voir CLAUDE.md) : ce worker est le SEUL mécanisme de cache-busting du projet. // de mise à jour immédiate qui ne se montrait pas fiable en pratique (voir js/mise-a-jour.js).
// Le message ne vient que de js/mise-a-jour.js, après un clic explicite : jamais tout seul, pour
// ne pas mélanger deux versions au milieu d'une session.
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
});
const cacheNamePrefix = 'offline-cache-'; const cacheNamePrefix = 'offline-cache-';
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
@@ -46,10 +41,9 @@ async function onActivate(event) {
.filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
.map(key => caches.delete(key))); .map(key => caches.delete(key)));
// Prendre la main sur les pages déjà ouvertes. skipWaiting() est censé le faire seul, mais // Sans effet sur les onglets déjà ouverts avant cette activation (ils ont fermé pour qu'elle
// c'est ce qui déclenche `controllerchange`, donc le rechargement côté page : sans reprise // ait lieu) ; utile seulement pour la toute première installation, où aucun worker ne
// effective, le bouton « Mettre à jour » se grise et il ne se passe plus rien — symptôme // contrôlait encore la page.
// observé sur téléphone. Cet appel est sans effet quand la reprise a déjà eu lieu.
await self.clients.claim(); await self.clients.claim();
} }