@foreach (var format in Formats)
{
diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor
index 71f28ca..945471f 100644
--- a/MaBibli.Client/Pages/Catalogue.razor
+++ b/MaBibli.Client/Pages/Catalogue.razor
@@ -67,7 +67,9 @@ else if (_livres is not null)
@foreach (var livre in _livres)
{
-
+ @* La vignette s'agrandit au clic ; le titre, lui, ouvre la fiche. *@
+
@livre.Titre
@@ -83,11 +85,26 @@ else if (_livres is not null)
}
+ @* 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. *@
- @Libelles.Format(livre.Format)
+ @* 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)
+ {
+ @Libelles.Format(livre.Format)
+ }
- @* Un livre dehors l'est pour tout le foyer : cette étiquette est la même
- pour tout le monde, contrairement au statut de lecture juste dessous. *@
+ @* 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)
+ {
+
+ @Libelles.Statut(statut)
+
+ }
+
+ @* Un livre dehors l'est pour tout le foyer, contrairement au statut. *@
@if (livre.PreteA is { } emprunteur)
{
Prêté à @emprunteur
@@ -98,12 +115,6 @@ else if (_livres is not null)
@livre.Editeur
}
-
- @* Changement de statut : action la plus fréquente, donc directement dans la liste. *@
-
}
@@ -130,7 +141,6 @@ else if (_livres is not null)
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();
@@ -207,35 +217,6 @@ else if (_livres is not null)
}
}
- 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();
diff --git a/MaBibli.Client/Pages/EditionLivre.razor b/MaBibli.Client/Pages/EditionLivre.razor
deleted file mode 100644
index d204734..0000000
--- a/MaBibli.Client/Pages/EditionLivre.razor
+++ /dev/null
@@ -1,162 +0,0 @@
-@page "/livres/{Id:int}"
-@inject ServiceLivresApi Api
-@inject NavigationManager Navigation
-
-
MaBibli — fiche du livre
-
-
Fiche du livre
-
-@if (_introuvable)
-{
-
Ce livre n'existe pas (ou plus).
-
Retour au catalogue
-}
-else if (_saisie is null)
-{
-
Chargement…
-}
-else
-{
-
-
- @if (_livre is not null)
- {
-
-
-
- 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. *@
-
- }
-
-
- @if (!_confirmationSuppression)
- {
- _confirmationSuppression = true">
- Supprimer ce livre
-
- }
- else
- {
-
- Supprimer « @_saisie.Titre » définitivement ? L'historique de ses prêts partira avec.
- @* Choix assumé : les prêts n'ont d'intérêt qu'attachés à un livre. Un historique
- orphelin (« quelqu'un a emprunté quelque chose ») ne se lit plus. *@
- @if (_livre?.PreteA is { } emprunteur)
- {
-
- Attention : ce livre est actuellement prêté à @emprunteur.
-
- }
-
-
-
- @(_suppression ? "Suppression…" : "Oui, supprimer")
-
- _confirmationSuppression = false">
- Annuler
-
-
- }
-
-}
-
-@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,
-
- // Les auteurs sont repris tels que rattachés au livre. Le statut, lui, est celui de
- // la personne qui édite : enregistrer ne touche jamais à la lecture de quelqu'un d'autre.
- Auteurs = _livre.Auteurs.Select(a => a.Nom).ToList(),
- Editeur = _livre.Editeur,
- Format = _livre.Format,
- Statut = _livre.Statut,
- CoverUrl = _livre.CoverUrl,
- };
- }
-
- ///
- /// Recharge la fiche après un prêt ou un retour, sans toucher au formulaire : l'état
- /// du prêt a changé, pas les champs que l'utilisateur est peut-être en train d'éditer.
- ///
- private async Task RechargerAsync() => _livre = await Api.ObtenirAsync(Id);
-
- 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("/");
-}
diff --git a/MaBibli.Client/Pages/FicheLivre.razor b/MaBibli.Client/Pages/FicheLivre.razor
new file mode 100644
index 0000000..022adf9
--- /dev/null
+++ b/MaBibli.Client/Pages/FicheLivre.razor
@@ -0,0 +1,304 @@
+@page "/livres/{Id:int}"
+@page "/livres/{Id:int}/edition"
+@inject ServiceLivresApi Api
+@inject NavigationManager Navigation
+@implements IDisposable
+
+@*
+ Une seule page, deux modes (IDEES.md) :
+
+ — consultation par défaut. Ouvrir une fiche depuis le catalogue ne doit rien pouvoir casser,
+ surtout au doigt sur un téléphone.
+ — édition sur demande explicite, via son adresse propre « /livres/{id}/edition ». Le mode
+ n'est pas un booléen caché : il est dans l'URL, donc le bouton « retour » du navigateur
+ sort de l'édition, ce qui est exactement ce qu'on attend d'un geste mobile.
+
+ Restent manipulables SANS entrer en édition, parce que ce ne sont pas des modifications de
+ la fiche mais les gestes du quotidien : le statut de lecture (le plus fréquent de tous) et
+ les prêts (prêter, marquer rendu).
+*@
+
+
MaBibli — @(_livre?.Titre ?? "fiche du livre")
+
+
@(ModeEdition ? "Modifier la fiche" : "Fiche du livre")
+
+@if (_introuvable)
+{
+
Ce livre n'existe pas (ou plus).
+
Retour au catalogue
+}
+else if (_livre is null || _saisie is null)
+{
+
Chargement…
+}
+else if (ModeEdition)
+{
+
+
+ @* La suppression n'est offerte qu'ici : c'est le seul écran où l'on vient pour modifier. *@
+
+ @if (!_confirmationSuppression)
+ {
+ _confirmationSuppression = true">
+ Supprimer ce livre
+
+ }
+ else
+ {
+
+ Supprimer « @_saisie.Titre » définitivement ? L'historique de ses prêts partira avec.
+ @* Choix assumé : les prêts n'ont d'intérêt qu'attachés à un livre. Un historique
+ orphelin (« quelqu'un a emprunté quelque chose ») ne se lit plus. *@
+ @if (_livre.PreteA is { } emprunteur)
+ {
+
+ Attention : ce livre est actuellement prêté à @emprunteur.
+
+ }
+
+
+
+ @(_suppression ? "Suppression…" : "Oui, supprimer")
+
+ _confirmationSuppression = false">
+ Annuler
+
+
+ }
+
+}
+else
+{
+ @if (_erreur is not null)
+ {
+
@_erreur
+ }
+
+
+
+
+
+
@_livre.Titre
+
+ @if (_livre.Auteurs.Count > 0)
+ {
+ @* Cliquer un auteur ramène au catalogue restreint à ses livres. *@
+
+ @foreach (var (auteur, index) in _livre.Auteurs.Select((a, i) => (a, i)))
+ {
+ @(index > 0 ? ", " : "")
+ @auteur.Nom
+ }
+
+ }
+
+
+ @if (!string.IsNullOrWhiteSpace(_livre.Editeur))
+ {
+
Éditeur @_livre.Editeur
+ }
+
+ @if (!string.IsNullOrWhiteSpace(_livre.Isbn))
+ {
+
ISBN @_livre.Isbn
+ }
+
+ @* Comme dans la liste : le format n'apparaît que pour un ebook, le physique
+ étant le cas par défaut. *@
+ @if (_livre.Format == Format.Numerique)
+ {
+
Format @Libelles.Format(_livre.Format)
+ }
+
+
+
+
+ @* Hors « Éditer » volontairement : changer son statut est l'action la plus fréquente
+ de l'application, et elle ne touche qu'à MA lecture, jamais à la fiche commune. *@
+
+ Statut de lecture (le vôtre)
+
+
+
+
+
+
+ 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. *@
+
+
+
+ Éditer
+ Catalogue
+
+}
+
+@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 bool _statutEnCours;
+ private string? _erreur;
+ private int _livreCharge;
+
+ ///
+ /// Le mode se lit dans l'adresse, pas dans un champ : « /livres/12/edition » est partageable,
+ /// et le retour arrière du navigateur ramène naturellement à la consultation.
+ ///
+ private bool ModeEdition =>
+ Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath
+ .TrimEnd('/').EndsWith("/edition", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Les deux adresses mènent au même composant avec le même Id : passer de l'une à
+ /// l'autre ne change aucun paramètre, et le routeur ne provoque alors aucun nouveau rendu
+ /// (constaté : l'URL devenait « /edition » sans que l'écran bascule). On s'abonne donc au
+ /// changement d'adresse pour redessiner nous-mêmes.
+ ///
+ protected override void OnInitialized() => Navigation.LocationChanged += SurNavigation;
+
+ private void SurNavigation(object? emetteur, LocationChangedEventArgs e) =>
+ InvokeAsync(StateHasChanged);
+
+ public void Dispose() => Navigation.LocationChanged -= SurNavigation;
+
+ protected override async Task OnParametersSetAsync()
+ {
+ if (_livreCharge == Id && _livre is not null)
+ {
+ // Simple bascule consultation <-> édition : inutile de relire le livre.
+ return;
+ }
+
+ _livreCharge = Id;
+ _introuvable = false;
+ _livre = null;
+ _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 = Recopier(_livre);
+ }
+
+ ///
Formulaire d'édition alimenté depuis la fiche chargée.
+ ///
+ /// Les auteurs sont repris tels que rattachés au livre. Le statut, lui, est celui de la
+ /// personne qui édite : enregistrer ne touche jamais à la lecture de quelqu'un d'autre.
+ ///
+ private static EnregistrementLivre Recopier(LivreDto livre) => new()
+ {
+ Isbn = livre.Isbn,
+ Titre = livre.Titre,
+ Auteurs = livre.Auteurs.Select(a => a.Nom).ToList(),
+ Editeur = livre.Editeur,
+ Format = livre.Format,
+ Statut = livre.Statut,
+ CoverUrl = livre.CoverUrl,
+ };
+
+ ///
+ /// Recharge la fiche après un prêt ou un retour, sans toucher au formulaire : l'état
+ /// du prêt a changé, pas les champs que l'utilisateur est peut-être en train d'éditer.
+ ///
+ private async Task RechargerAsync() => _livre = await Api.ObtenirAsync(Id) ?? _livre;
+
+ private async Task ChangerStatutAsync(Statut? statut)
+ {
+ _statutEnCours = true;
+ _erreur = null;
+
+ var resultat = await Api.ChangerStatutAsync(Id, statut);
+
+ _statutEnCours = false;
+
+ if (!resultat.EstOk)
+ {
+ _erreur = resultat.Erreur;
+ return;
+ }
+
+ _livre = resultat.Valeur;
+
+ // Le formulaire d'édition doit repartir du statut à jour, sinon un passage en édition
+ // réécrirait l'ancienne valeur au premier enregistrement.
+ if (_livre is not null)
+ {
+ _saisie = Recopier(_livre);
+ }
+ }
+
+ private async Task EnregistrerAsync()
+ {
+ _enregistrement = true;
+ _erreur = null;
+
+ var resultat = await Api.ModifierAsync(Id, _saisie!);
+
+ _enregistrement = false;
+
+ if (!resultat.EstOk)
+ {
+ _erreur = resultat.Erreur;
+ return;
+ }
+
+ // Retour à la consultation, pas au catalogue : on voit le résultat de sa modification.
+ _livre = resultat.Valeur;
+ _saisie = Recopier(_livre!);
+ RetourFiche();
+ }
+
+ 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 RetourFiche()
+ {
+ _confirmationSuppression = false;
+ Navigation.NavigateTo($"livres/{Id}");
+ }
+}
diff --git a/MaBibli.Client/Pages/Prets.razor b/MaBibli.Client/Pages/Prets.razor
index f0d10b3..bed7565 100644
--- a/MaBibli.Client/Pages/Prets.razor
+++ b/MaBibli.Client/Pages/Prets.razor
@@ -34,7 +34,8 @@ else
@foreach (var pret in _prets)
{
-
+
@pret.LivreTitre
diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css
index cefbfb8..eb474d4 100644
--- a/MaBibli.Client/wwwroot/css/app.css
+++ b/MaBibli.Client/wwwroot/css/app.css
@@ -560,3 +560,181 @@ body {
.scanner-mesure {
font-variant-numeric: tabular-nums;
}
+
+/* --- Agrandissement de la couverture --- */
+
+/* Le déclencheur recouvre exactement la vignette. Il n'existe que quand il y a
+ une image : sans couverture, le substitut à initiale reste un décor inerte. */
+.couverture-declencheur {
+ position: absolute;
+ inset: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ cursor: zoom-in;
+}
+
+.calque-couverture {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem;
+ background: rgba(0, 0, 0, 0.82);
+ cursor: zoom-out;
+}
+
+.calque-couverture:focus {
+ outline: none;
+}
+
+/* Bornée par la fenêtre : sur mobile la couverture occupe la largeur,
+ sur PC elle ne dépasse pas la hauteur utile. */
+.calque-image {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ border-radius: 6px;
+ background: #fff;
+ cursor: default;
+}
+
+.calque-image.image-cassee {
+ display: none;
+}
+
+.calque-fermer {
+ position: absolute;
+ top: calc(0.5rem + env(safe-area-inset-top));
+ right: 0.5rem;
+ min-width: 2.75rem;
+ min-height: 2.75rem;
+ border: 0;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.15);
+ color: #fff;
+ font-size: 1.1rem;
+ cursor: pointer;
+}
+
+/* --- Statut de lecture en lecture seule --- */
+
+/* Pastille pleine, aux couleurs des segments actifs, pour ne pas se confondre avec
+ l'étiquette pâle du prêt : le statut est personnel, le prêt est celui du foyer. */
+.etiquette-statut {
+ color: #fff;
+ font-weight: 600;
+}
+
+.etiquette-statut.statut-a-lire { background: #6b4fa8; }
+.etiquette-statut.statut-en-cours { background: #b06b00; }
+.etiquette-statut.statut-lu { background: #1f7a44; }
+
+/* --- Fiche du livre en lecture seule --- */
+
+.couverture-grande { width: 8rem; height: 12rem; }
+
+.fiche-entete {
+ display: flex;
+ gap: 1rem;
+ align-items: flex-start;
+ margin-bottom: 1rem;
+}
+
+.fiche-infos {
+ min-width: 0;
+ flex: 1;
+}
+
+.fiche-titre {
+ font-size: 1.15rem;
+ margin: 0 0 0.25rem;
+}
+
+.fiche-auteurs {
+ margin: 0 0 0.5rem;
+ color: var(--mb-texte-doux);
+}
+
+/* Deux colonnes « libellé / valeur » qui restent lisibles à 320 px. */
+.fiche-champs {
+ margin: 0;
+ font-size: 0.9rem;
+}
+
+.fiche-champs div {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.15rem 0;
+}
+
+.fiche-champs dt {
+ flex: 0 0 5rem;
+ color: var(--mb-texte-doux);
+}
+
+.fiche-champs dd {
+ margin: 0;
+ min-width: 0;
+ word-break: break-word;
+}
+
+/* Le statut reste manipulable hors édition : c'est le geste le plus fréquent
+ de l'application, l'enfouir derrière « Éditer » serait un recul. */
+.bloc-statut {
+ margin-top: 1.25rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--mb-bord);
+}
+
+/* --- Auteurs --- */
+
+/* Le nom d'un auteur est un point d'entrée, pas une décoration : il doit se voir comme
+ cliquable, et rester une cible atteignable au pouce dans une liste dense. */
+.lien-auteur {
+ color: var(--mb-accent);
+ text-decoration: none;
+ padding: 0.1rem 0;
+}
+
+.lien-auteur:hover,
+.lien-auteur:focus {
+ text-decoration: underline;
+}
+
+.liste-auteurs,
+.liste-rapprochements {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.carte-auteur-ligne {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 2.75rem;
+ padding: 0.4rem 0;
+ border-bottom: 1px solid var(--mb-bord);
+}
+
+.rapprochements {
+ margin-bottom: 1.5rem;
+ padding: 0.75rem;
+ border: 1px solid var(--mb-bord);
+ border-radius: 8px;
+ background: var(--mb-fond-doux);
+}
+
+.carte-rapprochement {
+ padding: 0.5rem 0;
+ border-top: 1px solid var(--mb-bord);
+}
+
+.rapprochement-noms {
+ margin: 0;
+ font-size: 0.95rem;
+}