Deux changements de modele en une seule migration, plus la recherche qui en depend. Le statut de lecture devient personnel. Il etait une colonne de Livre, donc partage par tout le foyer, alors que deux membres lisent le meme exemplaire a des rythmes differents. Il vit desormais dans une table (LivreId, Utilisateur, Statut) avec unicite sur le couple. L'absence de ligne vaut « non commence » : sur une bibliotheque de foyer la plupart des couples n'ont aucun statut, et les materialiser tous multiplierait les lignes par le nombre de comptes pour n'exprimer qu'un vide. Rien n'est donc ecrit a la creation d'un livre. Piege rencontre : un Dictionary<int, Statut> renvoyait la valeur 0 de l'enumeration — « À lire » — pour un livre sans ligne, rendant « non commence » indiscernable d'un choix explicite. Le dictionnaire est desormais typé Statut?. L'auteur devient une table. Deux formes normalisees y cohabitent, et ce n'est pas une redondance : NomNormalise garde l'ordre de saisie pour la recherche en sous-chaine, CleRegroupement trie les mots et porte l'index unique, donc l'invariant « un auteur, une fiche ». Les initiales echappent a la cle et sont traitees en memoire, sur une table qui compte au plus quelques centaines de lignes. Un livre peut avoir plusieurs auteurs — le lookup ISBN en renvoie quatre pour Introduction to Algorithms — d'ou la table de liaison, avec une position qui conserve l'ordre de la couverture. Les rapprochements ambigus ne sont jamais appliques seuls : l'API les liste, l'utilisateur accepte ou refuse, et les refus sont memorises pour que la suggestion ne revienne pas. Le couple refuse est range par identifiant croissant, donc un refus vaut dans les deux sens. Reprise des donnees existantes. L'ancien statut, commun, est rattache a AjoutePar — seule personne que la base associe au livre. Les statuts des livres sans AjoutePar sont perdus : les attribuer serait une invention. Les trois valeurs sont reprises telles quelles, « À lire » compris, parce que c'est ce que l'ancienne interface affichait. L'ancien champ auteur devient une fiche par valeur distincte. La migration ne peut pas tout faire : lower() de SQLite ne retire pas les accents, donc « Émile Zola » et « emile zola » y restent deux fiches. ServiceRenormalisation finit le travail en C# au demarrage, reunit ces variantes, applique aussi la regle des initiales — sans quoi une base heritee resterait eclatee la ou une saisie neuve aurait ete reunie d'emblee — et garde le nom d'affichage le plus presentable. Il est idempotent, et sert de filet si les regles de normalisation changent. L'ordre de la migration compte : les colonnes condamnees sont recopiees dans une table de transit avant d'etre supprimees, parce que supprimer une colonne sous SQLite reconstruit la table. Verifie sur une base a l'ancien schema contenant 9 livres, 2 prets et trois variantes de Zola : prets intacts, statuts rattaches, les trois Zola reunis sous « Émile Zola », « P.F. Hamilton » absorbe par « Peter F. Hamilton », « Hamilton » seul laisse en suggestion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
236 lines
7.5 KiB
Plaintext
236 lines
7.5 KiB
Plaintext
@page "/"
|
|
@inject ServiceLivresApi Api
|
|
@implements IDisposable
|
|
|
|
<PageTitle>MaBibli — catalogue</PageTitle>
|
|
|
|
<h1 class="titre-page">Catalogue</h1>
|
|
|
|
@if (_auteur is not null)
|
|
{
|
|
@* Le « cliquer sur un auteur » d'IDEES.md : le catalogue est ici restreint à ses livres. *@
|
|
<p class="message-discret">
|
|
Livres de <strong>@_auteur.Nom</strong>.
|
|
<a href="">Revenir à toute la bibliothèque</a>
|
|
</p>
|
|
}
|
|
|
|
<div class="barre-recherche">
|
|
<input class="champ-saisie" type="search" placeholder="Titre ou auteur…"
|
|
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
|
|
</div>
|
|
|
|
<div class="filtres">
|
|
<div class="segments" role="group" aria-label="Filtrer par format">
|
|
<button type="button" class="segment @(_format is null ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerFormat(null)">Tous</button>
|
|
@foreach (var format in Enum.GetValues<Format>())
|
|
{
|
|
<button type="button" class="segment @(_format == format ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerFormat(format)">@Libelles.Format(format)</button>
|
|
}
|
|
</div>
|
|
|
|
@* Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut pour moi
|
|
ne remonte sous aucune valeur, il reste visible sous « Tous ». *@
|
|
<div class="segments" role="group" aria-label="Filtrer par mon statut de lecture">
|
|
<button type="button" class="segment @(_statut is null ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerStatut(null)">Tous</button>
|
|
@foreach (var statut in Enum.GetValues<Statut>())
|
|
{
|
|
<button type="button" class="segment @(_statut == statut ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerStatut(statut)">@Libelles.Statut(statut)</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@if (_erreur is not null)
|
|
{
|
|
<p class="message-erreur" role="alert">@_erreur</p>
|
|
}
|
|
|
|
@if (_chargement && _livres is null)
|
|
{
|
|
<p class="message-discret">Chargement…</p>
|
|
}
|
|
else if (_livres is { Count: 0 })
|
|
{
|
|
<p class="message-discret">
|
|
@(EstFiltre ? "Aucun livre ne correspond à cette recherche." : "La bibliothèque est vide. Ajoutez un premier livre.")
|
|
</p>
|
|
}
|
|
else if (_livres is not null)
|
|
{
|
|
<p class="compteur">@_livres.Count livre@(_livres.Count > 1 ? "s" : "")</p>
|
|
|
|
<ul class="liste-livres">
|
|
@foreach (var livre in _livres)
|
|
{
|
|
<li class="carte-livre" @key="livre.Id">
|
|
<Couverture Url="@livre.CoverUrl" Titre="@livre.Titre" Classe="couverture-petite" />
|
|
|
|
<div class="carte-corps">
|
|
<a class="carte-titre" href="@($"livres/{livre.Id}")">@livre.Titre</a>
|
|
|
|
@if (livre.Auteurs.Count > 0)
|
|
{
|
|
<p class="carte-auteur">
|
|
@foreach (var (auteur, index) in livre.Auteurs.Select((a, i) => (a, i)))
|
|
{
|
|
@(index > 0 ? ", " : "")
|
|
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
|
|
}
|
|
</p>
|
|
}
|
|
|
|
<p class="carte-details">
|
|
<span class="etiquette">@Libelles.Format(livre.Format)</span>
|
|
@if (!string.IsNullOrWhiteSpace(livre.Editeur))
|
|
{
|
|
<span>@livre.Editeur</span>
|
|
}
|
|
</p>
|
|
|
|
@* Changement de statut : action la plus fréquente, donc directement dans la liste. *@
|
|
<ChoixStatut Valeur="livre.Statut"
|
|
ValeurChanged="s => ChangerStatut(livre, s)"
|
|
Desactive="_statutEnCours == livre.Id"
|
|
Compact="true" />
|
|
</div>
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
|
|
<nav class="actions-flottantes">
|
|
<a class="bouton bouton-principal" href="ajout/isbn">Ajouter par ISBN</a>
|
|
<a class="bouton bouton-discret" href="ajout/manuel">Saisie manuelle</a>
|
|
<a class="bouton bouton-discret" href="auteurs">Auteurs</a>
|
|
</nav>
|
|
|
|
@code {
|
|
/// <summary>Restreint le catalogue à un auteur. Vient de « ?auteur=… », posé par les liens de la liste.</summary>
|
|
[SupplyParameterFromQuery(Name = "auteur")]
|
|
public int? AuteurId { get; set; }
|
|
|
|
private IReadOnlyList<LivreDto>? _livres;
|
|
private AuteurDto? _auteur;
|
|
private int? _auteurCharge;
|
|
private string _recherche = string.Empty;
|
|
private Format? _format;
|
|
private Statut? _statut;
|
|
private bool _chargement;
|
|
private string? _erreur;
|
|
private int? _statutEnCours;
|
|
|
|
// Une frappe au clavier annule la recherche précédente encore en vol.
|
|
private CancellationTokenSource _cts = new();
|
|
|
|
private bool EstFiltre =>
|
|
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _statut is not null
|
|
|| AuteurId is not null;
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
if (_auteurCharge != AuteurId)
|
|
{
|
|
_auteurCharge = AuteurId;
|
|
_auteur = AuteurId is { } id ? await Api.ObtenirAuteurAsync(id) : null;
|
|
}
|
|
|
|
await ChargerAsync();
|
|
}
|
|
|
|
private async Task SurRecherche(ChangeEventArgs e)
|
|
{
|
|
_recherche = e.Value?.ToString() ?? string.Empty;
|
|
await ChargerAsync();
|
|
}
|
|
|
|
private Task FiltrerFormat(Format? format)
|
|
{
|
|
_format = format;
|
|
return ChargerAsync();
|
|
}
|
|
|
|
private Task FiltrerStatut(Statut? statut)
|
|
{
|
|
_statut = statut;
|
|
return ChargerAsync();
|
|
}
|
|
|
|
private async Task ChargerAsync()
|
|
{
|
|
_cts.Cancel();
|
|
_cts.Dispose();
|
|
_cts = new CancellationTokenSource();
|
|
var jeton = _cts.Token;
|
|
|
|
_chargement = true;
|
|
_erreur = null;
|
|
|
|
try
|
|
{
|
|
var criteres = new CritereLivres
|
|
{
|
|
Recherche = _recherche,
|
|
Format = _format,
|
|
Statut = _statut,
|
|
AuteurId = AuteurId,
|
|
};
|
|
|
|
_livres = await Api.ListerAsync(criteres, jeton);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return; // Une recherche plus récente a pris le relais.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_erreur = $"Impossible de charger le catalogue : {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
if (!jeton.IsCancellationRequested)
|
|
{
|
|
_chargement = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ChangerStatut(LivreDto livre, Statut? statut)
|
|
{
|
|
if (livre.Statut == statut)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_statutEnCours = livre.Id;
|
|
var resultat = await Api.ChangerStatutAsync(livre.Id, statut);
|
|
_statutEnCours = null;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreur = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
if (_statut is not null)
|
|
{
|
|
// Un filtre par statut est actif : le livre vient peut-être de sortir de la sélection,
|
|
// il faut donc recharger plutôt que corriger la liste en place.
|
|
await ChargerAsync();
|
|
return;
|
|
}
|
|
|
|
// Remplacement local : évite de recharger toute la liste pour un seul champ.
|
|
_livres = _livres!.Select(l => l.Id == livre.Id ? resultat.Valeur! : l).ToList();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_cts.Cancel();
|
|
_cts.Dispose();
|
|
}
|
|
}
|