6 Commits
Author SHA1 Message Date
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
mathieu 47d371fa3a On replie par defaut les série dans le catalogue.
Un bouton permet de déplier/replier toutes les séries d'un coup.
2026-09-10 01:39:17 +02:00
mathieu 61d4fb1678 Ajout d'un moyen de modifier par lot les ouvrages dans les séries. 2026-09-10 01:08:57 +02:00
mathieu c5b4e117ba Ajout de l'url des envies directement dans la modification des ouvrages. 2026-09-09 21:47:03 +02:00
mathieuandClaude Sonnet 5 d76fb10ee0 Un liste d'ouvrage rafale est accessible depuis le menu
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 19:16:33 +02:00
15 changed files with 950 additions and 212 deletions
@@ -214,13 +214,17 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
souhait.Isbn = isbn;
souhait.Note = Vide(saisie.Note);
// ⚠️ La couverture n'est écrite QUE si la charge utile en porte une, contrairement aux
// autres champs. Raison : aucun écran n'offre de champ « URL de couverture » pour une
// envie — décision actée, un tel champ inviterait à coller des liens morts. Un
// remplacement inconditionnel effacerait donc, à chaque correction de titre, l'image
// que la bibliographie avait fournie. Il n'y a rien à « vider » puisqu'il n'y a rien à
// saisir, et la lecture retombe de toute façon sur la formule OpenLibrary à ISBN connu.
souhait.CoverUrl = Vide(saisie.CoverUrl) ?? souhait.CoverUrl;
// ⚠️ Écrite SEULEMENT quand le geste de correction a été utilisé (CoverUrlModifiee) :
// « Modifier une envie » enregistre aussi le titre, l'ISBN, etc. sans qu'on ait touché à
// la couverture, et cette valeur affichée peut être une DÉDUCTION à la lecture (formule
// OpenLibrary à ISBN connu, voir Projeter) plutôt que ce qu'il y a réellement en base.
// La matérialiser à chaque enregistrement figerait cette déduction, et une correction
// d'ISBN qui suit ne la referait plus jouer. Le drapeau distingue « je n'y ai pas
// touché » de « je viens de vider le champ », ce qu'un simple null ne pourrait pas dire.
if (saisie.CoverUrlModifiee)
{
souhait.CoverUrl = Vide(saisie.CoverUrl);
}
// ⚠️ Indispensable : TitreNormalise est la CLÉ D'ŒUVRE, celle qui sert au rapprochement
// « déjà au catalogue ». Sans ce recalcul, corriger un titre laisserait le rapprochement
@@ -0,0 +1,226 @@
@inject ServiceLivresApi Api
@*
Panneau de modification en lot, ouvert depuis la fiche série une fois des tomes cochés.
⚠️ Reprend le DESIGN de FormulaireLivre.razor (mêmes composants, même ordre de champs), mais
chaque champ est précédé d'une case « Modifier… » : sans elle, la valeur par défaut d'un champ
(« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les livres
sélectionnés écraserait un champ qu'on ne voulait pas toucher (CLAUDE.md, invariant 10).
Un champ non coché ne s'affiche pas — c'est ce qui rend visible ce qui va être écrasé.
*@
<div class="formulaire">
<p class="message-avertissement" role="status">
Les champs cochés remplacent la valeur existante sur chacun des @NombreLivres livres
sélectionnés — pour les auteurs, une liste vide les retire tous.
</p>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierType" />
<span>Type</span>
</label>
</legend>
@if (Saisie.ModifierType)
{
<div class="segments">
@foreach (var type in Types)
{
<button type="button"
class="segment @Libelles.ClasseTypeDocument(type) @(Saisie.TypeDocument == type ? "segment-actif" : null)"
@onclick="() => Saisie.TypeDocument = type">
@Libelles.TypeDocument(type)
</button>
}
</div>
}
</fieldset>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierThemes" />
<span>Thèmes</span>
</label>
@if (Saisie.ModifierThemes)
{
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà dans la liste."
Suggestions="_themesConnus"
OnChangement="StateHasChanged" />
}
</div>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierAuteurs" />
<span>Auteurs</span>
</label>
@if (Saisie.ModifierAuteurs)
{
<SaisieListe Valeurs="Noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="Prénom Nom, ou Nom, Prénom"
MessageDoublon="Cet auteur est déjà dans la liste."
OnChangement="StateHasChanged">
<Complement Context="index">
@if (Saisie.Auteurs.Count > 1)
{
<div class="segments segments-role" role="group"
aria-label="@($"Rôle de {Saisie.Auteurs[index].Nom}")">
@foreach (var role in Roles)
{
<button type="button"
class="segment @(Saisie.Auteurs[index].Role == role ? "segment-actif" : null)"
@onclick="() => PoserRole(index, role)">
@Libelles.RoleAuteur(role)
</button>
}
</div>
}
</Complement>
</SaisieListe>
}
</div>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierFormat" />
<span>Format</span>
</label>
</legend>
@if (Saisie.ModifierFormat)
{
<div class="segments">
@foreach (var format in Formats)
{
<button type="button"
class="segment @(Saisie.Format == format ? "segment-actif" : null)"
@onclick="() => Saisie.Format = format">
@Libelles.Format(format)
</button>
}
</div>
}
</fieldset>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierEditeur" />
<span>Éditeur</span>
</label>
@if (Saisie.ModifierEditeur)
{
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
autocomplete="off" />
}
</div>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierStatut" />
<span>Statut de lecture <span class="champ-aide">(le vôtre)</span></span>
</label>
</legend>
@if (Saisie.ModifierStatut)
{
<ChoixStatut Valeur="Saisie.Statut" ValeurChanged="s => Saisie.Statut = s" />
}
</fieldset>
@if (!string.IsNullOrEmpty(Erreur))
{
<p class="message-erreur" role="alert">@Erreur</p>
}
@if (!string.IsNullOrEmpty(MessageBlocage))
{
<p class="message-avertissement" role="status">@MessageBlocage</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(EnCours || Bloque || Saisie.AucunChamp)"
title="@(Saisie.AucunChamp ? "Cochez au moins un champ à modifier." : MessageBlocage)"
@onclick="Valider">
@(EnCours ? "Enregistrement…" : LibelleValidation)
</button>
@if (OnAnnuler.HasDelegate)
{
<button type="button" class="bouton bouton-discret" disabled="@EnCours"
@onclick="OnAnnuler">
Annuler
</button>
}
</div>
</div>
@code {
private static readonly Format[] Formats = Enum.GetValues<Format>();
private static readonly TypeDocument[] Types = Enum.GetValues<TypeDocument>();
private static readonly RoleAuteur[] Roles = Enum.GetValues<RoleAuteur>();
private void PoserRole(int index, RoleAuteur role) =>
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role };
/// <summary>Voir la remarque équivalente sur FormulaireLivre.razor : référence stable tant
/// que la saisie ne change pas, sans quoi SaisieListe se remet à zéro à chaque frappe.</summary>
private NomsAuteurs? _noms;
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
private SaisieLot? _saisiePrecedente;
private IReadOnlyList<string> _themesConnus = [];
protected override async Task OnInitializedAsync()
{
try
{
_themesConnus =
[
.. (await Api.ListerAsync(new CritereLivres()))
.SelectMany(l => l.Themes)
.DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
.OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal),
];
}
catch (Exception)
{
_themesConnus = [];
}
}
protected override void OnParametersSet()
{
if (!ReferenceEquals(_saisiePrecedente, Saisie))
{
_saisiePrecedente = Saisie;
_noms = null;
}
}
[Parameter, EditorRequired] public SaisieLot Saisie { get; set; } = new();
[Parameter] public int NombreLivres { get; set; }
[Parameter] public string LibelleValidation { get; set; } = "Appliquer";
[Parameter] public string? Erreur { get; set; }
[Parameter] public bool EnCours { get; set; }
[Parameter] public string? MessageBlocage { get; set; }
private bool Bloque => !string.IsNullOrEmpty(MessageBlocage);
[Parameter] public EventCallback OnValider { get; set; }
[Parameter] public EventCallback OnAnnuler { get; set; }
private Task Valider() => OnValider.InvokeAsync();
}
+44 -16
View File
@@ -4,9 +4,9 @@
Un bloc de série au catalogue, replié ou déplié — et récursif, parce qu'un cycle est une
série de séries : chaque niveau porte sa propre bascule.
⚠️ Tout est PLIÉ par défaut, et l'on replie ce dont on ne veut pas. C'est la règle déjà
tenue par l'arbre des séries : l'inverse obligerait à ouvrir chaque bloc pour retrouver un
livre qu'on voyait très bien avant.
⚠️ Tout est REPLIÉ par défaut ici (2026-09-10), à l'inverse de l'arbre des séries : le
catalogue liste D'ABORD les livres, une série n'y est qu'un détail qu'on ouvre au besoin —
alors que l'écran des séries EST la liste des séries, où les masquer toutes viderait l'écran.
⚠️ Replié, le bloc annonce toujours ce qu'il contient : sans ce compte, le compteur du haut
du catalogue paraîtrait mentir.
@@ -43,7 +43,8 @@
<ul class="liste-livres">
@foreach (var sous in Entree.SousEntrees)
{
<GroupeCatalogue Entree="sous" Replies="Replies" Totaux="Totaux" />
<GroupeCatalogue Entree="sous" Ouverts="Ouverts" Totaux="Totaux"
LotActif="LotActif" SelectionLot="SelectionLot" OnSelectionner="OnSelectionner" />
}
</ul>
}
@@ -53,9 +54,24 @@
<ul class="liste-livres">
@foreach (var livre in Entree.Livres)
{
<li @key="livre.Id">
<CarteLivre Livre="livre" />
</li>
@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>
}
@@ -66,15 +82,14 @@
[Parameter, EditorRequired] public EntreeCatalogue Entree { get; set; } = default!;
/// <summary>
/// Blocs REPLIÉS, par identifiant de série — et non pliés : le défaut est de tout montrer.
/// Blocs OUVERTS, par identifiant de série — et non repliés : le défaut est de tout replier.
/// </summary>
/// <remarks>
/// État local de l'écran, volontairement non persistant, comme celui de l'arbre des séries :
/// c'est un confort de lecture, pas une propriété de la série. Il survit en revanche aux
/// rechargements du catalogue (frappe dans la recherche, filtre), le jeu appartenant à
/// l'écran et non au bloc.
/// État local de l'écran, volontairement non persistant : c'est un confort de lecture, pas
/// une propriété de la série. Il survit en revanche aux rechargements du catalogue (frappe
/// dans la recherche, filtre), le jeu appartenant à l'écran et non au bloc.
/// </remarks>
[Parameter, EditorRequired] public ISet<int> Replies { get; set; } = new HashSet<int>();
[Parameter, EditorRequired] public ISet<int> Ouverts { get; set; } = new HashSet<int>();
/// <summary>
/// Compteurs par série, descendance comprise : ce qu'on possède, et ce que la série recense.
@@ -86,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
@@ -95,13 +123,13 @@
private int Total => Compte.Total;
private bool Deplie => !Replies.Contains(Entree.Serie!.Id);
private bool Deplie => Ouverts.Contains(Entree.Serie!.Id);
private void Basculer()
{
if (!Replies.Add(Entree.Serie!.Id))
if (!Ouverts.Add(Entree.Serie!.Id))
{
Replies.Remove(Entree.Serie!.Id);
Ouverts.Remove(Entree.Serie!.Id);
}
}
}
+49
View File
@@ -0,0 +1,49 @@
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
namespace MaBibli.Client.Composants;
/// <summary>
/// Saisie du panneau « Modifier en lot » (fiche série).
/// </summary>
/// <remarks>
/// ⚠️ Chaque champ porte sa propre case <c>Modifier…</c> : sans elle, la valeur par défaut d'un
/// champ (« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les
/// livres sélectionnés écraserait un champ qu'on ne voulait pas toucher. Seuls les champs cochés
/// sont envoyés ; les autres livres restent inchangés sur les champs non cochés (CLAUDE.md,
/// invariant 10 : on n'affiche — et n'applique — que ce qui a été choisi).
/// </remarks>
public sealed class SaisieLot
{
public bool ModifierType { get; set; }
public TypeDocument TypeDocument { get; set; }
public bool ModifierThemes { get; set; }
public List<string> Themes { get; set; } = [];
public bool ModifierAuteurs { get; set; }
public List<AuteurSaisi> Auteurs { get; set; } = [];
public bool ModifierFormat { get; set; }
public Format Format { get; set; }
public bool ModifierEditeur { get; set; }
public string? Editeur { get; set; }
/// <summary>
/// Statut de lecture <b>de l'appelant</b>, comme partout ailleurs : appliquer un statut en
/// lot pose la lecture de la personne qui agit, jamais celle d'un tiers.
/// </summary>
public bool ModifierStatut { get; set; }
public Statut? Statut { get; set; }
public bool AucunChamp =>
!ModifierType && !ModifierThemes && !ModifierAuteurs
&& !ModifierFormat && !ModifierEditeur && !ModifierStatut;
}
+29 -4
View File
@@ -3,6 +3,7 @@
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@inject CacheHorsLigne Cache
@*
Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
@@ -93,6 +94,16 @@
</div>
}
@*
Une rafale non vidée reste consultable ailleurs sur le site — sans quoi y revenir
depuis un autre écran demande de refaire tout le chemin d'« Ajouter ». L'entrée
disparaît d'elle-même une fois la file effacée (bouton « Nouvelle rafale »).
*@
@if (_rafale is { Total: > 0 })
{
<NavLink class="menu-lien menu-lien-rafale" href="ajout/rafale">@LibelleRafale</NavLink>
}
<NavLink class="menu-lien" href="" Match="NavLinkMatch.All">Catalogue</NavLink>
<NavLink class="menu-lien" href="auteurs">Auteurs</NavLink>
<NavLink class="menu-lien" href="series">Séries</NavLink>
@@ -148,6 +159,12 @@
private ElementReference _menu;
/// <summary>Rafale non vidée, s'il y en a une. Relue à chaque navigation (lot AB).</summary>
private Rafale? _rafale;
private string LibelleRafale =>
_rafale is { Crees: > 0 } r ? $"Compte rendu ({r.Crees})" : "Rafale à reprendre";
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurChangementReseau;
@@ -158,6 +175,8 @@
// entrées empilées au-dessus de la liste des auteurs.
Navigation.LocationChanged += SurNavigation;
_rafale = await Cache.LireRafaleAsync();
// Écoute des bascules online/offline avant tout appel : un démarrage hors-ligne doit
// aller directement au cache, sans attendre l'échec d'une requête.
await Reseau.DemarrerAsync();
@@ -226,13 +245,19 @@
private void SurNavigation(object? _, LocationChangedEventArgs __)
{
if (!_menuOuvert)
if (_menuOuvert)
{
return;
_menuOuvert = false;
}
_menuOuvert = false;
_ = InvokeAsync(StateHasChanged);
// ⚠️ Relu à CHAQUE navigation, pas seulement au quitter de l'écran de rafale : c'est ce
// qui fait apparaître l'entrée du menu dès le premier code scanné, et disparaître dès
// la file effacée, où qu'on soit rendu entre-temps.
_ = InvokeAsync(async () =>
{
_rafale = await Cache.LireRafaleAsync();
StateHasChanged();
});
}
/// <summary>
+52
View File
@@ -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();
+233 -5
View File
@@ -32,6 +32,28 @@
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
</button>
@if (_grouper && Entrees.Any(e => e.EstGroupe))
{
<button type="button" class="bouton bouton-discret bouton-compact"
@onclick="() => { if (AuMoinsUneSerieOuverte) { ToutReplier(); } else { ToutDeplier(); } }">
@(AuMoinsUneSerieOuverte ? "Replier les séries" : "Déplier les séries")
</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>
@@ -206,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 })
{
@@ -218,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
@@ -228,13 +291,32 @@ else if (_livres is not null)
tomes occupait tout l'écran alors qu'on cherchait un livre rangé après lui. Replier ne
retire rien de la liste — le compteur du haut ne bouge pas, et le bloc replié dit
toujours ce qu'il contient.
⚠️ Ici, contrairement à l'arbre des séries, tout est REPLIÉ par défaut (2026-09-10) :
le catalogue liste des livres, pas des séries — une série n'y est qu'un regroupement
qu'on ouvre au besoin. Le bouton qui bascule tout l'arbre est dans l'en-tête, à côté
de « Filtrer », pas ici : c'est un réglage d'affichage, pas une action sur la liste.
*@
<ul class="liste-livres">
@foreach (var entree in Entrees)
{
@if (entree.EstGroupe)
{
<GroupeCatalogue Entree="entree" Replies="_replies" 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
{
@@ -269,15 +351,45 @@ else if (_livres is not null)
private bool _grouper = true;
/// <summary>
/// Blocs repliés à la main, par identifiant de série. Tout est plié par défaut.
/// Blocs ouverts à la main, par identifiant de série. Tout est replié par défaut.
/// </summary>
/// <remarks>
/// ⚠️ Le jeu appartient à l'ÉCRAN, et non aux blocs : le catalogue se relit à chaque frappe
/// dans la recherche, et un état porté par les composants se perdrait à chaque caractère.
/// Il n'est pas persistant d'une visite à l'autre pour autant — c'est un confort de
/// lecture, pas une propriété de la série, même règle que l'arbre des séries.
/// lecture, pas une propriété de la série.
/// </remarks>
private readonly HashSet<int> _replies = [];
private readonly HashSet<int> _ouverts = [];
private bool AuMoinsUneSerieOuverte => _ouverts.Count > 0;
private void ToutReplier() => _ouverts.Clear();
private void ToutDeplier()
{
foreach (var id in IdentifiantsSeries(Entrees))
{
_ouverts.Add(id);
}
}
private static IEnumerable<int> IdentifiantsSeries(IEnumerable<EntreeCatalogue> entrees)
{
foreach (var entree in entrees)
{
if (!entree.EstGroupe)
{
continue;
}
yield return entree.Serie!.Id;
foreach (var id in IdentifiantsSeries(entree.SousEntrees))
{
yield return id;
}
}
}
/// <summary>
/// Ce que chaque série possède et recense, descendance comprise.
@@ -305,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>
@@ -563,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;
+36 -3
View File
@@ -41,6 +41,16 @@ else if (_saisie is null)
else
{
<div class="formulaire">
@* Même ligne que dans le formulaire d'un ouvrage (FormulaireLivre) : un aperçu quand il y
a quelque chose à montrer, puis un champ d'adresse ordinaire, au même titre que les
autres. *@
@if (!string.IsNullOrEmpty(_saisie.CoverUrl) || !string.IsNullOrWhiteSpace(_saisie.Titre))
{
<div class="formulaire-apercu">
<Couverture Url="@_saisie.CoverUrl" Titre="@_saisie.Titre" Classe="couverture-moyenne" />
</div>
}
<label class="champ">
<span class="champ-libelle">Titre <span class="obligatoire" aria-hidden="true">*</span></span>
<input class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput"
@@ -73,6 +83,12 @@ else
@bind="_saisie.Isbn" @bind:event="oninput" placeholder="978…" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">URL de couverture</span>
<input class="champ-saisie" type="url" @bind="_saisie.CoverUrl" @bind:event="oninput"
placeholder="https://…" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Note</span>
<input class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
@@ -104,6 +120,13 @@ else
private bool _enCours;
private string? _erreur;
/// <summary>
/// La couverture telle que chargée — éventuellement déduite de l'ISBN (voir <c>Projeter</c>
/// côté serveur), pas forcément ce qu'il y a réellement en base. Sert uniquement à détecter,
/// à l'enregistrement, si l'utilisateur a touché au champ.
/// </summary>
private string? _couvertureChargee;
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurChangementReseau;
@@ -135,9 +158,8 @@ else
return;
}
// ⚠️ La couverture n'est PAS reprise ici : elle n'a pas de champ de saisie, et le
// service la conserve donc telle qu'elle est en base. La valeur portée par le DTO peut
// en outre être déduite de l'ISBN — la figer serait écrire en base ce qui se calcule.
_couvertureChargee = souhait.CoverUrl;
_saisie = new EnregistrementSouhait
{
Titre = souhait.Titre,
@@ -145,6 +167,7 @@ else
Editeur = souhait.Editeur,
Annee = souhait.Annee,
Isbn = souhait.Isbn,
CoverUrl = souhait.CoverUrl,
Note = souhait.Note,
};
}
@@ -156,6 +179,16 @@ else
return;
}
// ⚠️ Envoyée seulement si elle a changé depuis le chargement, et non à chaque
// enregistrement : la valeur affichée peut être une DÉDUCTION à la lecture (formule
// OpenLibrary à ISBN connu, voir `Projeter`) plutôt que ce qu'il y a réellement en base.
// La renvoyer telle quelle à chaque correction de titre la figerait en base, et une
// correction d'ISBN qui suit ne la referait plus jouer.
_saisie.CoverUrlModifiee = !string.Equals(
_saisie.CoverUrl?.Trim() ?? string.Empty,
_couvertureChargee?.Trim() ?? string.Empty,
StringComparison.Ordinal);
_enCours = true;
_erreur = null;
+180
View File
@@ -74,6 +74,20 @@ else
{
<a class="bouton bouton-compact bouton-discret" href="@($"series/{Id}/ordre")">Changer l'ordre</a>
}
@*
« Modifier en lot » n'apparaît qu'à partir de DEUX tomes possédés : sur un
seul, l'écran de la fiche fait aussi bien. Même seuil que « Changer l'ordre ».
*@
@if (TomesModifiablesEnLot > 1)
{
<button type="button" class="bouton bouton-compact bouton-discret"
aria-expanded="@Aria.Etat(_lotOuvert)" aria-controls="lot-tomes"
@onclick="BasculerLot">
Modifier en lot
</button>
}
<a class="bouton bouton-compact bouton-principal" href="@($"series/{Id}/edition")">Modifier</a>
}
else
@@ -332,6 +346,38 @@ else
}
</div>
@*
Modification en lot : on coche des tomes possédés, puis on choisit ce qu'on leur
applique. Même patron que la sélection de la bibliographie (barre en haut, collante),
sauf que l'action n'est pas un simple bouton mais un formulaire — il reste donc
affiché sous la barre plutôt que de se déclencher au clic.
*@
@if (LotActif)
{
<div id="lot-tomes">
@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="@_enCours" @onclick="() => _selectionLot.Clear()">
Tout décocher
</button>
</div>
<FormulaireLotLivres Saisie="_saisieLot" NombreLivres="_selectionLot.Count"
Erreur="@_erreur" EnCours="_enCours" MessageBlocage="@MotifBlocage"
OnValider="EnregistrerLotAsync" OnAnnuler="AnnulerLot" />
}
else
{
<p class="message-discret">Cochez les tomes à modifier ensemble.</p>
}
</div>
}
@*
⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se
lit avant le livre paru dix ans plus tôt. Les flèches sont le seul moyen de le
@@ -342,6 +388,17 @@ else
@foreach (var (element, index) in TomesAffiches.Select((e, i) => (e, i)))
{
<li class="carte-tome @(element.Possede ? null : "tome-manquant")" @key="element.Id">
@if (LotActif && element.LivreId is { } livreLot
&& _livresCatalogue.Any(l => l.Id == livreLot))
{
<label class="oeuvre-case">
<input type="checkbox"
checked="@_selectionLot.Contains(livreLot)"
@onchange="e => SelectionnerLot(livreLot, e.Value is true)" />
<span class="lecture-ecran">Sélectionner « @element.Titre »</span>
</label>
}
<div class="rang-actions">
@if (ModeOrdre)
{
@@ -641,6 +698,14 @@ else
private bool _enCours;
private string? _erreur;
/// <summary>Le panneau de modification en lot est déplié.</summary>
private bool _lotOuvert;
/// <summary>Identifiants des LIVRES (pas des places) cochés pour la modification en lot.</summary>
private readonly HashSet<int> _selectionLot = [];
private SaisieLot _saisieLot = new();
private string _titre = string.Empty;
private int _parenteId;
private bool _confirmeSuppression;
@@ -765,6 +830,11 @@ else
{
Ouvrir(VoieAjout.Aucune);
}
else
{
// Les deux panneaux se marcheraient dessus sur un petit écran : un seul à la fois.
_lotOuvert = false;
}
}
/// <summary>Vrai quand ce qui est tapé dans la saisie manuelle est un code et non un titre.</summary>
@@ -860,6 +930,116 @@ else
/// <summary>Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder.</summary>
private bool ModeConsultation => !ModeEdition && !ModeOrdre;
/// <summary>
/// Le panneau de lot n'a de sens qu'en consultation : la case à cocher se confondrait avec
/// les flèches de l'écran d'ordre, qui reste dédié au déplacement des tomes.
/// </summary>
private bool LotActif => _lotOuvert && ModeConsultation;
/// <summary>Tomes de la série effectivement rattachés à un livre du catalogue local.</summary>
private int TomesModifiablesEnLot =>
_serie?.Elements.Count(e => e.LivreId is { } id && _livresCatalogue.Any(l => l.Id == id)) ?? 0;
/// <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 <see cref="BasculerAjout"/> : un panneau qui se
/// rouvrirait sur l'état d'avant laisserait croire à une saisie encore active.
/// </summary>
private void BasculerLot()
{
_lotOuvert = !_lotOuvert;
_erreur = null;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
// Les deux panneaux se marcheraient dessus sur un petit écran : un seul à la fois.
if (_lotOuvert)
{
_ajoutOuvert = false;
Ouvrir(VoieAjout.Aucune);
}
}
private void SelectionnerLot(int livreId, bool selectionne)
{
if (selectionne)
{
_selectionLot.Add(livreId);
}
else
{
_selectionLot.Remove(livreId);
}
}
private void AnnulerLot()
{
_selectionLot.Clear();
_saisieLot = new SaisieLot();
_erreur = null;
}
/// <summary>
/// Applique la saisie en lot à chacun des livres cochés, un appel à la fois — même patron que
/// <c>MasquerSelectionAsync</c> de la bibliographie : 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()
{
_enCours = true;
_erreur = null;
var erreurs = new List<string>();
foreach (var livreId in _selectionLot)
{
var livre = _livresCatalogue.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!);
}
}
_enCours = false;
if (erreurs.Count > 0)
{
_erreur = string.Join(" ", erreurs.Distinct());
await ChargerAsync();
return;
}
_lotOuvert = false;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
_message = "Les livres sélectionnés ont été modifiés.";
await ChargerAsync();
}
private bool Suffixe(string fin) =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
.EndsWith(fin, StringComparison.OrdinalIgnoreCase);
+28 -36
View File
@@ -164,6 +164,11 @@ body {
.entete-fiche-actions {
flex: 0 0 auto;
/* Sans elle, un flex-basis « auto » prend la largeur de TOUS les boutons sur une seule
ligne (leur taille de contenu), et le flex-wrap ci-dessous ne peut jamais s'en servir :
la ligne déborde au lieu de se replier. Quatre boutons (dont « Modifier en lot ») y
tiennent tout juste à 1280px, mais débordent à 375px sans ce plafond. */
max-width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
@@ -1175,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' 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
@@ -1258,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 --- */
@@ -1664,6 +1638,20 @@ body {
font-size: 0.9rem;
}
/*
Le compte rendu de la dernière rafale (lot AB) : une entrée transitoire, présente
seulement tant qu'une file non vidée existe. Le même jaune que la pastille hors-ligne
la distingue des six destinations permanentes c'est une chose à finir, pas un lieu.
*/
.menu-lien-rafale {
border-left-color: #f5d76e;
font-weight: 600;
}
.menu-lien-rafale.active {
border-left-color: #f5d76e;
}
/*
À partir de 40rem la rangée tient sans se comprimer, et la bascule n'a plus
de raison d'être. Le point de rupture est répété dans `MainLayout.razor.css`
@@ -1705,6 +1693,10 @@ body {
border-bottom-color: #f5d76e;
}
.menu-lien-rafale {
border-bottom-color: #f5d76e;
}
/* En rangée, un filet au-dessus ne séparerait rien : le trait passe à GAUCHE, et la marge
supérieure disparaît sous peine de désaligner l'entrée du reste de la rangée. */
.menu-lien-annexe {
+2 -2
View File
@@ -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>
+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`
// 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();
}
+12
View File
@@ -65,6 +65,18 @@ public record EnregistrementSouhait
public string? CoverUrl { get; set; }
/// <summary>
/// Vrai seulement si l'utilisateur vient d'utiliser le geste de correction de la couverture.
/// </summary>
/// <remarks>
/// ⚠️ Sans ce signal, <see cref="CoverUrl"/> à <c>null</c> serait ambigu entre « je n'y ai
/// pas touché » (garder ce qu'il y a en base, y compris rien du tout, et laisser jouer la
/// formule OpenLibrary à ISBN connu) et « je viens de vider le champ » (retirer la
/// couverture pour de bon). Modifier le titre ou l'ISBN d'une envie ne doit jamais
/// matérialiser en base la couverture déduite qu'elle ne fait qu'afficher à la lecture.
/// </remarks>
public bool CoverUrlModifiee { get; set; }
public string? Note { get; set; }
}