@page "/series/{Id:int}" @page "/series/{Id:int}/edition" @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 { @_serie.NombrePossedes sur @_serie.Elements.Count tomes dans la bibliothèque. }

@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

} @* Les sous-séries d'un cycle : on les liste, on ne les déplie pas — chacune a son écran. *@ @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 (!ModeEdition) {

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. *@
@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 (!ModeEdition) { } } @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 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 string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; private bool ModeEdition => Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/') .EndsWith("/edition", 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(); _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 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!); } }