Transforme la bibliographie en écran de travail D1 à D5

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LIMONIER Mathieu
2026-08-20 13:42:46 +02:00
co-authored by Copilot
parent ddc60898da
commit 359f4f59c6
13 changed files with 1044 additions and 20 deletions
+151 -15
View File
@@ -86,6 +86,10 @@ else if (_bibliographie is { } biblio)
<p class="compteur">
@biblio.Oeuvres.Count œuvre@(biblio.Oeuvres.Count > 1 ? "s" : "")
— @_possedees déjà chez vous
@if (_masquees > 0)
{
<text> — @_masquees masquée@(_masquees > 1 ? "s" : "")</text>
}
</p>
@* Deux phrases distinctes plutôt qu'une seule à trous : enchaîner des fragments
@@ -124,16 +128,37 @@ else if (_bibliographie is { } biblio)
<p class="message-erreur" role="alert">@_message</p>
}
@* Filtre sur une seule question : « qu'est-ce qui me manque ? ». Les livres possédés ne
sont jamais masqués par défaut — le grisage est l'information principale de l'écran. *@
<div class="filtres">
<div class="segments" role="group" aria-label="Filtrer la bibliographie">
<button type="button" class="segment @(_seulementManquants ? null : "segment-actif")"
@onclick="() => _seulementManquants = false">Tout</button>
<button type="button" class="segment @(_seulementManquants ? "segment-actif" : null)"
@onclick="() => _seulementManquants = true">Ce qui me manque</button>
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-discret"
@onclick="() => _filtresOuverts = !_filtresOuverts"
aria-expanded="@_filtresOuverts">
Filtrer (@NombreFiltres)
</button>
</div>
@if (_filtresOuverts)
{
<div class="filtres">
<div class="segments" role="group" aria-label="Possession">
<button type="button" class="segment @(_modePossession == 0 ? "segment-actif" : null)"
@onclick="() => _modePossession = 0">Tout</button>
<button type="button" class="segment @(_modePossession == 1 ? "segment-actif" : null)"
@onclick="() => _modePossession = 1">Ceux que j'ai déjà</button>
<button type="button" class="segment @(_modePossession == 2 ? "segment-actif" : null)"
@onclick="() => _modePossession = 2">Ce qui me manque</button>
</div>
@if (_masquees > 0)
{
<div class="segments">
<button type="button" class="segment @(_afficherMasquees ? "segment-actif" : null)"
@onclick="() => _afficherMasquees = true">
Afficher les masquées (@_masquees)
</button>
<button type="button" class="segment @(!_afficherMasquees ? "segment-actif" : null)"
@onclick="() => _afficherMasquees = false">Masquer les masquées</button>
</div>
}
</div>
}
<ul class="liste-bibliographie">
@foreach (var oeuvre in Affichees(biblio))
@@ -204,16 +229,51 @@ else if (_bibliographie is { } biblio)
@if (oeuvre.ADecouvrir)
{
<button type="button" class="bouton bouton-compact bouton-principal"
disabled="@_enCours" @onclick="() => SouhaiterAsync(oeuvre)"
aria-label="@($"Ajouter « {oeuvre.Titre} » à ma liste d'envies")">
Je le veux
<div class="actions-formulaire">
<label class="selection-oeuvre">
<input type="checkbox"
checked="@_selection.Contains(oeuvre.Titre)"
@onchange="e => Selectionner(oeuvre.Titre, e.Value is true)" />
Sélectionner
</label>
<button type="button" class="bouton bouton-compact bouton-principal"
disabled="@_enCours" @onclick="() => SouhaiterAsync(oeuvre)"
aria-label="@($"Ajouter « {oeuvre.Titre} » à ma liste d'envies")">
Je le veux
</button>
</div>
}
@if (!oeuvre.Masquee)
{
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCours" @onclick="() => MasquerAsync(oeuvre)">
Masquer
</button>
}
else
{
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCours" @onclick="() => DemasquerAsync(oeuvre)">
Réafficher
</button>
}
</li>
}
</ul>
@if (_selection.Count > 0)
{
<div class="actions-flottantes">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne)"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="AjouterSelectionAsync">
Ajouter la sélection aux envies (@_selection.Count)
</button>
</div>
}
@* Le rapprochement est fait par titre, et il rate des choses (voir CleOeuvre). Le dire
franchement vaut mieux que laisser l'utilisateur découvrir seul qu'un livre qu'il
possède n'est pas grisé. *@
@@ -239,7 +299,9 @@ else if (_bibliographie is { } biblio)
public int AuteurId { get; set; }
private BibliographieDto? _bibliographie;
private bool _seulementManquants;
private int _modePossession;
private bool _filtresOuverts;
private bool _afficherMasquees;
private bool _enCours;
private bool _chargement;
@@ -250,13 +312,22 @@ else if (_bibliographie is { } biblio)
private string? _message;
private int _possedees;
private int _masquees;
private IReadOnlyList<LivreDto> _livresCatalogue = [];
private readonly HashSet<string> _oeuvresOuvertes = [];
private readonly HashSet<string> _selection = [];
protected override async Task OnParametersSetAsync() => await ChargerAsync();
private IEnumerable<OeuvreBibliographie> Affichees(BibliographieDto biblio) =>
_seulementManquants ? biblio.Oeuvres.Where(o => o.ADecouvrir) : biblio.Oeuvres;
biblio.Oeuvres.Where(o =>
(_afficherMasquees || !o.Masquee)
&& (_modePossession == 0
|| (_modePossession == 1 && o.Possede)
|| (_modePossession == 2 && o.ADecouvrir)));
private int NombreFiltres =>
(_modePossession == 0 ? 0 : 1) + (_masquees > 0 && !_afficherMasquees ? 1 : 0);
private async Task ChargerAsync()
{
@@ -278,6 +349,7 @@ else if (_bibliographie is { } biblio)
}
_possedees = _bibliographie.Oeuvres.Count(o => o.Possede);
_masquees = _bibliographie.Oeuvres.Count(o => o.Masquee);
}
catch (Exception)
{
@@ -300,6 +372,70 @@ else if (_bibliographie is { } biblio)
}
}
private void Selectionner(string titre, bool selectionnee)
{
if (selectionnee)
{
_selection.Add(titre);
}
else
{
_selection.Remove(titre);
}
}
private async Task AjouterSelectionAsync()
{
if (_bibliographie is null)
{
return;
}
var saisies = _bibliographie.Oeuvres
.Where(o => _selection.Contains(o.Titre) && o.ADecouvrir)
.Select(o => new EnregistrementSouhait
{
Titre = o.Titre,
Auteur = _bibliographie.Auteur.Nom,
Editeur = o.Editeur,
Annee = o.Annee,
Isbn = o.Isbn,
})
.ToList();
_enCours = true;
_message = null;
var resultats = await Api.AjouterSouhaitsAsync(saisies);
_enCours = false;
var erreurs = resultats.Where(r => !r.EstOk).Select(r => r.Erreur).Where(e => e is not null).ToList();
_message = erreurs.Count == 0 ? null : string.Join(" ", erreurs);
_selection.Clear();
await ChargerAsync();
}
private async Task MasquerAsync(OeuvreBibliographie oeuvre)
{
await ModifierMasquageAsync(oeuvre, false);
}
private async Task DemasquerAsync(OeuvreBibliographie oeuvre)
{
await ModifierMasquageAsync(oeuvre, true);
}
private async Task ModifierMasquageAsync(OeuvreBibliographie oeuvre, bool demasquer)
{
_enCours = true;
_message = demasquer
? await Api.DemasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre)
: await Api.MasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre);
_enCours = false;
if (_message is null)
{
await ChargerAsync();
}
}
private void BasculerOeuvre(string titre)
{
if (!_oeuvresOuvertes.Add(titre))
+63 -3
View File
@@ -25,6 +25,7 @@ namespace MaBibli.Client.Services;
public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, EtatReseau reseau)
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly Dictionary<int, BibliographieDto> _bibliographies = [];
/// <summary>
/// Recharge tous les instantanés depuis le réseau.
@@ -615,8 +616,29 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
}
public async Task<ResultatApi<SouhaitDto>> AjouterSouhaitAsync(
EnregistrementSouhait saisie, CancellationToken ct = default) =>
await EcrireAsync<SouhaitDto>(() => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct);
EnregistrementSouhait saisie, CancellationToken ct = default)
{
var resultat = await EcrireAsync<SouhaitDto>(
() => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct);
if (resultat.EstOk)
{
_bibliographies.Clear();
}
return resultat;
}
public async Task<IReadOnlyList<ResultatApi<SouhaitDto>>> AjouterSouhaitsAsync(
IReadOnlyList<EnregistrementSouhait> saisies, CancellationToken ct = default)
{
var resultats = new List<ResultatApi<SouhaitDto>>(saisies.Count);
foreach (var saisie in saisies)
{
resultats.Add(await AjouterSouhaitAsync(saisie, ct));
}
return resultats;
}
/// <summary>
/// Retire une envie. <c>null</c> si c'est fait, sinon le motif à afficher.
@@ -735,10 +757,48 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
public async Task<BibliographieDto?> ObtenirBibliographieAsync(
int auteurId, CancellationToken ct = default)
{
if (_bibliographies.TryGetValue(auteurId, out var cachee)
&& cachee.Etat == EtatSourceBibliographie.Ok)
{
return cachee;
}
var reponse = await http.GetAsync($"api/auteurs/{auteurId}/bibliographie", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
var bibliographie = reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync<BibliographieDto>(reponse, ct);
if (bibliographie?.Etat == EtatSourceBibliographie.Ok)
{
_bibliographies[auteurId] = bibliographie;
}
return bibliographie;
}
public async Task<string?> MasquerOeuvreBibliographieAsync(
int auteurId, string titre, CancellationToken ct = default) =>
await ModifierBibliographieAsync(
() => http.PostAsJsonAsync(
$"api/auteurs/{auteurId}/bibliographie/masque",
new MasquageBibliographie { Titre = titre }, Json, ct), ct);
public async Task<string?> DemasquerOeuvreBibliographieAsync(
int auteurId, string titre, CancellationToken ct = default) =>
await ModifierBibliographieAsync(
() => http.DeleteAsync(
$"api/auteurs/{auteurId}/bibliographie/masque?titre={Uri.EscapeDataString(titre)}"), ct);
private async Task<string?> ModifierBibliographieAsync(
Func<Task<HttpResponseMessage>> appel, CancellationToken ct)
{
var (reponse, motif) = await EnvoyerAsync(appel);
if (reponse is null)
{
return motif;
}
_bibliographies.Clear();
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
}
// ─────────────────────────────────────────────────────────────────────────