2 Commits
Author SHA1 Message Date
mathieu 47d371fa3a On replie par defaut les série dans le catalogue.
Un bouton permet de déplier/replier toutes les séries d'un coup.
2026-09-10 01:39:17 +02:00
mathieu 61d4fb1678 Ajout d'un moyen de modifier par lot les ouvrages dans les séries. 2026-09-10 01:08:57 +02:00
6 changed files with 519 additions and 17 deletions
@@ -0,0 +1,226 @@
@inject ServiceLivresApi Api
@*
Panneau de modification en lot, ouvert depuis la fiche série une fois des tomes cochés.
⚠️ Reprend le DESIGN de FormulaireLivre.razor (mêmes composants, même ordre de champs), mais
chaque champ est précédé d'une case « Modifier… » : sans elle, la valeur par défaut d'un champ
(« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les livres
sélectionnés écraserait un champ qu'on ne voulait pas toucher (CLAUDE.md, invariant 10).
Un champ non coché ne s'affiche pas — c'est ce qui rend visible ce qui va être écrasé.
*@
<div class="formulaire">
<p class="message-avertissement" role="status">
Les champs cochés remplacent la valeur existante sur chacun des @NombreLivres livres
sélectionnés — pour les auteurs, une liste vide les retire tous.
</p>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierType" />
<span>Type</span>
</label>
</legend>
@if (Saisie.ModifierType)
{
<div class="segments">
@foreach (var type in Types)
{
<button type="button"
class="segment @Libelles.ClasseTypeDocument(type) @(Saisie.TypeDocument == type ? "segment-actif" : null)"
@onclick="() => Saisie.TypeDocument = type">
@Libelles.TypeDocument(type)
</button>
}
</div>
}
</fieldset>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierThemes" />
<span>Thèmes</span>
</label>
@if (Saisie.ModifierThemes)
{
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà dans la liste."
Suggestions="_themesConnus"
OnChangement="StateHasChanged" />
}
</div>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierAuteurs" />
<span>Auteurs</span>
</label>
@if (Saisie.ModifierAuteurs)
{
<SaisieListe Valeurs="Noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="Prénom Nom, ou Nom, Prénom"
MessageDoublon="Cet auteur est déjà dans la liste."
OnChangement="StateHasChanged">
<Complement Context="index">
@if (Saisie.Auteurs.Count > 1)
{
<div class="segments segments-role" role="group"
aria-label="@($"Rôle de {Saisie.Auteurs[index].Nom}")">
@foreach (var role in Roles)
{
<button type="button"
class="segment @(Saisie.Auteurs[index].Role == role ? "segment-actif" : null)"
@onclick="() => PoserRole(index, role)">
@Libelles.RoleAuteur(role)
</button>
}
</div>
}
</Complement>
</SaisieListe>
}
</div>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierFormat" />
<span>Format</span>
</label>
</legend>
@if (Saisie.ModifierFormat)
{
<div class="segments">
@foreach (var format in Formats)
{
<button type="button"
class="segment @(Saisie.Format == format ? "segment-actif" : null)"
@onclick="() => Saisie.Format = format">
@Libelles.Format(format)
</button>
}
</div>
}
</fieldset>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierEditeur" />
<span>Éditeur</span>
</label>
@if (Saisie.ModifierEditeur)
{
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
autocomplete="off" />
}
</div>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierStatut" />
<span>Statut de lecture <span class="champ-aide">(le vôtre)</span></span>
</label>
</legend>
@if (Saisie.ModifierStatut)
{
<ChoixStatut Valeur="Saisie.Statut" ValeurChanged="s => Saisie.Statut = s" />
}
</fieldset>
@if (!string.IsNullOrEmpty(Erreur))
{
<p class="message-erreur" role="alert">@Erreur</p>
}
@if (!string.IsNullOrEmpty(MessageBlocage))
{
<p class="message-avertissement" role="status">@MessageBlocage</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(EnCours || Bloque || Saisie.AucunChamp)"
title="@(Saisie.AucunChamp ? "Cochez au moins un champ à modifier." : MessageBlocage)"
@onclick="Valider">
@(EnCours ? "Enregistrement…" : LibelleValidation)
</button>
@if (OnAnnuler.HasDelegate)
{
<button type="button" class="bouton bouton-discret" disabled="@EnCours"
@onclick="OnAnnuler">
Annuler
</button>
}
</div>
</div>
@code {
private static readonly Format[] Formats = Enum.GetValues<Format>();
private static readonly TypeDocument[] Types = Enum.GetValues<TypeDocument>();
private static readonly RoleAuteur[] Roles = Enum.GetValues<RoleAuteur>();
private void PoserRole(int index, RoleAuteur role) =>
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role };
/// <summary>Voir la remarque équivalente sur FormulaireLivre.razor : référence stable tant
/// que la saisie ne change pas, sans quoi SaisieListe se remet à zéro à chaque frappe.</summary>
private NomsAuteurs? _noms;
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
private SaisieLot? _saisiePrecedente;
private IReadOnlyList<string> _themesConnus = [];
protected override async Task OnInitializedAsync()
{
try
{
_themesConnus =
[
.. (await Api.ListerAsync(new CritereLivres()))
.SelectMany(l => l.Themes)
.DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
.OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal),
];
}
catch (Exception)
{
_themesConnus = [];
}
}
protected override void OnParametersSet()
{
if (!ReferenceEquals(_saisiePrecedente, Saisie))
{
_saisiePrecedente = Saisie;
_noms = null;
}
}
[Parameter, EditorRequired] public SaisieLot Saisie { get; set; } = new();
[Parameter] public int NombreLivres { get; set; }
[Parameter] public string LibelleValidation { get; set; } = "Appliquer";
[Parameter] public string? Erreur { get; set; }
[Parameter] public bool EnCours { get; set; }
[Parameter] public string? MessageBlocage { get; set; }
private bool Bloque => !string.IsNullOrEmpty(MessageBlocage);
[Parameter] public EventCallback OnValider { get; set; }
[Parameter] public EventCallback OnAnnuler { get; set; }
private Task Valider() => OnValider.InvokeAsync();
}
+12 -13
View File
@@ -4,9 +4,9 @@
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 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.
⚠️ Tout est REPLIÉ par défaut ici (2026-09-10), à l'inverse de l'arbre des séries : le
catalogue liste D'ABORD les livres, une série n'y est qu'un détail qu'on ouvre au besoin —
alors que l'écran des séries EST la liste des séries, où les masquer toutes viderait l'écran.
⚠️ Replié, le bloc annonce toujours ce qu'il contient : sans ce compte, le compteur du haut
du catalogue paraîtrait mentir.
@@ -43,7 +43,7 @@
<ul class="liste-livres">
@foreach (var sous in Entree.SousEntrees)
{
<GroupeCatalogue Entree="sous" Replies="Replies" Totaux="Totaux" />
<GroupeCatalogue Entree="sous" Ouverts="Ouverts" Totaux="Totaux" />
}
</ul>
}
@@ -66,15 +66,14 @@
[Parameter, EditorRequired] public EntreeCatalogue Entree { get; set; } = default!;
/// <summary>
/// Blocs REPLIÉS, par identifiant de série — et non pliés : le défaut est de tout montrer.
/// Blocs OUVERTS, par identifiant de série — et non repliés : le défaut est de tout replier.
/// </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.
/// État local de l'écran, volontairement non persistant : 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>();
[Parameter, EditorRequired] public ISet<int> Ouverts { get; set; } = new HashSet<int>();
/// <summary>
/// Compteurs par série, descendance comprise : ce qu'on possède, et ce que la série recense.
@@ -95,13 +94,13 @@
private int Total => Compte.Total;
private bool Deplie => !Replies.Contains(Entree.Serie!.Id);
private bool Deplie => Ouverts.Contains(Entree.Serie!.Id);
private void Basculer()
{
if (!Replies.Add(Entree.Serie!.Id))
if (!Ouverts.Add(Entree.Serie!.Id))
{
Replies.Remove(Entree.Serie!.Id);
Ouverts.Remove(Entree.Serie!.Id);
}
}
}
+49
View File
@@ -0,0 +1,49 @@
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
namespace MaBibli.Client.Composants;
/// <summary>
/// Saisie du panneau « Modifier en lot » (fiche série).
/// </summary>
/// <remarks>
/// ⚠️ Chaque champ porte sa propre case <c>Modifier…</c> : sans elle, la valeur par défaut d'un
/// champ (« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les
/// livres sélectionnés écraserait un champ qu'on ne voulait pas toucher. Seuls les champs cochés
/// sont envoyés ; les autres livres restent inchangés sur les champs non cochés (CLAUDE.md,
/// invariant 10 : on n'affiche — et n'applique — que ce qui a été choisi).
/// </remarks>
public sealed class SaisieLot
{
public bool ModifierType { get; set; }
public TypeDocument TypeDocument { get; set; }
public bool ModifierThemes { get; set; }
public List<string> Themes { get; set; } = [];
public bool ModifierAuteurs { get; set; }
public List<AuteurSaisi> Auteurs { get; set; } = [];
public bool ModifierFormat { get; set; }
public Format Format { get; set; }
public bool ModifierEditeur { get; set; }
public string? Editeur { get; set; }
/// <summary>
/// Statut de lecture <b>de l'appelant</b>, comme partout ailleurs : appliquer un statut en
/// lot pose la lecture de la personne qui agit, jamais celle d'un tiers.
/// </summary>
public bool ModifierStatut { get; set; }
public Statut? Statut { get; set; }
public bool AucunChamp =>
!ModifierType && !ModifierThemes && !ModifierAuteurs
&& !ModifierFormat && !ModifierEditeur && !ModifierStatut;
}
+47 -4
View File
@@ -32,6 +32,14 @@
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
</button>
@if (_grouper && Entrees.Any(e => e.EstGroupe))
{
<button type="button" class="bouton bouton-discret bouton-compact"
@onclick="() => { if (AuMoinsUneSerieOuverte) { ToutReplier(); } else { ToutDeplier(); } }">
@(AuMoinsUneSerieOuverte ? "Replier les séries" : "Déplier les séries")
</button>
}
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a>
@@ -228,13 +236,18 @@ else if (_livres is not null)
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.
⚠️ Ici, contrairement à l'arbre des séries, tout est REPLIÉ par défaut (2026-09-10) :
le catalogue liste des livres, pas des séries — une série n'y est qu'un regroupement
qu'on ouvre au besoin. Le bouton qui bascule tout l'arbre est dans l'en-tête, à côté
de « Filtrer », pas ici : c'est un réglage d'affichage, pas une action sur la liste.
*@
<ul class="liste-livres">
@foreach (var entree in Entrees)
{
@if (entree.EstGroupe)
{
<GroupeCatalogue Entree="entree" Replies="_replies" Totaux="_totauxSeries" />
<GroupeCatalogue Entree="entree" Ouverts="_ouverts" Totaux="_totauxSeries" />
}
else
{
@@ -269,15 +282,45 @@ else if (_livres is not null)
private bool _grouper = true;
/// <summary>
/// Blocs repliés à la main, par identifiant de série. Tout est plié par défaut.
/// Blocs ouverts à la main, par identifiant de série. Tout est replié 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.
/// lecture, pas une propriété de la série.
/// </remarks>
private readonly HashSet<int> _replies = [];
private readonly HashSet<int> _ouverts = [];
private bool AuMoinsUneSerieOuverte => _ouverts.Count > 0;
private void ToutReplier() => _ouverts.Clear();
private void ToutDeplier()
{
foreach (var id in IdentifiantsSeries(Entrees))
{
_ouverts.Add(id);
}
}
private static IEnumerable<int> IdentifiantsSeries(IEnumerable<EntreeCatalogue> entrees)
{
foreach (var entree in entrees)
{
if (!entree.EstGroupe)
{
continue;
}
yield return entree.Serie!.Id;
foreach (var id in IdentifiantsSeries(entree.SousEntrees))
{
yield return id;
}
}
}
/// <summary>
/// Ce que chaque série possède et recense, descendance comprise.
+180
View File
@@ -74,6 +74,20 @@ else
{
<a class="bouton bouton-compact bouton-discret" href="@($"series/{Id}/ordre")">Changer l'ordre</a>
}
@*
« Modifier en lot » n'apparaît qu'à partir de DEUX tomes possédés : sur un
seul, l'écran de la fiche fait aussi bien. Même seuil que « Changer l'ordre ».
*@
@if (TomesModifiablesEnLot > 1)
{
<button type="button" class="bouton bouton-compact bouton-discret"
aria-expanded="@Aria.Etat(_lotOuvert)" aria-controls="lot-tomes"
@onclick="BasculerLot">
Modifier en lot
</button>
}
<a class="bouton bouton-compact bouton-principal" href="@($"series/{Id}/edition")">Modifier</a>
}
else
@@ -332,6 +346,38 @@ else
}
</div>
@*
Modification en lot : on coche des tomes possédés, puis on choisit ce qu'on leur
applique. Même patron que la sélection de la bibliographie (barre en haut, collante),
sauf que l'action n'est pas un simple bouton mais un formulaire — il reste donc
affiché sous la barre plutôt que de se déclencher au clic.
*@
@if (LotActif)
{
<div id="lot-tomes">
@if (_selectionLot.Count > 0)
{
<div class="barre-selection" role="group" aria-label="Actions sur la sélection">
<span class="selection-compte">
@_selectionLot.Count sélectionné@(_selectionLot.Count > 1 ? "s" : "")
</span>
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCours" @onclick="() => _selectionLot.Clear()">
Tout décocher
</button>
</div>
<FormulaireLotLivres Saisie="_saisieLot" NombreLivres="_selectionLot.Count"
Erreur="@_erreur" EnCours="_enCours" MessageBlocage="@MotifBlocage"
OnValider="EnregistrerLotAsync" OnAnnuler="AnnulerLot" />
}
else
{
<p class="message-discret">Cochez les tomes à modifier ensemble.</p>
}
</div>
}
@*
⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se
lit avant le livre paru dix ans plus tôt. Les flèches sont le seul moyen de le
@@ -342,6 +388,17 @@ else
@foreach (var (element, index) in TomesAffiches.Select((e, i) => (e, i)))
{
<li class="carte-tome @(element.Possede ? null : "tome-manquant")" @key="element.Id">
@if (LotActif && element.LivreId is { } livreLot
&& _livresCatalogue.Any(l => l.Id == livreLot))
{
<label class="oeuvre-case">
<input type="checkbox"
checked="@_selectionLot.Contains(livreLot)"
@onchange="e => SelectionnerLot(livreLot, e.Value is true)" />
<span class="lecture-ecran">Sélectionner « @element.Titre »</span>
</label>
}
<div class="rang-actions">
@if (ModeOrdre)
{
@@ -641,6 +698,14 @@ else
private bool _enCours;
private string? _erreur;
/// <summary>Le panneau de modification en lot est déplié.</summary>
private bool _lotOuvert;
/// <summary>Identifiants des LIVRES (pas des places) cochés pour la modification en lot.</summary>
private readonly HashSet<int> _selectionLot = [];
private SaisieLot _saisieLot = new();
private string _titre = string.Empty;
private int _parenteId;
private bool _confirmeSuppression;
@@ -765,6 +830,11 @@ else
{
Ouvrir(VoieAjout.Aucune);
}
else
{
// Les deux panneaux se marcheraient dessus sur un petit écran : un seul à la fois.
_lotOuvert = false;
}
}
/// <summary>Vrai quand ce qui est tapé dans la saisie manuelle est un code et non un titre.</summary>
@@ -860,6 +930,116 @@ else
/// <summary>Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder.</summary>
private bool ModeConsultation => !ModeEdition && !ModeOrdre;
/// <summary>
/// Le panneau de lot n'a de sens qu'en consultation : la case à cocher se confondrait avec
/// les flèches de l'écran d'ordre, qui reste dédié au déplacement des tomes.
/// </summary>
private bool LotActif => _lotOuvert && ModeConsultation;
/// <summary>Tomes de la série effectivement rattachés à un livre du catalogue local.</summary>
private int TomesModifiablesEnLot =>
_serie?.Elements.Count(e => e.LivreId is { } id && _livresCatalogue.Any(l => l.Id == id)) ?? 0;
/// <summary>
/// Ouvre ou ferme le panneau de modification en lot. Le refermer vide aussi la sélection et
/// la saisie en cours — même règle que <see cref="BasculerAjout"/> : un panneau qui se
/// rouvrirait sur l'état d'avant laisserait croire à une saisie encore active.
/// </summary>
private void BasculerLot()
{
_lotOuvert = !_lotOuvert;
_erreur = null;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
// Les deux panneaux se marcheraient dessus sur un petit écran : un seul à la fois.
if (_lotOuvert)
{
_ajoutOuvert = false;
Ouvrir(VoieAjout.Aucune);
}
}
private void SelectionnerLot(int livreId, bool selectionne)
{
if (selectionne)
{
_selectionLot.Add(livreId);
}
else
{
_selectionLot.Remove(livreId);
}
}
private void AnnulerLot()
{
_selectionLot.Clear();
_saisieLot = new SaisieLot();
_erreur = null;
}
/// <summary>
/// Applique la saisie en lot à chacun des livres cochés, un appel à la fois — même patron que
/// <c>MasquerSelectionAsync</c> de la bibliographie : pas de point d'entrée dédié, la boucle
/// réutilise <see cref="ServiceLivresApi.ModifierAsync"/>, en ne remplaçant que les champs
/// cochés et en reprenant tels quels ceux qui ne le sont pas.
/// </summary>
private async Task EnregistrerLotAsync()
{
_enCours = true;
_erreur = null;
var erreurs = new List<string>();
foreach (var livreId in _selectionLot)
{
var livre = _livresCatalogue.FirstOrDefault(l => l.Id == livreId);
if (livre is null)
{
continue;
}
var saisie = new EnregistrementLivre
{
Isbn = livre.Isbn,
Titre = livre.Titre,
Auteurs = _saisieLot.ModifierAuteurs
? _saisieLot.Auteurs
: livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).ToList(),
Themes = _saisieLot.ModifierThemes ? _saisieLot.Themes : [.. livre.Themes],
Editeur = _saisieLot.ModifierEditeur ? _saisieLot.Editeur : livre.Editeur,
Format = _saisieLot.ModifierFormat ? _saisieLot.Format : livre.Format,
TypeDocument = _saisieLot.ModifierType ? _saisieLot.TypeDocument : livre.TypeDocument,
Statut = _saisieLot.ModifierStatut ? _saisieLot.Statut : livre.Statut,
NombrePages = livre.NombrePages,
CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
};
var resultat = await Api.ModifierAsync(livre.Id, saisie);
if (!resultat.EstOk)
{
erreurs.Add(resultat.Erreur!);
}
}
_enCours = false;
if (erreurs.Count > 0)
{
_erreur = string.Join(" ", erreurs.Distinct());
await ChargerAsync();
return;
}
_lotOuvert = false;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
_message = "Les livres sélectionnés ont été modifiés.";
await ChargerAsync();
}
private bool Suffixe(string fin) =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
.EndsWith(fin, StringComparison.OrdinalIgnoreCase);
+5
View File
@@ -164,6 +164,11 @@ body {
.entete-fiche-actions {
flex: 0 0 auto;
/* Sans elle, un flex-basis « auto » prend la largeur de TOUS les boutons sur une seule
ligne (leur taille de contenu), et le flex-wrap ci-dessous ne peut jamais s'en servir :
la ligne déborde au lieu de se replier. Quatre boutons (dont « Modifier en lot ») y
tiennent tout juste à 1280px, mais débordent à 375px sans ce plafond. */
max-width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;