Files
mabibli/MaBibli.Client/Composants/Couverture.razor
T
mathieu 42dec0dbd1 - Catalogage rapide (douchette, code-barres)
- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne.
- Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais.
Séries et sagas
- Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles).
- Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un.
- Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence.
- Filtre catalogue « sans couverture » pour repérer les livres à illustrer.
Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total).
- Corrections directes sur la fiche
- Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ».
- Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification.
- Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe.
- La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper.

- Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
2026-09-09 00:05:26 +02:00

238 lines
10 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)
{
@* ⚠️ 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 <img>. *@
@if (Corrigeable)
{
<button type="button" class="couverture-declencheur declencheur-correction"
aria-label="@($"Corriger l'adresse de la couverture de {Titre}")"
title="Cette image ne se charge pas — cliquez pour corriger son adresse"
@onclick="OnCorriger">
<img @key="UrlAffichee" class="couverture-image image-cassee"
src="@UrlAffichee" alt="Couverture de @Titre" loading="lazy" />
</button>
}
else if (Agrandissable && !_cassee)
{
@* 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="() => _cassee = true" />
</button>
}
else
{
<img @key="UrlAffichee"
class="couverture-image @(_cassee ? "image-cassee" : null)"
src="@UrlAffichee"
alt="Couverture de @Titre"
loading="lazy"
@onerror="() => _cassee = true" />
}
}
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.
*@
<button type="button" class="couverture-declencheur declencheur-correction"
aria-label="@($"Ajouter une couverture à {Titre}")"
title="Aucune couverture — cliquez pour en indiquer l'adresse"
@onclick="OnCorriger"></button>
}
</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; }
/// <summary>
/// Que faire quand l'image manque ou ne se charge pas — ouvrir le champ de son adresse.
/// </summary>
/// <remarks>
/// ⚠️ Renseigné, il <b>remplace</b> l'agrandissement sur une image cassée, et rend cliquable
/// le substitut à initiale d'un livre <b>sans</b> 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.
/// </remarks>
[Parameter] public EventCallback OnCorriger { get; set; }
/// <summary>L'image a échoué à se charger : 404 de l'hébergeur, adresse morte, 502 passager.</summary>
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);
/// <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;
// 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();
}
/// <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();
}
}
}