Files
mabibli/MaBibli.Client/Pages/Revue.razor
T
mathieu 42dec0dbd1 - Catalogage rapide (douchette, code-barres)
- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne.
- Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais.
Séries et sagas
- Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles).
- Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un.
- Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence.
- Filtre catalogue « sans couverture » pour repérer les livres à illustrer.
Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total).
- Corrections directes sur la fiche
- Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ».
- Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification.
- Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe.
- La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper.

- Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
2026-09-09 00:05:26 +02:00

674 lines
25 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 : elle se colle à la main, ou il n'y en a
pas. C'est donc ici que le clic pour la poser sert le plus.
⚠️ Même principe que la fiche livre : cliquer une couverture absente
ou cassée ouvre son adresse, focalisée, et Entrée enregistre. Ce
n'est pas « modifier le numéro » — c'est réparer ce qu'on a sous les
yeux, et c'est pourquoi ce geste-là existe aussi en consultation,
contrairement à « Modifier » et « Retirer ».
*@
<Couverture Url="@numero.CoverUrl" Titre="@($"N° {numero.Numero}")"
Classe="couverture-petite" Agrandissable="true"
OnCorriger="() => OuvrirCorrectionCouverture(numero)" />
<div class="carte-corps">
<p class="carte-titre">N° @numero.Numero</p>
@if (_correctionCouverture == numero.Id)
{
<div class="barre-recherche ligne-creation">
<input class="champ-saisie" type="url" @ref="_champUrlCouverture"
@bind="_urlCouverture" @bind:event="oninput"
@onkeydown="@(e => SurToucheCouverture(e, numero))"
placeholder="https://…"
aria-label="@($"Adresse de la couverture du numéro {numero.Numero}")"
autocomplete="off" />
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => EnregistrerCouvertureAsync(numero)">
@(_enCours ? "Enregistrement…" : "Enregistrer")
</button>
<button type="button" class="bouton bouton-discret"
disabled="@_enCours"
@onclick="() => _correctionCouverture = null">
Annuler
</button>
</div>
<p class="message-discret">
Collez l'adresse d'une image, puis Entrée. Vider le champ
retire la couverture.
</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>Numéro dont l'adresse de couverture est ouverte — <c>null</c> = aucun.</summary>
private int? _correctionCouverture;
private string _urlCouverture = string.Empty;
private ElementReference _champUrlCouverture;
private bool _focusCouverture;
/// <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;
/// <summary>
/// Ouvre l'adresse de la couverture d'un numéro, depuis l'image elle-même.
/// </summary>
/// <remarks>
/// ⚠️ Le focus est demandé <b>une seule fois</b>, au prochain rendu : le reprendre à chaque
/// rendu l'arracherait pendant qu'on colle ou qu'on tape.
/// </remarks>
private void OuvrirCorrectionCouverture(NumeroRevueDto numero)
{
_erreur = null;
_confirmeRetrait = null;
_correctionCouverture = numero.Id;
_urlCouverture = numero.CoverUrl ?? string.Empty;
_focusCouverture = true;
}
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (_focusCouverture)
{
_focusCouverture = false;
await _champUrlCouverture.FocusAsync();
}
}
private async Task SurToucheCouverture(KeyboardEventArgs e, NumeroRevueDto numero)
{
if (e.Key == "Enter")
{
await EnregistrerCouvertureAsync(numero);
}
else if (e.Key is "Escape" or "Esc")
{
_correctionCouverture = null;
}
}
/// <summary>
/// N'enregistre que l'adresse de la couverture, sans passer par l'écran d'édition.
/// </summary>
/// <remarks>
/// ⚠️ <b>Le point d'entrée REMPLACE le numéro entier</b>, articles à la une compris : tout ce
/// que porte la ligne doit être recopié dans la charge utile, faute de quoi poser une
/// couverture effacerait la une. C'est la même précaution que dans <c>OuvrirEdition</c>, et
/// c'est le genre d'oubli qu'aucun message d'erreur ne signalerait.
/// </remarks>
private async Task EnregistrerCouvertureAsync(NumeroRevueDto numero)
{
if (!Reseau.EnLigne || _enCours)
{
return;
}
var url = _urlCouverture.Trim();
_enCours = true;
_erreur = null;
var resultat = await Api.ModifierNumeroAsync(
numero.Id,
new AjoutNumeroRevue
{
Numero = numero.Numero,
DateParution = numero.DateParution,
Note = numero.Note,
CoverUrl = string.IsNullOrEmpty(url) ? null : url,
Articles = [.. numero.Articles],
});
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Appliquer(resultat.Valeur!);
_correctionCouverture = 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;
}
// Le numéro retiré portait peut-être le champ d'adresse ouvert : il n'a plus de cible.
if (_correctionCouverture == numero.Id)
{
_correctionCouverture = 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");
}
}
}