Files
mabibli/MaBibli.Client/Pages/FicheLivre.razor
T
mathieuandClaude Opus 5 94f0ef15c0 Compte les pages d'un livre, et fais des envies une liste à deux moitiés
Lot U — le nombre de pages
`Livre.NombrePages` est nullable, sans valeur par défaut : « 0 page » se lirait
comme une donnée là où l'on veut dire « on ne sait pas ». Même raison que pour
`TypeDocument.NonPrecise` — un défaut qui ne prétend rien n'a rien à reprendre,
d'où une migration réduite à un `AddColumn`. Le service refuse un zéro plutôt
que de l'écrire ; effacer le champ reste la façon de revenir à « inconnu ».

Le préremplissage vient de `dc:format`, que rien ne lisait jusqu'ici. ⚠️ Ce
champ n'est pas un nombre mais une phrase décrivant le support, et les notices
déjà enregistrées sous Fixtures/ le montrent : « 1 vol. (113 p.) : ill., couv.
ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm ». Les règles sont donc
étroites — un nombre suivi de « p. » ou de « page(s) », rien d'autre — et tout
le reste rend `null`. L'erreur n'est pas symétrique : un champ vide se remplit à
la main en trois secondes, un chiffre faux s'enregistre sans que personne ne le
voie. « 1 vol. » ne vaut pas 1, « 30 cm » ne vaut pas 30, et « (p. 45-90) »,
qui est une pagination de contribution, ne vaut rien. La valeur reste proposée
dans un champ modifiable, et rien n'est déduit pour un ebook.

Lot X — éditer une envie, et souhaiter une revue
`PUT /api/souhaits/{id}` recalcule la clé d'œuvre et l'auteur normalisé : sans
ce recalcul, le rapprochement « déjà au catalogue » continuerait de se faire sur
l'ancienne forme, et le signalement mentirait sans le dire. Le filtre sur
l'appelant fait partie de la clé de recherche, pas d'une vérification ultérieure
— l'envie d'un autre est introuvable (404), jamais refusée (403).

⚠️ Une édition peut heurter l'unicité (utilisateur, œuvre, auteur), ce qu'un
ajout ne peut pas : renommer une envie en une autre déjà présente répond par un
message lisible, jamais par « UNIQUE constraint failed ». 400 et non 409,
contrairement au doublon du catalogue : là-bas posséder deux exemplaires est
légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a rien à
confirmer. Le rang n'est pas touché — l'ordre a son propre point d'entrée.

⚠️ La couverture n'est écrite que si la charge utile en porte une. Aucun écran
n'offre de champ « URL de couverture » pour une envie (décision actée), donc un
remplacement inconditionnel l'aurait effacée à la première faute de frappe
corrigée.

`RevueSouhaitee` est une table sœur, et non des colonnes de plus sur
`LivreSouhaite` : un numéro n'a pas d'auteur et se distingue par son numéro,
deux choses que la clé d'unicité des envies de livres ne sait pas exprimer sans
devenir fausse pour tout le monde. `NumeroNormalise` est NOT NULL avec un défaut
vide — SQLite tient deux NULL pour distincts, et « Médor, sans numéro »
s'ajouterait autant de fois qu'on cliquerait. L'ISSN est canonisé avec son tiret,
seul code du projet rangé ainsi.

Le coût de la table sœur est payé partout où il devait l'être : affichage,
`.txt`, `.csv` et instantané hors-ligne `souhaits-revues`. ⚠️ Les revues forment
une SECTION à part plutôt que des lignes entrelacées : chaque table numérote son
rang indépendamment, et mélanger deux suites sans rapport produirait un ordre que
personne n'a choisi. Le `.txt`, groupé par auteur, ne pouvait de toute façon pas
les accueillir — elles n'en ont pas, et « Auteur non précisé » désigne des livres
dont l'auteur est inconnu. Le CSV gagne une colonne « Type » : sans elle, un tri
par titre rendrait revues et livres indiscernables, et la colonne des codes
mêlerait ISBN et ISSN en silence.

