Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
10 KiB
Plaintext
246 lines
10 KiB
Plaintext
@*
|
|
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.
|
|
*@
|
|
<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."
|
|
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.
|
|
/// </remarks>
|
|
private NomsAuteurs _noms = default!;
|
|
|
|
private EnregistrementLivre? _saisiePrecedente;
|
|
|
|
protected override void OnParametersSet()
|
|
{
|
|
if (!ReferenceEquals(_saisiePrecedente, Saisie))
|
|
{
|
|
_saisiePrecedente = Saisie;
|
|
_noms = new NomsAuteurs(Saisie.Auteurs);
|
|
}
|
|
}
|
|
|
|
[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();
|
|
}
|