@page "/" @inject ServiceLivresApi Api @inject EtatReseau Reseau @inject NavigationManager Navigation @implements IDisposable MaBibli — catalogue @* Les filtres tiennent derrière un seul bouton, près du titre : deux rangées de segments occupaient en permanence le haut de l'écran d'un téléphone pour un réglage qu'on change rarement. Le compteur sur le bouton est ce qui rend le repli sans danger — un filtre actif et replié serait invisible, et l'on conclurait que des livres ont disparu. *@ @* ⚠️ « Ajouter un ouvrage » est ICI, à côté de « Filtrer », et non plus dans une barre flottante en bas : c'est le geste le plus fréquent après la recherche, et les deux boutons du haut se manœuvrent d'une main. Il n'y en a qu'UN — deux entrées identiques sur le même écran seraient du bruit, et le projet désencombre par le regroupement, jamais par l'effacement. ⚠️ Hors-ligne il est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable d'une fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau. *@

Catalogue

@if (Reseau.EnLigne) { Ajouter un ouvrage } else { }
@if (_auteur is not null) { @* Le « cliquer sur un auteur » d'CLAUDE.md : le catalogue est ici restreint à ses livres. *@

Livres de @_auteur.Nom. Revenir à toute la bibliothèque

} @* La recherche accepte aussi un CODE : douchette, caméra, ou code recopié à la main. C'est le geste « j'ai le livre en main, qu'est-ce que j'en sais déjà ? », l'inverse exact de l'ajout. ⚠️ Le scan reste offert HORS-LIGNE, contrairement à l'ajout : le décodage se fait dans le navigateur, et la recherche retombe sur l'instantané. Rien là-dedans n'a besoin du réseau. *@
@if (_scanOuvert) { } @if (_filtresOuverts) {
@* Un fonds tout physique n'a rien à filtrer par format : la ligne n'apprendrait rien et prendrait de la place. Cohérent avec « le format ne s'affiche que pour les ebooks ». Elle réapparaît d'elle-même au premier ebook ajouté. *@ @if (PlusieursFormats) {
@foreach (var format in Enum.GetValues()) { }
} @* Même logique que pour le format : tant qu'aucun type n'a été renseigné, la ligne n'aurait qu'un seul choix utile. Elle apparaît dès la première fiche typée. ⚠️ « Non précisé » y figure comme valeur à part entière : « qu'est-ce que je n'ai pas encore rangé ? » est justement la question qui se pose après coup. *@ @if (PlusieursTypes) {
@foreach (var type in Enum.GetValues()) { }
} @* ⚠️ Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut pour moi ne remonte sous aucune valeur, il reste visible sous « Tous ». L'écran l'assume au lieu de laisser croire à une vue commune. *@
@foreach (var statut in Enum.GetValues()) { }
@* Le prêt, lui, est COMMUN au foyer : ce filtre ne dépend de personne. L'écran « Prêts » répond déjà à « qu'est-ce qui est dehors ? » ; celui-ci sert l'autre moitié de la question — « qu'est-ce que j'ai réellement sous la main ? » —, celle qu'on se pose avant de promettre un livre à quelqu'un. ⚠️ Même règle que pour les formats et les types : la ligne disparaît tant que rien n'est prêté, où elle n'offrirait qu'un choix utile. Et comme pour eux, elle ne se déduit QUE d'un chargement sans critère, sinon une recherche qui ne remonte que des livres à la maison escamoterait le filtre sans qu'on puisse l'élargir. *@ @if (QuelqueChoseEstPrete) {
} @* « Quels livres me reste-t-il à illustrer ? » — la seule question à laquelle ce filtre répond, et la raison pour laquelle il existe. ⚠️ « Sans couverture » veut dire « sans URL enregistrée », PAS « dont l'image ne se charge pas ». Savoir la seconde demanderait d'interroger chaque URL, ce que CLAUDE.md interdit : OpenLibrary rend des 502 intermittents, et le même livre entrerait et sortirait du filtre d'un rafraîchissement à l'autre. ⚠️ La ligne disparaît quand le fonds est homogène — tout illustré, ou rien. Contrairement aux formats, les DEUX extrêmes sont inutiles ici : si aucun livre n'a de couverture, la liste complète EST déjà celle des livres à illustrer. Et comme partout, cela ne se déduit QUE d'un chargement sans critère. *@ @if (CouverturesInegales) {
} @* ⚠️ Grouper ne CACHE rien — c'est pourquoi cette bascule n'entre pas dans le compteur du bouton « Filtrer », qui ne compte que ce qui rendrait des livres invisibles. Elle se voit d'ailleurs dans la liste elle-même : avec ou sans blocs de série. Décochée, on retrouve exactement le catalogue d'avant : tous les livres à plat. *@ @if (_series is { Count: > 0 }) { }