`ServiceRenormalisation` connaît la nouvelle table, avec la règle de collision
déjà en place. ⚠️ L'ISSN y est canonisé à part : `Renormaliser` n'applique rien
quand la clé ne bouge pas, un ISSN mal formé sur une ligne au titre inchangé y
échapperait.

`RevueSouhaitee` ne porte PAS de `CoverUrl` : rien à ajouter au garde de
`GET /api/couvertures`.

Vérifié en exécution : ISSN « 24666718 » rangé « 2466-6718 », édition de l'envie
d'un autre en 404, et les deux exports portant bien les deux moitiés.
602 tests au vert (552 au départ), aucun avertissement de compilation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:02:09 +02:00

439 lines
16 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>@if (auteur.Role != RoleAuteur.NonPrecise)
{
<span class="role-auteur"> (@Libelles.RoleAuteur(auteur.Role).ToLowerInvariant())</span>
}
}
</p>
}
@*
Les séries auxquelles ce livre appartient. Il peut y en avoir plusieurs — une
trilogie, une intégrale, un cycle — d'où une liste et non un champ.
⚠️ Lu depuis l'instantané des séries, donc disponible hors-ligne comme le reste.
*@
@foreach (var appartenance in _appartenances)
{
<p class="fiche-serie">
Tome @appartenance.Rang de
<a href="@($"series/{appartenance.Serie.Id}")">@appartenance.Serie.Titre</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>
}
@* Idem : « non précisé » est l'absence de réponse, pas une réponse. *@
@if (_livre.TypeDocument != TypeDocument.NonPrecise)
{
<div><dt>Type</dt><dd>@Libelles.TypeDocument(_livre.TypeDocument)</dd></div>
}
@if (_livre.Themes.Count > 0)
{
<div>
<dt>Thèmes</dt>
<dd class="etiquettes">
@foreach (var theme in _livre.Themes)
{
<span class="etiquette">@theme</span>
}
</dd>
</div>
}
@*
Même règle que le format et le type de document : la ligne ne s'affiche que
lorsqu'elle apprend quelque chose. « Nombre de pages : inconnu » sur presque
chaque fiche n'apprendrait rien et allongerait toutes les autres.
*@
@if (_livre.NombrePages is { } pages)
{
<div><dt>Pages</dt><dd>@pages</dd></div>
}
@if (!string.IsNullOrWhiteSpace(_livre.UrlNotice))
{
<div><dt>Source</dt><dd><a href="@_livre.UrlNotice" target="_blank" rel="noopener noreferrer">Voir la notice source</a></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;
/// <summary>Places de ce livre dans des séries : la série, et son rang de lecture.</summary>
private IReadOnlyList<AppartenanceSerie> _appartenances = [];
private sealed record AppartenanceSerie(SerieDto Serie, int Rang);
/// <summary>
/// Cherche ce livre dans les séries.
/// </summary>
/// <remarks>
/// Une seule lecture rend tout l'arbre, et elle est déjà en cache : chercher côté client
/// évite un point d'entrée « séries d'un livre » qui n'aurait servi qu'ici, et qui aurait
/// eu son propre instantané à tenir.
/// <para>
/// Le rang affiché est la <b>position dans l'ordre de lecture</b>, à partir de 1 — pas un
/// numéro de tome imprimé sur la couverture, que le modèle ne connaît pas.
/// </para>
/// </remarks>
private async Task ChargerAppartenancesAsync()
{
var series = await Api.ListerSeriesAsync();
_appartenances = series
.SelectMany(serie => serie.Elements
.Select((element, index) => (serie, element, index))
.Where(x => x.element.LivreId == Id)
.Select(x => new AppartenanceSerie(x.serie, x.index + 1)))
.ToList();
}
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);
await ChargerAppartenancesAsync();
}
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 => new AuteurSaisi(a.Nom, a.Role)).ToList(),
Editeur = livre.Editeur,
Format = livre.Format,
TypeDocument = livre.TypeDocument,
NombrePages = livre.NombrePages,
Themes = livre.Themes.ToList(),
Statut = livre.Statut,
CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
};
/// <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}");
}
}