Format disait physique ou numérique, jamais roman ou BD. Trois valeurs seulement : une valeur s'ajoute sans migration, la renommer une fois posée sur des centaines de fiches, non. Le défaut est « non précisé », pas « roman » : les fiches existantes n'ont pas été saisies avec cette question en tête, et un AddColumn suffit justement parce que la valeur par défaut ne prétend rien. L'interface n'affiche donc que ce qui a été choisi, comme pour le format. Filtre ajouté aux DEUX implémentations, serveur et hors-ligne, avec quatre jeux de critères de plus dans le test qui les confronte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
364 lines
14 KiB
Plaintext
364 lines
14 KiB
Plaintext
@page "/"
|
|
@inject ServiceLivresApi Api
|
|
@inject EtatReseau Reseau
|
|
@implements IDisposable
|
|
|
|
<PageTitle>MaBibli — catalogue</PageTitle>
|
|
|
|
@*
|
|
Les filtres tiennent derrière un seul bouton, près du titre : deux rangées de segments
|
|
occupaient en permanence le haut de l'écran d'un téléphone pour un réglage qu'on change
|
|
rarement. Le compteur sur le bouton est ce qui rend le repli sans danger — un filtre actif
|
|
et replié serait invisible, et l'on conclurait que des livres ont disparu.
|
|
*@
|
|
<div class="entete-page">
|
|
<h1 class="titre-page">Catalogue</h1>
|
|
|
|
<button type="button" class="bouton bouton-discret bouton-compact"
|
|
aria-expanded="@_filtresOuverts" aria-controls="filtres-catalogue"
|
|
@onclick="() => _filtresOuverts = !_filtresOuverts">
|
|
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
|
|
</button>
|
|
</div>
|
|
|
|
@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>
|
|
|
|
@if (_filtresOuverts)
|
|
{
|
|
<div class="filtres" id="filtres-catalogue">
|
|
@* Un fonds tout physique n'a rien à filtrer par format : la ligne n'apprendrait rien
|
|
et prendrait de la place. Cohérent avec « le format ne s'affiche que pour les
|
|
ebooks ». Elle réapparaît d'elle-même au premier ebook ajouté. *@
|
|
@if (PlusieursFormats)
|
|
{
|
|
<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>
|
|
}
|
|
|
|
@* Même logique que pour le format : tant qu'aucun type n'a été renseigné, la ligne
|
|
n'aurait qu'un seul choix utile. Elle apparaît dès la première fiche typée.
|
|
⚠️ « Non précisé » y figure comme valeur à part entière : « qu'est-ce que je n'ai
|
|
pas encore rangé ? » est justement la question qui se pose après coup. *@
|
|
@if (PlusieursTypes)
|
|
{
|
|
<div class="segments" role="group" aria-label="Filtrer par type de document">
|
|
<button type="button" class="segment @(_type is null ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerType(null)">Tous</button>
|
|
@foreach (var type in Enum.GetValues<TypeDocument>())
|
|
{
|
|
<button type="button" class="segment @(_type == type ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerType(type)">@Libelles.TypeDocument(type)</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 ». L'écran
|
|
l'assume au lieu de laisser croire à une vue commune. *@
|
|
<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>
|
|
|
|
<p class="champ-aide">
|
|
Le statut de lecture est <strong>le vôtre</strong> : filtrer dessus montre votre
|
|
lecture, pas celle du foyer.
|
|
</p>
|
|
</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">
|
|
@* La vignette s'agrandit au clic ; le titre, lui, ouvre la fiche. *@
|
|
<Couverture Url="@livre.CoverUrl" Titre="@livre.Titre"
|
|
Classe="couverture-petite" Agrandissable="true" />
|
|
|
|
<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>
|
|
}
|
|
|
|
@* Une seule ligne d'étiquettes, en lecture seule (IDEES.md) : la liste se
|
|
parcourt, elle ne se manipule pas. Tout se modifie depuis la fiche. *@
|
|
<p class="carte-details">
|
|
@* Un livre est physique par défaut : ne l'afficher que pour les ebooks,
|
|
sinon l'étiquette est sur toutes les lignes et n'apprend rien. *@
|
|
@if (livre.Format == Format.Numerique)
|
|
{
|
|
<span class="etiquette">@Libelles.Format(livre.Format)</span>
|
|
}
|
|
|
|
@* Même règle que le format : on n'affiche que ce qui a été CHOISI.
|
|
« Non précisé » n'apprendrait rien et serait sur presque chaque ligne. *@
|
|
@if (livre.TypeDocument != TypeDocument.NonPrecise)
|
|
{
|
|
<span class="etiquette">@Libelles.TypeDocument(livre.TypeDocument)</span>
|
|
}
|
|
|
|
@* Personnel : ce que montre cette pastille est MA lecture, pas celle du foyer.
|
|
Pas de pastille = non commencé, l'absence de ligne en base. *@
|
|
@if (livre.Statut is { } statut)
|
|
{
|
|
<span class="etiquette etiquette-statut @Libelles.ClasseStatut(statut)">
|
|
@Libelles.Statut(statut)
|
|
</span>
|
|
}
|
|
|
|
@* Un livre dehors l'est pour tout le foyer, contrairement au statut. *@
|
|
@if (livre.PreteA is { } emprunteur)
|
|
{
|
|
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
|
|
}
|
|
|
|
@if (!string.IsNullOrWhiteSpace(livre.Editeur))
|
|
{
|
|
<span>@livre.Editeur</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
|
|
@*
|
|
Une seule entrée d'ajout, et plus aucun lien de navigation : les quatre écrans sont dans
|
|
les onglets, en bas.
|
|
|
|
« Saisie manuelle » est retirée d'ici — elle reste accessible un écran plus loin, depuis
|
|
« Ajouter », qui la propose dès qu'un ISBN ne donne rien.
|
|
|
|
⚠️ Hors-ligne le bouton est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable
|
|
d'une fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau. Le
|
|
désencombrement demandé passe par le regroupement (deux boutons en un), pas par l'effacement.
|
|
*@
|
|
<nav class="actions-flottantes">
|
|
@if (Reseau.EnLigne)
|
|
{
|
|
<a class="bouton bouton-principal" href="ajout/isbn">Ajouter un livre</a>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="bouton bouton-principal" disabled
|
|
title="@EtatReseau.MotifHorsLigne">Ajouter un livre</button>
|
|
}
|
|
</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 TypeDocument? _type;
|
|
private Statut? _statut;
|
|
private bool _chargement;
|
|
private bool _filtresOuverts;
|
|
private string? _erreur;
|
|
|
|
/// <summary>
|
|
/// Formats réellement présents dans le fonds, ou <c>null</c> tant qu'on ne le sait pas.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Ne se calcule que sur un chargement <b>sans aucun critère</b>. Le déduire d'une liste
|
|
/// filtrée ferait disparaître la ligne « Tous / Physique / Numérique » dès qu'une recherche
|
|
/// ne remonte qu'un seul format — et l'utilisateur ne pourrait plus l'élargir. Dans le doute
|
|
/// (arrivée directe sur un auteur, par exemple) la ligne reste affichée : montrer un filtre
|
|
/// inutile est bénin, en escamoter un qui sert ne l'est pas.
|
|
/// </remarks>
|
|
private IReadOnlySet<Format>? _formatsDuFonds;
|
|
|
|
private bool PlusieursFormats => _formatsDuFonds is null || _formatsDuFonds.Count > 1;
|
|
|
|
/// <summary>Types réellement présents dans le fonds. Mêmes précautions que les formats.</summary>
|
|
private IReadOnlySet<TypeDocument>? _typesDuFonds;
|
|
|
|
private bool PlusieursTypes => _typesDuFonds is null || _typesDuFonds.Count > 1;
|
|
|
|
/// <summary>
|
|
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// La recherche et la restriction à un auteur n'y comptent pas : toutes deux se voient
|
|
/// déjà à l'écran, l'une dans son champ, l'autre dans sa phrase. Ce compteur ne dit que ce
|
|
/// qui serait invisible une fois le panneau refermé.
|
|
/// </remarks>
|
|
private int FiltresActifs =>
|
|
(_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1);
|
|
|
|
// 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 || _type is not null
|
|
|| _statut is not null || AuteurId is not null;
|
|
|
|
/// <summary>
|
|
/// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du
|
|
/// serveur ; à la coupure, pour basculer sur l'instantané local sans laisser un écran vide.
|
|
/// </summary>
|
|
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
|
|
|
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
|
{
|
|
await ChargerAsync();
|
|
StateHasChanged();
|
|
});
|
|
|
|
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 FiltrerType(TypeDocument? type)
|
|
{
|
|
_type = type;
|
|
return ChargerAsync();
|
|
}
|
|
|
|
private Task FiltrerStatut(Statut? statut)
|
|
{
|
|
_statut = statut;
|
|
return ChargerAsync();
|
|
}
|
|
|
|
/// <summary>Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte.</summary>
|
|
private static bool EstSansCritere(CritereLivres criteres) =>
|
|
string.IsNullOrWhiteSpace(criteres.Recherche)
|
|
&& criteres.Format is null
|
|
&& criteres.TypeDocument is null
|
|
&& criteres.Statut is null
|
|
&& criteres.AuteurId is null;
|
|
|
|
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,
|
|
TypeDocument = _type,
|
|
Statut = _statut,
|
|
AuteurId = AuteurId,
|
|
};
|
|
|
|
_livres = await Api.ListerAsync(criteres, jeton);
|
|
|
|
if (EstSansCritere(criteres))
|
|
{
|
|
_formatsDuFonds = _livres.Select(l => l.Format).ToHashSet();
|
|
_typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet();
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return; // Une recherche plus récente a pris le relais.
|
|
}
|
|
catch (Exception)
|
|
{
|
|
_erreur = Reseau.EnLigne
|
|
? "Le catalogue n'a pas pu être chargé. Réessayez dans un instant."
|
|
: "Aucune donnée n'a encore pu être enregistrée sur cet appareil.";
|
|
}
|
|
finally
|
|
{
|
|
if (!jeton.IsCancellationRequested)
|
|
{
|
|
_chargement = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Reseau.Change -= SurChangementReseau;
|
|
_cts.Cancel();
|
|
_cts.Dispose();
|
|
}
|
|
}
|