Files
mabibli/MaBibli.Client/Pages/Souhaits.razor
T
mathieuandClaude Opus 5 5cf566bc33 Traiter les retours d'usage du 2026-08-18 (2ᵉ série)
Six lots, issus d'IDEES.md. Les décisions actées sont reportées dans
CLAUDE.md, et les entrées traitées retirées d'IDEES.md.

1. Douchette USB, ISSN et enchaînement du scan
   - Champ ISBN focalisé à l'ouverture : c'est tout ce qu'exige une
     douchette USB, qui se présente au système comme un clavier. Remède
     au scan caméra, qui rate sur la webcam d'un PC (optique, pas
     décodage).
   - Un EAN en 977 est un périodique : il porte un ISSN, donc un titre de
     revue. Intercepté AVANT la validation ISBN (c'est un EAN-13
     parfaitement valide), l'ISSN est déduit du code puis nommé via
     bib.issn. Auparavant la cascade s'exécutait en entier pour finir sur
     « aucun résultat ».
   - L'enchaînement après scan existait déjà mais était invisible : une
     étape « Recherche » affiche désormais le code interrogé.

2. ISBN affichés avec des tirets
   Tables extraites du RangeMessage.xml officiel — plusieurs tranches ne
   sont pas celles qu'on suppose. Le francophone est découpé en entier,
   ailleurs on s'arrête au groupe et à la clé : aucune coupure fausse.
   Corrige au passage l'export CSV, où un ISBN nu était lu comme un
   nombre par Excel.

3. Hors-ligne : la liste d'envies
   ListerSouhaitsAsync était le seul point de lecture hors du dispositif
   hors-ligne, d'où le « 404 Not Found » brut à l'écran. Cinquième
   instantané, écritures refusées, plus aucun message HTTP. Même défaut
   corrigé sur la bibliographie.

4. Navigation par onglets
   Catalogue / Auteurs / Prêts / Envies dans MainLayout ; les barres
   d'actions ne portent plus que des actions. Filtres repliés derrière un
   bouton compteur, ligne « format » masquée quand le fonds n'a qu'un
   format. Une seule entrée d'ajout, désactivée hors-ligne — pas masquée.

5. Liste d'envies : ordre, recherche, couvertures
   Migration RangDesEnvies. Le remplissage reconduit l'ordre affiché
   jusqu'ici : sans lui, les listes existantes se seraient réordonnées
   toutes seules. Réordonnancement par flèches et glisser-déposer (le
   drag & drop HTML5 ne marche pas au doigt). Ajout dans son propre
   écran, avec recherche par titre (bib.title) et couvertures enfin
   alimentées.

6. Bibliographie : une source muette n'est pas une liste vide
   L'écran affichait « BnF injoignable » PUIS « la BnF ne connaît aucun
   livre de cet auteur » — la seconde phrase étant fausse. Les deux cas
   s'excluent désormais, et un bouton Réessayer est offert.

   Vérifié en exécution : le diagnostic d'IDEES.md était faux sur un
   point. Robert A. Harper a bien 7 œuvres à la BnF (85 notices
   annoncées) ; c'était le même délai dépassé observé deux fois, pris
   pour deux causes distinctes.

