Files
mabibli/MaBibli.Client/Layout/MainLayout.razor
T
mathieuandClaude Opus 5 1b47828c11 Remonte le type de document, colle le menu, et fais du retour une remontée
Cinq retours d'usage du 2026-08-21 qui ne touchent que l'interface (lots Q, R,
S, T et V d'IDEES.md). Aucune migration, aucune entité modifiée.

Q — L'ordre des champs du formulaire livre est une décision, pas une mise en
page : type de document, thèmes, auteurs, rôles, puis le reste. Le type
commande la suite de la saisie — les rôles ne se posent que là — et il fallait
descendre tout le formulaire pour dire « c'est une BD », c'est-à-dire après
avoir saisi ce qui en dépend. Rien n'est présélectionné : « non précisé » ne
prétend toujours rien, et les rôles gardent leur règle actée (deux auteurs au
moins, BD ou non). Des tests verrouillent la préservation des rôles à chaque
frappe, que ce réordonnancement ne doit pas entamer.

R — Bandeau et menu passent dans un même conteneur collant : sur PC la rangée
des six destinations défilait avec la page et devenait inatteignable au bas
d'une longue liste. Un seul conteneur, et non deux éléments collants
superposés, qui auraient obligé à écrire en dur la hauteur d'un bandeau qui
varie avec la pastille hors-ligne et le nom d'utilisateur. Sur téléphone le
menu déployé occupe l'écran entier : sous le bandeau, il partageait l'écran
avec la liste qu'on quittait. Trois portes de sortie s'ajoutent à la fermeture
déjà en place sur LocationChanged — croix, Échap (le calque prend le focus à
l'ouverture, comme celui d'agrandissement des couvertures) et clic hors des
liens. Le plein écran est explicitement annulé au-delà de 40 rem, sans quoi un
menu ouvert au doigt puis une fenêtre agrandie laisseraient un calque sans
bascule pour le refermer. Toutes les règles du menu restent en feuille globale.

S — ⚠️ Le retour du bandeau devient une remontée hiérarchique d'un cran de
route, ce qui RENVERSE la décision actée le 2026-08-20 (« le retour passe par
l'historique du navigateur, jamais par une destination calculée »). Le motif
d'alors reste vrai — un même écran s'atteint par plusieurs chemins — mais
l'historique remonte aussi les allers-retours (filtre, ordre, édition) et l'on
cliquait cinq fois sans quitter le même écran. La parenté est une table
explicite et testable, pas un découpage naïf d'URL : toutes les routes n'ont
pas la forme d'une arborescence, /auteurs/{id}/bibliographie remontant à la
liste des auteurs, dont il n'existe pas de fiche. Le garde-fou « ne jamais
sortir de l'application » y vit désormais aussi : la fonction rend toujours un
chemin interne, là où history.length ne disait pas ce qu'on croyait dans une
PWA standalone. js/navigation.js n'a plus d'utilisateur et disparaît.

T — Sur la fiche d'une série, « Modifier » et « Changer l'ordre » rejoignent la
ligne du titre et de l'avancement, groupés à droite comme la bibliographie et
la liste des auteurs le font déjà. Reléguées au bas de l'écran, les actions
d'une saga de vingt tomes ne se découvraient qu'après avoir déroulé la liste.
« Changer l'ordre » n'apparaît toujours qu'à partir de deux tomes ou deux
sous-séries.

V — Dans la liste des revues, toute la ligne ouvre la fiche, comme la carte
entière le fait au catalogue. Le titre reste un vrai lien — adresse, clavier,
clic-milieu — et son clic ne remonte pas jusqu'à la ligne, qui naviguerait une
seconde fois ; toute action posée un jour sur cette ligne devra faire de même.

⚠️ Aucune vérification en navigateur : le rendu de ces écrans repose sur la
compilation et la relecture. 552 tests au vert (506 avant ce lot).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:38:46 +02:00

281 lines
11 KiB
Plaintext

@inherits LayoutComponentBase
@implements IDisposable
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@*
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.
*@
@*
⚠️ Bandeau et menu sont dans un MÊME conteneur collant, et ce n'est pas cosmétique : sur PC
la rangée du menu doit rester atteignable au défilement (lot R), et deux éléments collants
superposés obligeraient à écrire en dur la hauteur du bandeau dans le `top` du second.
*@
<div class="entete">
<header class="bandeau">
@* 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. *@
<button type="button" class="bandeau-icone" @onclick="Retour"
title="Revenir à l'écran précédent" aria-label="Revenir à l'écran précédent">
<span aria-hidden="true">&#8592;</span>
</button>
<a class="marque" href="">
@* 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. *@
<svg class="marque-logo" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
<polygon points="32,15 10,20 10,47 32,44" fill="currentColor" />
<polygon points="32,15 54,20 54,47 32,44" fill="currentColor" />
<line x1="32" y1="15" x2="32" y2="44" stroke="#1b3a5c" stroke-width="1.5" />
</svg>
<span class="marque-nom">MaBibli</span>
</a>
@if (!Reseau.EnLigne)
{
@* Une pastille dans le bandeau, visible sur tous les écrans : l'état du réseau change
l'usage de l'application, il ne doit pas se découvrir au premier clic qui échoue. *@
<span class="pastille-hors-ligne" title="@EtatReseau.MotifHorsLigne">Hors ligne</span>
}
@if (_utilisateur?.Identifiant is not null)
{
<span class="utilisateur" title="@(_utilisateur.Simule ? "Utilisateur simulé (développement)" : "Connecté via le portail YunoHost")">
@_utilisateur.Affichage@(_utilisateur.Simule ? " (dev)" : "")
</span>
}
<button type="button" class="bandeau-icone bandeau-bascule" @onclick="BasculerMenu"
aria-expanded="@_menuOuvert" aria-controls="menu-principal"
aria-label="@(_menuOuvert ? "Fermer le menu" : "Ouvrir le menu")">
<span aria-hidden="true">@(_menuOuvert ? "\u2715" : "\u2630")</span>
</button>
</header>
@*
Les six destinations de l'application. Sur PC elles tiennent en une rangée sous le
bandeau ; sur téléphone elles occupent l'écran ENTIER au clic sur la bascule (lot R) —
déployé sous le bandeau, le menu partageait l'écran avec la liste qu'on quittait, et l'on
choisissait sa destination au milieu d'autre chose.
⚠️ Un calque plein écran sans porte de sortie est un piège : trois en sont offertes, en plus
de la fermeture déjà en place sur LocationChanged — la croix, la touche Échap (le calque est
focalisé à l'ouverture, sans quoi aucun keydown ne lui parviendrait, comme celui
d'agrandissement des couvertures) et le clic hors des liens.
Les entrées restent actives hors-ligne : les six écrans se consultent depuis leurs
instantanés. Ce sont les écritures qui se désactivent, jamais la navigation.
*@
<nav id="menu-principal" class="menu @(_menuOuvert ? "menu-ouvert" : null)" aria-label="Navigation principale"
tabindex="-1" @ref="_menu" @onclick="FermerMenu" @onkeydown="SurToucheMenu">
@if (_menuOuvert)
{
<div class="menu-entete">
<span class="menu-titre">Aller à</span>
<button type="button" class="menu-fermer" @onclick="FermerMenu" aria-label="Fermer le menu">
<span aria-hidden="true">&#10005;</span>
</button>
</div>
}
<NavLink class="menu-lien" href="" Match="NavLinkMatch.All">Catalogue</NavLink>
<NavLink class="menu-lien" href="auteurs">Auteurs</NavLink>
<NavLink class="menu-lien" href="series">Séries</NavLink>
<NavLink class="menu-lien" href="revues">Revues</NavLink>
<NavLink class="menu-lien" href="prets">Prêts</NavLink>
<NavLink class="menu-lien" href="souhaits">Envies</NavLink>
</nav>
</div>
@if (!Reseau.EnLigne)
{
@*
Dire d'où viennent les données et de quand elles datent. Sans cette phrase, une
bibliothèque affichée hors-ligne est indiscernable d'une bibliothèque à jour — et un
livre ajouté depuis un autre appareil manquerait sans explication.
*@
<p class="bandeau-reseau" role="status">
<strong>Hors ligne.</strong>
@(Reseau.DerniereSynchro is { } synchro
? $" Données enregistrées {Quand(synchro)}. "
: " Aucune donnée n'a encore pu être enregistrée sur cet appareil. ")
Consultation et recherche fonctionnent ; les modifications sont impossibles.
</p>
}
<main class="contenu">
@Body
</main>
@code {
private UtilisateurCourant? _utilisateur;
private bool _etaitEnLigne = true;
private bool _synchroEnCours;
/// <summary>Menu déployé (téléphone). Sur PC la rangée est visible en permanence.</summary>
private bool _menuOuvert;
/// <summary>Le calque du menu doit prendre le focus au prochain rendu — sinon pas d'Échap.</summary>
private bool _menuAFocaliser;
private ElementReference _menu;
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();
_etaitEnLigne = Reseau.EnLigne;
_utilisateur = await Api.ObtenirUtilisateurAsync();
// Rafraîchit tout le fonds, pas seulement l'écran ouvert : c'est ce qui rend la
// bibliothèque entière consultable et cherchable après la coupure.
await SynchroniserAsync();
}
/// <summary>
/// Au retour du réseau, on recharge : la bibliothèque a pu changer depuis un autre appareil,
/// et les actions d'écriture redeviennent disponibles dans la foulée.
/// </summary>
private void SurChangementReseau()
{
var revenu = Reseau.EnLigne && !_etaitEnLigne;
_etaitEnLigne = Reseau.EnLigne;
_ = InvokeAsync(async () =>
{
StateHasChanged();
if (revenu)
{
_utilisateur = await Api.ObtenirUtilisateurAsync();
await SynchroniserAsync();
StateHasChanged();
}
});
}
private async Task SynchroniserAsync()
{
if (_synchroEnCours)
{
return;
}
_synchroEnCours = true;
try
{
await Api.SynchroniserAsync();
}
finally
{
_synchroEnCours = false;
}
}
/// <summary>Date de synchronisation en clair : l'heure suffit le jour même.</summary>
private static string Quand(DateTimeOffset instant)
{
var local = instant.ToLocalTime();
return local.Date == DateTimeOffset.Now.Date
? $"aujourd'hui à {local:HH:mm}"
: $"le {local:dd/MM/yyyy} à {local:HH:mm}";
}
/// <summary>La date affichée vient de changer : rien à recharger, juste à redessiner.</summary>
private void SurSynchro() => _ = InvokeAsync(StateHasChanged);
private void SurNavigation(object? _, LocationChangedEventArgs __)
{
if (!_menuOuvert)
{
return;
}
_menuOuvert = false;
_ = InvokeAsync(StateHasChanged);
}
/// <summary>
/// Remonte d'un cran dans l'arborescence des écrans, en repliant le menu au passage.
/// </summary>
/// <remarks>
/// ⚠️ <b>Renverse la décision du 2026-08-20</b> (« le retour passe par l'historique du
/// navigateur, jamais par une destination calculée »). L'historique remonte aussi les
/// allers-retours — filtre, ordre, édition — et l'on cliquait cinq fois sans quitter le même
/// écran. La parenté est désormais explicite et testable : voir
/// <see cref="RemonteeRoutes"/>, qui porte aussi le garde-fou « ne jamais sortir de
/// l'application » que <c>history.length</c> tenait mal dans une PWA <c>standalone</c>.
/// </remarks>
private void Retour()
{
FermerMenu();
Navigation.NavigateTo(RemonteeRoutes.Parent(Navigation.ToBaseRelativePath(Navigation.Uri)));
}
private void BasculerMenu()
{
_menuOuvert = !_menuOuvert;
// Le focus n'est demandé qu'à l'ouverture : c'est lui qui rend Échap opérant sur un
// calque, et il n'y a rien à focaliser une fois le menu refermé.
_menuAFocaliser = _menuOuvert;
}
/// <summary>
/// Referme le menu. Posé sur le calque lui-même : un clic hors des liens ferme, et un clic
/// SUR un lien ferme aussi — la navigation qui suit s'en chargerait de toute façon.
/// </summary>
private void FermerMenu() => _menuOuvert = false;
private void SurToucheMenu(KeyboardEventArgs e)
{
if (e.Key is "Escape" or "Esc")
{
FermerMenu();
}
}
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (_menuAFocaliser)
{
_menuAFocaliser = false;
await _menu.FocusAsync();
}
}
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
Reseau.SynchroChange -= SurSynchro;
Navigation.LocationChanged -= SurNavigation;
}
}