Files
mabibli/MaBibli.Client/Pages/Catalogue.razor
T
mathieuandClaude Opus 5 ebc5f95d49 Consulter la bibliotheque hors-ligne depuis un cache IndexedDB
Le piege que CLAUDE.md signale : en Blazor WebAssembly le code tourne dans le
navigateur alors que SQLite vit sur le serveur, et le service worker ne met en
cache que les assets. Sans travail explicite, l'application demarre hors-ligne
et affiche une bibliotheque vide.

- js/cache-hors-ligne.js : instantanes JSON dans IndexedDB, et surveillance des
  bascules online/offline. Aucune logique metier.
- CacheHorsLigne / EtatReseau : lecture-ecriture des instantanes, et etat reseau
  combinant navigator.onLine (fiable seulement par sa negation) avec le sort
  reel des appels HTTP.
- ServiceLivresApi : les lectures retombent sur le cache, les ecritures sont
  refusees. Le catalogue entier est memorise, pas les reponses filtrees : c'est
  ce qui rend la recherche hors-ligne possible sur tout le fonds.
- FiltreLivresLocal : pendant navigateur de FiltreLivres, avec un test qui
  confronte les deux implementations sur les memes donnees.
- Interface : pastille et bandeau d'etat avec la date de synchronisation, et
  actions d'ecriture desactivees avec leur raison plutot que boutons morts.
- js/mise-a-jour.js : les empreintes WASM etant desactivees, le service worker
  est le seul cache-busting du projet. L'enregistrement journalise desormais ses
  echecs, et un bandeau propose la nouvelle version sans attendre la fermeture
  de tous les onglets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:59:19 +02:00

253 lines
8.8 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>
</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();
}
}