Traiter les retours d'usage du 2026-08-18 (2ᵉ série)
Six lots, issus d'IDEES.md. Les décisions actées sont reportées dans
CLAUDE.md, et les entrées traitées retirées d'IDEES.md.
1. Douchette USB, ISSN et enchaînement du scan
- Champ ISBN focalisé à l'ouverture : c'est tout ce qu'exige une
douchette USB, qui se présente au système comme un clavier. Remède
au scan caméra, qui rate sur la webcam d'un PC (optique, pas
décodage).
- Un EAN en 977 est un périodique : il porte un ISSN, donc un titre de
revue. Intercepté AVANT la validation ISBN (c'est un EAN-13
parfaitement valide), l'ISSN est déduit du code puis nommé via
bib.issn. Auparavant la cascade s'exécutait en entier pour finir sur
« aucun résultat ».
- L'enchaînement après scan existait déjà mais était invisible : une
étape « Recherche » affiche désormais le code interrogé.
2. ISBN affichés avec des tirets
Tables extraites du RangeMessage.xml officiel — plusieurs tranches ne
sont pas celles qu'on suppose. Le francophone est découpé en entier,
ailleurs on s'arrête au groupe et à la clé : aucune coupure fausse.
Corrige au passage l'export CSV, où un ISBN nu était lu comme un
nombre par Excel.
3. Hors-ligne : la liste d'envies
ListerSouhaitsAsync était le seul point de lecture hors du dispositif
hors-ligne, d'où le « 404 Not Found » brut à l'écran. Cinquième
instantané, écritures refusées, plus aucun message HTTP. Même défaut
corrigé sur la bibliographie.
4. Navigation par onglets
Catalogue / Auteurs / Prêts / Envies dans MainLayout ; les barres
d'actions ne portent plus que des actions. Filtres repliés derrière un
bouton compteur, ligne « format » masquée quand le fonds n'a qu'un
format. Une seule entrée d'ajout, désactivée hors-ligne — pas masquée.
5. Liste d'envies : ordre, recherche, couvertures
Migration RangDesEnvies. Le remplissage reconduit l'ordre affiché
jusqu'ici : sans lui, les listes existantes se seraient réordonnées
toutes seules. Réordonnancement par flèches et glisser-déposer (le
drag & drop HTML5 ne marche pas au doigt). Ajout dans son propre
écran, avec recherche par titre (bib.title) et couvertures enfin
alimentées.
6. Bibliographie : une source muette n'est pas une liste vide
L'écran affichait « BnF injoignable » PUIS « la BnF ne connaît aucun
livre de cet auteur » — la seconde phrase étant fausse. Les deux cas
s'excluent désormais, et un bouton Réessayer est offert.
Vérifié en exécution : le diagnostic d'IDEES.md était faux sur un
point. Robert A. Harper a bien 7 œuvres à la BnF (85 notices
annoncées) ; c'était le même délai dépassé observé deux fois, pris
pour deux causes distinctes.
380 tests, dont un qui applique réellement la migration (EnsureCreated
n'en joue aucune) et un qui verrouille les messages atteignant
l'utilisateur.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
{
|
||||
<p class="message-discret">
|
||||
Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.
|
||||
Une douchette USB fonctionne telle quelle : le champ est déjà actif.
|
||||
</p>
|
||||
|
||||
@*
|
||||
@@ -27,8 +28,17 @@
|
||||
</p>
|
||||
}
|
||||
|
||||
@*
|
||||
Le champ est focalisé à l'ouverture et valide sur Entrée : c'est tout ce qu'exige une
|
||||
douchette USB, qui se présente au système comme un CLAVIER — elle « tape » les chiffres
|
||||
puis Entrée. Aucune permission, aucun HTTPS, aucun décodage : c'est le contournement le
|
||||
plus rentable du scan caméra, qui rate souvent sur la webcam d'un PC.
|
||||
La recherche n'est délibérément PAS déclenchée à chaque frappe : une douchette tape trop
|
||||
vite, et chaque caractère partirait en requête.
|
||||
*@
|
||||
<div class="barre-recherche">
|
||||
<input class="champ-saisie" type="text" inputmode="numeric" placeholder="978…"
|
||||
@ref="_champIsbn"
|
||||
@bind="_isbn" @bind:event="oninput" @onkeydown="SurTouche"
|
||||
aria-label="ISBN" autocomplete="off" />
|
||||
</div>
|
||||
@@ -51,6 +61,18 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
Le scan enchaîne la recherche tout seul (décision actée). Encore faut-il que ça SE VOIE :
|
||||
sans cet écran, on retombait sur la saisie avec un bouton « Chercher » intact, et l'on
|
||||
croyait que le scan n'avait rien déclenché.
|
||||
*@
|
||||
@if (_etape == Etape.Recherche)
|
||||
{
|
||||
<p class="message-discret" role="status">
|
||||
Recherche de <span class="code-isbn">@FormatageIsbn.Afficher(_isbn)</span> à la BnF, puis chez OpenLibrary…
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (_etape == Etape.Scan)
|
||||
{
|
||||
<ScannerCodeBarres OnCodeDetecte="SurCodeDetecteAsync" OnAnnuler="Recommencer" />
|
||||
@@ -61,6 +83,11 @@
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
}
|
||||
|
||||
@if (_messagePeriodique is not null)
|
||||
{
|
||||
<p class="message-avertissement" role="status">@_messagePeriodique</p>
|
||||
}
|
||||
|
||||
@foreach (var avertissement in _avertissements)
|
||||
{
|
||||
<p class="message-avertissement">@avertissement</p>
|
||||
@@ -98,7 +125,7 @@
|
||||
<span class="etiquette">@candidat.Source</span>
|
||||
@if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge))
|
||||
{
|
||||
<span>trouvé via @candidat.IsbnInterroge</span>
|
||||
<span>trouvé via <span class="code-isbn">@FormatageIsbn.Afficher(candidat.IsbnInterroge)</span></span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@@ -132,10 +159,17 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
private enum Etape { Saisie, Scan, Choix, Formulaire }
|
||||
private enum Etape { Saisie, Scan, Recherche, Choix, Formulaire }
|
||||
|
||||
private Etape _etape = Etape.Saisie;
|
||||
private string _isbn = string.Empty;
|
||||
private ElementReference _champIsbn;
|
||||
|
||||
// Le focus ne se redonne qu'une fois par entrée dans l'étape de saisie : le reprendre à
|
||||
// chaque rendu arracherait le curseur à l'utilisateur en pleine frappe.
|
||||
private bool _focusAFaire = true;
|
||||
|
||||
private string? _messagePeriodique;
|
||||
private bool _chargement;
|
||||
private bool _enregistrement;
|
||||
private string? _erreur;
|
||||
@@ -146,6 +180,33 @@
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
|
||||
/// <summary>
|
||||
/// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C'est ce qui rend une <b>douchette USB</b> utilisable sans rien d'autre : elle tape dans
|
||||
/// le champ actif, quel qu'il soit. Sans focus automatique, l'utilisateur devrait cliquer
|
||||
/// dans le champ avant chaque livre — et le gain sur le scan caméra disparaîtrait.
|
||||
/// </remarks>
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_etape != Etape.Saisie || !_focusAFaire)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_focusAFaire = false;
|
||||
|
||||
try
|
||||
{
|
||||
await _champIsbn.FocusAsync();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// L'élément a disparu entre le rendu et l'appel (navigation rapide) : sans intérêt.
|
||||
}
|
||||
}
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
||||
@@ -156,16 +217,17 @@
|
||||
private void OuvrirScanner()
|
||||
{
|
||||
_erreur = null;
|
||||
_messagePeriodique = 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é.
|
||||
// ne retape jamais ce qui vient d'être scanné, et n'a aucun bouton à confirmer.
|
||||
// Le code lu reste dans le champ : si le décodage était mauvais, il se corrige et se relance.
|
||||
private async Task SurCodeDetecteAsync(string isbn)
|
||||
{
|
||||
_isbn = isbn;
|
||||
_etape = Etape.Saisie;
|
||||
await ChercherAsync();
|
||||
}
|
||||
|
||||
@@ -181,7 +243,9 @@
|
||||
{
|
||||
_chargement = true;
|
||||
_erreur = null;
|
||||
_messagePeriodique = null;
|
||||
_avertissements = [];
|
||||
_etape = Etape.Recherche;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -190,12 +254,21 @@
|
||||
if (resultat is null)
|
||||
{
|
||||
_erreur = $"« {_isbn} » n'est pas un ISBN valide.";
|
||||
RevenirALaSaisie();
|
||||
return;
|
||||
}
|
||||
|
||||
_avertissements = resultat.Avertissements;
|
||||
_candidats = resultat.Candidats;
|
||||
|
||||
// Un code de périodique (977) n'a JAMAIS de candidat : ce n'est pas un échec de
|
||||
// recherche, c'est un code qui ne décrit pas un livre. Le dire avant tout le reste.
|
||||
if (resultat.Periodique is { } revue)
|
||||
{
|
||||
DecrireLeMagazine(revue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_candidats.Count == 0)
|
||||
{
|
||||
// Aucune notice : on ne bloque pas, le formulaire manuel reste la porte de sortie.
|
||||
@@ -213,9 +286,12 @@
|
||||
|
||||
_etape = Etape.Choix;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
_erreur = $"La recherche a échoué : {ex.Message}";
|
||||
_erreur = Reseau.EnLigne
|
||||
? "La recherche a échoué. Réessayez, ou saisissez la fiche à la main."
|
||||
: "La recherche par ISBN interroge la BnF et OpenLibrary : indisponible hors ligne.";
|
||||
RevenirALaSaisie();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -223,6 +299,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dit ce qu'est le code scanné, puis bascule sur la saisie manuelle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// MaBibli ne gère pas les périodiques : ni numéro, ni date de parution. Le titre est
|
||||
/// pré-rempli, mais <b>pas l'ISBN</b> — le code-barres d'une revue n'en est pas un, et le
|
||||
/// ranger dans <c>Livre.Isbn</c> ferait échouer tout lookup ultérieur sur cette fiche.
|
||||
/// </remarks>
|
||||
private void DecrireLeMagazine(PeriodiqueDetecte revue)
|
||||
{
|
||||
var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »";
|
||||
|
||||
_messagePeriodique =
|
||||
$"Ce code-barres désigne {nom} (ISSN {revue.Issn}), pas un livre : "
|
||||
+ "il commence par 977, réservé aux publications en série. "
|
||||
+ "MaBibli ne gère pas les revues — vous pouvez tout de même créer une fiche à la main, "
|
||||
+ "sans ISBN.";
|
||||
|
||||
_saisie = new EnregistrementLivre
|
||||
{
|
||||
Titre = revue.Titre ?? string.Empty,
|
||||
Editeur = revue.Editeur,
|
||||
Format = Format.Physique,
|
||||
Statut = Statut.ALire,
|
||||
};
|
||||
|
||||
_erreur = null;
|
||||
_etape = Etape.Formulaire;
|
||||
}
|
||||
|
||||
/// <summary>Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette.</summary>
|
||||
private void RevenirALaSaisie()
|
||||
{
|
||||
_etape = Etape.Saisie;
|
||||
_focusAFaire = true;
|
||||
}
|
||||
|
||||
private void Choisir(CandidatLivre candidat)
|
||||
{
|
||||
_saisie = new EnregistrementLivre
|
||||
@@ -242,10 +355,11 @@
|
||||
|
||||
private void Recommencer()
|
||||
{
|
||||
_etape = Etape.Saisie;
|
||||
_erreur = null;
|
||||
_messagePeriodique = null;
|
||||
_erreurFormulaire = null;
|
||||
_candidats = [];
|
||||
RevenirALaSaisie();
|
||||
}
|
||||
|
||||
private async Task EnregistrerAsync()
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
@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>
|
||||
</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>
|
||||
</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 (_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>
|
||||
|
||||
<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>
|
||||
}
|
||||
|
||||
<nav class="actions-flottantes">
|
||||
<a class="bouton bouton-discret" href="souhaits">Retour à ma liste</a>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
private enum Mode { Titre, Isbn, Manuel }
|
||||
|
||||
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 IReadOnlyList<CandidatLivre> _candidats = [];
|
||||
private IReadOnlyList<string> _avertissements = [];
|
||||
private bool _recherche;
|
||||
private bool _ajout;
|
||||
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;
|
||||
|
||||
/// <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 Basculer(Mode mode)
|
||||
{
|
||||
_mode = mode;
|
||||
_focusAFaire = true;
|
||||
_erreur = null;
|
||||
_candidats = [];
|
||||
_avertissements = [];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (_candidats.Count == 0)
|
||||
{
|
||||
_erreur = "Aucune notice ne correspond. Vous pouvez l'ajouter à la main.";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_recherche = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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");
|
||||
}
|
||||
}
|
||||
@@ -84,11 +84,6 @@ else
|
||||
</ul>
|
||||
}
|
||||
|
||||
<nav class="actions-flottantes">
|
||||
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
|
||||
<a class="bouton bouton-discret" href="souhaits">Ma liste d'envies</a>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<AuteurDto>? _auteurs;
|
||||
private IReadOnlyList<SuggestionRapprochement>? _suggestions;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/auteurs/{AuteurId:int}/bibliographie"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
|
||||
<PageTitle>MaBibli — bibliographie</PageTitle>
|
||||
|
||||
@@ -12,23 +13,72 @@ else if (_erreur is not null)
|
||||
{
|
||||
<h1 class="titre-page">Bibliographie</h1>
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
|
||||
@* Une panne de réseau est le plus souvent passagère : sans ce bouton, il fallait
|
||||
recharger la page entière pour retenter. *@
|
||||
@if (_reessayable)
|
||||
{
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_chargement || !Reseau.EnLigne)"
|
||||
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
@onclick="ChargerAsync">
|
||||
@(_chargement ? "Nouvelle tentative…" : "Réessayer")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (_bibliographie is { } biblio)
|
||||
{
|
||||
<h1 class="titre-page">@biblio.Auteur.Nom</h1>
|
||||
|
||||
@if (biblio.Avertissement is not null)
|
||||
{
|
||||
<p class="message-avertissement" role="alert">@biblio.Avertissement</p>
|
||||
}
|
||||
@*
|
||||
⚠️ Les deux cas ci-dessous s'excluent, et c'est TOUT l'objet de ce bloc.
|
||||
|
||||
@if (biblio.Oeuvres.Count == 0)
|
||||
Avant correction, une BnF muette produisait une liste vide, que l'écran commentait
|
||||
d'un rassurant « la BnF ne connaît aucun livre de cet auteur » — affiché juste sous
|
||||
l'avertissement qui disait le contraire. On affirmait une absence qu'on n'avait pas
|
||||
pu constater, ce qui est la pire des deux erreurs possibles ici : elle ferme la
|
||||
question au lieu de la laisser ouverte.
|
||||
|
||||
La règle : on n'explique une absence de résultats QUE si la source a répondu.
|
||||
*@
|
||||
@if (biblio.SourceMuette)
|
||||
{
|
||||
<p class="message-avertissement" role="alert">
|
||||
@biblio.Avertissement
|
||||
</p>
|
||||
|
||||
<p class="message-discret">
|
||||
La bibliographie n'a donc <strong>pas pu être établie</strong> : cet écran ne dit
|
||||
rien de ce que cet auteur a écrit.
|
||||
</p>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_chargement || !Reseau.EnLigne)"
|
||||
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
@onclick="ChargerAsync">
|
||||
@(_chargement ? "Nouvelle tentative…" : "Réessayer")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else if (biblio.Oeuvres.Count == 0)
|
||||
{
|
||||
@* La source a répondu, et elle ne connaît rien : là, et là seulement, on peut
|
||||
l'expliquer. Le cas est fréquent et n'a rien d'anormal. *@
|
||||
<p class="message-discret">
|
||||
La BnF ne connaît aucun livre de cet auteur. C'est le cas attendu pour les auteurs
|
||||
étrangers : le dépôt légal français ne couvre que l'édition francophone.
|
||||
étrangers : le dépôt légal français ne couvre que l'édition francophone, et un
|
||||
auteur traduit n'y a souvent pas de notice à son nom.
|
||||
</p>
|
||||
}
|
||||
else if (biblio.Avertissement is not null)
|
||||
{
|
||||
@* Source répondue mais bancale (une page sur deux, par exemple) : la liste vaut
|
||||
quelque chose, on la montre en disant qu'elle est peut-être incomplète. *@
|
||||
<p class="message-avertissement" role="alert">@biblio.Avertissement</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Ces compteurs disent à l'utilisateur CE QU'IL REGARDE. Sur un auteur très réédité,
|
||||
@@ -150,14 +200,14 @@ else if (_bibliographie is { } biblio)
|
||||
}
|
||||
}
|
||||
|
||||
<nav class="actions-flottantes">
|
||||
<a class="bouton bouton-discret" href="auteurs">Retour aux auteurs</a>
|
||||
@if (_bibliographie is { } b)
|
||||
{
|
||||
@* « Retour aux auteurs » est parti : l'onglet Auteurs fait exactement cela, en permanence.
|
||||
Ne reste que ce qu'aucun onglet ne sait faire — le catalogue restreint à cet auteur. *@
|
||||
@if (_bibliographie is { } b)
|
||||
{
|
||||
<nav class="actions-flottantes">
|
||||
<a class="bouton bouton-discret" href="@($"?auteur={b.Auteur.Id}")">Ses livres chez vous</a>
|
||||
}
|
||||
<a class="bouton bouton-discret" href="souhaits">Ma liste d'envies</a>
|
||||
</nav>
|
||||
</nav>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
@@ -166,6 +216,11 @@ else if (_bibliographie is { } biblio)
|
||||
private BibliographieDto? _bibliographie;
|
||||
private bool _seulementManquants;
|
||||
private bool _enCours;
|
||||
private bool _chargement;
|
||||
|
||||
/// <summary>Vrai quand l'échec vient du réseau, donc qu'une nouvelle tentative a un sens.</summary>
|
||||
private bool _reessayable;
|
||||
|
||||
private string? _erreur;
|
||||
private string? _message;
|
||||
|
||||
@@ -179,6 +234,8 @@ else if (_bibliographie is { } biblio)
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
_erreur = null;
|
||||
_reessayable = false;
|
||||
_chargement = true;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -186,15 +243,32 @@ else if (_bibliographie is { } biblio)
|
||||
|
||||
if (_bibliographie is null)
|
||||
{
|
||||
// Celui-là ne se retente pas : l'auteur n'existe pas, réessayer ne le fera
|
||||
// pas apparaître.
|
||||
_erreur = "Cet auteur n'existe pas dans votre bibliothèque.";
|
||||
return;
|
||||
}
|
||||
|
||||
_possedees = _bibliographie.Oeuvres.Count(o => o.Possede);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
_erreur = $"Impossible d'obtenir la bibliographie : {ex.Message}";
|
||||
// ⚠️ Jamais le message brut de l'exception : hors-ligne, c'est un « 404 Not Found »
|
||||
// du service worker que l'utilisateur n'a aucun moyen d'interpréter. La
|
||||
// bibliographie interroge la BnF, elle ne peut PAS venir du cache — il n'y a rien
|
||||
// à réparer, seulement à dire.
|
||||
//
|
||||
// Ce chemin-ci est l'échec de l'appel à NOTRE serveur (délai du navigateur compris) ;
|
||||
// une BnF muette, elle, revient en 200 avec un état, et se traite plus haut.
|
||||
_erreur = Reseau.EnLigne
|
||||
? "La bibliographie n'a pas pu être obtenue. La BnF est parfois lente à répondre."
|
||||
: "La bibliographie vient de la BnF : indisponible hors ligne.";
|
||||
|
||||
_reessayable = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_chargement = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,21 @@
|
||||
|
||||
<PageTitle>MaBibli — catalogue</PageTitle>
|
||||
|
||||
<h1 class="titre-page">Catalogue</h1>
|
||||
@*
|
||||
Les filtres tiennent derrière un seul bouton, près du titre : deux rangées de segments
|
||||
occupaient en permanence le haut de l'écran d'un téléphone pour un réglage qu'on change
|
||||
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.
|
||||
*@
|
||||
<div class="entete-page">
|
||||
<h1 class="titre-page">Catalogue</h1>
|
||||
|
||||
<button type="button" class="bouton bouton-discret bouton-compact"
|
||||
aria-expanded="@_filtresOuverts" aria-controls="filtres-catalogue"
|
||||
@onclick="() => _filtresOuverts = !_filtresOuverts">
|
||||
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_auteur is not null)
|
||||
{
|
||||
@@ -21,29 +35,44 @@
|
||||
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
|
||||
</div>
|
||||
|
||||
<div class="filtres">
|
||||
<div class="segments" role="group" aria-label="Filtrer par format">
|
||||
<button type="button" class="segment @(_format is null ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerFormat(null)">Tous</button>
|
||||
@foreach (var format in Enum.GetValues<Format>())
|
||||
@if (_filtresOuverts)
|
||||
{
|
||||
<div class="filtres" id="filtres-catalogue">
|
||||
@* Un fonds tout physique n'a rien à filtrer par format : la ligne n'apprendrait rien
|
||||
et prendrait de la place. Cohérent avec « le format ne s'affiche que pour les
|
||||
ebooks ». Elle réapparaît d'elle-même au premier ebook ajouté. *@
|
||||
@if (PlusieursFormats)
|
||||
{
|
||||
<button type="button" class="segment @(_format == format ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerFormat(format)">@Libelles.Format(format)</button>
|
||||
<div class="segments" role="group" aria-label="Filtrer par format">
|
||||
<button type="button" class="segment @(_format is null ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerFormat(null)">Tous</button>
|
||||
@foreach (var format in Enum.GetValues<Format>())
|
||||
{
|
||||
<button type="button" class="segment @(_format == format ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerFormat(format)">@Libelles.Format(format)</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut pour moi
|
||||
ne remonte sous aucune valeur, il reste visible sous « Tous ». *@
|
||||
<div class="segments" role="group" aria-label="Filtrer par mon statut de lecture">
|
||||
<button type="button" class="segment @(_statut is null ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerStatut(null)">Tous</button>
|
||||
@foreach (var statut in Enum.GetValues<Statut>())
|
||||
{
|
||||
<button type="button" class="segment @(_statut == statut ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerStatut(statut)">@Libelles.Statut(statut)</button>
|
||||
}
|
||||
@* ⚠️ Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut
|
||||
pour moi ne remonte sous aucune valeur, il reste visible sous « Tous ». L'écran
|
||||
l'assume au lieu de laisser croire à une vue commune. *@
|
||||
<div class="segments" role="group" aria-label="Filtrer par mon statut de lecture">
|
||||
<button type="button" class="segment @(_statut is null ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerStatut(null)">Tous</button>
|
||||
@foreach (var statut in Enum.GetValues<Statut>())
|
||||
{
|
||||
<button type="button" class="segment @(_statut == statut ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerStatut(statut)">@Libelles.Statut(statut)</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="champ-aide">
|
||||
Le statut de lecture est <strong>le vôtre</strong> : filtrer dessus montre votre
|
||||
lecture, pas celle du foyer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_erreur is not null)
|
||||
{
|
||||
@@ -122,25 +151,27 @@ else if (_livres is not null)
|
||||
</ul>
|
||||
}
|
||||
|
||||
@* Hors-ligne, les deux entrées d'ajout deviennent des boutons éteints plutôt que des liens qui
|
||||
mèneraient à un formulaire invalidable. La consultation, elle, reste entière : recherche,
|
||||
filtres et tri portent sur toute la bibliothèque, depuis l'instantané local. *@
|
||||
@*
|
||||
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/isbn">Ajouter par ISBN</a>
|
||||
<a class="bouton bouton-discret" href="ajout/manuel">Saisie manuelle</a>
|
||||
<a class="bouton bouton-principal" href="ajout/isbn">Ajouter un livre</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-principal" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter par ISBN</button>
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Saisie manuelle</button>
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter un livre</button>
|
||||
}
|
||||
<a class="bouton bouton-discret" href="prets">Prêts</a>
|
||||
<a class="bouton bouton-discret" href="auteurs">Auteurs</a>
|
||||
<a class="bouton bouton-discret" href="souhaits">Envies</a>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
@@ -155,8 +186,33 @@ else if (_livres is not null)
|
||||
private Format? _format;
|
||||
private Statut? _statut;
|
||||
private bool _chargement;
|
||||
private bool _filtresOuverts;
|
||||
private string? _erreur;
|
||||
|
||||
/// <summary>
|
||||
/// Formats réellement présents dans le fonds, ou <c>null</c> tant qu'on ne le sait pas.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Ne se calcule que sur un chargement <b>sans aucun critère</b>. Le déduire d'une liste
|
||||
/// filtrée ferait disparaître la ligne « Tous / Physique / Numérique » dès qu'une recherche
|
||||
/// ne remonte qu'un seul format — et l'utilisateur ne pourrait plus l'élargir. Dans le doute
|
||||
/// (arrivée directe sur un auteur, par exemple) la ligne reste affichée : montrer un filtre
|
||||
/// inutile est bénin, en escamoter un qui sert ne l'est pas.
|
||||
/// </remarks>
|
||||
private IReadOnlySet<Format>? _formatsDuFonds;
|
||||
|
||||
private bool PlusieursFormats => _formatsDuFonds is null || _formatsDuFonds.Count > 1;
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// La recherche et la restriction à un auteur n'y comptent pas : toutes deux se voient
|
||||
/// déjà à l'écran, l'une dans son champ, l'autre dans sa phrase. Ce compteur ne dit que ce
|
||||
/// qui serait invisible une fois le panneau refermé.
|
||||
/// </remarks>
|
||||
private int FiltresActifs => (_format is null ? 0 : 1) + (_statut is null ? 0 : 1);
|
||||
|
||||
// Une frappe au clavier annule la recherche précédente encore en vol.
|
||||
private CancellationTokenSource _cts = new();
|
||||
|
||||
@@ -205,6 +261,13 @@ else if (_livres is not null)
|
||||
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)
|
||||
&& criteres.Format is null
|
||||
&& criteres.Statut is null
|
||||
&& criteres.AuteurId is null;
|
||||
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
@@ -226,14 +289,21 @@ else if (_livres is not null)
|
||||
};
|
||||
|
||||
_livres = await Api.ListerAsync(criteres, jeton);
|
||||
|
||||
if (EstSansCritere(criteres))
|
||||
{
|
||||
_formatsDuFonds = _livres.Select(l => l.Format).ToHashSet();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return; // Une recherche plus récente a pris le relais.
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
_erreur = $"Impossible de charger le catalogue : {ex.Message}";
|
||||
_erreur = Reseau.EnLigne
|
||||
? "Le catalogue n'a pas pu être chargé. Réessayez dans un instant."
|
||||
: "Aucune donnée n'a encore pu être enregistrée sur cet appareil.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -115,9 +115,10 @@ else
|
||||
<div><dt>Éditeur</dt><dd>@_livre.Editeur</dd></div>
|
||||
}
|
||||
|
||||
@* Tirets à l'affichage seulement : la valeur stockée reste nue. *@
|
||||
@if (!string.IsNullOrWhiteSpace(_livre.Isbn))
|
||||
{
|
||||
<div><dt>ISBN</dt><dd>@_livre.Isbn</dd></div>
|
||||
<div><dt>ISBN</dt><dd class="code-isbn">@FormatageIsbn.Afficher(_livre.Isbn)</dd></div>
|
||||
}
|
||||
|
||||
@* Comme dans la liste : le format n'apparaît que pour un ebook, le physique
|
||||
@@ -166,7 +167,6 @@ else
|
||||
<button type="button" class="bouton bouton-principal" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Éditer</button>
|
||||
}
|
||||
<a class="bouton bouton-discret" href="">Catalogue</a>
|
||||
</nav>
|
||||
}
|
||||
|
||||
|
||||
@@ -73,10 +73,6 @@ else
|
||||
</ul>
|
||||
}
|
||||
|
||||
<nav class="actions-flottantes">
|
||||
<a class="bouton bouton-discret" href="">Catalogue</a>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<PretDto>? _prets;
|
||||
private string? _erreur;
|
||||
|
||||
+195
-116
@@ -1,5 +1,7 @@
|
||||
@page "/souhaits"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — liste d'envies</PageTitle>
|
||||
|
||||
@@ -24,56 +26,28 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="bloc-souhait-ajout">
|
||||
<h2 class="titre-section">Ajouter une envie</h2>
|
||||
|
||||
@* Deux façons d'alimenter la liste : l'ISBN quand on a le livre en main (en librairie,
|
||||
chez quelqu'un), la saisie libre quand on n'a qu'un titre. La bibliographie d'un
|
||||
auteur, elle, alimente la liste depuis l'écran des auteurs. *@
|
||||
<div class="champ">
|
||||
<label class="champ-libelle" for="isbn-souhait">Pré-remplir depuis un ISBN</label>
|
||||
<div class="ligne-isbn">
|
||||
<input id="isbn-souhait" class="champ-saisie" type="text" inputmode="numeric"
|
||||
placeholder="978…" @bind="_isbn" @bind:event="oninput" />
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
disabled="@(_recherche || string.IsNullOrWhiteSpace(_isbn))"
|
||||
@onclick="ChercherIsbnAsync">
|
||||
@(_recherche ? "Recherche…" : "Chercher")
|
||||
</button>
|
||||
</div>
|
||||
<p class="champ-aide">Facultatif : les champs ci-dessous restent modifiables.</p>
|
||||
</div>
|
||||
|
||||
<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" @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 || string.IsNullOrWhiteSpace(_saisie.Titre))"
|
||||
@onclick="AjouterAsync">
|
||||
Ajouter à ma liste
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_souhaits.Count == 0)
|
||||
{
|
||||
@* Hors-ligne, « vide » et « jamais enregistrée » sont indiscernables : une liste jamais
|
||||
synchronisée rendrait exactement le même résultat qu'une liste réellement vide. On dit
|
||||
donc les deux plutôt que d'affirmer la première — même règle que pour une fiche
|
||||
absente de l'instantané. *@
|
||||
<p class="message-discret">
|
||||
Votre liste est vide. Ajoutez un livre ci-dessus, ou ouvrez la bibliographie d'un
|
||||
auteur depuis <a href="auteurs">la liste des auteurs</a>.
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<text>
|
||||
Votre liste est vide. Ajoutez une envie ci-dessous, ou ouvrez la
|
||||
bibliographie d'un auteur depuis <a href="auteurs">la liste des auteurs</a>.
|
||||
</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>
|
||||
Aucune envie dans les données enregistrées sur cet appareil. Votre liste est
|
||||
peut-être vide, ou simplement pas encore synchronisée : le retour du réseau
|
||||
tranchera.
|
||||
</text>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
else
|
||||
@@ -82,19 +56,85 @@ else
|
||||
|
||||
@* Un seul bloc d'export, en haut de liste : c'est l'action qu'on vient chercher quand on
|
||||
part en librairie, elle ne doit pas être au bout d'un défilement. *@
|
||||
@*
|
||||
⚠️ L'export est produit PAR LE SERVEUR : hors-ligne il n'y a pas de fichier à
|
||||
télécharger, et un lien laissé actif finirait sur une page d'erreur du navigateur.
|
||||
Un lien ne se désactive pas — d'où la bascule vers un bouton inerte qui porte sa
|
||||
raison, comme au catalogue.
|
||||
La consolation est dite juste en dessous : l'usage que servait l'export « .txt »,
|
||||
emporter sa liste en librairie, est désormais couvert par la liste elle-même.
|
||||
*@
|
||||
<div class="actions-export">
|
||||
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Texte)" download>
|
||||
Exporter en texte
|
||||
</a>
|
||||
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Csv)" download>
|
||||
Exporter en CSV
|
||||
</a>
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Texte)" download>
|
||||
Exporter en texte
|
||||
</a>
|
||||
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Csv)" download>
|
||||
Exporter en CSV
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
title="@MotifExport">Exporter en texte</button>
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
title="@MotifExport">Exporter en CSV</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Les exports sont fabriqués par le serveur : ils reviendront avec le réseau.
|
||||
La liste ci-dessous, elle, est celle enregistrée sur cet appareil — c'est
|
||||
exactement ce qu'on emportait dans le fichier texte.
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (_souhaits.Count > 1)
|
||||
{
|
||||
<p class="champ-aide">
|
||||
Du plus désiré au moins désiré. Utilisez les flèches pour déplacer une envie —
|
||||
sur ordinateur, vous pouvez aussi la faire glisser.
|
||||
</p>
|
||||
}
|
||||
|
||||
@*
|
||||
⚠️ Les flèches ne sont PAS un ornement à côté du glisser-déposer : le drag & drop
|
||||
HTML5 ne fonctionne pas au doigt sur mobile. Sans elles, réordonner n'existerait
|
||||
que sur PC — c'est-à-dire pas là où l'on consulte sa liste en librairie.
|
||||
*@
|
||||
<ul class="liste-souhaits">
|
||||
@foreach (var souhait in _souhaits)
|
||||
@foreach (var (souhait, index) in _souhaits.Select((s, i) => (s, i)))
|
||||
{
|
||||
<li class="carte-souhait" @key="souhait.Id">
|
||||
<li class="carte-souhait @(_glisse == souhait.Id ? "carte-glissee" : null)"
|
||||
@key="souhait.Id"
|
||||
draggable="@((Reseau.EnLigne && _souhaits.Count > 1).ToString().ToLowerInvariant())"
|
||||
@ondragstart="() => _glisse = souhait.Id"
|
||||
@ondragend="() => _glisse = null"
|
||||
@ondragover:preventDefault="true"
|
||||
@ondrop="() => DeposerAsync(index)">
|
||||
|
||||
@if (_souhaits.Count > 1)
|
||||
{
|
||||
<div class="rang-actions">
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || index == 0)"
|
||||
title="@MotifBlocage"
|
||||
aria-label="@($"Monter « {souhait.Titre} »")"
|
||||
@onclick="() => DeplacerAsync(index, index - 1)">▲</button>
|
||||
|
||||
<span class="rang-numero" aria-hidden="true">@(index + 1)</span>
|
||||
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || index == _souhaits.Count - 1)"
|
||||
title="@MotifBlocage"
|
||||
aria-label="@($"Descendre « {souhait.Titre} »")"
|
||||
@onclick="() => DeplacerAsync(index, index + 1)">▼</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<Couverture Url="@souhait.CoverUrl" Titre="@souhait.Titre"
|
||||
Classe="couverture-petite" Agrandissable="true" />
|
||||
|
||||
@@ -117,7 +157,7 @@ else
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(souhait.Isbn))
|
||||
{
|
||||
<span class="etiquette">ISBN @souhait.Isbn</span>
|
||||
<span class="etiquette code-isbn">ISBN @FormatageIsbn.Afficher(souhait.Isbn)</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@@ -128,7 +168,8 @@ else
|
||||
</div>
|
||||
|
||||
<button type="button" class="bouton bouton-compact bouton-danger"
|
||||
disabled="@_enCours" @onclick="() => SupprimerAsync(souhait)"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => SupprimerAsync(souhait)"
|
||||
aria-label="@($"Retirer « {souhait.Titre} » de la liste d'envies")">
|
||||
Retirer
|
||||
</button>
|
||||
@@ -138,93 +179,131 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
@* L'ajout est passé dans son propre écran : la page ne montre plus que la liste, comme la
|
||||
fiche livre ne montre plus son formulaire. *@
|
||||
<nav class="actions-flottantes">
|
||||
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
|
||||
<a class="bouton bouton-discret" href="auteurs">Auteurs</a>
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-principal" href="souhaits/ajout">Ajouter une envie</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-principal" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter une envie</button>
|
||||
}
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<SouhaitDto>? _souhaits;
|
||||
private EnregistrementSouhait _saisie = new();
|
||||
private string _isbn = string.Empty;
|
||||
private bool _ajout;
|
||||
private bool _recherche;
|
||||
private bool _enCours;
|
||||
private int? _glisse;
|
||||
private string? _erreur;
|
||||
|
||||
protected override Task OnInitializedAsync() => ChargerAsync();
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recharge la liste au retour du réseau, et redessine pour rendre les actions à l'écran.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On s'abonne à <c>Change</c> et jamais à <c>SynchroChange</c> : recharger écrit un
|
||||
/// instantané, qui émettrait une nouvelle synchro, qui rechargerait… sans fin.
|
||||
/// </remarks>
|
||||
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
||||
{
|
||||
await ChargerAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
||||
|
||||
/// <summary>Raison du blocage des écritures, ou <c>null</c> quand tout est possible.</summary>
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
private const string MotifExport =
|
||||
"L'export est produit par le serveur : indisponible hors ligne.";
|
||||
|
||||
/// <remarks>
|
||||
/// ⚠️ Aucun message HTTP brut ne doit remonter jusqu'ici. C'était le défaut d'origine :
|
||||
/// hors-ligne, l'écran affichait « net_http_message_not_success_statuscode_reason, 404,
|
||||
/// Not Found ». La liste passe désormais par le même repli sur instantané que le reste de
|
||||
/// l'application, et une panne se dit en français.
|
||||
/// </remarks>
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_erreur = null;
|
||||
_souhaits = await Api.ListerSouhaitsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
_erreur = $"Impossible de charger la liste d'envies : {ex.Message}";
|
||||
_souhaits ??= [];
|
||||
_erreur = Reseau.EnLigne
|
||||
? "La liste d'envies n'a pas pu être chargée. Réessayez dans un instant."
|
||||
: "Liste d'envies indisponible hors ligne : aucune donnée n'a encore pu être "
|
||||
+ "enregistrée sur cet appareil.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pré-remplit le formulaire depuis la cascade ISBN existante (BnF → OpenLibrary).
|
||||
/// Déplace une envie d'une position à une autre, puis enregistre l'ordre complet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On retient le premier candidat sans demander de choisir : contrairement à l'ajout au
|
||||
/// catalogue, une envie ne fige pas d'édition — l'utilisateur achètera celle qu'il trouvera.
|
||||
/// Tous les champs restent modifiables.
|
||||
/// La nouvelle suite est calculée <b>localement</b> et envoyée en entier : le serveur ne
|
||||
/// reçoit pas « monte de un » mais « voici l'ordre ». C'est la même opération pour les
|
||||
/// flèches et pour le glisser-déposer, donc un seul chemin à vérifier.
|
||||
/// <para>
|
||||
/// La liste affichée est réordonnée <b>avant</b> l'aller-retour réseau : un déplacement qui
|
||||
/// attendrait la réponse donnerait l'impression que la flèche n'a pas répondu. En cas
|
||||
/// d'échec, le rechargement remet l'ordre du serveur.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task ChercherIsbnAsync()
|
||||
private async Task DeplacerAsync(int depuis, int vers)
|
||||
{
|
||||
_recherche = true;
|
||||
_erreur = null;
|
||||
|
||||
try
|
||||
if (_souhaits is null || depuis == vers || vers < 0 || vers >= _souhaits.Count)
|
||||
{
|
||||
var resultat = await Api.ChercherIsbnAsync(_isbn.Trim());
|
||||
var candidat = resultat?.Candidats.FirstOrDefault();
|
||||
|
||||
if (candidat is null)
|
||||
{
|
||||
_erreur = $"Aucune notice trouvée pour « {_isbn.Trim()} ». Complétez à la main.";
|
||||
return;
|
||||
}
|
||||
|
||||
_saisie.Titre = candidat.Titre;
|
||||
_saisie.Auteur = string.Join(", ", candidat.Auteurs);
|
||||
_saisie.Editeur = candidat.Editeur;
|
||||
_saisie.Annee = candidat.Annee;
|
||||
_saisie.Isbn = _isbn.Trim();
|
||||
_saisie.CoverUrl = candidat.CoverUrl;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_erreur = $"La recherche a échoué : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_recherche = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AjouterAsync()
|
||||
{
|
||||
_ajout = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.AjouterSouhaitAsync(_saisie);
|
||||
|
||||
_ajout = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
_saisie = new EnregistrementSouhait();
|
||||
_isbn = string.Empty;
|
||||
await ChargerAsync();
|
||||
var ordonnee = _souhaits.ToList();
|
||||
var deplacee = ordonnee[depuis];
|
||||
ordonnee.RemoveAt(depuis);
|
||||
ordonnee.Insert(vers, deplacee);
|
||||
|
||||
_souhaits = ordonnee;
|
||||
_enCours = true;
|
||||
|
||||
_erreur = await Api.ReordonnerSouhaitsAsync(ordonnee.Select(s => s.Id).ToList());
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (_erreur is not null)
|
||||
{
|
||||
// L'ordre affiché n'est pas celui du serveur : on reprend le sien plutôt que de
|
||||
// laisser croire que le déplacement a été enregistré.
|
||||
await ChargerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fin d'un glisser-déposer : la carte saisie prend la place de celle survolée.</summary>
|
||||
private async Task DeposerAsync(int cible)
|
||||
{
|
||||
if (_souhaits is null || _glisse is not { } id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var depuis = _souhaits.ToList().FindIndex(s => s.Id == id);
|
||||
_glisse = null;
|
||||
|
||||
if (depuis >= 0)
|
||||
{
|
||||
await DeplacerAsync(depuis, cible);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SupprimerAsync(SouhaitDto souhait)
|
||||
|
||||
Reference in New Issue
Block a user