Ajouter la liste d'envies personnelle, son export et la bibliographie par auteur

La liste d'envies vit dans une table séparée (LivreSouhaite) plutôt que dans un
statut de plus sur Livre : un livre souhaité n'est pas possédé, et le loger dans
Livres l'aurait fait entrer dans le catalogue, les compteurs et les prêts, au
prix d'un « et qui n'est pas souhaité » à répéter dans chaque lecture. La portée
est personnelle, comme le statut de lecture — mais ici Utilisateur est une vraie
frontière : toute lecture filtre dessus.

Bibliographie : SRU BnF interrogé par bib.author, vérifié le 2026-08-18. Deux
filtres mesurés sur des réponses réelles sont indispensables — le type de
document (l'index mêle livres audio, jeux et spectacles) et surtout l'auteur
réel de la notice, « all » rapprochant les mots sur l'ensemble des auteurs :
« Émile Zola » remonte sinon toute l'œuvre de sa fille Denise Le Blond-Zola.
Les rééditions sont regroupées par clé d'œuvre (183 notices Werber -> 51
œuvres). Le rapprochement avec l'étagère se fait par titre, pas par ISBN, qui
désigne une édition et non une œuvre ; ses limites sont dites à l'écran.

Export en deux formats, tous deux du texte sans dépendance : .txt groupé par
auteur pour la librairie, .csv à séparateur point-virgule et BOM pour le
tableur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-18 13:59:06 +02:00
co-authored by Claude Opus 5
parent 3a5a3a7763
commit 8b8fe3be6e
27 changed files with 3939 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
@page "/souhaits"
@inject ServiceLivresApi Api
<PageTitle>MaBibli — liste d'envies</PageTitle>
<h1 class="titre-page">Liste d'envies</h1>
@* Le rappel n'est pas décoratif : la bibliothèque est commune, cette liste ne l'est pas.
Sans ce mot, on pourrait croire que le foyer entier voit ce qu'on note ici. *@
<p class="message-discret">
Les livres que vous aimeriez acquérir. <strong>Cette liste est la vôtre</strong> :
les autres membres du foyer ont la leur, et ne voient pas celle-ci.
Ces livres ne sont pas dans le catalogue et ne comptent pas dans la bibliothèque.
</p>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@if (_souhaits is null)
{
<p class="message-discret">Chargement…</p>
}
else
{
<section class="bloc-souhait-ajout">
<h2 class="titre-section">Ajouter une envie</h2>
@* Deux façons d'alimenter la liste : l'ISBN quand on a le livre en main (en librairie,
chez quelqu'un), la saisie libre quand on n'a qu'un titre. La bibliographie d'un
auteur, elle, alimente la liste depuis l'écran des auteurs. *@
<div class="champ">
<label class="champ-libelle" for="isbn-souhait">Pré-remplir depuis un ISBN</label>
<div class="ligne-isbn">
<input id="isbn-souhait" class="champ-saisie" type="text" inputmode="numeric"
placeholder="978…" @bind="_isbn" @bind:event="oninput" />
<button type="button" class="bouton bouton-discret"
disabled="@(_recherche || string.IsNullOrWhiteSpace(_isbn))"
@onclick="ChercherIsbnAsync">
@(_recherche ? "Recherche…" : "Chercher")
</button>
</div>
<p class="champ-aide">Facultatif : les champs ci-dessous restent modifiables.</p>
</div>
<div class="champ">
<label class="champ-libelle" for="titre-souhait">Titre <span class="obligatoire">*</span></label>
<input id="titre-souhait" class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="auteur-souhait">Auteur</label>
<input id="auteur-souhait" class="champ-saisie" type="text" @bind="_saisie.Auteur" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="note-souhait">Note</label>
<input id="note-souhait" class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
placeholder="« demandé à Noël », « en poche seulement »…" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_ajout || string.IsNullOrWhiteSpace(_saisie.Titre))"
@onclick="AjouterAsync">
Ajouter à ma liste
</button>
</div>
</section>
@if (_souhaits.Count == 0)
{
<p class="message-discret">
Votre liste est vide. Ajoutez un livre ci-dessus, ou ouvrez la bibliographie d'un
auteur depuis <a href="auteurs">la liste des auteurs</a>.
</p>
}
else
{
<p class="compteur">@_souhaits.Count livre@(_souhaits.Count > 1 ? "s" : "") souhaité@(_souhaits.Count > 1 ? "s" : "")</p>
@* Un seul bloc d'export, en haut de liste : c'est l'action qu'on vient chercher quand on
part en librairie, elle ne doit pas être au bout d'un défilement. *@
<div class="actions-export">
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Texte)" download>
Exporter en texte
</a>
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Csv)" download>
Exporter en CSV
</a>
</div>
<ul class="liste-souhaits">
@foreach (var souhait in _souhaits)
{
<li class="carte-souhait" @key="souhait.Id">
<Couverture Url="@souhait.CoverUrl" Titre="@souhait.Titre"
Classe="couverture-petite" Agrandissable="true" />
<div class="carte-corps">
<p class="carte-titre">@souhait.Titre</p>
@if (!string.IsNullOrWhiteSpace(souhait.Auteur))
{
<p class="carte-auteur">@souhait.Auteur</p>
}
<p class="carte-details">
@if (!string.IsNullOrWhiteSpace(souhait.Editeur))
{
<span>@souhait.Editeur</span>
}
@if (!string.IsNullOrWhiteSpace(souhait.Annee))
{
<span>@souhait.Annee</span>
}
@if (!string.IsNullOrWhiteSpace(souhait.Isbn))
{
<span class="etiquette">ISBN @souhait.Isbn</span>
}
</p>
@if (!string.IsNullOrWhiteSpace(souhait.Note))
{
<p class="souhait-note">@souhait.Note</p>
}
</div>
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@_enCours" @onclick="() => SupprimerAsync(souhait)"
aria-label="@($"Retirer « {souhait.Titre} » de la liste d'envies")">
Retirer
</button>
</li>
}
</ul>
}
}
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
<a class="bouton bouton-discret" href="auteurs">Auteurs</a>
</nav>
@code {
private IReadOnlyList<SouhaitDto>? _souhaits;
private EnregistrementSouhait _saisie = new();
private string _isbn = string.Empty;
private bool _ajout;
private bool _recherche;
private bool _enCours;
private string? _erreur;
protected override Task OnInitializedAsync() => ChargerAsync();
private async Task ChargerAsync()
{
try
{
_souhaits = await Api.ListerSouhaitsAsync();
}
catch (Exception ex)
{
_erreur = $"Impossible de charger la liste d'envies : {ex.Message}";
}
}
/// <summary>
/// Pré-remplit le formulaire depuis la cascade ISBN existante (BnF → OpenLibrary).
/// </summary>
/// <remarks>
/// On retient le premier candidat sans demander de choisir : contrairement à l'ajout au
/// catalogue, une envie ne fige pas d'édition — l'utilisateur achètera celle qu'il trouvera.
/// Tous les champs restent modifiables.
/// </remarks>
private async Task ChercherIsbnAsync()
{
_recherche = true;
_erreur = null;
try
{
var resultat = await Api.ChercherIsbnAsync(_isbn.Trim());
var candidat = resultat?.Candidats.FirstOrDefault();
if (candidat is null)
{
_erreur = $"Aucune notice trouvée pour « {_isbn.Trim()} ». Complétez à la main.";
return;
}
_saisie.Titre = candidat.Titre;
_saisie.Auteur = string.Join(", ", candidat.Auteurs);
_saisie.Editeur = candidat.Editeur;
_saisie.Annee = candidat.Annee;
_saisie.Isbn = _isbn.Trim();
_saisie.CoverUrl = candidat.CoverUrl;
}
catch (Exception ex)
{
_erreur = $"La recherche a échoué : {ex.Message}";
}
finally
{
_recherche = false;
}
}
private async Task AjouterAsync()
{
_ajout = true;
_erreur = null;
var resultat = await Api.AjouterSouhaitAsync(_saisie);
_ajout = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
_saisie = new EnregistrementSouhait();
_isbn = string.Empty;
await ChargerAsync();
}
private async Task SupprimerAsync(SouhaitDto souhait)
{
_enCours = true;
_erreur = await Api.SupprimerSouhaitAsync(souhait.Id);
_enCours = false;
if (_erreur is null)
{
await ChargerAsync();
}
}
}