@using MaBibli.Client.Services @inject CacheHorsLigne Cache @inject EtatReseau Reseau @implements IAsyncDisposable @* Couverture d'un livre, avec substitut. Les URL de couverture OpenLibrary renvoient des 502 intermittents (mesuré en phase 2 : 200, 200, puis 502 sur le même ISBN). On ne valide donc JAMAIS l'URL par un appel préalable : on l'affiche, et si l'image casse, l'attribut onerror la masque et laisse apparaître le substitut qui se trouve déjà dessous. Aucun aller-retour serveur, aucun état C# à gérer. @key sur l'img : si l'URL change, Blazor recrée l'élément au lieu de réutiliser celui que le onerror a marqué comme cassé. Agrandissement (CLAUDE.md) : seulement quand il y a une URL. Sans couverture, il n'y a rien à agrandir - le substitut à initiale reste un simple décor, jamais un bouton. ⚠️ Image cassée après coup (404/502 intermittent d'OpenLibrary) : le onerror désactive aussi le bouton déclencheur (this.closest('.couverture-declencheur').disabled = true), pas seulement l'image. Sans ça, cliquer une vignette cassée agrandissait quand même une image cassée (A4, CLAUDE.md). Cache des couvertures : le cache IndexedDB est consulté D'ABORD, en ligne comme hors ligne. Une image déjà rangée sur l'appareil s'affiche sans aucune requête réseau — c'est ce qui rend un rechargement instantané sur téléphone, là où covers.openlibrary.org impose deux redirections avant d'aboutir sur archive.org. Le réseau n'est sollicité QUE si rien n'est en cache, et la mise en cache démarre alors en tâche de fond. ⚠️ Ceci REMPLACE la règle d'origine (A5) « en ligne, rien ne doit ralentir la voie rapide », qui réservait le cache au hors-ligne. La lecture IndexedDB précède désormais l'affichage : elle est locale et sans réseau, donc largement plus rapide que ce qu'elle remplace. ⚠️ Corollaire : l'URL d'objet doit être LIBÉRÉE quand le composant disparaît ou change d'image (Dispose / changement d'URL). Un blob: retient son Blob en mémoire jusque-là, et il y a maintenant une vignette par carte sur tous les écrans. ⚠️ L'allègement (redimensionner, convertir en WebP) n'est PAS fait ici : à mesurer avant de s'y engager, comme pour l'AOT — voir CLAUDE.md. *@
@if (ADesPixels) { @* ⚠️ L'image cassée est désormais un état C# (`_cassee`), posé par @onerror, et non plus une classe ajoutée par du JavaScript en ligne. C'est ce qui permet d'en faire quelque chose : proposer de corriger l'URL, là où l'on constate qu'elle est morte. `onerror` ne remonte pas dans le DOM, mais Blazor le connaît comme événement non propagatif et pose son écouteur sur l'élément même : @onerror fonctionne bien sur une . *@ @if (Corrigeable) { } else if (Agrandissable && !_cassee) { @* Un vrai } else { Couverture de @Titre } } else if (OnCorriger.HasDelegate) { @* ⚠️ Pas de couverture DU TOUT, et pourtant cliquable : c'est le seul écart à la règle « le substitut à initiale n'est jamais cliquable ». Elle valait tant que le clic servait à AGRANDIR — il n'y avait rien à agrandir. Ici il sert à en POSER une, et c'est précisément le livre qui n'en a pas qui en a besoin. Ailleurs (cartes du catalogue, séries, envies), aucune correction n'est offerte : le paramètre n'est pas passé, et le substitut y reste un décor inerte. *@ }
@if (_agrandie) { @* Fermeture au clic extérieur (le clic sur l'image ne remonte pas) et à Échap. Le tabindex rend le calque focusable : sans focus, aucun keydown ne lui parviendrait. *@ } @code { [Parameter] public string? Url { get; set; } [Parameter] public string? Titre { get; set; } [Parameter] public string? Classe { get; set; } /// Le clic sur l'image l'affiche en grand. Sans effet s'il n'y a pas de couverture. [Parameter] public bool Agrandissable { get; set; } /// /// Que faire quand l'image manque ou ne se charge pas — ouvrir le champ de son adresse. /// /// /// ⚠️ Renseigné, il remplace l'agrandissement sur une image cassée, et rend cliquable /// le substitut à initiale d'un livre sans couverture. Agrandir une image qui ne /// s'affiche pas n'a jamais servi à rien ; en poser une là où l'on constate qu'elle manque /// est exactement ce qu'on veut faire à cet instant. /// [Parameter] public EventCallback OnCorriger { get; set; } /// L'image a échoué à se charger : 404 de l'hébergeur, adresse morte, 502 passager. private bool _cassee; private bool Corrigeable => _cassee && OnCorriger.HasDelegate; private bool _agrandie; private bool _aFocaliser; private ElementReference _calque; private string? _urlCachee; private (string? Url, bool EnLigne)? _urlResoluePour; private bool ADesPixels => !string.IsNullOrWhiteSpace(Url); /// /// URL réellement posée en src : le blob mis en cache dès qu'il existe — en ligne /// comme hors ligne —, sinon l'URL réseau telle quelle (qui échoue simplement hors-ligne, /// exactement comme avant cette fonctionnalité). /// private string? UrlAffichee => _urlCachee ?? Url; private string Initiale => string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant(); private void Ouvrir() { _agrandie = true; _aFocaliser = true; } private void Fermer() => _agrandie = false; private void SurToucheAsync(KeyboardEventArgs e) { if (e.Key is "Escape" or "Esc") { Fermer(); } } protected override async Task OnParametersSetAsync() { // ⚠️ Le jeton compare aussi l'état réseau, pas seulement l'URL : sans quoi, passer // en ligne → hors ligne sans que l'URL change (le cas le plus courant) ne relirait // jamais le cache, et la couverture resterait invisible hors-ligne. var jeton = (Url, Reseau.EnLigne); if (!ADesPixels || _urlResoluePour == jeton) { return; } _urlResoluePour = jeton; // Nouvelle image : ce qu'on savait de la précédente ne dit plus rien de celle-ci. _cassee = false; await LibererAsync(); // Le cache d'abord, EN LIGNE COMME HORS LIGNE : une image déjà sur l'appareil s'affiche // sans requête, sans redirection, et sans réveiller la radio du téléphone. _urlCachee = await Cache.LireCouvertureCacheeAsync(Url!); if (_urlCachee is null && Reseau.EnLigne) { // Rien en cache : le réseau sert l'image (UrlAffichee retombe sur Url), et la mise // en cache démarre en tâche de fond pour que le prochain chargement soit local. // ⚠️ Jamais attendue : elle ne doit pas retarder un affichage qui va avoir lieu. _ = Cache.MettreEnCacheCouvertureAsync(Url!); } StateHasChanged(); } /// Révoque l'URL d'objet en cours, s'il y en a une. private async Task LibererAsync() { if (_urlCachee is null) { return; } var aLiberer = _urlCachee; _urlCachee = null; await Cache.LibererCouvertureAsync(aLiberer); } public async ValueTask DisposeAsync() => await LibererAsync(); protected override async Task OnAfterRenderAsync(bool premierRendu) { if (_aFocaliser) { _aFocaliser = false; await _calque.FocusAsync(); } } }