Auteurs cherchables et triables, filtre de prêt, rang dans les exports, couvertures d'envies

Auteurs
- Recherche et tri (par nom, ou du plus présent au moins présent), en mémoire : la liste
  est déjà chargée en entier — c'est elle qui sert l'instantané hors-ligne — et la filtrer
  côté serveur aurait rendu la page inutilisable sans réseau.
- Trois colonnes dont une seule est élastique : compteur à gauche sur une largeur figée,
  nom au milieu, actions à droite. Les compteurs et les boutons s'alignent alors d'une
  ligne à l'autre au lieu de suivre la longueur du nom.

Catalogue
- Filtre « À la maison / Prêtés », posé dans les deux implémentations (serveur et
  hors-ligne) et dans le test qui les confronte, avec un cas de prêt CLOS — c'est
  exactement là que les deux auraient pu diverger en silence.
- La ligne disparaît tant que rien n'est prêté, comme celles des formats et des types.

Envies
- ⚠️ Les couvertures manquaient parce que seul l'écran « ajouter une envie » remplissait
  CoverUrl : la bibliographie et les tomes manquants transmettent un ISBN mais aucune
  image. Le repli est posé à la LECTURE, donc il vaut aussi pour les envies déjà
  enregistrées, sans migration.
- Les exports .txt et .csv portent le rang d'envie. Le .txt en a le plus besoin : son
  groupement par auteur détruit précisément cet ordre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-20 20:42:13 +02:00
