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>
524 lines
18 KiB
Plaintext
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");
|
|
}
|
|
}
|