diff --git a/MaBibli.Client/Composants/BasculeBibliographie.razor b/MaBibli.Client/Composants/BasculeBibliographie.razor
new file mode 100644
index 0000000..dec5fad
--- /dev/null
+++ b/MaBibli.Client/Composants/BasculeBibliographie.razor
@@ -0,0 +1,30 @@
+@*
+ Les deux autres façons de regarder le même auteur, posées à côté du filtre.
+
+ Elles étaient auparavant dans une barre flottante en bas d'écran. Sur une bibliographie
+ de cinquante lignes, cela revenait à les cacher : « Nouveautés » est justement ce qu'on
+ veut au moment où l'on ouvre l'écran, pas après l'avoir parcouru.
+
+ Composant plutôt que balisage recopié : la bibliographie les affiche à deux endroits
+ (liste garnie, et liste vide où le filtre n'a pas lieu d'être), et deux copies auraient
+ divergé.
+*@
+@if (Biblio.EstNouveautes)
+{
+
+ Toute la bibliographie
+
+}
+else
+{
+
+ Nouveautés
+
+}
+
+Ses livres chez vous
+
+@code {
+ [Parameter, EditorRequired] public BibliographieDto Biblio { get; set; } = default!;
+}
diff --git a/MaBibli.Client/Layout/MainLayout.razor b/MaBibli.Client/Layout/MainLayout.razor
index 83c73cc..a702646 100644
--- a/MaBibli.Client/Layout/MainLayout.razor
+++ b/MaBibli.Client/Layout/MainLayout.razor
@@ -2,13 +2,43 @@
@implements IDisposable
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
+@inject NavigationManager Navigation
+@inject IJSRuntime JS
-@* Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
- Le PC hérite de la même colonne, simplement centrée et limitée en largeur. *@
+@*
+ Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
+ Le PC hérite de la même colonne, simplement centrée et limitée en largeur.
+
+ ⚠️ La navigation est passée EN HAUT le 2026-08-20, à la demande de l'utilisateur, après
+ que la barre d'onglets du bas se soit affichée « toujours très mal » sur son téléphone.
+ Cela renverse la décision du 2026-08-18 (« en bas, le pouce atteint le bas de l'écran ») :
+ le raisonnement sur le pouce reste juste, mais il ne vaut rien face à une barre qui ne
+ s'affiche pas correctement. Le repli retenu est celui du gabarit Blazor par défaut — un
+ bouton bascule et une liste qui se déploie — parce qu'il ne dépend d'AUCUNE mise en page
+ exotique : sans la moindre feuille de style, il reste une suite de liens lisibles les uns
+ sous les autres, au lieu d'une rangée écrasée.
+*@
+ @* Le déplacement entre écrans était jugé « foireux » : d'un écran profond (bibliographie,
+ fiche de tome, ajout d'envie) il fallait deviner quel onglet ramenait en arrière. Un
+ retour explicite, au même endroit sur tous les écrans, répond à la question sans
+ obliger à connaître l'arborescence. *@
+
+
-
- MaBibli
+ @* Pictogramme dessiné en ligne plutôt que chargé depuis /logo-bandeau.svg : un fichier
+ séparé se sert (ou non) indépendamment de l'application, et c'est précisément ce qui
+ produisait l'icône cassée en haut à gauche — un appareil dont le cache datait d'avant
+ l'ajout du fichier recevait un 404. Un SVG en ligne ne peut pas manquer. *@
+
+ MaBibli
@if (!Reseau.EnLigne)
@@ -24,8 +54,30 @@
@_utilisateur.Affichage@(_utilisateur.Simule ? " (dev)" : "")
}
+
+
+@*
+ Les six destinations de l'application. Sur PC elles tiennent en une rangée sous le
+ bandeau ; sur téléphone elles se déploient en liste au clic sur la bascule.
+
+ Les entrées restent actives hors-ligne : les six écrans se consultent depuis leurs
+ instantanés. Ce sont les écritures qui se désactivent, pas la navigation.
+*@
+
+
@if (!Reseau.EnLigne)
{
@*
@@ -46,38 +98,24 @@
@Body
-@*
- Navigation permanente, en bas de l'écran : six onglets, toujours au même endroit.
- Elle remplace les listes de liens que chaque page portait dans sa barre d'actions — cinq
- boutons y passaient sur deux ou trois lignes sur un téléphone, et l'entrée « Envies » ne
- figurait pas partout. Les barres d'actions restantes ne portent plus que des ACTIONS.
-
- ⚠️ Le pouce atteint le bas de l'écran, pas le haut : c'est ce qui justifie le bas plutôt
- qu'un rang d'onglets sous le bandeau. Les actions de page se placent juste au-dessus
- (variable --mb-onglets-hauteur, partagée avec .actions-flottantes).
-
- Les onglets restent actifs hors-ligne : les six écrans se consultent depuis leurs
- instantanés. Ce sont les écritures qui se désactivent, pas la navigation.
-*@
-
-
@code {
private UtilisateurCourant? _utilisateur;
private bool _etaitEnLigne = true;
private bool _synchroEnCours;
+ /// Menu déployé (téléphone). Sur PC la rangée est visible en permanence.
+ private bool _menuOuvert;
+
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurChangementReseau;
Reseau.SynchroChange += SurSynchro;
+ // ⚠️ Sans cela, le menu resterait déployé par-dessus l'écran qu'on vient d'atteindre :
+ // NavLink ne referme rien de lui-même, et un clic sur « Auteurs » laisserait les six
+ // entrées empilées au-dessus de la liste des auteurs.
+ Navigation.LocationChanged += SurNavigation;
+
// É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.
await Reseau.DemarrerAsync();
@@ -144,9 +182,37 @@
/// La date affichée vient de changer : rien à recharger, juste à redessiner.
private void SurSynchro() => _ = InvokeAsync(StateHasChanged);
+ private void SurNavigation(object? _, LocationChangedEventArgs __)
+ {
+ if (!_menuOuvert)
+ {
+ return;
+ }
+
+ _menuOuvert = false;
+ _ = InvokeAsync(StateHasChanged);
+ }
+
+ ///
+ /// Revient à l'écran précédent, en repliant le menu au passage.
+ ///
+ ///
+ /// Le retour passe par l'historique du navigateur, pas par une destination calculée :
+ /// c'est le seul moyen de ramener là d'où l'on vient réellement, un même écran étant
+ /// atteignable par plusieurs chemins (une fiche livre s'ouvre depuis le catalogue, une
+ /// bibliographie, une série ou une recherche). Le repli sur le catalogue quand la pile est
+ /// vide est traité en JavaScript, history.length n'ayant pas d'équivalent côté C#.
+ ///
+ private async Task RetourAsync()
+ {
+ _menuOuvert = false;
+ await JS.InvokeVoidAsync("mabibliRetour");
+ }
+
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
Reseau.SynchroChange -= SurSynchro;
+ Navigation.LocationChanged -= SurNavigation;
}
}
diff --git a/MaBibli.Client/Layout/MainLayout.razor.css b/MaBibli.Client/Layout/MainLayout.razor.css
index 28f597c..f8a92ad 100644
--- a/MaBibli.Client/Layout/MainLayout.razor.css
+++ b/MaBibli.Client/Layout/MainLayout.razor.css
@@ -4,9 +4,8 @@
z-index: 10;
display: flex;
align-items: center;
- justify-content: space-between;
- gap: 0.75rem;
- padding: 0.75rem 1rem;
+ gap: 0.5rem;
+ padding: 0.6rem 0.75rem;
background: #1b3a5c;
color: #fff;
}
@@ -19,21 +18,59 @@
display: inline-flex;
align-items: center;
gap: 0.4rem;
+
+ /* La marque prend la place restante : c'est elle qui repousse la bascule à droite,
+ sans dépendre d'un justify-content que le nombre d'éléments du bandeau ferait varier
+ (la pastille hors-ligne et le nom d'utilisateur vont et viennent). */
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.marque-nom {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.marque-logo {
width: 1.3rem;
height: 1.3rem;
flex: 0 0 auto;
+ color: #fff;
+}
+
+/* Boutons du bandeau (retour, bascule du menu). Cibles tactiles de 2.25rem : on les touche
+ au pouce, souvent en marchant. */
+.bandeau-icone {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ font-size: 1.15rem;
+ line-height: 1;
+ color: #fff;
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.35);
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.bandeau-icone:hover,
+.bandeau-icone:focus-visible {
+ background: rgba(255, 255, 255, 0.15);
}
.utilisateur {
+ flex: 0 1 auto;
font-size: 0.8rem;
opacity: 0.85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- max-width: 55%;
+ max-width: 40%;
}
/* L'état du réseau change ce que l'application permet : il se voit dans le bandeau, en
@@ -67,52 +104,86 @@
width: 100%;
max-width: 46rem;
margin: 0 auto;
- /* Marge basse généreuse : ni les onglets ni les actions flottantes ne doivent masquer
- le dernier livre de la liste. */
- padding: 1rem 1rem 9rem;
+ /* Marge basse généreuse : la barre d'actions flottantes ne doit pas masquer le dernier
+ livre de la liste. */
+ padding: 1rem 1rem 6rem;
}
-/* --- Onglets de navigation --- */
+/* --- Menu principal --- */
-.onglets {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 20;
+/*
+ Repli du gabarit Blazor par défaut : sur téléphone, la liste est masquée et se déploie
+ au clic sur la bascule ; sur PC elle devient une rangée sous le bandeau.
+
+ ⚠️ Le défaut de la version précédente (six onglets en `flex: 1 1 0` sur une barre fixe)
+ était qu'elle n'avait AUCUN état de repli : dès que la place manquait — ou que la feuille
+ de style scopée n'était pas celle attendue — les six libellés se retrouvaient collés,
+ tronqués ou empilés sans mise en forme. Ici, l'état sans style est une liste de liens.
+*/
+.menu {
+ display: none;
+ flex-direction: column;
+ background: #16304c;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.15);
+}
+
+.menu-ouvert {
display: flex;
- background: #fff;
- border-top: 1px solid var(--mb-bord);
- padding-bottom: env(safe-area-inset-bottom);
-
- /* Hauteur figée et partagée : .actions-flottantes s'en sert pour se poser juste au-dessus. */
- height: calc(var(--mb-onglets-hauteur) + env(safe-area-inset-bottom));
}
-.onglet {
- flex: 1 1 0;
- padding: 0.4rem 0.25rem;
- line-height: 1.4;
- text-align: center;
+.menu-lien {
+ display: block;
+ padding: 0.7rem 1rem;
+ color: #e8edf3;
text-decoration: none;
- font-size: 0.8rem;
- color: var(--mb-texte-doux);
+ font-size: 0.95rem;
+ border-left: 4px solid transparent;
+}
- /* Le trait actif est posé en haut de l'onglet, du côté du contenu qu'il désigne. */
- border-top: 3px solid transparent;
-
- /* Le libellé ne doit jamais passer sur deux lignes. Cinq onglets tiennent sur 320 px à
- cette taille ; c'est la raison du 0.8rem plutôt que du 0.85rem d'avant « Séries ». */
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+.menu-lien:hover,
+.menu-lien:focus-visible {
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff;
}
/* NavLink pose « active » tout seul ; « Catalogue » utilise NavLinkMatch.All, sans quoi
il resterait allumé sur toutes les pages — son href est la racine. */
-.onglet.active {
- color: var(--mb-accent);
+.menu-lien.active {
+ color: #fff;
font-weight: 600;
- border-top-color: var(--mb-accent);
- background: var(--mb-fond-doux);
+ background: rgba(255, 255, 255, 0.16);
+ border-left-color: #f5d76e;
+}
+
+/*
+ À partir de 40rem la rangée tient sans se comprimer, et la bascule n'a plus de raison
+ d'être : le menu est toujours visible, l'état déployé n'a plus d'effet.
+
+ Le point de rupture est à 40rem plutôt qu'aux 48rem du reste de la feuille : six libellés
+ courts tiennent bien avant que la grille de cartes ne s'élargisse, et les garder cachés
+ derrière une bascule sur une tablette serait un clic de trop.
+*/
+@media (min-width: 40rem) {
+ .menu {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ gap: 0.25rem;
+ padding: 0 0.5rem;
+ }
+
+ .bandeau-bascule {
+ display: none;
+ }
+
+ .menu-lien {
+ padding: 0.6rem 0.9rem;
+ border-left: 0;
+ border-bottom: 3px solid transparent;
+ }
+
+ .menu-lien.active {
+ background: rgba(255, 255, 255, 0.12);
+ border-bottom-color: #f5d76e;
+ }
}
diff --git a/MaBibli.Client/Pages/Bibliographie.razor b/MaBibli.Client/Pages/Bibliographie.razor
index 934413f..223e206 100644
--- a/MaBibli.Client/Pages/Bibliographie.razor
+++ b/MaBibli.Client/Pages/Bibliographie.razor
@@ -95,6 +95,8 @@ else if (_bibliographie is { } biblio)
auteur traduit n'y a souvent pas de notice à son nom.
}
+
+
}
else if (biblio.Avertissement is not null)
{
@@ -164,7 +166,14 @@ else if (_bibliographie is { } biblio)
aria-expanded="@_filtresOuverts">
Filtrer (@NombreFiltres)
+
+ @* Les deux bascules d'écran vivent ICI, à côté du filtre, et non plus dans une barre
+ flottante en bas : « toute la bibliographie / les nouveautés » et « ses livres chez
+ vous » sont trois façons de restreindre la même liste. Les chercher en bas d'un
+ écran qui défile sur cinquante lignes n'avait pas de sens. *@
+
+
@if (_filtresOuverts)
{
@@ -190,120 +199,161 @@ else if (_bibliographie is { } biblio)
}
+ @*
+ La barre de sélection est EN HAUT, au-dessus de la liste, et non plus flottante en
+ bas : on coche en descendant, et il fallait auparavant redescendre jusqu'au bas de
+ l'écran pour valider — ou pire, la barre recouvrait les dernières lignes qu'on
+ venait justement de vouloir cocher.
+ *@
+ @if (_selection.Count > 0)
+ {
+
+ @_selection.Count sélectionnée@(_selection.Count > 1 ? "s" : "")
+
+ @if (SelectionADecouvrir(biblio).Count is var aSouhaiter and > 0)
+ {
+
+ }
+
+ @if (SelectionParMasquage(biblio, false).Count is var aMasquer and > 0)
+ {
+
+ }
+
+ @if (SelectionParMasquage(biblio, true).Count is var aReafficher and > 0)
+ {
+
+ }
+
+
+
+ }
+
@foreach (var oeuvre in Affichees(biblio))
{
@* La classe « oeuvre-possedee » grise la ligne : le livre est déjà sur l'étagère. *@
+ @*
+ La case est à GAUCHE du titre, et la ligne est centrée verticalement :
+ c'est la disposition d'une liste qu'on parcourt en cochant. Elle est
+ offerte sur TOUTE œuvre visible, pas seulement sur celles à découvrir —
+ masquer en lot suppose de pouvoir cocher ce qu'on possède déjà.
+ *@
+
+
@if (oeuvre is { Possede: true, LivreId: { } livreId }
&& _livresCatalogue.FirstOrDefault(l => l.Id == livreId) is { } livre)
{
}
- @if (!oeuvre.Possede || !_livresCatalogue.Any(l => l.Id == oeuvre.LivreId))
+ else
{
-
}
- @if (!oeuvre.Masquee)
- {
+ @* Les boutons sont groupés à droite, centrés sur la même ligne que le titre :
+ une colonne d'actions alignée se balaie du regard, des boutons empilés au fil
+ du texte non. *@
+
- }
-
@* Le rapprochement est fait par titre, et il rate des choses (voir CleOeuvre). Le dire
franchement vaut mieux que laisser l'utilisateur découvrir seul qu'un livre qu'il
possède n'est pas grisé. *@
@@ -315,28 +365,6 @@ else if (_bibliographie is { } biblio)
}
}
-@* « Retour aux auteurs » est parti : l'onglet Auteurs fait exactement cela, en permanence.
- Ne reste que ce qu'aucun onglet ne sait faire — le catalogue restreint à cet auteur. *@
-@if (_bibliographie is { } b)
-{
-
-}
-
@code {
[Parameter]
public int AuteurId { get; set; }
@@ -375,11 +403,19 @@ else if (_bibliographie is { } biblio)
private int NombreFiltres =>
(_modePossession == 0 ? 0 : 1) + (_masquees > 0 && !_afficherMasquees ? 1 : 0);
+ /// Les œuvres cochées qui peuvent effectivement rejoindre la liste d'envies.
+ private List SelectionADecouvrir(BibliographieDto biblio) =>
+ biblio.Oeuvres.Where(o => _selection.Contains(o.Titre) && o.ADecouvrir).ToList();
+
+ private List SelectionParMasquage(BibliographieDto biblio, bool masquees) =>
+ biblio.Oeuvres.Where(o => _selection.Contains(o.Titre) && o.Masquee == masquees).ToList();
+
private async Task ChargerAsync()
{
_erreur = null;
_reessayable = false;
_chargement = true;
+ _selection.Clear();
try
{
@@ -396,8 +432,7 @@ else if (_bibliographie is { } biblio)
return;
}
- _possedees = _bibliographie.Oeuvres.Count(o => o.Possede);
- _masquees = _bibliographie.Oeuvres.Count(o => o.Masquee);
+ RecompterAsync();
}
catch (Exception)
{
@@ -420,6 +455,44 @@ else if (_bibliographie is { } biblio)
}
}
+ private void RecompterAsync()
+ {
+ _possedees = _bibliographie?.Oeuvres.Count(o => o.Possede) ?? 0;
+ _masquees = _bibliographie?.Oeuvres.Count(o => o.Masquee) ?? 0;
+ }
+
+ ///
+ /// Applique en mémoire ce que l'écriture vient de changer, au lieu de tout recharger.
+ ///
+ ///
+ /// ⚠️ C'est LA correction de lenteur signalée en usage : chaque « Je le veux » et chaque
+ /// « Masquer » relançaient un ChargerAsync complet, donc une interrogation de la BnF
+ /// (une seconde par page, jusqu'à dix pages pour les nouveautés, qui ne sont pas mises en
+ /// cache) suivie d'une relecture de tout le catalogue. L'écran attendait une source
+ /// distante pour apprendre un drapeau personnel qu'il connaissait déjà.
+ ///
+ /// Le cache partagé de est corrigé de la même façon de son
+ /// côté ; ici on corrige la copie que cet écran tient en main, celle des nouveautés n'étant
+ /// justement pas dans ce cache.
+ ///
+ ///
+ private void MettreAJourOeuvre(string titre, Func correction)
+ {
+ if (_bibliographie is null)
+ {
+ return;
+ }
+
+ _bibliographie = _bibliographie with
+ {
+ Oeuvres = _bibliographie.Oeuvres
+ .Select(o => o.Titre == titre ? correction(o) : o)
+ .ToList(),
+ };
+
+ RecompterAsync();
+ }
+
private void Selectionner(string titre, bool selectionnee)
{
if (selectionnee)
@@ -439,48 +512,76 @@ else if (_bibliographie is { } biblio)
return;
}
- var saisies = _bibliographie.Oeuvres
- .Where(o => _selection.Contains(o.Titre) && o.ADecouvrir)
- .Select(o => new EnregistrementSouhait
- {
- Titre = o.Titre,
- Auteur = _bibliographie.Auteur.Nom,
- Editeur = o.Editeur,
- Annee = o.Annee,
- Isbn = o.Isbn,
- })
- .ToList();
+ var oeuvres = SelectionADecouvrir(_bibliographie);
+ var saisies = oeuvres.Select(Saisie).ToList();
_enCours = true;
_message = null;
var resultats = await Api.AjouterSouhaitsAsync(saisies);
_enCours = false;
+
+ // Chaque envie réussie bascule sa ligne ; les échecs gardent la leur intacte et sont
+ // rapportés. Rien n'est rechargé — voir MettreAJourOeuvre.
+ for (var i = 0; i < oeuvres.Count; i++)
+ {
+ if (resultats[i].EstOk)
+ {
+ var id = resultats[i].Valeur?.Id;
+ MettreAJourOeuvre(oeuvres[i].Titre, o => o with { Souhaite = true, SouhaitId = id });
+ }
+ }
+
var erreurs = resultats.Where(r => !r.EstOk).Select(r => r.Erreur).Where(e => e is not null).ToList();
_message = erreurs.Count == 0 ? null : string.Join(" ", erreurs);
_selection.Clear();
- await ChargerAsync();
}
- private async Task MasquerAsync(OeuvreBibliographie oeuvre)
+ /// Masque (ou réaffiche) toutes les œuvres cochées qui sont dans l'autre état.
+ private async Task MasquerSelectionAsync(bool masquer)
{
- await ModifierMasquageAsync(oeuvre, false);
+ if (_bibliographie is null)
+ {
+ return;
+ }
+
+ var oeuvres = SelectionParMasquage(_bibliographie, !masquer);
+
+ _enCours = true;
+ _message = null;
+
+ var erreurs = new List();
+ foreach (var oeuvre in oeuvres)
+ {
+ var motif = masquer
+ ? await Api.MasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre)
+ : await Api.DemasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre);
+
+ if (motif is null)
+ {
+ MettreAJourOeuvre(oeuvre.Titre, o => o with { Masquee = masquer });
+ }
+ else
+ {
+ erreurs.Add(motif);
+ }
+ }
+
+ _enCours = false;
+ _message = erreurs.Count == 0 ? null : string.Join(" ", erreurs.Distinct());
+ _selection.Clear();
}
- private async Task DemasquerAsync(OeuvreBibliographie oeuvre)
- {
- await ModifierMasquageAsync(oeuvre, true);
- }
-
- private async Task ModifierMasquageAsync(OeuvreBibliographie oeuvre, bool demasquer)
+ private async Task ModifierMasquageAsync(OeuvreBibliographie oeuvre, bool masquer)
{
_enCours = true;
- _message = demasquer
- ? await Api.DemasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre)
- : await Api.MasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre);
+ _message = masquer
+ ? await Api.MasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre)
+ : await Api.DemasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre);
_enCours = false;
+
if (_message is null)
{
- await ChargerAsync();
+ MettreAJourOeuvre(oeuvre.Titre, o => o with { Masquee = masquer });
}
}
@@ -493,17 +594,25 @@ else if (_bibliographie is { } biblio)
}
///
- /// Ajoute une œuvre à la liste d'envies, en emportant ce que la BnF en sait.
+ /// Ce que la BnF sait de l'œuvre, mis en forme pour la liste d'envies.
///
///
/// L'auteur enregistré est celui de l'écran, pas celui de la notice : c'est lui que
/// l'utilisateur a en tête, et c'est sous ce nom qu'il retrouvera l'envie dans l'export.
///
- /// Après l'ajout, la bibliographie est relue plutôt que corrigée en mémoire : c'est
- /// le serveur qui sait rapprocher l'envie de l'œuvre, et refaire ce calcul ici le
- /// dupliquerait — avec le risque qu'il diverge.
+ /// L'ISBN de la BnF est celui d'UNE édition relevée, souvent à 10 chiffres et parfois
+ /// épuisée. Il est repris tel quel : il aide à commander, sans engager le choix.
///
///
+ private EnregistrementSouhait Saisie(OeuvreBibliographie oeuvre) => new()
+ {
+ Titre = oeuvre.Titre,
+ Auteur = _bibliographie!.Auteur.Nom,
+ Editeur = oeuvre.Editeur,
+ Annee = oeuvre.Annee,
+ Isbn = oeuvre.Isbn,
+ };
+
private async Task SouhaiterAsync(OeuvreBibliographie oeuvre)
{
if (_bibliographie is null)
@@ -514,17 +623,7 @@ else if (_bibliographie is { } biblio)
_enCours = true;
_message = null;
- var resultat = await Api.AjouterSouhaitAsync(new EnregistrementSouhait
- {
- Titre = oeuvre.Titre,
- Auteur = _bibliographie.Auteur.Nom,
- Editeur = oeuvre.Editeur,
- Annee = oeuvre.Annee,
-
- // L'ISBN de la BnF est celui d'UNE édition relevée, souvent à 10 chiffres et parfois
- // épuisée. Il est repris tel quel : il aide à commander, sans engager le choix.
- Isbn = oeuvre.Isbn,
- });
+ var resultat = await Api.AjouterSouhaitAsync(Saisie(oeuvre));
_enCours = false;
@@ -534,6 +633,8 @@ else if (_bibliographie is { } biblio)
return;
}
- await ChargerAsync();
+ MettreAJourOeuvre(
+ oeuvre.Titre,
+ o => o with { Souhaite = true, SouhaitId = resultat.Valeur?.Id });
}
}
diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs
index 38113d4..1d7ad7a 100644
--- a/MaBibli.Client/Services/ServiceLivresApi.cs
+++ b/MaBibli.Client/Services/ServiceLivresApi.cs
@@ -4,6 +4,7 @@ using System.Text.Json;
using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
+using MaBibli.Shared.Textes;
namespace MaBibli.Client.Services;
@@ -657,7 +658,13 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
() => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct);
if (resultat.EstOk)
{
- _bibliographies.Clear();
+ // ⚠️ Surtout PAS _bibliographies.Clear() : la bibliographie suivante repartirait
+ // interroger la BnF (~1 s par page, jusqu'à dix pages pour les nouveautés). C'est
+ // exactement ce qui rendait « Je le veux » interminable — l'écran attendait une
+ // source distante pour apprendre une chose qu'il savait déjà.
+ PatcherOeuvres(
+ o => CleOeuvre.Cle(o.Titre) == CleOeuvre.Cle(saisie.Titre),
+ o => o with { Souhaite = true, SouhaitId = resultat.Valeur?.Id });
}
return resultat;
@@ -825,16 +832,17 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ModifierBibliographieAsync(
() => http.PostAsJsonAsync(
$"api/auteurs/{auteurId}/bibliographie/masque",
- new MasquageBibliographie { Titre = titre }, Json, ct), ct);
+ new MasquageBibliographie { Titre = titre }, Json, ct), titre, true, ct);
public async Task DemasquerOeuvreBibliographieAsync(
int auteurId, string titre, CancellationToken ct = default) =>
await ModifierBibliographieAsync(
() => http.DeleteAsync(
- $"api/auteurs/{auteurId}/bibliographie/masque?titre={Uri.EscapeDataString(titre)}"), ct);
+ $"api/auteurs/{auteurId}/bibliographie/masque?titre={Uri.EscapeDataString(titre)}"),
+ titre, false, ct);
private async Task ModifierBibliographieAsync(
- Func> appel, CancellationToken ct)
+ Func> appel, string titre, bool masquee, CancellationToken ct)
{
var (reponse, motif) = await EnvoyerAsync(appel);
if (reponse is null)
@@ -842,8 +850,44 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
return motif;
}
- _bibliographies.Clear();
- return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
+ if (!reponse.IsSuccessStatusCode)
+ {
+ return await MessageErreurAsync(reponse, ct);
+ }
+
+ PatcherOeuvres(o => o.Titre == titre, o => o with { Masquee = masquee });
+ return null;
+ }
+
+ ///
+ /// Corrige en place les œuvres des bibliographies déjà en mémoire.
+ ///
+ ///
+ /// ⚠️ C'est ce qui remplace l'ancien _bibliographies.Clear(), et la différence est
+ /// une seconde d'attente contre plusieurs. Masquer une œuvre ou l'ajouter à ses envies ne
+ /// change rien à ce que la BnF connaît de l'auteur : seuls des drapeaux
+ /// personnels basculent, et le client sait lesquels sans redemander quoi que ce soit.
+ ///
+ /// Ce cache reste, comme depuis le lot D, en mémoire et pour la seule session : rien n'est
+ /// écrit dans l'instantané hors-ligne, une bibliographie n'étant pas une donnée à nous.
+ ///
+ ///
+ private void PatcherOeuvres(
+ Func cible,
+ Func correction)
+ {
+ foreach (var (auteurId, biblio) in _bibliographies.ToList())
+ {
+ if (!biblio.Oeuvres.Any(cible))
+ {
+ continue;
+ }
+
+ _bibliographies[auteurId] = biblio with
+ {
+ Oeuvres = biblio.Oeuvres.Select(o => cible(o) ? correction(o) : o).ToList(),
+ };
+ }
}
// ─────────────────────────────────────────────────────────────────────────
diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css
index 9615e04..3058c64 100644
--- a/MaBibli.Client/wwwroot/css/app.css
+++ b/MaBibli.Client/wwwroot/css/app.css
@@ -127,10 +127,6 @@ code {
--mb-fond-doux: #f4f5f7;
--mb-texte-doux: #5c6470;
--mb-danger: #a4242c;
-
- /* Hauteur de la barre d'onglets (MainLayout). Partagée pour que les actions flottantes
- se posent JUSTE au-dessus : deux barres fixes en bas d'écran se recouvriraient. */
- --mb-onglets-hauteur: 2.6rem;
}
body {
@@ -300,13 +296,14 @@ body {
margin: 1rem 0;
}
-/* Cette barre ne porte plus que des ACTIONS : la navigation est passée dans les onglets.
- Elle se pose au-dessus d'eux, jamais dessus. */
+/* Cette barre ne porte que des ACTIONS : la navigation est dans le menu du bandeau.
+ Depuis que ce menu est passé en haut (2026-08-20), plus rien ne l'accompagne en bas de
+ l'écran : elle s'y pose donc directement, sans décalage à réserver. */
.actions-flottantes {
position: fixed;
left: 0;
right: 0;
- bottom: calc(var(--mb-onglets-hauteur) + env(safe-area-inset-bottom));
+ bottom: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
@@ -817,6 +814,88 @@ body {
border-bottom: 1px solid var(--mb-bord);
}
+/* ⚠️ La ligne de bibliographie, elle, est centrée : la case à cocher est à gauche du titre
+ et les boutons à droite, sur la même ligne. Alignés en haut, la case flottait au-dessus du
+ texte et les boutons pendaient sous lui — c'est ce qui rendait la liste illisible à coup
+ d'œil, alors qu'on la parcourt précisément pour cocher. */
+.carte-oeuvre {
+ align-items: center;
+}
+
+/* La carte partagée d'un livre possédé sert de corps à la ligne : elle porte déjà sa propre
+ bordure et son propre rembourrage, qu'il faut neutraliser ici pour ne pas doubler le trait. */
+.carte-oeuvre > .carte-livre {
+ flex: 1;
+ min-width: 0;
+ padding: 0;
+ border-bottom: 0;
+}
+
+.oeuvre-case {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ /* Cible tactile : la case native fait 1rem, on double la zone cliquable autour. */
+ padding: 0.4rem;
+ margin: -0.4rem 0;
+ cursor: pointer;
+}
+
+.oeuvre-case input {
+ width: 1.1rem;
+ height: 1.1rem;
+ cursor: pointer;
+}
+
+.oeuvre-actions {
+ flex: 0 0 auto;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+/*
+ Barre d'actions de la sélection, EN HAUT de la liste et collante : on coche en descendant,
+ et une barre en bas d'écran obligeait soit à redescendre pour valider, soit — pire —
+ recouvrait les dernières lignes qu'on cherchait justement à cocher.
+*/
+.barre-selection {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0.75rem 0;
+ padding: 0.6rem 0.75rem;
+ background: var(--mb-fond-doux);
+ border: 1px solid var(--mb-bord);
+ border-radius: 6px;
+}
+
+.selection-compte {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--mb-texte-doux);
+}
+
+/* Libellé réservé aux lecteurs d'écran : la case à cocher n'a plus de texte visible à côté
+ d'elle, mais elle doit continuer à dire ce qu'elle sélectionne. */
+.lecture-ecran {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
/* Le corps prend la place restante et « min-width: 0 » l'autorise à rétrécir :
sans lui, un titre long pousse le bouton hors de l'écran sur mobile. */
.carte-souhait .carte-corps,
diff --git a/MaBibli.Client/wwwroot/index.html b/MaBibli.Client/wwwroot/index.html
index 10e1795..fbae10f 100644
--- a/MaBibli.Client/wwwroot/index.html
+++ b/MaBibli.Client/wwwroot/index.html
@@ -34,6 +34,9 @@
navigator.serviceWorker (absent hors contexte sécurisé), journalise un échec au lieu de
l'avaler, et propose la mise à jour quand une nouvelle version est prête. -->
+
+