Files
mabibli/MaBibli.Client/Pages/Catalogue.razor
T
mathieu 580d8469ac Fusionner le cache hors-ligne IndexedDB
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	MaBibli.Client/wwwroot/css/app.css
2026-08-18 14:06:07 +02:00

254 lines
8.9 KiB
Plaintext

@page "/"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@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">
@* 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>
}
@* 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>
}
@* Hors-ligne, les deux entrées d'ajout deviennent des boutons éteints plutôt que des liens qui
mèneraient à un formulaire invalidable. La consultation, elle, reste entière : recherche,
filtres et tri portent sur toute la bibliothèque, depuis l'instantané local. *@
<nav class="actions-flottantes">
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="ajout/isbn">Ajouter par ISBN</a>
<a class="bouton bouton-discret" href="ajout/manuel">Saisie manuelle</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter par ISBN</button>
<button type="button" class="bouton bouton-discret" disabled
title="@EtatReseau.MotifHorsLigne">Saisie manuelle</button>
}
<a class="bouton bouton-discret" href="prets">Prêts</a>
<a class="bouton bouton-discret" href="auteurs">Auteurs</a>
<a class="bouton bouton-discret" href="souhaits">Envies</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;
// 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;
/// <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 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;
}
}
}
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
_cts.Cancel();
_cts.Dispose();
}
}