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>
1007 lines
38 KiB
Plaintext
1007 lines
38 KiB
Plaintext
@page "/series/{Id:int}"
|
|
@page "/series/{Id:int}/edition"
|
|
@page "/series/{Id:int}/ordre"
|
|
@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>
|
|
}
|
|
}
|
|
|
|
@*
|
|
Titre, avancement et actions sur la MÊME ligne (lot T) : c'est la disposition déjà tenue
|
|
par la bibliographie et la liste des auteurs, et elle rend les deux actions visibles sans
|
|
descendre au bas d'une saga de vingt tomes.
|
|
|
|
⚠️ « Changer l'ordre » n'apparaît qu'à partir de DEUX tomes ou deux sous-séries : un
|
|
bouton toujours présent mais inerte serait un recul. Et les deux écrans restent distincts
|
|
— « Modifier » touche à la fiche (nom, cycle, suppression), « Changer l'ordre » à la
|
|
place des tomes, dont les flèches ne vivent que là.
|
|
*@
|
|
<div class="entete-fiche">
|
|
<div class="entete-fiche-texte">
|
|
<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>
|
|
</div>
|
|
|
|
@if (ModeConsultation)
|
|
{
|
|
<div class="entete-fiche-actions">
|
|
@if (Reseau.EnLigne)
|
|
{
|
|
@if (_serie.Elements.Count > 1 || _filles.Count > 1)
|
|
{
|
|
<a class="bouton bouton-compact bouton-discret" href="@($"series/{Id}/ordre")">Changer l'ordre</a>
|
|
}
|
|
<a class="bouton bouton-compact bouton-principal" href="@($"series/{Id}/edition")">Modifier</a>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="bouton bouton-compact bouton-principal" disabled
|
|
title="@EtatReseau.MotifHorsLigne">Modifier</button>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@if (ModeOrdre)
|
|
{
|
|
<p class="message-discret">
|
|
Rangez les tomes dans l'ordre où vous voulez les <strong>lire</strong> — ce n'est
|
|
pas toujours l'ordre de publication : une préquelle se lit avant le livre paru dix
|
|
ans plus tôt. Rien d'autre ne se modifie depuis cet écran.
|
|
</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 arbre">
|
|
@foreach (var fille in _filles)
|
|
{
|
|
@* ⚠️ Les flèches n'apparaissent QUE dans l'écran d'ordre. En consultation, elles
|
|
se déclenchaient en faisant défiler du pouce, et l'ordre de lecture d'une saga
|
|
changeait sans qu'on l'ait voulu — même raison que pour la liste d'envies,
|
|
dont le réordonnancement vit sur son propre écran. *@
|
|
<ArbreSerie Serie="fille" Toutes="_toutes"
|
|
Totaux="_totaux" Replies="_replies"
|
|
Chemin="@CheminRacine"
|
|
ActionsActives="@(ModeOrdre && 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">
|
|
@if (ModeOrdre)
|
|
{
|
|
<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>
|
|
|
|
@if (ModeOrdre)
|
|
{
|
|
<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>
|
|
}
|
|
|
|
@if (!ModeOrdre)
|
|
{
|
|
<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>
|
|
|
|
@*
|
|
J1 : le scanner s'ouvre ICI, dans la place visée, et non sur
|
|
l'écran d'ajout.
|
|
|
|
⚠️ C'est ce qui répond à l'exigence « le retour doit ramener
|
|
sur la série ET sur la place visée » : il n'y a pas de
|
|
retour, on n'a jamais quitté la place. Passer par /ajout
|
|
obligeait à revenir rattacher à la main — donc à risquer de
|
|
rattacher le mauvais tome.
|
|
*@
|
|
<button type="button" class="bouton bouton-discret"
|
|
disabled="@(_enCours || _lookupScan || !Reseau.EnLigne)"
|
|
title="@MotifBlocage" @onclick="OuvrirScanTome">
|
|
Scanner
|
|
</button>
|
|
|
|
@if (_lookupScan)
|
|
{
|
|
<ScannerCodeBarres OnCodeDetecte="SurCodeTomeAsync"
|
|
OnAnnuler="() => _lookupScan = false" />
|
|
}
|
|
|
|
@* L1 : la cascade BnF puis OpenLibrary prend une seconde ou deux. *@
|
|
@if (_lookupEnCours)
|
|
{
|
|
<Patience Message="Interrogation de la BnF, puis d'OpenLibrary…" />
|
|
}
|
|
@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 (ModeConsultation)
|
|
{
|
|
<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>
|
|
|
|
@*
|
|
Lot P — le chemin inverse de « place vide → envie ».
|
|
|
|
⚠️ Ce geste n'ÉPARGNE QU'UNE RESAISIE : il crée une place ordinaire, LivreId à
|
|
NULL, titre repris de l'envie. Rien ne relie les deux en base, et c'est délibéré —
|
|
les séries sont COMMUNES au foyer, la liste d'envies est PERSONNELLE, et son sens
|
|
même est de préparer un cadeau sans que l'autre le voie venir. Une clé étrangère
|
|
vers l'envie afficherait « tome 3 souhaité par untel » à tout le monde.
|
|
*@
|
|
<button type="button" class="bouton bouton-discret"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
@onclick="OuvrirEnviesAsync">
|
|
Depuis mes envies
|
|
</button>
|
|
</div>
|
|
|
|
@if (_enviesOuvertes)
|
|
{
|
|
<div class="recherche-livre">
|
|
@if (_enviesChargement)
|
|
{
|
|
<Patience Message="Lecture de votre liste d'envies…" />
|
|
}
|
|
else if (_envies.Count == 0)
|
|
{
|
|
<p class="message-discret">
|
|
Votre liste d'envies est vide — ou ne contient rien qui ne soit déjà un tome
|
|
de cette série.
|
|
</p>
|
|
}
|
|
else
|
|
{
|
|
<p class="message-discret">
|
|
Le tome est ajouté comme <strong>manquant</strong>, avec le titre de l'envie.
|
|
L'envie <strong>reste dans votre liste</strong> : rien n'a été acheté, et
|
|
personne d'autre ne verra qu'elle est à vous.
|
|
</p>
|
|
|
|
@foreach (var envie in _envies)
|
|
{
|
|
<button type="button" class="bouton bouton-discret" @key="envie.Id"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
@onclick="() => AjouterDepuisEnvieAsync(envie)">
|
|
@envie.Titre@(string.IsNullOrWhiteSpace(envie.Auteur) ? "" : $" — {envie.Auteur}")
|
|
</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 (ModeOrdre)
|
|
{
|
|
<nav class="actions-flottantes">
|
|
<a class="bouton bouton-principal" href="@($"series/{Id}")">Terminé</a>
|
|
</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)>();
|
|
/// <summary>Nœuds repliés à la main. Tout est déplié par défaut — voir ArbreSerie.</summary>
|
|
private readonly HashSet<int> _replies = [];
|
|
|
|
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;
|
|
|
|
/// <summary>Le scanner est ouvert sur la place visée par le lookup ISBN (lot J1).</summary>
|
|
private bool _lookupScan;
|
|
|
|
private bool _enviesOuvertes;
|
|
private bool _enviesChargement;
|
|
private IReadOnlyList<SouhaitDto> _envies = [];
|
|
|
|
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
|
private IReadOnlySet<int> CheminRacine => new HashSet<int> { Id };
|
|
|
|
private bool ModeEdition => Suffixe("/edition");
|
|
|
|
/// <summary>
|
|
/// Écran dédié au réordonnancement, sur le modèle de <c>/souhaits/ordre</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Le mode est dans l'ADRESSE, pas dans un booléen interne : c'est la convention du
|
|
/// projet depuis la fiche livre, et elle fait du bouton « retour » du navigateur une
|
|
/// sortie naturelle. Comme les trois routes partagent le même paramètre, le composant
|
|
/// s'abonne à <c>LocationChanged</c> — sans quoi le routeur ne redessinerait rien.
|
|
/// </remarks>
|
|
private bool ModeOrdre => Suffixe("/ordre");
|
|
|
|
/// <summary>Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder.</summary>
|
|
private bool ModeConsultation => !ModeEdition && !ModeOrdre;
|
|
|
|
private bool Suffixe(string fin) =>
|
|
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
|
|
.EndsWith(fin, 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 OuvrirScanTome()
|
|
{
|
|
_erreur = null;
|
|
_lookupCandidats = [];
|
|
_lookupScan = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enchaîne le lookup sur le code lu, sans quitter la place visée.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Le code reste dans le champ : un décodage erroné se corrige et se relance, comme sur
|
|
/// l'écran d'ajout au catalogue.
|
|
/// <para>
|
|
/// ⚠️ Un code de périodique (<c>977</c>) est refusé plus bas par <c>ChercherLookupAsync</c> :
|
|
/// <c>ElementSerie.LivreId</c> ne pointe que vers <c>Livre</c>, et les revues restent
|
|
/// délibérément hors de ce flux.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task SurCodeTomeAsync(CodeScanne lecture)
|
|
{
|
|
_lookupScan = false;
|
|
_lookupIsbn = lecture.Code;
|
|
await ChercherLookupAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ouvre la liste des envies dans laquelle puiser un tome à recenser.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Les envies déjà présentes dans la série sont écartées de la liste : les proposer
|
|
/// mènerait droit au doublon, la place étant identifiée par son seul titre.
|
|
/// </remarks>
|
|
private async Task OuvrirEnviesAsync()
|
|
{
|
|
_erreur = null;
|
|
_message = null;
|
|
_enviesOuvertes = true;
|
|
_enviesChargement = true;
|
|
|
|
var toutes = await Api.ListerSouhaitsAsync();
|
|
|
|
var deja = new HashSet<string>(
|
|
_serie?.Elements.Select(e => NormalisationTexte.Normaliser(e.Titre)) ?? [],
|
|
StringComparer.Ordinal);
|
|
|
|
_envies = toutes
|
|
.Where(envie => !deja.Contains(NormalisationTexte.Normaliser(envie.Titre)))
|
|
.ToList();
|
|
|
|
_enviesChargement = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recense un tome à partir d'une envie, <b>sans rien lier</b>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// La place créée est celle qu'on aurait saisie à la main : <c>LivreId</c> à <c>NULL</c>,
|
|
/// titre repris de l'envie. L'envie <b>survit</b> — c'est la règle déjà tenue par « une
|
|
/// envie déjà au catalogue est signalée, jamais supprimée », et à plus forte raison ici
|
|
/// puisque rien n'a été acheté.
|
|
/// <para>
|
|
/// ⚠️ Ce qui doit rester invisible au foyer n'est pas l'existence du tome — on l'aurait
|
|
/// saisie à la main de toute façon — mais le <b>lien</b> avec la liste de quelqu'un. Il n'y
|
|
/// en a aucun en base.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task AjouterDepuisEnvieAsync(SouhaitDto envie)
|
|
{
|
|
await EcrireAsync(() => Api.AjouterElementSerieAsync(
|
|
Id, new AjoutElementSerie { Titre = envie.Titre }));
|
|
|
|
if (_erreur is null)
|
|
{
|
|
_enviesOuvertes = false;
|
|
_envies = [];
|
|
_message = $"« {envie.Titre} » est recensé comme tome manquant. "
|
|
+ "Il reste dans votre liste d'envies.";
|
|
}
|
|
}
|
|
|
|
private void OuvrirLookup(ElementSerieDto element)
|
|
{
|
|
_lookupElement = element;
|
|
_lookupIsbn = string.Empty;
|
|
_lookupCandidats = [];
|
|
_lookupScan = false;
|
|
_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,
|
|
NombrePages = candidat.NombrePages,
|
|
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!);
|
|
}
|
|
}
|