Cinq retours d'usage du 2026-08-21 qui ne touchent que l'interface (lots Q, R, S, T et V d'IDEES.md). Aucune migration, aucune entité modifiée. Q — L'ordre des champs du formulaire livre est une décision, pas une mise en page : type de document, thèmes, auteurs, rôles, puis le reste. Le type commande la suite de la saisie — les rôles ne se posent que là — et il fallait descendre tout le formulaire pour dire « c'est une BD », c'est-à-dire après avoir saisi ce qui en dépend. Rien n'est présélectionné : « non précisé » ne prétend toujours rien, et les rôles gardent leur règle actée (deux auteurs au moins, BD ou non). Des tests verrouillent la préservation des rôles à chaque frappe, que ce réordonnancement ne doit pas entamer. R — Bandeau et menu passent dans un même conteneur collant : sur PC la rangée des six destinations défilait avec la page et devenait inatteignable au bas d'une longue liste. Un seul conteneur, et non deux éléments collants superposés, qui auraient obligé à écrire en dur la hauteur d'un bandeau qui varie avec la pastille hors-ligne et le nom d'utilisateur. Sur téléphone le menu déployé occupe l'écran entier : sous le bandeau, il partageait l'écran avec la liste qu'on quittait. Trois portes de sortie s'ajoutent à la fermeture déjà en place sur LocationChanged — croix, Échap (le calque prend le focus à l'ouverture, comme celui d'agrandissement des couvertures) et clic hors des liens. Le plein écran est explicitement annulé au-delà de 40 rem, sans quoi un menu ouvert au doigt puis une fenêtre agrandie laisseraient un calque sans bascule pour le refermer. Toutes les règles du menu restent en feuille globale. S — ⚠️ Le retour du bandeau devient une remontée hiérarchique d'un cran de route, ce qui RENVERSE la décision actée le 2026-08-20 (« le retour passe par l'historique du navigateur, jamais par une destination calculée »). Le motif d'alors reste vrai — un même écran s'atteint par plusieurs chemins — mais l'historique remonte aussi les allers-retours (filtre, ordre, édition) et l'on cliquait cinq fois sans quitter le même écran. La parenté est une table explicite et testable, pas un découpage naïf d'URL : toutes les routes n'ont pas la forme d'une arborescence, /auteurs/{id}/bibliographie remontant à la liste des auteurs, dont il n'existe pas de fiche. Le garde-fou « ne jamais sortir de l'application » y vit désormais aussi : la fonction rend toujours un chemin interne, là où history.length ne disait pas ce qu'on croyait dans une PWA standalone. js/navigation.js n'a plus d'utilisateur et disparaît. T — Sur la fiche d'une série, « Modifier » et « Changer l'ordre » rejoignent la ligne du titre et de l'avancement, groupés à droite comme la bibliographie et la liste des auteurs le font déjà. Reléguées au bas de l'écran, les actions d'une saga de vingt tomes ne se découvraient qu'après avoir déroulé la liste. « Changer l'ordre » n'apparaît toujours qu'à partir de deux tomes ou deux sous-séries. V — Dans la liste des revues, toute la ligne ouvre la fiche, comme la carte entière le fait au catalogue. Le titre reste un vrai lien — adresse, clavier, clic-milieu — et son clic ne remonte pas jusqu'à la ligne, qui naviguerait une seconde fois ; toute action posée un jour sur cette ligne devra faire de même. ⚠️ Aucune vérification en navigateur : le rendu de ces écrans repose sur la compilation et la relecture. 552 tests au vert (506 avant ce lot). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
9.5 KiB
Plaintext
224 lines
9.5 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, puis rôles : 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>
|
|
|
|
<label class="champ">
|
|
@* Point-virgule, comme le champ des auteurs juste en dessous : trois champs voisins du
|
|
même formulaire ne doivent pas se saisir de trois façons. La virgule reste acceptée —
|
|
un thème n'en contient jamais, contrairement à un titre d'article à la une. *@
|
|
<span class="champ-libelle">
|
|
Thèmes <span class="champ-aide">(séparés par des points-virgules)</span>
|
|
</span>
|
|
<input class="champ-saisie" type="text" @bind="Saisie.ThemesTexte" @bind:event="oninput"
|
|
placeholder="Fantasy ; aventure ; space opera" autocomplete="off" />
|
|
</label>
|
|
|
|
@*
|
|
Un seul champ, plusieurs auteurs : le point-virgule les sépare. La virgule est déjà
|
|
prise par la forme bibliographique « Zola, Émile », qu'on doit pouvoir taper telle quelle.
|
|
Le serveur rapproche ensuite les variantes sûres d'un auteur déjà connu.
|
|
*@
|
|
<label class="champ">
|
|
<span class="champ-libelle">
|
|
Auteur<span class="champ-aide"> (plusieurs : séparez par « ; »)</span>
|
|
</span>
|
|
<input class="champ-saisie" type="text" @bind="Saisie.Auteur" @bind:event="oninput"
|
|
placeholder="Prénom Nom ; Prénom Nom" autocomplete="off" />
|
|
</label>
|
|
|
|
@*
|
|
Les rôles n'apparaissent qu'à partir de DEUX auteurs, et c'est délibéré : un roman n'a
|
|
qu'un signataire dont le rôle ne se pose pas, et une ligne de boutons sous chaque saisie
|
|
n'apprendrait rien. La question « qui écrit, qui dessine ? » ne se pose qu'à plusieurs.
|
|
Le défaut reste « non précisé » : on ne remplit pas une case à la place de l'utilisateur.
|
|
*@
|
|
@if (Saisie.Auteurs.Count > 1)
|
|
{
|
|
<fieldset class="champ">
|
|
<legend class="champ-libelle">
|
|
Rôles<span class="champ-aide"> (facultatif — utile pour une BD)</span>
|
|
</legend>
|
|
|
|
@foreach (var (auteur, index) in Saisie.Auteurs.Select((a, i) => (a, i)))
|
|
{
|
|
<div class="ligne-role" @key="index">
|
|
<span class="nom-role">@auteur.Nom</span>
|
|
<div class="segments">
|
|
@foreach (var role in Roles)
|
|
{
|
|
<button type="button"
|
|
class="segment @(auteur.Role == role ? "segment-actif" : null)"
|
|
@onclick="() => PoserRole(index, role)">
|
|
@Libelles.RoleAuteur(role)
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</fieldset>
|
|
}
|
|
|
|
@*
|
|
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>
|
|
|
|
<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 };
|
|
|
|
[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();
|
|
}
|