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>
355 lines
12 KiB
Plaintext
355 lines
12 KiB
Plaintext
@page "/livres/{Id:int}"
|
|
@page "/livres/{Id:int}/edition"
|
|
@inject ServiceLivresApi Api
|
|
@inject NavigationManager Navigation
|
|
@inject EtatReseau Reseau
|
|
@implements IDisposable
|
|
|
|
@*
|
|
Une seule page, deux modes (IDEES.md) :
|
|
|
|
— consultation par défaut. Ouvrir une fiche depuis le catalogue ne doit rien pouvoir casser,
|
|
surtout au doigt sur un téléphone.
|
|
— édition sur demande explicite, via son adresse propre « /livres/{id}/edition ». Le mode
|
|
n'est pas un booléen caché : il est dans l'URL, donc le bouton « retour » du navigateur
|
|
sort de l'édition, ce qui est exactement ce qu'on attend d'un geste mobile.
|
|
|
|
Restent manipulables SANS entrer en édition, parce que ce ne sont pas des modifications de
|
|
la fiche mais les gestes du quotidien : le statut de lecture (le plus fréquent de tous) et
|
|
les prêts (prêter, marquer rendu).
|
|
*@
|
|
|
|
<PageTitle>MaBibli — @(_livre?.Titre ?? "fiche du livre")</PageTitle>
|
|
|
|
<h1 class="titre-page">@(ModeEdition ? "Modifier la fiche" : "Fiche du livre")</h1>
|
|
|
|
@if (_introuvable)
|
|
{
|
|
@* Hors-ligne, « introuvable » ne veut pas dire « supprimé » : le livre peut simplement avoir
|
|
été ajouté après la dernière synchronisation. Le dire évite un contresens inquiétant. *@
|
|
<p class="message-erreur" role="alert">
|
|
@(Reseau.EnLigne
|
|
? "Ce livre n'existe pas (ou plus)."
|
|
: "Ce livre n'est pas dans les données enregistrées sur cet appareil. Il a peut-être été ajouté depuis la dernière synchronisation.")
|
|
</p>
|
|
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
|
|
}
|
|
else if (_livre is null || _saisie is null)
|
|
{
|
|
<p class="message-discret">Chargement…</p>
|
|
}
|
|
else if (ModeEdition)
|
|
{
|
|
<FormulaireLivre Saisie="_saisie"
|
|
LibelleValidation="Enregistrer les modifications"
|
|
Erreur="@_erreur"
|
|
EnCours="_enregistrement"
|
|
MessageBlocage="@MotifBlocage"
|
|
OnValider="EnregistrerAsync"
|
|
OnAnnuler="RetourFiche" />
|
|
|
|
@* La suppression n'est offerte qu'ici : c'est le seul écran où l'on vient pour modifier. *@
|
|
<section class="zone-danger">
|
|
@if (!_confirmationSuppression)
|
|
{
|
|
<button type="button" class="bouton bouton-danger"
|
|
disabled="@(!Reseau.EnLigne)" title="@MotifBlocage"
|
|
@onclick="() => _confirmationSuppression = true">
|
|
Supprimer ce livre
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<p class="message-avertissement">
|
|
Supprimer « @_saisie.Titre » définitivement ? L'historique de ses prêts partira avec.
|
|
@* Choix assumé : les prêts n'ont d'intérêt qu'attachés à un livre. Un historique
|
|
orphelin (« quelqu'un a emprunté quelque chose ») ne se lit plus. *@
|
|
@if (_livre.PreteA is { } emprunteur)
|
|
{
|
|
<span class="avertissement-fort">
|
|
Attention : ce livre est actuellement prêté à @emprunteur.
|
|
</span>
|
|
}
|
|
</p>
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-danger" disabled="@_suppression" @onclick="SupprimerAsync">
|
|
@(_suppression ? "Suppression…" : "Oui, supprimer")
|
|
</button>
|
|
<button type="button" class="bouton bouton-discret" disabled="@_suppression"
|
|
@onclick="() => _confirmationSuppression = false">
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
else
|
|
{
|
|
@if (_erreur is not null)
|
|
{
|
|
<p class="message-erreur" role="alert">@_erreur</p>
|
|
}
|
|
|
|
<div class="fiche-entete">
|
|
<Couverture Url="@_livre.CoverUrl" Titre="@_livre.Titre"
|
|
Classe="couverture-grande" Agrandissable="true" />
|
|
|
|
<div class="fiche-infos">
|
|
<h2 class="fiche-titre">@_livre.Titre</h2>
|
|
|
|
@if (_livre.Auteurs.Count > 0)
|
|
{
|
|
@* Cliquer un auteur ramène au catalogue restreint à ses livres. *@
|
|
<p class="fiche-auteurs">
|
|
@foreach (var (auteur, index) in _livre.Auteurs.Select((a, i) => (a, i)))
|
|
{
|
|
@(index > 0 ? ", " : "")
|
|
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
|
|
}
|
|
</p>
|
|
}
|
|
|
|
<dl class="fiche-champs">
|
|
@if (!string.IsNullOrWhiteSpace(_livre.Editeur))
|
|
{
|
|
<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 class="code-isbn">@FormatageIsbn.Afficher(_livre.Isbn)</dd></div>
|
|
}
|
|
|
|
@* Comme dans la liste : le format n'apparaît que pour un ebook, le physique
|
|
étant le cas par défaut. *@
|
|
@if (_livre.Format == Format.Numerique)
|
|
{
|
|
<div><dt>Format</dt><dd>@Libelles.Format(_livre.Format)</dd></div>
|
|
}
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
@* Hors « Éditer » volontairement : changer son statut est l'action la plus fréquente
|
|
de l'application, et elle ne touche qu'à MA lecture, jamais à la fiche commune. *@
|
|
<section class="bloc-statut">
|
|
<h2 class="titre-section">Statut de lecture <span class="champ-aide">(le vôtre)</span></h2>
|
|
<ChoixStatut Valeur="_livre.Statut"
|
|
ValeurChanged="ChangerStatutAsync"
|
|
Desactive="@(_statutEnCours || !Reseau.EnLigne)" />
|
|
@if (!Reseau.EnLigne)
|
|
{
|
|
<p class="message-discret">@EtatReseau.MotifHorsLigne</p>
|
|
}
|
|
</section>
|
|
|
|
<PretsLivre LivreId="Id" Format="_livre.Format"
|
|
PreteA="@_livre.PreteA" PreteDepuis="_livre.PreteDepuis"
|
|
OnChangement="RechargerAsync" />
|
|
|
|
<p class="message-discret">
|
|
Ajouté le @_livre.DateAjout.ToLocalTime().ToString("dd/MM/yyyy")
|
|
@if (!string.IsNullOrWhiteSpace(_livre.AjoutePar))
|
|
{
|
|
@($" par {_livre.AjoutePar}")
|
|
}
|
|
@* Information de traçabilité, jamais un filtre : la bibliothèque est commune. *@
|
|
</p>
|
|
|
|
<nav class="actions-flottantes">
|
|
@if (Reseau.EnLigne)
|
|
{
|
|
<a class="bouton bouton-principal" href="@($"livres/{Id}/edition")">Éditer</a>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="bouton bouton-principal" disabled
|
|
title="@EtatReseau.MotifHorsLigne">Éditer</button>
|
|
}
|
|
</nav>
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public int Id { get; set; }
|
|
|
|
private LivreDto? _livre;
|
|
private EnregistrementLivre? _saisie;
|
|
private bool _introuvable;
|
|
private bool _enregistrement;
|
|
private bool _suppression;
|
|
private bool _confirmationSuppression;
|
|
private bool _statutEnCours;
|
|
private string? _erreur;
|
|
private int _livreCharge;
|
|
|
|
/// <summary>
|
|
/// Le mode se lit dans l'adresse, pas dans un champ : « /livres/12/edition » est partageable,
|
|
/// et le retour arrière du navigateur ramène naturellement à la consultation.
|
|
/// </summary>
|
|
private bool ModeEdition =>
|
|
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath
|
|
.TrimEnd('/').EndsWith("/edition", StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// Les deux adresses mènent au même composant avec le même <c>Id</c> : passer de l'une à
|
|
/// l'autre ne change aucun paramètre, et le routeur ne provoque alors aucun nouveau rendu
|
|
/// (constaté : l'URL devenait « /edition » sans que l'écran bascule). On s'abonne donc au
|
|
/// changement d'adresse pour redessiner nous-mêmes.
|
|
/// </summary>
|
|
protected override void OnInitialized()
|
|
{
|
|
Navigation.LocationChanged += SurNavigation;
|
|
Reseau.Change += SurChangementReseau;
|
|
}
|
|
|
|
private void SurNavigation(object? emetteur, LocationChangedEventArgs e) =>
|
|
InvokeAsync(StateHasChanged);
|
|
|
|
/// <summary>Le réseau a basculé : la fiche se relit (serveur ou cache) et les actions suivent.</summary>
|
|
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
|
{
|
|
_livre = await Api.ObtenirAsync(Id) ?? _livre;
|
|
|
|
// On ne réécrit PAS le formulaire en cours d'édition : une coupure réseau au milieu
|
|
// d'une saisie effacerait ce que l'utilisateur vient de taper.
|
|
if (_livre is not null && !ModeEdition)
|
|
{
|
|
_saisie = Recopier(_livre);
|
|
}
|
|
|
|
StateHasChanged();
|
|
});
|
|
|
|
/// <summary>Raison affichable quand la fiche ne peut pas être modifiée, sinon <c>null</c>.</summary>
|
|
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
|
|
|
public void Dispose()
|
|
{
|
|
Navigation.LocationChanged -= SurNavigation;
|
|
Reseau.Change -= SurChangementReseau;
|
|
}
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
if (_livreCharge == Id && _livre is not null)
|
|
{
|
|
// Simple bascule consultation <-> édition : inutile de relire le livre.
|
|
return;
|
|
}
|
|
|
|
_livreCharge = Id;
|
|
_introuvable = false;
|
|
_livre = null;
|
|
_saisie = null;
|
|
|
|
try
|
|
{
|
|
_livre = await Api.ObtenirAsync(Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_erreur = $"Chargement impossible : {ex.Message}";
|
|
return;
|
|
}
|
|
|
|
if (_livre is null)
|
|
{
|
|
_introuvable = true;
|
|
return;
|
|
}
|
|
|
|
_saisie = Recopier(_livre);
|
|
}
|
|
|
|
/// <summary>Formulaire d'édition alimenté depuis la fiche chargée.</summary>
|
|
/// <remarks>
|
|
/// Les auteurs sont repris tels que rattachés au livre. Le statut, lui, est celui de la
|
|
/// personne qui édite : enregistrer ne touche jamais à la lecture de quelqu'un d'autre.
|
|
/// </remarks>
|
|
private static EnregistrementLivre Recopier(LivreDto livre) => new()
|
|
{
|
|
Isbn = livre.Isbn,
|
|
Titre = livre.Titre,
|
|
Auteurs = livre.Auteurs.Select(a => a.Nom).ToList(),
|
|
Editeur = livre.Editeur,
|
|
Format = livre.Format,
|
|
Statut = livre.Statut,
|
|
CoverUrl = livre.CoverUrl,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Recharge la fiche après un prêt ou un retour, <b>sans toucher au formulaire</b> : l'état
|
|
/// du prêt a changé, pas les champs que l'utilisateur est peut-être en train d'éditer.
|
|
/// </summary>
|
|
private async Task RechargerAsync() => _livre = await Api.ObtenirAsync(Id) ?? _livre;
|
|
|
|
private async Task ChangerStatutAsync(Statut? statut)
|
|
{
|
|
_statutEnCours = true;
|
|
_erreur = null;
|
|
|
|
var resultat = await Api.ChangerStatutAsync(Id, statut);
|
|
|
|
_statutEnCours = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreur = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
_livre = resultat.Valeur;
|
|
|
|
// Le formulaire d'édition doit repartir du statut à jour, sinon un passage en édition
|
|
// réécrirait l'ancienne valeur au premier enregistrement.
|
|
if (_livre is not null)
|
|
{
|
|
_saisie = Recopier(_livre);
|
|
}
|
|
}
|
|
|
|
private async Task EnregistrerAsync()
|
|
{
|
|
_enregistrement = true;
|
|
_erreur = null;
|
|
|
|
var resultat = await Api.ModifierAsync(Id, _saisie!);
|
|
|
|
_enregistrement = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreur = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
// Retour à la consultation, pas au catalogue : on voit le résultat de sa modification.
|
|
_livre = resultat.Valeur;
|
|
_saisie = Recopier(_livre!);
|
|
RetourFiche();
|
|
}
|
|
|
|
private async Task SupprimerAsync()
|
|
{
|
|
_suppression = true;
|
|
var erreur = await Api.SupprimerAsync(Id);
|
|
_suppression = false;
|
|
|
|
if (erreur is not null)
|
|
{
|
|
_erreur = erreur;
|
|
_confirmationSuppression = false;
|
|
return;
|
|
}
|
|
|
|
Navigation.NavigateTo("/");
|
|
}
|
|
|
|
private void RetourFiche()
|
|
{
|
|
_confirmationSuppression = false;
|
|
Navigation.NavigateTo($"livres/{Id}");
|
|
}
|
|
}
|