- Catalogage rapide (douchette, code-barres)

- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne.
- Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais.
Séries et sagas
- Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles).
- Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un.
- Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence.
- Filtre catalogue « sans couverture » pour repérer les livres à illustrer.
Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total).
- Corrections directes sur la fiche
- Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ».
- Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification.
- Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe.
- La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper.

- Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
This commit is contained in:
mathieu
2026-09-09 00:05:26 +02:00
parent bb071c3db9
commit 42dec0dbd1
43 changed files with 4154 additions and 189 deletions
+555 -115
View File
@@ -58,6 +58,18 @@ else
<div class="entete-fiche-actions">
@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.
*@
<button type="button" class="bouton bouton-compact bouton-discret"
aria-expanded="@Aria.Etat(_ajoutOuvert)" aria-controls="ajout-tome"
@onclick="BasculerAjout">
Ajouter
</button>
@if (_serie.Elements.Count > 1 || _filles.Count > 1)
{
<a class="bouton bouton-compact bouton-discret" href="@($"series/{Id}/ordre")">Changer l'ordre</a>
@@ -73,6 +85,187 @@ else
}
</div>
@if (ModeConsultation && _ajoutOuvert)
{
<div class="panneau-ajout" id="ajout-tome">
<h2 class="titre-section">Ajouter un tome</h2>
@*
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.
*@
<div class="segments" role="group" aria-label="Comment ajouter un tome">
<button type="button" class="segment @(_voie == VoieAjout.Manuelle ? "segment-actif" : null)"
@onclick="() => Ouvrir(VoieAjout.Manuelle)">Manuellement</button>
@*
⚠️ 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)
{
<a class="segment" href="@($"ajout/rafale?serie={Id}")">En rafale</a>
}
else
{
<button type="button" class="segment" disabled title="@MotifBlocage">En rafale</button>
}
<button type="button" class="segment @(_voie == VoieAjout.Catalogue ? "segment-actif" : null)"
@onclick="() => Ouvrir(VoieAjout.Catalogue)">Depuis le catalogue</button>
@*
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.
*@
<button type="button" class="segment @(_voie == VoieAjout.Envies ? "segment-actif" : null)"
@onclick="OuvrirEnviesAsync">Depuis mes envies</button>
</div>
@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… ».
*@
<div class="barre-recherche">
<input class="champ-saisie" type="text" placeholder="ISBN ou titre d'un tome…"
@ref="_champNouveauTome"
@bind="_nouveauTome" @bind:event="oninput" @onkeydown="SurToucheTome"
aria-label="ISBN ou titre du tome à ajouter" autocomplete="off" />
@* Facultatif, et volontairement à côté du titre : on le connaît en tenant le livre. *@
<input class="champ-saisie champ-numero" type="text" placeholder="N° (facultatif)"
@bind="_nouveauNumero" @bind:event="oninput" @onkeydown="SurToucheTome"
aria-label="Numéro du tome à ajouter" autocomplete="off" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || _lookupEnCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouveauTome))"
title="@MotifBlocage"
@onclick="AjouterSaisieAsync">
@(SaisieEstUnCode ? "Chercher ce code" : "Ajouter ce tome")
</button>
</div>
@if (_lookupEnCours)
{
<Patience Message="Recherche au catalogue, puis à la BnF et chez OpenLibrary…" />
}
@* 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)
{
<div class="recherche-livre">
<p class="message-discret">Ce code est déjà au catalogue. Quel exemplaire rattacher ?</p>
@foreach (var livre in _resultats)
{
<button type="button" class="bouton bouton-discret" @key="livre.Id"
@onclick="() => AjouterTomeAsync(livre.Id)">
@livre.Titre@(livre.AuteursAffiches is { } a ? $" — {a}" : "")
</button>
}
</div>
}
@* Rien au catalogue : la cascade a répondu, on crée le livre puis on le rattache. *@
@if (_ajoutParCode && _lookupCandidats.Count > 0)
{
<div class="recherche-livre">
@foreach (var candidat in _lookupCandidats)
{
<div class="carte-candidat">
<div class="carte-corps">
<p class="carte-titre">@candidat.Titre</p>
<p class="carte-auteur">@candidat.Auteur</p>
<p class="carte-details">
@(candidat.Editeur ?? "éditeur inconnu")
· @(candidat.Annee ?? "année inconnue")
</p>
<button type="button" class="bouton bouton-principal"
disabled="@_enCours"
@onclick="() => AjouterCommeLivreAsync(candidat)">
Ajouter ce livre à la série
</button>
</div>
</div>
}
</div>
}
}
@if (_voie == VoieAjout.Envies)
{
<div class="recherche-livre">
@if (_enviesChargement)
{
<Patience Message="Lecture de votre liste d'envies…" />
}
else if (_envies.Count == 0)
{
<p class="message-discret">
Votre liste d'envies est vide — ou ne contient rien qui ne soit déjà un tome
de cette série.
</p>
}
else
{
<p class="message-discret">
Le tome est ajouté comme <strong>manquant</strong>, avec le titre de l'envie.
L'envie <strong>reste dans votre liste</strong> : rien n'a été acheté, et
personne d'autre ne verra qu'elle est à vous.
</p>
@foreach (var envie in _envies)
{
<button type="button" class="bouton bouton-discret" @key="envie.Id"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => AjouterDepuisEnvieAsync(envie)">
@envie.Titre@(string.IsNullOrWhiteSpace(envie.Auteur) ? "" : $" — {envie.Auteur}")
</button>
}
}
</div>
}
@if (_voie == VoieAjout.Catalogue)
{
<div class="recherche-livre">
<input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
@ref="_champRecherche"
value="@_recherche" @oninput="ChercherAsync" @onfocus="ProposerAsync"
aria-label="Chercher un livre à ajouter" />
@foreach (var livre in _resultats)
{
<button type="button" class="bouton bouton-discret" @key="livre.Id"
@onclick="() => AjouterTomeAsync(livre.Id)">
@livre.Titre@(livre.AuteursAffiches is { } a ? $" — {a}" : "")
</button>
}
</div>
}
</div>
}
@if (ModeOrdre)
{
<p class="message-discret">
@@ -117,7 +310,27 @@ else
@if (!ModeEdition && _serie.Elements.Count > 0)
{
<h2 class="titre-section">Ordre de lecture</h2>
<div class="entete-section">
<h2 class="titre-section">@(TriParNumero ? "Tomes" : "Ordre de lecture")</h2>
@*
⚠️ 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)
{
<div class="segments" role="group" aria-label="Trier les tomes">
<button type="button" class="segment @(TriParNumero ? null : "segment-actif")"
@onclick="() => _triParNumero = false">Ordre de lecture</button>
<button type="button" class="segment @(TriParNumero ? "segment-actif" : null)"
@onclick="() => _triParNumero = true">Numéro</button>
</div>
}
</div>
@*
⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se
@@ -126,7 +339,7 @@ else
téléphone qu'on consulte une saga en librairie.
*@
<ul class="liste-tomes">
@foreach (var (element, index) in _serie.Elements.Select((e, i) => (e, i)))
@foreach (var (element, index) in TomesAffiches.Select((e, i) => (e, i)))
{
<li class="carte-tome @(element.Possede ? null : "tome-manquant")" @key="element.Id">
<div class="rang-actions">
@@ -139,7 +352,17 @@ else
@onclick="() => DeplacerAsync(index, index - 1)">▲</button>
}
<span class="rang-numero" aria-hidden="true">@(index + 1)</span>
@*
⚠️ La pastille ne porte QUE le numéro imprimé sur le tome, et rien
quand il n'y en a pas — en consultation comme en mode ordre. Le rang
de lecture ne s'affiche plus nulle part : deux natures de valeur dans
la même colonne, « 4 » se lisait indifféremment « quatrième à lire »
ou « tome 4 ». L'ordre, lui, reste stocké et se lit dans la liste.
*@
@if (element.Numero is { } numero && !string.IsNullOrWhiteSpace(numero))
{
<span class="rang-numero rang-tome" title="Numéro du tome">@numero</span>
}
@if (ModeOrdre)
{
@@ -221,6 +444,12 @@ else
</button>
}
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => OuvrirNumero(element)">
Numéro
</button>
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => RetirerAsync(element)">
@@ -229,11 +458,28 @@ else
</div>
}
@if (_numeroOuvert == element.Id)
{
<div class="barre-recherche">
<input class="champ-saisie" type="text"
placeholder="7, Hors-série, 3.5…"
@bind="_numeroSaisi" @bind:event="oninput"
@onkeydown="@(e => SurToucheNumero(e, element))"
aria-label="@($"Numéro du tome « {element.Titre} »")"
autocomplete="off" />
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne)"
@onclick="() => EnregistrerNumeroAsync(element)">Enregistrer</button>
<button type="button" class="bouton"
@onclick="() => _numeroOuvert = null">Annuler</button>
</div>
}
@if (_rattachement == element.Id)
{
<div class="recherche-livre">
<input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
value="@_recherche" @oninput="ChercherAsync"
value="@_recherche" @oninput="ChercherAsync" @onfocus="ProposerAsync"
aria-label="Chercher un livre à rattacher" />
@foreach (var resultat in _resultats)
@@ -321,102 +567,6 @@ else
</ul>
}
@if (ModeConsultation)
{
<h2 class="titre-section">Ajouter un tome</h2>
@*
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.
*@
<div class="barre-recherche">
<input class="champ-saisie" type="text" placeholder="Titre d'un tome…"
@bind="_nouveauTome" @bind:event="oninput" @onkeydown="SurToucheTome"
aria-label="Titre du tome à ajouter" autocomplete="off" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouveauTome))"
title="@MotifBlocage"
@onclick="() => AjouterTomeAsync(null)">
Ajouter ce tome
</button>
<button type="button" class="bouton bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => OuvrirRattachement(AjoutDepuisCatalogue)">
Depuis le catalogue
</button>
@*
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.
*@
<button type="button" class="bouton bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="OuvrirEnviesAsync">
Depuis mes envies
</button>
</div>
@if (_enviesOuvertes)
{
<div class="recherche-livre">
@if (_enviesChargement)
{
<Patience Message="Lecture de votre liste d'envies…" />
}
else if (_envies.Count == 0)
{
<p class="message-discret">
Votre liste d'envies est vide — ou ne contient rien qui ne soit déjà un tome
de cette série.
</p>
}
else
{
<p class="message-discret">
Le tome est ajouté comme <strong>manquant</strong>, avec le titre de l'envie.
L'envie <strong>reste dans votre liste</strong> : rien n'a été acheté, et
personne d'autre ne verra qu'elle est à vous.
</p>
@foreach (var envie in _envies)
{
<button type="button" class="bouton bouton-discret" @key="envie.Id"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => AjouterDepuisEnvieAsync(envie)">
@envie.Titre@(string.IsNullOrWhiteSpace(envie.Auteur) ? "" : $" — {envie.Auteur}")
</button>
}
}
</div>
}
@if (_rattachement == AjoutDepuisCatalogue)
{
<div class="recherche-livre">
<input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
value="@_recherche" @oninput="ChercherAsync"
aria-label="Chercher un livre à ajouter" />
@foreach (var livre in _resultats)
{
<button type="button" class="bouton bouton-discret" @key="livre.Id"
@onclick="() => AjouterTomeAsync(livre.Id)">
@livre.Titre@(livre.AuteursAffiches is { } a ? $" — {a}" : "")
</button>
}
</div>
}
}
@if (ModeEdition)
{
@@ -475,9 +625,6 @@ else
}
@code {
/// <summary>Marqueur de la recherche « ajouter depuis le catalogue », qui ne vise aucun élément.</summary>
private const int AjoutDepuisCatalogue = -1;
[Parameter] public int Id { get; set; }
private IReadOnlyList<SerieDto> _toutes = [];
@@ -500,6 +647,48 @@ else
private string _nouveauTome = string.Empty;
private string _nouveauNumero = string.Empty;
/// <summary>Par où l'on ajoute un tome. Rien n'est déplié tant qu'on n'a pas choisi.</summary>
/// <remarks>
/// ⚠️ « 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.
/// </remarks>
private enum VoieAjout { Aucune, Manuelle, Catalogue, Envies }
private VoieAjout _voie = VoieAjout.Aucune;
/// <summary>Le panneau d'ajout est déplié, comme les filtres du catalogue.</summary>
private bool _ajoutOuvert;
/// <summary>La saisie manuelle a été reconnue comme un code, et non comme un titre.</summary>
private bool _ajoutParCode;
private ElementReference _champNouveauTome;
private ElementReference _champRecherche;
/// <summary>Champ à focaliser au prochain rendu, s'il y en a un.</summary>
/// <remarks>
/// ⚠️ Une seule fois par ouverture : reprendre le focus à chaque rendu l'arracherait en
/// pleine frappe, ces champs se reliant à chaque caractère.
/// </remarks>
private VoieAjout? _focusAFaire;
/// <summary>Tome dont le champ « numéro » est déplié, s'il y en a un.</summary>
private int? _numeroOuvert;
private string _numeroSaisi = string.Empty;
/// <summary>
/// Trier par numéro de tome plutôt que par ordre de lecture.
/// </summary>
/// <remarks>
/// ⚠️ Confort de lecture, <b>non persistant</b> — 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.
/// </remarks>
private bool _triParNumero;
private int? _rattachement;
private string _recherche = string.Empty;
private IReadOnlyList<LivreDto> _resultats = [];
@@ -511,7 +700,6 @@ else
/// <summary>Le scanner est ouvert sur la place visée par le lookup ISBN (lot J1).</summary>
private bool _lookupScan;
private bool _enviesOuvertes;
private bool _enviesChargement;
private IReadOnlyList<SouhaitDto> _envies = [];
@@ -531,6 +719,144 @@ else
/// </remarks>
private bool ModeOrdre => Suffixe("/ordre");
/// <summary>
/// ⚠️ 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.
/// </summary>
private bool TriParNumero => _triParNumero && !ModeOrdre;
/// <summary>
/// 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.
/// </summary>
private bool TriPossible =>
!ModeOrdre
&& _serie is { } serie
&& serie.Elements.Count > 1
&& serie.Elements.Any(e => !string.IsNullOrWhiteSpace(e.Numero));
/// <summary>Les tomes dans l'ordre demandé — celui de lecture par défaut.</summary>
private IReadOnlyList<ElementSerieDto> 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;
}
}
/// <summary>
/// Ouvre ou ferme le panneau d'ajout.
/// </summary>
/// <remarks>
/// ⚠️ Le refermer referme <b>tout</b> : 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.
/// </remarks>
private void BasculerAjout()
{
_ajoutOuvert = !_ajoutOuvert;
if (!_ajoutOuvert)
{
Ouvrir(VoieAjout.Aucune);
}
}
/// <summary>Vrai quand ce qui est tapé dans la saisie manuelle est un code et non un titre.</summary>
private bool SaisieEstUnCode => RechercheIsbn.Formes(_nouveauTome) is not null;
/// <summary>
/// Déplie une voie d'ajout, ou la referme si c'est celle qui était ouverte.
/// </summary>
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 (_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;
}
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;
});
/// <summary>Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder.</summary>
private bool ModeConsultation => !ModeEdition && !ModeOrdre;
@@ -632,19 +958,91 @@ else
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveauTome) && Reseau.EnLigne)
{
await AjouterTomeAsync(null);
await AjouterSaisieAsync();
}
}
/// <summary>
/// Traite la saisie manuelle : un titre recense une place, un code désigne un livre.
/// </summary>
/// <remarks>
/// ⚠️ <b>Le catalogue d'abord, la BnF ensuite</b> (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.
/// <para>
/// Un seul livre au catalogue est rattaché <b>sans rien demander</b> ; 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.
/// </para>
/// </remarks>
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 }));
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();
}
}
}
@@ -747,9 +1145,16 @@ else
/// </remarks>
private async Task OuvrirEnviesAsync()
{
if (_voie == VoieAjout.Envies)
{
_voie = VoieAjout.Aucune;
return;
}
_erreur = null;
_message = null;
_enviesOuvertes = true;
_voie = VoieAjout.Envies;
_rattachement = null;
_enviesChargement = true;
var toutes = await Api.ListerSouhaitsAsync();
@@ -786,7 +1191,9 @@ else
if (_erreur is null)
{
_enviesOuvertes = false;
// 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.";
@@ -861,13 +1268,13 @@ else
}
}
/// <remarks>
/// ⚠️ Deux usages, et la différence tient à <c>_lookupElement</c> : rattaché à une place
/// <b>déjà recensée</b> (bouton « Ajouter par ISBN » d'un tome manquant), ou — s'il n'y en a
/// pas — <b>créant la place</b>, ce qui est le cas de la saisie manuelle par code.
/// </remarks>
private async Task AjouterCommeLivreAsync(CandidatLivre candidat)
{
if (_lookupElement is null)
{
return;
}
_enCours = true;
_erreur = null;
var saisie = new EnregistrementLivre
@@ -895,10 +1302,21 @@ else
return;
}
await EcrireAsync(() => Api.RattacherLivreSerieAsync(_lookupElement.Id, resultat.Livre!.Id));
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;
}
}
@@ -951,6 +1369,9 @@ else
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 = [];
@@ -963,13 +1384,32 @@ else
_resultats = [];
}
/// <summary>
/// Montre déjà des livres alors que rien n'est tapé.
/// </summary>
/// <remarks>
/// ⚠️ C'est ce qui <b>dit</b> 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.
/// </remarks>
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))
{
_resultats = [];
// Effacer ce qu'on a tapé ramène la proposition de départ, pas le vide.
await ProposerAsync();
return;
}