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>
439 lines
16 KiB
Plaintext
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 (CLAUDE.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}");
|
|
}
|
|
}
|