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>
347 lines
13 KiB
Plaintext
347 lines
13 KiB
Plaintext
@page "/auteurs"
|
|
@inject ServiceLivresApi Api
|
|
@inject EtatReseau Reseau
|
|
@implements IDisposable
|
|
|
|
<PageTitle>MaBibli — auteurs</PageTitle>
|
|
|
|
<h1 class="titre-page">Auteurs</h1>
|
|
|
|
@if (_erreur is not null)
|
|
{
|
|
<p class="message-erreur" role="alert">@_erreur</p>
|
|
}
|
|
|
|
@if (_suggestions is { Count: > 0 })
|
|
{
|
|
@*
|
|
Ce qui arrive ici est ce que la machine REFUSE de trancher. Les variantes sûres — casse,
|
|
accents, ordre « Nom, Prénom », initiales abrégées — ont déjà été réunies à l'écriture et
|
|
n'apparaissent jamais dans cette liste. Restent les cas ambigus, typiquement un patronyme
|
|
seul face à un nom complet : deux personnes d'une même famille écrivent aussi des livres.
|
|
*@
|
|
<section class="rapprochements">
|
|
<h2 class="titre-section">Rapprochements possibles</h2>
|
|
<p class="message-discret">
|
|
Ces noms se ressemblent sans qu'on puisse l'affirmer. Un patronyme seul peut désigner
|
|
une autre personne de la même famille — à vous de trancher. Une fusion se défait mal.
|
|
</p>
|
|
|
|
<ul class="liste-rapprochements">
|
|
@foreach (var suggestion in _suggestions)
|
|
{
|
|
<li class="carte-rapprochement" @key="(suggestion.Source.Id, suggestion.Cible.Id)">
|
|
<p class="rapprochement-noms">
|
|
<strong>@suggestion.Source.Nom</strong>
|
|
(@Livres(suggestion.Source.NombreDeLivres))
|
|
est-il <strong>@suggestion.Cible.Nom</strong>
|
|
(@Livres(suggestion.Cible.NombreDeLivres)) ?
|
|
</p>
|
|
|
|
<div class="actions-formulaire">
|
|
@* Une fusion est une écriture : elle demande le réseau, comme le reste. *@
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
@onclick="() => AccepterAsync(suggestion)">
|
|
Oui, réunir sous « @suggestion.Cible.Nom »
|
|
</button>
|
|
<button type="button" class="bouton bouton-discret"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
@onclick="() => RefuserAsync(suggestion)">
|
|
Non, deux personnes
|
|
</button>
|
|
</div>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
|
|
@if (_auteurs is null)
|
|
{
|
|
<p class="message-discret">Chargement…</p>
|
|
}
|
|
else if (_auteurs.Count == 0)
|
|
{
|
|
<p class="message-discret">Aucun auteur : la bibliothèque est vide.</p>
|
|
}
|
|
else
|
|
{
|
|
@*
|
|
Recherche et tri d'une seule pièce : la question qu'on se pose devant une liste
|
|
d'auteurs est soit « où est untel ? » (recherche), soit « qui ai-je le plus ? » (tri).
|
|
|
|
⚠️ Tout se fait en mémoire, sans nouvel appel : la liste des auteurs est déjà
|
|
entièrement chargée — c'est elle qui sert l'instantané hors-ligne — et la filtrer
|
|
côté serveur aurait rendu la page inutilisable sans réseau. La normalisation passe
|
|
par NormalisationTexte, comme partout : « emile » retrouve « Émile ».
|
|
*@
|
|
<div class="barre-recherche">
|
|
<input class="champ-saisie" type="search" placeholder="Nom d'auteur…"
|
|
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un auteur" />
|
|
</div>
|
|
|
|
<div class="segments" role="group" aria-label="Tri">
|
|
<button type="button" class="segment @(_tri == TriAuteurs.Nom ? "segment-actif" : null)"
|
|
@onclick="() => _tri = TriAuteurs.Nom">Par nom</button>
|
|
<button type="button" class="segment @(_tri == TriAuteurs.NombreDeLivres ? "segment-actif" : null)"
|
|
@onclick="() => _tri = TriAuteurs.NombreDeLivres">Les plus présents</button>
|
|
</div>
|
|
|
|
@* Le compteur dit ce qu'on regarde : sur une liste filtrée, afficher le total ferait
|
|
croire que la recherche n'a rien fait. *@
|
|
<p class="compteur">
|
|
@Affiches.Count auteur@(Affiches.Count > 1 ? "s" : "")
|
|
@if (Affiches.Count != _auteurs.Count)
|
|
{
|
|
<text> sur @_auteurs.Count</text>
|
|
}
|
|
</p>
|
|
|
|
@if (Affiches.Count == 0)
|
|
{
|
|
<p class="message-discret">Aucun auteur ne correspond à « @_recherche ».</p>
|
|
}
|
|
|
|
<ul class="liste-auteurs">
|
|
@foreach (var auteur in Affiches)
|
|
{
|
|
@*
|
|
Trois colonnes, dont une seule est élastique.
|
|
|
|
Le nombre de livres passe à GAUCHE du nom, sur une largeur figée de trois
|
|
chiffres : les compteurs s'alignent alors les uns sous les autres et se
|
|
comparent d'un coup d'œil. Trois chiffres suffisent — personne ne possède
|
|
mille livres d'un même auteur. Les actions se rangent tout à droite, elles
|
|
aussi sur une largeur constante, pour que seul le nom absorbe la place
|
|
restante au lieu de décaler les boutons ligne après ligne.
|
|
*@
|
|
<li class="carte-auteur-ligne" @key="auteur.Id">
|
|
<span class="auteur-compte" title="@Livres(auteur.NombreDeLivres)">
|
|
@auteur.NombreDeLivres
|
|
<span class="lecture-ecran">@Livres(auteur.NombreDeLivres)</span>
|
|
</span>
|
|
|
|
<a class="lien-auteur auteur-nom" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
|
|
|
|
<span class="auteur-actions">
|
|
@* Le declencheur decrit dans CLAUDE.md : depuis un auteur qu'on a deja, voir
|
|
tout ce qu'il a ecrit et marquer ce qui manque. *@
|
|
<a class="bouton bouton-compact bouton-discret"
|
|
href="@($"auteurs/{auteur.Id}/bibliographie")">Bibliographie</a>
|
|
@if (_auteurRenomme == auteur.Id)
|
|
{
|
|
<input class="champ-saisie champ-auteur-renomme" @bind="_nomRenomme" />
|
|
<button type="button" class="bouton bouton-compact bouton-principal"
|
|
disabled="@_renommageEnCours" @onclick="RenommerAsync">
|
|
Enregistrer
|
|
</button>
|
|
<button type="button" class="bouton bouton-compact bouton-discret"
|
|
disabled="@_renommageEnCours" @onclick="AnnulerRenommage">
|
|
Annuler
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="bouton bouton-compact bouton-discret"
|
|
disabled="@(_enCours || !Reseau.EnLigne)"
|
|
title="@MotifBlocage"
|
|
@onclick="() => CommencerRenommage(auteur)">
|
|
Renommer
|
|
</button>
|
|
}
|
|
</span>
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
|
|
@if (_collisionRenommage is not null)
|
|
{
|
|
<section class="message-avertissement" role="alert">
|
|
<p>
|
|
Un auteur existe déjà sous le nom « @_collisionRenommage.Nom ».
|
|
Les livres seront regroupés sous cette fiche.
|
|
Confirmer la fusion ?
|
|
</p>
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@_renommageEnCours" @onclick="ConfirmerFusionAsync">
|
|
Oui, fusionner
|
|
</button>
|
|
<button type="button" class="bouton bouton-discret"
|
|
disabled="@_renommageEnCours" @onclick="AnnulerRenommage">
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
@code {
|
|
/// <summary>Les deux façons de trier une liste d'auteurs, et il n'y en a pas de troisième.</summary>
|
|
private enum TriAuteurs
|
|
{
|
|
/// <summary>Ordre alphabétique : « où est untel ? ».</summary>
|
|
Nom = 0,
|
|
|
|
/// <summary>Du plus présent au moins présent : « qui ai-je le plus ? ».</summary>
|
|
NombreDeLivres = 1,
|
|
}
|
|
|
|
private IReadOnlyList<AuteurDto>? _auteurs;
|
|
private string _recherche = string.Empty;
|
|
private TriAuteurs _tri = TriAuteurs.Nom;
|
|
private IReadOnlyList<SuggestionRapprochement>? _suggestions;
|
|
private bool _enCours;
|
|
private bool _renommageEnCours;
|
|
private int? _auteurRenomme;
|
|
private string _nomRenomme = string.Empty;
|
|
private AuteurDto? _collisionRenommage;
|
|
private string? _erreur;
|
|
|
|
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
|
|
|
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
|
{
|
|
await ChargerAsync();
|
|
StateHasChanged();
|
|
});
|
|
|
|
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
|
|
|
/// <summary>Raison du blocage des fusions, ou <c>null</c> en ligne.</summary>
|
|
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
|
|
|
protected override Task OnInitializedAsync() => ChargerAsync();
|
|
|
|
private static string Livres(int nombre) => $"{nombre} livre{(nombre > 1 ? "s" : "")}";
|
|
|
|
private void SurRecherche(ChangeEventArgs e) => _recherche = e.Value?.ToString() ?? string.Empty;
|
|
|
|
/// <summary>
|
|
/// La liste telle qu'elle s'affiche : filtrée par la recherche, puis triée.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// La recherche est une <b>sous-chaîne du nom normalisé</b>, comme celle du catalogue :
|
|
/// « zola » retrouve « Émile Zola », et « emile » aussi. Elle ne cherche pas mot à mot,
|
|
/// contrairement au rapprochement d'auteurs, qui répond à une autre question — « ces deux
|
|
/// noms désignent-ils la même personne ? » — et n'a rien à faire ici.
|
|
/// <para>
|
|
/// Le tri par nom est <b>ordinal sur la forme normalisée</b>, pour ne pas dépendre de la
|
|
/// culture du navigateur : c'est la même règle que <c>FiltreLivresLocal</c>, et elle évite
|
|
/// qu'un appareil classe « Éluard » avant « Duras » et un autre après.
|
|
/// </para>
|
|
/// </remarks>
|
|
private IReadOnlyList<AuteurDto> Affiches
|
|
{
|
|
get
|
|
{
|
|
if (_auteurs is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var terme = NormalisationTexte.Normaliser(_recherche);
|
|
var retenus = string.IsNullOrEmpty(terme)
|
|
? _auteurs
|
|
: _auteurs.Where(a => NormalisationTexte.Normaliser(a.Nom).Contains(terme)).ToList();
|
|
|
|
return _tri == TriAuteurs.NombreDeLivres
|
|
? retenus
|
|
.OrderByDescending(a => a.NombreDeLivres)
|
|
.ThenBy(a => NormalisationTexte.Normaliser(a.Nom), StringComparer.Ordinal)
|
|
.ToList()
|
|
: retenus
|
|
.OrderBy(a => NormalisationTexte.Normaliser(a.Nom), StringComparer.Ordinal)
|
|
.ToList();
|
|
}
|
|
}
|
|
|
|
private async Task ChargerAsync()
|
|
{
|
|
_erreur = null;
|
|
|
|
try
|
|
{
|
|
_auteurs = await Api.ListerAuteursAsync();
|
|
_suggestions = await Api.ListerRapprochementsAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_erreur = $"Impossible de charger les auteurs : {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private Task AccepterAsync(SuggestionRapprochement suggestion) =>
|
|
DeciderAsync(() => Api.AccepterRapprochementAsync(suggestion.Source.Id, suggestion.Cible.Id));
|
|
|
|
private Task RefuserAsync(SuggestionRapprochement suggestion) =>
|
|
DeciderAsync(() => Api.RefuserRapprochementAsync(suggestion.Source.Id, suggestion.Cible.Id));
|
|
|
|
private void CommencerRenommage(AuteurDto auteur)
|
|
{
|
|
_auteurRenomme = auteur.Id;
|
|
_nomRenomme = auteur.Nom;
|
|
_collisionRenommage = null;
|
|
_erreur = null;
|
|
}
|
|
|
|
private void AnnulerRenommage()
|
|
{
|
|
_auteurRenomme = null;
|
|
_collisionRenommage = null;
|
|
_nomRenomme = string.Empty;
|
|
}
|
|
|
|
private Task RenommerAsync() => EnvoyerRenommageAsync(false);
|
|
|
|
private Task ConfirmerFusionAsync() => EnvoyerRenommageAsync(true);
|
|
|
|
private async Task EnvoyerRenommageAsync(bool confirmerFusion)
|
|
{
|
|
if (_auteurRenomme is null || string.IsNullOrWhiteSpace(_nomRenomme))
|
|
{
|
|
_erreur = "Le nom de l'auteur est obligatoire.";
|
|
return;
|
|
}
|
|
|
|
_renommageEnCours = true;
|
|
_erreur = null;
|
|
var resultat = await Api.RenommerAuteurAsync(
|
|
_auteurRenomme.Value, _nomRenomme, confirmerFusion);
|
|
_renommageEnCours = false;
|
|
|
|
if (resultat is null)
|
|
{
|
|
_erreur = "Le renommage n'a pas pu être enregistré.";
|
|
return;
|
|
}
|
|
|
|
if (resultat.Collision is not null)
|
|
{
|
|
_collisionRenommage = resultat.Collision;
|
|
return;
|
|
}
|
|
|
|
AnnulerRenommage();
|
|
await ChargerAsync();
|
|
}
|
|
|
|
private async Task DeciderAsync(Func<Task<string?>> action)
|
|
{
|
|
_enCours = true;
|
|
var erreur = await action();
|
|
_enCours = false;
|
|
|
|
if (erreur is not null)
|
|
{
|
|
_erreur = erreur;
|
|
return;
|
|
}
|
|
|
|
// Une fusion peut en rendre d'autres caduques (l'un des deux noms disparaît) : on relit
|
|
// la liste complète plutôt que de retirer la ligne traitée à la main.
|
|
await ChargerAsync();
|
|
}
|
|
}
|