Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
540 lines
21 KiB
Plaintext
540 lines
21 KiB
Plaintext
@page "/auteurs/{AuteurId:int}/bibliographie"
|
|
@inject ServiceLivresApi Api
|
|
@inject EtatReseau Reseau
|
|
|
|
<PageTitle>MaBibli — bibliographie</PageTitle>
|
|
|
|
@if (_bibliographie is null && _erreur is null)
|
|
{
|
|
<h1 class="titre-page">Bibliographie</h1>
|
|
<p class="message-discret">Interrogation de la BnF…</p>
|
|
}
|
|
else if (_erreur is not null)
|
|
{
|
|
<h1 class="titre-page">Bibliographie</h1>
|
|
<p class="message-erreur" role="alert">@_erreur</p>
|
|
|
|
@* Une panne de réseau est le plus souvent passagère : sans ce bouton, il fallait
|
|
recharger la page entière pour retenter. *@
|
|
@if (_reessayable)
|
|
{
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_chargement || !Reseau.EnLigne)"
|
|
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
|
@onclick="ChargerAsync">
|
|
@(_chargement ? "Nouvelle tentative…" : "Réessayer")
|
|
</button>
|
|
</div>
|
|
}
|
|
}
|
|
else if (_bibliographie is { } biblio)
|
|
{
|
|
<h1 class="titre-page">
|
|
@if (biblio.EstNouveautes)
|
|
{
|
|
<text>Nouveautés de @biblio.Auteur.Nom</text>
|
|
}
|
|
else
|
|
{
|
|
@biblio.Auteur.Nom
|
|
}
|
|
</h1>
|
|
|
|
@*
|
|
⚠️ Les deux cas ci-dessous s'excluent, et c'est TOUT l'objet de ce bloc.
|
|
|
|
Avant correction, une BnF muette produisait une liste vide, que l'écran commentait
|
|
d'un rassurant « la BnF ne connaît aucun livre de cet auteur » — affiché juste sous
|
|
l'avertissement qui disait le contraire. On affirmait une absence qu'on n'avait pas
|
|
pu constater, ce qui est la pire des deux erreurs possibles ici : elle ferme la
|
|
question au lieu de la laisser ouverte.
|
|
|
|
La règle : on n'explique une absence de résultats QUE si la source a répondu.
|
|
*@
|
|
@if (biblio.SourceMuette)
|
|
{
|
|
<p class="message-avertissement" role="alert">
|
|
@biblio.Avertissement
|
|
</p>
|
|
|
|
<p class="message-discret">
|
|
La bibliographie n'a donc <strong>pas pu être établie</strong> : cet écran ne dit
|
|
rien de ce que cet auteur a écrit.
|
|
</p>
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_chargement || !Reseau.EnLigne)"
|
|
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
|
@onclick="ChargerAsync">
|
|
@(_chargement ? "Nouvelle tentative…" : "Réessayer")
|
|
</button>
|
|
</div>
|
|
}
|
|
else if (biblio.Oeuvres.Count == 0)
|
|
{
|
|
@if (biblio.EstNouveautes && biblio.SeuilNouveautesInconnu)
|
|
{
|
|
<p class="message-discret">
|
|
Impossible de dater le livre le plus récent que vous possédez dans les notices
|
|
reçues. La BnF a répondu, mais aucune nouveauté fiable ne peut être déduite.
|
|
</p>
|
|
}
|
|
else if (biblio.EstNouveautes)
|
|
{
|
|
<p class="message-discret">Aucune nouveauté trouvée depuis @biblio.AnneeSeuilNouveautes.</p>
|
|
}
|
|
else
|
|
{
|
|
@* La source a répondu, et elle ne connaît rien : là, et là seulement, on peut
|
|
l'expliquer. *@
|
|
<p class="message-discret">
|
|
La BnF ne connaît aucun livre de cet auteur. C'est le cas attendu pour les auteurs
|
|
étrangers : le dépôt légal français ne couvre que l'édition francophone, et un
|
|
auteur traduit n'y a souvent pas de notice à son nom.
|
|
</p>
|
|
}
|
|
}
|
|
else if (biblio.Avertissement is not null)
|
|
{
|
|
@* Source répondue mais bancale (une page sur deux, par exemple) : la liste vaut
|
|
quelque chose, on la montre en disant qu'elle est peut-être incomplète. *@
|
|
<p class="message-avertissement" role="alert">@biblio.Avertissement</p>
|
|
}
|
|
else
|
|
{
|
|
@* Ces compteurs disent à l'utilisateur CE QU'IL REGARDE. Sur un auteur très réédité,
|
|
la liste n'est qu'un extrait : le taire ferait croire qu'un livre absent n'existe pas. *@
|
|
<p class="compteur">
|
|
@biblio.Oeuvres.Count œuvre@(biblio.Oeuvres.Count > 1 ? "s" : "")
|
|
— @_possedees déjà chez vous
|
|
@if (_masquees > 0)
|
|
{
|
|
<text> — @_masquees masquée@(_masquees > 1 ? "s" : "")</text>
|
|
}
|
|
</p>
|
|
|
|
@if (biblio.EstNouveautes)
|
|
{
|
|
<p class="message-discret">
|
|
Œuvres parues après @biblio.AnneeSeuilNouveautes, d'après les éditions BnF reçues.
|
|
</p>
|
|
}
|
|
|
|
@* Deux phrases distinctes plutôt qu'une seule à trous : enchaîner des fragments
|
|
conditionnels dans une même phrase produit une ponctuation bancale dès qu'un des
|
|
fragments manque (« exister. .34 notices ont été écartées »). *@
|
|
<p class="message-discret">
|
|
@if (biblio.Tronquee)
|
|
{
|
|
<text>
|
|
D'après le catalogue de la BnF, à partir de @biblio.NoticesLues notices
|
|
sur les <strong>@biblio.NoticesAnnoncees</strong> qu'elle annonce :
|
|
<strong>cette liste est un extrait</strong>, classé par pertinence.
|
|
Un livre absent d'ici peut très bien exister.
|
|
</text>
|
|
}
|
|
else
|
|
{
|
|
<text>
|
|
D'après le catalogue de la BnF, sur
|
|
@biblio.NoticesLues notice@(biblio.NoticesLues > 1 ? "s" : "").
|
|
</text>
|
|
}
|
|
</p>
|
|
|
|
@if (biblio.NoticesEcartees > 0)
|
|
{
|
|
<p class="message-discret">
|
|
@biblio.NoticesEcartees notice@(biblio.NoticesEcartees > 1 ? "s ont" : " a")
|
|
été écartée@(biblio.NoticesEcartees > 1 ? "s" : "") : autre support (livre audio,
|
|
jeu) ou ouvrage signé de quelqu'un d'autre.
|
|
</p>
|
|
}
|
|
|
|
@if (_message is not null)
|
|
{
|
|
<p class="message-erreur" role="alert">@_message</p>
|
|
}
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-discret"
|
|
@onclick="() => _filtresOuverts = !_filtresOuverts"
|
|
aria-expanded="@_filtresOuverts">
|
|
Filtrer (@NombreFiltres)
|
|
</button>
|
|
</div>
|
|
@if (_filtresOuverts)
|
|
{
|
|
<div class="filtres">
|
|
<div class="segments" role="group" aria-label="Possession">
|
|
<button type="button" class="segment @(_modePossession == 0 ? "segment-actif" : null)"
|
|
@onclick="() => _modePossession = 0">Tout</button>
|
|
<button type="button" class="segment @(_modePossession == 1 ? "segment-actif" : null)"
|
|
@onclick="() => _modePossession = 1">Ceux que j'ai déjà</button>
|
|
<button type="button" class="segment @(_modePossession == 2 ? "segment-actif" : null)"
|
|
@onclick="() => _modePossession = 2">Ce qui me manque</button>
|
|
</div>
|
|
@if (_masquees > 0)
|
|
{
|
|
<div class="segments">
|
|
<button type="button" class="segment @(_afficherMasquees ? "segment-actif" : null)"
|
|
@onclick="() => _afficherMasquees = true">
|
|
Afficher les masquées (@_masquees)
|
|
</button>
|
|
<button type="button" class="segment @(!_afficherMasquees ? "segment-actif" : null)"
|
|
@onclick="() => _afficherMasquees = false">Masquer les masquées</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<ul class="liste-bibliographie">
|
|
@foreach (var oeuvre in Affichees(biblio))
|
|
{
|
|
@* La classe « oeuvre-possedee » grise la ligne : le livre est déjà sur l'étagère. *@
|
|
<li class="carte-oeuvre @(oeuvre.Possede ? "oeuvre-possedee" : null)" @key="oeuvre.Titre">
|
|
@if (oeuvre is { Possede: true, LivreId: { } livreId }
|
|
&& _livresCatalogue.FirstOrDefault(l => l.Id == livreId) is { } livre)
|
|
{
|
|
<CarteLivre Livre="livre" />
|
|
}
|
|
@if (!oeuvre.Possede || !_livresCatalogue.Any(l => l.Id == oeuvre.LivreId))
|
|
{
|
|
<div class="carte-corps">
|
|
<p class="carte-titre">
|
|
@if (oeuvre is { Possede: true, LivreId: { } livreIdPossede })
|
|
{
|
|
<a class="lien-auteur" href="@($"livres/{livreIdPossede}")">@oeuvre.Titre</a>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="carte-titre oeuvre-titre-bouton"
|
|
@onclick="() => BasculerOeuvre(oeuvre.Titre)"
|
|
aria-expanded="@_oeuvresOuvertes.Contains(oeuvre.Titre)">
|
|
@oeuvre.Titre
|
|
</button>
|
|
}
|
|
</p>
|
|
|
|
<p class="carte-details">
|
|
@if (!string.IsNullOrWhiteSpace(oeuvre.Annee))
|
|
{
|
|
<span>@oeuvre.Annee</span>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(oeuvre.Editeur))
|
|
{
|
|
<span>@oeuvre.Editeur</span>
|
|
}
|
|
@if (oeuvre.NombreEditions > 1)
|
|
{
|
|
<span class="etiquette">@oeuvre.NombreEditions éditions</span>
|
|
}
|
|
|
|
@if (oeuvre.Possede)
|
|
{
|
|
<span class="etiquette etiquette-possede">Déjà chez vous</span>
|
|
}
|
|
else if (oeuvre.Souhaite)
|
|
{
|
|
<span class="etiquette etiquette-souhaite">Dans vos envies</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
@if (_oeuvresOuvertes.Contains(oeuvre.Titre))
|
|
{
|
|
<p class="message-discret detail-oeuvre">
|
|
@if (!string.IsNullOrWhiteSpace(oeuvre.Isbn))
|
|
{
|
|
<span>ISBN : @FormatageIsbn.Afficher(oeuvre.Isbn)</span>
|
|
}
|
|
@if (oeuvre.NombreEditions > 1)
|
|
{
|
|
<span>@oeuvre.NombreEditions éditions recensées</span>
|
|
}
|
|
</p>
|
|
}
|
|
}
|
|
|
|
@if (oeuvre.ADecouvrir)
|
|
{
|
|
<div class="actions-formulaire">
|
|
<label class="selection-oeuvre">
|
|
<input type="checkbox"
|
|
checked="@_selection.Contains(oeuvre.Titre)"
|
|
@onchange="e => Selectionner(oeuvre.Titre, e.Value is true)" />
|
|
Sélectionner
|
|
</label>
|
|
<button type="button" class="bouton bouton-compact bouton-principal"
|
|
disabled="@_enCours" @onclick="() => SouhaiterAsync(oeuvre)"
|
|
aria-label="@($"Ajouter « {oeuvre.Titre} » à ma liste d'envies")">
|
|
Je le veux
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
@if (!oeuvre.Masquee)
|
|
{
|
|
<button type="button" class="bouton bouton-compact bouton-discret"
|
|
disabled="@_enCours" @onclick="() => MasquerAsync(oeuvre)">
|
|
Masquer
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="bouton bouton-compact bouton-discret"
|
|
disabled="@_enCours" @onclick="() => DemasquerAsync(oeuvre)">
|
|
Réafficher
|
|
</button>
|
|
}
|
|
</li>
|
|
}
|
|
</ul>
|
|
|
|
@if (_selection.Count > 0)
|
|
{
|
|
<div class="actions-flottantes">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_enCours || !Reseau.EnLigne)"
|
|
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
|
@onclick="AjouterSelectionAsync">
|
|
Ajouter la sélection aux envies (@_selection.Count)
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
@* Le rapprochement est fait par titre, et il rate des choses (voir CleOeuvre). Le dire
|
|
franchement vaut mieux que laisser l'utilisateur découvrir seul qu'un livre qu'il
|
|
possède n'est pas grisé. *@
|
|
<p class="message-discret">
|
|
Le rapprochement avec votre bibliothèque se fait <strong>par titre</strong>. Un livre
|
|
que vous possédez sous un titre différent (traduction, intégrale, édition retitrée)
|
|
n'apparaîtra donc pas comme possédé. Rien n'est masqué : à vous de trancher.
|
|
</p>
|
|
}
|
|
}
|
|
|
|
@* « Retour aux auteurs » est parti : l'onglet Auteurs fait exactement cela, en permanence.
|
|
Ne reste que ce qu'aucun onglet ne sait faire — le catalogue restreint à cet auteur. *@
|
|
@if (_bibliographie is { } b)
|
|
{
|
|
<nav class="actions-flottantes">
|
|
@if (!b.EstNouveautes)
|
|
{
|
|
<a class="bouton bouton-discret"
|
|
href="@($"auteurs/{b.Auteur.Id}/bibliographie?nouveautes=true")">
|
|
Nouveautés
|
|
</a>
|
|
}
|
|
else
|
|
{
|
|
<a class="bouton bouton-discret" href="@($"auteurs/{b.Auteur.Id}/bibliographie")">
|
|
Toute la bibliographie
|
|
</a>
|
|
}
|
|
<a class="bouton bouton-discret" href="@($"?auteur={b.Auteur.Id}")">Ses livres chez vous</a>
|
|
</nav>
|
|
}
|
|
|
|
@code {
|
|
[Parameter]
|
|
public int AuteurId { get; set; }
|
|
|
|
[SupplyParameterFromQuery(Name = "nouveautes")]
|
|
public bool Nouveautes { get; set; }
|
|
|
|
private BibliographieDto? _bibliographie;
|
|
private int _modePossession;
|
|
private bool _filtresOuverts;
|
|
private bool _afficherMasquees;
|
|
private bool _enCours;
|
|
private bool _chargement;
|
|
|
|
/// <summary>Vrai quand l'échec vient du réseau, donc qu'une nouvelle tentative a un sens.</summary>
|
|
private bool _reessayable;
|
|
|
|
private string? _erreur;
|
|
private string? _message;
|
|
|
|
private int _possedees;
|
|
private int _masquees;
|
|
private IReadOnlyList<LivreDto> _livresCatalogue = [];
|
|
private readonly HashSet<string> _oeuvresOuvertes = [];
|
|
private readonly HashSet<string> _selection = [];
|
|
|
|
protected override async Task OnParametersSetAsync() => await ChargerAsync();
|
|
|
|
private IEnumerable<OeuvreBibliographie> Affichees(BibliographieDto biblio) =>
|
|
biblio.Oeuvres.Where(o =>
|
|
(_afficherMasquees || !o.Masquee)
|
|
&& (_modePossession == 0
|
|
|| (_modePossession == 1 && o.Possede)
|
|
|| (_modePossession == 2 && o.ADecouvrir)));
|
|
|
|
private int NombreFiltres =>
|
|
(_modePossession == 0 ? 0 : 1) + (_masquees > 0 && !_afficherMasquees ? 1 : 0);
|
|
|
|
private async Task ChargerAsync()
|
|
{
|
|
_erreur = null;
|
|
_reessayable = false;
|
|
_chargement = true;
|
|
|
|
try
|
|
{
|
|
_bibliographie = Nouveautes
|
|
? await Api.ObtenirNouveautesAsync(AuteurId)
|
|
: await Api.ObtenirBibliographieAsync(AuteurId);
|
|
_livresCatalogue = await Api.ListerAsync(new CritereLivres());
|
|
|
|
if (_bibliographie is null)
|
|
{
|
|
// Celui-là ne se retente pas : l'auteur n'existe pas, réessayer ne le fera
|
|
// pas apparaître.
|
|
_erreur = "Cet auteur n'existe pas dans votre bibliothèque.";
|
|
return;
|
|
}
|
|
|
|
_possedees = _bibliographie.Oeuvres.Count(o => o.Possede);
|
|
_masquees = _bibliographie.Oeuvres.Count(o => o.Masquee);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// ⚠️ Jamais le message brut de l'exception : hors-ligne, c'est un « 404 Not Found »
|
|
// du service worker que l'utilisateur n'a aucun moyen d'interpréter. La
|
|
// bibliographie interroge la BnF, elle ne peut PAS venir du cache — il n'y a rien
|
|
// à réparer, seulement à dire.
|
|
//
|
|
// Ce chemin-ci est l'échec de l'appel à NOTRE serveur (délai du navigateur compris) ;
|
|
// une BnF muette, elle, revient en 200 avec un état, et se traite plus haut.
|
|
_erreur = Reseau.EnLigne
|
|
? "La bibliographie n'a pas pu être obtenue. La BnF est parfois lente à répondre."
|
|
: "La bibliographie vient de la BnF : indisponible hors ligne.";
|
|
|
|
_reessayable = true;
|
|
}
|
|
finally
|
|
{
|
|
_chargement = false;
|
|
}
|
|
}
|
|
|
|
private void Selectionner(string titre, bool selectionnee)
|
|
{
|
|
if (selectionnee)
|
|
{
|
|
_selection.Add(titre);
|
|
}
|
|
else
|
|
{
|
|
_selection.Remove(titre);
|
|
}
|
|
}
|
|
|
|
private async Task AjouterSelectionAsync()
|
|
{
|
|
if (_bibliographie is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var saisies = _bibliographie.Oeuvres
|
|
.Where(o => _selection.Contains(o.Titre) && o.ADecouvrir)
|
|
.Select(o => new EnregistrementSouhait
|
|
{
|
|
Titre = o.Titre,
|
|
Auteur = _bibliographie.Auteur.Nom,
|
|
Editeur = o.Editeur,
|
|
Annee = o.Annee,
|
|
Isbn = o.Isbn,
|
|
})
|
|
.ToList();
|
|
|
|
_enCours = true;
|
|
_message = null;
|
|
var resultats = await Api.AjouterSouhaitsAsync(saisies);
|
|
_enCours = false;
|
|
var erreurs = resultats.Where(r => !r.EstOk).Select(r => r.Erreur).Where(e => e is not null).ToList();
|
|
_message = erreurs.Count == 0 ? null : string.Join(" ", erreurs);
|
|
_selection.Clear();
|
|
await ChargerAsync();
|
|
}
|
|
|
|
private async Task MasquerAsync(OeuvreBibliographie oeuvre)
|
|
{
|
|
await ModifierMasquageAsync(oeuvre, false);
|
|
}
|
|
|
|
private async Task DemasquerAsync(OeuvreBibliographie oeuvre)
|
|
{
|
|
await ModifierMasquageAsync(oeuvre, true);
|
|
}
|
|
|
|
private async Task ModifierMasquageAsync(OeuvreBibliographie oeuvre, bool demasquer)
|
|
{
|
|
_enCours = true;
|
|
_message = demasquer
|
|
? await Api.DemasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre)
|
|
: await Api.MasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre);
|
|
_enCours = false;
|
|
if (_message is null)
|
|
{
|
|
await ChargerAsync();
|
|
}
|
|
}
|
|
|
|
private void BasculerOeuvre(string titre)
|
|
{
|
|
if (!_oeuvresOuvertes.Add(titre))
|
|
{
|
|
_oeuvresOuvertes.Remove(titre);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ajoute une œuvre à la liste d'envies, en emportant ce que la BnF en sait.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// L'auteur enregistré est celui de l'écran, pas celui de la notice : c'est lui que
|
|
/// l'utilisateur a en tête, et c'est sous ce nom qu'il retrouvera l'envie dans l'export.
|
|
/// <para>
|
|
/// Après l'ajout, la bibliographie est <b>relue</b> plutôt que corrigée en mémoire : c'est
|
|
/// le serveur qui sait rapprocher l'envie de l'œuvre, et refaire ce calcul ici le
|
|
/// dupliquerait — avec le risque qu'il diverge.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task SouhaiterAsync(OeuvreBibliographie oeuvre)
|
|
{
|
|
if (_bibliographie is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_enCours = true;
|
|
_message = null;
|
|
|
|
var resultat = await Api.AjouterSouhaitAsync(new EnregistrementSouhait
|
|
{
|
|
Titre = oeuvre.Titre,
|
|
Auteur = _bibliographie.Auteur.Nom,
|
|
Editeur = oeuvre.Editeur,
|
|
Annee = oeuvre.Annee,
|
|
|
|
// L'ISBN de la BnF est celui d'UNE édition relevée, souvent à 10 chiffres et parfois
|
|
// épuisée. Il est repris tel quel : il aide à commander, sans engager le choix.
|
|
Isbn = oeuvre.Isbn,
|
|
});
|
|
|
|
_enCours = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_message = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
await ChargerAsync();
|
|
}
|
|
}
|