Le decodage EAN-13/EAN-8 est fait en C# par ZXing.Net (IsbnScanner), conformement a la decision actee dans CLAUDE.md : le JavaScript ne fournit que les pixels de la camera, aucune logique de decodage ne lui est confiee. Le scan se greffe en amont du flux d'ajout par ISBN existant : des qu'un code est lu et reconnu comme ISBN-13 valide, la recherche de notices est declenchee sans que l'utilisateur ait a retaper quoi que ce soit. La saisie manuelle reste accessible en permanence, y compris quand la camera echoue. Points traites explicitement : - camera arriere par defaut (facingMode "environment", en "ideal" pour ne pas echouer sur un PC sans camera arriere) ; - liberation de la camera a l'annulation, au succes et au demontage du composant ; - cadence limitee a ~12 images/s et decodage restreint a la bande centrale reduite a 640 px de large : inutile de decoder chaque frame en pleine taille ; - messages distincts pour permission refusee, absence de camera, camera occupee et contexte non securise (getUserMedia exige HTTPS ou localhost : une IP de reseau local en http ne marchera jamais, c'est une source de confusion garantie). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
7.3 KiB
Plaintext
241 lines
7.3 KiB
Plaintext
@page "/ajout/isbn"
|
|
@inject ServiceLivresApi Api
|
|
@inject NavigationManager Navigation
|
|
|
|
<PageTitle>MaBibli — ajouter par ISBN</PageTitle>
|
|
|
|
<h1 class="titre-page">Ajouter par ISBN</h1>
|
|
|
|
@if (_etape == Etape.Saisie)
|
|
{
|
|
<p class="message-discret">
|
|
Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.
|
|
</p>
|
|
|
|
<div class="barre-recherche">
|
|
<input class="champ-saisie" type="text" inputmode="numeric" placeholder="978…"
|
|
@bind="_isbn" @bind:event="oninput" @onkeydown="SurTouche"
|
|
aria-label="ISBN" autocomplete="off" />
|
|
</div>
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_chargement || string.IsNullOrWhiteSpace(_isbn))"
|
|
@onclick="ChercherAsync">
|
|
@(_chargement ? "Recherche…" : "Chercher")
|
|
</button>
|
|
@* La saisie manuelle reste le recours quand le code-barres est abîmé, absent,
|
|
ou que la caméra est indisponible : elle ne disparaît jamais derrière le scan. *@
|
|
<button type="button" class="bouton bouton-discret" @onclick="OuvrirScanner">
|
|
Scanner
|
|
</button>
|
|
<a class="bouton bouton-discret" href="ajout/manuel">Saisir à la main</a>
|
|
</div>
|
|
}
|
|
|
|
@if (_etape == Etape.Scan)
|
|
{
|
|
<ScannerCodeBarres OnCodeDetecte="SurCodeDetecteAsync" OnAnnuler="Recommencer" />
|
|
}
|
|
|
|
@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 (_etape == Etape.Choix)
|
|
{
|
|
@* Décision actée dans CLAUDE.md : plusieurs notices pour un même ISBN (rééditions
|
|
successives partageant l'ISBN), on ne choisit JAMAIS à la place de l'utilisateur.
|
|
Les candidats partagent titre et auteur : ce sont l'éditeur et l'année qui départagent,
|
|
donc ils sont mis en avant. *@
|
|
<p class="message-discret">
|
|
@_candidats.Count notices correspondent à cet ISBN. Elles se distinguent par l'éditeur et l'année.
|
|
</p>
|
|
|
|
<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>
|
|
|
|
<p class="carte-details">
|
|
<span class="etiquette">@candidat.Source</span>
|
|
@if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge))
|
|
{
|
|
<span>trouvé via @candidat.IsbnInterroge</span>
|
|
}
|
|
</p>
|
|
|
|
<button type="button" class="bouton bouton-principal"
|
|
@onclick="() => Choisir(candidat)">
|
|
Choisir cette édition
|
|
</button>
|
|
</div>
|
|
</li>
|
|
}
|
|
</ul>
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-discret" @onclick="Recommencer">Aucune : saisir à la main</button>
|
|
</div>
|
|
}
|
|
|
|
@if (_etape == Etape.Formulaire)
|
|
{
|
|
<p class="message-discret">
|
|
Vérifiez et complétez la fiche : tout reste modifiable.
|
|
</p>
|
|
|
|
<FormulaireLivre Saisie="_saisie"
|
|
LibelleValidation="Ajouter au catalogue"
|
|
Erreur="@_erreurFormulaire"
|
|
EnCours="_enregistrement"
|
|
OnValider="EnregistrerAsync"
|
|
OnAnnuler="Recommencer" />
|
|
}
|
|
|
|
@code {
|
|
private enum Etape { Saisie, Scan, Choix, Formulaire }
|
|
|
|
private Etape _etape = Etape.Saisie;
|
|
private string _isbn = string.Empty;
|
|
private bool _chargement;
|
|
private bool _enregistrement;
|
|
private string? _erreur;
|
|
private string? _erreurFormulaire;
|
|
private IReadOnlyList<CandidatLivre> _candidats = [];
|
|
private IReadOnlyList<string> _avertissements = [];
|
|
private EnregistrementLivre _saisie = new();
|
|
|
|
private void OuvrirScanner()
|
|
{
|
|
_erreur = null;
|
|
_avertissements = [];
|
|
_etape = Etape.Scan;
|
|
}
|
|
|
|
// Le scan enchaîne directement sur le flux de lookup existant : l'utilisateur
|
|
// ne retape jamais ce qui vient d'être scanné.
|
|
private async Task SurCodeDetecteAsync(string isbn)
|
|
{
|
|
_isbn = isbn;
|
|
_etape = Etape.Saisie;
|
|
await ChercherAsync();
|
|
}
|
|
|
|
private async Task SurTouche(KeyboardEventArgs e)
|
|
{
|
|
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_isbn))
|
|
{
|
|
await ChercherAsync();
|
|
}
|
|
}
|
|
|
|
private async Task ChercherAsync()
|
|
{
|
|
_chargement = true;
|
|
_erreur = null;
|
|
_avertissements = [];
|
|
|
|
try
|
|
{
|
|
var resultat = await Api.ChercherIsbnAsync(_isbn.Trim());
|
|
|
|
if (resultat is null)
|
|
{
|
|
_erreur = $"« {_isbn} » n'est pas un ISBN valide.";
|
|
return;
|
|
}
|
|
|
|
_avertissements = resultat.Avertissements;
|
|
_candidats = resultat.Candidats;
|
|
|
|
if (_candidats.Count == 0)
|
|
{
|
|
// Aucune notice : on ne bloque pas, le formulaire manuel reste la porte de sortie.
|
|
_erreur = "Aucune notice trouvée pour cet ISBN. Complétez la fiche à la main.";
|
|
_saisie = new EnregistrementLivre { Isbn = resultat.IsbnDemande };
|
|
_etape = Etape.Formulaire;
|
|
return;
|
|
}
|
|
|
|
if (_candidats.Count == 1)
|
|
{
|
|
Choisir(_candidats[0]);
|
|
return;
|
|
}
|
|
|
|
_etape = Etape.Choix;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_erreur = $"La recherche a échoué : {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
_chargement = false;
|
|
}
|
|
}
|
|
|
|
private void Choisir(CandidatLivre candidat)
|
|
{
|
|
_saisie = new EnregistrementLivre
|
|
{
|
|
Isbn = _isbn.Trim(),
|
|
Titre = candidat.Titre,
|
|
Auteur = candidat.Auteur,
|
|
Editeur = candidat.Editeur,
|
|
CoverUrl = candidat.CoverUrl,
|
|
Format = Format.Physique,
|
|
Statut = Statut.ALire,
|
|
};
|
|
|
|
_erreur = null;
|
|
_etape = Etape.Formulaire;
|
|
}
|
|
|
|
private void Recommencer()
|
|
{
|
|
_etape = Etape.Saisie;
|
|
_erreur = null;
|
|
_erreurFormulaire = null;
|
|
_candidats = [];
|
|
}
|
|
|
|
private async Task EnregistrerAsync()
|
|
{
|
|
_enregistrement = true;
|
|
_erreurFormulaire = null;
|
|
|
|
var resultat = await Api.CreerAsync(_saisie);
|
|
|
|
_enregistrement = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreurFormulaire = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
Navigation.NavigateTo("/");
|
|
}
|
|
}
|