@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.

} } @* Titre, avancement et actions sur la MÊME ligne (lot T) : c'est la disposition déjà tenue par la bibliographie et la liste des auteurs, et elle rend les deux actions visibles sans descendre au bas d'une saga de vingt tomes. ⚠️ « Changer l'ordre » n'apparaît qu'à partir de DEUX tomes ou deux sous-séries : un bouton toujours présent mais inerte serait un recul. Et les deux écrans restent distincts — « Modifier » touche à la fiche (nom, cycle, suppression), « Changer l'ordre » à la place des tomes, dont les flèches ne vivent que là. *@

@_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 (_serie.Etat != EtatSerie.EnCours) { — Série @(_serie.Etat == EtatSerie.Terminee ? "terminée" : "abandonnée"). }

@if (ModeConsultation) {
@if (Reseau.EnLigne) { @* « Ajouter » se déplie sur place, comme « Filtrer » au catalogue : les quatre voies et leur formulaire vivaient en bas de page, après une saga de vingt tomes qu'il fallait dérouler pour les atteindre. Recliquer referme tout, la voie ouverte comprise. *@ @if (_serie.Elements.Count > 1 || _filles.Count > 1) { Changer l'ordre } @* « Modifier en lot » n'apparaît qu'à partir de DEUX tomes possédés : sur un seul, l'écran de la fiche fait aussi bien. Même seuil que « Changer l'ordre ». *@ @if (TomesModifiablesEnLot > 1) { } Modifier } else { }
}
@if (ModeConsultation && _ajoutOuvert) {

Ajouter un tome

@* QUATRE voies, annoncées d'abord, la vue correspondante ensuite. Une saga se recense d'un coup — souvent avant d'en posséder la moitié — puis se remplit au fil des achats, et l'on n'entre pas les tomes de la même façon selon qu'on les a en main, au catalogue, ou seulement en tête. Un tome saisi sans livre N'EST PAS un brouillon : c'est ce qui rend le trou visible. ⚠️ Rien n'est déplié tant qu'on n'a pas choisi : les quatre formulaires empilés occupaient l'écran sous la liste des tomes, et l'on ne voyait plus la saga. *@
@* ⚠️ La rafale MÈNE À L'ÉCRAN EXISTANT (`/ajout/rafale?serie=…`), elle n'est pas réimplémentée ici : la file, sa persistance et sa reprise sont un mécanisme entier, et une seconde copie divergerait. La série voyage dans l'adresse, et la rafale la retient dans sa file — c'est ce qui fait qu'une reprise rattache au bon endroit. *@ @if (Reseau.EnLigne) { En rafale } else { } @* 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 (_voie == VoieAjout.Manuelle) { @* ⚠️ Le champ accepte un TITRE ou un ISBN, et fait deux choses différentes selon ce qu'on y met : un titre recense une place (le tome qu'on n'a pas encore), un code désigne un livre — et on le cherche alors d'abord au catalogue, ensuite à la BnF. Sans cela, scanner un code créait un tome intitulé « 9782… ». *@
@* Facultatif, et volontairement à côté du titre : on le connaît en tenant le livre. *@
@if (_lookupEnCours) { } @* Le code désigne plusieurs livres DÉJÀ possédés : on ne choisit pas à la place de l'utilisateur — deux exemplaires d'un même ISBN sont légitimes. *@ @if (_ajoutParCode && _resultats.Count > 0) {

Ce code est déjà au catalogue. Quel exemplaire rattacher ?

@foreach (var livre in _resultats) { }
} @* Rien au catalogue : la cascade a répondu, on crée le livre puis on le rattache. *@ @if (_ajoutParCode && _lookupCandidats.Count > 0) {
@foreach (var candidat in _lookupCandidats) {

@candidat.Titre

@candidat.Auteur

@(candidat.Editeur ?? "éditeur inconnu") · @(candidat.Annee ?? "année inconnue")

}
} } @if (_voie == VoieAjout.Envies) {
@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 (_voie == VoieAjout.Catalogue) {
@foreach (var livre in _resultats) { }
}
} @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) {

@(TriParNumero ? "Tomes" : "Ordre de lecture")

@* ⚠️ Le tri est un CHOIX D'ÉCRAN, non persistant — comme le pliage des séries, et contrairement au rang des envies, qui est un ordre choisi et stocké. Ranger la liste autrement pour la lire ne change rien à l'ordre de lecture de la saga. Il n'apparaît qu'à partir de deux tomes ET si au moins un porte un numéro : sinon les deux tris donneraient la même liste. *@ @if (TriPossible) {
}
@* Modification en lot : on coche des tomes possédés, puis on choisit ce qu'on leur applique. Même patron que la sélection de la bibliographie (barre en haut, collante), sauf que l'action n'est pas un simple bouton mais un formulaire — il reste donc affiché sous la barre plutôt que de se déclencher au clic. *@ @if (LotActif) {
@if (_selectionLot.Count > 0) {
@_selectionLot.Count sélectionné@(_selectionLot.Count > 1 ? "s" : "")
} else {

Cochez les tomes à modifier ensemble.

}
} @* ⚠️ 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) {

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) { } } @code { [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; /// Le panneau de modification en lot est déplié. private bool _lotOuvert; /// Identifiants des LIVRES (pas des places) cochés pour la modification en lot. private readonly HashSet _selectionLot = []; private SaisieLot _saisieLot = new(); private string _titre = string.Empty; private int _parenteId; private EtatSerie _etat = EtatSerie.EnCours; private bool _confirmeSuppression; private string _nouveauTome = string.Empty; private string _nouveauNumero = string.Empty; /// Par où l'on ajoute un tome. Rien n'est déplié tant qu'on n'a pas choisi. /// /// ⚠️ « En rafale » n'y figure pas : c'est un LIEN vers l'écran de rafale, pas une vue de /// celui-ci. La file, sa persistance et sa reprise sont un mécanisme entier, qu'une seconde /// implémentation ferait diverger. /// private enum VoieAjout { Aucune, Manuelle, Catalogue, Envies } private VoieAjout _voie = VoieAjout.Aucune; /// Le panneau d'ajout est déplié, comme les filtres du catalogue. private bool _ajoutOuvert; /// La saisie manuelle a été reconnue comme un code, et non comme un titre. private bool _ajoutParCode; private ElementReference _champNouveauTome; private ElementReference _champRecherche; /// Champ à focaliser au prochain rendu, s'il y en a un. /// /// ⚠️ Une seule fois par ouverture : reprendre le focus à chaque rendu l'arracherait en /// pleine frappe, ces champs se reliant à chaque caractère. /// private VoieAjout? _focusAFaire; /// Tome dont le champ « numéro » est déplié, s'il y en a un. private int? _numeroOuvert; private string _numeroSaisi = string.Empty; private ElementReference _champNumero; /// Le champ « numéro » vient de s'ouvrir : à focaliser au prochain rendu. private bool _focusNumero; /// /// Trier par numéro de tome plutôt que par ordre de lecture. /// /// /// ⚠️ Confort de lecture, non persistant — même règle que le pliage des séries au /// catalogue. L'ordre de lecture, lui, est stocké : c'est une propriété de la saga. /// private bool _triParNumero; 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 _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"); /// /// ⚠️ Le tri par numéro est ignoré en mode ordre : on y déplace des rangs, et réordonner /// une liste triée autrement ferait sauter les tomes sous les flèches. /// private bool TriParNumero => _triParNumero && !ModeOrdre; /// /// Le choix n'apparaît qu'à partir de deux tomes, et si au moins un porte un numéro : /// sinon les deux tris rendraient exactement la même liste. /// private bool TriPossible => !ModeOrdre && _serie is { } serie && serie.Elements.Count > 1 && serie.Elements.Any(e => !string.IsNullOrWhiteSpace(e.Numero)); /// Les tomes dans l'ordre demandé — celui de lecture par défaut. private IReadOnlyList TomesAffiches { get { var elements = _serie?.Elements ?? []; // Le tri du serveur (Position) est déjà le bon : on ne le refait pas pour rien. return TriParNumero ? [.. elements.OrderBy(e => e.Numero, TriNaturel.Comparateur).ThenBy(e => e.Id)] : elements; } } /// /// Ouvre ou ferme le panneau d'ajout. /// /// /// ⚠️ Le refermer referme tout : la voie choisie, ce qui était tapé, et les résultats /// d'une recherche de code. Un panneau qui se rouvre sur l'état d'avant ferait croire à une /// saisie en cours alors qu'on l'avait quittée. /// private void BasculerAjout() { _ajoutOuvert = !_ajoutOuvert; if (!_ajoutOuvert) { Ouvrir(VoieAjout.Aucune); } else { // Les deux panneaux se marcheraient dessus sur un petit écran : un seul à la fois. _lotOuvert = false; } } /// Vrai quand ce qui est tapé dans la saisie manuelle est un code et non un titre. private bool SaisieEstUnCode => RechercheIsbn.Formes(_nouveauTome) is not null; /// /// Déplie une voie d'ajout, ou la referme si c'est celle qui était ouverte. /// private void Ouvrir(VoieAjout voie) { _erreur = null; _message = null; if (_voie == voie) { _voie = VoieAjout.Aucune; FermerRecherche(); return; } _voie = voie; _focusAFaire = voie; _nouveauTome = string.Empty; _nouveauNumero = string.Empty; _ajoutParCode = false; _lookupCandidats = []; // Les deux recherches de catalogue partagent le champ et les résultats : ouvrir celle-ci // ferme celle d'une place précise, sinon les deux listes se marcheraient dessus. FermerRecherche(); } protected override async Task OnAfterRenderAsync(bool premierRendu) { if (_focusNumero) { _focusNumero = false; await _champNumero.FocusAsync(); } if (_focusAFaire is not { } voie) { return; } _focusAFaire = null; switch (voie) { case VoieAjout.Manuelle: await _champNouveauTome.FocusAsync(); break; case VoieAjout.Catalogue: await _champRecherche.FocusAsync(); await ProposerAsync(); StateHasChanged(); break; } } private void OuvrirNumero(ElementSerieDto element) { _numeroOuvert = element.Id; _numeroSaisi = element.Numero ?? string.Empty; _focusNumero = true; } private async Task SurToucheNumero(KeyboardEventArgs e, ElementSerieDto element) { if (e.Key == "Enter" && Reseau.EnLigne) { await EnregistrerNumeroAsync(element); } else if (e.Key == "Escape") { _numeroOuvert = null; } } private Task EnregistrerNumeroAsync(ElementSerieDto element) => EcrireAsync(async () => { // Le titre est renvoyé tel quel : ce point d'entrée corrige le numéro, pas le tome. // ⚠️ `element.Titre` peut être celui du LIVRE rattaché ; c'est bien lui qu'on veut // conserver comme filet, la place n'en portant pas d'autre une fois détachée. var resultat = await Api.ModifierElementSerieAsync( element.Id, new AjoutElementSerie { Titre = element.Titre, Numero = _numeroSaisi }); if (resultat.EstOk) { _numeroOuvert = null; _numeroSaisi = string.Empty; } return resultat; }); /// Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder. private bool ModeConsultation => !ModeEdition && !ModeOrdre; /// /// Le panneau de lot n'a de sens qu'en consultation : la case à cocher se confondrait avec /// les flèches de l'écran d'ordre, qui reste dédié au déplacement des tomes. /// private bool LotActif => _lotOuvert && ModeConsultation; /// Tomes de la série effectivement rattachés à un livre du catalogue local. private int TomesModifiablesEnLot => _serie?.Elements.Count(e => e.LivreId is { } id && _livresCatalogue.Any(l => l.Id == id)) ?? 0; /// /// Ouvre ou ferme le panneau de modification en lot. Le refermer vide aussi la sélection et /// la saisie en cours — même règle que : un panneau qui se /// rouvrirait sur l'état d'avant laisserait croire à une saisie encore active. /// private void BasculerLot() { _lotOuvert = !_lotOuvert; _erreur = null; _selectionLot.Clear(); _saisieLot = new SaisieLot(); // Les deux panneaux se marcheraient dessus sur un petit écran : un seul à la fois. if (_lotOuvert) { _ajoutOuvert = false; Ouvrir(VoieAjout.Aucune); } } private void SelectionnerLot(int livreId, bool selectionne) { if (selectionne) { _selectionLot.Add(livreId); } else { _selectionLot.Remove(livreId); } } private void AnnulerLot() { _selectionLot.Clear(); _saisieLot = new SaisieLot(); _erreur = null; } /// /// Applique la saisie en lot à chacun des livres cochés, un appel à la fois — même patron que /// MasquerSelectionAsync de la bibliographie : pas de point d'entrée dédié, la boucle /// réutilise , en ne remplaçant que les champs /// cochés et en reprenant tels quels ceux qui ne le sont pas. /// private async Task EnregistrerLotAsync() { _enCours = true; _erreur = null; var erreurs = new List(); foreach (var livreId in _selectionLot) { var livre = _livresCatalogue.FirstOrDefault(l => l.Id == livreId); if (livre is null) { continue; } var saisie = new EnregistrementLivre { Isbn = livre.Isbn, Titre = livre.Titre, Auteurs = _saisieLot.ModifierAuteurs ? _saisieLot.Auteurs : livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).ToList(), Themes = _saisieLot.ModifierThemes ? _saisieLot.Themes : [.. livre.Themes], Editeur = _saisieLot.ModifierEditeur ? _saisieLot.Editeur : livre.Editeur, Format = _saisieLot.ModifierFormat ? _saisieLot.Format : livre.Format, TypeDocument = _saisieLot.ModifierType ? _saisieLot.TypeDocument : livre.TypeDocument, Statut = _saisieLot.ModifierStatut ? _saisieLot.Statut : livre.Statut, NombrePages = livre.NombrePages, CoverUrl = livre.CoverUrl, UrlNotice = livre.UrlNotice, }; var resultat = await Api.ModifierAsync(livre.Id, saisie); if (!resultat.EstOk) { erreurs.Add(resultat.Erreur!); } } _enCours = false; if (erreurs.Count > 0) { _erreur = string.Join(" ", erreurs.Distinct()); await ChargerAsync(); return; } _lotOuvert = false; _selectionLot.Clear(); _saisieLot = new SaisieLot(); _message = "Les livres sélectionnés ont été modifiés."; await ChargerAsync(); } 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)).Reverse(); 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; _etat = _serie.Etat; } /// 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 AjouterSaisieAsync(); } } /// /// Traite la saisie manuelle : un titre recense une place, un code désigne un livre. /// /// /// ⚠️ Le catalogue d'abord, la BnF ensuite (choisi avec l'utilisateur le 2026-09-08). /// Un tome d'une saga est souvent déjà dans la bibliothèque — c'est même le cas ordinaire /// quand on range une série après coup : passer directement à la cascade distante recréerait /// un livre qu'on possède, et l'on n'aurait plus qu'un avertissement de doublon à trancher. /// /// Un seul livre au catalogue est rattaché sans rien demander ; plusieurs sont /// proposés, parce que posséder deux exemplaires d'un même ISBN est légitime et qu'on ne /// choisit pas à la place de quelqu'un. /// /// private async Task AjouterSaisieAsync() { if (!SaisieEstUnCode) { _ajoutParCode = false; await AjouterTomeAsync(null); return; } _ajoutParCode = true; _erreur = null; _message = null; _resultats = []; _lookupCandidats = []; _lookupEnCours = true; var possedes = await Api.ListerAsync(new CritereLivres { Recherche = _nouveauTome }); if (possedes.Count == 1) { _lookupEnCours = false; await AjouterTomeAsync(possedes[0].Id); return; } if (possedes.Count > 1) { _lookupEnCours = false; _resultats = possedes; return; } // Rien au catalogue : la cascade BnF → OpenLibrary. ⚠️ Aucune place n'est visée — le // livre choisi en créera une, contrairement au lookup d'un tome manquant déjà recensé. _lookupEnCours = false; _lookupElement = null; _lookupIsbn = _nouveauTome; await ChercherLookupAsync(); } private async Task AjouterTomeAsync(int? livreId) { // ⚠️ Le numéro saisi est transmis DANS LES DEUX CAS : on le connaît en tenant le livre, // et le perdre parce qu'on a rattaché un exemplaire du catalogue n'aurait aucun sens. await EcrireAsync(() => Api.AjouterElementSerieAsync( Id, new AjoutElementSerie { LivreId = livreId, Titre = livreId is null ? _nouveauTome : null, Numero = _nouveauNumero, })); if (_erreur is null) { _nouveauTome = string.Empty; _nouveauNumero = string.Empty; _ajoutParCode = false; _lookupCandidats = []; FermerRecherche(); // On ajoute rarement un seul tome depuis le catalogue : la voie reste ouverte, et // reprend sa proposition de départ plutôt que de rester sur une liste vide. if (_voie == VoieAjout.Catalogue) { await ProposerAsync(); } } } 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() { if (_voie == VoieAjout.Envies) { _voie = VoieAjout.Aucune; return; } _erreur = null; _message = null; _voie = VoieAjout.Envies; _rattachement = null; _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) { // La voie se referme : le message dit ce qui s'est passé, et la liste d'envies // rouverte au même endroit laisserait croire qu'il reste quelque chose à cliquer. _voie = VoieAjout.Aucune; _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; } } /// /// ⚠️ Deux usages, et la différence tient à _lookupElement : rattaché à une place /// déjà recensée (bouton « Ajouter par ISBN » d'un tome manquant), ou — s'il n'y en a /// pas — créant la place, ce qui est le cas de la saisie manuelle par code. /// private async Task AjouterCommeLivreAsync(CandidatLivre candidat) { _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, NombrePages = candidat.NombrePages, 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; } if (_lookupElement is { } place) { await EcrireAsync(() => Api.RattacherLivreSerieAsync(place.Id, resultat.Livre!.Id)); } else { // Saisie manuelle par code : la place n'existe pas encore, ce livre la crée. await AjouterTomeAsync(resultat.Livre!.Id); } if (_erreur is null) { _lookupElement = null; _lookupCandidats = []; _ajoutParCode = false; } } 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, Etat = _etat })); if (_erreur is null) { Navigation.NavigateTo($"series/{Id}"); } } 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) { // Une place précise et « depuis le catalogue » partagent champ et résultats : l'une // ferme l'autre. _voie = VoieAjout.Aucune; _rattachement = cible; _recherche = string.Empty; _resultats = []; } private void FermerRecherche() { _rattachement = null; _recherche = string.Empty; _resultats = []; } /// /// Montre déjà des livres alors que rien n'est tapé. /// /// /// ⚠️ C'est ce qui dit qu'il y a une recherche : un champ vide sous une liste vide ne /// se distingue pas d'un formulaire qui attend un titre exact. On propose donc les premiers /// livres, que la frappe restreint ensuite. /// private async Task ProposerAsync() { if (!string.IsNullOrWhiteSpace(_recherche)) { return; } _resultats = (await Api.ListerAsync(new CritereLivres())).Take(8).ToList(); } private async Task ChercherAsync(ChangeEventArgs e) { _recherche = e.Value?.ToString() ?? string.Empty; if (string.IsNullOrWhiteSpace(_recherche)) { // Effacer ce qu'on a tapé ramène la proposition de départ, pas le vide. await ProposerAsync(); 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!); } }