@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.
*@
@*
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.
*@
@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.
*@
Hors ligne.
@(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.
}
@Body
@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;
/// Le calque du menu doit prendre le focus au prochain rendu — sinon pas d'Échap.
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();
}
///
/// 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.
///
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;
}
}
/// Date de synchronisation en clair : l'heure suffit le jour même.
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}";
}
/// 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);
}
///
/// Remonte d'un cran dans l'arborescence des écrans, en repliant le menu au passage.
///
///
/// ⚠️ Renverse la décision du 2026-08-20 (« 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
/// , qui porte aussi le garde-fou « ne jamais sortir de
/// l'application » que history.length tenait mal dans une PWA standalone.
///
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;
}
///
/// 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.
///
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;
}
}