Files
mabibli/MaBibli.Client/Pages/Serie.razor
T

775 lines
28 KiB
Plaintext

@page "/series/{Id:int}"
@page "/series/{Id:int}/edition"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — @(_serie?.Titre ?? "série")</PageTitle>
@if (_serie is null)
{
<p class="message-discret" role="status">
@(_chargement ? "Chargement…" : "Cette série n'est pas dans les données enregistrées sur cet appareil.")
</p>
}
else
{
@if (!ModeEdition && _parente is { } parente)
{
@if (!ModeEdition)
{
<p class="message-discret">
Fait partie de <a href="@($"series/{parente.Id}")">@parente.Titre</a>.
</p>
}
}
<h1 class="titre-page">@_serie.Titre</h1>
<p class="message-discret">
@if (_serie.Elements.Count == 0)
{
<text>Aucun tome recensé. Ajoutez ceux que vous possédez, et ceux qui vous manquent.</text>
}
else
{
var total = Total(_serie);
<text>@total.Possedes sur @total.Total tomes dans la bibliothèque.</text>
}
</p>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@* La bascule vers les envies ne change rien à l'écran : sans un mot, on ne saurait pas
qu'elle a eu lieu, et on cliquerait deux fois. *@
@if (_message is not null)
{
<p class="message-avertissement" role="status">@_message</p>
}
@* 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)
{
<ArbreSerie Serie="fille" Toutes="_toutes" Livres="_livresCatalogue"
Totaux="_totaux" Deplies="_deplies"
Chemin="@CheminRacine"
ActionsActives="Reseau.EnLigne" MotifBlocage="@MotifBlocage"
OnDeplacer="DeplacerSousSerieAsync" />
}
</ul>
}
@if (!ModeEdition && _serie.Elements.Count > 0)
{
<h2 class="titre-section">Ordre de lecture</h2>
@*
⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se
lit avant le livre paru dix ans plus tôt. Les flèches sont le seul moyen de le
changer — le glisser-déposer HTML5 ne fonctionne pas au doigt, et c'est sur
téléphone qu'on consulte une saga en librairie.
*@
<ul class="liste-tomes">
@foreach (var (element, index) in _serie.Elements.Select((e, i) => (e, i)))
{
<li class="carte-tome @(element.Possede ? null : "tome-manquant")" @key="element.Id">
<div class="rang-actions">
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == 0)"
title="@MotifBlocage"
aria-label="@($"Monter « {element.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 == _serie.Elements.Count - 1)"
title="@MotifBlocage"
aria-label="@($"Descendre « {element.Titre} »")"
@onclick="() => DeplacerAsync(index, index + 1)">▼</button>
</div>
<div class="carte-corps">
@if (element.LivreId is { } livreId && _livresCatalogue.FirstOrDefault(l => l.Id == livreId) is { } livre)
{
<CarteLivre Livre="livre" />
}
else if (element.LivreId is { } idRattache)
{
<a class="carte-titre" href="@($"livres/{idRattache}")">@element.Titre</a>
}
else
{
<p class="carte-titre">@element.Titre</p>
}
@if ((element.LivreId is null || !_livresCatalogue.Any(l => l.Id == element.LivreId))
&& !string.IsNullOrWhiteSpace(element.Auteurs))
{
<p class="carte-auteur">@element.Auteurs</p>
}
@if (element.LivreId is null || !_livresCatalogue.Any(l => l.Id == element.LivreId))
{
<p class="carte-details">
@if (!element.Possede)
{
@* Dire ce qui manque, et non laisser deviner par l'absence de lien. *@
<span class="etiquette etiquette-manquant">Pas dans la bibliothèque</span>
}
@if (element.PreteA is { } emprunteur)
{
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
}
</p>
}
<div class="actions-tome">
@if (!element.Possede)
{
@* Le tome manquant est COMMUN au foyer, l'envie qu'il devient est
PERSONNELLE : deux membres peuvent vouloir le même tome. *@
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => SouhaiterAsync(element)">
Ajouter à mes envies
</button>
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@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
{
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => RattacherAsync(element.Id, null)">
Détacher
</button>
}
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => RetirerAsync(element)">
Retirer
</button>
</div>
@if (_rattachement == element.Id)
{
<div class="recherche-livre">
<input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
value="@_recherche" @oninput="ChercherAsync"
aria-label="Chercher un livre à rattacher" />
@foreach (var resultat in _resultats)
{
<button type="button" class="bouton bouton-discret"
@key="resultat.Id"
@onclick="() => RattacherAsync(element.Id, resultat.Id)">
@resultat.Titre@(resultat.AuteursAffiches is { } a ? $" — {a}" : "")
</button>
}
</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>
}
</ul>
}
@if (!ModeEdition)
{
<h2 class="titre-section">Ajouter un tome</h2>
@*
Deux voies, parce qu'une saga se recense d'un coup — souvent avant d'en posséder la
moitié — puis se remplit au fil des achats. Un tome saisi sans livre N'EST PAS un
brouillon : c'est ce qui rend le trou visible.
*@
<div class="barre-recherche">
<input class="champ-saisie" type="text" placeholder="Titre d'un tome…"
@bind="_nouveauTome" @bind:event="oninput" @onkeydown="SurToucheTome"
aria-label="Titre du tome à ajouter" autocomplete="off" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouveauTome))"
title="@MotifBlocage"
@onclick="() => AjouterTomeAsync(null)">
Ajouter ce tome
</button>
<button type="button" class="bouton bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => OuvrirRattachement(AjoutDepuisCatalogue)">
Depuis le catalogue
</button>
</div>
@if (_rattachement == AjoutDepuisCatalogue)
{
<div class="recherche-livre">
<input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
value="@_recherche" @oninput="ChercherAsync"
aria-label="Chercher un livre à ajouter" />
@foreach (var livre in _resultats)
{
<button type="button" class="bouton bouton-discret" @key="livre.Id"
@onclick="() => AjouterTomeAsync(livre.Id)">
@livre.Titre@(livre.AuteursAffiches is { } a ? $" — {a}" : "")
</button>
}
</div>
}
}
@if (ModeEdition)
{
<h2 class="titre-section">La série</h2>
<label class="champ">
<span class="champ-libelle">Nom</span>
<input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput" />
</label>
<label class="champ">
<span class="champ-libelle">
Rangée dans<span class="champ-aide"> (un cycle contient des séries)</span>
</span>
<select class="champ-saisie" @bind="_parenteId">
<option value="0">Aucune — série de premier niveau</option>
@foreach (var candidate in Candidates)
{
<option value="@candidate.Id">@candidate.Titre</option>
}
</select>
</label>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
title="@MotifBlocage"
@onclick="EnregistrerAsync">
Enregistrer
</button>
<button type="button" class="bouton bouton-discret bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="SupprimerAsync">
@(_confirmeSuppression ? "Confirmer la suppression" : "Supprimer la série")
</button>
</div>
@if (_confirmeSuppression)
{
<p class="message-avertissement" role="status">
Les livres restent au catalogue ; seules les places disparaissent.
@if (_filles.Count > 0)
{
<text> Les @_filles.Count série(s) de ce cycle deviendront des séries de premier niveau.</text>
}
</p>
}
}
@if (!ModeEdition)
{
<nav class="actions-flottantes">
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="@($"series/{Id}/edition")">Modifier</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Modifier</button>
}
</nav>
}
}
@code {
/// <summary>Marqueur de la recherche « ajouter depuis le catalogue », qui ne vise aucun élément.</summary>
private const int AjoutDepuisCatalogue = -1;
[Parameter] public int Id { get; set; }
private IReadOnlyList<SerieDto> _toutes = [];
private IReadOnlyList<LivreDto> _livresCatalogue = [];
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;
private string? _erreur;
private string _titre = string.Empty;
private int _parenteId;
private bool _confirmeSuppression;
private string _nouveauTome = string.Empty;
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('/')
.EndsWith("/edition", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Séries dans lesquelles celle-ci peut être rangée.
/// </summary>
/// <remarks>
/// Ni elle-même, ni ses descendantes : l'arbre se refermerait sur lui-même. Le serveur le
/// refuse aussi — cette liste évite seulement de proposer un choix qui sera rejeté.
/// </remarks>
private IEnumerable<SerieDto> Candidates =>
_toutes.Where(s => s.Id != Id && !EstDescendante(s.Id));
private bool EstDescendante(int candidate)
{
var courante = _toutes.FirstOrDefault(s => s.Id == candidate);
var garde = _toutes.Count + 1;
while (courante is not null && garde-- > 0)
{
if (courante.Id == Id)
{
return true;
}
courante = courante.SerieParenteId is { } parenteId
? _toutes.FirstOrDefault(s => s.Id == parenteId)
: null;
}
return false;
}
protected override void OnInitialized()
{
Reseau.Change += SurChangementReseau;
Navigation.LocationChanged += SurNavigation;
}
private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(StateHasChanged);
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerAsync();
StateHasChanged();
});
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
Navigation.LocationChanged -= SurNavigation;
}
protected override Task OnParametersSetAsync() => ChargerAsync();
private async Task ChargerAsync()
{
_chargement = true;
_toutes = await Api.ListerSeriesAsync();
_livresCatalogue = await Api.ListerAsync(new CritereLivres());
_chargement = false;
Rattacher();
}
/// <summary>Recale les vues dérivées après un chargement ou une écriture.</summary>
private void Rattacher()
{
_serie = _toutes.FirstOrDefault(s => s.Id == Id);
if (_serie is null)
{
return;
}
_parente = _serie.SerieParenteId is { } parenteId
? _toutes.FirstOrDefault(s => s.Id == parenteId)
: null;
_filles = _toutes.Where(s => s.SerieParenteId == Id).OrderBy(s => s.Position).ToList();
_totaux = TotauxSeries.Calculer(_toutes);
_titre = _serie.Titre;
_parenteId = _serie.SerieParenteId ?? 0;
}
/// <summary>Remplace la série courante par celle que vient de rendre le serveur.</summary>
private void Appliquer(SerieDto serie)
{
_toutes = _toutes.Select(s => s.Id == serie.Id ? serie : s).ToList();
Rattacher();
}
private async Task SurToucheTome(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveauTome) && Reseau.EnLigne)
{
await AjouterTomeAsync(null);
}
}
private async Task AjouterTomeAsync(int? livreId)
{
await EcrireAsync(() => Api.AjouterElementSerieAsync(
Id, new AjoutElementSerie { LivreId = livreId, Titre = livreId is null ? _nouveauTome : null }));
if (_erreur is null)
{
_nouveauTome = string.Empty;
FermerRecherche();
}
}
private Task RattacherAsync(int elementId, int? livreId) =>
EcrireAsync(async () =>
{
var resultat = await Api.RattacherLivreSerieAsync(elementId, livreId);
FermerRecherche();
return resultat;
});
private async Task RetirerAsync(ElementSerieDto element)
{
_enCours = true;
_erreur = await Api.RetirerElementSerieAsync(element.Id);
_enCours = false;
if (_erreur is null)
{
await ChargerAsync();
}
}
private async Task SouhaiterAsync(ElementSerieDto element)
{
_enCours = true;
_erreur = null;
var resultat = await Api.SouhaiterTomeAsync(element.Id);
_enCours = false;
_erreur = resultat.Erreur;
if (resultat.EstOk)
{
_message = $"« {element.Titre} » est dans votre liste d'envies.";
}
}
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,
UrlNotice = candidat.UrlNotice,
};
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)
{
if (_serie is null || vers < 0 || vers >= _serie.Elements.Count)
{
return;
}
// Réordonné localement d'abord : la flèche doit répondre tout de suite, l'aller-retour
// réseau ne doit pas se voir. Un échec recharge l'ordre du serveur.
var ids = _serie.Elements.Select(e => e.Id).ToList();
(ids[depuis], ids[vers]) = (ids[vers], ids[depuis]);
await EcrireAsync(() => Api.ReordonnerSerieAsync(Id, ids));
}
private async Task EnregistrerAsync()
{
await EcrireAsync(() => Api.ModifierSerieAsync(
Id, new EnregistrementSerie { Titre = _titre, SerieParenteId = _parenteId == 0 ? null : _parenteId }));
// Le rangement dans un cycle change la parenté des AUTRES séries affichées : on relit.
if (_erreur is null)
{
await ChargerAsync();
}
}
private async Task SupprimerAsync()
{
if (!_confirmeSuppression)
{
_confirmeSuppression = true;
return;
}
_enCours = true;
_erreur = await Api.SupprimerSerieAsync(Id);
_enCours = false;
if (_erreur is null)
{
Navigation.NavigateTo("series");
}
}
private void OuvrirRattachement(int cible)
{
_rattachement = cible;
_recherche = string.Empty;
_resultats = [];
}
private void FermerRecherche()
{
_rattachement = null;
_recherche = string.Empty;
_resultats = [];
}
private async Task ChercherAsync(ChangeEventArgs e)
{
_recherche = e.Value?.ToString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(_recherche))
{
_resultats = [];
return;
}
// La recherche passe par le service habituel : hors-ligne elle interroge l'instantané,
// en ligne le serveur. Rien de particulier à prévoir ici.
_resultats = (await Api.ListerAsync(new CritereLivres { Recherche = _recherche }))
.Take(8)
.ToList();
}
/// <summary>Exécute une écriture qui rend la série, en tenant l'état d'attente et l'erreur.</summary>
private async Task EcrireAsync(Func<Task<ResultatApi<SerieDto>>> appel)
{
_enCours = true;
_erreur = null;
_message = null;
var resultat = await appel();
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
// L'ordre affiché a pu être modifié localement avant l'appel : on reprend celui du
// serveur plutôt que de laisser croire à un enregistrement.
await ChargerAsync();
return;
}
Appliquer(resultat.Valeur!);
}
}