Le statut de lecture est le vôtre : filtrer dessus montre votre lecture, pas celle du foyer. Le prêt, lui, est commun.

} @if (_erreur is not null) { } @if (_chargement && _livres is null) {

Chargement…

} else if (_livres is { Count: 0 }) {

@(EstFiltre ? "Aucun livre ne correspond à cette recherche." : "La bibliothèque est vide. Ajoutez un premier livre.")

} else if (_livres is not null) {

@_livres.Count livre@(_livres.Count > 1 ? "s" : "")

@* Les tomes d'une série tiennent sous son nom, à la place qu'occuperait le premier d'entre eux : l'ordre général du catalogue ne bouge pas, on ne perd donc pas un livre qu'on cherchait à sa lettre. Le nom du bloc MÈNE À LA SÉRIE, où figurent aussi les tomes qu'on ne possède pas — ce que le catalogue, lui, ne peut pas montrer. Chaque bloc se REPLIE, et un cycle replie chacun de ses niveaux : un cycle de vingt tomes occupait tout l'écran alors qu'on cherchait un livre rangé après lui. Replier ne retire rien de la liste — le compteur du haut ne bouge pas, et le bloc replié dit toujours ce qu'il contient. *@ } @code { /// Restreint le catalogue à un auteur. Vient de « ?auteur=… », posé par les liens de la liste. [SupplyParameterFromQuery(Name = "auteur")] public int? AuteurId { get; set; } private IReadOnlyList? _livres; /// /// Les séries du foyer, pour regrouper leurs tomes. null = pas encore lues. /// /// /// ⚠️ Elles ne sont lues qu'une fois par visite (et à chaque bascule du réseau), pas à /// chaque chargement du catalogue : celui-ci se relit à chaque frappe dans la recherche, et /// une requête de séries par caractère serait du gâchis pour une liste qui ne bouge pas /// pendant qu'on tape. Le prix est qu'un tome rattaché depuis un autre écran n'apparaît /// groupé qu'au prochain passage ici. /// private IReadOnlyList? _series; /// Regroupement actif — l'utilisateur peut le défaire, rien ne disparaît alors. private bool _grouper = true; /// /// Blocs repliés à la main, par identifiant de série. Tout est déplié par défaut. /// /// /// ⚠️ Le jeu appartient à l'ÉCRAN, et non aux blocs : le catalogue se relit à chaque frappe /// dans la recherche, et un état porté par les composants se perdrait à chaque caractère. /// Il n'est pas persistant d'une visite à l'autre pour autant — c'est un confort de /// lecture, pas une propriété de la série, même règle que l'arbre des séries. /// private readonly HashSet _replies = []; /// /// Ce que chaque série possède et recense, descendance comprise. /// /// /// ⚠️ Recalculé seulement quand les séries changent, et non à chaque chargement du /// catalogue : celui-ci se relit à chaque frappe dans la recherche. /// private IReadOnlyDictionary _totauxSeries = new Dictionary(); /// Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés. private IReadOnlyList Entrees => GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null); private AuteurDto? _auteur; private int? _auteurCharge; private string _recherche = string.Empty; private Format? _format; private TypeDocument? _type; private Statut? _statut; private bool? _prete; private bool? _sansCouverture; private bool _chargement; private bool _filtresOuverts; private string? _erreur; /// /// Formats réellement présents dans le fonds, ou null tant qu'on ne le sait pas. /// /// /// ⚠️ Ne se calcule que sur un chargement sans aucun critère. Le déduire d'une liste /// filtrée ferait disparaître la ligne « Tous / Physique / Numérique » dès qu'une recherche /// ne remonte qu'un seul format — et l'utilisateur ne pourrait plus l'élargir. Dans le doute /// (arrivée directe sur un auteur, par exemple) la ligne reste affichée : montrer un filtre /// inutile est bénin, en escamoter un qui sert ne l'est pas. /// private IReadOnlySet? _formatsDuFonds; private bool PlusieursFormats => _formatsDuFonds is null || _formatsDuFonds.Count > 1; /// Types réellement présents dans le fonds. Mêmes précautions que les formats. private IReadOnlySet? _typesDuFonds; private bool PlusieursTypes => _typesDuFonds is null || _typesDuFonds.Count > 1; /// /// Vrai si au moins un livre du fonds est actuellement dehors — ou qu'on ne le sait pas. /// /// Mêmes précautions que les formats : dans le doute, la ligne reste affichée. private bool? _fondsAvecPret; private bool QuelqueChoseEstPrete => _fondsAvecPret is not false; /// /// Vrai si le fonds contient à la fois des livres avec et sans couverture — ou qu'on ne le /// sait pas encore. /// /// /// ⚠️ Il faut les DEUX, contrairement au filtre de prêt qui se contente d'un prêt en cours : /// sur un fonds entièrement dépourvu de couvertures, « sans couverture » rendrait la liste /// entière, et ne serait donc pas un filtre. Dans le doute, la ligne reste affichée. /// private bool? _fondsCouverturesInegales; private bool CouverturesInegales => _fondsCouverturesInegales is not false; /// /// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ». /// /// /// La recherche et la restriction à un auteur n'y comptent pas : toutes deux se voient /// déjà à l'écran, l'une dans son champ, l'autre dans sa phrase. Ce compteur ne dit que ce /// qui serait invisible une fois le panneau refermé. /// private int FiltresActifs => (_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1) + (_prete is null ? 0 : 1) + (_sansCouverture is null ? 0 : 1); // Une frappe au clavier annule la recherche précédente encore en vol. private CancellationTokenSource _cts = new(); private bool EstFiltre => !string.IsNullOrWhiteSpace(_recherche) || _format is not null || _type is not null || _statut is not null || _prete is not null || _sansCouverture is not null || AuteurId is not null; /// /// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du /// serveur ; à la coupure, pour basculer sur l'instantané local sans laisser un écran vide. /// protected override void OnInitialized() => Reseau.Change += SurChangementReseau; /// /// Lit les séries, et se contente de ne pas grouper si elles manquent. /// /// /// ⚠️ Une lecture ratée ne doit rien coûter au catalogue : sans série, la liste s'affiche à /// plat, ce qui est exactement l'écran d'avant. Hors-ligne, la lecture retombe d'elle-même /// sur l'instantané « series ». /// private async Task ChargerSeriesAsync() { try { _series = await Api.ListerSeriesAsync(); _totauxSeries = TotauxSeries.Calculer(_series); } catch (Exception) { _series = null; _totauxSeries = new Dictionary(); } } private void SurChangementReseau() => _ = InvokeAsync(async () => { await ChargerSeriesAsync(); await ChargerAsync(); StateHasChanged(); }); protected override async Task OnParametersSetAsync() { if (_series is null) { await ChargerSeriesAsync(); } if (_auteurCharge != AuteurId) { _auteurCharge = AuteurId; _auteur = AuteurId is { } id ? await Api.ObtenirAuteurAsync(id) : null; } await ChargerAsync(); } private async Task SurRecherche(ChangeEventArgs e) { _recherche = e.Value?.ToString() ?? string.Empty; await ChargerAsync(); } /// Le scanner est ouvert au-dessus de la liste, pour retrouver un livre en main. private bool _scanOuvert; /// /// Un code lu remplit la recherche, exactement comme une douchette l'aurait tapé. /// /// /// Le code reste dans le champ : un décodage erroné se corrige et se relance, comme sur /// l'écran d'ajout. ⚠️ L'add-on EAN-2 est ignoré — il ne concerne que les revues, qui ne /// sont pas au catalogue. /// private async Task SurCodeScanneAsync(CodeScanne lecture) { _scanOuvert = false; _recherche = lecture.Code; await ChargerAsync(); } /// /// Ouvre la fiche quand le code désigne un seul livre du catalogue. /// /// /// C'est tout l'intérêt du scan : on tient le livre, on veut sa fiche, pas une liste d'un /// élément à cliquer. /// /// ⚠️ Deux conditions, et les deux comptent. Un seul résultat, sinon on choisirait à /// la place de l'utilisateur — deux exemplaires du même ISBN sont légitimes. Et un /// code complet et valide : sur un fragment en cours de frappe, le résultat unique /// est un hasard, et l'écran sauterait sous les doigts. /// /// private bool OuvrirSiUnique() { if (_livres is not { Count: 1 } || !RechercheIsbn.EstCodeComplet(_recherche)) { return false; } Navigation.NavigateTo($"livres/{_livres[0].Id}"); return true; } private Task FiltrerFormat(Format? format) { _format = format; return ChargerAsync(); } private Task FiltrerType(TypeDocument? type) { _type = type; return ChargerAsync(); } private Task FiltrerStatut(Statut? statut) { _statut = statut; return ChargerAsync(); } private Task FiltrerPret(bool? prete) { _prete = prete; return ChargerAsync(); } private Task FiltrerCouverture(bool? sansCouverture) { _sansCouverture = sansCouverture; return ChargerAsync(); } /// Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte. private static bool EstSansCritere(CritereLivres criteres) => string.IsNullOrWhiteSpace(criteres.Recherche) && criteres.Format is null && criteres.TypeDocument is null && criteres.Statut is null && criteres.Prete is null && criteres.SansCouverture is null && criteres.AuteurId is null; private async Task ChargerAsync() { _cts.Cancel(); _cts.Dispose(); _cts = new CancellationTokenSource(); var jeton = _cts.Token; _chargement = true; _erreur = null; try { var criteres = new CritereLivres { Recherche = _recherche, Format = _format, TypeDocument = _type, Statut = _statut, Prete = _prete, SansCouverture = _sansCouverture, AuteurId = AuteurId, }; _livres = await Api.ListerAsync(criteres, jeton); if (OuvrirSiUnique()) { return; } if (EstSansCritere(criteres)) { _formatsDuFonds = _livres.Select(l => l.Format).ToHashSet(); _typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet(); _fondsAvecPret = _livres.Any(l => l.PreteA is not null); _fondsCouverturesInegales = _livres.Any(l => string.IsNullOrEmpty(l.CoverUrl)) && _livres.Any(l => !string.IsNullOrEmpty(l.CoverUrl)); } } catch (OperationCanceledException) { return; // Une recherche plus récente a pris le relais. } catch (Exception) { _erreur = Reseau.EnLigne ? "Le catalogue n'a pas pu être chargé. Réessayez dans un instant." : "Aucune donnée n'a encore pu être enregistrée sur cet appareil."; } finally { if (!jeton.IsCancellationRequested) { _chargement = false; } } } public void Dispose() { Reseau.Change -= SurChangementReseau; _cts.Cancel(); _cts.Dispose(); } }