- 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
+50 -1
View File
@@ -72,6 +72,10 @@
question. *@
<a class="bouton bouton-discret" href="ajout/manuel">Saisir un livre</a>
<a class="bouton bouton-discret" href="revues/ajout">Saisir une revue</a>
@* Une pile de livres et une douchette : on collecte d'abord, on traite ensuite.
Reste ici, et non dans le menu : c'est une façon d'ajouter, pas une destination. *@
<a class="bouton bouton-discret" href="ajout/rafale">Cataloguer en rafale</a>
</div>
}
@@ -225,7 +229,29 @@
private EnregistrementLivre _saisie = new();
private DoublonsLivre? _doublons;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
/// <summary>
/// Code transmis par un autre écran — un résidu de rafale à trancher.
/// </summary>
/// <remarks>
/// ⚠️ La recherche s'enchaîne d'elle-même, comme après un scan : arriver ici depuis un
/// compte rendu de rafale signifie qu'on a déjà décidé de reprendre ce code. Demander un
/// clic de plus sur « Chercher » serait le détour que cet écran documente déjà comme un
/// défaut (voir l'étape « Recherche »).
/// </remarks>
[SupplyParameterFromQuery(Name = "code")]
public string? CodePropose { get; set; }
protected override void OnInitialized()
{
Reseau.Change += SurChangementReseau;
if (!string.IsNullOrWhiteSpace(CodePropose))
{
_isbn = CodePropose.Trim();
}
}
private bool _repriseFaite;
/// <summary>
/// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche.
@@ -237,6 +263,29 @@
/// </remarks>
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
// ⚠️ AVANT le garde de focus ci-dessous, qui rend la main dès que le focus est posé :
// greffée après, la reprise ne serait jouée qu'une fois sur deux, selon l'étape.
//
// ⚠️ Et surtout PAS conditionnée à `premierRendu` : au premier rendu, l'état réseau
// n'est pas encore établi (il s'initialise de façon asynchrone), donc `EnLigne` est
// faux et la reprise ne partait jamais. Constaté à l'écran — le champ se préremplissait
// bien, la recherche ne s'enchaînait pas. C'est `_repriseFaite` qui garantit l'unicité,
// pas le premier rendu ; l'événement réseau provoque le rendu où la condition passe.
if (!_repriseFaite && !string.IsNullOrWhiteSpace(CodePropose) && Reseau.EnLigne)
{
_repriseFaite = true;
await ChercherAsync();
// ⚠️ `StateHasChanged` est INDISPENSABLE ici, et son absence ne se voit pas :
// `OnAfterRenderAsync` n'est pas un gestionnaire d'événement, donc Blazor ne
// redessine rien de lui-même. La recherche partait bien — sonde à l'appui — et
// remplissait `_candidats`, mais l'écran restait sur l'étape de saisie.
// Même famille que « un composant qui écrit dans un objet prêté doit prévenir son
// parent » : le symptôme trompe, on cherche un appel qui n'a pas eu lieu là où il
// n'y a qu'un rendu manquant.
StateHasChanged();
}
if (_etape != Etape.Saisie || !_focusAFaire)
{
return;
+20 -1
View File
@@ -38,7 +38,26 @@ else
@code {
private readonly EnregistrementLivre _saisie = new();
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
/// <summary>
/// ISBN transmis par un autre écran — un résidu de rafale qu'aucune source ne connaît.
/// </summary>
/// <remarks>
/// ⚠️ Sans cette reprise, le lien du compte rendu de rafale menait à un formulaire
/// <b>vide</b> : il fallait retaper le code qu'on venait de scanner, c'est-à-dire refaire
/// exactement ce que la rafale existe pour éviter. Le lien compilait et ne servait à rien.
/// </remarks>
[SupplyParameterFromQuery(Name = "isbn")]
public string? IsbnPropose { get; set; }
protected override void OnInitialized()
{
Reseau.Change += SurChangementReseau;
if (!string.IsNullOrWhiteSpace(IsbnPropose))
{
_saisie.Isbn = IsbnPropose.Trim();
}
}
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
+741
View File
@@ -0,0 +1,741 @@
@page "/ajout/rafale"
@inject ServiceLivresApi Api
@inject CacheHorsLigne Cache
@inject EtatReseau Reseau
@inject NavigationManager Nav
@implements IDisposable
<PageTitle>MaBibli — cataloguer en rafale</PageTitle>
<h1>Cataloguer en rafale</h1>
@*
Lancée depuis une saga, la rafale rattache chaque livre créé à la série. ⚠️ La cible vit
dans la FILE (Rafale.SerieId), pas dans cet écran : une rafale se reprend plus tard, et la
reprise doit rattacher au même endroit.
*@
@if (SerieVisee is { } visee)
{
<p class="aide">
Les livres créés seront ajoutés à la série
<a href="@($"series/{visee.Id}")"><strong>@visee.Titre</strong></a>.
</p>
}
@*
Le geste visé : une pile de livres et une douchette. Celle-ci se présente au système comme
un CLAVIER — elle tape les chiffres puis « Entrée ». Or dans une zone de texte multiligne,
« Entrée » fait un saut de ligne : la rafale se collecte donc toute seule, sans mécanique
dédiée. C'est ce qui rend cet écran bien moins coûteux qu'une file interactive.
*@
@if (_erreur is { } erreur)
{
<p class="message-erreur" role="alert">@erreur</p>
}
@if (_repriseProposee)
{
@*
⚠️ Sans cette proposition, la file existerait en base et personne ne la retrouverait :
on rescannerait tout. C'est la contrepartie directe de « reprendre plus tard ».
*@
<div class="encart-reprise">
@*
⚠️ La file est proposée MÊME TERMINÉE (2026-09-08). Elle ne l'était que s'il restait
à traiter ou des résidus : une rafale entièrement réussie devenait donc inatteignable
alors qu'elle dormait dans IndexedDB, et la liste des livres créés — avec ses liens
vers chaque fiche — était perdue au premier changement d'écran.
*@
@if (_rafale.ResteATraiter || _rafale.Residus.Count > 0)
{
<p>
Une rafale de <strong>@_rafale.Total code@(_rafale.Total > 1 ? "s" : "")</strong>
est restée en cours@(_rafale.Traites > 0 ? $", {_rafale.Traites} déjà traité{(_rafale.Traites > 1 ? "s" : "")}" : "").
</p>
}
else
{
<p>
Votre dernière rafale est terminée :
<strong>@_rafale.Crees livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "")</strong>.
Son compte rendu, et les liens vers chaque fiche, sont encore là.
</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal" @onclick="Reprendre">
@(_rafale.ResteATraiter || _rafale.Residus.Count > 0 ? "Reprendre" : "Revoir le compte rendu")
</button>
<button type="button" class="bouton bouton-danger" @onclick="AbandonnerAsync">
@(_rafale.ResteATraiter || _rafale.Residus.Count > 0
? "Abandonner cette rafale"
: "Commencer une nouvelle rafale")
</button>
</div>
</div>
}
else if (_phase == Phase.Collecte)
{
<p class="aide">
Scannez les codes-barres à la suite : chaque lecture ajoute une ligne. Vous pouvez aussi
les taper ou les coller, un par ligne. Rien n'est envoyé avant que vous ne lanciez le
traitement.
</p>
@*
⚠️ La zone est focalisée à l'ouverture, comme le champ ISBN de l'écran d'ajout : une
douchette « tape » dès qu'on scanne, et sans le focus le premier code partait dans le
vide. C'est une étape de moins, et c'est celle qu'on oublie en ayant les mains prises.
⚠️ Le focus ne se reprend pas à chaque rendu (`_focusAFaire`) : il serait arraché à
chaque frappe, et la zone se réécrit à chaque caractère.
*@
<div class="champ">
<label for="codes">Codes scannés</label>
<textarea id="codes" class="champ-saisie zone-codes" rows="10"
@ref="_zoneCodes"
placeholder="9782070612758&#10;9782253004226&#10;…"
@bind="_saisie" @bind:event="oninput"></textarea>
</div>
<p class="aide" aria-live="polite">
@{
var codes = SaisieRafale.Decouper(_saisie).Count;
}
@if (codes == 0)
{
<span>Aucun code pour l'instant.</span>
}
else
{
<span><strong>@codes</strong> code@(codes > 1 ? "s" : "") — les doublons de saisie sont écartés.</span>
}
</p>
@*
Cochée par défaut : cataloguer cinquante livres ne doit pas être interrompu cinquante
fois. ⚠️ Mais le compte rendu DIT combien ont été passés — posséder deux exemplaires est
légitime, et passer en silence contredirait cet esprit.
*@
<label class="case-a-cocher">
<input type="checkbox" @bind="_passerLesDoublons" />
Passer les livres déjà au catalogue
</label>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(!Reseau.EnLigne || SaisieRafale.Decouper(_saisie).Count == 0)"
title="@MotifBlocage"
@onclick="LancerAsync">
Traiter @(SaisieRafale.Decouper(_saisie).Count > 0 ? $"({SaisieRafale.Decouper(_saisie).Count})" : "")
</button>
</div>
@if (!Reseau.EnLigne)
{
@*
La COLLECTE marche hors-ligne, pas le traitement : c'est le seul endroit du projet
où l'on accumule quelque chose sans réseau. ⚠️ Ce ne sont pas des écritures en
attente, ce sont des codes à interroger — rien n'est promis à la base.
*@
<p class="aide">
Vous pouvez scanner sans réseau : la liste est conservée sur cet appareil. Le
traitement, lui, interroge la BnF et attendra le retour du réseau.
</p>
}
}
else if (_phase == Phase.Traitement)
{
<h2 class="titre-section">Traitement en cours</h2>
<div class="avancement" role="progressbar"
aria-valuenow="@_rafale.Pourcentage" aria-valuemin="0" aria-valuemax="100"
aria-label="Avancement du traitement">
<div class="avancement-barre" style="width: @(_rafale.Pourcentage)%"></div>
</div>
<p aria-live="polite">
<strong>@_rafale.Pourcentage %</strong> — @_rafale.Traites sur @_rafale.Total
@if (_codeEnCours is { } encours)
{
<span class="aide"> · @FormatageIsbn.Afficher(encours)</span>
}
</p>
<div class="actions-formulaire">
<button type="button" class="bouton" @onclick="() => _interrompu = true">
Interrompre
</button>
</div>
<p class="aide">
Vous pourrez reprendre plus tard : ce qui est déjà enregistré ne sera pas refait.
</p>
}
else
{
<h2 class="titre-section">Compte rendu</h2>
<ul class="bilan">
<li><strong>@_rafale.Crees</strong> livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "")</li>
@if (_rafale.Passes > 0)
{
@*
⚠️ Se COMPTE, ne se tait pas : sans cette ligne on croirait avoir ajouté
cinquante livres alors qu'on en a ajouté quarante-deux.
*@
<li><strong>@_rafale.Passes</strong> déjà au catalogue, passé@(_rafale.Passes > 1 ? "s" : "")</li>
}
@if (_rafale.Residus.Count > 0)
{
<li><strong>@_rafale.Residus.Count</strong> à regarder ci-dessous</li>
}
</ul>
@*
⚠️ La liste des livres ajoutés est la moitié utile du compte rendu : sans elle, on sait
qu'on a créé vingt fiches et l'on n'a aucun moyen de les retrouver autrement qu'en
fouillant le catalogue à la main. Or c'est juste après la rafale qu'on veut les
compléter — un scan ne donne ni type de document, ni thèmes, ni série.
Chaque ligne mène donc à sa fiche, et un bouton mène droit à son écran d'édition.
*@
@if (Ajoutes.Count > 0)
{
<h2 class="titre-section">Livres ajoutés</h2>
<ul class="liste-residus">
@foreach (var ligne in Ajoutes)
{
<li class="carte-residu" @key="ligne.Code">
<div class="residu-tete">
<a class="carte-titre" href="@($"livres/{ligne.LivreId}")">
@(string.IsNullOrWhiteSpace(ligne.Titre) ? "Fiche du livre" : ligne.Titre)
</a>
<span class="code-isbn">@FormatageIsbn.Afficher(ligne.Code)</span>
</div>
@if (!string.IsNullOrWhiteSpace(ligne.Motif))
{
<p class="aide">@ligne.Motif</p>
}
<div class="actions-tome">
<a class="bouton bouton-compact" href="@($"livres/{ligne.LivreId}/edition")">
Compléter la fiche
</a>
</div>
</li>
}
</ul>
}
@if (_rafale.ResteATraiter)
{
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(!Reseau.EnLigne)" title="@MotifBlocage"
@onclick="TraiterAsync">
Continuer (@(_rafale.Total - _rafale.Traites) restant@(_rafale.Total - _rafale.Traites > 1 ? "s" : ""))
</button>
</div>
}
@if (_rafale.Residus.Count > 0)
{
<h2 class="titre-section">À reprendre</h2>
<ul class="liste-residus">
@foreach (var ligne in _rafale.Residus)
{
<li class="carte-residu" @key="ligne.Code">
<div class="residu-tete">
<span class="code-isbn">@FormatageIsbn.Afficher(ligne.Code)</span>
<span class="etiquette @ClasseEtat(ligne.Etat)">@Libelle(ligne.Etat)</span>
</div>
@if (!string.IsNullOrWhiteSpace(ligne.Titre))
{
<p class="carte-titre">@ligne.Titre</p>
}
@if (!string.IsNullOrWhiteSpace(ligne.Motif))
{
<p class="aide">@ligne.Motif</p>
}
<div class="actions-tome">
@*
Chaque résidu renvoie vers l'écran qui sait le traiter, plutôt que
de réimplémenter ici le choix d'édition ou la fiche revue.
*@
<a class="bouton bouton-compact" href="@LienDeReprise(ligne)">
@(ligne.Etat == EtatCodeRafale.Revue ? "Ouvrir la revue" : "Reprendre")
</a>
<button type="button" class="bouton bouton-compact"
@onclick="() => RetirerAsync(ligne)">
Retirer de la liste
</button>
</div>
</li>
}
</ul>
}
<p class="aide">
Cette liste reste consultable en revenant sur cet écran, tant que vous ne l'avez pas
vidée.
</p>
<div class="actions-formulaire">
@* Venu d'une saga, on y retourne : c'est là qu'on voit ce qui manque encore. *@
@if (SerieVisee is { } retour)
{
<a class="bouton bouton-principal" href="@($"series/{retour.Id}")">
Revenir à @retour.Titre
</a>
}
<button type="button" class="bouton bouton-danger" @onclick="AbandonnerAsync">
Terminer et vider la liste
</button>
</div>
}
@code {
private enum Phase { Collecte, Traitement, Bilan }
/// <summary>
/// Série d'où l'on vient, quand la rafale a été lancée depuis une saga.
/// </summary>
/// <remarks>
/// ⚠️ Ce paramètre ne sert qu'à <b>amorcer</b> la file : une fois lancée, c'est
/// <c>Rafale.SerieId</c> qui commande, sans quoi une reprise ouverte depuis une autre
/// adresse rattacherait ailleurs.
/// </remarks>
[SupplyParameterFromQuery(Name = "serie")]
public int? SerieId { get; set; }
private SerieDto? _serie;
private Phase _phase = Phase.Collecte;
private Rafale _rafale = new();
private string _saisie = string.Empty;
private bool _passerLesDoublons = true;
private bool _repriseProposee;
private bool _interrompu;
private string? _codeEnCours;
private string? _erreur;
/// <summary>
/// ISBN déjà au catalogue, sous leurs DEUX formes.
/// </summary>
/// <remarks>
/// ⚠️ Lu une seule fois par traitement, et volontairement : le catalogue ne bouge que par
/// nos propres créations, qu'on y ajoute au fil de l'eau.
/// </remarks>
private HashSet<string> _isbnDuCatalogue = new(StringComparer.Ordinal);
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
/// <summary>La série à laquelle rattacher : celle de la file en cours, sinon celle de l'adresse.</summary>
private SerieDto? SerieVisee => _serie;
private int? CibleSerie => _rafale.SerieId ?? SerieId;
/// <summary>
/// Nom de la série visée, pour le dire plutôt que de rattacher en silence.
/// </summary>
/// <remarks>
/// Lu depuis l'instantané des séries, donc sans coût ni dépendance au réseau. Une lecture
/// ratée ne retire rien : le rattachement, lui, ne dépend pas de ce nom.
/// </remarks>
private async Task ChargerSerieAsync()
{
if (CibleSerie is not { } id)
{
_serie = null;
return;
}
try
{
_serie = (await Api.ListerSeriesAsync()).FirstOrDefault(s => s.Id == id);
}
catch (Exception)
{
_serie = null;
}
}
/// <summary>Zone de collecte, focalisée à l'ouverture pour qu'une douchette y tape d'emblée.</summary>
private ElementReference _zoneCodes;
private bool _focusAFaire = true;
/// <summary>Les livres réellement créés par cette rafale, dans l'ordre où ils ont été scannés.</summary>
private IReadOnlyList<CodeRafale> Ajoutes =>
[.. _rafale.Codes.Where(c => c.Etat == EtatCodeRafale.Cree && c.LivreId is not null)];
/// <remarks>
/// ⚠️ Une seule fois par entrée dans l'étape de collecte : reprendre le focus à chaque rendu
/// arracherait le curseur en pleine frappe, et la zone se réécrit à chaque caractère.
/// </remarks>
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (_focusAFaire && _phase == Phase.Collecte && !_repriseProposee)
{
_focusAFaire = false;
await _zoneCodes.FocusAsync();
}
}
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurReseau;
// ⚠️ Une rafale inachevée doit se proposer d'elle-même : sans cela, elle existe dans
// IndexedDB et personne ne la retrouve.
// ⚠️ Toute file non vidée est proposée, terminée ou non : c'est le seul chemin de retour
// vers la liste des livres qu'une rafale vient de créer.
var reprise = await Cache.LireRafaleAsync();
if (reprise is { Total: > 0 })
{
_rafale = reprise;
_passerLesDoublons = reprise.PasserLesDoublons;
_repriseProposee = true;
}
await ChargerSerieAsync();
}
public void Dispose() => Reseau.Change -= SurReseau;
private void SurReseau() => InvokeAsync(StateHasChanged);
private void Reprendre()
{
_repriseProposee = false;
_phase = Phase.Bilan;
}
private async Task AbandonnerAsync()
{
await Cache.EffacerRafaleAsync();
_rafale = new Rafale();
_saisie = string.Empty;
_repriseProposee = false;
_phase = Phase.Collecte;
// On revient à une zone vide : c'est une nouvelle rafale, elle mérite le même focus
// que la première.
_focusAFaire = true;
// ⚠️ La file effacée emportait sa série : sans cette relecture, le bandeau continuait
// d'annoncer « les livres seront ajoutés à la série X » pour une rafale neuve qui,
// ouverte sans paramètre d'adresse, ne rattachera nulle part.
await ChargerSerieAsync();
}
private async Task LancerAsync()
{
var codes = SaisieRafale.Decouper(_saisie);
if (codes.Count == 0)
{
return;
}
_rafale = new Rafale
{
Codes = [.. codes.Select(c => new CodeRafale { Code = c })],
PasserLesDoublons = _passerLesDoublons,
SerieId = SerieId,
};
await Cache.EcrireRafaleAsync(_rafale);
await TraiterAsync();
}
/// <summary>
/// Traite la file, un code à la fois.
/// </summary>
/// <remarks>
/// ⚠️ <b>Un par un, délibérément.</b> Cinquante livres font jusqu'à cent requêtes — chaque
/// ISBN est cherché en 13 puis en 10 — et c'est le SERVEUR qui appelle la BnF. Les lancer
/// ensemble lui ferait ouvrir cent connexions vers <c>catalogue.bnf.fr</c>. Séquentiel, la
/// progression se voit et une coupure ne perd que le code en cours.
/// <para>
/// La file est réécrite <b>après chaque code</b> : c'est ce qui rend la reprise exacte.
/// </para>
/// </remarks>
private async Task TraiterAsync()
{
_phase = Phase.Traitement;
_interrompu = false;
_erreur = null;
await ChargerIsbnDuCatalogueAsync();
for (var i = 0; i < _rafale.Codes.Count; i++)
{
if (_interrompu || !Reseau.EnLigne)
{
break;
}
if (_rafale.Codes[i].Etat != EtatCodeRafale.ATraiter)
{
continue; // Déjà traité : une reprise ne refait jamais ce qui est fait.
}
_codeEnCours = _rafale.Codes[i].Code;
StateHasChanged();
_rafale.Codes[i] = await TraiterUnAsync(_rafale.Codes[i]);
await Cache.EcrireRafaleAsync(_rafale);
StateHasChanged();
}
_codeEnCours = null;
_phase = Phase.Bilan;
}
/// <summary>
/// Les ISBN déjà possédés, dans les deux formes, pour reconnaître un doublon sans requête.
/// </summary>
/// <remarks>
/// ⚠️ <b>Les deux formes sont indispensables</b> : un livre saisi avant 2007 porte un
/// ISBN-10 en base, alors qu'un scanner lit toujours un EAN-13. Ne comparer qu'une forme
/// ferait rescanner tout le fonds ancien comme s'il était neuf — c'est le même piège que
/// celui de la recherche BnF, à l'autre bout de la chaîne.
/// </remarks>
private async Task ChargerIsbnDuCatalogueAsync()
{
try
{
var livres = await Api.ListerAsync(new CritereLivres());
_isbnDuCatalogue = new HashSet<string>(StringComparer.Ordinal);
foreach (var isbn in livres.Select(l => IsbnUtils.Normaliser(l.Isbn)).OfType<string>())
{
_isbnDuCatalogue.Add(isbn);
if (IsbnUtils.TryConvertirEnIsbn10(isbn, out var court) && court is not null)
{
_isbnDuCatalogue.Add(court);
}
}
}
catch (Exception)
{
// Sans cette liste, on retombe simplement sur la détection du serveur à la
// création : plus lente, mais jamais fausse.
_isbnDuCatalogue = [];
}
}
private bool DejaAuCatalogue(string code)
{
if (_isbnDuCatalogue.Contains(code))
{
return true;
}
return IsbnUtils.TryConvertirEnIsbn10(code, out var court)
&& court is not null
&& _isbnDuCatalogue.Contains(court);
}
private async Task<CodeRafale> TraiterUnAsync(CodeRafale code)
{
// ⚠️ AVANT le lookup, et avant le choix d'édition. Constaté à l'écran : sans ce test,
// un livre déjà possédé dont la BnF rend trois notices ressortait « à choisir » — on
// demandait de trancher l'édition d'un livre qu'on allait de toute façon passer.
// Épargne au passage une requête BnF par livre déjà catalogué.
if (_rafale.PasserLesDoublons && DejaAuCatalogue(code.Code))
{
return code with
{
Etat = EtatCodeRafale.Passe,
Motif = "Déjà au catalogue.",
};
}
ResultatLookupIsbn? lookup;
try
{
lookup = await Api.ChercherIsbnAsync(code.Code);
}
catch (Exception)
{
return code with
{
Etat = EtatCodeRafale.Echec,
Motif = "La recherche n'a pas abouti. Réessayez plus tard.",
};
}
if (lookup is null)
{
return code with
{
Etat = EtatCodeRafale.Echec,
Motif = "La recherche n'a pas abouti. Réessayez plus tard.",
};
}
// Un préfixe 977 est un périodique : il porte un ISSN, donc un titre de revue, et son
// numéro se saisit à la main. Il ne peut pas suivre le chemin des livres.
if (lookup.Periodique is { } revue)
{
return code with
{
Etat = EtatCodeRafale.Revue,
Titre = revue.Titre,
Motif = "Magazine : le numéro de parution se saisit à la main.",
};
}
if (lookup.Candidats.Count == 0)
{
return code with
{
Etat = EtatCodeRafale.Introuvable,
Motif = "Aucune source ne connaît ce code. À saisir à la main.",
};
}
// ⚠️ Plusieurs notices : on NE choisit PAS à la place de l'utilisateur. C'est la règle
// actée « notices multiples : demander systématiquement », et elle vaut ici aussi —
// sauf qu'on demande à la fin, pas au milieu de la rafale.
if (lookup.Candidats.Count > 1)
{
return code with
{
Etat = EtatCodeRafale.AChoisir,
Titre = lookup.Candidats[0].Titre,
Motif = $"{lookup.Candidats.Count} éditions possibles : à choisir.",
};
}
var candidat = lookup.Candidats[0];
// Même construction que l'écran d'ajout unitaire : `Format.Physique` parce qu'un livre
// scanné est un objet qu'on tient, et `Statut.ALire` parce qu'on vient de l'acquérir.
var saisie = new EnregistrementLivre
{
Isbn = code.Code,
Titre = candidat.Titre,
Auteur = candidat.Auteur,
Editeur = candidat.Editeur,
NombrePages = candidat.NombrePages,
CoverUrl = candidat.CoverUrl,
UrlNotice = candidat.UrlNotice,
Format = Format.Physique,
Statut = Statut.ALire,
};
var resultat = await Api.CreerAsync(saisie);
if (resultat.Doublons is { } doublons)
{
// Passer est le défaut, mais cela se compte : voir le compte rendu.
return _rafale.PasserLesDoublons
? code with
{
Etat = EtatCodeRafale.Passe,
Titre = candidat.Titre,
Motif = doublons.Message,
}
: code with
{
Etat = EtatCodeRafale.AChoisir,
Titre = candidat.Titre,
Motif = doublons.Message,
};
}
if (resultat.Livre is { } livre)
{
// Un code scanné deux fois dans la MÊME rafale est déjà écarté par la découpe ;
// ceci couvre le cas de deux codes différents qui désignent le même livre.
_isbnDuCatalogue.Add(code.Code);
return code with
{
Etat = EtatCodeRafale.Cree,
Titre = livre.Titre,
LivreId = livre.Id,
Motif = await RattacherAsync(livre),
};
}
return code with
{
Etat = EtatCodeRafale.Echec,
Titre = candidat.Titre,
Motif = resultat.Erreur ?? "L'enregistrement n'a pas abouti.",
};
}
/// <summary>
/// Range le livre créé dans la série visée, s'il y en a une.
/// </summary>
/// <remarks>
/// ⚠️ Un rattachement raté ne remet pas la ligne en échec : <b>le livre existe</b>, et le
/// redire « à traiter » le recréerait à la reprise. On le dit dans le compte rendu, où la
/// ligne mène déjà à sa fiche — c'est là qu'on répare, en un geste.
/// </remarks>
private async Task<string?> RattacherAsync(LivreDto livre)
{
if (_rafale.SerieId is not { } serieId)
{
return null;
}
var resultat = await Api.AjouterElementSerieAsync(
serieId, new AjoutElementSerie { LivreId = livre.Id });
return resultat.EstOk
? null
: "Livre enregistré, mais il n'a pas pu être ajouté à la série.";
}
private async Task RetirerAsync(CodeRafale code)
{
_rafale.Codes.RemoveAll(c => c.Code == code.Code);
await Cache.EcrireRafaleAsync(_rafale);
}
/// <summary>
/// Où reprendre un résidu — l'écran qui sait déjà traiter ce cas.
/// </summary>
/// <remarks>
/// ⚠️ On ne réimplémente ici ni le choix d'édition, ni la fiche revue, ni la saisie
/// manuelle : trois copies d'un écran existant divergeraient.
/// </remarks>
private static string LienDeReprise(CodeRafale code) => code.Etat switch
{
EtatCodeRafale.Introuvable => $"/ajout/manuel?isbn={code.Code}",
_ => $"/ajout/isbn?code={code.Code}",
};
private static string Libelle(EtatCodeRafale etat) => etat switch
{
EtatCodeRafale.AChoisir => "Plusieurs éditions",
EtatCodeRafale.Introuvable => "Introuvable",
EtatCodeRafale.Revue => "Magazine",
EtatCodeRafale.Echec => "Échec",
_ => string.Empty,
};
private static string ClasseEtat(EtatCodeRafale etat) => etat switch
{
EtatCodeRafale.Revue => "etiquette-type",
EtatCodeRafale.Echec => "etiquette-manquant",
_ => "etiquette-prete",
};
}
+162 -33
View File
@@ -1,6 +1,7 @@
@page "/"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
<PageTitle>MaBibli — catalogue</PageTitle>
@@ -11,14 +12,36 @@
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.
*@
<div class="entete-page">
<h1 class="titre-page">Catalogue</h1>
<button type="button" class="bouton bouton-discret bouton-compact"
aria-expanded="@Aria.Etat(_filtresOuverts)" aria-controls="filtres-catalogue"
@onclick="() => _filtresOuverts = !_filtresOuverts">
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
</button>
<div class="entete-page-actions">
<button type="button" class="bouton bouton-discret bouton-compact"
aria-expanded="@Aria.Etat(_filtresOuverts)" aria-controls="filtres-catalogue"
@onclick="() => _filtresOuverts = !_filtresOuverts">
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
</button>
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a>
}
else
{
<button type="button" class="bouton bouton-principal bouton-compact" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
}
</div>
</div>
@if (_auteur is not null)
@@ -30,11 +53,28 @@
</p>
}
<div class="barre-recherche">
<input class="champ-saisie" type="search" placeholder="Titre ou auteur…"
@*
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.
*@
<div class="barre-recherche ligne-creation">
<input class="champ-saisie" type="search" placeholder="Titre, auteur ou ISBN…"
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
<button type="button" class="bouton bouton-discret"
@onclick="() => _scanOuvert = !_scanOuvert">
@(_scanOuvert ? "Fermer" : "Scanner")
</button>
</div>
@if (_scanOuvert)
{
<ScannerCodeBarres OnCodeDetecte="SurCodeScanneAsync" OnAnnuler="() => _scanOuvert = false" />
}
@if (_filtresOuverts)
{
<div class="filtres" id="filtres-catalogue">
@@ -110,6 +150,32 @@
</div>
}
@*
« 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)
{
<div class="segments" role="group" aria-label="Filtrer par couverture">
<button type="button" class="segment @(_sansCouverture is null ? "segment-actif" : null)"
@onclick="() => FiltrerCouverture(null)">Tous</button>
<button type="button" class="segment @(_sansCouverture == false ? "segment-actif" : null)"
@onclick="() => FiltrerCouverture(false)">Avec couverture</button>
<button type="button" class="segment @(_sansCouverture == true ? "segment-actif" : null)"
@onclick="() => FiltrerCouverture(true)">Sans couverture</button>
</div>
}
@*
⚠️ 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
@@ -168,7 +234,7 @@ else if (_livres is not null)
{
@if (entree.EstGroupe)
{
<GroupeCatalogue Entree="entree" Replies="_replies" />
<GroupeCatalogue Entree="entree" Replies="_replies" Totaux="_totauxSeries" />
}
else
{
@@ -180,29 +246,6 @@ else if (_livres is not null)
</ul>
}
@*
Une seule entrée d'ajout, et plus aucun lien de navigation : les quatre écrans sont dans
les onglets, en bas.
« Saisie manuelle » est retirée d'ici — elle reste accessible un écran plus loin, depuis
« Ajouter », qui la propose dès qu'un ISBN ne donne rien.
⚠️ Hors-ligne le bouton 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. Le
désencombrement demandé passe par le regroupement (deux boutons en un), pas par l'effacement.
*@
<nav class="actions-flottantes">
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="ajout">Ajouter un ouvrage</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
}
</nav>
@code {
/// <summary>Restreint le catalogue à un auteur. Vient de « ?auteur=… », posé par les liens de la liste.</summary>
[SupplyParameterFromQuery(Name = "auteur")]
@@ -236,6 +279,16 @@ else if (_livres is not null)
/// </remarks>
private readonly HashSet<int> _replies = [];
/// <summary>
/// Ce que chaque série possède et recense, descendance comprise.
/// </summary>
/// <remarks>
/// ⚠️ Recalculé <b>seulement</b> quand les séries changent, et non à chaque chargement du
/// catalogue : celui-ci se relit à chaque frappe dans la recherche.
/// </remarks>
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totauxSeries =
new Dictionary<int, (int, int)>();
/// <summary>Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés.</summary>
private IReadOnlyList<EntreeCatalogue> Entrees =>
GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null);
@@ -246,6 +299,8 @@ else if (_livres is not null)
private TypeDocument? _type;
private Statut? _statut;
private bool? _prete;
private bool? _sansCouverture;
private bool _chargement;
private bool _filtresOuverts;
private string? _erreur;
@@ -277,6 +332,19 @@ else if (_livres is not null)
private bool QuelqueChoseEstPrete => _fondsAvecPret is not false;
/// <summary>
/// Vrai si le fonds contient à la fois des livres avec et sans couverture — ou qu'on ne le
/// sait pas encore.
/// </summary>
/// <remarks>
/// ⚠️ 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.
/// </remarks>
private bool? _fondsCouverturesInegales;
private bool CouverturesInegales => _fondsCouverturesInegales is not false;
/// <summary>
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
/// </summary>
@@ -287,14 +355,15 @@ else if (_livres is not null)
/// </remarks>
private int FiltresActifs =>
(_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1)
+ (_prete 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 || AuteurId is not null;
|| _statut is not null || _prete is not null || _sansCouverture is not null
|| AuteurId is not null;
/// <summary>
/// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du
@@ -315,10 +384,12 @@ else if (_livres is not null)
try
{
_series = await Api.ListerSeriesAsync();
_totauxSeries = TotauxSeries.Calculer(_series);
}
catch (Exception)
{
_series = null;
_totauxSeries = new Dictionary<int, (int, int)>();
}
}
@@ -351,6 +422,48 @@ else if (_livres is not null)
await ChargerAsync();
}
/// <summary>Le scanner est ouvert au-dessus de la liste, pour retrouver un livre en main.</summary>
private bool _scanOuvert;
/// <summary>
/// Un code lu remplit la recherche, exactement comme une douchette l'aurait tapé.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private async Task SurCodeScanneAsync(CodeScanne lecture)
{
_scanOuvert = false;
_recherche = lecture.Code;
await ChargerAsync();
}
/// <summary>
/// Ouvre la fiche quand le code désigne <b>un seul</b> livre du catalogue.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// ⚠️ Deux conditions, et les deux comptent. <b>Un seul résultat</b>, sinon on choisirait à
/// la place de l'utilisateur — deux exemplaires du même ISBN sont légitimes. Et un
/// <b>code complet et valide</b> : sur un fragment en cours de frappe, le résultat unique
/// est un hasard, et l'écran sauterait sous les doigts.
/// </para>
/// </remarks>
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;
@@ -375,6 +488,12 @@ else if (_livres is not null)
return ChargerAsync();
}
private Task FiltrerCouverture(bool? sansCouverture)
{
_sansCouverture = sansCouverture;
return ChargerAsync();
}
/// <summary>Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte.</summary>
private static bool EstSansCritere(CritereLivres criteres) =>
string.IsNullOrWhiteSpace(criteres.Recherche)
@@ -382,6 +501,7 @@ else if (_livres is not 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()
@@ -403,16 +523,25 @@ else if (_livres is not null)
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)
+113 -1
View File
@@ -91,8 +91,17 @@ else
}
<div class="fiche-entete">
@*
⚠️ Une couverture CASSÉE devient un point de correction : cliquer dessus ouvre son
adresse, focalisée, et « Entrée » enregistre. C'est là qu'on constate le défaut, et
c'est donc là qu'il faut pouvoir le réparer — passer par « Éditer » puis descendre
au dernier champ du formulaire pour corriger un lien mort était un détour.
Une couverture qui s'affiche, elle, s'agrandit comme avant.
*@
<Couverture Url="@_livre.CoverUrl" Titre="@_livre.Titre"
Classe="couverture-grande" Agrandissable="true" />
Classe="couverture-grande" Agrandissable="true"
OnCorriger="OuvrirCorrectionCouverture" />
<div class="fiche-infos">
<h2 class="fiche-titre">@_livre.Titre</h2>
@@ -181,6 +190,32 @@ else
</div>
</div>
@if (_correctionCouverture)
{
<div class="barre-recherche ligne-creation">
<input class="champ-saisie" type="url" @ref="_champUrlCouverture"
@bind="_urlCouverture" @bind:event="oninput"
@onkeydown="SurToucheCouverture"
placeholder="https://…" aria-label="Adresse de la couverture"
autocomplete="off" />
<button type="button" class="bouton bouton-principal"
disabled="@(_enregistrement || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="EnregistrerCouvertureAsync">
@(_enregistrement ? "Enregistrement…" : "Enregistrer")
</button>
<button type="button" class="bouton bouton-discret" disabled="@_enregistrement"
@onclick="() => _correctionCouverture = false">
Annuler
</button>
</div>
<p class="message-discret">
Collez l'adresse d'une image, puis Entrée. Vider le champ retire la couverture.
</p>
}
@* Hors « Éditer » volontairement : changer son statut est l'action la plus fréquente
de l'application, et elle ne touche qu'à MA lecture, jamais à la fiche commune. *@
<section class="bloc-statut">
@@ -368,6 +403,83 @@ else
/// </summary>
private async Task RechargerAsync() => _livre = await Api.ObtenirAsync(Id) ?? _livre;
/// <summary>Champ d'adresse de la couverture, ouvert depuis l'image cassée elle-même.</summary>
private bool _correctionCouverture;
private string _urlCouverture = string.Empty;
private ElementReference _champUrlCouverture;
private bool _focusCouverture;
private void OuvrirCorrectionCouverture()
{
_erreur = null;
_urlCouverture = _livre?.CoverUrl ?? string.Empty;
_correctionCouverture = true;
// ⚠️ Le focus est demandé UNE fois, au prochain rendu : le reprendre à chaque rendu
// l'arracherait pendant qu'on colle ou qu'on tape.
_focusCouverture = true;
}
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (_focusCouverture)
{
_focusCouverture = false;
await _champUrlCouverture.FocusAsync();
}
}
private async Task SurToucheCouverture(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
await EnregistrerCouvertureAsync();
}
else if (e.Key is "Escape" or "Esc")
{
_correctionCouverture = false;
}
}
/// <summary>
/// Enregistre la seule adresse de couverture, sans passer par l'écran d'édition.
/// </summary>
/// <remarks>
/// Même exception que le statut de lecture et les prêts : ce n'est pas « modifier la fiche »,
/// c'est réparer ce qu'on a sous les yeux. La saisie du formulaire d'édition est mise à jour
/// dans la foulée, sinon un passage ultérieur en édition réécrirait l'ancienne adresse.
/// </remarks>
private async Task EnregistrerCouvertureAsync()
{
if (_saisie is null)
{
return;
}
var url = _urlCouverture.Trim();
_saisie.CoverUrl = string.IsNullOrEmpty(url) ? null : url;
_enregistrement = true;
_erreur = null;
var resultat = await Api.ModifierAsync(Id, _saisie);
_enregistrement = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
_livre = resultat.Valeur;
_saisie = Recopier(_livre!);
_correctionCouverture = false;
}
private async Task ChangerStatutAsync(Statut? statut)
{
_statutEnCours = true;
+138 -4
View File
@@ -189,15 +189,53 @@ else
}
else
{
@* La couverture d'un numéro ne vient d'aucune source — l'ISSN désigne la
revue, pas la parution : sans URL collée, c'est le substitut à
initiale qui s'affiche, et il n'est pas cliquable. *@
@*
La couverture d'un numéro ne vient d'AUCUNE source — l'ISSN désigne
la revue, pas la parution : elle se colle à la main, ou il n'y en a
pas. C'est donc ici que le clic pour la poser sert le plus.
⚠️ Même principe que la fiche livre : cliquer une couverture absente
ou cassée ouvre son adresse, focalisée, et Entrée enregistre. Ce
n'est pas « modifier le numéro » — c'est réparer ce qu'on a sous les
yeux, et c'est pourquoi ce geste-là existe aussi en consultation,
contrairement à « Modifier » et « Retirer ».
*@
<Couverture Url="@numero.CoverUrl" Titre="@($"N° {numero.Numero}")"
Classe="couverture-petite" Agrandissable="true" />
Classe="couverture-petite" Agrandissable="true"
OnCorriger="() => OuvrirCorrectionCouverture(numero)" />
<div class="carte-corps">
<p class="carte-titre">N° @numero.Numero</p>
@if (_correctionCouverture == numero.Id)
{
<div class="barre-recherche ligne-creation">
<input class="champ-saisie" type="url" @ref="_champUrlCouverture"
@bind="_urlCouverture" @bind:event="oninput"
@onkeydown="@(e => SurToucheCouverture(e, numero))"
placeholder="https://…"
aria-label="@($"Adresse de la couverture du numéro {numero.Numero}")"
autocomplete="off" />
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => EnregistrerCouvertureAsync(numero)">
@(_enCours ? "Enregistrement…" : "Enregistrer")
</button>
<button type="button" class="bouton bouton-discret"
disabled="@_enCours"
@onclick="() => _correctionCouverture = null">
Annuler
</button>
</div>
<p class="message-discret">
Collez l'adresse d'une image, puis Entrée. Vider le champ
retire la couverture.
</p>
}
<p class="carte-details">
@if (numero.DateParution is { } parution)
{
@@ -300,6 +338,15 @@ else
/// <summary>Numéro dont le retrait attend confirmation — <c>null</c> = aucun.</summary>
private int? _confirmeRetrait;
/// <summary>Numéro dont l'adresse de couverture est ouverte — <c>null</c> = aucun.</summary>
private int? _correctionCouverture;
private string _urlCouverture = string.Empty;
private ElementReference _champUrlCouverture;
private bool _focusCouverture;
/// <summary>
/// Écran d'édition de la revue : nom, ISSN, éditeur, suppression, et la seule place où un
/// numéro se modifie ou se retire.
@@ -440,6 +487,87 @@ else
private void FermerEdition() => _edition = null;
/// <summary>
/// Ouvre l'adresse de la couverture d'un numéro, depuis l'image elle-même.
/// </summary>
/// <remarks>
/// ⚠️ Le focus est demandé <b>une seule fois</b>, au prochain rendu : le reprendre à chaque
/// rendu l'arracherait pendant qu'on colle ou qu'on tape.
/// </remarks>
private void OuvrirCorrectionCouverture(NumeroRevueDto numero)
{
_erreur = null;
_confirmeRetrait = null;
_correctionCouverture = numero.Id;
_urlCouverture = numero.CoverUrl ?? string.Empty;
_focusCouverture = true;
}
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (_focusCouverture)
{
_focusCouverture = false;
await _champUrlCouverture.FocusAsync();
}
}
private async Task SurToucheCouverture(KeyboardEventArgs e, NumeroRevueDto numero)
{
if (e.Key == "Enter")
{
await EnregistrerCouvertureAsync(numero);
}
else if (e.Key is "Escape" or "Esc")
{
_correctionCouverture = null;
}
}
/// <summary>
/// N'enregistre que l'adresse de la couverture, sans passer par l'écran d'édition.
/// </summary>
/// <remarks>
/// ⚠️ <b>Le point d'entrée REMPLACE le numéro entier</b>, articles à la une compris : tout ce
/// que porte la ligne doit être recopié dans la charge utile, faute de quoi poser une
/// couverture effacerait la une. C'est la même précaution que dans <c>OuvrirEdition</c>, et
/// c'est le genre d'oubli qu'aucun message d'erreur ne signalerait.
/// </remarks>
private async Task EnregistrerCouvertureAsync(NumeroRevueDto numero)
{
if (!Reseau.EnLigne || _enCours)
{
return;
}
var url = _urlCouverture.Trim();
_enCours = true;
_erreur = null;
var resultat = await Api.ModifierNumeroAsync(
numero.Id,
new AjoutNumeroRevue
{
Numero = numero.Numero,
DateParution = numero.DateParution,
Note = numero.Note,
CoverUrl = string.IsNullOrEmpty(url) ? null : url,
Articles = [.. numero.Articles],
});
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Appliquer(resultat.Valeur!);
_correctionCouverture = null;
}
private async Task EnregistrerNumeroAsync()
{
if (_edition is not { } numeroId
@@ -490,6 +618,12 @@ else
_edition = null;
}
// Le numéro retiré portait peut-être le champ d'adresse ouvert : il n'a plus de cible.
if (_correctionCouverture == numero.Id)
{
_correctionCouverture = null;
}
if (_erreur is null)
{
await ChargerAsync();
+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;
}