Files
mabibli/MaBibli.Client/Layout/MainLayout.razor
T
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

294 lines
12 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="@Aria.Etat(_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>
@*
« À propos » n'est PAS une septième destination : c'est une annexe, et elle est
visuellement détachée pour cela. Les six entrées ci-dessus sont la bibliothèque ; celle-ci
porte le manuel, le contact, la version et la licence — on y va une fois, pas tous les
jours. Mise sur le même rang, elle diluerait une navigation qu'on venait justement de
reprendre. Elle reste dans le menu plutôt qu'en pied de page : sur téléphone, un pied de
page vit sous une liste de trois cents livres.
⚠️ Elle reste active hors-ligne, comme les six autres : la version qu'on vient y lire est
justement ce qu'on cherche quand quelque chose ne va pas.
*@
<NavLink class="menu-lien menu-lien-annexe" href="a-propos">À propos</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;
}
}