API - CRUD complet sur /api/livres : liste filtrable (format, statut) et cherchable (titre, auteur), consultation, creation, modification, changement de statut isole, suppression. - Aucune lecture ne filtre sur AjoutePar : la bibliotheque est commune au foyer. Le critere n'existe meme pas dans CritereLivres, pour qu'on ne puisse pas s'en servir par inadvertance. Deux tests verrouillent l'invariant, dont un par reflexion sur le type des criteres. - AjoutePar est renseigne par le serveur a la creation, jamais par le client, et n'est pas reecrit a l'edition (pas plus que DateAjout). - Identite lue dans les en-tetes SSOwat (YNH_USER, YNH_USER_EMAIL, YNH_USER_FULLNAME), avec repli sur un utilisateur simule configure en developpement uniquement. Sans en-tete ni simulation, l'identite reste inconnue plutot qu'inventee. Expose par GET /api/moi. - Migration appliquee au demarrage : pas d'etape manuelle a l'installation. Interface Blazor, pensee mobile d'abord - Catalogue unique (physiques et numeriques melanges, distingues par le format) avec recherche, filtres et changement de statut directement dans la liste. - Ajout par ISBN : quand la cascade rend plusieurs notices, un ecran de choix les presente en mettant en avant editeur et annee, seuls elements qui les departagent. Le formulaire reste ensuite entierement modifiable. - Ajout manuel, edition, suppression avec confirmation explicite. - Couvertures : l'URL OpenLibrary n'est jamais validee par un appel prealable (502 intermittents mesures en phase 2) ; un attribut onerror masque l'image cassee et laisse apparaitre un substitut. Decisions prises la ou CLAUDE.md etait muet - Tri alphabetique par titre. - Un ISBN saisi doit etre valide, mais reste facultatif. - Ecran de choix affiche seulement a partir de deux candidats. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
6.5 KiB
Plaintext
215 lines
6.5 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">
|
|
Saisissez l'ISBN imprimé sur le livre. Le scan par la caméra arrivera plus tard.
|
|
</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>
|
|
<a class="bouton bouton-discret" href="ajout/manuel">Saisir à la main</a>
|
|
</div>
|
|
}
|
|
|
|
@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, 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 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("/");
|
|
}
|
|
}
|