Files
mabibli/MaBibli.Client/Pages/Revue.razor
T
mathieuandClaude Opus 5 1d35dc06f4 Donne aux numéros de revue leur couverture et leur une, et un tiret à l'ISSN
L'ISSN est désormais rangé sous sa forme à tiret, et pas seulement affiché ainsi.
C'est le seul endroit où le projet s'écarte de « la valeur stockée reste nue »,
et il y a une raison : le code-barres produit déjà un ISSN à tiret, et la BnF
interroge `bib.issn` avec le tiret. Un ISSN tapé « 24666718 » ne se rapprochait
donc de rien, et le scan suivant créait une seconde fiche à côté. La canonisation
a lieu avant la recherche, pas seulement à l'écriture. Les champs de saisie, eux,
gardent la valeur tapée : découper à la frappe se battrait avec le curseur.

`ServiceRenormalisation` rattrape les ISSN existants — par un passage dédié, le
mécanisme générique confondant tous les NULL en une seule clé, alors que
l'unicité est ici un index partiel. Une fiche dont la forme canonique est déjà
prise garde la sienne : le serveur doit démarrer.

La couverture d'un numéro est une URL collée, jamais une photo : aucune source ne
peut la fournir, l'ISSN désignant la revue et non la parution, et le projet ne
stocke aucun fichier. Le garde du relais de couvertures a donc été étendu aux
numéros — l'oublier n'aurait produit aucune erreur visible, seulement une image
présente en ligne et absente hors-ligne.

Les articles à la une vivent dans une table à part et non dans `Theme` : un thème
est un vocabulaire qu'on réutilise, un titre d'article est unique à sa parution.
Rien ne se partage, donc pas de n-n — une simple clé étrangère vers le numéro.
Ils se saisissent au point-virgule seul : « Ukraine, deux ans après » serait
coupé en deux par la virgule.

Un PUT sur le numéro était indispensable : on note un numéro le jour où on le
range et on en recopie le sommaire plus tard. Sans lui, couverture et une
n'auraient existé qu'à la création, c'est-à-dire jamais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:25:29 +02:00

415 lines
14 KiB
Plaintext

@page "/revues/{Id:int}"
@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
{
<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>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</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. *@
<ul class="liste-numeros">
@foreach (var numero in _revue.Numeros)
{
<li class="carte-numero" @key="numero.Id">
@if (_edition == numero.Id)
{
<div class="carte-corps">
<ChampsNumeroRevue Saisie="_saisieEdition" OnEntree="EnregistrerNumeroAsync" />
<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="() => _edition = null">
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>
<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>
<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)">
Retirer
</button>
</div>
}
</li>
}
</ul>
}
<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" />
<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>
<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>
}
}
@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.</summary>
private AjoutNumeroRevue _saisie = new();
/// <summary>Numéro en cours de modification, et sa saisie — <c>null</c> = aucun.</summary>
private int? _edition;
private AjoutNumeroRevue _saisieEdition = new();
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerAsync();
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
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;
}
if (!_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
{
_numeroPropose = true;
_saisie.Numero = NumeroScanne.Trim();
}
}
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;
_edition = numero.Id;
_saisieEdition = new AjoutNumeroRevue
{
Numero = numero.Numero,
DateParution = numero.DateParution,
Note = numero.Note,
CoverUrl = numero.CoverUrl,
Articles = [.. numero.Articles],
};
}
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)
{
_enCours = true;
_erreur = await Api.RetirerNumeroAsync(numero.Id);
_enCours = false;
// 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!);
}
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");
}
}
}