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>
This commit is contained in:
mathieu
2026-08-17 22:55:07 +02:00
co-authored by Claude Opus 5
parent aed03e6b6f
commit fd6438b912
29 changed files with 2430 additions and 203 deletions
@@ -0,0 +1,35 @@
@*
Couverture d'un livre, avec substitut.
Les URL de couverture OpenLibrary renvoient des 502 intermittents (mesuré en phase 2 :
200, 200, puis 502 sur le même ISBN). On ne valide donc JAMAIS l'URL par un appel préalable :
on l'affiche, et si l'image casse, l'attribut onerror la masque et laisse apparaître le
substitut qui se trouve déjà dessous. Aucun aller-retour serveur, aucun état C# à gérer.
@key sur l'img : si l'URL change, Blazor recrée l'élément au lieu de réutiliser celui que
le onerror a marqué comme cassé.
*@
<div class="couverture @Classe">
<span class="couverture-substitut" aria-hidden="true">@Initiale</span>
@if (!string.IsNullOrWhiteSpace(Url))
{
<img @key="Url"
class="couverture-image"
src="@Url"
alt="Couverture de @Titre"
loading="lazy"
onerror="this.classList.add('image-cassee')" />
}
</div>
@code {
[Parameter] public string? Url { get; set; }
[Parameter] public string? Titre { get; set; }
[Parameter] public string? Classe { get; set; }
private string Initiale =>
string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant();
}
@@ -0,0 +1,112 @@
@*
Formulaire d'une fiche livre, partagé par l'ajout (ISBN ou manuel) et l'édition.
Le pré-remplissage par ISBN est une AIDE, pas une contrainte : tous les champs restent
modifiables, et une fiche incomplète peut toujours être complétée à la main (CLAUDE.md).
*@
<div class="formulaire">
@if (!string.IsNullOrEmpty(Saisie.CoverUrl) || !string.IsNullOrWhiteSpace(Saisie.Titre))
{
<div class="formulaire-apercu">
<Couverture Url="@Saisie.CoverUrl" Titre="@Saisie.Titre" Classe="couverture-moyenne" />
</div>
}
<label class="champ">
<span class="champ-libelle">Titre <span class="obligatoire" aria-hidden="true">*</span></span>
<input class="champ-saisie" type="text" @bind="Saisie.Titre" @bind:event="oninput"
placeholder="Titre du livre" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Auteur</span>
<input class="champ-saisie" type="text" @bind="Saisie.Auteur" @bind:event="oninput"
placeholder="Prénom Nom" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Éditeur</span>
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">ISBN <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="text" inputmode="numeric" @bind="Saisie.Isbn" @bind:event="oninput"
placeholder="978…" autocomplete="off" />
</label>
<fieldset class="champ">
<legend class="champ-libelle">Format</legend>
<div class="segments">
@foreach (var format in Formats)
{
<button type="button"
class="segment @(Saisie.Format == format ? "segment-actif" : null)"
@onclick="() => Saisie.Format = format">
@Libelles.Format(format)
</button>
}
</div>
</fieldset>
<fieldset class="champ">
<legend class="champ-libelle">Statut de lecture</legend>
<div class="segments">
@foreach (var statut in Statuts)
{
<button type="button"
class="segment @(Saisie.Statut == statut ? "segment-actif" : null)"
@onclick="() => Saisie.Statut = statut">
@Libelles.Statut(statut)
</button>
}
</div>
</fieldset>
<label class="champ">
<span class="champ-libelle">URL de couverture</span>
<input class="champ-saisie" type="url" @bind="Saisie.CoverUrl" @bind:event="oninput"
placeholder="https://…" autocomplete="off" />
</label>
@if (!string.IsNullOrEmpty(Erreur))
{
<p class="message-erreur" role="alert">@Erreur</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(EnCours || string.IsNullOrWhiteSpace(Saisie.Titre))"
@onclick="Valider">
@(EnCours ? "Enregistrement…" : LibelleValidation)
</button>
@if (OnAnnuler.HasDelegate)
{
<button type="button" class="bouton bouton-discret" disabled="@EnCours"
@onclick="OnAnnuler">
Annuler
</button>
}
</div>
</div>
@code {
private static readonly Format[] Formats = Enum.GetValues<Format>();
private static readonly Statut[] Statuts = Enum.GetValues<Statut>();
[Parameter, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new();
[Parameter] public string LibelleValidation { get; set; } = "Enregistrer";
[Parameter] public string? Erreur { get; set; }
[Parameter] public bool EnCours { get; set; }
[Parameter] public EventCallback OnValider { get; set; }
[Parameter] public EventCallback OnAnnuler { get; set; }
private Task Valider() => OnValider.InvokeAsync();
}
+31
View File
@@ -0,0 +1,31 @@
using MaBibli.Shared.Entites;
namespace MaBibli.Client.Composants;
/// <summary>Libellés français des énumérations, au même endroit pour toute l'interface.</summary>
public static class Libelles
{
public static string Format(Format format) => format switch
{
MaBibli.Shared.Entites.Format.Physique => "Physique",
MaBibli.Shared.Entites.Format.Numerique => "Numérique",
_ => format.ToString(),
};
public static string Statut(Statut statut) => statut switch
{
MaBibli.Shared.Entites.Statut.ALire => "À lire",
MaBibli.Shared.Entites.Statut.EnCours => "En cours",
MaBibli.Shared.Entites.Statut.Lu => "Lu",
_ => statut.ToString(),
};
/// <summary>Classe CSS associée au statut, pour la pastille de couleur.</summary>
public static string ClasseStatut(Statut statut) => statut switch
{
MaBibli.Shared.Entites.Statut.ALire => "statut-a-lire",
MaBibli.Shared.Entites.Statut.EnCours => "statut-en-cours",
MaBibli.Shared.Entites.Statut.Lu => "statut-lu",
_ => string.Empty,
};
}
+23 -13
View File
@@ -1,16 +1,26 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
@inject ServiceLivresApi Api
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
@* Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
Le PC hérite de la même colonne, simplement centrée et limitée en largeur. *@
<header class="bandeau">
<a class="marque" href="">MaBibli</a>
<article class="content px-4">
@Body
</article>
</main>
</div>
@if (_utilisateur?.Identifiant is not null)
{
<span class="utilisateur" title="@(_utilisateur.Simule ? "Utilisateur simulé (développement)" : "Connecté via le portail YunoHost")">
@_utilisateur.Affichage@(_utilisateur.Simule ? " (dev)" : "")
</span>
}
</header>
<main class="contenu">
@Body
</main>
@code {
private UtilisateurCourant? _utilisateur;
protected override async Task OnInitializedAsync()
=> _utilisateur = await Api.ObtenirUtilisateurAsync();
}
+30 -70
View File
@@ -1,77 +1,37 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
.bandeau {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #1b3a5c;
color: #fff;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
.marque {
color: #fff;
font-size: 1.15rem;
font-weight: 600;
text-decoration: none;
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
.utilisateur {
font-size: 0.8rem;
opacity: 0.85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 55%;
}
.contenu {
display: block;
width: 100%;
max-width: 46rem;
margin: 0 auto;
/* Marge basse généreuse : les actions flottantes ne doivent pas masquer le dernier livre. */
padding: 1rem 1rem 6rem;
}
-29
View File
@@ -1,29 +0,0 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">MaBibli</a>
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
</nav>
</div>
@code {
private bool collapseNavMenu = true;
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}
-83
View File
@@ -1,83 +0,0 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
.nav-scrollable {
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}
+214
View File
@@ -0,0 +1,214 @@
@page "/ajout/isbn"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
<PageTitle>MaBibli — ajouter par ISBN</PageTitle>
<h1 class="titre-page">Ajouter par ISBN</h1>
@if (_etape == Etape.Saisie)
{
<p class="message-discret">
Saisissez l'ISBN imprimé sur le livre. Le scan par la caméra arrivera plus tard.
</p>
<div class="barre-recherche">
<input class="champ-saisie" type="text" inputmode="numeric" placeholder="978…"
@bind="_isbn" @bind:event="oninput" @onkeydown="SurTouche"
aria-label="ISBN" autocomplete="off" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_chargement || string.IsNullOrWhiteSpace(_isbn))"
@onclick="ChercherAsync">
@(_chargement ? "Recherche…" : "Chercher")
</button>
<a class="bouton bouton-discret" href="ajout/manuel">Saisir à la main</a>
</div>
}
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@foreach (var avertissement in _avertissements)
{
<p class="message-avertissement">@avertissement</p>
}
@if (_etape == Etape.Choix)
{
@* Décision actée dans CLAUDE.md : plusieurs notices pour un même ISBN (rééditions
successives partageant l'ISBN), on ne choisit JAMAIS à la place de l'utilisateur.
Les candidats partagent titre et auteur : ce sont l'éditeur et l'année qui départagent,
donc ils sont mis en avant. *@
<p class="message-discret">
@_candidats.Count notices correspondent à cet ISBN. Elles se distinguent par l'éditeur et l'année.
</p>
<ul class="liste-candidats">
@foreach (var (candidat, index) in _candidats.Select((c, i) => (c, i)))
{
<li class="carte-candidat" @key="index">
<Couverture Url="@candidat.CoverUrl" Titre="@candidat.Titre" Classe="couverture-petite" />
<div class="carte-corps">
<p class="carte-titre">@candidat.Titre</p>
@if (!string.IsNullOrWhiteSpace(candidat.Auteur))
{
<p class="carte-auteur">@candidat.Auteur</p>
}
<p class="candidat-distinction">
<span class="candidat-editeur">@(candidat.Editeur ?? "éditeur inconnu")</span>
<span class="candidat-annee">@(candidat.Annee ?? "année inconnue")</span>
</p>
<p class="carte-details">
<span class="etiquette">@candidat.Source</span>
@if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge))
{
<span>trouvé via @candidat.IsbnInterroge</span>
}
</p>
<button type="button" class="bouton bouton-principal"
@onclick="() => Choisir(candidat)">
Choisir cette édition
</button>
</div>
</li>
}
</ul>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-discret" @onclick="Recommencer">Aucune : saisir à la main</button>
</div>
}
@if (_etape == Etape.Formulaire)
{
<p class="message-discret">
Vérifiez et complétez la fiche : tout reste modifiable.
</p>
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreurFormulaire"
EnCours="_enregistrement"
OnValider="EnregistrerAsync"
OnAnnuler="Recommencer" />
}
@code {
private enum Etape { Saisie, Choix, Formulaire }
private Etape _etape = Etape.Saisie;
private string _isbn = string.Empty;
private bool _chargement;
private bool _enregistrement;
private string? _erreur;
private string? _erreurFormulaire;
private IReadOnlyList<CandidatLivre> _candidats = [];
private IReadOnlyList<string> _avertissements = [];
private EnregistrementLivre _saisie = new();
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_isbn))
{
await ChercherAsync();
}
}
private async Task ChercherAsync()
{
_chargement = true;
_erreur = null;
_avertissements = [];
try
{
var resultat = await Api.ChercherIsbnAsync(_isbn.Trim());
if (resultat is null)
{
_erreur = $"« {_isbn} » n'est pas un ISBN valide.";
return;
}
_avertissements = resultat.Avertissements;
_candidats = resultat.Candidats;
if (_candidats.Count == 0)
{
// Aucune notice : on ne bloque pas, le formulaire manuel reste la porte de sortie.
_erreur = "Aucune notice trouvée pour cet ISBN. Complétez la fiche à la main.";
_saisie = new EnregistrementLivre { Isbn = resultat.IsbnDemande };
_etape = Etape.Formulaire;
return;
}
if (_candidats.Count == 1)
{
Choisir(_candidats[0]);
return;
}
_etape = Etape.Choix;
}
catch (Exception ex)
{
_erreur = $"La recherche a échoué : {ex.Message}";
}
finally
{
_chargement = false;
}
}
private void Choisir(CandidatLivre candidat)
{
_saisie = new EnregistrementLivre
{
Isbn = _isbn.Trim(),
Titre = candidat.Titre,
Auteur = candidat.Auteur,
Editeur = candidat.Editeur,
CoverUrl = candidat.CoverUrl,
Format = Format.Physique,
Statut = Statut.ALire,
};
_erreur = null;
_etape = Etape.Formulaire;
}
private void Recommencer()
{
_etape = Etape.Saisie;
_erreur = null;
_erreurFormulaire = null;
_candidats = [];
}
private async Task EnregistrerAsync()
{
_enregistrement = true;
_erreurFormulaire = null;
var resultat = await Api.CreerAsync(_saisie);
_enregistrement = false;
if (!resultat.EstOk)
{
_erreurFormulaire = resultat.Erreur;
return;
}
Navigation.NavigateTo("/");
}
}
+44
View File
@@ -0,0 +1,44 @@
@page "/ajout/manuel"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
<PageTitle>MaBibli — saisie manuelle</PageTitle>
<h1 class="titre-page">Saisie manuelle</h1>
<p class="message-discret">
Pour les livres sans ISBN, ou dont aucune notice ne convient. L'ISBN reste facultatif.
</p>
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreur"
EnCours="_enregistrement"
OnValider="EnregistrerAsync"
OnAnnuler="Retour" />
@code {
private readonly EnregistrementLivre _saisie = new();
private bool _enregistrement;
private string? _erreur;
private async Task EnregistrerAsync()
{
_enregistrement = true;
_erreur = null;
var resultat = await Api.CreerAsync(_saisie);
_enregistrement = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Navigation.NavigateTo("/");
}
private void Retour() => Navigation.NavigateTo("/");
}
+201
View File
@@ -0,0 +1,201 @@
@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();
}
}
+143
View File
@@ -0,0 +1,143 @@
@page "/livres/{Id:int}"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
<PageTitle>MaBibli — fiche du livre</PageTitle>
<h1 class="titre-page">Fiche du livre</h1>
@if (_introuvable)
{
<p class="message-erreur" role="alert">Ce livre n'existe pas (ou plus).</p>
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
}
else if (_saisie is null)
{
<p class="message-discret">Chargement…</p>
}
else
{
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Enregistrer les modifications"
Erreur="@_erreur"
EnCours="_enregistrement"
OnValider="EnregistrerAsync"
OnAnnuler="Retour" />
@if (_livre is not null)
{
<p class="message-discret">
Ajouté le @_livre.DateAjout.ToLocalTime().ToString("dd/MM/yyyy")
@if (!string.IsNullOrWhiteSpace(_livre.AjoutePar))
{
@($" par {_livre.AjoutePar}")
}
@* Information de traçabilité, jamais un filtre : la bibliothèque est commune. *@
</p>
}
<section class="zone-danger">
@if (!_confirmationSuppression)
{
<button type="button" class="bouton bouton-danger" @onclick="() => _confirmationSuppression = true">
Supprimer ce livre
</button>
}
else
{
<p class="message-avertissement">
Supprimer « @_saisie.Titre » définitivement ? L'historique de ses prêts partira avec.
</p>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-danger" disabled="@_suppression" @onclick="SupprimerAsync">
@(_suppression ? "Suppression…" : "Oui, supprimer")
</button>
<button type="button" class="bouton bouton-discret" disabled="@_suppression"
@onclick="() => _confirmationSuppression = false">
Annuler
</button>
</div>
}
</section>
}
@code {
[Parameter] public int Id { get; set; }
private LivreDto? _livre;
private EnregistrementLivre? _saisie;
private bool _introuvable;
private bool _enregistrement;
private bool _suppression;
private bool _confirmationSuppression;
private string? _erreur;
protected override async Task OnParametersSetAsync()
{
_introuvable = false;
_saisie = null;
try
{
_livre = await Api.ObtenirAsync(Id);
}
catch (Exception ex)
{
_erreur = $"Chargement impossible : {ex.Message}";
return;
}
if (_livre is null)
{
_introuvable = true;
return;
}
_saisie = new EnregistrementLivre
{
Isbn = _livre.Isbn,
Titre = _livre.Titre,
Auteur = _livre.Auteur,
Editeur = _livre.Editeur,
Format = _livre.Format,
Statut = _livre.Statut,
CoverUrl = _livre.CoverUrl,
};
}
private async Task EnregistrerAsync()
{
_enregistrement = true;
_erreur = null;
var resultat = await Api.ModifierAsync(Id, _saisie!);
_enregistrement = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Navigation.NavigateTo("/");
}
private async Task SupprimerAsync()
{
_suppression = true;
var erreur = await Api.SupprimerAsync(Id);
_suppression = false;
if (erreur is not null)
{
_erreur = erreur;
_confirmationSuppression = false;
return;
}
Navigation.NavigateTo("/");
}
private void Retour() => Navigation.NavigateTo("/");
}
-7
View File
@@ -1,7 +0,0 @@
@page "/"
<PageTitle>MaBibli</PageTitle>
<h1>MaBibli</h1>
<p>Squelette de l'application. Les fonctionnalités arrivent dans les phases suivantes.</p>
+1
View File
@@ -7,5 +7,6 @@ builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<MaBibli.Client.Services.ServiceLivresApi>();
await builder.Build().RunAsync();
+145
View File
@@ -0,0 +1,145 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
namespace MaBibli.Client.Services;
/// <summary>
/// Accès à l'API depuis le client Blazor.
/// </summary>
/// <remarks>
/// Aucun cache : la phase 3 suppose le réseau disponible. Le cache hors-ligne est la phase 6.
/// </remarks>
public sealed class ServiceLivresApi(HttpClient http)
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyList<LivreDto>> ListerAsync(CritereLivres criteres, CancellationToken ct = default)
{
var parametres = new List<string>();
if (!string.IsNullOrWhiteSpace(criteres.Recherche))
{
parametres.Add($"recherche={Uri.EscapeDataString(criteres.Recherche.Trim())}");
}
if (criteres.Format is { } format)
{
parametres.Add($"format={format}");
}
if (criteres.Statut is { } statut)
{
parametres.Add($"statut={statut}");
}
var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty);
return await http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct) ?? [];
}
public async Task<LivreDto?> ObtenirAsync(int id, CancellationToken ct = default)
{
var reponse = await http.GetAsync($"api/livres/{id}", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync<LivreDto>(reponse, ct);
}
public async Task<ResultatApi<LivreDto>> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default)
{
var reponse = await http.PostAsJsonAsync("api/livres", saisie, Json, ct);
return await InterpreterAsync<LivreDto>(reponse, ct);
}
public async Task<ResultatApi<LivreDto>> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default)
{
var reponse = await http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct);
return await InterpreterAsync<LivreDto>(reponse, ct);
}
public async Task<ResultatApi<LivreDto>> ChangerStatutAsync(int id, Statut statut, CancellationToken ct = default)
{
var reponse = await http.PatchAsJsonAsync(
$"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct);
return await InterpreterAsync<LivreDto>(reponse, ct);
}
public async Task<string?> SupprimerAsync(int id, CancellationToken ct = default)
{
var reponse = await http.DeleteAsync($"api/livres/{id}", ct);
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
}
public async Task<ResultatLookupIsbn?> ChercherIsbnAsync(string isbn, CancellationToken ct = default)
{
var reponse = await http.GetAsync($"api/isbn/{Uri.EscapeDataString(isbn)}", ct);
return reponse.IsSuccessStatusCode
? await LireAsync<ResultatLookupIsbn>(reponse, ct)
: null;
}
public async Task<UtilisateurCourant> ObtenirUtilisateurAsync(CancellationToken ct = default)
{
try
{
return await http.GetFromJsonAsync<UtilisateurCourant>("api/moi", Json, ct)
?? UtilisateurCourant.Anonyme;
}
catch (HttpRequestException)
{
return UtilisateurCourant.Anonyme;
}
}
private static async Task<T?> LireAsync<T>(HttpResponseMessage reponse, CancellationToken ct)
{
reponse.EnsureSuccessStatusCode();
return await reponse.Content.ReadFromJsonAsync<T>(Json, ct);
}
private static async Task<ResultatApi<T>> InterpreterAsync<T>(HttpResponseMessage reponse, CancellationToken ct)
{
if (reponse.IsSuccessStatusCode)
{
var valeur = await reponse.Content.ReadFromJsonAsync<T>(Json, ct);
return new ResultatApi<T>(valeur, null);
}
return new ResultatApi<T>(default, await MessageErreurAsync(reponse, ct));
}
/// <summary>Récupère le message d'erreur de l'API pour l'afficher tel quel à l'utilisateur.</summary>
private static async Task<string> MessageErreurAsync(HttpResponseMessage reponse, CancellationToken ct)
{
try
{
var corps = await reponse.Content.ReadFromJsonAsync<ErreurApi>(Json, ct);
if (!string.IsNullOrWhiteSpace(corps?.Erreur))
{
return corps.Erreur;
}
}
catch (Exception)
{
// Corps illisible ou vide : on retombe sur le code HTTP.
}
return reponse.StatusCode switch
{
HttpStatusCode.NotFound => "Ce livre n'existe plus.",
_ => $"L'enregistrement a échoué ({(int)reponse.StatusCode}).",
};
}
private sealed record ErreurApi(string? Erreur);
}
/// <summary>Résultat d'un appel d'écriture : la valeur, ou un message d'erreur affichable.</summary>
public readonly record struct ResultatApi<T>(T? Valeur, string? Erreur)
{
public bool EstOk => Erreur is null;
}
+5
View File
@@ -7,4 +7,9 @@
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using MaBibli.Client
@using MaBibli.Client.Composants
@using MaBibli.Client.Layout
@using MaBibli.Client.Services
@using MaBibli.Shared.Catalogue
@using MaBibli.Shared.Dtos
@using MaBibli.Shared.Entites
+337 -1
View File
@@ -112,4 +112,340 @@ code {
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
}
/* ------------------------------------------------------------------
MaBibli — interface du catalogue (phase 3)
Conçue mobile d'abord : une seule colonne, cibles tactiles d'au moins
2,75 rem, aucune règle de largeur en dessous du point de rupture.
Le PC n'ajoute qu'une largeur maximale et une grille de cartes.
------------------------------------------------------------------ */
:root {
--mb-accent: #1b3a5c;
--mb-bord: #dcdcdc;
--mb-fond-doux: #f4f5f7;
--mb-texte-doux: #5c6470;
--mb-danger: #a4242c;
}
body {
background: #fff;
color: #1d2129;
}
.titre-page {
font-size: 1.4rem;
margin: 0 0 0.75rem;
}
.message-discret {
color: var(--mb-texte-doux);
font-size: 0.9rem;
}
.message-erreur {
background: #fdecec;
border-left: 3px solid var(--mb-danger);
color: #7a1a20;
padding: 0.6rem 0.75rem;
border-radius: 4px;
margin: 0.75rem 0;
}
.message-avertissement {
background: #fff8e1;
border-left: 3px solid #c99700;
color: #6b5200;
padding: 0.6rem 0.75rem;
border-radius: 4px;
margin: 0.5rem 0;
font-size: 0.9rem;
}
.compteur {
color: var(--mb-texte-doux);
font-size: 0.85rem;
margin: 0.75rem 0 0.25rem;
}
/* --- Champs de saisie --- */
.barre-recherche {
margin-bottom: 0.75rem;
}
.champ {
display: block;
margin-bottom: 0.9rem;
border: 0;
padding: 0;
}
.champ-libelle {
display: block;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.3rem;
}
.champ-aide,
.obligatoire {
font-weight: 400;
color: var(--mb-texte-doux);
}
.champ-saisie {
width: 100%;
/* 16px minimum : en dessous, iOS zoome au focus. */
font-size: 1rem;
padding: 0.7rem 0.75rem;
border: 1px solid var(--mb-bord);
border-radius: 8px;
background: #fff;
}
.champ-saisie:focus {
outline: 2px solid var(--mb-accent);
outline-offset: -1px;
}
/* --- Segments (filtres, format, statut) --- */
.segments {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.segment {
flex: 1 1 auto;
min-height: 2.5rem;
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
border: 1px solid var(--mb-bord);
border-radius: 999px;
background: #fff;
color: #1d2129;
cursor: pointer;
}
.segment-actif {
background: var(--mb-accent);
border-color: var(--mb-accent);
color: #fff;
}
.segments-compacts .segment {
min-height: 2.2rem;
font-size: 0.8rem;
}
.segment.statut-a-lire.segment-actif { background: #6b4fa8; border-color: #6b4fa8; }
.segment.statut-en-cours.segment-actif { background: #b06b00; border-color: #b06b00; }
.segment.statut-lu.segment-actif { background: #1f7a44; border-color: #1f7a44; }
/* --- Boutons --- */
.bouton {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
padding: 0.6rem 1rem;
border-radius: 8px;
border: 1px solid var(--mb-bord);
background: #fff;
color: #1d2129;
font-size: 0.95rem;
text-decoration: none;
cursor: pointer;
}
.bouton:disabled {
opacity: 0.55;
cursor: default;
}
.bouton-principal {
background: var(--mb-accent);
border-color: var(--mb-accent);
color: #fff;
}
.bouton-discret {
background: var(--mb-fond-doux);
}
.bouton-danger {
background: var(--mb-danger);
border-color: var(--mb-danger);
color: #fff;
}
.actions-formulaire {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1rem 0;
}
.actions-flottantes {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 0.5rem;
padding: 0.6rem 1rem calc(0.6rem + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.96);
border-top: 1px solid var(--mb-bord);
}
.actions-flottantes .bouton {
flex: 1;
}
/* --- Couvertures --- */
.couverture {
position: relative;
flex: 0 0 auto;
overflow: hidden;
border-radius: 6px;
background: var(--mb-fond-doux);
border: 1px solid var(--mb-bord);
}
.couverture-petite { width: 3.5rem; height: 5.25rem; }
.couverture-moyenne { width: 6rem; height: 9rem; }
.couverture-substitut {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 700;
color: #9aa2ad;
}
.couverture-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Posée par l'attribut onerror : les couvertures OpenLibrary renvoient
des 502 intermittents, l'image cassée laisse place au substitut. */
.couverture-image.image-cassee {
display: none;
}
/* --- Liste de livres --- */
.liste-livres,
.liste-candidats {
list-style: none;
margin: 0;
padding: 0;
}
.carte-livre,
.carte-candidat {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--mb-bord);
}
.carte-corps {
min-width: 0;
flex: 1;
}
.carte-titre {
display: block;
font-weight: 600;
color: #1d2129;
text-decoration: none;
margin: 0 0 0.15rem;
}
.carte-auteur {
margin: 0 0 0.25rem;
color: var(--mb-texte-doux);
font-size: 0.9rem;
}
.carte-details {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
margin: 0 0 0.5rem;
font-size: 0.8rem;
color: var(--mb-texte-doux);
}
.etiquette {
background: var(--mb-fond-doux);
border-radius: 4px;
padding: 0.1rem 0.4rem;
}
/* --- Choix entre plusieurs notices --- */
/* Les candidats partagent titre et auteur : ce sont l'éditeur et l'année
qui les départagent, donc ils sont mis en avant visuellement. */
.candidat-distinction {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 0.4rem;
font-size: 0.9rem;
}
.candidat-editeur {
font-weight: 600;
}
.candidat-annee {
background: #eef2f7;
border-radius: 4px;
padding: 0.05rem 0.4rem;
}
/* --- Formulaire --- */
.formulaire-apercu {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.zone-danger {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--mb-bord);
}
/* --- PC : simple élargissement, la structure mobile ne change pas. --- */
@media (min-width: 48rem) {
.actions-flottantes {
position: static;
padding: 0;
margin-top: 1.5rem;
background: none;
border-top: 0;
}
.actions-flottantes .bouton {
flex: 0 0 auto;
}
}