- Pour les grandes bibli, ajouter un visuel que l'appli est en train de charger les données en cache
801 lines
32 KiB
Plaintext
801 lines
32 KiB
Plaintext
@page "/"
|
|
@inject ServiceLivresApi Api
|
|
@inject EtatReseau Reseau
|
|
@inject NavigationManager Navigation
|
|
@implements IDisposable
|
|
|
|
<PageTitle>MaBibli — catalogue</PageTitle>
|
|
|
|
@*
|
|
Les filtres tiennent derrière un seul bouton, près du titre : deux rangées de segments
|
|
occupaient en permanence le haut de l'écran d'un téléphone pour un réglage qu'on change
|
|
rarement. Le compteur sur le bouton est ce qui rend le repli sans danger — un filtre actif
|
|
et replié serait invisible, et l'on conclurait que des livres ont disparu.
|
|
*@
|
|
@*
|
|
⚠️ « Ajouter un ouvrage » est ICI, à côté de « Filtrer », et non plus dans une barre
|
|
flottante en bas : c'est le geste le plus fréquent après la recherche, et les deux boutons
|
|
du haut se manœuvrent d'une main. Il n'y en a qu'UN — deux entrées identiques sur le même
|
|
écran seraient du bruit, et le projet désencombre par le regroupement, jamais par
|
|
l'effacement.
|
|
|
|
⚠️ Hors-ligne il est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable d'une
|
|
fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau.
|
|
*@
|
|
<div class="entete-page">
|
|
<h1 class="titre-page">Catalogue</h1>
|
|
|
|
<div class="entete-page-actions">
|
|
<button type="button" class="bouton bouton-discret bouton-compact"
|
|
aria-expanded="@Aria.Etat(_filtresOuverts)" aria-controls="filtres-catalogue"
|
|
@onclick="() => _filtresOuverts = !_filtresOuverts">
|
|
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
|
|
</button>
|
|
|
|
@if (_grouper && Entrees.Any(e => e.EstGroupe))
|
|
{
|
|
<button type="button" class="bouton bouton-discret bouton-compact"
|
|
@onclick="() => { if (AuMoinsUneSerieOuverte) { ToutReplier(); } else { ToutDeplier(); } }">
|
|
@(AuMoinsUneSerieOuverte ? "Replier les séries" : "Déplier les séries")
|
|
</button>
|
|
}
|
|
|
|
@*
|
|
« Modifier en lot » n'apparaît qu'à partir de DEUX livres — même seuil que sur la fiche
|
|
série. Reste possible hors-ligne : l'écriture, elle, est bloquée par livre comme
|
|
partout ailleurs (⚠️ MotifBlocage).
|
|
*@
|
|
@if (Reseau.EnLigne && _livres is { Count: > 1 })
|
|
{
|
|
<button type="button" class="bouton bouton-discret bouton-compact"
|
|
aria-expanded="@Aria.Etat(_lotOuvert)" aria-controls="lot-catalogue"
|
|
@onclick="BasculerLot">
|
|
Modifier en lot
|
|
</button>
|
|
}
|
|
|
|
@if (Reseau.EnLigne)
|
|
{
|
|
<a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="bouton bouton-principal bouton-compact" disabled
|
|
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@if (_auteur is not null)
|
|
{
|
|
@* Le « cliquer sur un auteur » d'CLAUDE.md : le catalogue est ici restreint à ses livres. *@
|
|
<p class="message-discret">
|
|
Livres de <strong>@_auteur.Nom</strong>.
|
|
<a href="">Revenir à toute la bibliothèque</a>
|
|
</p>
|
|
}
|
|
|
|
@*
|
|
La recherche accepte aussi un CODE : douchette, caméra, ou code recopié à la main. C'est le
|
|
geste « j'ai le livre en main, qu'est-ce que j'en sais déjà ? », l'inverse exact de l'ajout.
|
|
|
|
⚠️ Le scan reste offert HORS-LIGNE, contrairement à l'ajout : le décodage se fait dans le
|
|
navigateur, et la recherche retombe sur l'instantané. Rien là-dedans n'a besoin du réseau.
|
|
*@
|
|
<div class="barre-recherche ligne-creation">
|
|
<input class="champ-saisie" type="search" placeholder="Titre, auteur ou ISBN…"
|
|
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
|
|
|
|
<button type="button" class="bouton bouton-discret"
|
|
@onclick="() => _scanOuvert = !_scanOuvert">
|
|
@(_scanOuvert ? "Fermer" : "Scanner")
|
|
</button>
|
|
</div>
|
|
|
|
@if (_scanOuvert)
|
|
{
|
|
<ScannerCodeBarres OnCodeDetecte="SurCodeScanneAsync" OnAnnuler="() => _scanOuvert = false" />
|
|
}
|
|
|
|
@if (_filtresOuverts)
|
|
{
|
|
<div class="filtres" id="filtres-catalogue">
|
|
@* Un fonds tout physique n'a rien à filtrer par format : la ligne n'apprendrait rien
|
|
et prendrait de la place. Cohérent avec « le format ne s'affiche que pour les
|
|
ebooks ». Elle réapparaît d'elle-même au premier ebook ajouté. *@
|
|
@if (PlusieursFormats)
|
|
{
|
|
<div class="segments" role="group" aria-label="Filtrer par format">
|
|
<button type="button" class="segment @(_format is null ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerFormat(null)">Tous</button>
|
|
@foreach (var format in Enum.GetValues<Format>())
|
|
{
|
|
<button type="button" class="segment @(_format == format ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerFormat(format)">@Libelles.Format(format)</button>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@* Même logique que pour le format : tant qu'aucun type n'a été renseigné, la ligne
|
|
n'aurait qu'un seul choix utile. Elle apparaît dès la première fiche typée.
|
|
⚠️ « Non précisé » y figure comme valeur à part entière : « qu'est-ce que je n'ai
|
|
pas encore rangé ? » est justement la question qui se pose après coup. *@
|
|
@if (PlusieursTypes)
|
|
{
|
|
<div class="segments" role="group" aria-label="Filtrer par type de document">
|
|
<button type="button" class="segment @(_type is null ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerType(null)">Tous</button>
|
|
@foreach (var type in Enum.GetValues<TypeDocument>())
|
|
{
|
|
<button type="button"
|
|
class="segment @Libelles.ClasseTypeDocument(type) @(_type == type ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerType(type)">@Libelles.TypeDocument(type)</button>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@* ⚠️ Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut
|
|
pour moi ne remonte sous aucune valeur, il reste visible sous « Tous ». L'écran
|
|
l'assume au lieu de laisser croire à une vue commune. *@
|
|
<div class="segments" role="group" aria-label="Filtrer par mon statut de lecture">
|
|
<button type="button" class="segment @(_statut is null ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerStatut(null)">Tous</button>
|
|
@foreach (var statut in Enum.GetValues<Statut>())
|
|
{
|
|
<button type="button"
|
|
class="segment @Libelles.ClasseStatut(statut) @(_statut == statut ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerStatut(statut)">@Libelles.Statut(statut)</button>
|
|
}
|
|
</div>
|
|
|
|
@*
|
|
Le prêt, lui, est COMMUN au foyer : ce filtre ne dépend de personne. L'écran
|
|
« Prêts » répond déjà à « qu'est-ce qui est dehors ? » ; celui-ci sert l'autre
|
|
moitié de la question — « qu'est-ce que j'ai réellement sous la main ? » —, celle
|
|
qu'on se pose avant de promettre un livre à quelqu'un.
|
|
|
|
⚠️ Même règle que pour les formats et les types : la ligne disparaît tant que rien
|
|
n'est prêté, où elle n'offrirait qu'un choix utile. Et comme pour eux, elle ne se
|
|
déduit QUE d'un chargement sans critère, sinon une recherche qui ne remonte que des
|
|
livres à la maison escamoterait le filtre sans qu'on puisse l'élargir.
|
|
*@
|
|
@if (QuelqueChoseEstPrete)
|
|
{
|
|
<div class="segments" role="group" aria-label="Filtrer par prêt en cours">
|
|
<button type="button" class="segment @(_prete is null ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerPret(null)">Tous</button>
|
|
<button type="button" class="segment @(_prete == false ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerPret(false)">À la maison</button>
|
|
<button type="button"
|
|
class="segment segment-prete @(_prete == true ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerPret(true)">Prêtés</button>
|
|
</div>
|
|
}
|
|
|
|
@*
|
|
« Quels livres me reste-t-il à illustrer ? » — la seule question à laquelle ce
|
|
filtre répond, et la raison pour laquelle il existe.
|
|
|
|
⚠️ « Sans couverture » veut dire « sans URL enregistrée », PAS « dont l'image ne
|
|
se charge pas ». Savoir la seconde demanderait d'interroger chaque URL, ce que
|
|
CLAUDE.md interdit : OpenLibrary rend des 502 intermittents, et le même livre
|
|
entrerait et sortirait du filtre d'un rafraîchissement à l'autre.
|
|
|
|
⚠️ La ligne disparaît quand le fonds est homogène — tout illustré, ou rien.
|
|
Contrairement aux formats, les DEUX extrêmes sont inutiles ici : si aucun livre
|
|
n'a de couverture, la liste complète EST déjà celle des livres à illustrer.
|
|
Et comme partout, cela ne se déduit QUE d'un chargement sans critère.
|
|
*@
|
|
@if (CouverturesInegales)
|
|
{
|
|
<div class="segments" role="group" aria-label="Filtrer par couverture">
|
|
<button type="button" class="segment @(_sansCouverture is null ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerCouverture(null)">Tous</button>
|
|
<button type="button" class="segment @(_sansCouverture == false ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerCouverture(false)">Avec couverture</button>
|
|
<button type="button" class="segment @(_sansCouverture == true ? "segment-actif" : null)"
|
|
@onclick="() => FiltrerCouverture(true)">Sans couverture</button>
|
|
</div>
|
|
}
|
|
|
|
@*
|
|
⚠️ Grouper ne CACHE rien — c'est pourquoi cette bascule n'entre pas dans le compteur
|
|
du bouton « Filtrer », qui ne compte que ce qui rendrait des livres invisibles. Elle
|
|
se voit d'ailleurs dans la liste elle-même : avec ou sans blocs de série.
|
|
|
|
Décochée, on retrouve exactement le catalogue d'avant : tous les livres à plat.
|
|
*@
|
|
@if (_series is { Count: > 0 })
|
|
{
|
|
<label class="bascule">
|
|
<input type="checkbox" checked="@_grouper"
|
|
@onchange="e => _grouper = (bool?)e.Value ?? true" />
|
|
<span>Grouper les livres d'une même série</span>
|
|
</label>
|
|
}
|
|
|
|
<p class="champ-aide">
|
|
Le statut de lecture est <strong>le vôtre</strong> : filtrer dessus montre votre
|
|
lecture, pas celle du foyer. Le prêt, lui, est commun.
|
|
</p>
|
|
</div>
|
|
}
|
|
|
|
@if (_erreur is not null)
|
|
{
|
|
<p class="message-erreur" role="alert">@_erreur</p>
|
|
}
|
|
|
|
@if (_chargement && _livres is null)
|
|
{
|
|
<Patience Message="Chargement du catalogue…" />
|
|
}
|
|
else if (_livres is { Count: 0 })
|
|
{
|
|
<p class="message-discret">
|
|
@(EstFiltre ? "Aucun livre ne correspond à cette recherche." : "La bibliothèque est vide. Ajoutez un premier livre.")
|
|
</p>
|
|
}
|
|
else if (_livres is not null)
|
|
{
|
|
<p class="compteur">@_livres.Count livre@(_livres.Count > 1 ? "s" : "")</p>
|
|
|
|
@*
|
|
Modification en lot : même patron que la fiche série (barre collante, formulaire affiché
|
|
sous elle) — sauf que la sélection porte ici sur le catalogue ENTIER, groupé ou non, d'où
|
|
le bouton « Tout sélectionner » qui coche d'un coup tous les livres actuellement chargés
|
|
(donc déjà filtrés : cocher les 400 livres du fonds derrière un filtre à trois résultats
|
|
n'aurait aucun sens).
|
|
*@
|
|
@if (_lotOuvert)
|
|
{
|
|
<div id="lot-catalogue">
|
|
@if (_selectionLot.Count > 0)
|
|
{
|
|
<div class="barre-selection" role="group" aria-label="Actions sur la sélection">
|
|
<span class="selection-compte">
|
|
@_selectionLot.Count sélectionné@(_selectionLot.Count > 1 ? "s" : "")
|
|
</span>
|
|
<button type="button" class="bouton bouton-compact bouton-discret"
|
|
disabled="@_enCoursLot" @onclick="ToutSelectionnerLot">
|
|
Tout sélectionner (@_livres!.Count)
|
|
</button>
|
|
<button type="button" class="bouton bouton-compact bouton-discret"
|
|
disabled="@_enCoursLot" @onclick="() => _selectionLot.Clear()">
|
|
Tout décocher
|
|
</button>
|
|
</div>
|
|
|
|
<FormulaireLotLivres Saisie="_saisieLot" NombreLivres="_selectionLot.Count"
|
|
Erreur="@_erreurLot" EnCours="_enCoursLot" MessageBlocage="@MotifBlocageLot"
|
|
OnValider="EnregistrerLotAsync" OnAnnuler="AnnulerLot" />
|
|
}
|
|
else
|
|
{
|
|
<p class="message-discret">
|
|
Cochez les livres à modifier ensemble, ou
|
|
<button type="button" class="bouton bouton-discret bouton-compact"
|
|
@onclick="ToutSelectionnerLot">tout sélectionner (@_livres!.Count)</button>.
|
|
</p>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@*
|
|
Les tomes d'une série tiennent sous son nom, à la place qu'occuperait le premier d'entre
|
|
eux : l'ordre général du catalogue ne bouge pas, on ne perd donc pas un livre qu'on
|
|
cherchait à sa lettre. Le nom du bloc MÈNE À LA SÉRIE, où figurent aussi les tomes qu'on
|
|
ne possède pas — ce que le catalogue, lui, ne peut pas montrer.
|
|
|
|
Chaque bloc se REPLIE, et un cycle replie chacun de ses niveaux : un cycle de vingt
|
|
tomes occupait tout l'écran alors qu'on cherchait un livre rangé après lui. Replier ne
|
|
retire rien de la liste — le compteur du haut ne bouge pas, et le bloc replié dit
|
|
toujours ce qu'il contient.
|
|
|
|
⚠️ Ici, contrairement à l'arbre des séries, tout est REPLIÉ par défaut (2026-09-10) :
|
|
le catalogue liste des livres, pas des séries — une série n'y est qu'un regroupement
|
|
qu'on ouvre au besoin. Le bouton qui bascule tout l'arbre est dans l'en-tête, à côté
|
|
de « Filtrer », pas ici : c'est un réglage d'affichage, pas une action sur la liste.
|
|
*@
|
|
<ul class="liste-livres">
|
|
@foreach (var entree in Entrees)
|
|
{
|
|
@if (entree.EstGroupe)
|
|
{
|
|
<GroupeCatalogue Entree="entree" Ouverts="_ouverts" Totaux="_totauxSeries"
|
|
LotActif="_lotOuvert" SelectionLot="_selectionLot"
|
|
OnSelectionner="args => SelectionnerLot(args.LivreId, args.Coche)" />
|
|
}
|
|
else if (_lotOuvert)
|
|
{
|
|
<li class="carte-oeuvre" @key="entree.Livres[0].Id">
|
|
<label class="oeuvre-case">
|
|
<input type="checkbox"
|
|
checked="@_selectionLot.Contains(entree.Livres[0].Id)"
|
|
@onchange="e => SelectionnerLot(entree.Livres[0].Id, e.Value is true)" />
|
|
<span class="lecture-ecran">Sélectionner « @entree.Livres[0].Titre »</span>
|
|
</label>
|
|
<CarteLivre Livre="entree.Livres[0]" />
|
|
</li>
|
|
}
|
|
else
|
|
{
|
|
<li @key="entree.Livres[0].Id">
|
|
<CarteLivre Livre="entree.Livres[0]" />
|
|
</li>
|
|
}
|
|
}
|
|
</ul>
|
|
}
|
|
|
|
@code {
|
|
/// <summary>Restreint le catalogue à un auteur. Vient de « ?auteur=… », posé par les liens de la liste.</summary>
|
|
[SupplyParameterFromQuery(Name = "auteur")]
|
|
public int? AuteurId { get; set; }
|
|
|
|
private IReadOnlyList<LivreDto>? _livres;
|
|
|
|
/// <summary>
|
|
/// Les séries du foyer, pour regrouper leurs tomes. <c>null</c> = pas encore lues.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Elles ne sont lues <b>qu'une fois</b> par visite (et à chaque bascule du réseau), pas à
|
|
/// chaque chargement du catalogue : celui-ci se relit à chaque frappe dans la recherche, et
|
|
/// une requête de séries par caractère serait du gâchis pour une liste qui ne bouge pas
|
|
/// pendant qu'on tape. Le prix est qu'un tome rattaché depuis un autre écran n'apparaît
|
|
/// groupé qu'au prochain passage ici.
|
|
/// </remarks>
|
|
private IReadOnlyList<SerieDto>? _series;
|
|
|
|
/// <summary>Regroupement actif — l'utilisateur peut le défaire, rien ne disparaît alors.</summary>
|
|
private bool _grouper = true;
|
|
|
|
/// <summary>
|
|
/// Blocs ouverts à la main, par identifiant de série. Tout est replié par défaut.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Le jeu appartient à l'ÉCRAN, et non aux blocs : le catalogue se relit à chaque frappe
|
|
/// dans la recherche, et un état porté par les composants se perdrait à chaque caractère.
|
|
/// Il n'est pas persistant d'une visite à l'autre pour autant — c'est un confort de
|
|
/// lecture, pas une propriété de la série.
|
|
/// </remarks>
|
|
private readonly HashSet<int> _ouverts = [];
|
|
|
|
private bool AuMoinsUneSerieOuverte => _ouverts.Count > 0;
|
|
|
|
private void ToutReplier() => _ouverts.Clear();
|
|
|
|
private void ToutDeplier()
|
|
{
|
|
foreach (var id in IdentifiantsSeries(Entrees))
|
|
{
|
|
_ouverts.Add(id);
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<int> IdentifiantsSeries(IEnumerable<EntreeCatalogue> entrees)
|
|
{
|
|
foreach (var entree in entrees)
|
|
{
|
|
if (!entree.EstGroupe)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
yield return entree.Serie!.Id;
|
|
|
|
foreach (var id in IdentifiantsSeries(entree.SousEntrees))
|
|
{
|
|
yield return id;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ce que chaque série possède et recense, descendance comprise.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Recalculé <b>seulement</b> quand les séries changent, et non à chaque chargement du
|
|
/// catalogue : celui-ci se relit à chaque frappe dans la recherche.
|
|
/// </remarks>
|
|
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totauxSeries =
|
|
new Dictionary<int, (int, int)>();
|
|
|
|
/// <summary>Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés.</summary>
|
|
private IReadOnlyList<EntreeCatalogue> Entrees =>
|
|
GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null);
|
|
private AuteurDto? _auteur;
|
|
private int? _auteurCharge;
|
|
private string _recherche = string.Empty;
|
|
private Format? _format;
|
|
private TypeDocument? _type;
|
|
private Statut? _statut;
|
|
private bool? _prete;
|
|
|
|
private bool? _sansCouverture;
|
|
private bool _chargement;
|
|
private bool _filtresOuverts;
|
|
private string? _erreur;
|
|
|
|
/// <summary>Le panneau de modification en lot est déplié. Même patron que la fiche série.</summary>
|
|
private bool _lotOuvert;
|
|
|
|
/// <summary>Identifiants des livres cochés pour la modification en lot.</summary>
|
|
private readonly HashSet<int> _selectionLot = [];
|
|
|
|
private SaisieLot _saisieLot = new();
|
|
private bool _enCoursLot;
|
|
private string? _erreurLot;
|
|
|
|
private string? MotifBlocageLot => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
|
|
|
/// <summary>
|
|
/// Formats réellement présents dans le fonds, ou <c>null</c> tant qu'on ne le sait pas.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Ne se calcule que sur un chargement <b>sans aucun critère</b>. Le déduire d'une liste
|
|
/// filtrée ferait disparaître la ligne « Tous / Physique / Numérique » dès qu'une recherche
|
|
/// ne remonte qu'un seul format — et l'utilisateur ne pourrait plus l'élargir. Dans le doute
|
|
/// (arrivée directe sur un auteur, par exemple) la ligne reste affichée : montrer un filtre
|
|
/// inutile est bénin, en escamoter un qui sert ne l'est pas.
|
|
/// </remarks>
|
|
private IReadOnlySet<Format>? _formatsDuFonds;
|
|
|
|
private bool PlusieursFormats => _formatsDuFonds is null || _formatsDuFonds.Count > 1;
|
|
|
|
/// <summary>Types réellement présents dans le fonds. Mêmes précautions que les formats.</summary>
|
|
private IReadOnlySet<TypeDocument>? _typesDuFonds;
|
|
|
|
private bool PlusieursTypes => _typesDuFonds is null || _typesDuFonds.Count > 1;
|
|
|
|
/// <summary>
|
|
/// Vrai si au moins un livre du fonds est actuellement dehors — ou qu'on ne le sait pas.
|
|
/// </summary>
|
|
/// <remarks>Mêmes précautions que les formats : dans le doute, la ligne reste affichée.</remarks>
|
|
private bool? _fondsAvecPret;
|
|
|
|
private bool QuelqueChoseEstPrete => _fondsAvecPret is not false;
|
|
|
|
/// <summary>
|
|
/// Vrai si le fonds contient à la fois des livres avec et sans couverture — ou qu'on ne le
|
|
/// sait pas encore.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Il faut les DEUX, contrairement au filtre de prêt qui se contente d'un prêt en cours :
|
|
/// sur un fonds entièrement dépourvu de couvertures, « sans couverture » rendrait la liste
|
|
/// entière, et ne serait donc pas un filtre. Dans le doute, la ligne reste affichée.
|
|
/// </remarks>
|
|
private bool? _fondsCouverturesInegales;
|
|
|
|
private bool CouverturesInegales => _fondsCouverturesInegales is not false;
|
|
|
|
/// <summary>
|
|
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// La recherche et la restriction à un auteur n'y comptent pas : toutes deux se voient
|
|
/// déjà à l'écran, l'une dans son champ, l'autre dans sa phrase. Ce compteur ne dit que ce
|
|
/// qui serait invisible une fois le panneau refermé.
|
|
/// </remarks>
|
|
private int FiltresActifs =>
|
|
(_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1)
|
|
+ (_prete is null ? 0 : 1) + (_sansCouverture is null ? 0 : 1);
|
|
|
|
// Une frappe au clavier annule la recherche précédente encore en vol.
|
|
private CancellationTokenSource _cts = new();
|
|
|
|
private bool EstFiltre =>
|
|
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _type is not null
|
|
|| _statut is not null || _prete is not null || _sansCouverture is not null
|
|
|| AuteurId is not null;
|
|
|
|
/// <summary>
|
|
/// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du
|
|
/// serveur ; à la coupure, pour basculer sur l'instantané local sans laisser un écran vide.
|
|
/// </summary>
|
|
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
|
|
|
/// <summary>
|
|
/// Lit les séries, et se contente de ne pas grouper si elles manquent.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Une lecture ratée ne doit rien coûter au catalogue : sans série, la liste s'affiche à
|
|
/// plat, ce qui est exactement l'écran d'avant. Hors-ligne, la lecture retombe d'elle-même
|
|
/// sur l'instantané « series ».
|
|
/// </remarks>
|
|
private async Task ChargerSeriesAsync()
|
|
{
|
|
try
|
|
{
|
|
_series = await Api.ListerSeriesAsync();
|
|
_totauxSeries = TotauxSeries.Calculer(_series);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
_series = null;
|
|
_totauxSeries = new Dictionary<int, (int, int)>();
|
|
}
|
|
}
|
|
|
|
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
|
{
|
|
await ChargerSeriesAsync();
|
|
await ChargerAsync();
|
|
StateHasChanged();
|
|
});
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
if (_series is null)
|
|
{
|
|
await ChargerSeriesAsync();
|
|
}
|
|
|
|
if (_auteurCharge != AuteurId)
|
|
{
|
|
_auteurCharge = AuteurId;
|
|
_auteur = AuteurId is { } id ? await Api.ObtenirAuteurAsync(id) : null;
|
|
}
|
|
|
|
await ChargerAsync();
|
|
}
|
|
|
|
private async Task SurRecherche(ChangeEventArgs e)
|
|
{
|
|
_recherche = e.Value?.ToString() ?? string.Empty;
|
|
await ChargerAsync();
|
|
}
|
|
|
|
/// <summary>Le scanner est ouvert au-dessus de la liste, pour retrouver un livre en main.</summary>
|
|
private bool _scanOuvert;
|
|
|
|
/// <summary>
|
|
/// Un code lu remplit la recherche, exactement comme une douchette l'aurait tapé.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Le code reste dans le champ : un décodage erroné se corrige et se relance, comme sur
|
|
/// l'écran d'ajout. ⚠️ L'add-on EAN-2 est ignoré — il ne concerne que les revues, qui ne
|
|
/// sont pas au catalogue.
|
|
/// </remarks>
|
|
private async Task SurCodeScanneAsync(CodeScanne lecture)
|
|
{
|
|
_scanOuvert = false;
|
|
_recherche = lecture.Code;
|
|
await ChargerAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ouvre la fiche quand le code désigne <b>un seul</b> livre du catalogue.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// C'est tout l'intérêt du scan : on tient le livre, on veut sa fiche, pas une liste d'un
|
|
/// élément à cliquer.
|
|
/// <para>
|
|
/// ⚠️ Deux conditions, et les deux comptent. <b>Un seul résultat</b>, sinon on choisirait à
|
|
/// la place de l'utilisateur — deux exemplaires du même ISBN sont légitimes. Et un
|
|
/// <b>code complet et valide</b> : sur un fragment en cours de frappe, le résultat unique
|
|
/// est un hasard, et l'écran sauterait sous les doigts.
|
|
/// </para>
|
|
/// </remarks>
|
|
private bool OuvrirSiUnique()
|
|
{
|
|
if (_livres is not { Count: 1 } || !RechercheIsbn.EstCodeComplet(_recherche))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Navigation.NavigateTo($"livres/{_livres[0].Id}");
|
|
return true;
|
|
}
|
|
|
|
private Task FiltrerFormat(Format? format)
|
|
{
|
|
_format = format;
|
|
return ChargerAsync();
|
|
}
|
|
|
|
private Task FiltrerType(TypeDocument? type)
|
|
{
|
|
_type = type;
|
|
return ChargerAsync();
|
|
}
|
|
|
|
private Task FiltrerStatut(Statut? statut)
|
|
{
|
|
_statut = statut;
|
|
return ChargerAsync();
|
|
}
|
|
|
|
private Task FiltrerPret(bool? prete)
|
|
{
|
|
_prete = prete;
|
|
return ChargerAsync();
|
|
}
|
|
|
|
private Task FiltrerCouverture(bool? sansCouverture)
|
|
{
|
|
_sansCouverture = sansCouverture;
|
|
return ChargerAsync();
|
|
}
|
|
|
|
/// <summary>Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte.</summary>
|
|
private static bool EstSansCritere(CritereLivres criteres) =>
|
|
string.IsNullOrWhiteSpace(criteres.Recherche)
|
|
&& criteres.Format is null
|
|
&& criteres.TypeDocument is null
|
|
&& criteres.Statut is null
|
|
&& criteres.Prete is null
|
|
&& criteres.SansCouverture is null
|
|
&& criteres.AuteurId is null;
|
|
|
|
private async Task ChargerAsync()
|
|
{
|
|
_cts.Cancel();
|
|
_cts.Dispose();
|
|
_cts = new CancellationTokenSource();
|
|
var jeton = _cts.Token;
|
|
|
|
_chargement = true;
|
|
_erreur = null;
|
|
|
|
try
|
|
{
|
|
var criteres = new CritereLivres
|
|
{
|
|
Recherche = _recherche,
|
|
Format = _format,
|
|
TypeDocument = _type,
|
|
Statut = _statut,
|
|
Prete = _prete,
|
|
SansCouverture = _sansCouverture,
|
|
AuteurId = AuteurId,
|
|
};
|
|
|
|
_livres = await Api.ListerAsync(criteres, jeton);
|
|
|
|
if (OuvrirSiUnique())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (EstSansCritere(criteres))
|
|
{
|
|
_formatsDuFonds = _livres.Select(l => l.Format).ToHashSet();
|
|
_typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet();
|
|
_fondsAvecPret = _livres.Any(l => l.PreteA is not null);
|
|
_fondsCouverturesInegales =
|
|
_livres.Any(l => string.IsNullOrEmpty(l.CoverUrl))
|
|
&& _livres.Any(l => !string.IsNullOrEmpty(l.CoverUrl));
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return; // Une recherche plus récente a pris le relais.
|
|
}
|
|
catch (Exception)
|
|
{
|
|
_erreur = Reseau.EnLigne
|
|
? "Le catalogue n'a pas pu être chargé. Réessayez dans un instant."
|
|
: "Aucune donnée n'a encore pu être enregistrée sur cet appareil.";
|
|
}
|
|
finally
|
|
{
|
|
if (!jeton.IsCancellationRequested)
|
|
{
|
|
_chargement = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ouvre ou ferme le panneau de modification en lot. Le refermer vide aussi la sélection et
|
|
/// la saisie en cours — même règle que sur la fiche série : un panneau qui se rouvrirait sur
|
|
/// l'état d'avant laisserait croire à une saisie encore active.
|
|
/// </summary>
|
|
private void BasculerLot()
|
|
{
|
|
_lotOuvert = !_lotOuvert;
|
|
_erreurLot = null;
|
|
_selectionLot.Clear();
|
|
_saisieLot = new SaisieLot();
|
|
}
|
|
|
|
private void SelectionnerLot(int livreId, bool selectionne)
|
|
{
|
|
if (selectionne)
|
|
{
|
|
_selectionLot.Add(livreId);
|
|
}
|
|
else
|
|
{
|
|
_selectionLot.Remove(livreId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Coche d'un coup tous les livres actuellement CHARGÉS — donc déjà filtrés. Un groupe replié
|
|
/// n'y échappe pas : la sélection porte sur <c>_livres</c>, pas sur ce que l'écran affiche.
|
|
/// </summary>
|
|
private void ToutSelectionnerLot()
|
|
{
|
|
foreach (var livre in _livres ?? [])
|
|
{
|
|
_selectionLot.Add(livre.Id);
|
|
}
|
|
}
|
|
|
|
private void AnnulerLot()
|
|
{
|
|
_selectionLot.Clear();
|
|
_saisieLot = new SaisieLot();
|
|
_erreurLot = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applique la saisie en lot à chacun des livres cochés, un appel à la fois — même patron que
|
|
/// la fiche série : pas de point d'entrée dédié, la boucle réutilise
|
|
/// <see cref="ServiceLivresApi.ModifierAsync"/>, en ne remplaçant que les champs cochés et en
|
|
/// reprenant tels quels ceux qui ne le sont pas.
|
|
/// </summary>
|
|
private async Task EnregistrerLotAsync()
|
|
{
|
|
_enCoursLot = true;
|
|
_erreurLot = null;
|
|
|
|
var erreurs = new List<string>();
|
|
|
|
foreach (var livreId in _selectionLot)
|
|
{
|
|
var livre = (_livres ?? []).FirstOrDefault(l => l.Id == livreId);
|
|
if (livre is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var saisie = new EnregistrementLivre
|
|
{
|
|
Isbn = livre.Isbn,
|
|
Titre = livre.Titre,
|
|
Auteurs = _saisieLot.ModifierAuteurs
|
|
? _saisieLot.Auteurs
|
|
: livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).ToList(),
|
|
Themes = _saisieLot.ModifierThemes ? _saisieLot.Themes : [.. livre.Themes],
|
|
Editeur = _saisieLot.ModifierEditeur ? _saisieLot.Editeur : livre.Editeur,
|
|
Format = _saisieLot.ModifierFormat ? _saisieLot.Format : livre.Format,
|
|
TypeDocument = _saisieLot.ModifierType ? _saisieLot.TypeDocument : livre.TypeDocument,
|
|
Statut = _saisieLot.ModifierStatut ? _saisieLot.Statut : livre.Statut,
|
|
NombrePages = livre.NombrePages,
|
|
CoverUrl = livre.CoverUrl,
|
|
UrlNotice = livre.UrlNotice,
|
|
};
|
|
|
|
var resultat = await Api.ModifierAsync(livre.Id, saisie);
|
|
if (!resultat.EstOk)
|
|
{
|
|
erreurs.Add(resultat.Erreur!);
|
|
}
|
|
}
|
|
|
|
_enCoursLot = false;
|
|
|
|
if (erreurs.Count > 0)
|
|
{
|
|
_erreurLot = string.Join(" ", erreurs.Distinct());
|
|
await ChargerAsync();
|
|
return;
|
|
}
|
|
|
|
_lotOuvert = false;
|
|
_selectionLot.Clear();
|
|
_saisieLot = new SaisieLot();
|
|
await ChargerAsync();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Reseau.Change -= SurChangementReseau;
|
|
_cts.Cancel();
|
|
_cts.Dispose();
|
|
}
|
|
}
|