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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user