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) + { + + } + + +
+ } + - @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 @@ - -