Files
mabibli/MaBibli.Client/Pages/Serie.razor
T
LIMONIER MathieuandCopilot 3d5e73838e Sépare consultation et édition pour les lots B1 B2 B3
Réutilise la carte livre du catalogue, sépare les modes des séries et des envies, et déplie les œuvres bibliographiques non possédées.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-20 13:11:43 +02:00

583 lines
20 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
{
<text>@_serie.NombrePossedes sur @_serie.Elements.Count 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>
}
@* Les sous-séries d'un cycle : on les liste, on ne les déplie pas — chacune a son écran. *@
@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>
}
</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>
}
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>
}
</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 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 string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
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();
_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 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!);
}
}