- 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
+162 -33
View File
@@ -1,6 +1,7 @@
@page "/"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
<PageTitle>MaBibli — catalogue</PageTitle>
@@ -11,14 +12,36 @@
rarement. Le compteur sur le bouton est ce qui rend le repli sans danger — un filtre actif
et replié serait invisible, et l'on conclurait que des livres ont disparu.
*@
@*
⚠️ « Ajouter un ouvrage » est ICI, à côté de « Filtrer », et non plus dans une barre
flottante en bas : c'est le geste le plus fréquent après la recherche, et les deux boutons
du haut se manœuvrent d'une main. Il n'y en a qu'UN — deux entrées identiques sur le même
écran seraient du bruit, et le projet désencombre par le regroupement, jamais par
l'effacement.
⚠️ Hors-ligne il est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable d'une
fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau.
*@
<div class="entete-page">
<h1 class="titre-page">Catalogue</h1>
<button type="button" class="bouton bouton-discret bouton-compact"
aria-expanded="@Aria.Etat(_filtresOuverts)" aria-controls="filtres-catalogue"
@onclick="() => _filtresOuverts = !_filtresOuverts">
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
</button>
<div class="entete-page-actions">
<button type="button" class="bouton bouton-discret bouton-compact"
aria-expanded="@Aria.Etat(_filtresOuverts)" aria-controls="filtres-catalogue"
@onclick="() => _filtresOuverts = !_filtresOuverts">
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
</button>
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a>
}
else
{
<button type="button" class="bouton bouton-principal bouton-compact" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
}
</div>
</div>
@if (_auteur is not null)
@@ -30,11 +53,28 @@
</p>
}
<div class="barre-recherche">
<input class="champ-saisie" type="search" placeholder="Titre ou auteur…"
@*
La recherche accepte aussi un CODE : douchette, caméra, ou code recopié à la main. C'est le
geste « j'ai le livre en main, qu'est-ce que j'en sais déjà ? », l'inverse exact de l'ajout.
⚠️ Le scan reste offert HORS-LIGNE, contrairement à l'ajout : le décodage se fait dans le
navigateur, et la recherche retombe sur l'instantané. Rien là-dedans n'a besoin du réseau.
*@
<div class="barre-recherche ligne-creation">
<input class="champ-saisie" type="search" placeholder="Titre, auteur ou ISBN…"
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
<button type="button" class="bouton bouton-discret"
@onclick="() => _scanOuvert = !_scanOuvert">
@(_scanOuvert ? "Fermer" : "Scanner")
</button>
</div>
@if (_scanOuvert)
{
<ScannerCodeBarres OnCodeDetecte="SurCodeScanneAsync" OnAnnuler="() => _scanOuvert = false" />
}
@if (_filtresOuverts)
{
<div class="filtres" id="filtres-catalogue">
@@ -110,6 +150,32 @@
</div>
}
@*
« Quels livres me reste-t-il à illustrer ? » — la seule question à laquelle ce
filtre répond, et la raison pour laquelle il existe.
⚠️ « Sans couverture » veut dire « sans URL enregistrée », PAS « dont l'image ne
se charge pas ». Savoir la seconde demanderait d'interroger chaque URL, ce que
CLAUDE.md interdit : OpenLibrary rend des 502 intermittents, et le même livre
entrerait et sortirait du filtre d'un rafraîchissement à l'autre.
⚠️ La ligne disparaît quand le fonds est homogène — tout illustré, ou rien.
Contrairement aux formats, les DEUX extrêmes sont inutiles ici : si aucun livre
n'a de couverture, la liste complète EST déjà celle des livres à illustrer.
Et comme partout, cela ne se déduit QUE d'un chargement sans critère.
*@
@if (CouverturesInegales)
{
<div class="segments" role="group" aria-label="Filtrer par couverture">
<button type="button" class="segment @(_sansCouverture is null ? "segment-actif" : null)"
@onclick="() => FiltrerCouverture(null)">Tous</button>
<button type="button" class="segment @(_sansCouverture == false ? "segment-actif" : null)"
@onclick="() => FiltrerCouverture(false)">Avec couverture</button>
<button type="button" class="segment @(_sansCouverture == true ? "segment-actif" : null)"
@onclick="() => FiltrerCouverture(true)">Sans couverture</button>
</div>
}
@*
⚠️ Grouper ne CACHE rien — c'est pourquoi cette bascule n'entre pas dans le compteur
du bouton « Filtrer », qui ne compte que ce qui rendrait des livres invisibles. Elle
@@ -168,7 +234,7 @@ else if (_livres is not null)
{
@if (entree.EstGroupe)
{
<GroupeCatalogue Entree="entree" Replies="_replies" />
<GroupeCatalogue Entree="entree" Replies="_replies" Totaux="_totauxSeries" />
}
else
{
@@ -180,29 +246,6 @@ else if (_livres is not null)
</ul>
}
@*
Une seule entrée d'ajout, et plus aucun lien de navigation : les quatre écrans sont dans
les onglets, en bas.
« Saisie manuelle » est retirée d'ici — elle reste accessible un écran plus loin, depuis
« Ajouter », qui la propose dès qu'un ISBN ne donne rien.
⚠️ Hors-ligne le bouton est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable
d'une fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau. Le
désencombrement demandé passe par le regroupement (deux boutons en un), pas par l'effacement.
*@
<nav class="actions-flottantes">
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="ajout">Ajouter un ouvrage</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
}
</nav>
@code {
/// <summary>Restreint le catalogue à un auteur. Vient de « ?auteur=… », posé par les liens de la liste.</summary>
[SupplyParameterFromQuery(Name = "auteur")]
@@ -236,6 +279,16 @@ else if (_livres is not null)
/// </remarks>
private readonly HashSet<int> _replies = [];
/// <summary>
/// Ce que chaque série possède et recense, descendance comprise.
/// </summary>
/// <remarks>
/// ⚠️ Recalculé <b>seulement</b> quand les séries changent, et non à chaque chargement du
/// catalogue : celui-ci se relit à chaque frappe dans la recherche.
/// </remarks>
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totauxSeries =
new Dictionary<int, (int, int)>();
/// <summary>Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés.</summary>
private IReadOnlyList<EntreeCatalogue> Entrees =>
GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null);
@@ -246,6 +299,8 @@ else if (_livres is not null)
private TypeDocument? _type;
private Statut? _statut;
private bool? _prete;
private bool? _sansCouverture;
private bool _chargement;
private bool _filtresOuverts;
private string? _erreur;
@@ -277,6 +332,19 @@ else if (_livres is not null)
private bool QuelqueChoseEstPrete => _fondsAvecPret is not false;
/// <summary>
/// Vrai si le fonds contient à la fois des livres avec et sans couverture — ou qu'on ne le
/// sait pas encore.
/// </summary>
/// <remarks>
/// ⚠️ Il faut les DEUX, contrairement au filtre de prêt qui se contente d'un prêt en cours :
/// sur un fonds entièrement dépourvu de couvertures, « sans couverture » rendrait la liste
/// entière, et ne serait donc pas un filtre. Dans le doute, la ligne reste affichée.
/// </remarks>
private bool? _fondsCouverturesInegales;
private bool CouverturesInegales => _fondsCouverturesInegales is not false;
/// <summary>
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
/// </summary>
@@ -287,14 +355,15 @@ else if (_livres is not null)
/// </remarks>
private int FiltresActifs =>
(_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1)
+ (_prete is null ? 0 : 1);
+ (_prete is null ? 0 : 1) + (_sansCouverture is null ? 0 : 1);
// Une frappe au clavier annule la recherche précédente encore en vol.
private CancellationTokenSource _cts = new();
private bool EstFiltre =>
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _type is not null
|| _statut is not null || _prete is not null || AuteurId is not null;
|| _statut is not null || _prete is not null || _sansCouverture is not null
|| AuteurId is not null;
/// <summary>
/// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du
@@ -315,10 +384,12 @@ else if (_livres is not null)
try
{
_series = await Api.ListerSeriesAsync();
_totauxSeries = TotauxSeries.Calculer(_series);
}
catch (Exception)
{
_series = null;
_totauxSeries = new Dictionary<int, (int, int)>();
}
}
@@ -351,6 +422,48 @@ else if (_livres is not null)
await ChargerAsync();
}
/// <summary>Le scanner est ouvert au-dessus de la liste, pour retrouver un livre en main.</summary>
private bool _scanOuvert;
/// <summary>
/// Un code lu remplit la recherche, exactement comme une douchette l'aurait tapé.
/// </summary>
/// <remarks>
/// Le code reste dans le champ : un décodage erroné se corrige et se relance, comme sur
/// l'écran d'ajout. ⚠️ L'add-on EAN-2 est ignoré — il ne concerne que les revues, qui ne
/// sont pas au catalogue.
/// </remarks>
private async Task SurCodeScanneAsync(CodeScanne lecture)
{
_scanOuvert = false;
_recherche = lecture.Code;
await ChargerAsync();
}
/// <summary>
/// Ouvre la fiche quand le code désigne <b>un seul</b> livre du catalogue.
/// </summary>
/// <remarks>
/// C'est tout l'intérêt du scan : on tient le livre, on veut sa fiche, pas une liste d'un
/// élément à cliquer.
/// <para>
/// ⚠️ Deux conditions, et les deux comptent. <b>Un seul résultat</b>, sinon on choisirait à
/// la place de l'utilisateur — deux exemplaires du même ISBN sont légitimes. Et un
/// <b>code complet et valide</b> : sur un fragment en cours de frappe, le résultat unique
/// est un hasard, et l'écran sauterait sous les doigts.
/// </para>
/// </remarks>
private bool OuvrirSiUnique()
{
if (_livres is not { Count: 1 } || !RechercheIsbn.EstCodeComplet(_recherche))
{
return false;
}
Navigation.NavigateTo($"livres/{_livres[0].Id}");
return true;
}
private Task FiltrerFormat(Format? format)
{
_format = format;
@@ -375,6 +488,12 @@ else if (_livres is not null)
return ChargerAsync();
}
private Task FiltrerCouverture(bool? sansCouverture)
{
_sansCouverture = sansCouverture;
return ChargerAsync();
}
/// <summary>Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte.</summary>
private static bool EstSansCritere(CritereLivres criteres) =>
string.IsNullOrWhiteSpace(criteres.Recherche)
@@ -382,6 +501,7 @@ else if (_livres is not null)
&& criteres.TypeDocument is null
&& criteres.Statut is null
&& criteres.Prete is null
&& criteres.SansCouverture is null
&& criteres.AuteurId is null;
private async Task ChargerAsync()
@@ -403,16 +523,25 @@ else if (_livres is not null)
TypeDocument = _type,
Statut = _statut,
Prete = _prete,
SansCouverture = _sansCouverture,
AuteurId = AuteurId,
};
_livres = await Api.ListerAsync(criteres, jeton);
if (OuvrirSiUnique())
{
return;
}
if (EstSansCritere(criteres))
{
_formatsDuFonds = _livres.Select(l => l.Format).ToHashSet();
_typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet();
_fondsAvecPret = _livres.Any(l => l.PreteA is not null);
_fondsCouverturesInegales =
_livres.Any(l => string.IsNullOrEmpty(l.CoverUrl))
&& _livres.Any(l => !string.IsNullOrEmpty(l.CoverUrl));
}
}
catch (OperationCanceledException)