Files
mabibli/MaBibli.Client/Pages/AjoutSouhait.razor
T
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

524 lines
18 KiB
Plaintext

@page "/souhaits/ajout"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
@*
Écran séparé, comme la fiche livre l'est de son édition : la page « Envies » ne montre plus
que la liste, et l'ajout a la place de proposer une vraie recherche.
Trois façons d'alimenter la liste, dans l'ordre où le besoin se présente :
— par titre/auteur, le cas NORMAL : on souhaite un livre qu'on n'a pas en main ;
— par ISBN, quand on l'a en main (en librairie, chez quelqu'un) ;
— à la main, quand aucune source ne connaît le livre.
*@
<PageTitle>MaBibli — ajouter une envie</PageTitle>
<h1 class="titre-page">Ajouter une envie</h1>
@if (!Reseau.EnLigne)
{
<p class="message-avertissement" role="status">
Hors ligne : la recherche interroge la BnF et OpenLibrary, et l'enregistrement passe par
le serveur. Votre liste reste consultable.
</p>
}
<div class="onglets-secondaires" role="group" aria-label="Manière de chercher">
<button type="button" class="segment @(_mode == Mode.Titre ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Titre)">Par titre</button>
<button type="button" class="segment @(_mode == Mode.Isbn ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Isbn)">Par ISBN</button>
<button type="button" class="segment @(_mode == Mode.Manuel ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Manuel)">À la main</button>
<button type="button" class="segment @(_mode == Mode.Revue ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Revue)">Une revue</button>
</div>
@if (_mode == Mode.Titre)
{
<div class="champ">
<label class="champ-libelle" for="titre-cherche">Titre <span class="obligatoire">*</span></label>
<input id="titre-cherche" class="champ-saisie" type="text" @ref="_premierChamp"
@bind="_titreCherche" @bind:event="oninput" @onkeydown="SurTouche"
placeholder="La bête humaine" />
</div>
<div class="champ">
<label class="champ-libelle" for="auteur-cherche">Auteur</label>
<input id="auteur-cherche" class="champ-saisie" type="text"
@bind="_auteurCherche" @bind:event="oninput" @onkeydown="SurTouche"
placeholder="Facultatif : resserre la recherche" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_recherche || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titreCherche))"
title="@MotifBlocage" @onclick="ChercherParTitreAsync">
@(_recherche ? "Recherche…" : "Chercher")
</button>
</div>
}
@if (_mode == Mode.Isbn)
{
<div class="champ">
<label class="champ-libelle" for="isbn-cherche">ISBN</label>
<input id="isbn-cherche" class="champ-saisie" type="text" inputmode="numeric"
placeholder="978…" @ref="_premierChamp"
@bind="_isbn" @bind:event="oninput" @onkeydown="SurTouche" />
<p class="champ-aide">Le livre est en main : le code-barres suffit.</p>
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_recherche || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_isbn))"
title="@MotifBlocage" @onclick="ChercherParIsbnAsync">
@(_recherche ? "Recherche…" : "Chercher")
</button>
@*
J2 : le cas d'usage est direct — le livre est en main, en librairie. C'est le même
composant de scan que l'écran d'ajout au catalogue, sans création de Livre au bout :
un souhait n'est pas une possession.
*@
<button type="button" class="bouton bouton-discret"
disabled="@(_scan || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="OuvrirScanner">
Scanner
</button>
</div>
}
@if (_scan)
{
<ScannerCodeBarres OnCodeDetecte="SurCodeDetecteAsync" OnAnnuler="() => _scan = false" />
}
@* L1 : la BnF met environ une seconde à répondre. Sans indicateur, l'écran ne dit rien et
l'on reclique — donc on relance l'appel. *@
@if (_recherche)
{
<Patience Message="Interrogation de la BnF, puis d'OpenLibrary…" />
}
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@foreach (var avertissement in _avertissements)
{
<p class="message-avertissement">@avertissement</p>
}
@if (_candidats.Count > 0)
{
@* On ne choisit jamais à la place de l'utilisateur : ce sont l'éditeur, l'année et
l'auteur qui départagent des notices au titre identique. *@
<p class="message-discret">
@_candidats.Count notice@(_candidats.Count > 1 ? "s" : "") trouvée@(_candidats.Count > 1 ? "s" : "").
Choisissez celle qui correspond, ou complétez à la main.
</p>
<div class="champ champ-tri">
<label class="champ-libelle" for="tri-resultats">Trier par</label>
<select id="tri-resultats" class="champ-saisie" value="@_tri"
@onchange="e => ChangerTri(e.Value?.ToString())">
<option value="@TriResultats.Date">Date (récent en premier)</option>
<option value="@TriResultats.Titre">Titre</option>
<option value="@TriResultats.Editeur">Éditeur</option>
</select>
</div>
<ul class="liste-candidats">
@foreach (var (candidat, index) in _candidats.Select((c, i) => (c, i)))
{
<li class="carte-candidat" @key="index">
<Couverture Url="@candidat.CoverUrl" Titre="@candidat.Titre" Classe="couverture-petite" />
<div class="carte-corps">
<p class="carte-titre">@candidat.Titre</p>
@if (!string.IsNullOrWhiteSpace(candidat.Auteur))
{
<p class="carte-auteur">@candidat.Auteur</p>
}
<p class="candidat-distinction">
<span class="candidat-editeur">@(candidat.Editeur ?? "éditeur inconnu")</span>
<span class="candidat-annee">@(candidat.Annee ?? "année inconnue")</span>
</p>
@if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge))
{
<p class="carte-details">
<span class="etiquette code-isbn">ISBN @FormatageIsbn.Afficher(candidat.IsbnInterroge)</span>
</p>
}
<button type="button" class="bouton bouton-principal"
disabled="@(_ajout || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => AjouterCandidatAsync(candidat)">
Ajouter à ma liste
</button>
</div>
</li>
}
</ul>
}
@if (_mode == Mode.Manuel)
{
@* Le recours quand aucune source ne connaît le livre — un ouvrage étranger, un titre à
paraître, un livre d'occasion sans ISBN. Il ne disparaît jamais derrière la recherche. *@
<div class="champ">
<label class="champ-libelle" for="titre-souhait">Titre <span class="obligatoire">*</span></label>
<input id="titre-souhait" class="champ-saisie" type="text" @ref="_premierChamp"
@bind="_saisie.Titre" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="auteur-souhait">Auteur</label>
<input id="auteur-souhait" class="champ-saisie" type="text"
@bind="_saisie.Auteur" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="note-souhait">Note</label>
<input id="note-souhait" class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
placeholder="« demandé à Noël », « en poche seulement »…" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_ajout || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Titre))"
title="@MotifBlocage" @onclick="() => AjouterAsync(_saisie)">
Ajouter à ma liste
</button>
</div>
}
@if (_mode == Mode.Revue)
{
@*
⚠️ Aucune recherche ici, et ce n'est pas un manque : l'ISSN désigne un TITRE de
périodique, jamais une parution. Interroger la BnF ne dirait donc rien du numéro, qui est
justement ce qu'on souhaite. On note ce qu'on a vu en kiosque.
*@
<p class="message-discret">
Une revue entière, ou un numéro précis. Le numéro peut rester vide : « je voudrais
m'abonner à Médor » est une envie comme une autre.
</p>
<div class="champ">
<label class="champ-libelle" for="titre-revue">Titre de la revue <span class="obligatoire">*</span></label>
<input id="titre-revue" class="champ-saisie" type="text" @ref="_premierChamp"
@bind="_saisieRevue.Titre" @bind:event="oninput" placeholder="Médor" />
</div>
<div class="champ">
<label class="champ-libelle" for="numero-revue">Numéro</label>
<input id="numero-revue" class="champ-saisie" type="text"
@bind="_saisieRevue.Numero" @bind:event="oninput" placeholder="43, hors-série 7…" />
</div>
<div class="champ">
<label class="champ-libelle" for="issn-revue">ISSN</label>
<input id="issn-revue" class="champ-saisie" type="text"
@bind="_saisieRevue.Issn" @bind:event="oninput" placeholder="2466-6718" />
</div>
<div class="champ">
<label class="champ-libelle" for="note-revue">Note</label>
<input id="note-revue" class="champ-saisie" type="text"
@bind="_saisieRevue.Note" @bind:event="oninput" placeholder="« vu chez le dentiste »…" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_ajout || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisieRevue.Titre))"
title="@MotifBlocage" @onclick="AjouterRevueAsync">
Ajouter à ma liste
</button>
</div>
}
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="souhaits">Retour à ma liste</a>
</nav>
@code {
private enum Mode { Titre, Isbn, Manuel, Revue }
private Mode _mode = Mode.Titre;
private ElementReference _premierChamp;
private bool _focusAFaire = true;
private string _titreCherche = string.Empty;
private string _auteurCherche = string.Empty;
private string _isbn = string.Empty;
private EnregistrementSouhait _saisie = new();
private EnregistrementRevueSouhaitee _saisieRevue = new();
private IReadOnlyList<CandidatLivre> _candidats = [];
private IReadOnlyList<string> _avertissements = [];
private TriResultats _tri = TriResultats.Date;
private bool _recherche;
private bool _ajout;
/// <summary>Le scanner est ouvert. Il ne l'est que depuis la voie « par ISBN ».</summary>
private bool _scan;
private string? _erreur;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
public void Dispose() => Reseau.Change -= SurChangementReseau;
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private enum TriResultats
{
Date,
Titre,
Editeur,
}
/// <summary>
/// Focalise le premier champ à l'ouverture et à chaque changement de mode.
/// </summary>
/// <remarks>
/// Même raison qu'à l'écran d'ajout par ISBN : on arrive ici pour taper, et le mode « Par
/// ISBN » doit rester utilisable à la douchette. Le drapeau évite de reprendre le focus à
/// chaque rendu, ce qui arracherait le curseur en pleine frappe.
/// </remarks>
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (!_focusAFaire)
{
return;
}
_focusAFaire = false;
try
{
await _premierChamp.FocusAsync();
}
catch (InvalidOperationException)
{
}
}
private void OuvrirScanner()
{
_erreur = null;
_candidats = [];
_avertissements = [];
_scan = true;
}
/// <summary>
/// Enchaîne la recherche sur le code lu, sans bouton à confirmer.
/// </summary>
/// <remarks>
/// Le code reste dans le champ : un décodage erroné se corrige et se relance. C'est la
/// décision déjà actée pour l'écran d'ajout au catalogue, et la seule cohérente avec une
/// douchette, qui envoie <c>Entrée</c> d'elle-même.
/// <para>
/// ⚠️ L'add-on EAN-2 (numéro de parution) est ignoré ici, et ce n'est pas un oubli : il ne
/// concerne que les revues, qui ne se souhaitent pas — cet écran alimente une liste
/// d'<i>œuvres</i>. Un code de périodique est d'ailleurs intercepté plus bas et nommé.
/// </para>
/// </remarks>
private async Task SurCodeDetecteAsync(CodeScanne lecture)
{
_scan = false;
_isbn = lecture.Code;
await ChercherParIsbnAsync();
}
private void Basculer(Mode mode)
{
_scan = false;
_mode = mode;
_focusAFaire = true;
_erreur = null;
_candidats = [];
_avertissements = [];
_tri = TriResultats.Date;
}
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key != "Enter")
{
return;
}
if (_mode == Mode.Titre && !string.IsNullOrWhiteSpace(_titreCherche))
{
await ChercherParTitreAsync();
}
else if (_mode == Mode.Isbn && !string.IsNullOrWhiteSpace(_isbn))
{
await ChercherParIsbnAsync();
}
}
private async Task ChercherParTitreAsync() =>
await ChercherAsync(() => Api.ChercherNoticesAsync(_titreCherche, _auteurCherche));
private async Task ChercherParIsbnAsync() =>
await ChercherAsync(() => Api.ChercherIsbnAsync(_isbn.Trim()));
private async Task ChercherAsync(Func<Task<ResultatLookupIsbn?>> appel)
{
_recherche = true;
_erreur = null;
_candidats = [];
_avertissements = [];
try
{
var resultat = await appel();
if (resultat is null)
{
_erreur = Reseau.EnLigne
? "La recherche n'a rien pu ramener. Réessayez, ou complétez à la main."
: "La recherche interroge la BnF : indisponible hors ligne.";
return;
}
_avertissements = resultat.Avertissements;
// Un code de périodique n'est pas un livre : on le dit ici aussi, plutôt que de
// laisser un « aucun résultat » qui enverrait chercher un défaut inexistant.
if (resultat.Periodique is { } revue)
{
var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »";
_erreur = $"Ce code désigne {nom} (ISSN {revue.Issn}), pas un livre.";
return;
}
_candidats = resultat.Candidats;
_candidats = Trier(_candidats);
if (_candidats.Count == 0)
{
_erreur = "Aucune notice ne correspond. Vous pouvez l'ajouter à la main.";
}
}
finally
{
_recherche = false;
}
}
private void ChangerTri(string? valeur)
{
if (!Enum.TryParse<TriResultats>(valeur, out var tri))
{
return;
}
_tri = tri;
_candidats = Trier(_candidats);
}
private IReadOnlyList<CandidatLivre> Trier(IEnumerable<CandidatLivre> candidats) =>
_tri switch
{
TriResultats.Titre => candidats
.OrderBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(c => c.Editeur, StringComparer.CurrentCultureIgnoreCase)
.ToList(),
TriResultats.Editeur => candidats
.OrderBy(c => c.Editeur ?? string.Empty, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList(),
_ => candidats
.OrderByDescending(c => AnneeTriable(c.Annee))
.ThenBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList(),
};
private static int AnneeTriable(string? date)
{
if (string.IsNullOrWhiteSpace(date))
{
return 0;
}
for (var i = 0; i + 4 <= date.Length; i++)
{
if (int.TryParse(date.AsSpan(i, 4), out var annee) && annee is >= 1000 and <= 2999)
{
return annee;
}
}
return 0;
}
/// <summary>
/// Enregistre le candidat choisi, <b>couverture comprise</b>.
/// </summary>
/// <remarks>
/// C'est ce qui remplit enfin <c>LivreSouhaite.CoverUrl</c>, jusqu'ici toujours vide : la
/// couverture vient d'OpenLibrary dès qu'un ISBN est connu. Sans ISBN il n'y en a pas, et
/// l'on n'en invente pas — la vignette retombe sur son substitut à initiale.
/// </remarks>
private Task AjouterCandidatAsync(CandidatLivre candidat) =>
AjouterAsync(new EnregistrementSouhait
{
Titre = candidat.Titre,
Auteur = candidat.Auteur,
Editeur = candidat.Editeur,
Annee = candidat.Annee,
Isbn = candidat.IsbnInterroge,
CoverUrl = candidat.CoverUrl,
});
private async Task AjouterAsync(EnregistrementSouhait saisie)
{
_ajout = true;
_erreur = null;
var resultat = await Api.AjouterSouhaitAsync(saisie);
_ajout = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
// Retour à la liste : l'envie vient de s'y poser en dernier, et c'est là qu'on la classe.
Navigation.NavigateTo("souhaits");
}
private async Task AjouterRevueAsync()
{
_ajout = true;
_erreur = null;
var resultat = await Api.AjouterRevueSouhaiteeAsync(_saisieRevue);
_ajout = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Navigation.NavigateTo("souhaits");
}
}