Sépare consultation et édition pour les lots B1 B2 B3

Réutilise la carte livre du catalogue, sépare les modes des séries et des envies, et déplie les œuvres bibliographiques non possédées.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LIMONIER Mathieu
2026-08-20 13:11:43 +02:00
co-authored by Copilot
parent 5d45eb492e
commit 3d5e73838e
8 changed files with 238 additions and 87 deletions
+20
View File
@@ -1766,6 +1766,26 @@ affichée, convertir en WebP). IDEES.md posait la question à mesurer avant de s
règle que pour l'AOT WASM — non fait tant que la taille du cache image n'a pas montré de
problème réel en usage.
## Lot B — consulter d'abord, modifier ensuite (2026-08-20)
`CarteLivre.razor` est le composant partagé des cartes du catalogue. Il reçoit un `LivreDto`,
porte la couverture, les auteurs et les étiquettes de format, type, statut, prêt et éditeur,
et le catalogue l'utilise lui-même. Une œuvre bibliographique possédée réutilise cette carte
quand son livre est disponible.
Pour une série, `/series/{id}` est la consultation : parenté, tomes, carte du livre rattaché,
réordonnancement, rattachement, ajout et retrait restent des gestes courants. Seuls le titre,
la parenté dans un cycle et la suppression sont dans `/series/{id}/edition`. Comme pour la
fiche livre, le composant s'abonne à `LocationChanged` car les routes partagent le même paramètre.
Pour les envies, `/souhaits` consulte et conserve l'export et la suppression courante.
`/souhaits/ordre` porte exclusivement le réordonnancement (flèches et glisser-déposer), afin
que ces contrôles ne puissent pas être déclenchés en faisant défiler la liste.
Une œuvre bibliographique non possédée n'a pas de fiche locale. Son titre est donc un bouton
qui déplie inline son ISBN et son nombre d'éditions, tandis que les œuvres possédées ouvrent
leur fiche via la carte partagée.
## Installation YunoHost — éprouvée en production le 2026-08-18
Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé,
+3 -1
View File
@@ -130,7 +130,7 @@ mesure montre un gain réel. Pas encore mesuré à l'échelle d'un vrai fonds.
---
## Lot B — Consulter d'abord, modifier ensuite
## Lot B — Consulter d'abord, modifier ensuite — traité le 2026-08-20
Un seul motif, déjà acté et éprouvé pour la fiche livre (`/livres/{id}` consulte,
`/livres/{id}/edition` modifie, le mode est **dans l'URL**), à étendre aux écrans qui présentent
@@ -145,6 +145,8 @@ encore un formulaire vivant sous le pouce.
- **B3. Bibliographie d'un auteur** : même carte que le catalogue, et un **clic ouvre le détail**
de l'œuvre au lieu de s'arrêter à une ligne.
✅ Traité : voir la décision et les limites retenues dans `CLAUDE.md`.
⚠️ Le point commun des trois n'est pas cosmétique : c'est **une seule carte de livre** réutilisée
partout. Si les trois écrans réimplémentent la carte chacun de leur côté, les pastilles
divergeront comme le filtre a divergé (voir A2).
@@ -0,0 +1,57 @@
<div class="carte-livre">
<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>
@if (auteur.Role != RoleAuteur.NonPrecise)
{
<span class="role-auteur"> (@Libelles.RoleAuteur(auteur.Role).ToLowerInvariant())</span>
}
}
</p>
}
<p class="carte-details">
@if (Livre.Format == Format.Numerique)
{
<span class="etiquette">@Libelles.Format(Livre.Format)</span>
}
@if (Livre.TypeDocument != TypeDocument.NonPrecise)
{
<span class="etiquette etiquette-type @Libelles.ClasseTypeDocument(Livre.TypeDocument)">
@Libelles.TypeDocument(Livre.TypeDocument)
</span>
}
@if (Livre.Statut is { } statut)
{
<span class="etiquette etiquette-statut @Libelles.ClasseStatut(statut)">
@Libelles.Statut(statut)
</span>
}
@if (Livre.PreteA is { } emprunteur)
{
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
}
@if (!string.IsNullOrWhiteSpace(Livre.Editeur))
{
<span>@Livre.Editeur</span>
}
</p>
@Actions
</div>
</div>
@code {
[Parameter, EditorRequired] public LivreDto Livre { get; set; } = default!;
[Parameter] public RenderFragment? Actions { get; set; }
}
+38 -2
View File
@@ -140,15 +140,26 @@ else if (_bibliographie is { } biblio)
{
@* La classe « oeuvre-possedee » grise la ligne : le livre est déjà sur l'étagère. *@
<li class="carte-oeuvre @(oeuvre.Possede ? "oeuvre-possedee" : null)" @key="oeuvre.Titre">
@if (oeuvre is { Possede: true, LivreId: { } livreId }
&& _livresCatalogue.FirstOrDefault(l => l.Id == livreId) is { } livre)
{
<CarteLivre Livre="livre" />
}
@if (!oeuvre.Possede || !_livresCatalogue.Any(l => l.Id == oeuvre.LivreId))
{
<div class="carte-corps">
<p class="carte-titre">
@if (oeuvre is { Possede: true, LivreId: { } livreId })
@if (oeuvre is { Possede: true, LivreId: { } livreIdPossede })
{
<a class="lien-auteur" href="@($"livres/{livreId}")">@oeuvre.Titre</a>
<a class="lien-auteur" href="@($"livres/{livreIdPossede}")">@oeuvre.Titre</a>
}
else
{
<button type="button" class="carte-titre oeuvre-titre-bouton"
@onclick="() => BasculerOeuvre(oeuvre.Titre)"
aria-expanded="@_oeuvresOuvertes.Contains(oeuvre.Titre)">
@oeuvre.Titre
</button>
}
</p>
@@ -176,6 +187,20 @@ else if (_bibliographie is { } biblio)
}
</p>
</div>
@if (_oeuvresOuvertes.Contains(oeuvre.Titre))
{
<p class="message-discret detail-oeuvre">
@if (!string.IsNullOrWhiteSpace(oeuvre.Isbn))
{
<span>ISBN : @FormatageIsbn.Afficher(oeuvre.Isbn)</span>
}
@if (oeuvre.NombreEditions > 1)
{
<span>@oeuvre.NombreEditions éditions recensées</span>
}
</p>
}
}
@if (oeuvre.ADecouvrir)
{
@@ -225,6 +250,8 @@ else if (_bibliographie is { } biblio)
private string? _message;
private int _possedees;
private IReadOnlyList<LivreDto> _livresCatalogue = [];
private readonly HashSet<string> _oeuvresOuvertes = [];
protected override async Task OnParametersSetAsync() => await ChargerAsync();
@@ -240,6 +267,7 @@ else if (_bibliographie is { } biblio)
try
{
_bibliographie = await Api.ObtenirBibliographieAsync(AuteurId);
_livresCatalogue = await Api.ListerAsync(new CritereLivres());
if (_bibliographie is null)
{
@@ -272,6 +300,14 @@ else if (_bibliographie is { } biblio)
}
}
private void BasculerOeuvre(string titre)
{
if (!_oeuvresOuvertes.Add(titre))
{
_oeuvresOuvertes.Remove(titre);
}
}
/// <summary>
/// Ajoute une œuvre à la liste d'envies, en emportant ce que la BnF en sait.
/// </summary>
+2 -63
View File
@@ -115,69 +115,8 @@ else if (_livres is not null)
<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>@if (auteur.Role != RoleAuteur.NonPrecise)
{
@* Comme le format et le type : seul ce qui a été choisi s'affiche. *@
<span class="role-auteur"> (@Libelles.RoleAuteur(auteur.Role).ToLowerInvariant())</span>
}
}
</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>
}
@* Même règle que le format : on n'affiche que ce qui a été CHOISI.
« Non précisé » n'apprendrait rien et serait sur presque chaque ligne. *@
@if (livre.TypeDocument != TypeDocument.NonPrecise)
{
<span class="etiquette etiquette-type @Libelles.ClasseTypeDocument(livre.TypeDocument)">
@Libelles.TypeDocument(livre.TypeDocument)
</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 @key="livre.Id">
<CarteLivre Livre="livre" />
</li>
}
</ul>
+60 -12
View File
@@ -1,4 +1,5 @@
@page "/series/{Id:int}"
@page "/series/{Id:int}/edition"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@@ -14,12 +15,15 @@
}
else
{
@if (_parente is { } parente)
@if (!ModeEdition && _parente is { } parente)
{
@if (!ModeEdition)
{
<p class="message-discret">
Fait partie de <a href="@($"series/{parente.Id}")">@parente.Titre</a>.
</p>
}
}
<h1 class="titre-page">@_serie.Titre</h1>
@@ -47,7 +51,7 @@ else
}
@* Les sous-séries d'un cycle : on les liste, on ne les déplie pas — chacune a son écran. *@
@if (_filles.Count > 0)
@if (!ModeEdition && _filles.Count > 0)
{
<h2 class="titre-section">Séries de ce cycle</h2>
<ul class="liste-sous-series">
@@ -63,7 +67,7 @@ else
</ul>
}
@if (_serie.Elements.Count > 0)
@if (!ModeEdition && _serie.Elements.Count > 0)
{
<h2 class="titre-section">Ordre de lecture</h2>
@@ -94,20 +98,27 @@ else
</div>
<div class="carte-corps">
@if (element.LivreId is { } livreId)
@if (element.LivreId is { } livreId && _livresCatalogue.FirstOrDefault(l => l.Id == livreId) is { } livre)
{
<a class="carte-titre" href="@($"livres/{livreId}")">@element.Titre</a>
<CarteLivre Livre="livre" />
}
else if (element.LivreId is { } idRattache)
{
<a class="carte-titre" href="@($"livres/{idRattache}")">@element.Titre</a>
}
else
{
<p class="carte-titre">@element.Titre</p>
}
@if (!string.IsNullOrWhiteSpace(element.Auteurs))
@if ((element.LivreId is null || !_livresCatalogue.Any(l => l.Id == element.LivreId))
&& !string.IsNullOrWhiteSpace(element.Auteurs))
{
<p class="carte-auteur">@element.Auteurs</p>
}
@if (element.LivreId is null || !_livresCatalogue.Any(l => l.Id == element.LivreId))
{
<p class="carte-details">
@if (!element.Possede)
{
@@ -120,6 +131,7 @@ else
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
}
</p>
}
<div class="actions-tome">
@if (!element.Possede)
@@ -161,12 +173,12 @@ else
value="@_recherche" @oninput="ChercherAsync"
aria-label="Chercher un livre à rattacher" />
@foreach (var livre in _resultats)
@foreach (var resultat in _resultats)
{
<button type="button" class="bouton bouton-discret"
@key="livre.Id"
@onclick="() => RattacherAsync(element.Id, livre.Id)">
@livre.Titre@(livre.AuteursAffiches is { } a ? $" — {a}" : "")
@key="resultat.Id"
@onclick="() => RattacherAsync(element.Id, resultat.Id)">
@resultat.Titre@(resultat.AuteursAffiches is { } a ? $" — {a}" : "")
</button>
}
</div>
@@ -177,6 +189,8 @@ else
</ul>
}
@if (!ModeEdition)
{
<h2 class="titre-section">Ajouter un tome</h2>
@*
@@ -221,7 +235,10 @@ else
}
</div>
}
}
@if (ModeEdition)
{
<h2 class="titre-section">La série</h2>
<label class="champ">
@@ -268,6 +285,21 @@ else
</p>
}
}
@if (!ModeEdition)
{
<nav class="actions-flottantes">
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="@($"series/{Id}/edition")">Modifier</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Modifier</button>
}
</nav>
}
}
@code {
/// <summary>Marqueur de la recherche « ajouter depuis le catalogue », qui ne vise aucun élément.</summary>
@@ -276,6 +308,7 @@ else
[Parameter] public int Id { get; set; }
private IReadOnlyList<SerieDto> _toutes = [];
private IReadOnlyList<LivreDto> _livresCatalogue = [];
private SerieDto? _serie;
private SerieDto? _parente;
private List<SerieDto> _filles = [];
@@ -296,6 +329,10 @@ else
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private bool ModeEdition =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
.EndsWith("/edition", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Séries dans lesquelles celle-ci peut être rangée.
/// </summary>
@@ -326,7 +363,13 @@ else
return false;
}
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
protected override void OnInitialized()
{
Reseau.Change += SurChangementReseau;
Navigation.LocationChanged += SurNavigation;
}
private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(StateHasChanged);
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
@@ -334,7 +377,11 @@ else
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
Navigation.LocationChanged -= SurNavigation;
}
protected override Task OnParametersSetAsync() => ChargerAsync();
@@ -342,6 +389,7 @@ else
{
_chargement = true;
_toutes = await Api.ListerSeriesAsync();
_livresCatalogue = await Api.ListerAsync(new CritereLivres());
_chargement = false;
Rattacher();
+41 -8
View File
@@ -1,6 +1,8 @@
@page "/souhaits"
@page "/souhaits/ordre"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
<PageTitle>MaBibli — liste d'envies</PageTitle>
@@ -92,7 +94,7 @@ else
</p>
}
@if (_souhaits.Count > 1)
@if (ModeOrdre && _souhaits.Count > 1)
{
<p class="champ-aide">
Du plus désiré au moins désiré. Utilisez les flèches pour déplacer une envie —
@@ -110,13 +112,13 @@ else
{
<li class="carte-souhait @(_glisse == souhait.Id ? "carte-glissee" : null)"
@key="souhait.Id"
draggable="@((Reseau.EnLigne && _souhaits.Count > 1).ToString().ToLowerInvariant())"
@ondragstart="() => _glisse = souhait.Id"
@ondragend="() => _glisse = null"
@ondragover:preventDefault="true"
draggable="@((ModeOrdre && Reseau.EnLigne && _souhaits.Count > 1).ToString().ToLowerInvariant())"
@ondragstart="() => DebuterGlisse(souhait.Id)"
@ondragend="TerminerGlisse"
@ondragover:preventDefault="ModeOrdre"
@ondrop="() => DeposerAsync(index)">
@if (_souhaits.Count > 1)
@if (ModeOrdre && _souhaits.Count > 1)
{
<div class="rang-actions">
<button type="button" class="bouton bouton-compact"
@@ -182,12 +184,15 @@ else
}
</div>
@if (!ModeOrdre)
{
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => SupprimerAsync(souhait)"
aria-label="@($"Retirer « {souhait.Titre} » de la liste d'envies")">
Retirer
</button>
}
</li>
}
</ul>
@@ -197,6 +202,14 @@ else
@* L'ajout est passé dans son propre écran : la page ne montre plus que la liste, comme la
fiche livre ne montre plus son formulaire. *@
<nav class="actions-flottantes">
@if (ModeOrdre)
{
<a class="bouton bouton-discret" href="souhaits">Terminer</a>
}
else if (Reseau.EnLigne)
{
<a class="bouton bouton-discret" href="souhaits/ordre">Modifier l'ordre</a>
}
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="souhaits/ajout">Ajouter une envie</a>
@@ -213,13 +226,19 @@ else
private bool _enCours;
private int? _glisse;
private string? _erreur;
private bool ModeOrdre =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
.EndsWith("/souhaits/ordre", StringComparison.OrdinalIgnoreCase);
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurChangementReseau;
Navigation.LocationChanged += SurNavigation;
await ChargerAsync();
}
private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(StateHasChanged);
/// <summary>
/// Recharge la liste au retour du réseau, et redessine pour rendre les actions à l'écran.
/// </summary>
@@ -233,7 +252,11 @@ else
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
Navigation.LocationChanged -= SurNavigation;
}
/// <summary>Raison du blocage des écritures, ou <c>null</c> quand tout est possible.</summary>
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
@@ -307,7 +330,7 @@ else
/// <summary>Fin d'un glisser-déposer : la carte saisie prend la place de celle survolée.</summary>
private async Task DeposerAsync(int cible)
{
if (_souhaits is null || _glisse is not { } id)
if (!ModeOrdre || _souhaits is null || _glisse is not { } id)
{
return;
}
@@ -321,6 +344,16 @@ else
}
}
private void DebuterGlisse(int id)
{
if (ModeOrdre && Reseau.EnLigne)
{
_glisse = id;
}
}
private void TerminerGlisse() => _glisse = null;
private async Task SupprimerAsync(SouhaitDto souhait)
{
_enCours = true;
+16
View File
@@ -850,6 +850,22 @@ body {
font-weight: 400;
}
.oeuvre-titre-bouton {
width: 100%;
padding: 0;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.detail-oeuvre {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.25rem 0 0;
}
.etiquette-possede {
background: #e3ece3;
color: #2f5d34;