Files
mabibli/MaBibli.Client/Composants/FormulaireLivre.razor
T
mathieu 42dec0dbd1 - Catalogage rapide (douchette, code-barres)
- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne.
- Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais.
Séries et sagas
- Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles).
- Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un.
- Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence.
- Filtre catalogue « sans couverture » pour repérer les livres à illustrer.
Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total).
- Corrections directes sur la fiche
- Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ».
- Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification.
- Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe.
- La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper.

- Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
2026-09-09 00:05:26 +02:00

297 lines
13 KiB
Plaintext

@inject ServiceLivresApi Api
@*
Formulaire d'une fiche livre, partagé par l'ajout (ISBN ou manuel) et l'édition.
Le pré-remplissage par ISBN est une AIDE, pas une contrainte : tous les champs restent
modifiables, et une fiche incomplète peut toujours être complétée à la main (CLAUDE.md).
*@
<div class="formulaire">
@if (!string.IsNullOrEmpty(Saisie.CoverUrl) || !string.IsNullOrWhiteSpace(Saisie.Titre))
{
<div class="formulaire-apercu">
<Couverture Url="@Saisie.CoverUrl" Titre="@Saisie.Titre" Classe="couverture-moyenne" />
</div>
}
<label class="champ">
<span class="champ-libelle">Titre <span class="obligatoire" aria-hidden="true">*</span></span>
<input class="champ-saisie" type="text" @bind="Saisie.Titre" @bind:event="oninput"
placeholder="Titre du livre" autocomplete="off" />
</label>
@*
⚠️ L'ORDRE DES CHAMPS EST UNE DÉCISION (lot Q, 2026-08-21), pas une mise en page.
Type de document, puis thèmes, puis auteurs (chacun avec son rôle) : c'est l'ordre dans
lequel on regarde un livre qu'on tient en main, et surtout le type COMMANDE LA SUITE de
la saisie — les rôles ne se posent que là. Relégué en bas, il obligeait à descendre tout le formulaire
pour dire « c'est une BD », c'est-à-dire après avoir saisi ce qui en dépend.
⚠️ Remonter le champ le rend visible ; le PRÉREMPLIR écrirait quelque chose de faux. Le
défaut reste « non précisé », qui ne prétend rien (décision actée) — c'est aussi ce qui
rend l'étiquette honnête dans les listes : elle ne s'affiche que si un type a été choisi.
*@
<fieldset class="champ">
<legend class="champ-libelle">
Type<span class="champ-aide"> (facultatif)</span>
</legend>
<div class="segments">
@foreach (var type in Types)
{
<button type="button"
class="segment @Libelles.ClasseTypeDocument(type) @(Saisie.TypeDocument == type ? "segment-actif" : null)"
@onclick="() => Saisie.TypeDocument = type">
@Libelles.TypeDocument(type)
</button>
}
</div>
</fieldset>
@*
⚠️ Thèmes et auteurs se saisissent UN PAR UN, et non plus sur une ligne à séparateurs.
Le champ unique se reliait à chaque frappe sur une propriété qui découpait puis
recomposait la liste : le point-virgule tapé y était élagué (entrée vide) et l'espace
qui le suit rogné — le séparateur était le seul caractère qu'on ne pouvait pas taper.
Voir CLAUDE.md.
*@
@*
⚠️ Les thèmes déjà employés sont PROPOSÉS : un thème est un vocabulaire, il ne vaut que
réutilisé. « fantasy » et « Fantaisie » saisis séparément ne regroupent rien, et le
filtre par thème n'y verrait que deux étiquettes sans rapport.
*@
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà sur ce livre."
Suggestions="_themesConnus"
OnChangement="StateHasChanged" />
@*
Un auteur par ligne, et son RÔLE avec lui.
⚠️ Le rôle vit sur le LIEN, pas sur la personne : le même auteur scénarise un album et
dessine le suivant. C'est aussi pourquoi renommer une ligne conserve son rôle
(NomsAuteurs) — on corrige un nom, on ne désigne pas quelqu'un d'autre.
⚠️ Les rôles n'apparaissent qu'à partir de DEUX auteurs, décision inchangée : un roman
n'a qu'un signataire dont le rôle ne se pose pas. Le défaut reste « non précisé ».
*@
<SaisieListe Valeurs="Noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="Prénom Nom, ou Nom, Prénom"
MessageDoublon="Cet auteur est déjà sur ce livre."
OnChangement="StateHasChanged">
<Complement Context="index">
@if (Saisie.Auteurs.Count > 1)
{
<div class="segments segments-role" role="group"
aria-label="@($"Rôle de {Saisie.Auteurs[index].Nom}")">
@foreach (var role in Roles)
{
<button type="button"
class="segment @(Saisie.Auteurs[index].Role == role ? "segment-actif" : null)"
@onclick="() => PoserRole(index, role)">
@Libelles.RoleAuteur(role)
</button>
}
</div>
}
</Complement>
</SaisieListe>
@*
Le format n'est plus affiché dans les listes que pour les ebooks : un livre y est donc
physique par défaut. Cette valeur DOIT rester présélectionnée ici, sinon on créerait
des fiches sans format en croyant que « rien d'affiché = physique ».
C'est le cas sans code particulier : Format.Physique vaut 0, valeur par défaut de
l'énumération, et le segment correspondant est donc actif dès l'ouverture.
*@
<fieldset class="champ">
<legend class="champ-libelle">
Format<span class="champ-aide"> (physique par défaut)</span>
</legend>
<div class="segments">
@foreach (var format in Formats)
{
<button type="button"
class="segment @(Saisie.Format == format ? "segment-actif" : null)"
@onclick="() => Saisie.Format = format">
@Libelles.Format(format)
</button>
}
</div>
</fieldset>
<label class="champ">
<span class="champ-libelle">Éditeur</span>
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
autocomplete="off" />
</label>
@*
⚠️ Le champ reste VIDE quand on ne sait pas : « 0 » se lirait comme une donnée. C'est
aussi pourquoi la valeur proposée par la BnF arrive ici et non en base directement —
une pagination lue dans une phrase (« 1 vol. (349 p.) ») se corrige d'un geste.
*@
<label class="champ">
<span class="champ-libelle">
Nombre de pages <span class="champ-aide">(facultatif)</span>
</span>
<input class="champ-saisie" type="number" inputmode="numeric" min="1" step="1"
@bind="Saisie.NombrePages" @bind:event="oninput"
placeholder="349" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">ISBN <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="text" inputmode="numeric" @bind="Saisie.Isbn" @bind:event="oninput"
placeholder="978…" autocomplete="off" />
</label>
<fieldset class="champ">
<legend class="champ-libelle">
Statut de lecture<span class="champ-aide"> (le vôtre)</span>
</legend>
<ChoixStatut Valeur="Saisie.Statut" ValeurChanged="s => Saisie.Statut = s" />
</fieldset>
<label class="champ">
<span class="champ-libelle">URL de couverture</span>
<input class="champ-saisie" type="url" @bind="Saisie.CoverUrl" @bind:event="oninput"
placeholder="https://…" autocomplete="off" />
</label>
@if (!string.IsNullOrEmpty(Erreur))
{
<p class="message-erreur" role="alert">@Erreur</p>
}
@* Un enregistrement impossible se dit AVANT le clic : un bouton qui échoue en silence est
pire que pas de bouton du tout (CLAUDE.md, stratégie hors-ligne). *@
@if (!string.IsNullOrEmpty(MessageBlocage))
{
<p class="message-avertissement" role="status">@MessageBlocage</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(EnCours || Bloque || string.IsNullOrWhiteSpace(Saisie.Titre))"
title="@MessageBlocage"
@onclick="Valider">
@(EnCours ? "Enregistrement…" : LibelleValidation)
</button>
@if (OnAnnuler.HasDelegate)
{
<button type="button" class="bouton bouton-discret" disabled="@EnCours"
@onclick="OnAnnuler">
Annuler
</button>
}
</div>
</div>
@code {
private static readonly Format[] Formats = Enum.GetValues<Format>();
private static readonly TypeDocument[] Types = Enum.GetValues<TypeDocument>();
private static readonly RoleAuteur[] Roles = Enum.GetValues<RoleAuteur>();
/// <summary>
/// Pose le rôle d'un auteur.
/// </summary>
/// <remarks>
/// <see cref="AuteurSaisi"/> est un record : on remplace l'entrée plutôt que de la muter,
/// et la liste reste celle que la propriété <c>Auteur</c> saura relire pour préserver les
/// rôles à la frappe suivante.
/// </remarks>
private void PoserRole(int index, RoleAuteur role) =>
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role };
/// <summary>
/// Les noms des auteurs, vus comme une liste de chaînes par <c>SaisieListe</c>.
/// </summary>
/// <remarks>
/// ⚠️ Sa <b>référence doit rester stable</b> tant que la saisie ne change pas : le composant
/// se remet à zéro quand la liste qu'on lui prête change d'identité, et il perdrait donc à
/// chaque rendu ce qui est en cours de frappe. D'où la construction paresseuse, invalidée
/// par <c>OnParametersSet</c> et par elle seule.
/// <para>
/// ⚠️ Elle ne peut PAS être construite dans <c>OnParametersSet</c> uniquement : dès qu'un
/// composant a un <c>OnInitializedAsync</c> qui attend vraiment (ici la lecture des thèmes
/// connus), Blazor rend une première fois <b>avant</b> d'appeler <c>OnParametersSet</c>. Ce
/// rendu-là voyait un <c>null</c>, et l'enfant levait une <c>NullReferenceException</c> —
/// invisible à l'écran, qui finissait par s'afficher, mais bien présente en console.
/// </para>
/// </remarks>
private NomsAuteurs? _noms;
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
private EnregistrementLivre? _saisiePrecedente;
/// <summary>
/// Les thèmes déjà employés dans la bibliothèque, proposés sous le champ.
/// </summary>
/// <remarks>
/// ⚠️ Déduits du <b>catalogue</b>, et non d'un point d'entrée « /api/themes » : la liste des
/// livres est déjà lue partout, elle porte les thèmes, et surtout elle a son instantané —
/// une nouvelle vue de l'API en exigerait un autre, faute de quoi les propositions
/// disparaîtraient hors-ligne. Le catalogue d'un foyer se charge entier, c'est déjà le parti
/// pris de la détection de doublons.
/// <para>
/// Une lecture ratée ne coûte rien : sans propositions, le champ redevient celui d'avant.
/// </para>
/// </remarks>
private IReadOnlyList<string> _themesConnus = [];
protected override async Task OnInitializedAsync()
{
try
{
_themesConnus =
[
.. (await Api.ListerAsync(new CritereLivres()))
.SelectMany(l => l.Themes)
.DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
.OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal),
];
}
catch (Exception)
{
_themesConnus = [];
}
}
protected override void OnParametersSet()
{
if (!ReferenceEquals(_saisiePrecedente, Saisie))
{
_saisiePrecedente = Saisie;
_noms = null;
}
}
[Parameter, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new();
[Parameter] public string LibelleValidation { get; set; } = "Enregistrer";
[Parameter] public string? Erreur { get; set; }
[Parameter] public bool EnCours { get; set; }
/// <summary>
/// Raison pour laquelle l'enregistrement est impossible (typiquement l'absence de réseau).
/// Renseignée, elle désactive la validation et s'affiche : la saisie reste lisible, mais
/// l'utilisateur sait pourquoi il ne peut pas valider.
/// </summary>
[Parameter] public string? MessageBlocage { get; set; }
private bool Bloque => !string.IsNullOrEmpty(MessageBlocage);
[Parameter] public EventCallback OnValider { get; set; }
[Parameter] public EventCallback OnAnnuler { get; set; }
private Task Valider() => OnValider.InvokeAsync();
}