Replie les blocs de série au catalogue, cycle par cycle

Le catalogue ne rangeait un livre que sous sa série directe : il n'y avait
qu'un seul niveau, donc rien à replier « à chaque niveau ». Le regroupement
devient un arbre — un cycle coiffe ses séries, comme sur /series — et chaque
nœud porte sa bascule.

Tout reste déplié par défaut ; un bloc replié annonce ce qu'il contient, et
l'état de repli appartient à l'écran, sans quoi une frappe dans la recherche
le perdrait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-22 01:34:04 +02:00
co-authored by Claude Opus 5
parent 7d2f3dc5d9
commit e85867014c
6 changed files with 411 additions and 48 deletions
@@ -0,0 +1,84 @@
@using MaBibli.Client.Services
@*
Un bloc de série au catalogue, replié ou déplié — et récursif, parce qu'un cycle est une
série de séries : chaque niveau porte sa propre bascule.
⚠️ Tout est DÉPLIÉ par défaut, et l'on replie ce dont on ne veut pas. C'est la règle déjà
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.
*@
<li class="groupe-serie" @key="Entree.Serie!.Id">
<div class="groupe-serie-entete">
@* ⚠️ « aria-expanded="@Deplie" » ne marche PAS : Blazor traite un booléen comme un attribut
de présence et rend « aria-expanded="" ». L'état doit être écrit en toutes lettres. *@
<button type="button" class="groupe-serie-plier"
aria-expanded="@(Deplie ? "true" : "false")"
aria-label="@($"{(Deplie ? "Replier" : "Déplier")} « {Entree.Serie!.Titre} »")"
@onclick="Basculer">
<span aria-hidden="true">@(Deplie ? "▾" : "▸")</span>
</button>
@* Le nom mène à la SÉRIE : c'est le seul écran qui montre aussi les tomes manquants,
ce que le catalogue ne peut pas faire. *@
<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" : "")
</span>
</a>
</div>
@if (Deplie)
{
@if (Entree.SousEntrees.Count > 0)
{
<ul class="liste-livres">
@foreach (var sous in Entree.SousEntrees)
{
<GroupeCatalogue Entree="sous" Replies="Replies" />
}
</ul>
}
@if (Entree.Livres.Count > 0)
{
<ul class="liste-livres">
@foreach (var livre in Entree.Livres)
{
<li @key="livre.Id">
<CarteLivre Livre="livre" />
</li>
}
</ul>
}
}
</li>
@code {
[Parameter, EditorRequired] public EntreeCatalogue Entree { get; set; } = default!;
/// <summary>
/// Blocs REPLIÉS, par identifiant de série — et non dépliés : le défaut est de tout montrer.
/// </summary>
/// <remarks>
/// État local de l'écran, volontairement non persistant, comme celui de l'arbre des séries :
/// c'est un confort de lecture, pas une propriété de la série. Il survit en revanche aux
/// rechargements du catalogue (frappe dans la recherche, filtre), le jeu appartenant à
/// l'écran et non au bloc.
/// </remarks>
[Parameter, EditorRequired] public ISet<int> Replies { get; set; } = new HashSet<int>();
private bool Deplie => !Replies.Contains(Entree.Serie!.Id);
private void Basculer()
{
if (!Replies.Add(Entree.Serie!.Id))
{
Replies.Remove(Entree.Serie!.Id);
}
}
}
+18 -18
View File
@@ -157,29 +157,18 @@ else if (_livres is not null)
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.
Chaque bloc se REPLIE, et un cycle replie chacun de ses niveaux : un cycle de vingt
tomes occupait tout l'écran alors qu'on cherchait un livre rangé après lui. Replier ne
retire rien de la liste — le compteur du haut ne bouge pas, et le bloc replié dit
toujours ce qu'il contient.
*@
<ul class="liste-livres">
@foreach (var entree in Entrees)
{
@if (entree.Serie is { } serie)
@if (entree.EstGroupe)
{
<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>
<GroupeCatalogue Entree="entree" Replies="_replies" />
}
else
{
@@ -236,6 +225,17 @@ else if (_livres is not null)
/// <summary>Regroupement actif — l'utilisateur peut le défaire, rien ne disparaît alors.</summary>
private bool _grouper = true;
/// <summary>
/// Blocs repliés à la main, par identifiant de série. Tout est déplié par défaut.
/// </summary>
/// <remarks>
/// ⚠️ Le jeu appartient à l'ÉCRAN, et non aux blocs : le catalogue se relit à chaque frappe
/// dans la recherche, et un état porté par les composants se perdrait à chaque caractère.
/// Il n'est pas persistant d'une visite à l'autre pour autant — c'est un confort de
/// lecture, pas une propriété de la série, même règle que l'arbre des séries.
/// </remarks>
private readonly HashSet<int> _replies = [];
/// <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);
+125 -30
View File
@@ -6,26 +6,44 @@ 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>
/// <remarks>
/// ⚠️ Une entrée de série peut en contenir d'autres : un cycle est une <b>série de séries</b>
/// (<c>Serie.SerieParenteId</c>), et le catalogue le montre tel quel depuis le 2026-08-22 —
/// sans quoi « replier chaque niveau » n'aurait pas de niveaux à replier.
/// </remarks>
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>
/// <summary>Les livres rattachés <b>directement</b> à cette série ; un seul hors série.</summary>
public required IReadOnlyList<LivreDto> Livres { get; init; }
/// <summary>Les séries contenues, quand ce bloc est un cycle. Vide sinon.</summary>
public IReadOnlyList<EntreeCatalogue> SousEntrees { get; init; } = [];
public bool EstGroupe => Serie is not null;
/// <summary>
/// Combien de livres du catalogue tiennent dans ce bloc, descendance comprise.
/// </summary>
/// <remarks>
/// C'est ce que le bloc annonce, y compris <b>replié</b> : replier ne doit pas faire
/// perdre le compte de ce qu'on vient de cacher.
/// </remarks>
public int NombreLivres => Livres.Count + SousEntrees.Sum(e => e.NombreLivres);
}
/// <summary>
/// Regroupe les livres du catalogue par série.
/// Regroupe les livres du catalogue par série, en respectant les cycles.
/// </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>.
/// même la raison d'être de <c>ElementSerie.Position</c>, et les sous-séries d'un cycle
/// suivent le leur (<c>Serie.Position</c>), comme sur l'écran des séries.
/// <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
@@ -39,6 +57,12 @@ public sealed record EntreeCatalogue
/// <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>
/// <para>
/// ⚠️ <b>Seuls les niveaux qui portent quelque chose existent.</b> Un cycle de cinq séries dont
/// une seule a un tome au catalogue ne produit pas quatre nœuds vides : on ne crée que la
/// chaîne qui mène aux livres présents. Un niveau vide serait une indentation qui ne se
/// replierait sur rien.
/// </para>
/// </remarks>
public static class GroupementCatalogue
{
@@ -51,46 +75,117 @@ public static class GroupementCatalogue
}
var place = PlaceDesLivres(series);
var parId = series.ToDictionary(s => s.Id);
// 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)>>();
// Les blocs s'accumulent dans le brouillon de leur RACINE, créé à la position du premier
// livre rencontré sous elle : c'est ce qui range le bloc là où l'ordre du catalogue
// l'attend. Les nœuds intermédiaires, eux, se créent en chemin.
var brouillons = new List<Noeud>();
var noeuds = new Dictionary<int, Noeud>();
foreach (var livre in livres)
{
if (!place.TryGetValue(livre.Id, out var appartenance))
{
brouillons.Add((null, [(0, livre)]));
brouillons.Add(new Noeud(null) { Livres = { (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));
var noeud = Descendre(Ascendance(appartenance.Serie, parId), noeuds, brouillons);
noeud.Livres.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),
],
}),
];
return [.. brouillons.Select(Materialiser)];
}
/// <summary>Un bloc en cours de construction : ses tomes directs et ses sous-blocs.</summary>
private sealed class Noeud(SerieDto? serie)
{
public SerieDto? Serie { get; } = serie;
public List<(int Position, LivreDto Livre)> Livres { get; } = [];
public List<Noeud> Enfants { get; } = [];
}
/// <summary>
/// La chaîne de la racine jusqu'à <paramref name="serie"/>, cycle compris.
/// </summary>
/// <remarks>
/// ⚠️ La remontée est <b>bornée par le nombre de séries</b>, comme celle du serveur : une
/// boucle résiduelle en base ferait sinon tourner cette fonction sans fin. Mieux vaut un
/// bloc rangé un cran trop bas qu'un catalogue qui ne s'affiche plus.
/// </remarks>
private static List<SerieDto> Ascendance(SerieDto serie, Dictionary<int, SerieDto> parId)
{
var chaine = new List<SerieDto> { serie };
var vues = new HashSet<int> { serie.Id };
var courante = serie;
while (courante.SerieParenteId is { } parenteId
&& parId.TryGetValue(parenteId, out var parente)
&& vues.Add(parente.Id)
&& chaine.Count <= parId.Count)
{
chaine.Add(parente);
courante = parente;
}
chaine.Reverse();
return chaine;
}
/// <summary>Retrouve — ou crée — le nœud de chaque série de la chaîne, et rend le dernier.</summary>
private static Noeud Descendre(
List<SerieDto> chaine, Dictionary<int, Noeud> noeuds, List<Noeud> brouillons)
{
Noeud? parent = null;
foreach (var serie in chaine)
{
if (!noeuds.TryGetValue(serie.Id, out var noeud))
{
noeud = new Noeud(serie);
noeuds[serie.Id] = noeud;
if (parent is null)
{
brouillons.Add(noeud);
}
else
{
parent.Enfants.Add(noeud);
}
}
parent = noeud;
}
return parent!;
}
private static EntreeCatalogue Materialiser(Noeud noeud) => new()
{
Serie = noeud.Serie,
Livres =
[
.. noeud.Livres
.OrderBy(t => t.Position)
.ThenBy(t => t.Livre.Id)
.Select(t => t.Livre),
],
// Les sous-séries d'abord, puis les tomes rattachés directement au cycle : c'est la
// disposition de l'écran d'une série, et deux écrans ne doivent pas en donner deux.
SousEntrees =
[
.. noeud.Enfants
.OrderBy(e => e.Serie!.Position)
.ThenBy(e => e.Serie!.Id)
.Select(Materialiser),
],
};
/// <summary>
/// À quelle série — et à quelle place — appartient chaque livre rattaché.
/// </summary>
+27
View File
@@ -413,12 +413,39 @@ body {
padding-left: 0.6rem;
}
/* La bascule et le nom sur une même ligne. ⚠️ « min-width: 0 » à chaque étage : la ligne est
elle-même dans une cascade de flex, et sans lui un titre de série long fait défiler la page
entière — le piège du lot M, puis de l'arbre des séries. */
.groupe-serie-entete {
display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
}
/* ⚠️ La bascule est un BOUTON à côté du lien, jamais dedans : un bouton imbriqué dans un <a>
n'est pas du HTML valide, et le clic irait à la série au lieu de replier. */
.groupe-serie-plier {
flex: 0 0 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
height: 1.5rem;
padding: 0;
font-size: 0.9rem;
color: var(--mb-texte-doux);
background: transparent;
border: 0;
cursor: pointer;
}
/* 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;
min-width: 0;
padding: 0.35rem 0;
color: var(--mb-texte);
text-decoration: none;