4 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
mathieu c5b4e117ba Ajout de l'url des envies directement dans la modification des ouvrages. 2026-09-09 21:47:03 +02:00
mathieuandClaude Sonnet 5 d76fb10ee0 Un liste d'ouvrage rafale est accessible depuis le menu
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 19:16:33 +02:00
10 changed files with 625 additions and 31 deletions
@@ -214,13 +214,17 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
souhait.Isbn = isbn; souhait.Isbn = isbn;
souhait.Note = Vide(saisie.Note); souhait.Note = Vide(saisie.Note);
// ⚠️ La couverture n'est écrite QUE si la charge utile en porte une, contrairement aux // ⚠️ Écrite SEULEMENT quand le geste de correction a été utilisé (CoverUrlModifiee) :
// autres champs. Raison : aucun écran n'offre de champ « URL de couverture » pour une // « Modifier une envie » enregistre aussi le titre, l'ISBN, etc. sans qu'on ait touché à
// envie — décision actée, un tel champ inviterait à coller des liens morts. Un // la couverture, et cette valeur affichée peut être une DÉDUCTION à la lecture (formule
// remplacement inconditionnel effacerait donc, à chaque correction de titre, l'image // OpenLibrary à ISBN connu, voir Projeter) plutôt que ce qu'il y a réellement en base.
// que la bibliographie avait fournie. Il n'y a rien à « vider » puisqu'il n'y a rien à // La matérialiser à chaque enregistrement figerait cette déduction, et une correction
// saisir, et la lecture retombe de toute façon sur la formule OpenLibrary à ISBN connu. // d'ISBN qui suit ne la referait plus jouer. Le drapeau distingue « je n'y ai pas
souhait.CoverUrl = Vide(saisie.CoverUrl) ?? souhait.CoverUrl; // touché » de « je viens de vider le champ », ce qu'un simple null ne pourrait pas dire.
if (saisie.CoverUrlModifiee)
{
souhait.CoverUrl = Vide(saisie.CoverUrl);
}
// ⚠️ Indispensable : TitreNormalise est la CLÉ D'ŒUVRE, celle qui sert au rapprochement // ⚠️ Indispensable : TitreNormalise est la CLÉ D'ŒUVRE, celle qui sert au rapprochement
// « déjà au catalogue ». Sans ce recalcul, corriger un titre laisserait le rapprochement // « déjà au catalogue ». Sans ce recalcul, corriger un titre laisserait le rapprochement
@@ -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 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. 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à ⚠️ Tout est REPLIÉ par défaut ici (2026-09-10), à l'inverse de l'arbre des séries : le
tenue par l'arbre des séries : l'inverse obligerait à ouvrir chaque bloc pour retrouver un catalogue liste D'ABORD les livres, une série n'y est qu'un détail qu'on ouvre au besoin —
livre qu'on voyait très bien avant. 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 ⚠️ Replié, le bloc annonce toujours ce qu'il contient : sans ce compte, le compteur du haut
du catalogue paraîtrait mentir. du catalogue paraîtrait mentir.
@@ -43,7 +43,7 @@
<ul class="liste-livres"> <ul class="liste-livres">
@foreach (var sous in Entree.SousEntrees) @foreach (var sous in Entree.SousEntrees)
{ {
<GroupeCatalogue Entree="sous" Replies="Replies" Totaux="Totaux" /> <GroupeCatalogue Entree="sous" Ouverts="Ouverts" Totaux="Totaux" />
} }
</ul> </ul>
} }
@@ -66,15 +66,14 @@
[Parameter, EditorRequired] public EntreeCatalogue Entree { get; set; } = default!; [Parameter, EditorRequired] public EntreeCatalogue Entree { get; set; } = default!;
/// <summary> /// <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> /// </summary>
/// <remarks> /// <remarks>
/// État local de l'écran, volontairement non persistant, comme celui de l'arbre des séries : /// État local de l'écran, volontairement non persistant : c'est un confort de lecture, pas
/// c'est un confort de lecture, pas une propriété de la série. Il survit en revanche aux /// une propriété de la série. Il survit en revanche aux rechargements du catalogue (frappe
/// rechargements du catalogue (frappe dans la recherche, filtre), le jeu appartenant à /// dans la recherche, filtre), le jeu appartenant à l'écran et non au bloc.
/// l'écran et non au bloc.
/// </remarks> /// </remarks>
[Parameter, EditorRequired] public ISet<int> Replies { get; set; } = new HashSet<int>(); [Parameter, EditorRequired] public ISet<int> Ouverts { get; set; } = new HashSet<int>();
/// <summary> /// <summary>
/// Compteurs par série, descendance comprise : ce qu'on possède, et ce que la série recense. /// 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 int Total => Compte.Total;
private bool Deplie => !Replies.Contains(Entree.Serie!.Id); private bool Deplie => Ouverts.Contains(Entree.Serie!.Id);
private void Basculer() 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;
}
+29 -4
View File
@@ -3,6 +3,7 @@
@inject ServiceLivresApi Api @inject ServiceLivresApi Api
@inject EtatReseau Reseau @inject EtatReseau Reseau
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject CacheHorsLigne Cache
@* @*
Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche. Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
@@ -93,6 +94,16 @@
</div> </div>
} }
@*
Une rafale non vidée reste consultable ailleurs sur le site — sans quoi y revenir
depuis un autre écran demande de refaire tout le chemin d'« Ajouter ». L'entrée
disparaît d'elle-même une fois la file effacée (bouton « Nouvelle rafale »).
*@
@if (_rafale is { Total: > 0 })
{
<NavLink class="menu-lien menu-lien-rafale" href="ajout/rafale">@LibelleRafale</NavLink>
}
<NavLink class="menu-lien" href="" Match="NavLinkMatch.All">Catalogue</NavLink> <NavLink class="menu-lien" href="" Match="NavLinkMatch.All">Catalogue</NavLink>
<NavLink class="menu-lien" href="auteurs">Auteurs</NavLink> <NavLink class="menu-lien" href="auteurs">Auteurs</NavLink>
<NavLink class="menu-lien" href="series">Séries</NavLink> <NavLink class="menu-lien" href="series">Séries</NavLink>
@@ -148,6 +159,12 @@
private ElementReference _menu; private ElementReference _menu;
/// <summary>Rafale non vidée, s'il y en a une. Relue à chaque navigation (lot AB).</summary>
private Rafale? _rafale;
private string LibelleRafale =>
_rafale is { Crees: > 0 } r ? $"Compte rendu ({r.Crees})" : "Rafale à reprendre";
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
Reseau.Change += SurChangementReseau; Reseau.Change += SurChangementReseau;
@@ -158,6 +175,8 @@
// entrées empilées au-dessus de la liste des auteurs. // entrées empilées au-dessus de la liste des auteurs.
Navigation.LocationChanged += SurNavigation; Navigation.LocationChanged += SurNavigation;
_rafale = await Cache.LireRafaleAsync();
// Écoute des bascules online/offline avant tout appel : un démarrage hors-ligne doit // Écoute des bascules online/offline avant tout appel : un démarrage hors-ligne doit
// aller directement au cache, sans attendre l'échec d'une requête. // aller directement au cache, sans attendre l'échec d'une requête.
await Reseau.DemarrerAsync(); await Reseau.DemarrerAsync();
@@ -226,13 +245,19 @@
private void SurNavigation(object? _, LocationChangedEventArgs __) private void SurNavigation(object? _, LocationChangedEventArgs __)
{ {
if (!_menuOuvert) if (_menuOuvert)
{ {
return; _menuOuvert = false;
} }
_menuOuvert = false; // ⚠️ Relu à CHAQUE navigation, pas seulement au quitter de l'écran de rafale : c'est ce
_ = InvokeAsync(StateHasChanged); // qui fait apparaître l'entrée du menu dès le premier code scanné, et disparaître dès
// la file effacée, où qu'on soit rendu entre-temps.
_ = InvokeAsync(async () =>
{
_rafale = await Cache.LireRafaleAsync();
StateHasChanged();
});
} }
/// <summary> /// <summary>
+47 -4
View File
@@ -32,6 +32,14 @@
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "") Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
</button> </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) @if (Reseau.EnLigne)
{ {
<a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a> <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 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 retire rien de la liste — le compteur du haut ne bouge pas, et le bloc replié dit
toujours ce qu'il contient. 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"> <ul class="liste-livres">
@foreach (var entree in Entrees) @foreach (var entree in Entrees)
{ {
@if (entree.EstGroupe) @if (entree.EstGroupe)
{ {
<GroupeCatalogue Entree="entree" Replies="_replies" Totaux="_totauxSeries" /> <GroupeCatalogue Entree="entree" Ouverts="_ouverts" Totaux="_totauxSeries" />
} }
else else
{ {
@@ -269,15 +282,45 @@ else if (_livres is not null)
private bool _grouper = true; private bool _grouper = true;
/// <summary> /// <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> /// </summary>
/// <remarks> /// <remarks>
/// ⚠️ Le jeu appartient à l'ÉCRAN, et non aux blocs : le catalogue se relit à chaque frappe /// ⚠️ 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. /// 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 /// 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> /// </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> /// <summary>
/// Ce que chaque série possède et recense, descendance comprise. /// Ce que chaque série possède et recense, descendance comprise.
+36 -3
View File
@@ -41,6 +41,16 @@ else if (_saisie is null)
else else
{ {
<div class="formulaire"> <div class="formulaire">
@* Même ligne que dans le formulaire d'un ouvrage (FormulaireLivre) : un aperçu quand il y
a quelque chose à montrer, puis un champ d'adresse ordinaire, au même titre que les
autres. *@
@if (!string.IsNullOrEmpty(_saisie.CoverUrl) || !string.IsNullOrWhiteSpace(_saisie.Titre))
{
<div class="formulaire-apercu">
<Couverture Url="@_saisie.CoverUrl" Titre="@_saisie.Titre" Classe="couverture-moyenne" />
</div>
}
<label class="champ"> <label class="champ">
<span class="champ-libelle">Titre <span class="obligatoire" aria-hidden="true">*</span></span> <span class="champ-libelle">Titre <span class="obligatoire" aria-hidden="true">*</span></span>
<input class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput" <input class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput"
@@ -73,6 +83,12 @@ else
@bind="_saisie.Isbn" @bind:event="oninput" placeholder="978…" autocomplete="off" /> @bind="_saisie.Isbn" @bind:event="oninput" placeholder="978…" autocomplete="off" />
</label> </label>
<label class="champ">
<span class="champ-libelle">URL de couverture</span>
<input class="champ-saisie" type="url" @bind="_saisie.CoverUrl" @bind:event="oninput"
placeholder="https://…" autocomplete="off" />
</label>
<label class="champ"> <label class="champ">
<span class="champ-libelle">Note</span> <span class="champ-libelle">Note</span>
<input class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput" <input class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
@@ -104,6 +120,13 @@ else
private bool _enCours; private bool _enCours;
private string? _erreur; private string? _erreur;
/// <summary>
/// La couverture telle que chargée — éventuellement déduite de l'ISBN (voir <c>Projeter</c>
/// côté serveur), pas forcément ce qu'il y a réellement en base. Sert uniquement à détecter,
/// à l'enregistrement, si l'utilisateur a touché au champ.
/// </summary>
private string? _couvertureChargee;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
Reseau.Change += SurChangementReseau; Reseau.Change += SurChangementReseau;
@@ -135,9 +158,8 @@ else
return; return;
} }
// ⚠️ La couverture n'est PAS reprise ici : elle n'a pas de champ de saisie, et le _couvertureChargee = souhait.CoverUrl;
// service la conserve donc telle qu'elle est en base. La valeur portée par le DTO peut
// en outre être déduite de l'ISBN — la figer serait écrire en base ce qui se calcule.
_saisie = new EnregistrementSouhait _saisie = new EnregistrementSouhait
{ {
Titre = souhait.Titre, Titre = souhait.Titre,
@@ -145,6 +167,7 @@ else
Editeur = souhait.Editeur, Editeur = souhait.Editeur,
Annee = souhait.Annee, Annee = souhait.Annee,
Isbn = souhait.Isbn, Isbn = souhait.Isbn,
CoverUrl = souhait.CoverUrl,
Note = souhait.Note, Note = souhait.Note,
}; };
} }
@@ -156,6 +179,16 @@ else
return; return;
} }
// ⚠️ Envoyée seulement si elle a changé depuis le chargement, et non à chaque
// enregistrement : la valeur affichée peut être une DÉDUCTION à la lecture (formule
// OpenLibrary à ISBN connu, voir `Projeter`) plutôt que ce qu'il y a réellement en base.
// La renvoyer telle quelle à chaque correction de titre la figerait en base, et une
// correction d'ISBN qui suit ne la referait plus jouer.
_saisie.CoverUrlModifiee = !string.Equals(
_saisie.CoverUrl?.Trim() ?? string.Empty,
_couvertureChargee?.Trim() ?? string.Empty,
StringComparison.Ordinal);
_enCours = true; _enCours = true;
_erreur = null; _erreur = null;
+180
View File
@@ -74,6 +74,20 @@ else
{ {
<a class="bouton bouton-compact bouton-discret" href="@($"series/{Id}/ordre")">Changer l'ordre</a> <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> <a class="bouton bouton-compact bouton-principal" href="@($"series/{Id}/edition")">Modifier</a>
} }
else else
@@ -332,6 +346,38 @@ else
} }
</div> </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 ⚠️ 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 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))) @foreach (var (element, index) in TomesAffiches.Select((e, i) => (e, i)))
{ {
<li class="carte-tome @(element.Possede ? null : "tome-manquant")" @key="element.Id"> <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"> <div class="rang-actions">
@if (ModeOrdre) @if (ModeOrdre)
{ {
@@ -641,6 +698,14 @@ else
private bool _enCours; private bool _enCours;
private string? _erreur; 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 string _titre = string.Empty;
private int _parenteId; private int _parenteId;
private bool _confirmeSuppression; private bool _confirmeSuppression;
@@ -765,6 +830,11 @@ else
{ {
Ouvrir(VoieAjout.Aucune); 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> /// <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> /// <summary>Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder.</summary>
private bool ModeConsultation => !ModeEdition && !ModeOrdre; 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) => private bool Suffixe(string fin) =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/') Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
.EndsWith(fin, StringComparison.OrdinalIgnoreCase); .EndsWith(fin, StringComparison.OrdinalIgnoreCase);
+23
View File
@@ -164,6 +164,11 @@ body {
.entete-fiche-actions { .entete-fiche-actions {
flex: 0 0 auto; 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; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
@@ -1664,6 +1669,20 @@ body {
font-size: 0.9rem; font-size: 0.9rem;
} }
/*
Le compte rendu de la dernière rafale (lot AB) : une entrée transitoire, présente
seulement tant qu'une file non vidée existe. Le même jaune que la pastille hors-ligne
la distingue des six destinations permanentes — c'est une chose à finir, pas un lieu.
*/
.menu-lien-rafale {
border-left-color: #f5d76e;
font-weight: 600;
}
.menu-lien-rafale.active {
border-left-color: #f5d76e;
}
/* /*
À partir de 40rem la rangée tient sans se comprimer, et la bascule n'a plus À partir de 40rem la rangée tient sans se comprimer, et la bascule n'a plus
de raison d'être. Le point de rupture est répété dans `MainLayout.razor.css` de raison d'être. Le point de rupture est répété dans `MainLayout.razor.css`
@@ -1705,6 +1724,10 @@ body {
border-bottom-color: #f5d76e; border-bottom-color: #f5d76e;
} }
.menu-lien-rafale {
border-bottom-color: #f5d76e;
}
/* En rangée, un filet au-dessus ne séparerait rien : le trait passe à GAUCHE, et la marge /* En rangée, un filet au-dessus ne séparerait rien : le trait passe à GAUCHE, et la marge
supérieure disparaît sous peine de désaligner l'entrée du reste de la rangée. */ supérieure disparaît sous peine de désaligner l'entrée du reste de la rangée. */
.menu-lien-annexe { .menu-lien-annexe {
+12
View File
@@ -65,6 +65,18 @@ public record EnregistrementSouhait
public string? CoverUrl { get; set; } public string? CoverUrl { get; set; }
/// <summary>
/// Vrai seulement si l'utilisateur vient d'utiliser le geste de correction de la couverture.
/// </summary>
/// <remarks>
/// ⚠️ Sans ce signal, <see cref="CoverUrl"/> à <c>null</c> serait ambigu entre « je n'y ai
/// pas touché » (garder ce qu'il y a en base, y compris rien du tout, et laisser jouer la
/// formule OpenLibrary à ISBN connu) et « je viens de vider le champ » (retirer la
/// couverture pour de bon). Modifier le titre ou l'ISBN d'une envie ne doit jamais
/// matérialiser en base la couverture déduite qu'elle ne fait qu'afficher à la lecture.
/// </remarks>
public bool CoverUrlModifiee { get; set; }
public string? Note { get; set; } public string? Note { get; set; }
} }