Implémente C1 à C5 pour les séries et cycles
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
parent
3d5e73838e
commit
ddc60898da
@@ -0,0 +1,121 @@
|
||||
@using MaBibli.Shared.Dtos
|
||||
|
||||
<li class="carte-serie arbre-serie" @key="Serie.Id">
|
||||
<div class="arbre-serie-entete">
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
aria-expanded="@Deplie"
|
||||
@onclick="Basculer">
|
||||
@(Deplie ? "▼" : "▶")
|
||||
</button>
|
||||
<a class="carte-titre" href="@($"series/{Serie.Id}")">@Serie.Titre</a>
|
||||
<span class="carte-details">@Avancement()</span>
|
||||
@if (ActionsActives)
|
||||
{
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
disabled="@(_index == 0)" title="@MotifBlocage"
|
||||
aria-label="@($"Monter « {Serie.Titre} »")"
|
||||
@onclick="Monter">▲</button>
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
disabled="@(_index < 0 || _index == Soeurs.Count - 1)" title="@MotifBlocage"
|
||||
aria-label="@($"Descendre « {Serie.Titre} »")"
|
||||
@onclick="Descendre">▼</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Deplie)
|
||||
{
|
||||
<div class="arbre-serie-detail">
|
||||
@if (Serie.Elements.Count == 0)
|
||||
{
|
||||
<p class="message-discret">Aucun tome recensé.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var element in Serie.Elements)
|
||||
{
|
||||
@if (element.LivreId is { } livreId
|
||||
&& Livres.FirstOrDefault(l => l.Id == livreId) is { } livre)
|
||||
{
|
||||
<CarteLivre Livre="livre" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="carte-tome tome-manquant">
|
||||
@element.Titre
|
||||
<span class="etiquette etiquette-manquant">Pas dans la bibliothèque</span>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if (Enfants.Count > 0)
|
||||
{
|
||||
<ul class="liste-sous-series">
|
||||
@foreach (var enfant in Enfants.Where(e => !CheminAvecSerie.Contains(e.Id)))
|
||||
{
|
||||
<ArbreSerie Serie="enfant" Toutes="Toutes" Livres="Livres"
|
||||
Totaux="Totaux" Chemin="@CheminAvecSerie"
|
||||
ActionsActives="ActionsActives"
|
||||
MotifBlocage="MotifBlocage"
|
||||
Deplies="Deplies"
|
||||
OnDeplacer="OnDeplacer" />
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public SerieDto Serie { get; set; } = default!;
|
||||
[Parameter, EditorRequired] public IReadOnlyList<SerieDto> Toutes { get; set; } = [];
|
||||
[Parameter, EditorRequired] public IReadOnlyList<LivreDto> Livres { get; set; } = [];
|
||||
[Parameter, EditorRequired] public IReadOnlyDictionary<int, (int Possedes, int Total)> Totaux { get; set; } = new Dictionary<int, (int, int)>();
|
||||
[Parameter] public IReadOnlySet<int> Chemin { get; set; } = new HashSet<int>();
|
||||
[Parameter] public ISet<int> Deplies { get; set; } = new HashSet<int>();
|
||||
[Parameter] public bool ActionsActives { get; set; }
|
||||
[Parameter] public string? MotifBlocage { get; set; }
|
||||
[Parameter] public EventCallback<DeplacementSerie> OnDeplacer { get; set; }
|
||||
|
||||
private List<SerieDto> Soeurs => Toutes
|
||||
.Where(s => s.SerieParenteId == Serie.SerieParenteId)
|
||||
.OrderBy(s => s.Position)
|
||||
.ThenBy(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
private List<SerieDto> Enfants => Toutes
|
||||
.Where(s => s.SerieParenteId == Serie.Id)
|
||||
.OrderBy(s => s.Position)
|
||||
.ThenBy(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
private int _index => Soeurs.FindIndex(s => s.Id == Serie.Id);
|
||||
private bool Deplie => Deplies.Contains(Serie.Id);
|
||||
private IReadOnlySet<int> CheminAvecSerie => new HashSet<int>(Chemin) { Serie.Id };
|
||||
|
||||
private string Avancement()
|
||||
{
|
||||
var total = Totaux.TryGetValue(Serie.Id, out var valeur)
|
||||
? valeur
|
||||
: (Possedes: Serie.NombrePossedes, Total: Serie.Elements.Count);
|
||||
return total.Total == 0
|
||||
? "Aucun tome recensé"
|
||||
: $"{total.Possedes} sur {total.Total} tome{(total.Total > 1 ? "s" : "")}";
|
||||
}
|
||||
|
||||
private void Basculer()
|
||||
{
|
||||
if (!Deplies.Add(Serie.Id))
|
||||
{
|
||||
Deplies.Remove(Serie.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private Task Monter() => Deplacer(-1);
|
||||
private Task Descendre() => Deplacer(1);
|
||||
|
||||
private Task Deplacer(int delta) =>
|
||||
Serie.SerieParenteId is { } parent
|
||||
? OnDeplacer.InvokeAsync(new DeplacementSerie(Serie.Id, parent, delta))
|
||||
: Task.CompletedTask;
|
||||
}
|
||||
@@ -34,7 +34,8 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>@_serie.NombrePossedes sur @_serie.Elements.Count tomes dans la bibliothèque.</text>
|
||||
var total = Total(_serie);
|
||||
<text>@total.Possedes sur @total.Total tomes dans la bibliothèque.</text>
|
||||
}
|
||||
</p>
|
||||
|
||||
@@ -50,19 +51,19 @@ else
|
||||
<p class="message-avertissement" role="status">@_message</p>
|
||||
}
|
||||
|
||||
@* Les sous-séries d'un cycle : on les liste, on ne les déplie pas — chacune a son écran. *@
|
||||
@* L'arbre est rendu récursivement ; le chemin protège le rendu si une base ancienne contient
|
||||
malgré tout une boucle. *@
|
||||
@if (!ModeEdition && _filles.Count > 0)
|
||||
{
|
||||
<h2 class="titre-section">Séries de ce cycle</h2>
|
||||
<ul class="liste-sous-series">
|
||||
@foreach (var fille in _filles)
|
||||
{
|
||||
<li @key="fille.Id">
|
||||
<a href="@($"series/{fille.Id}")">@fille.Titre</a>
|
||||
<span class="carte-details">
|
||||
@fille.NombrePossedes sur @fille.Elements.Count
|
||||
</span>
|
||||
</li>
|
||||
<ArbreSerie Serie="fille" Toutes="_toutes" Livres="_livresCatalogue"
|
||||
Totaux="_totaux" Deplies="_deplies"
|
||||
Chemin="@CheminRacine"
|
||||
ActionsActives="Reseau.EnLigne" MotifBlocage="@MotifBlocage"
|
||||
OnDeplacer="DeplacerSousSerieAsync" />
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@@ -149,6 +150,12 @@ else
|
||||
@onclick="() => OuvrirRattachement(element.Id)">
|
||||
Je l'ai
|
||||
</button>
|
||||
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => OuvrirLookup(element)">
|
||||
Ajouter par ISBN
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -183,6 +190,47 @@ else
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_lookupElement?.Id == element.Id)
|
||||
{
|
||||
<div class="recherche-livre">
|
||||
<input class="champ-saisie" type="text" inputmode="numeric"
|
||||
placeholder="ISBN du tome…"
|
||||
@bind="_lookupIsbn" @bind:event="oninput"
|
||||
@onkeydown="SurToucheLookup" />
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_lookupIsbn))"
|
||||
title="@MotifBlocage" @onclick="ChercherLookupAsync">
|
||||
@(_lookupEnCours ? "Recherche…" : "Préremplir")
|
||||
</button>
|
||||
@if (_lookupCandidats.Count > 0)
|
||||
{
|
||||
@foreach (var candidat in _lookupCandidats)
|
||||
{
|
||||
<div class="carte-candidat">
|
||||
<div class="carte-corps">
|
||||
<p class="carte-titre">@candidat.Titre</p>
|
||||
<p class="carte-auteur">@candidat.Auteur</p>
|
||||
<p class="carte-details">
|
||||
@(candidat.Editeur ?? "éditeur inconnu")
|
||||
· @(candidat.Annee ?? "année inconnue")
|
||||
</p>
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@_enCours"
|
||||
@onclick="() => AjouterCommeLivreAsync(candidat)">
|
||||
Ajouter comme livre
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
disabled="@_enCours"
|
||||
@onclick="() => AjouterCommeEnvieAsync(candidat)">
|
||||
Ajouter comme envie
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@@ -312,6 +360,9 @@ else
|
||||
private SerieDto? _serie;
|
||||
private SerieDto? _parente;
|
||||
private List<SerieDto> _filles = [];
|
||||
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totaux =
|
||||
new Dictionary<int, (int, int)>();
|
||||
private readonly HashSet<int> _deplies = [];
|
||||
|
||||
private bool _chargement = true;
|
||||
private bool _enCours;
|
||||
@@ -326,8 +377,13 @@ else
|
||||
private int? _rattachement;
|
||||
private string _recherche = string.Empty;
|
||||
private IReadOnlyList<LivreDto> _resultats = [];
|
||||
private ElementSerieDto? _lookupElement;
|
||||
private string _lookupIsbn = string.Empty;
|
||||
private IReadOnlyList<CandidatLivre> _lookupCandidats = [];
|
||||
private bool _lookupEnCours;
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
private IReadOnlySet<int> CheminRacine => new HashSet<int> { Id };
|
||||
|
||||
private bool ModeEdition =>
|
||||
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
|
||||
@@ -410,6 +466,7 @@ else
|
||||
: null;
|
||||
|
||||
_filles = _toutes.Where(s => s.SerieParenteId == Id).OrderBy(s => s.Position).ToList();
|
||||
_totaux = TotauxSeries.Calculer(_toutes);
|
||||
|
||||
_titre = _serie.Titre;
|
||||
_parenteId = _serie.SerieParenteId ?? 0;
|
||||
@@ -478,6 +535,140 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private (int Possedes, int Total) Total(SerieDto serie) =>
|
||||
_totaux.TryGetValue(serie.Id, out var total)
|
||||
? total
|
||||
: (serie.NombrePossedes, serie.Elements.Count);
|
||||
|
||||
private async Task DeplacerSousSerieAsync(DeplacementSerie mouvement)
|
||||
{
|
||||
var soeurs = _toutes
|
||||
.Where(s => s.SerieParenteId == mouvement.ParenteId)
|
||||
.OrderBy(s => s.Position)
|
||||
.ThenBy(s => s.Id)
|
||||
.ToList();
|
||||
var index = soeurs.FindIndex(s => s.Id == mouvement.SerieId);
|
||||
var cible = index + mouvement.Delta;
|
||||
if (index < 0 || cible < 0 || cible >= soeurs.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ids = soeurs.Select(s => s.Id).ToList();
|
||||
(ids[index], ids[cible]) = (ids[cible], ids[index]);
|
||||
await EcrireAsync(() => Api.ReordonnerSousSeriesAsync(mouvement.ParenteId, ids));
|
||||
if (_erreur is null)
|
||||
{
|
||||
await ChargerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OuvrirLookup(ElementSerieDto element)
|
||||
{
|
||||
_lookupElement = element;
|
||||
_lookupIsbn = string.Empty;
|
||||
_lookupCandidats = [];
|
||||
_erreur = null;
|
||||
}
|
||||
|
||||
private async Task SurToucheLookup(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_lookupIsbn))
|
||||
{
|
||||
await ChercherLookupAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ChercherLookupAsync()
|
||||
{
|
||||
_lookupEnCours = true;
|
||||
_erreur = null;
|
||||
_lookupCandidats = [];
|
||||
var resultat = await Api.ChercherIsbnAsync(_lookupIsbn);
|
||||
_lookupEnCours = false;
|
||||
|
||||
if (resultat is null)
|
||||
{
|
||||
_erreur = Reseau.EnLigne
|
||||
? "La recherche n'a rien pu ramener."
|
||||
: EtatReseau.MotifHorsLigne;
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultat.Periodique is not null)
|
||||
{
|
||||
_erreur = "Les revues ne peuvent pas être rattachées à une série.";
|
||||
return;
|
||||
}
|
||||
|
||||
_lookupCandidats = resultat.Candidats;
|
||||
if (_lookupCandidats.Count == 0)
|
||||
{
|
||||
_erreur = "Aucune notice ne correspond à cet ISBN.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AjouterCommeEnvieAsync(CandidatLivre candidat)
|
||||
{
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
var resultat = await Api.AjouterSouhaitAsync(new EnregistrementSouhait
|
||||
{
|
||||
Titre = candidat.Titre,
|
||||
Auteur = candidat.Auteur,
|
||||
Editeur = candidat.Editeur,
|
||||
Annee = candidat.Annee,
|
||||
Isbn = candidat.IsbnInterroge,
|
||||
CoverUrl = candidat.CoverUrl,
|
||||
});
|
||||
_enCours = false;
|
||||
_erreur = resultat.Erreur;
|
||||
if (resultat.EstOk)
|
||||
{
|
||||
_message = $"« {candidat.Titre} » est dans votre liste d'envies.";
|
||||
_lookupElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AjouterCommeLivreAsync(CandidatLivre candidat)
|
||||
{
|
||||
if (_lookupElement is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
var saisie = new EnregistrementLivre
|
||||
{
|
||||
Isbn = candidat.IsbnInterroge,
|
||||
Titre = candidat.Titre,
|
||||
Auteurs = candidat.Auteurs.Select(a => new AuteurSaisi(a)).ToList(),
|
||||
Editeur = candidat.Editeur,
|
||||
CoverUrl = candidat.CoverUrl,
|
||||
};
|
||||
var resultat = await Api.CreerAsync(saisie);
|
||||
_enCours = false;
|
||||
|
||||
if (resultat.Doublons is not null)
|
||||
{
|
||||
_erreur = resultat.Doublons.Message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
await EcrireAsync(() => Api.RattacherLivreSerieAsync(_lookupElement.Id, resultat.Livre!.Id));
|
||||
if (_erreur is null)
|
||||
{
|
||||
_lookupElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
private string? _message;
|
||||
|
||||
private async Task DeplacerAsync(int depuis, int vers)
|
||||
|
||||
@@ -50,30 +50,18 @@ else
|
||||
<ul class="liste-series">
|
||||
@foreach (var serie in Racines)
|
||||
{
|
||||
<li class="carte-serie" @key="serie.Id">
|
||||
<a class="carte-titre" href="@($"series/{serie.Id}")">@serie.Titre</a>
|
||||
<p class="carte-details">@Avancement(serie)</p>
|
||||
|
||||
@* Un cycle contient des séries : elles se voient d'ici, sans ouvrir le cycle. *@
|
||||
@if (Filles(serie.Id) is { Count: > 0 } filles)
|
||||
{
|
||||
<ul class="liste-sous-series">
|
||||
@foreach (var fille in filles)
|
||||
{
|
||||
<li @key="fille.Id">
|
||||
<a href="@($"series/{fille.Id}")">@fille.Titre</a>
|
||||
<span class="carte-details">@Avancement(fille)</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</li>
|
||||
<ArbreSerie Serie="serie" Toutes="_series" Livres="_livres"
|
||||
Totaux="_totaux" Deplies="_deplies" />
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<SerieDto>? _series;
|
||||
private IReadOnlyList<LivreDto> _livres = [];
|
||||
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totaux =
|
||||
new Dictionary<int, (int, int)>();
|
||||
private readonly HashSet<int> _deplies = [];
|
||||
private string _nouveau = string.Empty;
|
||||
private bool _enCours;
|
||||
private string? _erreur;
|
||||
@@ -84,21 +72,6 @@ else
|
||||
private IEnumerable<SerieDto> Racines =>
|
||||
_series?.Where(s => s.SerieParenteId is null) ?? [];
|
||||
|
||||
private List<SerieDto> Filles(int parenteId) =>
|
||||
_series?.Where(s => s.SerieParenteId == parenteId).OrderBy(s => s.Position).ToList() ?? [];
|
||||
|
||||
/// <summary>
|
||||
/// « 4 tomes sur 7 » : le compteur est l'information principale de l'écran.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Une série sans aucun tome ne se décrit pas par « 0 sur 0 », qui ressemble à une erreur :
|
||||
/// elle dit ce qu'il faut faire.
|
||||
/// </remarks>
|
||||
private static string Avancement(SerieDto serie) =>
|
||||
serie.Elements.Count == 0
|
||||
? "Aucun tome recensé"
|
||||
: $"{serie.NombrePossedes} tome{(serie.NombrePossedes > 1 ? "s" : "")} sur {serie.Elements.Count}";
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
||||
@@ -111,7 +84,12 @@ else
|
||||
|
||||
protected override Task OnInitializedAsync() => ChargerAsync();
|
||||
|
||||
private async Task ChargerAsync() => _series = await Api.ListerSeriesAsync();
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
_series = await Api.ListerSeriesAsync();
|
||||
_livres = await Api.ListerAsync(new CritereLivres());
|
||||
_totaux = TotauxSeries.Calculer(_series);
|
||||
}
|
||||
|
||||
private async Task SurTouche(KeyboardEventArgs e)
|
||||
{
|
||||
|
||||
@@ -790,6 +790,13 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
|
||||
() => http.PutAsJsonAsync(
|
||||
$"api/series/{serieId}/ordre", new OrdreElementsSerie { Ids = [.. ids] }, Json, ct), ct);
|
||||
|
||||
public async Task<ResultatApi<SerieDto>> ReordonnerSousSeriesAsync(
|
||||
int serieId, IReadOnlyList<int> ids, CancellationToken ct = default) =>
|
||||
await EcrireAsync<SerieDto>(
|
||||
() => http.PutAsJsonAsync(
|
||||
$"api/series/{serieId}/sous-series/ordre",
|
||||
new OrdreSeries { Ids = [.. ids] }, Json, ct), ct);
|
||||
|
||||
/// <summary>Met un tome manquant dans <b>ma</b> liste d'envies — la série, elle, est commune.</summary>
|
||||
public async Task<ResultatApi<SouhaitDto>> SouhaiterTomeAsync(
|
||||
int elementId, CancellationToken ct = default) =>
|
||||
|
||||
Reference in New Issue
Block a user