Files
mabibli/MaBibli.Client/Pages/Catalogue.razor
T
mathieuandClaude Opus 5 fd6438b912 Phase 3 : CRUD des livres, statuts de lecture et identite SSOwat
API
- CRUD complet sur /api/livres : liste filtrable (format, statut) et
  cherchable (titre, auteur), consultation, creation, modification,
  changement de statut isole, suppression.
- Aucune lecture ne filtre sur AjoutePar : la bibliotheque est commune au
  foyer. Le critere n'existe meme pas dans CritereLivres, pour qu'on ne
  puisse pas s'en servir par inadvertance. Deux tests verrouillent
  l'invariant, dont un par reflexion sur le type des criteres.
- AjoutePar est renseigne par le serveur a la creation, jamais par le
  client, et n'est pas reecrit a l'edition (pas plus que DateAjout).
- Identite lue dans les en-tetes SSOwat (YNH_USER, YNH_USER_EMAIL,
  YNH_USER_FULLNAME), avec repli sur un utilisateur simule configure en
  developpement uniquement. Sans en-tete ni simulation, l'identite reste
  inconnue plutot qu'inventee. Expose par GET /api/moi.
- Migration appliquee au demarrage : pas d'etape manuelle a l'installation.

Interface Blazor, pensee mobile d'abord
- Catalogue unique (physiques et numeriques melanges, distingues par le
  format) avec recherche, filtres et changement de statut directement dans
  la liste.
- Ajout par ISBN : quand la cascade rend plusieurs notices, un ecran de
  choix les presente en mettant en avant editeur et annee, seuls elements
  qui les departagent. Le formulaire reste ensuite entierement modifiable.
- Ajout manuel, edition, suppression avec confirmation explicite.
- Couvertures : l'URL OpenLibrary n'est jamais validee par un appel
  prealable (502 intermittents mesures en phase 2) ; un attribut onerror
  masque l'image cassee et laisse apparaitre un substitut.

Decisions prises la ou CLAUDE.md etait muet
- Tri alphabetique par titre.
- Un ISBN saisi doit etre valide, mais reste facultatif.
- Ecran de choix affiche seulement a partir de deux candidats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:55:07 +02:00

202 lines
6.5 KiB
Plaintext

@page "/"
@inject ServiceLivresApi Api
@implements IDisposable
<PageTitle>MaBibli — catalogue</PageTitle>
<h1 class="titre-page">Catalogue</h1>
<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>
<div class="segments" role="group" aria-label="Filtrer par statut">
<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 (!string.IsNullOrWhiteSpace(livre.Auteur))
{
<p class="carte-auteur">@livre.Auteur</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. *@
<div class="segments segments-compacts" role="group" aria-label="Statut de lecture">
@foreach (var statut in Enum.GetValues<Statut>())
{
<button type="button"
class="segment @Libelles.ClasseStatut(statut) @(livre.Statut == statut ? "segment-actif" : null)"
disabled="@(_statutEnCours == livre.Id)"
@onclick="() => ChangerStatut(livre, statut)">
@Libelles.Statut(statut)
</button>
}
</div>
</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>
</nav>
@code {
private IReadOnlyList<LivreDto>? _livres;
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;
protected override Task OnInitializedAsync() => 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 };
_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();
}
}