Files
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

186 lines
7.2 KiB
Plaintext

@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.
*@
<div class="couverture @Classe">
<span class="couverture-substitut" aria-hidden="true">@Initiale</span>
@if (ADesPixels)
{
@if (Agrandissable)
{
@* Un vrai <button> et pas une <div> cliquable : cible tactile, focus clavier,
et activation à l'Entrée sans une ligne de code. *@
<button type="button" class="couverture-declencheur"
aria-label="Agrandir la couverture de @Titre"
@onclick="Ouvrir">
<img @key="UrlAffichee"
class="couverture-image"
src="@UrlAffichee"
alt="Couverture de @Titre"
loading="lazy"
onerror="this.classList.add('image-cassee'); const b=this.closest('.couverture-declencheur'); if(b){b.disabled=true;}" />
</button>
}
else
{
<img @key="UrlAffichee"
class="couverture-image"
src="@UrlAffichee"
alt="Couverture de @Titre"
loading="lazy"
onerror="this.classList.add('image-cassee')" />
}
}
</div>
@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. *@
<div class="calque-couverture" role="dialog" aria-modal="true"
aria-label="Couverture de @Titre"
tabindex="-1" @ref="_calque"
@onclick="Fermer" @onkeydown="SurToucheAsync">
<img class="calque-image" src="@UrlAffichee" alt="Couverture de @Titre"
@onclick:stopPropagation="true"
onerror="this.classList.add('image-cassee')" />
<button type="button" class="calque-fermer" aria-label="Fermer">✕</button>
</div>
}
@code {
[Parameter] public string? Url { get; set; }
[Parameter] public string? Titre { get; set; }
[Parameter] public string? Classe { get; set; }
/// <summary>Le clic sur l'image l'affiche en grand. Sans effet s'il n'y a pas de couverture.</summary>
[Parameter] public bool Agrandissable { get; set; }
private bool _agrandie;
private bool _aFocaliser;
private ElementReference _calque;
private string? _urlCachee;
private (string? Url, bool EnLigne)? _urlResoluePour;
private bool ADesPixels => !string.IsNullOrWhiteSpace(Url);
/// <summary>
/// URL réellement posée en <c>src</c> : 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é).
/// </summary>
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;
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();
}
/// <summary>Révoque l'URL d'objet en cours, s'il y en a une.</summary>
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();
}
}
}