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>
540 lines
20 KiB
Plaintext
540 lines
20 KiB
Plaintext
@page "/revues/{Id:int}"
|
|
@page "/revues/{Id:int}/edition"
|
|
@inject ServiceLivresApi Api
|
|
@inject NavigationManager Navigation
|
|
@inject EtatReseau Reseau
|
|
@implements IDisposable
|
|
|
|
<PageTitle>MaBibli — @(_revue?.Titre ?? "revue")</PageTitle>
|
|
|
|
@if (_revue is null)
|
|
{
|
|
<p class="message-discret" role="status">
|
|
@(_chargement ? "Chargement…" : "Cette revue n'est pas dans les données enregistrées sur cet appareil.")
|
|
</p>
|
|
}
|
|
else
|
|
{
|
|
@*
|
|
Titre, identité de la revue et actions sur la MÊME ligne (lot W) : disposition
|
|
symétrique de celle de la fiche d'une série, et déjà tenue par la bibliographie et la
|
|
liste des auteurs. Elle rend les deux actions visibles sans descendre au bas d'une
|
|
collection de trente numéros.
|
|
*@
|
|
<div class="entete-fiche">
|
|
<div class="entete-fiche-texte">
|
|
<h1 class="titre-page">@_revue.Titre</h1>
|
|
|
|
<p class="carte-details">
|
|
@if (!string.IsNullOrWhiteSpace(_revue.Issn))
|
|
{
|
|
@* K2 : un ISSN se coupe TOUJOURS au même endroit, contrairement aux tranches
|
|
d'un ISBN. Aucune table à embarquer, et aucune coupure ne peut être fausse. *@
|
|
<span class="code-isbn">ISSN @FormatageIssn.Afficher(_revue.Issn)</span>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(_revue.Editeur))
|
|
{
|
|
<span>@_revue.Editeur</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
|
|
@if (!ModeEdition)
|
|
{
|
|
<div class="entete-fiche-actions">
|
|
@* Ajouter un numéro reste en CONSULTATION, et ce n'est pas une entorse à
|
|
« consulter d'abord, modifier ensuite » : le geste ne touche pas à la fiche de
|
|
la revue, il range un objet de plus — comme noter un prêt depuis la fiche d'un
|
|
livre. Ce sont la modification et le retrait d'un numéro qui vivent, elles,
|
|
uniquement dans l'écran d'édition. *@
|
|
<button type="button" class="bouton bouton-compact bouton-discret"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
@onclick="BasculerAjout">
|
|
@(_ajout ? "Fermer" : "Ajouter un numéro")
|
|
</button>
|
|
|
|
@if (Reseau.EnLigne)
|
|
{
|
|
<a class="bouton bouton-compact bouton-principal"
|
|
href="@($"revues/{Id}/edition")">Modifier la revue</a>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="bouton bouton-compact bouton-principal" disabled
|
|
title="@EtatReseau.MotifHorsLigne">Modifier la revue</button>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@if (_erreur is not null)
|
|
{
|
|
<p class="message-erreur" role="alert">@_erreur</p>
|
|
}
|
|
|
|
@if (!ModeEdition && _ajout)
|
|
{
|
|
<h2 class="titre-section">Ajouter un numéro</h2>
|
|
|
|
@*
|
|
⚠️ Le numéro se saisit à la main, et ce n'est pas un oubli : les deux chiffres de
|
|
parution du code-barres ne sont PAS un numéro fiable (CLAUDE.md), et l'add-on EAN-2
|
|
qui le porterait vraiment n'a pas pu être vérifié sur un magazine réel.
|
|
*@
|
|
@if (_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
|
|
{
|
|
<p class="message-avertissement" role="status">
|
|
Le code-barres portait « @NumeroScanne » à côté du code principal, ce qui est
|
|
normalement le numéro de parution. <strong>Vérifiez-le sur la couverture</strong>
|
|
avant d'enregistrer.
|
|
</p>
|
|
}
|
|
|
|
<ChampsNumeroRevue Saisie="_saisie" OnEntree="AjouterAsync" OnChangement="StateHasChanged" />
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Numero))"
|
|
title="@MotifBlocage"
|
|
@onclick="AjouterAsync">
|
|
Ajouter ce numéro
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
@if (ModeEdition)
|
|
{
|
|
<h2 class="titre-section">La revue</h2>
|
|
|
|
<label class="champ">
|
|
<span class="champ-libelle">Nom</span>
|
|
<input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput" />
|
|
</label>
|
|
|
|
<label class="champ">
|
|
<span class="champ-libelle">
|
|
ISSN <span class="champ-aide">(identifiant de la revue ; le tiret est posé à l'enregistrement)</span>
|
|
</span>
|
|
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
|
|
placeholder="2466-6718" autocomplete="off" />
|
|
</label>
|
|
|
|
<label class="champ">
|
|
<span class="champ-libelle">Éditeur</span>
|
|
<input class="champ-saisie" type="text" @bind="_editeur" @bind:event="oninput" />
|
|
</label>
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
|
|
title="@MotifBlocage"
|
|
@onclick="EnregistrerAsync">
|
|
Enregistrer
|
|
</button>
|
|
|
|
<button type="button" class="bouton bouton-discret bouton-danger"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
@onclick="SupprimerAsync">
|
|
@(_confirmeSuppression ? "Confirmer la suppression" : "Supprimer la revue")
|
|
</button>
|
|
</div>
|
|
|
|
@if (_confirmeSuppression)
|
|
{
|
|
<p class="message-avertissement" role="status">
|
|
Les @_revue.Numeros.Count numéro(s) recensés disparaîtront avec elle : ils
|
|
n'existent que par leur revue.
|
|
</p>
|
|
}
|
|
}
|
|
|
|
<h2 class="titre-section">Numéros possédés</h2>
|
|
|
|
@if (_revue.Numeros.Count == 0)
|
|
{
|
|
<p class="message-discret">
|
|
Aucun numéro recensé pour l'instant.
|
|
</p>
|
|
}
|
|
else
|
|
{
|
|
@* Du plus récent au plus ancien : devant un kiosque, la question est « ai-je le
|
|
dernier ? », pas « par quoi ai-je commencé ». C'est l'inverse d'une série, qui suit
|
|
son ordre de lecture. L'ordre est celui rendu par l'API — les numéros sans date
|
|
ferment la liste, une date inconnue n'étant pas une date ancienne. *@
|
|
<ul class="liste-numeros">
|
|
@foreach (var numero in _revue.Numeros)
|
|
{
|
|
<li class="carte-numero" @key="numero.Id">
|
|
@if (ModeEdition && _edition == numero.Id)
|
|
{
|
|
<div class="carte-corps">
|
|
<ChampsNumeroRevue Saisie="_saisieEdition" OnEntree="EnregistrerNumeroAsync"
|
|
OnChangement="StateHasChanged" />
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisieEdition.Numero))"
|
|
title="@MotifBlocage"
|
|
@onclick="EnregistrerNumeroAsync">
|
|
Enregistrer ce numéro
|
|
</button>
|
|
|
|
<button type="button" class="bouton bouton-discret"
|
|
disabled="@_enCours" @onclick="FermerEdition">
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
@* La couverture d'un numéro ne vient d'aucune source — l'ISSN désigne la
|
|
revue, pas la parution : sans URL collée, c'est le substitut à
|
|
initiale qui s'affiche, et il n'est pas cliquable. *@
|
|
<Couverture Url="@numero.CoverUrl" Titre="@($"N° {numero.Numero}")"
|
|
Classe="couverture-petite" Agrandissable="true" />
|
|
|
|
<div class="carte-corps">
|
|
<p class="carte-titre">N° @numero.Numero</p>
|
|
|
|
<p class="carte-details">
|
|
@if (numero.DateParution is { } parution)
|
|
{
|
|
<span>@parution.ToLocalTime().ToString("MMMM yyyy")</span>
|
|
}
|
|
else
|
|
{
|
|
<span>Date de parution inconnue</span>
|
|
}
|
|
</p>
|
|
|
|
@* Les articles à la une décrivent CE numéro, pas la revue : ils
|
|
changent à chaque parution. D'où une étiquette par titre, sur la
|
|
fiche du numéro et nulle part ailleurs. *@
|
|
@if (numero.Articles.Count > 0)
|
|
{
|
|
<p class="carte-details">
|
|
@foreach (var article in numero.Articles)
|
|
{
|
|
<span class="etiquette etiquette-une">@article</span>
|
|
}
|
|
</p>
|
|
}
|
|
|
|
@if (!string.IsNullOrWhiteSpace(numero.Note))
|
|
{
|
|
<p class="souhait-note">@numero.Note</p>
|
|
}
|
|
</div>
|
|
|
|
@*
|
|
⚠️ Modifier et retirer un numéro n'existent QUE dans l'écran
|
|
d'édition : en consultation, ces deux boutons se déclenchaient sous
|
|
le pouce en faisant défiler la collection — même raison que les
|
|
flèches d'ordre d'une série, reléguées sur leur propre écran.
|
|
*@
|
|
@if (ModeEdition)
|
|
{
|
|
<div class="actions-tome">
|
|
<button type="button" class="bouton bouton-compact"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
aria-label="@($"Modifier le numéro {numero.Numero}")"
|
|
@onclick="() => OuvrirEdition(numero)">
|
|
Modifier
|
|
</button>
|
|
|
|
@* Un retrait est destructeur et ne se défait pas : il se
|
|
confirme, comme la suppression d'un livre ou d'une revue. *@
|
|
<button type="button" class="bouton bouton-compact bouton-danger"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
aria-label="@($"Retirer le numéro {numero.Numero}")"
|
|
@onclick="() => RetirerAsync(numero)">
|
|
@(_confirmeRetrait == numero.Id ? "Confirmer le retrait" : "Retirer")
|
|
</button>
|
|
</div>
|
|
}
|
|
}
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public int Id { get; set; }
|
|
|
|
/// <summary>
|
|
/// Numéro lu sur l'add-on <b>EAN-2</b> du code-barres, transmis par l'écran de scan.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ <b>Proposé, pas acquis.</b> L'EAN-2 est normativement le numéro de parution, mais
|
|
/// rien ne l'a confirmé sur des magazines réels : il atterrit donc dans le champ, où
|
|
/// l'utilisateur le compare à la couverture et le corrige. C'est aussi pourquoi l'écran le
|
|
/// dit explicitement au lieu de le glisser en douce.
|
|
/// </remarks>
|
|
[SupplyParameterFromQuery(Name = "numero")]
|
|
public string? NumeroScanne { get; set; }
|
|
|
|
/// <summary>Le numéro scanné n'est proposé qu'une fois : ensuite, le champ appartient à l'utilisateur.</summary>
|
|
private bool _numeroPropose;
|
|
|
|
private RevueDto? _revue;
|
|
private bool _chargement = true;
|
|
private bool _enCours;
|
|
private string? _erreur;
|
|
private bool _confirmeSuppression;
|
|
|
|
private string _titre = string.Empty;
|
|
private string? _issn;
|
|
private string? _editeur;
|
|
|
|
/// <summary>Saisie du numéro qu'on ajoute, et repli du formulaire qui la porte.</summary>
|
|
private AjoutNumeroRevue _saisie = new();
|
|
private bool _ajout;
|
|
|
|
/// <summary>Numéro en cours de modification, et sa saisie — <c>null</c> = aucun.</summary>
|
|
private int? _edition;
|
|
private AjoutNumeroRevue _saisieEdition = new();
|
|
|
|
/// <summary>Numéro dont le retrait attend confirmation — <c>null</c> = aucun.</summary>
|
|
private int? _confirmeRetrait;
|
|
|
|
/// <summary>
|
|
/// Écran d'édition de la revue : nom, ISSN, éditeur, suppression, et la seule place où un
|
|
/// numéro se modifie ou se retire.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Le mode est dans l'ADRESSE, pas dans un booléen interne : convention du projet depuis
|
|
/// la fiche livre. Comme les deux routes partagent le même paramètre <c>Id</c>, le routeur
|
|
/// ne déclenche <b>aucun</b> nouveau rendu en passant de l'une à l'autre — d'où l'abonnement
|
|
/// à <c>LocationChanged</c>, sans lequel l'URL changerait et l'écran resterait le même.
|
|
/// </remarks>
|
|
private bool ModeEdition =>
|
|
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
|
|
.EndsWith("/edition", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
|
|
|
protected override void OnInitialized()
|
|
{
|
|
Reseau.Change += SurChangementReseau;
|
|
Navigation.LocationChanged += SurNavigation;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Quitter l'édition referme ce qui n'a de sens que là : un formulaire de numéro resté
|
|
/// ouvert, et une confirmation de retrait en attente.
|
|
/// </summary>
|
|
private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(() =>
|
|
{
|
|
if (!ModeEdition)
|
|
{
|
|
_edition = null;
|
|
_confirmeRetrait = null;
|
|
_confirmeSuppression = false;
|
|
}
|
|
|
|
StateHasChanged();
|
|
});
|
|
|
|
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
|
{
|
|
await ChargerAsync();
|
|
StateHasChanged();
|
|
});
|
|
|
|
public void Dispose()
|
|
{
|
|
Reseau.Change -= SurChangementReseau;
|
|
Navigation.LocationChanged -= SurNavigation;
|
|
}
|
|
|
|
protected override Task OnParametersSetAsync() => ChargerAsync();
|
|
|
|
private async Task ChargerAsync()
|
|
{
|
|
_chargement = true;
|
|
_revue = (await Api.ListerRevuesAsync()).FirstOrDefault(r => r.Id == Id);
|
|
_chargement = false;
|
|
|
|
if (_revue is not null)
|
|
{
|
|
_titre = _revue.Titre;
|
|
_issn = _revue.Issn;
|
|
_editeur = _revue.Editeur;
|
|
}
|
|
|
|
// Un numéro lu au code-barres ouvre le formulaire de lui-même : on vient de scanner,
|
|
// demander un clic de plus pour saisir ce qu'on tient en main serait un détour.
|
|
if (!_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
|
|
{
|
|
_numeroPropose = true;
|
|
_saisie.Numero = NumeroScanne.Trim();
|
|
_ajout = true;
|
|
}
|
|
}
|
|
|
|
private void BasculerAjout()
|
|
{
|
|
_erreur = null;
|
|
_ajout = !_ajout;
|
|
}
|
|
|
|
private void Appliquer(RevueDto revue)
|
|
{
|
|
_revue = revue;
|
|
_titre = revue.Titre;
|
|
_issn = revue.Issn;
|
|
_editeur = revue.Editeur;
|
|
}
|
|
|
|
private async Task AjouterAsync()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_saisie.Numero) || !Reseau.EnLigne || _enCours)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_enCours = true;
|
|
_erreur = null;
|
|
|
|
// La conversion de la date en UTC vit dans ChampsNumeroRevue : les deux formulaires qui
|
|
// s'en servent ne peuvent donc pas l'oublier chacun de leur côté.
|
|
var resultat = await Api.AjouterNumeroAsync(Id, _saisie);
|
|
|
|
_enCours = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreur = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
Appliquer(resultat.Valeur!);
|
|
_saisie = new AjoutNumeroRevue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ouvre la modification d'un numéro, en recopiant ce qu'il porte déjà.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// La couverture et les articles à la une <b>ne peuvent pas</b> être connus à l'achat : on
|
|
/// note un numéro quand on le range, et on en recopie la une plus tard. Sans cet écran, ces
|
|
/// deux champs n'auraient existé qu'à la création, c'est-à-dire jamais.
|
|
/// </remarks>
|
|
private void OuvrirEdition(NumeroRevueDto numero)
|
|
{
|
|
_erreur = null;
|
|
_confirmeRetrait = null;
|
|
_edition = numero.Id;
|
|
_saisieEdition = new AjoutNumeroRevue
|
|
{
|
|
Numero = numero.Numero,
|
|
DateParution = numero.DateParution,
|
|
Note = numero.Note,
|
|
CoverUrl = numero.CoverUrl,
|
|
Articles = [.. numero.Articles],
|
|
};
|
|
}
|
|
|
|
private void FermerEdition() => _edition = null;
|
|
|
|
private async Task EnregistrerNumeroAsync()
|
|
{
|
|
if (_edition is not { } numeroId
|
|
|| string.IsNullOrWhiteSpace(_saisieEdition.Numero)
|
|
|| !Reseau.EnLigne
|
|
|| _enCours)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_enCours = true;
|
|
_erreur = null;
|
|
|
|
var resultat = await Api.ModifierNumeroAsync(numeroId, _saisieEdition);
|
|
|
|
_enCours = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreur = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
Appliquer(resultat.Valeur!);
|
|
_edition = null;
|
|
}
|
|
|
|
private async Task RetirerAsync(NumeroRevueDto numero)
|
|
{
|
|
// Premier clic : on demande confirmation. Un numéro retiré ne se récupère pas, et la
|
|
// liste s'égrène sous le pouce.
|
|
if (_confirmeRetrait != numero.Id)
|
|
{
|
|
_erreur = null;
|
|
_confirmeRetrait = numero.Id;
|
|
return;
|
|
}
|
|
|
|
_enCours = true;
|
|
_erreur = await Api.RetirerNumeroAsync(numero.Id);
|
|
_enCours = false;
|
|
_confirmeRetrait = null;
|
|
|
|
// Le numéro retiré était peut-être celui qu'on modifiait : un formulaire ouvert sur une
|
|
// ligne disparue n'aurait plus rien à enregistrer.
|
|
if (_edition == numero.Id)
|
|
{
|
|
_edition = null;
|
|
}
|
|
|
|
if (_erreur is null)
|
|
{
|
|
await ChargerAsync();
|
|
}
|
|
}
|
|
|
|
private async Task EnregistrerAsync()
|
|
{
|
|
_enCours = true;
|
|
_erreur = null;
|
|
|
|
var resultat = await Api.ModifierRevueAsync(
|
|
Id, new EnregistrementRevue { Titre = _titre, Issn = _issn, Editeur = _editeur });
|
|
|
|
_enCours = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreur = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
Appliquer(resultat.Valeur!);
|
|
|
|
// La fiche est à jour : on retombe sur la consultation, comme après l'édition d'un
|
|
// livre ou d'une série. Rester en édition inviterait à réenregistrer.
|
|
Navigation.NavigateTo($"revues/{Id}");
|
|
}
|
|
|
|
private async Task SupprimerAsync()
|
|
{
|
|
if (!_confirmeSuppression)
|
|
{
|
|
_confirmeSuppression = true;
|
|
return;
|
|
}
|
|
|
|
_enCours = true;
|
|
_erreur = await Api.SupprimerRevueAsync(Id);
|
|
_enCours = false;
|
|
|
|
if (_erreur is null)
|
|
{
|
|
Navigation.NavigateTo("revues");
|
|
}
|
|
}
|
|
}
|