Le rôle vit sur le LIEN livre-auteur, jamais sur l'auteur : le même auteur scénarise un album et dessine le suivant, et il ne doit pas exister en deux fiches pour autant — ce que l'index unique sur la clé de regroupement interdit de toute façon. Les rôles se retrouvent par clé d'auteur résolue et non par position : ResoudreAsync écarte les noms vides et fusionne les variantes, donc sa liste peut être plus courte que la saisie. Piège traité : le champ des noms reconstruit la liste à chaque frappe. Decouper reprend les rôles connus en rapprochant sur la forme normalisée, sans quoi corriger une faute de frappe les effacerait tous. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
368 lines
14 KiB
Plaintext
368 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>@if (auteur.Role != RoleAuteur.NonPrecise)
|
|
{
|
|
@* Comme le format et le type : seul ce qui a été choisi s'affiche. *@
|
|
<span class="role-auteur"> (@Libelles.RoleAuteur(auteur.Role).ToLowerInvariant())</span>
|
|
}
|
|
}
|
|
</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();
|
|
}
|
|
}
|