From 83089d7479c55a52d02140cfed9de25d327e0299 Mon Sep 17 00:00:00 2001 From: mathieu Date: Thu, 20 Aug 2026 20:35:39 +0200 Subject: [PATCH] Menu en haut, retour explicite, et bibliographie qui ne rappelle plus la BnF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation - La barre d'onglets du bas devient un menu en haut, sur le repli du gabarit Blazor : bascule sur téléphone, rangée sur PC. Cela renverse la décision du 2026-08-18 (le pouce atteint le bas de l'écran) : le raisonnement reste juste, mais il ne vaut rien face à une barre qui s'affiche mal. Le point tenu est qu'il existe désormais un état de repli — sans feuille de style, il reste une liste de liens lisible. - Bouton « retour » dans le bandeau, sur tous les écrans, appuyé sur l'historique du navigateur, avec repli sur le catalogue quand la pile est vide (PWA ouverte depuis l'écran d'accueil). - Le pictogramme de la marque passe en SVG en ligne : le fichier logo-bandeau.svg pouvait manquer sur un appareil dont le cache précédait son ajout, d'où l'icône cassée. Bibliographie - Case à cocher à gauche du titre, ligne centrée verticalement, boutons groupés à droite. - Barre de sélection collante EN HAUT, avec masquage groupé et « tout décocher ». - « Nouveautés » et « Ses livres chez vous » remontent à côté du filtre. - ⚠️ « Je le veux » et « Masquer » ne rechargent plus rien : ils basculent un drapeau personnel que le client connaît déjà. Auparavant chaque clic vidait le cache des bibliographies et relançait la BnF (une seconde par page, jusqu'à dix pages pour les nouveautés, qui ne sont pas mises en cache) puis relisait tout le catalogue. Co-Authored-By: Claude Opus 5 --- .../Composants/BasculeBibliographie.razor | 30 ++ MaBibli.Client/Layout/MainLayout.razor | 118 +++-- MaBibli.Client/Layout/MainLayout.razor.css | 149 +++++-- MaBibli.Client/Pages/Bibliographie.razor | 411 +++++++++++------- MaBibli.Client/Services/ServiceLivresApi.cs | 56 ++- MaBibli.Client/wwwroot/css/app.css | 93 +++- MaBibli.Client/wwwroot/index.html | 3 + MaBibli.Client/wwwroot/js/navigation.js | 18 + MaBibli.Client/wwwroot/logo-bandeau.svg | 13 - 9 files changed, 645 insertions(+), 246 deletions(-) create mode 100644 MaBibli.Client/Composants/BasculeBibliographie.razor create mode 100644 MaBibli.Client/wwwroot/js/navigation.js delete mode 100644 MaBibli.Client/wwwroot/logo-bandeau.svg 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 is { Possede: true, LivreId: { } livreIdPossede }) - { - @oeuvre.Titre - } - else - { - - } -

    +
    +

    + @if (oeuvre is { Possede: true, LivreId: { } livreIdPossede }) + { + @oeuvre.Titre + } + else + { + + } +

    -

    - @if (!string.IsNullOrWhiteSpace(oeuvre.Annee)) - { - @oeuvre.Annee - } - @if (!string.IsNullOrWhiteSpace(oeuvre.Editeur)) - { - @oeuvre.Editeur - } - @if (oeuvre.NombreEditions > 1) - { - @oeuvre.NombreEditions éditions - } +

    + @if (!string.IsNullOrWhiteSpace(oeuvre.Annee)) + { + @oeuvre.Annee + } + @if (!string.IsNullOrWhiteSpace(oeuvre.Editeur)) + { + @oeuvre.Editeur + } + @if (oeuvre.NombreEditions > 1) + { + @oeuvre.NombreEditions éditions + } - @if (oeuvre.Possede) - { - Déjà chez vous - } - else if (oeuvre.Souhaite) - { - Dans vos envies - } -

    -
    - @if (_oeuvresOuvertes.Contains(oeuvre.Titre)) - { -

    - @if (!string.IsNullOrWhiteSpace(oeuvre.Isbn)) - { - ISBN : @FormatageIsbn.Afficher(oeuvre.Isbn) - } - @if (oeuvre.NombreEditions > 1) - { - @oeuvre.NombreEditions éditions recensées - } -

    - } - } + @if (oeuvre.Possede) + { + Déjà chez vous + } + else if (oeuvre.Souhaite) + { + Dans vos envies + } +

    - @if (oeuvre.ADecouvrir) - { -
    - - + @if (_oeuvresOuvertes.Contains(oeuvre.Titre)) + { +

    + @if (!string.IsNullOrWhiteSpace(oeuvre.Isbn)) + { + ISBN : @FormatageIsbn.Afficher(oeuvre.Isbn) + } + @if (oeuvre.NombreEditions > 1) + { + @oeuvre.NombreEditions éditions recensées + } +

    + }
    } - @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. *@ +
    + @if (oeuvre.ADecouvrir) + { + + } + - } - else - { - - } +
  • }
- @if (_selection.Count > 0) - { -
- -
- } - @* 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. --> + + diff --git a/MaBibli.Client/wwwroot/js/navigation.js b/MaBibli.Client/wwwroot/js/navigation.js new file mode 100644 index 0000000..66938bc --- /dev/null +++ b/MaBibli.Client/wwwroot/js/navigation.js @@ -0,0 +1,18 @@ +/* + Retour à l'écran précédent. + + ⚠️ `history.back()` seul ne suffit pas : ouvert depuis l'écran d'accueil du téléphone, + l'application démarre sur une pile d'un seul cran, et « retour » sortirait de la PWA au + lieu de remonter d'un écran. On retombe alors sur le catalogue, qui est la racine. + + Volontairement posé sur `window` plutôt qu'exporté en module : l'appel vient de Blazor par + IJSRuntime, et un module imposerait un import asynchrone pour une seule fonction. +*/ +window.mabibliRetour = function () { + if (window.history.length > 1) { + window.history.back(); + return; + } + + window.location.href = '/'; +}; diff --git a/MaBibli.Client/wwwroot/logo-bandeau.svg b/MaBibli.Client/wwwroot/logo-bandeau.svg deleted file mode 100644 index dff9508..0000000 --- a/MaBibli.Client/wwwroot/logo-bandeau.svg +++ /dev/null @@ -1,13 +0,0 @@ - -