Rassemble au catalogue les tomes qui racontent la même histoire

Les livres d'une même série se suivaient au hasard de l'alphabet, et rien
ne disait qu'ils allaient ensemble. Ils tiennent maintenant sous le nom de
leur série, à la place qu'occupait le premier d'entre eux — l'ordre général
ne bouge donc pas — et ce nom mène à la série, seul écran qui montre aussi
les tomes qui manquent.

Le regroupement ne cache ni ne duplique rien : une bascule le défait, et
un livre rattaché à deux séries ne paraît qu'une fois, toujours sous la
même. La règle est une fonction pure, avec ses tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-22 00:16:52 +02:00
co-authored by Claude Opus 5
parent 75f962ab46
commit 7d2f3dc5d9
5 changed files with 459 additions and 4 deletions
+94 -4
View File
@@ -110,6 +110,22 @@
</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
se voit d'ailleurs dans la liste elle-même : avec ou sans blocs de série.
Décochée, on retrouve exactement le catalogue d'avant : tous les livres à plat.
*@
@if (_series is { Count: > 0 })
{
<label class="bascule">
<input type="checkbox" checked="@_grouper"
@onchange="e => _grouper = (bool?)e.Value ?? true" />
<span>Grouper les livres d'une même série</span>
</label>
}
<p class="champ-aide">
Le statut de lecture est <strong>le vôtre</strong> : filtrer dessus montre votre
lecture, pas celle du foyer. Le prêt, lui, est commun.
@@ -136,12 +152,41 @@ else if (_livres is not null)
{
<p class="compteur">@_livres.Count livre@(_livres.Count > 1 ? "s" : "")</p>
@*
Les tomes d'une série tiennent sous son nom, à la place qu'occuperait le premier d'entre
eux : l'ordre général du catalogue ne bouge pas, on ne perd donc pas un livre qu'on
cherchait à sa lettre. Le nom du bloc MÈNE À LA SÉRIE, où figurent aussi les tomes qu'on
ne possède pas — ce que le catalogue, lui, ne peut pas montrer.
*@
<ul class="liste-livres">
@foreach (var livre in _livres)
@foreach (var entree in Entrees)
{
<li @key="livre.Id">
<CarteLivre Livre="livre" />
</li>
@if (entree.Serie is { } serie)
{
<li class="groupe-serie" @key="serie">
<a class="groupe-serie-titre" href="@($"series/{serie.Id}")">
<span class="groupe-serie-nom">@serie.Titre</span>
<span class="groupe-serie-compte">
@entree.Livres.Count tome@(entree.Livres.Count > 1 ? "s" : "")
</span>
</a>
<ul class="liste-livres">
@foreach (var livre in entree.Livres)
{
<li @key="livre.Id">
<CarteLivre Livre="livre" />
</li>
}
</ul>
</li>
}
else
{
<li @key="entree.Livres[0].Id">
<CarteLivre Livre="entree.Livres[0]" />
</li>
}
}
</ul>
}
@@ -175,6 +220,25 @@ else if (_livres is not null)
public int? AuteurId { get; set; }
private IReadOnlyList<LivreDto>? _livres;
/// <summary>
/// Les séries du foyer, pour regrouper leurs tomes. <c>null</c> = pas encore lues.
/// </summary>
/// <remarks>
/// ⚠️ Elles ne sont lues <b>qu'une fois</b> par visite (et à chaque bascule du réseau), pas à
/// chaque chargement du catalogue : celui-ci se relit à chaque frappe dans la recherche, et
/// une requête de séries par caractère serait du gâchis pour une liste qui ne bouge pas
/// pendant qu'on tape. Le prix est qu'un tome rattaché depuis un autre écran n'apparaît
/// groupé qu'au prochain passage ici.
/// </remarks>
private IReadOnlyList<SerieDto>? _series;
/// <summary>Regroupement actif — l'utilisateur peut le défaire, rien ne disparaît alors.</summary>
private bool _grouper = true;
/// <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);
private AuteurDto? _auteur;
private int? _auteurCharge;
private string _recherche = string.Empty;
@@ -238,14 +302,40 @@ else if (_livres is not null)
/// </summary>
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
/// <summary>
/// Lit les séries, et se contente de ne pas grouper si elles manquent.
/// </summary>
/// <remarks>
/// ⚠️ Une lecture ratée ne doit rien coûter au catalogue : sans série, la liste s'affiche à
/// plat, ce qui est exactement l'écran d'avant. Hors-ligne, la lecture retombe d'elle-même
/// sur l'instantané « series ».
/// </remarks>
private async Task ChargerSeriesAsync()
{
try
{
_series = await Api.ListerSeriesAsync();
}
catch (Exception)
{
_series = null;
}
}
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerSeriesAsync();
await ChargerAsync();
StateHasChanged();
});
protected override async Task OnParametersSetAsync()
{
if (_series is null)
{
await ChargerSeriesAsync();
}
if (_auteurCharge != AuteurId)
{
_auteurCharge = AuteurId;
@@ -0,0 +1,123 @@
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Textes;
namespace MaBibli.Client.Services;
/// <summary>
/// Une entrée du catalogue : un livre seul, ou les tomes d'une même série sous son nom.
/// </summary>
public sealed record EntreeCatalogue
{
/// <summary>La série qui coiffe le bloc, ou <c>null</c> pour un livre seul.</summary>
public SerieDto? Serie { get; init; }
/// <summary>Les livres de l'entrée : un seul hors série, les tomes visibles sinon.</summary>
public required IReadOnlyList<LivreDto> Livres { get; init; }
public bool EstGroupe => Serie is not null;
}
/// <summary>
/// Regroupe les livres du catalogue par série.
/// </summary>
/// <remarks>
/// <b>Le bloc se place là où son premier tome serait tombé</b> dans l'ordre du catalogue
/// (choisi avec l'utilisateur le 2026-08-22) : l'ordre général reste celui qu'on connaît, et
/// l'on retrouve « La Légende de Drizzt » entre « Dracula » et « Dune ». À l'intérieur, les
/// tomes suivent l'<b>ordre de lecture</b>, seul ordre qui ait un sens pour une saga — c'est
/// même la raison d'être de <c>ElementSerie.Position</c>.
/// <para>
/// ⚠️ <b>Rien n'est jamais masqué ni déplacé hors de la liste.</b> Un livre filtré reste absent,
/// un livre visible reste visible : le regroupement ne fait que rassembler, et l'écran offre de
/// le défaire. C'est la même règle que le grisage de la bibliographie — on marque, on ne cache
/// pas.
/// </para>
/// <para>
/// ⚠️ Un livre peut appartenir à <b>plusieurs</b> séries (le modèle l'autorise, sans unicité sur
/// <c>LivreId</c> seul). Il n'apparaît pourtant qu'une fois : le dupliquer ferait mentir le
/// compteur du catalogue et donnerait deux cartes du même exemplaire. La série retenue est la
/// <b>première par ordre alphabétique</b> — un critère explicable, à défaut d'être le bon dans
/// tous les cas ; sa fiche livre, elle, les montre toutes.
/// </para>
/// </remarks>
public static class GroupementCatalogue
{
public static IReadOnlyList<EntreeCatalogue> Grouper(
IReadOnlyList<LivreDto> livres, IReadOnlyList<SerieDto>? series)
{
if (series is null || series.Count == 0)
{
return [.. livres.Select(l => new EntreeCatalogue { Livres = [l] })];
}
var place = PlaceDesLivres(series);
// Les tomes s'accumulent dans le brouillon du groupe, créé à la position de son PREMIER
// tome rencontré : c'est ce qui range le bloc là où l'ordre du catalogue l'attend.
var brouillons = new List<(SerieDto? Serie, List<(int Position, LivreDto Livre)> Livres)>();
var groupes = new Dictionary<int, List<(int Position, LivreDto Livre)>>();
foreach (var livre in livres)
{
if (!place.TryGetValue(livre.Id, out var appartenance))
{
brouillons.Add((null, [(0, livre)]));
continue;
}
if (!groupes.TryGetValue(appartenance.Serie.Id, out var tomes))
{
tomes = [];
groupes[appartenance.Serie.Id] = tomes;
brouillons.Add((appartenance.Serie, tomes));
}
tomes.Add((appartenance.Position, livre));
}
return
[
.. brouillons.Select(b => new EntreeCatalogue
{
Serie = b.Serie,
Livres =
[
.. b.Livres
.OrderBy(t => t.Position)
.ThenBy(t => t.Livre.Id)
.Select(t => t.Livre),
],
}),
];
}
/// <summary>
/// À quelle série — et à quelle place — appartient chaque livre rattaché.
/// </summary>
/// <remarks>
/// ⚠️ Les séries sont parcourues dans l'ordre alphabétique de leur titre normalisé, et le
/// <c>TryAdd</c> garde donc la <b>première</b> : sans cet ordre, la série retenue pour un
/// livre rattaché deux fois dépendrait de l'ordre où l'API rend les séries, c'est-à-dire de
/// rien de compréhensible.
/// </remarks>
private static Dictionary<int, (SerieDto Serie, int Position)> PlaceDesLivres(
IReadOnlyList<SerieDto> series)
{
var place = new Dictionary<int, (SerieDto, int)>();
foreach (var serie in series
.OrderBy(s => NormalisationTexte.Normaliser(s.Titre), StringComparer.Ordinal)
.ThenBy(s => s.Id))
{
foreach (var element in serie.Elements)
{
if (element.LivreId is { } livreId)
{
place.TryAdd(livreId, (serie, element.Position));
}
}
}
return place;
}
}
+49
View File
@@ -402,6 +402,55 @@ body {
padding: 0;
}
/* --- Livres d'une même série, rassemblés au catalogue (2026-08-22) ---
Le bloc se pose là où son premier tome serait tombé dans l'ordre du catalogue : il ne
déplace donc rien, il rassemble. Le trait à gauche est ce qui fait lire les cartes comme
« dedans » plutôt que comme la suite de la liste. */
.groupe-serie {
margin: 0.5rem 0;
border-left: 3px solid var(--mb-accent);
padding-left: 0.6rem;
}
/* Le nom mène à la série : c'est le seul endroit du catalogue qui montre aussi ce qui manque. */
.groupe-serie-titre {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0;
color: var(--mb-texte);
text-decoration: none;
font-weight: 600;
}
.groupe-serie-titre:hover .groupe-serie-nom,
.groupe-serie-titre:focus-visible .groupe-serie-nom {
text-decoration: underline;
}
.groupe-serie-nom {
min-width: 0;
overflow-wrap: anywhere;
}
.groupe-serie-compte {
color: var(--mb-texte-doux);
font-size: 0.85rem;
font-weight: 400;
}
/* Une case à cocher parmi des rangées de segments : elle doit se lire comme un réglage, pas
comme un filtre de plus. */
.bascule {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.25rem 0;
font-size: 0.9rem;
}
.carte-livre,
.carte-candidat {
display: flex;