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>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user