Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7fe9ed6a9 | ||
|
|
dbb103f6db | ||
|
|
e2254e700d |
@@ -43,7 +43,8 @@
|
||||
<ul class="liste-livres">
|
||||
@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>
|
||||
}
|
||||
@@ -52,11 +53,26 @@
|
||||
{
|
||||
<ul class="liste-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">
|
||||
<CarteLivre Livre="livre" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
@@ -85,6 +101,19 @@
|
||||
/// </remarks>
|
||||
[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 =>
|
||||
Totaux is not null && Totaux.TryGetValue(Entree.Serie!.Id, out var connu)
|
||||
? connu
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/a-propos"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject IJSRuntime JS
|
||||
@implements IDisposable
|
||||
|
||||
@*
|
||||
@@ -93,6 +94,34 @@
|
||||
celle qui y tourne en ce moment.
|
||||
</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
|
||||
{
|
||||
@@ -145,6 +174,7 @@
|
||||
private const string Depot = "https://git.akbar.nohost.me/mathieu/mabibli";
|
||||
|
||||
private VersionApplication? _version;
|
||||
private bool _forcageEnCours;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -161,6 +191,28 @@
|
||||
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)
|
||||
{
|
||||
var local = instant.ToLocalTime();
|
||||
|
||||
@@ -40,6 +40,20 @@
|
||||
</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)
|
||||
{
|
||||
<a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a>
|
||||
@@ -214,7 +228,7 @@
|
||||
|
||||
@if (_chargement && _livres is null)
|
||||
{
|
||||
<p class="message-discret">Chargement…</p>
|
||||
<Patience Message="Chargement du catalogue…" />
|
||||
}
|
||||
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>
|
||||
|
||||
@*
|
||||
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
|
||||
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)
|
||||
{
|
||||
<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
|
||||
{
|
||||
@@ -348,6 +417,18 @@ else if (_livres is not null)
|
||||
private bool _filtresOuverts;
|
||||
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>
|
||||
/// Formats réellement présents dans le fonds, ou <c>null</c> tant qu'on ne le sait pas.
|
||||
/// </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()
|
||||
{
|
||||
Reseau.Change -= SurChangementReseau;
|
||||
|
||||
@@ -520,6 +520,7 @@ else
|
||||
<div class="barre-recherche">
|
||||
<input class="champ-saisie" type="text"
|
||||
placeholder="7, Hors-série, 3.5…"
|
||||
@ref="_champNumero"
|
||||
@bind="_numeroSaisi" @bind:event="oninput"
|
||||
@onkeydown="@(e => SurToucheNumero(e, element))"
|
||||
aria-label="@($"Numéro du tome « {element.Titre} »")"
|
||||
@@ -745,6 +746,11 @@ else
|
||||
|
||||
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>
|
||||
/// Trier par numéro de tome plutôt que par ordre de lecture.
|
||||
/// </summary>
|
||||
@@ -869,6 +875,12 @@ else
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_focusNumero)
|
||||
{
|
||||
_focusNumero = false;
|
||||
await _champNumero.FocusAsync();
|
||||
}
|
||||
|
||||
if (_focusAFaire is not { } voie)
|
||||
{
|
||||
return;
|
||||
@@ -894,6 +906,7 @@ else
|
||||
{
|
||||
_numeroOuvert = element.Id;
|
||||
_numeroSaisi = element.Numero ?? string.Empty;
|
||||
_focusNumero = true;
|
||||
}
|
||||
|
||||
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é.
|
||||
/// </remarks>
|
||||
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)
|
||||
{
|
||||
@@ -1522,10 +1535,9 @@ else
|
||||
await EcrireAsync(() => Api.ModifierSerieAsync(
|
||||
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)
|
||||
{
|
||||
await ChargerAsync();
|
||||
Navigation.NavigateTo($"series/{Id}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1180,41 +1180,6 @@ body {
|
||||
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,
|
||||
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
|
||||
@@ -1263,7 +1228,11 @@ body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
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 --- */
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
</div>
|
||||
<script src="_framework/blazor.webassembly.js"></script>
|
||||
<!-- 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
|
||||
l'avaler, et propose la mise à jour quand une nouvelle version est prête. -->
|
||||
navigator.serviceWorker (absent hors contexte sécurisé), et journalise un échec au lieu
|
||||
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>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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`
|
||||
// et `dotnet.js` portent donc des noms stables, et c'est le service worker — lui seul — qui
|
||||
// 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).
|
||||
// Ce qui reste, et pourquoi :
|
||||
// 1. `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
|
||||
// défaut de la PWA alors que c'est le contexte qui n'est pas éligible.
|
||||
//
|
||||
// ⚠️ Le clic n'est plus obligatoire (décidé le 2026-08-20, après un blocage constaté sur
|
||||
// l'application installée sur téléphone). CLAUDE.md actait « jamais tout seul » pour ne pas
|
||||
// recharger sous une saisie en cours ; la raison reste valable, mais elle ne vaut que pour une
|
||||
// session déjà entamée. Voir FENETRE_DEMARRAGE ci-dessous.
|
||||
// 2. 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.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -28,121 +30,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Fenêtre pendant laquelle une nouvelle version s'applique TOUTE SEULE, sans rien demander.
|
||||
// Au-delà, l'utilisateur est en train de faire quelque chose (une fiche à moitié saisie, un
|
||||
// prêt en cours d'enregistrement) : on repasse par le bandeau, pour ne pas lui arracher son
|
||||
// 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.
|
||||
// updateViaCache: 'none' — sans lien avec le comportement d'application (ci-dessus) : ceci
|
||||
// évite seulement qu'un cache HTTP intermédiaire masque une nouvelle version du script lui-
|
||||
// même, y compris service-worker-assets.js qu'il importe.
|
||||
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) {
|
||||
// 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.
|
||||
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('fetch', event => event.respondWith(onFetch(event)));
|
||||
|
||||
// Mise à jour à la demande. Sans cela, un nouveau worker attend que TOUS les onglets de
|
||||
// l'application soient fermés — un onglet oublié fige indéfiniment l'utilisateur sur l'ancienne
|
||||
// version. C'est d'autant plus important ici que les empreintes des assets WASM sont désactivées
|
||||
// (voir CLAUDE.md) : ce worker est le SEUL mécanisme de cache-busting du projet.
|
||||
// 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();
|
||||
});
|
||||
// Comportement par défaut : pas de SKIP_WAITING. Un nouveau worker attend que TOUS les onglets
|
||||
// de l'application soient fermés avant de prendre la main — la mise à jour a lieu au prochain
|
||||
// lancement, jamais au milieu d'une session. Revenu en arrière le 2026-09-10 après un mécanisme
|
||||
// de mise à jour immédiate qui ne se montrait pas fiable en pratique (voir js/mise-a-jour.js).
|
||||
|
||||
const cacheNamePrefix = 'offline-cache-';
|
||||
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
|
||||
@@ -46,10 +41,9 @@ async function onActivate(event) {
|
||||
.filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
|
||||
.map(key => caches.delete(key)));
|
||||
|
||||
// Prendre la main sur les pages déjà ouvertes. skipWaiting() est censé le faire seul, mais
|
||||
// c'est ce qui déclenche `controllerchange`, donc le rechargement côté page : sans reprise
|
||||
// effective, le bouton « Mettre à jour » se grise et il ne se passe plus rien — symptôme
|
||||
// observé sur téléphone. Cet appel est sans effet quand la reprise a déjà eu lieu.
|
||||
// Sans effet sur les onglets déjà ouverts avant cette activation (ils ont fermé pour qu'elle
|
||||
// ait lieu) ; utile seulement pour la toute première installation, où aucun worker ne
|
||||
// contrôlait encore la page.
|
||||
await self.clients.claim();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user