diff --git a/MaBibli.Client/Composants/Couverture.razor b/MaBibli.Client/Composants/Couverture.razor index d214628..fff65ec 100644 --- a/MaBibli.Client/Composants/Couverture.razor +++ b/MaBibli.Client/Composants/Couverture.razor @@ -8,21 +8,58 @@ @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é. + + Agrandissement (IDEES.md) : seulement quand il y a une URL. Sans couverture, il n'y a rien + à agrandir — le substitut à initiale reste un simple décor, jamais un bouton. *@
- @if (!string.IsNullOrWhiteSpace(Url)) + @if (ADesPixels) { - Couverture de @Titre + @if (Agrandissable) + { + @* Un vrai + } + else + { + Couverture de @Titre + } }
+@if (_agrandie) +{ + @* Fermeture au clic extérieur (le clic sur l'image ne remonte pas) et à Échap. + Le tabindex rend le calque focusable : sans focus, aucun keydown ne lui parviendrait. *@ + +} + @code { [Parameter] public string? Url { get; set; } @@ -30,6 +67,40 @@ [Parameter] public string? Classe { get; set; } + /// Le clic sur l'image l'affiche en grand. Sans effet s'il n'y a pas de couverture. + [Parameter] public bool Agrandissable { get; set; } + + private bool _agrandie; + private bool _aFocaliser; + private ElementReference _calque; + + private bool ADesPixels => !string.IsNullOrWhiteSpace(Url); + private string Initiale => string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant(); + + private void Ouvrir() + { + _agrandie = true; + _aFocaliser = true; + } + + private void Fermer() => _agrandie = false; + + private void SurToucheAsync(KeyboardEventArgs e) + { + if (e.Key is "Escape" or "Esc") + { + Fermer(); + } + } + + protected override async Task OnAfterRenderAsync(bool premierRendu) + { + if (_aFocaliser) + { + _aFocaliser = false; + await _calque.FocusAsync(); + } + } } diff --git a/MaBibli.Client/Composants/FormulaireLivre.razor b/MaBibli.Client/Composants/FormulaireLivre.razor index 624ce79..3908117 100644 --- a/MaBibli.Client/Composants/FormulaireLivre.razor +++ b/MaBibli.Client/Composants/FormulaireLivre.razor @@ -43,8 +43,17 @@ placeholder="978…" autocomplete="off" /> + @* + Le format n'est plus affiché dans les listes que pour les ebooks : un livre y est donc + physique par défaut. Cette valeur DOIT rester présélectionnée ici, sinon on créerait + des fiches sans format en croyant que « rien d'affiché = physique ». + C'est le cas sans code particulier : Format.Physique vaut 0, valeur par défaut de + l'énumération, et le segment correspondant est donc actif dès l'ouverture. + *@
- Format + + Format (physique par défaut) +
@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) -{ - - 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) - { - - } - 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. - - } -

    -
    - - -
    - } -
    -} - -@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) +{ + + 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) + { + + } + 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. + + } +

    +
    + + +
    + } +
    +} +else +{ + @if (_erreur is not null) + { + + } + +
    + + +
    +

    @_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. *@ +

    + + +} + +@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; +}