Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
471 lines
19 KiB
Plaintext
471 lines
19 KiB
Plaintext
@page "/souhaits"
|
|
@page "/souhaits/ordre"
|
|
@inject ServiceLivresApi Api
|
|
@inject EtatReseau Reseau
|
|
@inject NavigationManager Navigation
|
|
@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 et les revues 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.
|
|
Rien de ce qui est ici n'est dans le catalogue, ni ne compte 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 && _revues.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">@Compteur()</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 (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 —
|
|
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.
|
|
*@
|
|
@* Une liste vide n'est pas rendue : on peut n'avoir que des revues en envie. *@
|
|
@if (_souhaits.Count > 0)
|
|
{
|
|
<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="@((ModeOrdre && Reseau.EnLigne && _souhaits.Count > 1).ToString().ToLowerInvariant())"
|
|
@ondragstart="() => DebuterGlisse(souhait.Id)"
|
|
@ondragend="TerminerGlisse"
|
|
@ondragover:preventDefault="ModeOrdre"
|
|
@ondrop="() => DeposerAsync(index)">
|
|
|
|
@if (ModeOrdre && _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>
|
|
|
|
@*
|
|
« Éditer » AU-DESSUS de « Retirer » : les deux gestes se ressemblent, et
|
|
celui qui répare doit se présenter avant celui qui détruit.
|
|
⚠️ Un lien, et non un bouton désactivé hors-ligne : la NAVIGATION reste
|
|
toujours possible, c'est l'écriture qui se refuse — et l'écran d'édition
|
|
le dit lui-même, avec son motif.
|
|
*@
|
|
@if (!ModeOrdre)
|
|
{
|
|
<div class="souhait-actions">
|
|
<a class="bouton bouton-compact" href="@($"souhaits/{souhait.Id}/edition")"
|
|
aria-label="@($"Éditer « {souhait.Titre} »")">
|
|
Éditer
|
|
</a>
|
|
|
|
<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>
|
|
</div>
|
|
}
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
}
|
|
|
|
@*
|
|
⚠️ Une SECTION à part, et non des lignes mêlées aux livres : les deux tables numérotent
|
|
leur rang indépendamment (voir RevueSouhaitee.Rang), donc un classement commun afficherait
|
|
un ordre que personne n'a choisi. C'est le coût assumé de la table sœur — mais la liste
|
|
les porte bien toutes les deux, y compris hors-ligne, ce qui est le seul point qui compte
|
|
au kiosque.
|
|
*@
|
|
@if (_revues.Count > 0 && !ModeOrdre)
|
|
{
|
|
<h2 class="titre-section">Revues et magazines</h2>
|
|
|
|
<ul class="liste-souhaits">
|
|
@foreach (var revue in _revues)
|
|
{
|
|
<li class="carte-souhait" @key="revue">
|
|
<div class="carte-corps">
|
|
<p class="carte-titre">@revue.TitreComplet</p>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(revue.Issn))
|
|
{
|
|
<p class="carte-details">
|
|
<span class="etiquette code-isbn">ISSN @revue.Issn</span>
|
|
</p>
|
|
}
|
|
|
|
@if (!string.IsNullOrWhiteSpace(revue.Note))
|
|
{
|
|
<p class="souhait-note">@revue.Note</p>
|
|
}
|
|
</div>
|
|
|
|
<div class="souhait-actions">
|
|
<a class="bouton bouton-compact" href="@($"souhaits/revues/{revue.Id}/edition")"
|
|
aria-label="@($"Éditer « {revue.TitreComplet} »")">
|
|
Éditer
|
|
</a>
|
|
|
|
<button type="button" class="bouton bouton-compact bouton-danger"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
@onclick="() => SupprimerRevueAsync(revue)"
|
|
aria-label="@($"Retirer « {revue.TitreComplet} » de la liste d'envies")">
|
|
Retirer
|
|
</button>
|
|
</div>
|
|
</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 (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>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="bouton bouton-principal" disabled
|
|
title="@EtatReseau.MotifHorsLigne">Ajouter une envie</button>
|
|
}
|
|
</nav>
|
|
|
|
@code {
|
|
private IReadOnlyList<SouhaitDto>? _souhaits;
|
|
private IReadOnlyList<RevueSouhaiteeDto> _revues = [];
|
|
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>
|
|
/// <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;
|
|
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;
|
|
|
|
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();
|
|
|
|
// Les deux moitiés se chargent ensemble : une liste d'envies amputée de ses revues
|
|
// serait fausse sans le dire.
|
|
_revues = await Api.ListerRevuesSouhaiteesAsync();
|
|
}
|
|
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 (!ModeOrdre || _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 void DebuterGlisse(int id)
|
|
{
|
|
if (ModeOrdre && Reseau.EnLigne)
|
|
{
|
|
_glisse = id;
|
|
}
|
|
}
|
|
|
|
private void TerminerGlisse() => _glisse = null;
|
|
|
|
/// <summary>« 3 livres et 1 revue », sans jamais nommer une section vide.</summary>
|
|
private string Compteur()
|
|
{
|
|
var morceaux = new List<string>();
|
|
|
|
if (_souhaits is { Count: > 0 })
|
|
{
|
|
morceaux.Add($"{_souhaits.Count} livre{(_souhaits.Count > 1 ? "s" : "")}");
|
|
}
|
|
|
|
if (_revues.Count > 0)
|
|
{
|
|
morceaux.Add($"{_revues.Count} revue{(_revues.Count > 1 ? "s" : "")}");
|
|
}
|
|
|
|
return string.Join(" et ", morceaux) + " en envie";
|
|
}
|
|
|
|
private async Task SupprimerRevueAsync(RevueSouhaiteeDto revue)
|
|
{
|
|
_enCours = true;
|
|
_erreur = await Api.SupprimerRevueSouhaiteeAsync(revue.Id);
|
|
_enCours = false;
|
|
|
|
if (_erreur is null)
|
|
{
|
|
await ChargerAsync();
|
|
}
|
|
}
|
|
|
|
private async Task SupprimerAsync(SouhaitDto souhait)
|
|
{
|
|
_enCours = true;
|
|
_erreur = await Api.SupprimerSouhaitAsync(souhait.Id);
|
|
_enCours = false;
|
|
|
|
if (_erreur is null)
|
|
{
|
|
await ChargerAsync();
|
|
}
|
|
}
|
|
}
|