Files
mabibli/MaBibli.Client/Pages/Souhaits.razor
T
mathieuandClaude Opus 5 a49125ddc3 Signaler les envies déjà entrées au catalogue, sans les supprimer
La demande initiale était de retirer l'envie ; c'est refusé. Le catalogue
est commun et la liste d'envies personnelle : supprimer modifierait la
liste d'un autre en silence, avec sa note. Et le rapprochement par clé
d'œuvre est faillible, alors qu'une suppression ne se rattrape pas.

L'étiquette « Déjà au catalogue » mène à la fiche, pour vérifier avant de
retirer. Le critère est celui des doublons, la convention d'auteur commun
étant désormais partagée par les deux (RapprochementAuteurs).

Au passage : accolade manquante sur .etiquette-souhaite, qui avalait la
règle suivante — le bandeau de mise à jour perdait son style.

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

336 lines
14 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">
@*
⚠️ On SIGNALE, on ne supprime pas : la liste d'envies est
personnelle et le catalogue est commun, donc l'envie n'est
peut-être pas de celui qui a ajouté le livre — et la note
(« demandé à Noël ») disparaîtrait avec elle. Le rapprochement
est en outre faillible : l'étiquette mène à la fiche pour qu'on
puisse vérifier avant de retirer quoi que ce soit.
*@
@if (souhait is { Possede: true, LivreId: { } livreId })
{
<a class="etiquette etiquette-possede" href="@($"livres/{livreId}")">
Déjà au catalogue
</a>
}
@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();
}
}
}