380 tests, dont un qui applique réellement la migration (EnsureCreated
n'en joue aucune) et un qui verrouille les messages atteignant
l'utilisateur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:14:01 +02:00

321 lines
13 KiB
Plaintext

@page "/souhaits"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — liste d'envies</PageTitle>
<h1 class="titre-page">Liste d'envies</h1>
@* Le rappel n'est pas décoratif : la bibliothèque est commune, cette liste ne l'est pas.
Sans ce mot, on pourrait croire que le foyer entier voit ce qu'on note ici. *@
<p class="message-discret">
Les livres que vous aimeriez acquérir. <strong>Cette liste est la vôtre</strong> :
les autres membres du foyer ont la leur, et ne voient pas celle-ci.
Ces livres ne sont pas dans le catalogue et ne comptent pas dans la bibliothèque.
</p>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@if (_souhaits is null)
{
<p class="message-discret">Chargement…</p>
}
else
{
@if (_souhaits.Count == 0)
{
@* Hors-ligne, « vide » et « jamais enregistrée » sont indiscernables : une liste jamais
synchronisée rendrait exactement le même résultat qu'une liste réellement vide. On dit
donc les deux plutôt que d'affirmer la première — même règle que pour une fiche
absente de l'instantané. *@
<p class="message-discret">
@if (Reseau.EnLigne)
{
<text>
Votre liste est vide. Ajoutez une envie ci-dessous, ou ouvrez la
bibliographie d'un auteur depuis <a href="auteurs">la liste des auteurs</a>.
</text>
}
else
{
<text>
Aucune envie dans les données enregistrées sur cet appareil. Votre liste est
peut-être vide, ou simplement pas encore synchronisée : le retour du réseau
tranchera.
</text>
}
</p>
}
else
{
<p class="compteur">@_souhaits.Count livre@(_souhaits.Count > 1 ? "s" : "") souhaité@(_souhaits.Count > 1 ? "s" : "")</p>
@* Un seul bloc d'export, en haut de liste : c'est l'action qu'on vient chercher quand on
part en librairie, elle ne doit pas être au bout d'un défilement. *@
@*
⚠️ L'export est produit PAR LE SERVEUR : hors-ligne il n'y a pas de fichier à
télécharger, et un lien laissé actif finirait sur une page d'erreur du navigateur.
Un lien ne se désactive pas — d'où la bascule vers un bouton inerte qui porte sa
raison, comme au catalogue.
La consolation est dite juste en dessous : l'usage que servait l'export « .txt »,
emporter sa liste en librairie, est désormais couvert par la liste elle-même.
*@
<div class="actions-export">
@if (Reseau.EnLigne)
{
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Texte)" download>
Exporter en texte
</a>
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Csv)" download>
Exporter en CSV
</a>
}
else
{
<button type="button" class="bouton bouton-discret" disabled
title="@MotifExport">Exporter en texte</button>
<button type="button" class="bouton bouton-discret" disabled
title="@MotifExport">Exporter en CSV</button>
}
</div>
@if (!Reseau.EnLigne)
{
<p class="message-discret">
Les exports sont fabriqués par le serveur : ils reviendront avec le réseau.
La liste ci-dessous, elle, est celle enregistrée sur cet appareil — c'est
exactement ce qu'on emportait dans le fichier texte.
</p>
}
@if (_souhaits.Count > 1)
{
<p class="champ-aide">
Du plus désiré au moins désiré. Utilisez les flèches pour déplacer une envie —
sur ordinateur, vous pouvez aussi la faire glisser.
</p>
}
@*
⚠️ Les flèches ne sont PAS un ornement à côté du glisser-déposer : le drag & drop
HTML5 ne fonctionne pas au doigt sur mobile. Sans elles, réordonner n'existerait
que sur PC — c'est-à-dire pas là où l'on consulte sa liste en librairie.
*@
<ul class="liste-souhaits">
@foreach (var (souhait, index) in _souhaits.Select((s, i) => (s, i)))
{
<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"
@ondrop="() => DeposerAsync(index)">
@if (_souhaits.Count > 1)
{
<div class="rang-actions">
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == 0)"
title="@MotifBlocage"
aria-label="@($"Monter « {souhait.Titre} »")"
@onclick="() => DeplacerAsync(index, index - 1)">▲</button>
<span class="rang-numero" aria-hidden="true">@(index + 1)</span>
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == _souhaits.Count - 1)"
title="@MotifBlocage"
aria-label="@($"Descendre « {souhait.Titre} »")"
@onclick="() => DeplacerAsync(index, index + 1)">▼</button>
</div>
}
<Couverture Url="@souhait.CoverUrl" Titre="@souhait.Titre"
Classe="couverture-petite" Agrandissable="true" />
<div class="carte-corps">
<p class="carte-titre">@souhait.Titre</p>
@if (!string.IsNullOrWhiteSpace(souhait.Auteur))
{
<p class="carte-auteur">@souhait.Auteur</p>
}
<p class="carte-details">
@if (!string.IsNullOrWhiteSpace(souhait.Editeur))
{
<span>@souhait.Editeur</span>
}
@if (!string.IsNullOrWhiteSpace(souhait.Annee))
{
<span>@souhait.Annee</span>
}
@if (!string.IsNullOrWhiteSpace(souhait.Isbn))
{
<span class="etiquette code-isbn">ISBN @FormatageIsbn.Afficher(souhait.Isbn)</span>
}
</p>
@if (!string.IsNullOrWhiteSpace(souhait.Note))
{
<p class="souhait-note">@souhait.Note</p>
}
</div>
<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>
}
}
@* 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 (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="souhaits/ajout">Ajouter une envie</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter une envie</button>
}
</nav>
@code {
private IReadOnlyList<SouhaitDto>? _souhaits;
private bool _enCours;
private int? _glisse;
private string? _erreur;
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurChangementReseau;
await ChargerAsync();
}
/// <summary>
/// Recharge la liste au retour du réseau, et redessine pour rendre les actions à l'écran.
/// </summary>
/// <remarks>
/// On s'abonne à <c>Change</c> et jamais à <c>SynchroChange</c> : recharger écrit un
/// instantané, qui émettrait une nouvelle synchro, qui rechargerait… sans fin.
/// </remarks>
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerAsync();
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
/// <summary>Raison du blocage des écritures, ou <c>null</c> quand tout est possible.</summary>
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private const string MotifExport =
"L'export est produit par le serveur : indisponible hors ligne.";
/// <remarks>
/// ⚠️ Aucun message HTTP brut ne doit remonter jusqu'ici. C'était le défaut d'origine :
/// hors-ligne, l'écran affichait « net_http_message_not_success_statuscode_reason, 404,
/// Not Found ». La liste passe désormais par le même repli sur instantané que le reste de
/// l'application, et une panne se dit en français.
/// </remarks>
private async Task ChargerAsync()
{
try
{
_erreur = null;
_souhaits = await Api.ListerSouhaitsAsync();
}
catch (Exception)
{
_souhaits ??= [];
_erreur = Reseau.EnLigne
? "La liste d'envies n'a pas pu être chargée. Réessayez dans un instant."
: "Liste d'envies indisponible hors ligne : aucune donnée n'a encore pu être "
+ "enregistrée sur cet appareil.";
}
}
/// <summary>
/// Déplace une envie d'une position à une autre, puis enregistre l'ordre complet.
/// </summary>
/// <remarks>
/// La nouvelle suite est calculée <b>localement</b> et envoyée en entier : le serveur ne
/// reçoit pas « monte de un » mais « voici l'ordre ». C'est la même opération pour les
/// flèches et pour le glisser-déposer, donc un seul chemin à vérifier.
/// <para>
/// La liste affichée est réordonnée <b>avant</b> l'aller-retour réseau : un déplacement qui
/// attendrait la réponse donnerait l'impression que la flèche n'a pas répondu. En cas
/// d'échec, le rechargement remet l'ordre du serveur.
/// </para>
/// </remarks>
private async Task DeplacerAsync(int depuis, int vers)
{
if (_souhaits is null || depuis == vers || vers < 0 || vers >= _souhaits.Count)
{
return;
}
var ordonnee = _souhaits.ToList();
var deplacee = ordonnee[depuis];
ordonnee.RemoveAt(depuis);
ordonnee.Insert(vers, deplacee);
_souhaits = ordonnee;
_enCours = true;
_erreur = await Api.ReordonnerSouhaitsAsync(ordonnee.Select(s => s.Id).ToList());
_enCours = false;
if (_erreur is not null)
{
// L'ordre affiché n'est pas celui du serveur : on reprend le sien plutôt que de
// laisser croire que le déplacement a été enregistré.
await ChargerAsync();
}
}
/// <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)
{
return;
}
var depuis = _souhaits.ToList().FindIndex(s => s.Id == id);
_glisse = null;
if (depuis >= 0)
{
await DeplacerAsync(depuis, cible);
}
}
private async Task SupprimerAsync(SouhaitDto souhait)
{
_enCours = true;
_erreur = await Api.SupprimerSouhaitAsync(souhait.Id);
_enCours = false;
if (_erreur is null)
{
await ChargerAsync();
}
}
}