co-authored by Claude Opus 5
parent 83089d7479
commit 4fd67e4e3b
13 changed files with 344 additions and 24 deletions
+109 -5
View File
@@ -67,21 +67,71 @@ else if (_auteurs.Count == 0)
}
else
{
<p class="compteur">@_auteurs.Count auteur@(_auteurs.Count > 1 ? "s" : "")</p>
@*
Recherche et tri d'une seule pièce : la question qu'on se pose devant une liste
d'auteurs est soit « où est untel ? » (recherche), soit « qui ai-je le plus ? » (tri).
⚠️ Tout se fait en mémoire, sans nouvel appel : la liste des auteurs est déjà
entièrement chargée — c'est elle qui sert l'instantané hors-ligne — et la filtrer
côté serveur aurait rendu la page inutilisable sans réseau. La normalisation passe
par NormalisationTexte, comme partout : « emile » retrouve « Émile ».
*@
<div class="barre-recherche">
<input class="champ-saisie" type="search" placeholder="Nom d'auteur…"
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un auteur" />
</div>
<div class="segments" role="group" aria-label="Tri">
<button type="button" class="segment @(_tri == TriAuteurs.Nom ? "segment-actif" : null)"
@onclick="() => _tri = TriAuteurs.Nom">Par nom</button>
<button type="button" class="segment @(_tri == TriAuteurs.NombreDeLivres ? "segment-actif" : null)"
@onclick="() => _tri = TriAuteurs.NombreDeLivres">Les plus présents</button>
</div>
@* Le compteur dit ce qu'on regarde : sur une liste filtrée, afficher le total ferait
croire que la recherche n'a rien fait. *@
<p class="compteur">
@Affiches.Count auteur@(Affiches.Count > 1 ? "s" : "")
@if (Affiches.Count != _auteurs.Count)
{
<text> sur @_auteurs.Count</text>
}
</p>
@if (Affiches.Count == 0)
{
<p class="message-discret">Aucun auteur ne correspond à « @_recherche ».</p>
}
<ul class="liste-auteurs">
@foreach (var auteur in _auteurs)
@foreach (var auteur in Affiches)
{
@*
Trois colonnes, dont une seule est élastique.
Le nombre de livres passe à GAUCHE du nom, sur une largeur figée de trois
chiffres : les compteurs s'alignent alors les uns sous les autres et se
comparent d'un coup d'œil. Trois chiffres suffisent — personne ne possède
mille livres d'un même auteur. Les actions se rangent tout à droite, elles
aussi sur une largeur constante, pour que seul le nom absorbe la place
restante au lieu de décaler les boutons ligne après ligne.
*@
<li class="carte-auteur-ligne" @key="auteur.Id">
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
<span class="etiquette">@Livres(auteur.NombreDeLivres)</span>
<span class="auteur-compte" title="@Livres(auteur.NombreDeLivres)">
@auteur.NombreDeLivres
<span class="lecture-ecran">@Livres(auteur.NombreDeLivres)</span>
</span>
<a class="lien-auteur auteur-nom" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
<span class="auteur-actions">
@* Le declencheur decrit dans IDEES.md : depuis un auteur qu'on a deja, voir
tout ce qu'il a ecrit et marquer ce qui manque. *@
<a class="bouton bouton-compact bouton-discret"
href="@($"auteurs/{auteur.Id}/bibliographie")">Bibliographie</a>
@if (_auteurRenomme == auteur.Id)
{
<input class="champ-texte champ-auteur-renomme" @bind="_nomRenomme" />
<input class="champ-saisie champ-auteur-renomme" @bind="_nomRenomme" />
<button type="button" class="bouton bouton-compact bouton-principal"
disabled="@_renommageEnCours" @onclick="RenommerAsync">
Enregistrer
@@ -100,6 +150,7 @@ else
Renommer
</button>
}
</span>
</li>
}
</ul>
@@ -127,7 +178,19 @@ else
}
@code {
/// <summary>Les deux façons de trier une liste d'auteurs, et il n'y en a pas de troisième.</summary>
private enum TriAuteurs
{
/// <summary>Ordre alphabétique : « où est untel ? ».</summary>
Nom = 0,
/// <summary>Du plus présent au moins présent : « qui ai-je le plus ? ».</summary>
NombreDeLivres = 1,
}
private IReadOnlyList<AuteurDto>? _auteurs;
private string _recherche = string.Empty;
private TriAuteurs _tri = TriAuteurs.Nom;
private IReadOnlyList<SuggestionRapprochement>? _suggestions;
private bool _enCours;
private bool _renommageEnCours;
@@ -153,6 +216,47 @@ else
private static string Livres(int nombre) => $"{nombre} livre{(nombre > 1 ? "s" : "")}";
private void SurRecherche(ChangeEventArgs e) => _recherche = e.Value?.ToString() ?? string.Empty;
/// <summary>
/// La liste telle qu'elle s'affiche : filtrée par la recherche, puis triée.
/// </summary>
/// <remarks>
/// La recherche est une <b>sous-chaîne du nom normalisé</b>, comme celle du catalogue :
/// « zola » retrouve « Émile Zola », et « emile » aussi. Elle ne cherche pas mot à mot,
/// contrairement au rapprochement d'auteurs, qui répond à une autre question — « ces deux
/// noms désignent-ils la même personne ? » — et n'a rien à faire ici.
/// <para>
/// Le tri par nom est <b>ordinal sur la forme normalisée</b>, pour ne pas dépendre de la
/// culture du navigateur : c'est la même règle que <c>FiltreLivresLocal</c>, et elle évite
/// qu'un appareil classe « Éluard » avant « Duras » et un autre après.
/// </para>
/// </remarks>
private IReadOnlyList<AuteurDto> Affiches
{
get
{
if (_auteurs is null)
{
return [];
}
var terme = NormalisationTexte.Normaliser(_recherche);
var retenus = string.IsNullOrEmpty(terme)
? _auteurs
: _auteurs.Where(a => NormalisationTexte.Normaliser(a.Nom).Contains(terme)).ToList();
return _tri == TriAuteurs.NombreDeLivres
? retenus
.OrderByDescending(a => a.NombreDeLivres)
.ThenBy(a => NormalisationTexte.Normaliser(a.Nom), StringComparer.Ordinal)
.ToList()
: retenus
.OrderBy(a => NormalisationTexte.Normaliser(a.Nom), StringComparer.Ordinal)
.ToList();
}
}
private async Task ChargerAsync()
{
_erreur = null;
+46 -3
View File
@@ -86,9 +86,33 @@
}
</div>
@*
Le prêt, lui, est COMMUN au foyer : ce filtre ne dépend de personne. L'écran
« Prêts » répond déjà à « qu'est-ce qui est dehors ? » ; celui-ci sert l'autre
moitié de la question — « qu'est-ce que j'ai réellement sous la main ? » —, celle
qu'on se pose avant de promettre un livre à quelqu'un.
⚠️ Même règle que pour les formats et les types : la ligne disparaît tant que rien
n'est prêté, où elle n'offrirait qu'un choix utile. Et comme pour eux, elle ne se
déduit QUE d'un chargement sans critère, sinon une recherche qui ne remonte que des
livres à la maison escamoterait le filtre sans qu'on puisse l'élargir.
*@
@if (QuelqueChoseEstPrete)
{
<div class="segments" role="group" aria-label="Filtrer par prêt en cours">
<button type="button" class="segment @(_prete is null ? "segment-actif" : null)"
@onclick="() => FiltrerPret(null)">Tous</button>
<button type="button" class="segment @(_prete == false ? "segment-actif" : null)"
@onclick="() => FiltrerPret(false)">À la maison</button>
<button type="button"
class="segment segment-prete @(_prete == true ? "segment-actif" : null)"
@onclick="() => FiltrerPret(true)">Prêtés</button>
</div>
}
<p class="champ-aide">
Le statut de lecture est <strong>le vôtre</strong> : filtrer dessus montre votre
lecture, pas celle du foyer.
lecture, pas celle du foyer. Le prêt, lui, est commun.
</p>
</div>
}
@@ -157,6 +181,7 @@ else if (_livres is not null)
private Format? _format;
private TypeDocument? _type;
private Statut? _statut;
private bool? _prete;
private bool _chargement;
private bool _filtresOuverts;
private string? _erreur;
@@ -180,6 +205,14 @@ else if (_livres is not null)
private bool PlusieursTypes => _typesDuFonds is null || _typesDuFonds.Count > 1;
/// <summary>
/// Vrai si au moins un livre du fonds est actuellement dehors — ou qu'on ne le sait pas.
/// </summary>
/// <remarks>Mêmes précautions que les formats : dans le doute, la ligne reste affichée.</remarks>
private bool? _fondsAvecPret;
private bool QuelqueChoseEstPrete => _fondsAvecPret is not false;
/// <summary>
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
/// </summary>
@@ -189,14 +222,15 @@ else if (_livres is not null)
/// qui serait invisible une fois le panneau refermé.
/// </remarks>
private int FiltresActifs =>
(_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1);
(_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1)
+ (_prete 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 || AuteurId is not null;
|| _statut is not null || _prete 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
@@ -245,12 +279,19 @@ else if (_livres is not null)
return ChargerAsync();
}
private Task FiltrerPret(bool? prete)
{
_prete = prete;
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)
&& criteres.Format is null
&& criteres.TypeDocument is null
&& criteres.Statut is null
&& criteres.Prete is null
&& criteres.AuteurId is null;
private async Task ChargerAsync()
@@ -271,6 +312,7 @@ else if (_livres is not null)
Format = _format,
TypeDocument = _type,
Statut = _statut,
Prete = _prete,
AuteurId = AuteurId,
};
@@ -280,6 +322,7 @@ else if (_livres is not null)
{
_formatsDuFonds = _livres.Select(l => l.Format).ToHashSet();
_typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet();
_fondsAvecPret = _livres.Any(l => l.PreteA is not null);
}
}
catch (OperationCanceledException)
@@ -53,6 +53,14 @@ public static class FiltreLivresLocal
livres = livres.Where(l => l.Auteurs.Any(a => a.Id == auteurId));
}
if (criteres.Prete is { } prete)
{
// LivreDto.PreteA porte l'emprunteur du prêt EN COURS, ou null : le serveur a déjà
// écarté les prêts clos. C'est exactement ce que le filtre serveur exprime par
// « une ligne sans date de retour », d'où l'équivalence des deux implémentations.
livres = livres.Where(l => (l.PreteA is not null) == prete);
}
var terme = NormalisationTexte.Normaliser(criteres.Recherche);
if (terme.Length > 0)
{
@@ -95,6 +95,12 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
parametres.Add($"auteurId={auteurId}");
}
if (criteres.Prete is { } prete)
{
// Minuscules : c'est ce que le liage de ASP.NET Core attend d'un bool?.
parametres.Add($"prete={(prete ? "true" : "false")}");
}
var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty);
var (ok, liste) = await EssayerAsync(() => http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct));
+1
View File
@@ -14,3 +14,4 @@
@using MaBibli.Shared.Dtos
@using MaBibli.Shared.Entites
@using MaBibli.Shared.Isbn
@using MaBibli.Shared.Textes
+43 -1
View File
@@ -248,6 +248,10 @@ body {
/* Type de document : même vocabulaire que les pastilles de statut, deux couleurs distinctes
des trois du statut et de celle du prêt, pour qu'on reconnaisse ce qu'on filtre (A2, IDEES.md). */
/* Le segment « Prêtés » reprend la couleur de l'étiquette de prêt, comme les segments de
statut reprennent celle de leur pastille : le filtre et la carte doivent se répondre. */
.segment.segment-prete.segment-actif { background: #8a5a00; border-color: #8a5a00; }
.segment.type-roman.segment-actif { background: #2a6f97; border-color: #2a6f97; }
.segment.type-bd.segment-actif { background: #a13d63; border-color: #a13d63; }
@@ -739,12 +743,50 @@ body {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
min-height: 2.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--mb-bord);
}
/*
Trois colonnes, dont une seule est élastique.
⚠️ C'est la largeur FIGÉE des deux colonnes extrêmes qui fait tout l'intérêt : sans elle,
chaque ligne plaçait son compteur et ses boutons à un endroit différent, au gré de la
longueur du nom, et l'œil devait relire chaque ligne entière. Trois chiffres suffisent au
compteur — personne ne possède mille livres d'un même auteur.
*/
.auteur-compte {
flex: 0 0 2.5rem;
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
font-weight: 600;
color: var(--mb-texte-doux);
}
.auteur-nom {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.auteur-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
}
/* Le champ de renommage s'insère dans la colonne d'actions : il doit pouvoir rétrécir, sinon
il pousse « Enregistrer » hors de l'écran sur un téléphone. */
.champ-auteur-renomme {
min-width: 0;
max-width: 10rem;
}
.rapprochements {
margin-bottom: 1.5rem;
padding: 0.75rem;