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>
444 lines
18 KiB
Plaintext
444 lines
18 KiB
Plaintext
@page "/"
|
|
@inject ServiceLivresApi Api
|
|
@inject EtatReseau Reseau
|
|
@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.
|
|
*@
|
|
<div class="entete-page">
|
|
<h1 class="titre-page">Catalogue</h1>
|
|
|
|
<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>
|
|
</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>
|
|
}
|
|
|
|
<div class="barre-recherche">
|
|
<input class="champ-saisie" type="search" placeholder="Titre ou auteur…"
|
|
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
|
|
</div>
|
|
|
|
@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>
|
|
}
|
|
|
|
@*
|
|
⚠️ 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)
|
|
{
|
|
<p class="message-discret">Chargement…</p>
|
|
}
|
|
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>
|
|
|
|
@*
|
|
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.
|
|
*@
|
|
<ul class="liste-livres">
|
|
@foreach (var entree in Entrees)
|
|
{
|
|
@if (entree.EstGroupe)
|
|
{
|
|
<GroupeCatalogue Entree="entree" Replies="_replies" />
|
|
}
|
|
else
|
|
{
|
|
<li @key="entree.Livres[0].Id">
|
|
<CarteLivre Livre="entree.Livres[0]" />
|
|
</li>
|
|
}
|
|
}
|
|
</ul>
|
|
}
|
|
|
|
@*
|
|
Une seule entrée d'ajout, et plus aucun lien de navigation : les quatre écrans sont dans
|
|
les onglets, en bas.
|
|
|
|
« Saisie manuelle » est retirée d'ici — elle reste accessible un écran plus loin, depuis
|
|
« Ajouter », qui la propose dès qu'un ISBN ne donne rien.
|
|
|
|
⚠️ Hors-ligne le bouton 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. Le
|
|
désencombrement demandé passe par le regroupement (deux boutons en un), pas par l'effacement.
|
|
*@
|
|
<nav class="actions-flottantes">
|
|
@if (Reseau.EnLigne)
|
|
{
|
|
<a class="bouton bouton-principal" href="ajout">Ajouter un ouvrage</a>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="bouton bouton-principal" disabled
|
|
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
|
|
}
|
|
</nav>
|
|
|
|
@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 repliés à la main, par identifiant de série. Tout est déplié 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, même règle que l'arbre des séries.
|
|
/// </remarks>
|
|
private readonly HashSet<int> _replies = [];
|
|
|
|
/// <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 _chargement;
|
|
private bool _filtresOuverts;
|
|
private string? _erreur;
|
|
|
|
/// <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>
|
|
/// 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);
|
|
|
|
// 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 || 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();
|
|
}
|
|
catch (Exception)
|
|
{
|
|
_series = null;
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// <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.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,
|
|
AuteurId = AuteurId,
|
|
};
|
|
|
|
_livres = await Api.ListerAsync(criteres, jeton);
|
|
|
|
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);
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Reseau.Change -= SurChangementReseau;
|
|
_cts.Cancel();
|
|
_cts.Dispose();
|
|
}
|
|
}
|