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>
294 lines
12 KiB
Plaintext
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">←</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">✕</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;
|
|
}
|
|
}
|