- 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é.
This commit is contained in:
mathieu
2026-09-09 00:05:26 +02:00
parent bb071c3db9
commit 42dec0dbd1
43 changed files with 4154 additions and 189 deletions
+56 -4
View File
@@ -42,7 +42,24 @@
@if (ADesPixels)
{
@if (Agrandissable)
@* ⚠️ 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. *@
@@ -54,19 +71,35 @@
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;}" />
@onerror="() => _cassee = true" />
</button>
}
else
{
<img @key="UrlAffichee"
class="couverture-image"
class="couverture-image @(_cassee ? "image-cassee" : null)"
src="@UrlAffichee"
alt="Couverture de @Titre"
loading="lazy"
onerror="this.classList.add('image-cassee')" />
@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)
@@ -95,6 +128,22 @@
/// <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;
@@ -141,6 +190,9 @@
}
_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
@@ -1,3 +1,5 @@
@inject ServiceLivresApi Api
@*
Formulaire d'une fiche livre, partagé par l'ajout (ISBN ou manuel) et l'édition.
@@ -53,8 +55,14 @@
qui le suit rogné — le séparateur était le seul caractère qu'on ne pouvait pas taper.
Voir CLAUDE.md.
*@
@*
⚠️ Les thèmes déjà employés sont PROPOSÉS : un thème est un vocabulaire, il ne vaut que
réutilisé. « fantasy » et « Fantaisie » saisis séparément ne regroupent rien, et le
filtre par thème n'y verrait que deux étiquettes sans rapport.
*@
<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à sur ce livre."
Suggestions="_themesConnus"
OnChangement="StateHasChanged" />
@*
@@ -67,7 +75,7 @@
⚠️ Les rôles n'apparaissent qu'à partir de DEUX auteurs, décision inchangée : un roman
n'a qu'un signataire dont le rôle ne se pose pas. Le défaut reste « non précisé ».
*@
<SaisieListe Valeurs="_noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
<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à sur ce livre."
OnChangement="StateHasChanged">
@@ -205,18 +213,61 @@
/// <remarks>
/// ⚠️ Sa <b>référence doit rester stable</b> tant que la saisie ne change pas : le composant
/// se remet à zéro quand la liste qu'on lui prête change d'identité, et il perdrait donc à
/// chaque rendu ce qui est en cours de frappe.
/// chaque rendu ce qui est en cours de frappe. D'où la construction paresseuse, invalidée
/// par <c>OnParametersSet</c> et par elle seule.
/// <para>
/// ⚠️ Elle ne peut PAS être construite dans <c>OnParametersSet</c> uniquement : dès qu'un
/// composant a un <c>OnInitializedAsync</c> qui attend vraiment (ici la lecture des thèmes
/// connus), Blazor rend une première fois <b>avant</b> d'appeler <c>OnParametersSet</c>. Ce
/// rendu-là voyait un <c>null</c>, et l'enfant levait une <c>NullReferenceException</c> —
/// invisible à l'écran, qui finissait par s'afficher, mais bien présente en console.
/// </para>
/// </remarks>
private NomsAuteurs _noms = default!;
private NomsAuteurs? _noms;
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
private EnregistrementLivre? _saisiePrecedente;
/// <summary>
/// Les thèmes déjà employés dans la bibliothèque, proposés sous le champ.
/// </summary>
/// <remarks>
/// ⚠️ Déduits du <b>catalogue</b>, et non d'un point d'entrée « /api/themes » : la liste des
/// livres est déjà lue partout, elle porte les thèmes, et surtout elle a son instantané —
/// une nouvelle vue de l'API en exigerait un autre, faute de quoi les propositions
/// disparaîtraient hors-ligne. Le catalogue d'un foyer se charge entier, c'est déjà le parti
/// pris de la détection de doublons.
/// <para>
/// Une lecture ratée ne coûte rien : sans propositions, le champ redevient celui d'avant.
/// </para>
/// </remarks>
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 = new NomsAuteurs(Saisie.Auteurs);
_noms = null;
}
}
@@ -8,8 +8,13 @@
tenue par l'arbre des séries : l'inverse obligerait à ouvrir chaque bloc pour retrouver un
livre qu'on voyait très bien avant.
⚠️ Replié, le bloc annonce toujours ce qu'il contient (« 5 tomes ») : sans ce compte, le
compteur du haut du catalogue paraîtrait mentir.
⚠️ Replié, le bloc annonce toujours ce qu'il contient : sans ce compte, le compteur du haut
du catalogue paraîtrait mentir.
⚠️ Et ce compte distingue trois choses (2026-09-08) : ce qui est AFFICHÉ (filtre compris), ce
qu'on POSSÈDE de cette série, et ce qu'elle compte de tomes. Un seul nombre suivait le
filtre — « 3 tomes » devenait « 1 tome » sous une recherche, et se lisait « cette série n'a
qu'un tome ». Les égalités se replient : sans filtre et série complète, on relit « 3 tomes ».
*@
<li class="groupe-serie" @key="Entree.Serie!.Id">
<div class="groupe-serie-entete">
@@ -26,7 +31,7 @@
<a class="groupe-serie-titre" href="@($"series/{Entree.Serie!.Id}")">
<span class="groupe-serie-nom">@Entree.Serie!.Titre</span>
<span class="groupe-serie-compte">
@Entree.NombreLivres tome@(Entree.NombreLivres > 1 ? "s" : "")
@Libelles.CompteBloc(Entree.NombreLivres, Possedes, Total)
</span>
</a>
</div>
@@ -38,7 +43,7 @@
<ul class="liste-livres">
@foreach (var sous in Entree.SousEntrees)
{
<GroupeCatalogue Entree="sous" Replies="Replies" />
<GroupeCatalogue Entree="sous" Replies="Replies" Totaux="Totaux" />
}
</ul>
}
@@ -71,6 +76,25 @@
/// </remarks>
[Parameter, EditorRequired] public ISet<int> Replies { get; set; } = new HashSet<int>();
/// <summary>
/// Compteurs par série, descendance comprise : ce qu'on possède, et ce que la série recense.
/// </summary>
/// <remarks>
/// ⚠️ Calculés une fois par l'écran (<c>TotauxSeries</c>), et non ici : le catalogue se relit
/// à chaque frappe dans la recherche, et chaque bloc les recalculerait pour tout l'arbre.
/// C'est aussi ce qui garantit le <b>même</b> compte que la fiche de la série.
/// </remarks>
[Parameter] public IReadOnlyDictionary<int, (int Possedes, int Total)>? Totaux { get; set; }
private (int Possedes, int Total) Compte =>
Totaux is not null && Totaux.TryGetValue(Entree.Serie!.Id, out var connu)
? connu
: (Entree.NombreLivres, Entree.NombreLivres);
private int Possedes => Compte.Possedes;
private int Total => Compte.Total;
private bool Deplie => !Replies.Contains(Entree.Serie!.Id);
private void Basculer()
+35
View File
@@ -66,4 +66,39 @@ public static class Libelles
MaBibli.Shared.Entites.TypeDocument.BandeDessinee => "type-bd",
_ => string.Empty,
};
/// <summary>
/// Ce qu'annonce un bloc de série au catalogue : ce qui y est affiché, ce qu'on possède, et
/// ce que la série compte de tomes.
/// </summary>
/// <remarks>
/// ⚠️ <b>Le compte d'un bloc changeait avec le filtre</b> : « 3 tomes » devenait « 1 tome »
/// dès qu'une recherche écartait les autres, et se lisait alors comme « cette série n'a qu'un
/// tome ». Le nombre affiché ne disait pas de quoi il parlait.
/// <para>
/// Trois nombres seraient du bruit quand ils coïncident : les égalités se replient donc.
/// Un fonds complet et non filtré redit simplement « 3 tomes », comme avant.
/// </para>
/// </remarks>
/// <param name="affiches">Tomes de ce bloc actuellement à l'écran, filtre appliqué.</param>
/// <param name="possedes">Tomes de cette série présents au catalogue, descendance comprise.</param>
/// <param name="total">Tomes recensés dans la série, ceux qui manquent compris.</param>
public static string CompteBloc(int affiches, int possedes, int total)
{
var tomes = total > 1 ? "tomes" : "tome";
if (affiches == possedes && possedes == total)
{
return $"{total} {tomes}";
}
if (affiches == possedes)
{
return $"{possedes} sur {total} {tomes}";
}
return possedes == total
? $"{affiches} affiché{(affiches > 1 ? "s" : "")} sur {total} {tomes}"
: $"{affiches} affiché{(affiches > 1 ? "s" : "")} · {possedes} sur {total} {tomes}";
}
}
+82 -5
View File
@@ -69,8 +69,12 @@
à <strong>@pret.Emprunteur</strong>
depuis le @Jour(pret.DatePret)
<span class="pret-duree">(@Duree(pret.JoursEcoules))</span>
@Croix(pret)
</p>
@Confirmation(pret)
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal" disabled="@_occupe"
@onclick="RendreAsync">
@@ -127,11 +131,21 @@
@foreach (var passe in Historique)
{
<li @key="passe.Id">
<strong>@passe.Emprunteur</strong>
<span class="historique-dates">
du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
— @Duree(passe.JoursEcoules)
</span>
@* La croix reste SUR la ligne des dates : elle appartient à ce prêt, et
reléguée dessous elle se lisait comme une action de la liste entière. *@
<div class="historique-ligne">
<span class="historique-qui">
<strong>@passe.Emprunteur</strong>
<span class="historique-dates">
du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
— @Duree(passe.JoursEcoules)
</span>
</span>
@Croix(passe)
</div>
@Confirmation(passe)
</li>
}
</ul>
@@ -143,7 +157,44 @@
}
</section>
@*
⚠️ Effacer un prêt N'EST PAS le rendre, et les deux ne doivent jamais se confondre. Rendre
pose une date et garde la ligne : c'est l'histoire du livre, et la raison d'être de la table.
La croix défait une SAISIE — mauvais emprunteur, mauvaise date, prêt noté deux fois. Un
historique juste vaut mieux qu'un historique complet mais faux.
⚠️ Elle se confirme, et le mot le dit : « définitivement ». C'est la seule action de cet
écran qui détruit quelque chose, et elle est à côté de deux boutons qui n'en détruisent
aucun.
*@
@code {
/// <summary>La croix d'effacement d'une ligne de prêt.</summary>
private RenderFragment Croix(PretDto pret) =>
@<button type="button" class="bouton-croix" disabled="@_occupe"
title="Effacer ce prêt (saisie erronée)"
aria-label="@($"Effacer le prêt à {pret.Emprunteur}")"
@onclick="() => _aEffacer = pret.Id">✕</button>;
/// <summary>La demande de confirmation, sous la ligne visée seulement.</summary>
private RenderFragment Confirmation(PretDto pret) => @<text>
@if (_aEffacer == pret.Id)
{
<p class="message-avertissement" role="status">
Effacer définitivement ce prêt ? Il disparaîtra de l'historique du livre.
</p>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-danger" disabled="@_occupe"
@onclick="() => EffacerAsync(pret)">
@(_occupe ? "Suppression…" : "Oui, effacer")
</button>
<button type="button" class="bouton bouton-discret" disabled="@_occupe"
@onclick="() => _aEffacer = null">
Annuler
</button>
</div>
}
</text>;
[Parameter, EditorRequired] public int LivreId { get; set; }
/// <summary>Format du livre : un ebook ne se prête pas.</summary>
@@ -169,6 +220,9 @@
private string? _erreur;
private int _livreCharge;
/// <summary>Prêt dont l'effacement attend confirmation, s'il y en a un.</summary>
private int? _aEffacer;
private PretDto? EnCours => _prets?.FirstOrDefault(p => p.EstEnCours);
private IReadOnlyList<PretDto> Historique =>
@@ -279,6 +333,29 @@
await OnChangement.InvokeAsync();
}
private async Task EffacerAsync(PretDto pret)
{
_occupe = true;
_erreur = null;
var erreur = await Api.SupprimerPretAsync(pret.Id);
_occupe = false;
_aEffacer = null;
if (erreur is not null)
{
_erreur = erreur;
return;
}
await ChargerAsync();
// Effacer le prêt EN COURS remet le livre à la maison : la fiche porte cet état, elle
// doit donc se relire, comme après un retour.
await OnChangement.InvokeAsync();
}
private static string Jour(DateTime instant) =>
instant.ToLocalTime().ToString("dd/MM/yyyy");
@@ -22,6 +22,7 @@
<div class="ligne-creation">
<input id="@_idChamp" class="champ-saisie" type="text" @bind="_saisie"
@bind:event="oninput" @onkeydown="SurTouche"
@onfocus="() => _suggestionsOuvertes = true"
placeholder="@Placeholder" autocomplete="off" />
<button type="button" class="bouton bouton-compact"
@@ -43,6 +44,27 @@
<p class="message-discret" role="status">@_message</p>
}
@*
Ce qui existe déjà ailleurs, proposé dès le focus — un thème est un VOCABULAIRE, il ne
vaut que réutilisé : « fantasy » et « Fantaisie » saisis séparément ne regroupent rien.
⚠️ La liste ne se referme PAS au blur, et c'est délibéré : le blur précède le clic sur
une proposition, et la faire disparaître à ce moment-là rend les propositions
incliquables — le piège classique de l'autocomplétion. Elle se referme quand on a choisi.
*@
@if (Proposees.Count > 0)
{
<div class="suggestions">
@foreach (var suggestion in Proposees)
{
<button type="button" class="segment" @key="suggestion"
@onclick="() => Choisir(suggestion)">
@suggestion
</button>
}
</div>
}
@if (Valeurs.Count > 0)
{
<ul class="liste-saisie">
@@ -97,6 +119,16 @@
/// </summary>
[Parameter] public RenderFragment<int>? Complement { get; set; }
/// <summary>
/// Valeurs déjà employées ailleurs, proposées sous le champ. <c>null</c> = aucune proposition.
/// </summary>
/// <remarks>
/// ⚠️ N'a de sens que pour un <b>vocabulaire</b> — les thèmes, qui ne valent que réutilisés.
/// Elle n'en a aucun pour des valeurs uniques à leur fiche (titres d'articles à la une, noms
/// d'auteurs d'un livre donné), où elle ne ferait que du bruit.
/// </remarks>
[Parameter] public IReadOnlyList<string>? Suggestions { get; set; }
/// <summary>
/// Prévient le parent que la liste a changé.
/// </summary>
@@ -116,6 +148,45 @@
private string? _message;
/// <summary>Les propositions sont dépliées depuis le premier focus, jusqu'à ce qu'on choisisse.</summary>
private bool _suggestionsOuvertes;
/// <summary>Nombre de propositions affichées : au-delà, la liste cache le formulaire.</summary>
private const int MaximumPropose = 8;
/// <summary>
/// Ce qui reste à proposer : ni ce qui est déjà sur la fiche, ni ce qui ne correspond pas à
/// ce qu'on tape.
/// </summary>
private IReadOnlyList<string> Proposees
{
get
{
if (!_suggestionsOuvertes || Suggestions is null)
{
return [];
}
var deja = Valeurs.Select(NormalisationTexte.Normaliser).ToHashSet(StringComparer.Ordinal);
var terme = NormalisationTexte.Normaliser(_saisie);
return
[
.. Suggestions
.Where(s => !deja.Contains(NormalisationTexte.Normaliser(s)))
.Where(s => terme.Length == 0
|| NormalisationTexte.Normaliser(s).Contains(terme, StringComparison.Ordinal))
.Take(MaximumPropose),
];
}
}
private void Choisir(string suggestion)
{
_saisie = suggestion;
Valider();
}
/// <summary>Liste servie au rendu précédent, pour repérer que le parent en a changé.</summary>
private IList<string>? _precedente;
@@ -137,6 +208,7 @@
_saisie = null;
_indexModifie = null;
_message = null;
_suggestionsOuvertes = false;
}
/// <remarks>