Arbre des séries dépouillé, et ordre de lecture derrière son propre écran

- L'arbre ne montre plus que la descendance : titre, avancement, lien. Il dépliait
  auparavant la liste complète des tomes de chaque nœud, cartes de livres comprises —
  un cycle occupait plusieurs écrans et l'on ne voyait plus qui contient quoi. Corollaire :
  tout est déplié par défaut, et l'on replie ce dont on ne veut pas.
- /series ne charge plus le catalogue entier : l'arbre n'en a plus l'usage.
- ⚠️ Nouvelle route /series/{id}/ordre, sur le modèle de /souhaits/ordre. Les flèches
  n'existent plus qu'ici : en consultation, elles se déclenchaient au défilement du pouce
  et l'ordre de lecture d'une saga changeait sans qu'on l'ait voulu.
- Le mode ordre ne fait QUE réordonner : les actions par tome et l'ajout de tome n'y
  figurent pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-20 20:45:14 +02:00
co-authored by Claude Opus 5
parent 4fd67e4e3b
commit bd4fea2638
4 changed files with 230 additions and 86 deletions
+73 -61
View File
@@ -1,78 +1,86 @@
@using MaBibli.Shared.Dtos
<li class="carte-serie arbre-serie" @key="Serie.Id">
<div class="arbre-serie-entete">
<button type="button" class="bouton bouton-discret"
aria-expanded="@Deplie"
@onclick="Basculer">
@(Deplie ? "▼" : "▶")
</button>
<a class="carte-titre" href="@($"series/{Serie.Id}")">@Serie.Titre</a>
<span class="carte-details">@Avancement()</span>
@*
L'arbre des séries : rien d'autre que la descendance.
⚠️ Il dépliait auparavant la liste complète des tomes de chaque série, carte de livre
comprise. Un cycle de quatre séries occupait alors plusieurs écrans, et l'on ne voyait
justement plus ce qu'un arbre sert à voir — qui contient quoi. Les tomes ont leur écran,
celui de la série ; ici on ne montre que le titre, l'avancement, et le lien qui y mène.
Corollaire : tout est DÉPLIÉ par défaut, et l'on replie ce dont on ne veut pas. L'inverse
(tout replié) obligeait à ouvrir chaque nœud pour découvrir s'il contenait quelque chose,
c'est-à-dire à faire à la main le travail que l'arbre est censé faire.
*@
<li class="arbre-noeud" @key="Serie.Id">
<div class="arbre-ligne">
@if (Enfants.Count > 0)
{
<button type="button" class="arbre-plier" aria-expanded="@Deplie"
aria-label="@($"{(Deplie ? "Replier" : "Déplier")} « {Serie.Titre} »")"
@onclick="Basculer">
<span aria-hidden="true">@(Deplie ? "▾" : "▸")</span>
</button>
}
else
{
@* Une puce à la place de la bascule : sans elle, les titres sans descendance ne
s'aligneraient pas sur ceux qui en ont. *@
<span class="arbre-puce" aria-hidden="true">•</span>
}
<a class="arbre-titre" href="@($"series/{Serie.Id}")">@Serie.Titre</a>
<span class="arbre-compte">@Avancement()</span>
@if (ActionsActives)
{
<button type="button" class="bouton bouton-compact"
disabled="@(_index == 0)" title="@MotifBlocage"
aria-label="@($"Monter « {Serie.Titre} »")"
@onclick="Monter">▲</button>
<button type="button" class="bouton bouton-compact"
disabled="@(_index < 0 || _index == Soeurs.Count - 1)" title="@MotifBlocage"
aria-label="@($"Descendre « {Serie.Titre} »")"
@onclick="Descendre">▼</button>
<span class="arbre-actions">
<button type="button" class="bouton bouton-compact"
disabled="@(_index <= 0)" title="@MotifBlocage"
aria-label="@($"Monter « {Serie.Titre} »")"
@onclick="Monter">▲</button>
<button type="button" class="bouton bouton-compact"
disabled="@(_index < 0 || _index == Soeurs.Count - 1)" title="@MotifBlocage"
aria-label="@($"Descendre « {Serie.Titre} »")"
@onclick="Descendre">▼</button>
</span>
}
</div>
@if (Deplie)
@* Le chemin déjà rendu coupe la récursion : une boucle résiduelle en base ferait sinon
boucler le rendu Razor à l'infini. Le garde-fou serveur, lui, reste borné par le
nombre de séries. *@
@if (Deplie && Enfants.Count > 0)
{
<div class="arbre-serie-detail">
@if (Serie.Elements.Count == 0)
<ul class="arbre-enfants">
@foreach (var enfant in Enfants.Where(e => !CheminAvecSerie.Contains(e.Id)))
{
<p class="message-discret">Aucun tome recensé.</p>
<ArbreSerie Serie="enfant" Toutes="Toutes"
Totaux="Totaux" Chemin="@CheminAvecSerie"
ActionsActives="ActionsActives"
MotifBlocage="MotifBlocage"
Replies="Replies"
OnDeplacer="OnDeplacer" />
}
else
{
@foreach (var element in Serie.Elements)
{
@if (element.LivreId is { } livreId
&& Livres.FirstOrDefault(l => l.Id == livreId) is { } livre)
{
<CarteLivre Livre="livre" />
}
else
{
<p class="carte-tome tome-manquant">
@element.Titre
<span class="etiquette etiquette-manquant">Pas dans la bibliothèque</span>
</p>
}
}
}
@if (Enfants.Count > 0)
{
<ul class="liste-sous-series">
@foreach (var enfant in Enfants.Where(e => !CheminAvecSerie.Contains(e.Id)))
{
<ArbreSerie Serie="enfant" Toutes="Toutes" Livres="Livres"
Totaux="Totaux" Chemin="@CheminAvecSerie"
ActionsActives="ActionsActives"
MotifBlocage="MotifBlocage"
Deplies="Deplies"
OnDeplacer="OnDeplacer" />
}
</ul>
}
</div>
</ul>
}
</li>
@code {
[Parameter, EditorRequired] public SerieDto Serie { get; set; } = default!;
[Parameter, EditorRequired] public IReadOnlyList<SerieDto> Toutes { get; set; } = [];
[Parameter, EditorRequired] public IReadOnlyList<LivreDto> Livres { get; set; } = [];
[Parameter, EditorRequired] public IReadOnlyDictionary<int, (int Possedes, int Total)> Totaux { get; set; } = new Dictionary<int, (int, int)>();
[Parameter] public IReadOnlySet<int> Chemin { get; set; } = new HashSet<int>();
[Parameter] public ISet<int> Deplies { get; set; } = new HashSet<int>();
/// <summary>
/// Nœuds REPLIÉS, et non dépliés : le défaut est de tout montrer.
/// </summary>
/// <remarks>
/// État local de l'écran, volontairement non persistant : c'est un confort de lecture,
/// pas une propriété de la série.
/// </remarks>
[Parameter] public ISet<int> Replies { get; set; } = new HashSet<int>();
[Parameter] public bool ActionsActives { get; set; }
[Parameter] public string? MotifBlocage { get; set; }
[Parameter] public EventCallback<DeplacementSerie> OnDeplacer { get; set; }
@@ -90,24 +98,28 @@
.ToList();
private int _index => Soeurs.FindIndex(s => s.Id == Serie.Id);
private bool Deplie => Deplies.Contains(Serie.Id);
private bool Deplie => !Replies.Contains(Serie.Id);
private IReadOnlySet<int> CheminAvecSerie => new HashSet<int>(Chemin) { Serie.Id };
/// <summary>
/// « 3 sur 7 tomes », en comptant les descendants — c'est ce qu'on vient chercher.
/// </summary>
private string Avancement()
{
var total = Totaux.TryGetValue(Serie.Id, out var valeur)
? valeur
: (Possedes: Serie.NombrePossedes, Total: Serie.Elements.Count);
return total.Total == 0
? "Aucun tome recensé"
? "aucun tome recensé"
: $"{total.Possedes} sur {total.Total} tome{(total.Total > 1 ? "s" : "")}";
}
private void Basculer()
{
if (!Deplies.Add(Serie.Id))
if (!Replies.Add(Serie.Id))
{
Deplies.Remove(Serie.Id);
Replies.Remove(Serie.Id);
}
}
+72 -19
View File
@@ -1,5 +1,6 @@
@page "/series/{Id:int}"
@page "/series/{Id:int}/edition"
@page "/series/{Id:int}/ordre"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@@ -39,6 +40,15 @@ else
}
</p>
@if (ModeOrdre)
{
<p class="message-discret">
Rangez les tomes dans l'ordre où vous voulez les <strong>lire</strong> — ce n'est
pas toujours l'ordre de publication : une préquelle se lit avant le livre paru dix
ans plus tôt. Rien d'autre ne se modifie depuis cet écran.
</p>
}
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
@@ -56,13 +66,17 @@ else
@if (!ModeEdition && _filles.Count > 0)
{
<h2 class="titre-section">Séries de ce cycle</h2>
<ul class="liste-sous-series">
<ul class="liste-sous-series arbre">
@foreach (var fille in _filles)
{
<ArbreSerie Serie="fille" Toutes="_toutes" Livres="_livresCatalogue"
Totaux="_totaux" Deplies="_deplies"
@* ⚠️ Les flèches n'apparaissent QUE dans l'écran d'ordre. En consultation, elles
se déclenchaient en faisant défiler du pouce, et l'ordre de lecture d'une saga
changeait sans qu'on l'ait voulu — même raison que pour la liste d'envies,
dont le réordonnancement vit sur son propre écran. *@
<ArbreSerie Serie="fille" Toutes="_toutes"
Totaux="_totaux" Replies="_replies"
Chemin="@CheminRacine"
ActionsActives="Reseau.EnLigne" MotifBlocage="@MotifBlocage"
ActionsActives="@(ModeOrdre && Reseau.EnLigne)" MotifBlocage="@MotifBlocage"
OnDeplacer="DeplacerSousSerieAsync" />
}
</ul>
@@ -83,19 +97,25 @@ else
{
<li class="carte-tome @(element.Possede ? null : "tome-manquant")" @key="element.Id">
<div class="rang-actions">
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == 0)"
title="@MotifBlocage"
aria-label="@($"Monter « {element.Titre} »")"
@onclick="() => DeplacerAsync(index, index - 1)">▲</button>
@if (ModeOrdre)
{
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == 0)"
title="@MotifBlocage"
aria-label="@($"Monter « {element.Titre} »")"
@onclick="() => DeplacerAsync(index, index - 1)">▲</button>
}
<span class="rang-numero" aria-hidden="true">@(index + 1)</span>
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == _serie.Elements.Count - 1)"
title="@MotifBlocage"
aria-label="@($"Descendre « {element.Titre} »")"
@onclick="() => DeplacerAsync(index, index + 1)">▼</button>
@if (ModeOrdre)
{
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == _serie.Elements.Count - 1)"
title="@MotifBlocage"
aria-label="@($"Descendre « {element.Titre} »")"
@onclick="() => DeplacerAsync(index, index + 1)">▼</button>
}
</div>
<div class="carte-corps">
@@ -134,6 +154,8 @@ else
</p>
}
@if (!ModeOrdre)
{
<div class="actions-tome">
@if (!element.Possede)
{
@@ -172,6 +194,7 @@ else
Retirer
</button>
</div>
}
@if (_rattachement == element.Id)
{
@@ -237,7 +260,7 @@ else
</ul>
}
@if (!ModeEdition)
@if (ModeConsultation)
{
<h2 class="titre-section">Ajouter un tome</h2>
@@ -333,11 +356,24 @@ else
</p>
}
}
@if (!ModeEdition)
@if (ModeOrdre)
{
<nav class="actions-flottantes">
<a class="bouton bouton-principal" href="@($"series/{Id}")">Terminé</a>
</nav>
}
else if (ModeConsultation)
{
<nav class="actions-flottantes">
@if (Reseau.EnLigne)
{
@* Deux écrans distincts, et ce n'est pas une subtilité : « Modifier » touche à la
fiche (nom, cycle, suppression), « Changer l'ordre » à la place des tomes. Les
mêmes flèches offertes en consultation se déclenchaient au défilement. *@
@if (_serie.Elements.Count > 1 || _filles.Count > 1)
{
<a class="bouton bouton-discret" href="@($"series/{Id}/ordre")">Changer l'ordre</a>
}
<a class="bouton bouton-principal" href="@($"series/{Id}/edition")">Modifier</a>
}
else
@@ -362,7 +398,8 @@ else
private List<SerieDto> _filles = [];
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totaux =
new Dictionary<int, (int, int)>();
private readonly HashSet<int> _deplies = [];
/// <summary>Nœuds repliés à la main. Tout est déplié par défaut — voir ArbreSerie.</summary>
private readonly HashSet<int> _replies = [];
private bool _chargement = true;
private bool _enCours;
@@ -385,9 +422,25 @@ else
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private IReadOnlySet<int> CheminRacine => new HashSet<int> { Id };
private bool ModeEdition =>
private bool ModeEdition => Suffixe("/edition");
/// <summary>
/// Écran dédié au réordonnancement, sur le modèle de <c>/souhaits/ordre</c>.
/// </summary>
/// <remarks>
/// ⚠️ Le mode est dans l'ADRESSE, pas dans un booléen interne : c'est la convention du
/// projet depuis la fiche livre, et elle fait du bouton « retour » du navigateur une
/// sortie naturelle. Comme les trois routes partagent le même paramètre, le composant
/// s'abonne à <c>LocationChanged</c> — sans quoi le routeur ne redessinerait rien.
/// </remarks>
private bool ModeOrdre => Suffixe("/ordre");
/// <summary>Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder.</summary>
private bool ModeConsultation => !ModeEdition && !ModeOrdre;
private bool Suffixe(string fin) =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
.EndsWith("/edition", StringComparison.OrdinalIgnoreCase);
.EndsWith(fin, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Séries dans lesquelles celle-ci peut être rangée.
+7 -6
View File
@@ -47,21 +47,21 @@ else if (_series.Count == 0)
}
else
{
<ul class="liste-series">
<ul class="liste-series arbre">
@foreach (var serie in Racines)
{
<ArbreSerie Serie="serie" Toutes="_series" Livres="_livres"
Totaux="_totaux" Deplies="_deplies" />
<ArbreSerie Serie="serie" Toutes="_series" Totaux="_totaux" Replies="_replies" />
}
</ul>
}
@code {
private IReadOnlyList<SerieDto>? _series;
private IReadOnlyList<LivreDto> _livres = [];
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totaux =
new Dictionary<int, (int, int)>();
private readonly HashSet<int> _deplies = [];
/// <summary>Nœuds repliés à la main. Tout est déplié par défaut — voir ArbreSerie.</summary>
private readonly HashSet<int> _replies = [];
private string _nouveau = string.Empty;
private bool _enCours;
private string? _erreur;
@@ -86,8 +86,9 @@ else
private async Task ChargerAsync()
{
// ⚠️ Le catalogue n'est plus chargé ici : l'arbre ne montre que la descendance des
// séries, plus leurs tomes. C'était une lecture complète du fonds pour rien.
_series = await Api.ListerSeriesAsync();
_livres = await Api.ListerAsync(new CritereLivres());
_totaux = TotauxSeries.Calculer(_series);
}
+78
View File
@@ -1100,6 +1100,84 @@ body {
.liste-series,
.liste-tomes,
/*
Arbre des séries : une simple imbrication de listes, indentée par un liseré vertical.
⚠️ Volontairement dépouillé — pas de carte, pas de couverture, pas de tome. L'arbre répond
à « qui contient quoi », et l'écran d'une série répond au reste. La version précédente
dépliait la liste complète des livres de chaque nœud, ce qui noyait la hiérarchie qu'elle
était censée montrer.
*/
.arbre,
.arbre-enfants {
list-style: none;
margin: 0;
padding: 0;
}
.arbre-enfants {
/* Le liseré remplace les traits d'un arbre dessiné : il suffit à rattacher visuellement
une descendance à son parent, sans pseudo-éléments à positionner. */
margin-left: 0.7rem;
padding-left: 0.8rem;
border-left: 2px solid var(--mb-bord);
}
.arbre-ligne {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2.25rem;
padding: 0.2rem 0;
}
.arbre-plier,
.arbre-puce {
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;
}
.arbre-plier {
cursor: pointer;
}
.arbre-titre {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
color: #1d2129;
text-decoration: none;
}
.arbre-titre:hover,
.arbre-titre:focus-visible {
text-decoration: underline;
}
.arbre-compte {
flex: 0 0 auto;
font-size: 0.8rem;
color: var(--mb-texte-doux);
white-space: nowrap;
}
.arbre-actions {
flex: 0 0 auto;
display: flex;
gap: 0.25rem;
}
.liste-sous-series {
list-style: none;
margin: 0.5rem 0;