Appliquer les retours d'interface : fiche en lecture seule, liste allegee
Cinq retours d'IDEES.md, tous pensés pour le mobile d'abord.
Fiche du livre en deux modes, portés par deux routes : « /livres/{id} »
consulte, « /livres/{id}/edition » modifie. Ouvrir une fiche depuis le
catalogue ne place plus un formulaire vivant sous le pouce. Mettre le mode
dans l'adresse fait du retour navigateur une sortie d'édition naturelle.
Piège rencontré : les deux routes visent le même composant avec le même Id,
donc aucun paramètre ne change et le routeur ne redessine pas — l'URL passait
à « /edition » sans que l'écran bascule, alors qu'un chargement direct de
cette URL marchait, ce qui masque le défaut. Le composant s'abonne donc à
LocationChanged.
Restent hors « Éditer », parce que ce ne sont pas des modifications de la
fiche : le statut de lecture (action la plus fréquente, et personnelle) et
les prêts (gestes du quotidien, donnée du foyer). La suppression, elle, est
réservée au mode édition.
Dans la liste, le statut devient une pastille en lecture seule sur la ligne
des étiquettes ; les trois boutons actionnables par carte partaient trop
facilement en faisant défiler. Le format ne s'affiche plus que pour les
ebooks, le physique étant le cas par défaut — le formulaire le
présélectionne déjà, l'énumération valant 0.
Couverture agrandissable au clic, fermeture au clic extérieur ou par Échap
(le calque est focalisé à l'ouverture, sans quoi Échap resterait sans effet).
Le substitut à initiale n'est jamais cliquable : le déclencheur n'existe que
s'il y a une image.
Le regroupement par auteur existait déjà (table Auteur, page /auteurs, filtre
« ?auteur= ») ; y manquaient les liens depuis la fiche et les styles de la
page auteurs, jamais écrits — la liste sortait en puces brutes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,21 +8,58 @@
|
||||
|
||||
@key sur l'img : si l'URL change, Blazor recrée l'élément au lieu de réutiliser celui que
|
||||
le onerror a marqué comme cassé.
|
||||
|
||||
Agrandissement (IDEES.md) : seulement quand il y a une URL. Sans couverture, il n'y a rien
|
||||
à agrandir — le substitut à initiale reste un simple décor, jamais un bouton.
|
||||
*@
|
||||
<div class="couverture @Classe">
|
||||
<span class="couverture-substitut" aria-hidden="true">@Initiale</span>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Url))
|
||||
@if (ADesPixels)
|
||||
{
|
||||
<img @key="Url"
|
||||
class="couverture-image"
|
||||
src="@Url"
|
||||
alt="Couverture de @Titre"
|
||||
loading="lazy"
|
||||
onerror="this.classList.add('image-cassee')" />
|
||||
@if (Agrandissable)
|
||||
{
|
||||
@* Un vrai <button> et pas une <div> cliquable : cible tactile, focus clavier,
|
||||
et activation à l'Entrée sans une ligne de code. *@
|
||||
<button type="button" class="couverture-declencheur"
|
||||
aria-label="Agrandir la couverture de @Titre"
|
||||
@onclick="Ouvrir">
|
||||
<img @key="Url"
|
||||
class="couverture-image"
|
||||
src="@Url"
|
||||
alt="Couverture de @Titre"
|
||||
loading="lazy"
|
||||
onerror="this.classList.add('image-cassee')" />
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img @key="Url"
|
||||
class="couverture-image"
|
||||
src="@Url"
|
||||
alt="Couverture de @Titre"
|
||||
loading="lazy"
|
||||
onerror="this.classList.add('image-cassee')" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_agrandie)
|
||||
{
|
||||
@* Fermeture au clic extérieur (le clic sur l'image ne remonte pas) et à Échap.
|
||||
Le tabindex rend le calque focusable : sans focus, aucun keydown ne lui parviendrait. *@
|
||||
<div class="calque-couverture" role="dialog" aria-modal="true"
|
||||
aria-label="Couverture de @Titre"
|
||||
tabindex="-1" @ref="_calque"
|
||||
@onclick="Fermer" @onkeydown="SurToucheAsync">
|
||||
<img class="calque-image" src="@Url" alt="Couverture de @Titre"
|
||||
@onclick:stopPropagation="true"
|
||||
onerror="this.classList.add('image-cassee')" />
|
||||
|
||||
<button type="button" class="calque-fermer" aria-label="Fermer">✕</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Url { get; set; }
|
||||
|
||||
@@ -30,6 +67,40 @@
|
||||
|
||||
[Parameter] public string? Classe { get; set; }
|
||||
|
||||
/// <summary>Le clic sur l'image l'affiche en grand. Sans effet s'il n'y a pas de couverture.</summary>
|
||||
[Parameter] public bool Agrandissable { get; set; }
|
||||
|
||||
private bool _agrandie;
|
||||
private bool _aFocaliser;
|
||||
private ElementReference _calque;
|
||||
|
||||
private bool ADesPixels => !string.IsNullOrWhiteSpace(Url);
|
||||
|
||||
private string Initiale =>
|
||||
string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant();
|
||||
|
||||
private void Ouvrir()
|
||||
{
|
||||
_agrandie = true;
|
||||
_aFocaliser = true;
|
||||
}
|
||||
|
||||
private void Fermer() => _agrandie = false;
|
||||
|
||||
private void SurToucheAsync(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key is "Escape" or "Esc")
|
||||
{
|
||||
Fermer();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_aFocaliser)
|
||||
{
|
||||
_aFocaliser = false;
|
||||
await _calque.FocusAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,17 @@
|
||||
placeholder="978…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@*
|
||||
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</legend>
|
||||
<legend class="champ-libelle">
|
||||
Format<span class="champ-aide"> (physique par défaut)</span>
|
||||
</legend>
|
||||
<div class="segments">
|
||||
@foreach (var format in Formats)
|
||||
{
|
||||
|
||||
@@ -67,7 +67,9 @@ else if (_livres is not null)
|
||||
@foreach (var livre in _livres)
|
||||
{
|
||||
<li class="carte-livre" @key="livre.Id">
|
||||
<Couverture Url="@livre.CoverUrl" Titre="@livre.Titre" Classe="couverture-petite" />
|
||||
@* La vignette s'agrandit au clic ; le titre, lui, ouvre la fiche. *@
|
||||
<Couverture Url="@livre.CoverUrl" Titre="@livre.Titre"
|
||||
Classe="couverture-petite" Agrandissable="true" />
|
||||
|
||||
<div class="carte-corps">
|
||||
<a class="carte-titre" href="@($"livres/{livre.Id}")">@livre.Titre</a>
|
||||
@@ -83,11 +85,26 @@ else if (_livres is not null)
|
||||
</p>
|
||||
}
|
||||
|
||||
@* Une seule ligne d'étiquettes, en lecture seule (IDEES.md) : la liste se
|
||||
parcourt, elle ne se manipule pas. Tout se modifie depuis la fiche. *@
|
||||
<p class="carte-details">
|
||||
<span class="etiquette">@Libelles.Format(livre.Format)</span>
|
||||
@* Un livre est physique par défaut : ne l'afficher que pour les ebooks,
|
||||
sinon l'étiquette est sur toutes les lignes et n'apprend rien. *@
|
||||
@if (livre.Format == Format.Numerique)
|
||||
{
|
||||
<span class="etiquette">@Libelles.Format(livre.Format)</span>
|
||||
}
|
||||
|
||||
@* Un livre dehors l'est pour tout le foyer : cette étiquette est la même
|
||||
pour tout le monde, contrairement au statut de lecture juste dessous. *@
|
||||
@* Personnel : ce que montre cette pastille est MA lecture, pas celle du foyer.
|
||||
Pas de pastille = non commencé, l'absence de ligne en base. *@
|
||||
@if (livre.Statut is { } statut)
|
||||
{
|
||||
<span class="etiquette etiquette-statut @Libelles.ClasseStatut(statut)">
|
||||
@Libelles.Statut(statut)
|
||||
</span>
|
||||
}
|
||||
|
||||
@* Un livre dehors l'est pour tout le foyer, contrairement au statut. *@
|
||||
@if (livre.PreteA is { } emprunteur)
|
||||
{
|
||||
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
|
||||
@@ -98,12 +115,6 @@ else if (_livres is not null)
|
||||
<span>@livre.Editeur</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@* Changement de statut : action la plus fréquente, donc directement dans la liste. *@
|
||||
<ChoixStatut Valeur="livre.Statut"
|
||||
ValeurChanged="s => ChangerStatut(livre, s)"
|
||||
Desactive="_statutEnCours == livre.Id"
|
||||
Compact="true" />
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@@ -130,7 +141,6 @@ else if (_livres is not null)
|
||||
private Statut? _statut;
|
||||
private bool _chargement;
|
||||
private string? _erreur;
|
||||
private int? _statutEnCours;
|
||||
|
||||
// Une frappe au clavier annule la recherche précédente encore en vol.
|
||||
private CancellationTokenSource _cts = new();
|
||||
@@ -207,35 +217,6 @@ else if (_livres is not null)
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ChangerStatut(LivreDto livre, Statut? statut)
|
||||
{
|
||||
if (livre.Statut == statut)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_statutEnCours = livre.Id;
|
||||
var resultat = await Api.ChangerStatutAsync(livre.Id, statut);
|
||||
_statutEnCours = null;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_statut is not null)
|
||||
{
|
||||
// Un filtre par statut est actif : le livre vient peut-être de sortir de la sélection,
|
||||
// il faut donc recharger plutôt que corriger la liste en place.
|
||||
await ChargerAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remplacement local : évite de recharger toute la liste pour un seul champ.
|
||||
_livres = _livres!.Select(l => l.Id == livre.Id ? resultat.Valeur! : l).ToList();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
@page "/livres/{Id:int}"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>MaBibli — fiche du livre</PageTitle>
|
||||
|
||||
<h1 class="titre-page">Fiche du livre</h1>
|
||||
|
||||
@if (_introuvable)
|
||||
{
|
||||
<p class="message-erreur" role="alert">Ce livre n'existe pas (ou plus).</p>
|
||||
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
|
||||
}
|
||||
else if (_saisie is null)
|
||||
{
|
||||
<p class="message-discret">Chargement…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<FormulaireLivre Saisie="_saisie"
|
||||
LibelleValidation="Enregistrer les modifications"
|
||||
Erreur="@_erreur"
|
||||
EnCours="_enregistrement"
|
||||
OnValider="EnregistrerAsync"
|
||||
OnAnnuler="Retour" />
|
||||
|
||||
@if (_livre is not null)
|
||||
{
|
||||
<PretsLivre LivreId="Id" Format="_livre.Format" 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>
|
||||
}
|
||||
|
||||
<section class="zone-danger">
|
||||
@if (!_confirmationSuppression)
|
||||
{
|
||||
<button type="button" class="bouton bouton-danger" @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>
|
||||
}
|
||||
|
||||
@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 string? _erreur;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_introuvable = false;
|
||||
_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 = new EnregistrementLivre
|
||||
{
|
||||
Isbn = _livre.Isbn,
|
||||
Titre = _livre.Titre,
|
||||
|
||||
// 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.
|
||||
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);
|
||||
|
||||
private async Task EnregistrerAsync()
|
||||
{
|
||||
_enregistrement = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.ModifierAsync(Id, _saisie!);
|
||||
|
||||
_enregistrement = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.NavigateTo("/");
|
||||
}
|
||||
|
||||
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 Retour() => Navigation.NavigateTo("/");
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
@page "/livres/{Id:int}"
|
||||
@page "/livres/{Id:int}/edition"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject NavigationManager Navigation
|
||||
@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)
|
||||
{
|
||||
<p class="message-erreur" role="alert">Ce livre n'existe pas (ou plus).</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"
|
||||
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" @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>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_livre.Isbn))
|
||||
{
|
||||
<div><dt>ISBN</dt><dd>@_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" />
|
||||
</section>
|
||||
|
||||
<PretsLivre LivreId="Id" Format="_livre.Format" 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">
|
||||
<a class="bouton bouton-principal" href="@($"livres/{Id}/edition")">Éditer</a>
|
||||
<a class="bouton bouton-discret" href="">Catalogue</a>
|
||||
</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;
|
||||
|
||||
private void SurNavigation(object? emetteur, LocationChangedEventArgs e) =>
|
||||
InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose() => Navigation.LocationChanged -= SurNavigation;
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ else
|
||||
@foreach (var pret in _prets)
|
||||
{
|
||||
<li class="carte-livre" @key="pret.Id">
|
||||
<Couverture Url="@pret.LivreCoverUrl" Titre="@pret.LivreTitre" Classe="couverture-petite" />
|
||||
<Couverture Url="@pret.LivreCoverUrl" Titre="@pret.LivreTitre"
|
||||
Classe="couverture-petite" Agrandissable="true" />
|
||||
|
||||
<div class="carte-corps">
|
||||
<a class="carte-titre" href="@($"livres/{pret.LivreId}")">@pret.LivreTitre</a>
|
||||
|
||||
@@ -560,3 +560,181 @@ body {
|
||||
.scanner-mesure {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* --- Agrandissement de la couverture --- */
|
||||
|
||||
/* Le déclencheur recouvre exactement la vignette. Il n'existe que quand il y a
|
||||
une image : sans couverture, le substitut à initiale reste un décor inerte. */
|
||||
.couverture-declencheur {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.calque-couverture {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.calque-couverture:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Bornée par la fenêtre : sur mobile la couverture occupe la largeur,
|
||||
sur PC elle ne dépasse pas la hauteur utile. */
|
||||
.calque-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.calque-image.image-cassee {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calque-fermer {
|
||||
position: absolute;
|
||||
top: calc(0.5rem + env(safe-area-inset-top));
|
||||
right: 0.5rem;
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* --- Statut de lecture en lecture seule --- */
|
||||
|
||||
/* Pastille pleine, aux couleurs des segments actifs, pour ne pas se confondre avec
|
||||
l'étiquette pâle du prêt : le statut est personnel, le prêt est celui du foyer. */
|
||||
.etiquette-statut {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.etiquette-statut.statut-a-lire { background: #6b4fa8; }
|
||||
.etiquette-statut.statut-en-cours { background: #b06b00; }
|
||||
.etiquette-statut.statut-lu { background: #1f7a44; }
|
||||
|
||||
/* --- Fiche du livre en lecture seule --- */
|
||||
|
||||
.couverture-grande { width: 8rem; height: 12rem; }
|
||||
|
||||
.fiche-entete {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.fiche-infos {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fiche-titre {
|
||||
font-size: 1.15rem;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.fiche-auteurs {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--mb-texte-doux);
|
||||
}
|
||||
|
||||
/* Deux colonnes « libellé / valeur » qui restent lisibles à 320 px. */
|
||||
.fiche-champs {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.fiche-champs div {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
|
||||
.fiche-champs dt {
|
||||
flex: 0 0 5rem;
|
||||
color: var(--mb-texte-doux);
|
||||
}
|
||||
|
||||
.fiche-champs dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Le statut reste manipulable hors édition : c'est le geste le plus fréquent
|
||||
de l'application, l'enfouir derrière « Éditer » serait un recul. */
|
||||
.bloc-statut {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--mb-bord);
|
||||
}
|
||||
|
||||
/* --- Auteurs --- */
|
||||
|
||||
/* Le nom d'un auteur est un point d'entrée, pas une décoration : il doit se voir comme
|
||||
cliquable, et rester une cible atteignable au pouce dans une liste dense. */
|
||||
.lien-auteur {
|
||||
color: var(--mb-accent);
|
||||
text-decoration: none;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
|
||||
.lien-auteur:hover,
|
||||
.lien-auteur:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.liste-auteurs,
|
||||
.liste-rapprochements {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.carte-auteur-ligne {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--mb-bord);
|
||||
}
|
||||
|
||||
.rapprochements {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--mb-bord);
|
||||
border-radius: 8px;
|
||||
background: var(--mb-fond-doux);
|
||||
}
|
||||
|
||||
.carte-rapprochement {
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--mb-bord);
|
||||
}
|
||||
|
||||
.rapprochement-noms {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user