@page "/series/{Id:int}" @page "/series/{Id:int}/edition" @page "/series/{Id:int}/ordre" @inject ServiceLivresApi Api @inject NavigationManager Navigation @inject EtatReseau Reseau @implements IDisposable MaBibli — @(_serie?.Titre ?? "série") @if (_serie is null) {

@(_chargement ? "Chargement…" : "Cette série n'est pas dans les données enregistrées sur cet appareil.")

} else { @if (!ModeEdition && _parente is { } parente) { @if (!ModeEdition) {

Fait partie de @parente.Titre.

} }

@_serie.Titre

@if (_serie.Elements.Count == 0) { Aucun tome recensé. Ajoutez ceux que vous possédez, et ceux qui vous manquent. } else { var total = Total(_serie); @total.Possedes sur @total.Total tomes dans la bibliothèque. }

@if (ModeOrdre) {

Rangez les tomes dans l'ordre où vous voulez les lire — ce n'est pas toujours l'ordre de publication : une préquelle se lit avant le livre paru dix ans plus tôt. Rien d'autre ne se modifie depuis cet écran.

} @if (_erreur is not null) { } @* La bascule vers les envies ne change rien à l'écran : sans un mot, on ne saurait pas qu'elle a eu lieu, et on cliquerait deux fois. *@ @if (_message is not null) {

@_message

} @* L'arbre est rendu récursivement ; le chemin protège le rendu si une base ancienne contient malgré tout une boucle. *@ @if (!ModeEdition && _filles.Count > 0) {

Séries de ce cycle

} @if (!ModeEdition && _serie.Elements.Count > 0) {

Ordre de lecture

@* ⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se lit avant le livre paru dix ans plus tôt. Les flèches sont le seul moyen de le changer — le glisser-déposer HTML5 ne fonctionne pas au doigt, et c'est sur téléphone qu'on consulte une saga en librairie. *@ } @if (ModeConsultation) {

Ajouter un tome

@* Deux voies, parce qu'une saga se recense d'un coup — souvent avant d'en posséder la moitié — puis se remplit au fil des achats. Un tome saisi sans livre N'EST PAS un brouillon : c'est ce qui rend le trou visible. *@
@* Lot P — le chemin inverse de « place vide → envie ». ⚠️ Ce geste n'ÉPARGNE QU'UNE RESAISIE : il crée une place ordinaire, LivreId à NULL, titre repris de l'envie. Rien ne relie les deux en base, et c'est délibéré — les séries sont COMMUNES au foyer, la liste d'envies est PERSONNELLE, et son sens même est de préparer un cadeau sans que l'autre le voie venir. Une clé étrangère vers l'envie afficherait « tome 3 souhaité par untel » à tout le monde. *@
@if (_enviesOuvertes) {
@if (_enviesChargement) { } else if (_envies.Count == 0) {

Votre liste d'envies est vide — ou ne contient rien qui ne soit déjà un tome de cette série.

} else {

Le tome est ajouté comme manquant, avec le titre de l'envie. L'envie reste dans votre liste : rien n'a été acheté, et personne d'autre ne verra qu'elle est à vous.

@foreach (var envie in _envies) { } }
} @if (_rattachement == AjoutDepuisCatalogue) {
@foreach (var livre in _resultats) { }
} } @if (ModeEdition) {

La série

@if (_confirmeSuppression) {

Les livres restent au catalogue ; seules les places disparaissent. @if (_filles.Count > 0) { Les @_filles.Count série(s) de ce cycle deviendront des séries de premier niveau. }

} } @if (ModeOrdre) { } else if (ModeConsultation) { } } @code { /// Marqueur de la recherche « ajouter depuis le catalogue », qui ne vise aucun élément. private const int AjoutDepuisCatalogue = -1; [Parameter] public int Id { get; set; } private IReadOnlyList _toutes = []; private IReadOnlyList _livresCatalogue = []; private SerieDto? _serie; private SerieDto? _parente; private List _filles = []; private IReadOnlyDictionary _totaux = new Dictionary(); /// Nœuds repliés à la main. Tout est déplié par défaut — voir ArbreSerie. private readonly HashSet _replies = []; private bool _chargement = true; private bool _enCours; private string? _erreur; private string _titre = string.Empty; private int _parenteId; private bool _confirmeSuppression; private string _nouveauTome = string.Empty; private int? _rattachement; private string _recherche = string.Empty; private IReadOnlyList _resultats = []; private ElementSerieDto? _lookupElement; private string _lookupIsbn = string.Empty; private IReadOnlyList _lookupCandidats = []; private bool _lookupEnCours; /// Le scanner est ouvert sur la place visée par le lookup ISBN (lot J1). private bool _lookupScan; private bool _enviesOuvertes; private bool _enviesChargement; private IReadOnlyList _envies = []; private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; private IReadOnlySet CheminRacine => new HashSet { Id }; private bool ModeEdition => Suffixe("/edition"); /// /// Écran dédié au réordonnancement, sur le modèle de /souhaits/ordre. /// /// /// ⚠️ Le mode est dans l'ADRESSE, pas dans un booléen interne : c'est la convention du /// projet depuis la fiche livre, et elle fait du bouton « retour » du navigateur une /// sortie naturelle. Comme les trois routes partagent le même paramètre, le composant /// s'abonne à LocationChanged — sans quoi le routeur ne redessinerait rien. /// private bool ModeOrdre => Suffixe("/ordre"); /// Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder. private bool ModeConsultation => !ModeEdition && !ModeOrdre; private bool Suffixe(string fin) => Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/') .EndsWith(fin, StringComparison.OrdinalIgnoreCase); /// /// Séries dans lesquelles celle-ci peut être rangée. /// /// /// Ni elle-même, ni ses descendantes : l'arbre se refermerait sur lui-même. Le serveur le /// refuse aussi — cette liste évite seulement de proposer un choix qui sera rejeté. /// private IEnumerable Candidates => _toutes.Where(s => s.Id != Id && !EstDescendante(s.Id)); private bool EstDescendante(int candidate) { var courante = _toutes.FirstOrDefault(s => s.Id == candidate); var garde = _toutes.Count + 1; while (courante is not null && garde-- > 0) { if (courante.Id == Id) { return true; } courante = courante.SerieParenteId is { } parenteId ? _toutes.FirstOrDefault(s => s.Id == parenteId) : null; } return false; } protected override void OnInitialized() { Reseau.Change += SurChangementReseau; Navigation.LocationChanged += SurNavigation; } private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(StateHasChanged); private void SurChangementReseau() => _ = InvokeAsync(async () => { await ChargerAsync(); StateHasChanged(); }); public void Dispose() { Reseau.Change -= SurChangementReseau; Navigation.LocationChanged -= SurNavigation; } protected override Task OnParametersSetAsync() => ChargerAsync(); private async Task ChargerAsync() { _chargement = true; _toutes = await Api.ListerSeriesAsync(); _livresCatalogue = await Api.ListerAsync(new CritereLivres()); _chargement = false; Rattacher(); } /// Recale les vues dérivées après un chargement ou une écriture. private void Rattacher() { _serie = _toutes.FirstOrDefault(s => s.Id == Id); if (_serie is null) { return; } _parente = _serie.SerieParenteId is { } parenteId ? _toutes.FirstOrDefault(s => s.Id == parenteId) : null; _filles = _toutes.Where(s => s.SerieParenteId == Id).OrderBy(s => s.Position).ToList(); _totaux = TotauxSeries.Calculer(_toutes); _titre = _serie.Titre; _parenteId = _serie.SerieParenteId ?? 0; } /// Remplace la série courante par celle que vient de rendre le serveur. private void Appliquer(SerieDto serie) { _toutes = _toutes.Select(s => s.Id == serie.Id ? serie : s).ToList(); Rattacher(); } private async Task SurToucheTome(KeyboardEventArgs e) { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveauTome) && Reseau.EnLigne) { await AjouterTomeAsync(null); } } private async Task AjouterTomeAsync(int? livreId) { await EcrireAsync(() => Api.AjouterElementSerieAsync( Id, new AjoutElementSerie { LivreId = livreId, Titre = livreId is null ? _nouveauTome : null })); if (_erreur is null) { _nouveauTome = string.Empty; FermerRecherche(); } } private Task RattacherAsync(int elementId, int? livreId) => EcrireAsync(async () => { var resultat = await Api.RattacherLivreSerieAsync(elementId, livreId); FermerRecherche(); return resultat; }); private async Task RetirerAsync(ElementSerieDto element) { _enCours = true; _erreur = await Api.RetirerElementSerieAsync(element.Id); _enCours = false; if (_erreur is null) { await ChargerAsync(); } } private async Task SouhaiterAsync(ElementSerieDto element) { _enCours = true; _erreur = null; var resultat = await Api.SouhaiterTomeAsync(element.Id); _enCours = false; _erreur = resultat.Erreur; if (resultat.EstOk) { _message = $"« {element.Titre} » est dans votre liste d'envies."; } } private (int Possedes, int Total) Total(SerieDto serie) => _totaux.TryGetValue(serie.Id, out var total) ? total : (serie.NombrePossedes, serie.Elements.Count); private async Task DeplacerSousSerieAsync(DeplacementSerie mouvement) { var soeurs = _toutes .Where(s => s.SerieParenteId == mouvement.ParenteId) .OrderBy(s => s.Position) .ThenBy(s => s.Id) .ToList(); var index = soeurs.FindIndex(s => s.Id == mouvement.SerieId); var cible = index + mouvement.Delta; if (index < 0 || cible < 0 || cible >= soeurs.Count) { return; } var ids = soeurs.Select(s => s.Id).ToList(); (ids[index], ids[cible]) = (ids[cible], ids[index]); await EcrireAsync(() => Api.ReordonnerSousSeriesAsync(mouvement.ParenteId, ids)); if (_erreur is null) { await ChargerAsync(); } } private void OuvrirScanTome() { _erreur = null; _lookupCandidats = []; _lookupScan = true; } /// /// Enchaîne le lookup sur le code lu, sans quitter la place visée. /// /// /// Le code reste dans le champ : un décodage erroné se corrige et se relance, comme sur /// l'écran d'ajout au catalogue. /// /// ⚠️ Un code de périodique (977) est refusé plus bas par ChercherLookupAsync : /// ElementSerie.LivreId ne pointe que vers Livre, et les revues restent /// délibérément hors de ce flux. /// /// private async Task SurCodeTomeAsync(CodeScanne lecture) { _lookupScan = false; _lookupIsbn = lecture.Code; await ChercherLookupAsync(); } /// /// Ouvre la liste des envies dans laquelle puiser un tome à recenser. /// /// /// ⚠️ Les envies déjà présentes dans la série sont écartées de la liste : les proposer /// mènerait droit au doublon, la place étant identifiée par son seul titre. /// private async Task OuvrirEnviesAsync() { _erreur = null; _message = null; _enviesOuvertes = true; _enviesChargement = true; var toutes = await Api.ListerSouhaitsAsync(); var deja = new HashSet( _serie?.Elements.Select(e => NormalisationTexte.Normaliser(e.Titre)) ?? [], StringComparer.Ordinal); _envies = toutes .Where(envie => !deja.Contains(NormalisationTexte.Normaliser(envie.Titre))) .ToList(); _enviesChargement = false; } /// /// Recense un tome à partir d'une envie, sans rien lier. /// /// /// La place créée est celle qu'on aurait saisie à la main : LivreId à NULL, /// titre repris de l'envie. L'envie survit — c'est la règle déjà tenue par « une /// envie déjà au catalogue est signalée, jamais supprimée », et à plus forte raison ici /// puisque rien n'a été acheté. /// /// ⚠️ Ce qui doit rester invisible au foyer n'est pas l'existence du tome — on l'aurait /// saisie à la main de toute façon — mais le lien avec la liste de quelqu'un. Il n'y /// en a aucun en base. /// /// private async Task AjouterDepuisEnvieAsync(SouhaitDto envie) { await EcrireAsync(() => Api.AjouterElementSerieAsync( Id, new AjoutElementSerie { Titre = envie.Titre })); if (_erreur is null) { _enviesOuvertes = false; _envies = []; _message = $"« {envie.Titre} » est recensé comme tome manquant. " + "Il reste dans votre liste d'envies."; } } private void OuvrirLookup(ElementSerieDto element) { _lookupElement = element; _lookupIsbn = string.Empty; _lookupCandidats = []; _lookupScan = false; _erreur = null; } private async Task SurToucheLookup(KeyboardEventArgs e) { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_lookupIsbn)) { await ChercherLookupAsync(); } } private async Task ChercherLookupAsync() { _lookupEnCours = true; _erreur = null; _lookupCandidats = []; var resultat = await Api.ChercherIsbnAsync(_lookupIsbn); _lookupEnCours = false; if (resultat is null) { _erreur = Reseau.EnLigne ? "La recherche n'a rien pu ramener." : EtatReseau.MotifHorsLigne; return; } if (resultat.Periodique is not null) { _erreur = "Les revues ne peuvent pas être rattachées à une série."; return; } _lookupCandidats = resultat.Candidats; if (_lookupCandidats.Count == 0) { _erreur = "Aucune notice ne correspond à cet ISBN."; } } private async Task AjouterCommeEnvieAsync(CandidatLivre candidat) { _enCours = true; _erreur = null; var resultat = await Api.AjouterSouhaitAsync(new EnregistrementSouhait { Titre = candidat.Titre, Auteur = candidat.Auteur, Editeur = candidat.Editeur, Annee = candidat.Annee, Isbn = candidat.IsbnInterroge, CoverUrl = candidat.CoverUrl, }); _enCours = false; _erreur = resultat.Erreur; if (resultat.EstOk) { _message = $"« {candidat.Titre} » est dans votre liste d'envies."; _lookupElement = null; } } private async Task AjouterCommeLivreAsync(CandidatLivre candidat) { if (_lookupElement is null) { return; } _enCours = true; _erreur = null; var saisie = new EnregistrementLivre { Isbn = candidat.IsbnInterroge, Titre = candidat.Titre, Auteurs = candidat.Auteurs.Select(a => new AuteurSaisi(a)).ToList(), Editeur = candidat.Editeur, CoverUrl = candidat.CoverUrl, UrlNotice = candidat.UrlNotice, }; var resultat = await Api.CreerAsync(saisie); _enCours = false; if (resultat.Doublons is not null) { _erreur = resultat.Doublons.Message; return; } if (!resultat.EstOk) { _erreur = resultat.Erreur; return; } await EcrireAsync(() => Api.RattacherLivreSerieAsync(_lookupElement.Id, resultat.Livre!.Id)); if (_erreur is null) { _lookupElement = null; } } private string? _message; private async Task DeplacerAsync(int depuis, int vers) { if (_serie is null || vers < 0 || vers >= _serie.Elements.Count) { return; } // Réordonné localement d'abord : la flèche doit répondre tout de suite, l'aller-retour // réseau ne doit pas se voir. Un échec recharge l'ordre du serveur. var ids = _serie.Elements.Select(e => e.Id).ToList(); (ids[depuis], ids[vers]) = (ids[vers], ids[depuis]); await EcrireAsync(() => Api.ReordonnerSerieAsync(Id, ids)); } private async Task EnregistrerAsync() { await EcrireAsync(() => Api.ModifierSerieAsync( Id, new EnregistrementSerie { Titre = _titre, SerieParenteId = _parenteId == 0 ? null : _parenteId })); // Le rangement dans un cycle change la parenté des AUTRES séries affichées : on relit. if (_erreur is null) { await ChargerAsync(); } } private async Task SupprimerAsync() { if (!_confirmeSuppression) { _confirmeSuppression = true; return; } _enCours = true; _erreur = await Api.SupprimerSerieAsync(Id); _enCours = false; if (_erreur is null) { Navigation.NavigateTo("series"); } } private void OuvrirRattachement(int cible) { _rattachement = cible; _recherche = string.Empty; _resultats = []; } private void FermerRecherche() { _rattachement = null; _recherche = string.Empty; _resultats = []; } private async Task ChercherAsync(ChangeEventArgs e) { _recherche = e.Value?.ToString() ?? string.Empty; if (string.IsNullOrWhiteSpace(_recherche)) { _resultats = []; return; } // La recherche passe par le service habituel : hors-ligne elle interroge l'instantané, // en ligne le serveur. Rien de particulier à prévoir ici. _resultats = (await Api.ListerAsync(new CritereLivres { Recherche = _recherche })) .Take(8) .ToList(); } /// Exécute une écriture qui rend la série, en tenant l'état d'attente et l'erreur. private async Task EcrireAsync(Func>> appel) { _enCours = true; _erreur = null; _message = null; var resultat = await appel(); _enCours = false; if (!resultat.EstOk) { _erreur = resultat.Erreur; // L'ordre affiché a pu être modifié localement avant l'appel : on reprend celui du // serveur plutôt que de laisser croire à un enregistrement. await ChargerAsync(); return; } Appliquer(resultat.Valeur!); } }