- Catalogage rapide (douchette, code-barres)
- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne. - Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais. Séries et sagas - Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles). - Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un. - Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence. - Filtre catalogue « sans couverture » pour repérer les livres à illustrer. Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total). - Corrections directes sur la fiche - Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ». - Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification. - Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe. - La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper. - Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
This commit is contained in:
@@ -42,7 +42,24 @@
|
||||
|
||||
@if (ADesPixels)
|
||||
{
|
||||
@if (Agrandissable)
|
||||
@* ⚠️ L'image cassée est désormais un état C# (`_cassee`), posé par @onerror, et non plus
|
||||
une classe ajoutée par du JavaScript en ligne. C'est ce qui permet d'en faire quelque
|
||||
chose : proposer de corriger l'URL, là où l'on constate qu'elle est morte.
|
||||
|
||||
`onerror` ne remonte pas dans le DOM, mais Blazor le connaît comme événement
|
||||
non propagatif et pose son écouteur sur l'élément même : @onerror fonctionne bien
|
||||
sur une <img>. *@
|
||||
@if (Corrigeable)
|
||||
{
|
||||
<button type="button" class="couverture-declencheur declencheur-correction"
|
||||
aria-label="@($"Corriger l'adresse de la couverture de {Titre}")"
|
||||
title="Cette image ne se charge pas — cliquez pour corriger son adresse"
|
||||
@onclick="OnCorriger">
|
||||
<img @key="UrlAffichee" class="couverture-image image-cassee"
|
||||
src="@UrlAffichee" alt="Couverture de @Titre" loading="lazy" />
|
||||
</button>
|
||||
}
|
||||
else if (Agrandissable && !_cassee)
|
||||
{
|
||||
@* Un vrai <button> et pas une <div> cliquable : cible tactile, focus clavier,
|
||||
et activation à l'Entrée sans une ligne de code. *@
|
||||
@@ -54,19 +71,35 @@
|
||||
src="@UrlAffichee"
|
||||
alt="Couverture de @Titre"
|
||||
loading="lazy"
|
||||
onerror="this.classList.add('image-cassee'); const b=this.closest('.couverture-declencheur'); if(b){b.disabled=true;}" />
|
||||
@onerror="() => _cassee = true" />
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img @key="UrlAffichee"
|
||||
class="couverture-image"
|
||||
class="couverture-image @(_cassee ? "image-cassee" : null)"
|
||||
src="@UrlAffichee"
|
||||
alt="Couverture de @Titre"
|
||||
loading="lazy"
|
||||
onerror="this.classList.add('image-cassee')" />
|
||||
@onerror="() => _cassee = true" />
|
||||
}
|
||||
}
|
||||
else if (OnCorriger.HasDelegate)
|
||||
{
|
||||
@*
|
||||
⚠️ Pas de couverture DU TOUT, et pourtant cliquable : c'est le seul écart à la règle
|
||||
« le substitut à initiale n'est jamais cliquable ». Elle valait tant que le clic
|
||||
servait à AGRANDIR — il n'y avait rien à agrandir. Ici il sert à en POSER une, et
|
||||
c'est précisément le livre qui n'en a pas qui en a besoin.
|
||||
|
||||
Ailleurs (cartes du catalogue, séries, envies), aucune correction n'est offerte :
|
||||
le paramètre n'est pas passé, et le substitut y reste un décor inerte.
|
||||
*@
|
||||
<button type="button" class="couverture-declencheur declencheur-correction"
|
||||
aria-label="@($"Ajouter une couverture à {Titre}")"
|
||||
title="Aucune couverture — cliquez pour en indiquer l'adresse"
|
||||
@onclick="OnCorriger"></button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_agrandie)
|
||||
@@ -95,6 +128,22 @@
|
||||
/// <summary>Le clic sur l'image l'affiche en grand. Sans effet s'il n'y a pas de couverture.</summary>
|
||||
[Parameter] public bool Agrandissable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Que faire quand l'image manque ou ne se charge pas — ouvrir le champ de son adresse.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Renseigné, il <b>remplace</b> l'agrandissement sur une image cassée, et rend cliquable
|
||||
/// le substitut à initiale d'un livre <b>sans</b> couverture. Agrandir une image qui ne
|
||||
/// s'affiche pas n'a jamais servi à rien ; en poser une là où l'on constate qu'elle manque
|
||||
/// est exactement ce qu'on veut faire à cet instant.
|
||||
/// </remarks>
|
||||
[Parameter] public EventCallback OnCorriger { get; set; }
|
||||
|
||||
/// <summary>L'image a échoué à se charger : 404 de l'hébergeur, adresse morte, 502 passager.</summary>
|
||||
private bool _cassee;
|
||||
|
||||
private bool Corrigeable => _cassee && OnCorriger.HasDelegate;
|
||||
|
||||
private bool _agrandie;
|
||||
private bool _aFocaliser;
|
||||
private ElementReference _calque;
|
||||
@@ -141,6 +190,9 @@
|
||||
}
|
||||
|
||||
_urlResoluePour = jeton;
|
||||
|
||||
// Nouvelle image : ce qu'on savait de la précédente ne dit plus rien de celle-ci.
|
||||
_cassee = false;
|
||||
await LibererAsync();
|
||||
|
||||
// Le cache d'abord, EN LIGNE COMME HORS LIGNE : une image déjà sur l'appareil s'affiche
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@inject ServiceLivresApi Api
|
||||
|
||||
@*
|
||||
Formulaire d'une fiche livre, partagé par l'ajout (ISBN ou manuel) et l'édition.
|
||||
|
||||
@@ -53,8 +55,14 @@
|
||||
qui le suit rogné — le séparateur était le seul caractère qu'on ne pouvait pas taper.
|
||||
Voir CLAUDE.md.
|
||||
*@
|
||||
@*
|
||||
⚠️ Les thèmes déjà employés sont PROPOSÉS : un thème est un vocabulaire, il ne vaut que
|
||||
réutilisé. « fantasy » et « Fantaisie » saisis séparément ne regroupent rien, et le
|
||||
filtre par thème n'y verrait que deux étiquettes sans rapport.
|
||||
*@
|
||||
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
|
||||
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà sur ce livre."
|
||||
Suggestions="_themesConnus"
|
||||
OnChangement="StateHasChanged" />
|
||||
|
||||
@*
|
||||
@@ -67,7 +75,7 @@
|
||||
⚠️ Les rôles n'apparaissent qu'à partir de DEUX auteurs, décision inchangée : un roman
|
||||
n'a qu'un signataire dont le rôle ne se pose pas. Le défaut reste « non précisé ».
|
||||
*@
|
||||
<SaisieListe Valeurs="_noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
|
||||
<SaisieListe Valeurs="Noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
|
||||
Placeholder="Prénom Nom, ou Nom, Prénom"
|
||||
MessageDoublon="Cet auteur est déjà sur ce livre."
|
||||
OnChangement="StateHasChanged">
|
||||
@@ -205,18 +213,61 @@
|
||||
/// <remarks>
|
||||
/// ⚠️ Sa <b>référence doit rester stable</b> tant que la saisie ne change pas : le composant
|
||||
/// se remet à zéro quand la liste qu'on lui prête change d'identité, et il perdrait donc à
|
||||
/// chaque rendu ce qui est en cours de frappe.
|
||||
/// chaque rendu ce qui est en cours de frappe. D'où la construction paresseuse, invalidée
|
||||
/// par <c>OnParametersSet</c> et par elle seule.
|
||||
/// <para>
|
||||
/// ⚠️ Elle ne peut PAS être construite dans <c>OnParametersSet</c> uniquement : dès qu'un
|
||||
/// composant a un <c>OnInitializedAsync</c> qui attend vraiment (ici la lecture des thèmes
|
||||
/// connus), Blazor rend une première fois <b>avant</b> d'appeler <c>OnParametersSet</c>. Ce
|
||||
/// rendu-là voyait un <c>null</c>, et l'enfant levait une <c>NullReferenceException</c> —
|
||||
/// invisible à l'écran, qui finissait par s'afficher, mais bien présente en console.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private NomsAuteurs _noms = default!;
|
||||
private NomsAuteurs? _noms;
|
||||
|
||||
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
|
||||
|
||||
private EnregistrementLivre? _saisiePrecedente;
|
||||
|
||||
/// <summary>
|
||||
/// Les thèmes déjà employés dans la bibliothèque, proposés sous le champ.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Déduits du <b>catalogue</b>, et non d'un point d'entrée « /api/themes » : la liste des
|
||||
/// livres est déjà lue partout, elle porte les thèmes, et surtout elle a son instantané —
|
||||
/// une nouvelle vue de l'API en exigerait un autre, faute de quoi les propositions
|
||||
/// disparaîtraient hors-ligne. Le catalogue d'un foyer se charge entier, c'est déjà le parti
|
||||
/// pris de la détection de doublons.
|
||||
/// <para>
|
||||
/// Une lecture ratée ne coûte rien : sans propositions, le champ redevient celui d'avant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private IReadOnlyList<string> _themesConnus = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_themesConnus =
|
||||
[
|
||||
.. (await Api.ListerAsync(new CritereLivres()))
|
||||
.SelectMany(l => l.Themes)
|
||||
.DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
|
||||
.OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal),
|
||||
];
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_themesConnus = [];
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(_saisiePrecedente, Saisie))
|
||||
{
|
||||
_saisiePrecedente = Saisie;
|
||||
_noms = new NomsAuteurs(Saisie.Auteurs);
|
||||
_noms = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@
|
||||
tenue par l'arbre des séries : l'inverse obligerait à ouvrir chaque bloc pour retrouver un
|
||||
livre qu'on voyait très bien avant.
|
||||
|
||||
⚠️ Replié, le bloc annonce toujours ce qu'il contient (« 5 tomes ») : sans ce compte, le
|
||||
compteur du haut du catalogue paraîtrait mentir.
|
||||
⚠️ Replié, le bloc annonce toujours ce qu'il contient : sans ce compte, le compteur du haut
|
||||
du catalogue paraîtrait mentir.
|
||||
|
||||
⚠️ Et ce compte distingue trois choses (2026-09-08) : ce qui est AFFICHÉ (filtre compris), ce
|
||||
qu'on POSSÈDE de cette série, et ce qu'elle compte de tomes. Un seul nombre suivait le
|
||||
filtre — « 3 tomes » devenait « 1 tome » sous une recherche, et se lisait « cette série n'a
|
||||
qu'un tome ». Les égalités se replient : sans filtre et série complète, on relit « 3 tomes ».
|
||||
*@
|
||||
<li class="groupe-serie" @key="Entree.Serie!.Id">
|
||||
<div class="groupe-serie-entete">
|
||||
@@ -26,7 +31,7 @@
|
||||
<a class="groupe-serie-titre" href="@($"series/{Entree.Serie!.Id}")">
|
||||
<span class="groupe-serie-nom">@Entree.Serie!.Titre</span>
|
||||
<span class="groupe-serie-compte">
|
||||
@Entree.NombreLivres tome@(Entree.NombreLivres > 1 ? "s" : "")
|
||||
@Libelles.CompteBloc(Entree.NombreLivres, Possedes, Total)
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -38,7 +43,7 @@
|
||||
<ul class="liste-livres">
|
||||
@foreach (var sous in Entree.SousEntrees)
|
||||
{
|
||||
<GroupeCatalogue Entree="sous" Replies="Replies" />
|
||||
<GroupeCatalogue Entree="sous" Replies="Replies" Totaux="Totaux" />
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@@ -71,6 +76,25 @@
|
||||
/// </remarks>
|
||||
[Parameter, EditorRequired] public ISet<int> Replies { get; set; } = new HashSet<int>();
|
||||
|
||||
/// <summary>
|
||||
/// Compteurs par série, descendance comprise : ce qu'on possède, et ce que la série recense.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Calculés une fois par l'écran (<c>TotauxSeries</c>), et non ici : le catalogue se relit
|
||||
/// à chaque frappe dans la recherche, et chaque bloc les recalculerait pour tout l'arbre.
|
||||
/// C'est aussi ce qui garantit le <b>même</b> compte que la fiche de la série.
|
||||
/// </remarks>
|
||||
[Parameter] public IReadOnlyDictionary<int, (int Possedes, int Total)>? Totaux { get; set; }
|
||||
|
||||
private (int Possedes, int Total) Compte =>
|
||||
Totaux is not null && Totaux.TryGetValue(Entree.Serie!.Id, out var connu)
|
||||
? connu
|
||||
: (Entree.NombreLivres, Entree.NombreLivres);
|
||||
|
||||
private int Possedes => Compte.Possedes;
|
||||
|
||||
private int Total => Compte.Total;
|
||||
|
||||
private bool Deplie => !Replies.Contains(Entree.Serie!.Id);
|
||||
|
||||
private void Basculer()
|
||||
|
||||
@@ -66,4 +66,39 @@ public static class Libelles
|
||||
MaBibli.Shared.Entites.TypeDocument.BandeDessinee => "type-bd",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Ce qu'annonce un bloc de série au catalogue : ce qui y est affiché, ce qu'on possède, et
|
||||
/// ce que la série compte de tomes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Le compte d'un bloc changeait avec le filtre</b> : « 3 tomes » devenait « 1 tome »
|
||||
/// dès qu'une recherche écartait les autres, et se lisait alors comme « cette série n'a qu'un
|
||||
/// tome ». Le nombre affiché ne disait pas de quoi il parlait.
|
||||
/// <para>
|
||||
/// Trois nombres seraient du bruit quand ils coïncident : les égalités se replient donc.
|
||||
/// Un fonds complet et non filtré redit simplement « 3 tomes », comme avant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="affiches">Tomes de ce bloc actuellement à l'écran, filtre appliqué.</param>
|
||||
/// <param name="possedes">Tomes de cette série présents au catalogue, descendance comprise.</param>
|
||||
/// <param name="total">Tomes recensés dans la série, ceux qui manquent compris.</param>
|
||||
public static string CompteBloc(int affiches, int possedes, int total)
|
||||
{
|
||||
var tomes = total > 1 ? "tomes" : "tome";
|
||||
|
||||
if (affiches == possedes && possedes == total)
|
||||
{
|
||||
return $"{total} {tomes}";
|
||||
}
|
||||
|
||||
if (affiches == possedes)
|
||||
{
|
||||
return $"{possedes} sur {total} {tomes}";
|
||||
}
|
||||
|
||||
return possedes == total
|
||||
? $"{affiches} affiché{(affiches > 1 ? "s" : "")} sur {total} {tomes}"
|
||||
: $"{affiches} affiché{(affiches > 1 ? "s" : "")} · {possedes} sur {total} {tomes}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,12 @@
|
||||
à <strong>@pret.Emprunteur</strong>
|
||||
depuis le @Jour(pret.DatePret)
|
||||
<span class="pret-duree">(@Duree(pret.JoursEcoules))</span>
|
||||
|
||||
@Croix(pret)
|
||||
</p>
|
||||
|
||||
@Confirmation(pret)
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal" disabled="@_occupe"
|
||||
@onclick="RendreAsync">
|
||||
@@ -127,11 +131,21 @@
|
||||
@foreach (var passe in Historique)
|
||||
{
|
||||
<li @key="passe.Id">
|
||||
<strong>@passe.Emprunteur</strong>
|
||||
<span class="historique-dates">
|
||||
du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
|
||||
— @Duree(passe.JoursEcoules)
|
||||
</span>
|
||||
@* La croix reste SUR la ligne des dates : elle appartient à ce prêt, et
|
||||
reléguée dessous elle se lisait comme une action de la liste entière. *@
|
||||
<div class="historique-ligne">
|
||||
<span class="historique-qui">
|
||||
<strong>@passe.Emprunteur</strong>
|
||||
<span class="historique-dates">
|
||||
du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
|
||||
— @Duree(passe.JoursEcoules)
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@Croix(passe)
|
||||
</div>
|
||||
|
||||
@Confirmation(passe)
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -143,7 +157,44 @@
|
||||
}
|
||||
</section>
|
||||
|
||||
@*
|
||||
⚠️ Effacer un prêt N'EST PAS le rendre, et les deux ne doivent jamais se confondre. Rendre
|
||||
pose une date et garde la ligne : c'est l'histoire du livre, et la raison d'être de la table.
|
||||
La croix défait une SAISIE — mauvais emprunteur, mauvaise date, prêt noté deux fois. Un
|
||||
historique juste vaut mieux qu'un historique complet mais faux.
|
||||
|
||||
⚠️ Elle se confirme, et le mot le dit : « définitivement ». C'est la seule action de cet
|
||||
écran qui détruit quelque chose, et elle est à côté de deux boutons qui n'en détruisent
|
||||
aucun.
|
||||
*@
|
||||
@code {
|
||||
/// <summary>La croix d'effacement d'une ligne de prêt.</summary>
|
||||
private RenderFragment Croix(PretDto pret) =>
|
||||
@<button type="button" class="bouton-croix" disabled="@_occupe"
|
||||
title="Effacer ce prêt (saisie erronée)"
|
||||
aria-label="@($"Effacer le prêt à {pret.Emprunteur}")"
|
||||
@onclick="() => _aEffacer = pret.Id">✕</button>;
|
||||
|
||||
/// <summary>La demande de confirmation, sous la ligne visée seulement.</summary>
|
||||
private RenderFragment Confirmation(PretDto pret) => @<text>
|
||||
@if (_aEffacer == pret.Id)
|
||||
{
|
||||
<p class="message-avertissement" role="status">
|
||||
Effacer définitivement ce prêt ? Il disparaîtra de l'historique du livre.
|
||||
</p>
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-danger" disabled="@_occupe"
|
||||
@onclick="() => EffacerAsync(pret)">
|
||||
@(_occupe ? "Suppression…" : "Oui, effacer")
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-discret" disabled="@_occupe"
|
||||
@onclick="() => _aEffacer = null">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</text>;
|
||||
|
||||
[Parameter, EditorRequired] public int LivreId { get; set; }
|
||||
|
||||
/// <summary>Format du livre : un ebook ne se prête pas.</summary>
|
||||
@@ -169,6 +220,9 @@
|
||||
private string? _erreur;
|
||||
private int _livreCharge;
|
||||
|
||||
/// <summary>Prêt dont l'effacement attend confirmation, s'il y en a un.</summary>
|
||||
private int? _aEffacer;
|
||||
|
||||
private PretDto? EnCours => _prets?.FirstOrDefault(p => p.EstEnCours);
|
||||
|
||||
private IReadOnlyList<PretDto> Historique =>
|
||||
@@ -279,6 +333,29 @@
|
||||
await OnChangement.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task EffacerAsync(PretDto pret)
|
||||
{
|
||||
_occupe = true;
|
||||
_erreur = null;
|
||||
|
||||
var erreur = await Api.SupprimerPretAsync(pret.Id);
|
||||
|
||||
_occupe = false;
|
||||
_aEffacer = null;
|
||||
|
||||
if (erreur is not null)
|
||||
{
|
||||
_erreur = erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
await ChargerAsync();
|
||||
|
||||
// Effacer le prêt EN COURS remet le livre à la maison : la fiche porte cet état, elle
|
||||
// doit donc se relire, comme après un retour.
|
||||
await OnChangement.InvokeAsync();
|
||||
}
|
||||
|
||||
private static string Jour(DateTime instant) =>
|
||||
instant.ToLocalTime().ToString("dd/MM/yyyy");
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<div class="ligne-creation">
|
||||
<input id="@_idChamp" class="champ-saisie" type="text" @bind="_saisie"
|
||||
@bind:event="oninput" @onkeydown="SurTouche"
|
||||
@onfocus="() => _suggestionsOuvertes = true"
|
||||
placeholder="@Placeholder" autocomplete="off" />
|
||||
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
@@ -43,6 +44,27 @@
|
||||
<p class="message-discret" role="status">@_message</p>
|
||||
}
|
||||
|
||||
@*
|
||||
Ce qui existe déjà ailleurs, proposé dès le focus — un thème est un VOCABULAIRE, il ne
|
||||
vaut que réutilisé : « fantasy » et « Fantaisie » saisis séparément ne regroupent rien.
|
||||
|
||||
⚠️ La liste ne se referme PAS au blur, et c'est délibéré : le blur précède le clic sur
|
||||
une proposition, et la faire disparaître à ce moment-là rend les propositions
|
||||
incliquables — le piège classique de l'autocomplétion. Elle se referme quand on a choisi.
|
||||
*@
|
||||
@if (Proposees.Count > 0)
|
||||
{
|
||||
<div class="suggestions">
|
||||
@foreach (var suggestion in Proposees)
|
||||
{
|
||||
<button type="button" class="segment" @key="suggestion"
|
||||
@onclick="() => Choisir(suggestion)">
|
||||
@suggestion
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Valeurs.Count > 0)
|
||||
{
|
||||
<ul class="liste-saisie">
|
||||
@@ -97,6 +119,16 @@
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment<int>? Complement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Valeurs déjà employées ailleurs, proposées sous le champ. <c>null</c> = aucune proposition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ N'a de sens que pour un <b>vocabulaire</b> — les thèmes, qui ne valent que réutilisés.
|
||||
/// Elle n'en a aucun pour des valeurs uniques à leur fiche (titres d'articles à la une, noms
|
||||
/// d'auteurs d'un livre donné), où elle ne ferait que du bruit.
|
||||
/// </remarks>
|
||||
[Parameter] public IReadOnlyList<string>? Suggestions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Prévient le parent que la liste a changé.
|
||||
/// </summary>
|
||||
@@ -116,6 +148,45 @@
|
||||
|
||||
private string? _message;
|
||||
|
||||
/// <summary>Les propositions sont dépliées depuis le premier focus, jusqu'à ce qu'on choisisse.</summary>
|
||||
private bool _suggestionsOuvertes;
|
||||
|
||||
/// <summary>Nombre de propositions affichées : au-delà, la liste cache le formulaire.</summary>
|
||||
private const int MaximumPropose = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Ce qui reste à proposer : ni ce qui est déjà sur la fiche, ni ce qui ne correspond pas à
|
||||
/// ce qu'on tape.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> Proposees
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_suggestionsOuvertes || Suggestions is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var deja = Valeurs.Select(NormalisationTexte.Normaliser).ToHashSet(StringComparer.Ordinal);
|
||||
var terme = NormalisationTexte.Normaliser(_saisie);
|
||||
|
||||
return
|
||||
[
|
||||
.. Suggestions
|
||||
.Where(s => !deja.Contains(NormalisationTexte.Normaliser(s)))
|
||||
.Where(s => terme.Length == 0
|
||||
|| NormalisationTexte.Normaliser(s).Contains(terme, StringComparison.Ordinal))
|
||||
.Take(MaximumPropose),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private void Choisir(string suggestion)
|
||||
{
|
||||
_saisie = suggestion;
|
||||
Valider();
|
||||
}
|
||||
|
||||
/// <summary>Liste servie au rendu précédent, pour repérer que le parent en a changé.</summary>
|
||||
private IList<string>? _precedente;
|
||||
|
||||
@@ -137,6 +208,7 @@
|
||||
_saisie = null;
|
||||
_indexModifie = null;
|
||||
_message = null;
|
||||
_suggestionsOuvertes = false;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -72,6 +72,10 @@
|
||||
question. *@
|
||||
<a class="bouton bouton-discret" href="ajout/manuel">Saisir un livre</a>
|
||||
<a class="bouton bouton-discret" href="revues/ajout">Saisir une revue</a>
|
||||
|
||||
@* Une pile de livres et une douchette : on collecte d'abord, on traite ensuite.
|
||||
Reste ici, et non dans le menu : c'est une façon d'ajouter, pas une destination. *@
|
||||
<a class="bouton bouton-discret" href="ajout/rafale">Cataloguer en rafale</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -225,7 +229,29 @@
|
||||
private EnregistrementLivre _saisie = new();
|
||||
private DoublonsLivre? _doublons;
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
/// <summary>
|
||||
/// Code transmis par un autre écran — un résidu de rafale à trancher.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ La recherche s'enchaîne d'elle-même, comme après un scan : arriver ici depuis un
|
||||
/// compte rendu de rafale signifie qu'on a déjà décidé de reprendre ce code. Demander un
|
||||
/// clic de plus sur « Chercher » serait le détour que cet écran documente déjà comme un
|
||||
/// défaut (voir l'étape « Recherche »).
|
||||
/// </remarks>
|
||||
[SupplyParameterFromQuery(Name = "code")]
|
||||
public string? CodePropose { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(CodePropose))
|
||||
{
|
||||
_isbn = CodePropose.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private bool _repriseFaite;
|
||||
|
||||
/// <summary>
|
||||
/// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche.
|
||||
@@ -237,6 +263,29 @@
|
||||
/// </remarks>
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
// ⚠️ AVANT le garde de focus ci-dessous, qui rend la main dès que le focus est posé :
|
||||
// greffée après, la reprise ne serait jouée qu'une fois sur deux, selon l'étape.
|
||||
//
|
||||
// ⚠️ Et surtout PAS conditionnée à `premierRendu` : au premier rendu, l'état réseau
|
||||
// n'est pas encore établi (il s'initialise de façon asynchrone), donc `EnLigne` est
|
||||
// faux et la reprise ne partait jamais. Constaté à l'écran — le champ se préremplissait
|
||||
// bien, la recherche ne s'enchaînait pas. C'est `_repriseFaite` qui garantit l'unicité,
|
||||
// pas le premier rendu ; l'événement réseau provoque le rendu où la condition passe.
|
||||
if (!_repriseFaite && !string.IsNullOrWhiteSpace(CodePropose) && Reseau.EnLigne)
|
||||
{
|
||||
_repriseFaite = true;
|
||||
await ChercherAsync();
|
||||
|
||||
// ⚠️ `StateHasChanged` est INDISPENSABLE ici, et son absence ne se voit pas :
|
||||
// `OnAfterRenderAsync` n'est pas un gestionnaire d'événement, donc Blazor ne
|
||||
// redessine rien de lui-même. La recherche partait bien — sonde à l'appui — et
|
||||
// remplissait `_candidats`, mais l'écran restait sur l'étape de saisie.
|
||||
// Même famille que « un composant qui écrit dans un objet prêté doit prévenir son
|
||||
// parent » : le symptôme trompe, on cherche un appel qui n'a pas eu lieu là où il
|
||||
// n'y a qu'un rendu manquant.
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
if (_etape != Etape.Saisie || !_focusAFaire)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -38,7 +38,26 @@ else
|
||||
@code {
|
||||
private readonly EnregistrementLivre _saisie = new();
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
/// <summary>
|
||||
/// ISBN transmis par un autre écran — un résidu de rafale qu'aucune source ne connaît.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Sans cette reprise, le lien du compte rendu de rafale menait à un formulaire
|
||||
/// <b>vide</b> : il fallait retaper le code qu'on venait de scanner, c'est-à-dire refaire
|
||||
/// exactement ce que la rafale existe pour éviter. Le lien compilait et ne servait à rien.
|
||||
/// </remarks>
|
||||
[SupplyParameterFromQuery(Name = "isbn")]
|
||||
public string? IsbnPropose { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(IsbnPropose))
|
||||
{
|
||||
_saisie.Isbn = IsbnPropose.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
|
||||
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
@page "/ajout/rafale"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject CacheHorsLigne Cache
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Nav
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — cataloguer en rafale</PageTitle>
|
||||
|
||||
<h1>Cataloguer en rafale</h1>
|
||||
|
||||
@*
|
||||
Lancée depuis une saga, la rafale rattache chaque livre créé à la série. ⚠️ La cible vit
|
||||
dans la FILE (Rafale.SerieId), pas dans cet écran : une rafale se reprend plus tard, et la
|
||||
reprise doit rattacher au même endroit.
|
||||
*@
|
||||
@if (SerieVisee is { } visee)
|
||||
{
|
||||
<p class="aide">
|
||||
Les livres créés seront ajoutés à la série
|
||||
<a href="@($"series/{visee.Id}")"><strong>@visee.Titre</strong></a>.
|
||||
</p>
|
||||
}
|
||||
|
||||
@*
|
||||
Le geste visé : une pile de livres et une douchette. Celle-ci se présente au système comme
|
||||
un CLAVIER — elle tape les chiffres puis « Entrée ». Or dans une zone de texte multiligne,
|
||||
« Entrée » fait un saut de ligne : la rafale se collecte donc toute seule, sans mécanique
|
||||
dédiée. C'est ce qui rend cet écran bien moins coûteux qu'une file interactive.
|
||||
*@
|
||||
|
||||
@if (_erreur is { } erreur)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@erreur</p>
|
||||
}
|
||||
|
||||
@if (_repriseProposee)
|
||||
{
|
||||
@*
|
||||
⚠️ Sans cette proposition, la file existerait en base et personne ne la retrouverait :
|
||||
on rescannerait tout. C'est la contrepartie directe de « reprendre plus tard ».
|
||||
*@
|
||||
<div class="encart-reprise">
|
||||
@*
|
||||
⚠️ La file est proposée MÊME TERMINÉE (2026-09-08). Elle ne l'était que s'il restait
|
||||
à traiter ou des résidus : une rafale entièrement réussie devenait donc inatteignable
|
||||
alors qu'elle dormait dans IndexedDB, et la liste des livres créés — avec ses liens
|
||||
vers chaque fiche — était perdue au premier changement d'écran.
|
||||
*@
|
||||
@if (_rafale.ResteATraiter || _rafale.Residus.Count > 0)
|
||||
{
|
||||
<p>
|
||||
Une rafale de <strong>@_rafale.Total code@(_rafale.Total > 1 ? "s" : "")</strong>
|
||||
est restée en cours@(_rafale.Traites > 0 ? $", {_rafale.Traites} déjà traité{(_rafale.Traites > 1 ? "s" : "")}" : "").
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>
|
||||
Votre dernière rafale est terminée :
|
||||
<strong>@_rafale.Crees livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "")</strong>.
|
||||
Son compte rendu, et les liens vers chaque fiche, sont encore là.
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal" @onclick="Reprendre">
|
||||
@(_rafale.ResteATraiter || _rafale.Residus.Count > 0 ? "Reprendre" : "Revoir le compte rendu")
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-danger" @onclick="AbandonnerAsync">
|
||||
@(_rafale.ResteATraiter || _rafale.Residus.Count > 0
|
||||
? "Abandonner cette rafale"
|
||||
: "Commencer une nouvelle rafale")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_phase == Phase.Collecte)
|
||||
{
|
||||
<p class="aide">
|
||||
Scannez les codes-barres à la suite : chaque lecture ajoute une ligne. Vous pouvez aussi
|
||||
les taper ou les coller, un par ligne. Rien n'est envoyé avant que vous ne lanciez le
|
||||
traitement.
|
||||
</p>
|
||||
|
||||
@*
|
||||
⚠️ La zone est focalisée à l'ouverture, comme le champ ISBN de l'écran d'ajout : une
|
||||
douchette « tape » dès qu'on scanne, et sans le focus le premier code partait dans le
|
||||
vide. C'est une étape de moins, et c'est celle qu'on oublie en ayant les mains prises.
|
||||
|
||||
⚠️ Le focus ne se reprend pas à chaque rendu (`_focusAFaire`) : il serait arraché à
|
||||
chaque frappe, et la zone se réécrit à chaque caractère.
|
||||
*@
|
||||
<div class="champ">
|
||||
<label for="codes">Codes scannés</label>
|
||||
<textarea id="codes" class="champ-saisie zone-codes" rows="10"
|
||||
@ref="_zoneCodes"
|
||||
placeholder="9782070612758 9782253004226 …"
|
||||
@bind="_saisie" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
|
||||
<p class="aide" aria-live="polite">
|
||||
@{
|
||||
var codes = SaisieRafale.Decouper(_saisie).Count;
|
||||
}
|
||||
@if (codes == 0)
|
||||
{
|
||||
<span>Aucun code pour l'instant.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span><strong>@codes</strong> code@(codes > 1 ? "s" : "") — les doublons de saisie sont écartés.</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@*
|
||||
Cochée par défaut : cataloguer cinquante livres ne doit pas être interrompu cinquante
|
||||
fois. ⚠️ Mais le compte rendu DIT combien ont été passés — posséder deux exemplaires est
|
||||
légitime, et passer en silence contredirait cet esprit.
|
||||
*@
|
||||
<label class="case-a-cocher">
|
||||
<input type="checkbox" @bind="_passerLesDoublons" />
|
||||
Passer les livres déjà au catalogue
|
||||
</label>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(!Reseau.EnLigne || SaisieRafale.Decouper(_saisie).Count == 0)"
|
||||
title="@MotifBlocage"
|
||||
@onclick="LancerAsync">
|
||||
Traiter @(SaisieRafale.Decouper(_saisie).Count > 0 ? $"({SaisieRafale.Decouper(_saisie).Count})" : "")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
@*
|
||||
La COLLECTE marche hors-ligne, pas le traitement : c'est le seul endroit du projet
|
||||
où l'on accumule quelque chose sans réseau. ⚠️ Ce ne sont pas des écritures en
|
||||
attente, ce sont des codes à interroger — rien n'est promis à la base.
|
||||
*@
|
||||
<p class="aide">
|
||||
Vous pouvez scanner sans réseau : la liste est conservée sur cet appareil. Le
|
||||
traitement, lui, interroge la BnF et attendra le retour du réseau.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
else if (_phase == Phase.Traitement)
|
||||
{
|
||||
<h2 class="titre-section">Traitement en cours</h2>
|
||||
|
||||
<div class="avancement" role="progressbar"
|
||||
aria-valuenow="@_rafale.Pourcentage" aria-valuemin="0" aria-valuemax="100"
|
||||
aria-label="Avancement du traitement">
|
||||
<div class="avancement-barre" style="width: @(_rafale.Pourcentage)%"></div>
|
||||
</div>
|
||||
|
||||
<p aria-live="polite">
|
||||
<strong>@_rafale.Pourcentage %</strong> — @_rafale.Traites sur @_rafale.Total
|
||||
@if (_codeEnCours is { } encours)
|
||||
{
|
||||
<span class="aide"> · @FormatageIsbn.Afficher(encours)</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton" @onclick="() => _interrompu = true">
|
||||
Interrompre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="aide">
|
||||
Vous pourrez reprendre plus tard : ce qui est déjà enregistré ne sera pas refait.
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h2 class="titre-section">Compte rendu</h2>
|
||||
|
||||
<ul class="bilan">
|
||||
<li><strong>@_rafale.Crees</strong> livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "")</li>
|
||||
@if (_rafale.Passes > 0)
|
||||
{
|
||||
@*
|
||||
⚠️ Se COMPTE, ne se tait pas : sans cette ligne on croirait avoir ajouté
|
||||
cinquante livres alors qu'on en a ajouté quarante-deux.
|
||||
*@
|
||||
<li><strong>@_rafale.Passes</strong> déjà au catalogue, passé@(_rafale.Passes > 1 ? "s" : "")</li>
|
||||
}
|
||||
@if (_rafale.Residus.Count > 0)
|
||||
{
|
||||
<li><strong>@_rafale.Residus.Count</strong> à regarder ci-dessous</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@*
|
||||
⚠️ La liste des livres ajoutés est la moitié utile du compte rendu : sans elle, on sait
|
||||
qu'on a créé vingt fiches et l'on n'a aucun moyen de les retrouver autrement qu'en
|
||||
fouillant le catalogue à la main. Or c'est juste après la rafale qu'on veut les
|
||||
compléter — un scan ne donne ni type de document, ni thèmes, ni série.
|
||||
|
||||
Chaque ligne mène donc à sa fiche, et un bouton mène droit à son écran d'édition.
|
||||
*@
|
||||
@if (Ajoutes.Count > 0)
|
||||
{
|
||||
<h2 class="titre-section">Livres ajoutés</h2>
|
||||
|
||||
<ul class="liste-residus">
|
||||
@foreach (var ligne in Ajoutes)
|
||||
{
|
||||
<li class="carte-residu" @key="ligne.Code">
|
||||
<div class="residu-tete">
|
||||
<a class="carte-titre" href="@($"livres/{ligne.LivreId}")">
|
||||
@(string.IsNullOrWhiteSpace(ligne.Titre) ? "Fiche du livre" : ligne.Titre)
|
||||
</a>
|
||||
<span class="code-isbn">@FormatageIsbn.Afficher(ligne.Code)</span>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ligne.Motif))
|
||||
{
|
||||
<p class="aide">@ligne.Motif</p>
|
||||
}
|
||||
|
||||
<div class="actions-tome">
|
||||
<a class="bouton bouton-compact" href="@($"livres/{ligne.LivreId}/edition")">
|
||||
Compléter la fiche
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (_rafale.ResteATraiter)
|
||||
{
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(!Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="TraiterAsync">
|
||||
Continuer (@(_rafale.Total - _rafale.Traites) restant@(_rafale.Total - _rafale.Traites > 1 ? "s" : ""))
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_rafale.Residus.Count > 0)
|
||||
{
|
||||
<h2 class="titre-section">À reprendre</h2>
|
||||
|
||||
<ul class="liste-residus">
|
||||
@foreach (var ligne in _rafale.Residus)
|
||||
{
|
||||
<li class="carte-residu" @key="ligne.Code">
|
||||
<div class="residu-tete">
|
||||
<span class="code-isbn">@FormatageIsbn.Afficher(ligne.Code)</span>
|
||||
<span class="etiquette @ClasseEtat(ligne.Etat)">@Libelle(ligne.Etat)</span>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ligne.Titre))
|
||||
{
|
||||
<p class="carte-titre">@ligne.Titre</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ligne.Motif))
|
||||
{
|
||||
<p class="aide">@ligne.Motif</p>
|
||||
}
|
||||
|
||||
<div class="actions-tome">
|
||||
@*
|
||||
Chaque résidu renvoie vers l'écran qui sait le traiter, plutôt que
|
||||
de réimplémenter ici le choix d'édition ou la fiche revue.
|
||||
*@
|
||||
<a class="bouton bouton-compact" href="@LienDeReprise(ligne)">
|
||||
@(ligne.Etat == EtatCodeRafale.Revue ? "Ouvrir la revue" : "Reprendre")
|
||||
</a>
|
||||
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
@onclick="() => RetirerAsync(ligne)">
|
||||
Retirer de la liste
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<p class="aide">
|
||||
Cette liste reste consultable en revenant sur cet écran, tant que vous ne l'avez pas
|
||||
vidée.
|
||||
</p>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
@* Venu d'une saga, on y retourne : c'est là qu'on voit ce qui manque encore. *@
|
||||
@if (SerieVisee is { } retour)
|
||||
{
|
||||
<a class="bouton bouton-principal" href="@($"series/{retour.Id}")">
|
||||
Revenir à @retour.Titre
|
||||
</a>
|
||||
}
|
||||
|
||||
<button type="button" class="bouton bouton-danger" @onclick="AbandonnerAsync">
|
||||
Terminer et vider la liste
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private enum Phase { Collecte, Traitement, Bilan }
|
||||
|
||||
/// <summary>
|
||||
/// Série d'où l'on vient, quand la rafale a été lancée depuis une saga.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Ce paramètre ne sert qu'à <b>amorcer</b> la file : une fois lancée, c'est
|
||||
/// <c>Rafale.SerieId</c> qui commande, sans quoi une reprise ouverte depuis une autre
|
||||
/// adresse rattacherait ailleurs.
|
||||
/// </remarks>
|
||||
[SupplyParameterFromQuery(Name = "serie")]
|
||||
public int? SerieId { get; set; }
|
||||
|
||||
private SerieDto? _serie;
|
||||
|
||||
private Phase _phase = Phase.Collecte;
|
||||
private Rafale _rafale = new();
|
||||
private string _saisie = string.Empty;
|
||||
private bool _passerLesDoublons = true;
|
||||
private bool _repriseProposee;
|
||||
private bool _interrompu;
|
||||
private string? _codeEnCours;
|
||||
private string? _erreur;
|
||||
|
||||
/// <summary>
|
||||
/// ISBN déjà au catalogue, sous leurs DEUX formes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Lu une seule fois par traitement, et volontairement : le catalogue ne bouge que par
|
||||
/// nos propres créations, qu'on y ajoute au fil de l'eau.
|
||||
/// </remarks>
|
||||
private HashSet<string> _isbnDuCatalogue = new(StringComparer.Ordinal);
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
/// <summary>La série à laquelle rattacher : celle de la file en cours, sinon celle de l'adresse.</summary>
|
||||
private SerieDto? SerieVisee => _serie;
|
||||
|
||||
private int? CibleSerie => _rafale.SerieId ?? SerieId;
|
||||
|
||||
/// <summary>
|
||||
/// Nom de la série visée, pour le dire plutôt que de rattacher en silence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lu depuis l'instantané des séries, donc sans coût ni dépendance au réseau. Une lecture
|
||||
/// ratée ne retire rien : le rattachement, lui, ne dépend pas de ce nom.
|
||||
/// </remarks>
|
||||
private async Task ChargerSerieAsync()
|
||||
{
|
||||
if (CibleSerie is not { } id)
|
||||
{
|
||||
_serie = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_serie = (await Api.ListerSeriesAsync()).FirstOrDefault(s => s.Id == id);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_serie = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zone de collecte, focalisée à l'ouverture pour qu'une douchette y tape d'emblée.</summary>
|
||||
private ElementReference _zoneCodes;
|
||||
|
||||
private bool _focusAFaire = true;
|
||||
|
||||
/// <summary>Les livres réellement créés par cette rafale, dans l'ordre où ils ont été scannés.</summary>
|
||||
private IReadOnlyList<CodeRafale> Ajoutes =>
|
||||
[.. _rafale.Codes.Where(c => c.Etat == EtatCodeRafale.Cree && c.LivreId is not null)];
|
||||
|
||||
/// <remarks>
|
||||
/// ⚠️ Une seule fois par entrée dans l'étape de collecte : reprendre le focus à chaque rendu
|
||||
/// arracherait le curseur en pleine frappe, et la zone se réécrit à chaque caractère.
|
||||
/// </remarks>
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_focusAFaire && _phase == Phase.Collecte && !_repriseProposee)
|
||||
{
|
||||
_focusAFaire = false;
|
||||
await _zoneCodes.FocusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurReseau;
|
||||
|
||||
// ⚠️ Une rafale inachevée doit se proposer d'elle-même : sans cela, elle existe dans
|
||||
// IndexedDB et personne ne la retrouve.
|
||||
// ⚠️ Toute file non vidée est proposée, terminée ou non : c'est le seul chemin de retour
|
||||
// vers la liste des livres qu'une rafale vient de créer.
|
||||
var reprise = await Cache.LireRafaleAsync();
|
||||
if (reprise is { Total: > 0 })
|
||||
{
|
||||
_rafale = reprise;
|
||||
_passerLesDoublons = reprise.PasserLesDoublons;
|
||||
_repriseProposee = true;
|
||||
}
|
||||
|
||||
await ChargerSerieAsync();
|
||||
}
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurReseau;
|
||||
|
||||
private void SurReseau() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void Reprendre()
|
||||
{
|
||||
_repriseProposee = false;
|
||||
_phase = Phase.Bilan;
|
||||
}
|
||||
|
||||
private async Task AbandonnerAsync()
|
||||
{
|
||||
await Cache.EffacerRafaleAsync();
|
||||
_rafale = new Rafale();
|
||||
_saisie = string.Empty;
|
||||
_repriseProposee = false;
|
||||
_phase = Phase.Collecte;
|
||||
|
||||
// On revient à une zone vide : c'est une nouvelle rafale, elle mérite le même focus
|
||||
// que la première.
|
||||
_focusAFaire = true;
|
||||
|
||||
// ⚠️ La file effacée emportait sa série : sans cette relecture, le bandeau continuait
|
||||
// d'annoncer « les livres seront ajoutés à la série X » pour une rafale neuve qui,
|
||||
// ouverte sans paramètre d'adresse, ne rattachera nulle part.
|
||||
await ChargerSerieAsync();
|
||||
}
|
||||
|
||||
private async Task LancerAsync()
|
||||
{
|
||||
var codes = SaisieRafale.Decouper(_saisie);
|
||||
if (codes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_rafale = new Rafale
|
||||
{
|
||||
Codes = [.. codes.Select(c => new CodeRafale { Code = c })],
|
||||
PasserLesDoublons = _passerLesDoublons,
|
||||
SerieId = SerieId,
|
||||
};
|
||||
|
||||
await Cache.EcrireRafaleAsync(_rafale);
|
||||
await TraiterAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traite la file, un code à la fois.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Un par un, délibérément.</b> Cinquante livres font jusqu'à cent requêtes — chaque
|
||||
/// ISBN est cherché en 13 puis en 10 — et c'est le SERVEUR qui appelle la BnF. Les lancer
|
||||
/// ensemble lui ferait ouvrir cent connexions vers <c>catalogue.bnf.fr</c>. Séquentiel, la
|
||||
/// progression se voit et une coupure ne perd que le code en cours.
|
||||
/// <para>
|
||||
/// La file est réécrite <b>après chaque code</b> : c'est ce qui rend la reprise exacte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task TraiterAsync()
|
||||
{
|
||||
_phase = Phase.Traitement;
|
||||
_interrompu = false;
|
||||
_erreur = null;
|
||||
|
||||
await ChargerIsbnDuCatalogueAsync();
|
||||
|
||||
for (var i = 0; i < _rafale.Codes.Count; i++)
|
||||
{
|
||||
if (_interrompu || !Reseau.EnLigne)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (_rafale.Codes[i].Etat != EtatCodeRafale.ATraiter)
|
||||
{
|
||||
continue; // Déjà traité : une reprise ne refait jamais ce qui est fait.
|
||||
}
|
||||
|
||||
_codeEnCours = _rafale.Codes[i].Code;
|
||||
StateHasChanged();
|
||||
|
||||
_rafale.Codes[i] = await TraiterUnAsync(_rafale.Codes[i]);
|
||||
|
||||
await Cache.EcrireRafaleAsync(_rafale);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
_codeEnCours = null;
|
||||
_phase = Phase.Bilan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Les ISBN déjà possédés, dans les deux formes, pour reconnaître un doublon sans requête.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Les deux formes sont indispensables</b> : un livre saisi avant 2007 porte un
|
||||
/// ISBN-10 en base, alors qu'un scanner lit toujours un EAN-13. Ne comparer qu'une forme
|
||||
/// ferait rescanner tout le fonds ancien comme s'il était neuf — c'est le même piège que
|
||||
/// celui de la recherche BnF, à l'autre bout de la chaîne.
|
||||
/// </remarks>
|
||||
private async Task ChargerIsbnDuCatalogueAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var livres = await Api.ListerAsync(new CritereLivres());
|
||||
_isbnDuCatalogue = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var isbn in livres.Select(l => IsbnUtils.Normaliser(l.Isbn)).OfType<string>())
|
||||
{
|
||||
_isbnDuCatalogue.Add(isbn);
|
||||
|
||||
if (IsbnUtils.TryConvertirEnIsbn10(isbn, out var court) && court is not null)
|
||||
{
|
||||
_isbnDuCatalogue.Add(court);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Sans cette liste, on retombe simplement sur la détection du serveur à la
|
||||
// création : plus lente, mais jamais fausse.
|
||||
_isbnDuCatalogue = [];
|
||||
}
|
||||
}
|
||||
|
||||
private bool DejaAuCatalogue(string code)
|
||||
{
|
||||
if (_isbnDuCatalogue.Contains(code))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsbnUtils.TryConvertirEnIsbn10(code, out var court)
|
||||
&& court is not null
|
||||
&& _isbnDuCatalogue.Contains(court);
|
||||
}
|
||||
|
||||
private async Task<CodeRafale> TraiterUnAsync(CodeRafale code)
|
||||
{
|
||||
// ⚠️ AVANT le lookup, et avant le choix d'édition. Constaté à l'écran : sans ce test,
|
||||
// un livre déjà possédé dont la BnF rend trois notices ressortait « à choisir » — on
|
||||
// demandait de trancher l'édition d'un livre qu'on allait de toute façon passer.
|
||||
// Épargne au passage une requête BnF par livre déjà catalogué.
|
||||
if (_rafale.PasserLesDoublons && DejaAuCatalogue(code.Code))
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Passe,
|
||||
Motif = "Déjà au catalogue.",
|
||||
};
|
||||
}
|
||||
|
||||
ResultatLookupIsbn? lookup;
|
||||
try
|
||||
{
|
||||
lookup = await Api.ChercherIsbnAsync(code.Code);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Echec,
|
||||
Motif = "La recherche n'a pas abouti. Réessayez plus tard.",
|
||||
};
|
||||
}
|
||||
|
||||
if (lookup is null)
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Echec,
|
||||
Motif = "La recherche n'a pas abouti. Réessayez plus tard.",
|
||||
};
|
||||
}
|
||||
|
||||
// Un préfixe 977 est un périodique : il porte un ISSN, donc un titre de revue, et son
|
||||
// numéro se saisit à la main. Il ne peut pas suivre le chemin des livres.
|
||||
if (lookup.Periodique is { } revue)
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Revue,
|
||||
Titre = revue.Titre,
|
||||
Motif = "Magazine : le numéro de parution se saisit à la main.",
|
||||
};
|
||||
}
|
||||
|
||||
if (lookup.Candidats.Count == 0)
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Introuvable,
|
||||
Motif = "Aucune source ne connaît ce code. À saisir à la main.",
|
||||
};
|
||||
}
|
||||
|
||||
// ⚠️ Plusieurs notices : on NE choisit PAS à la place de l'utilisateur. C'est la règle
|
||||
// actée « notices multiples : demander systématiquement », et elle vaut ici aussi —
|
||||
// sauf qu'on demande à la fin, pas au milieu de la rafale.
|
||||
if (lookup.Candidats.Count > 1)
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.AChoisir,
|
||||
Titre = lookup.Candidats[0].Titre,
|
||||
Motif = $"{lookup.Candidats.Count} éditions possibles : à choisir.",
|
||||
};
|
||||
}
|
||||
|
||||
var candidat = lookup.Candidats[0];
|
||||
|
||||
// Même construction que l'écran d'ajout unitaire : `Format.Physique` parce qu'un livre
|
||||
// scanné est un objet qu'on tient, et `Statut.ALire` parce qu'on vient de l'acquérir.
|
||||
var saisie = new EnregistrementLivre
|
||||
{
|
||||
Isbn = code.Code,
|
||||
Titre = candidat.Titre,
|
||||
Auteur = candidat.Auteur,
|
||||
Editeur = candidat.Editeur,
|
||||
NombrePages = candidat.NombrePages,
|
||||
CoverUrl = candidat.CoverUrl,
|
||||
UrlNotice = candidat.UrlNotice,
|
||||
Format = Format.Physique,
|
||||
Statut = Statut.ALire,
|
||||
};
|
||||
|
||||
var resultat = await Api.CreerAsync(saisie);
|
||||
|
||||
if (resultat.Doublons is { } doublons)
|
||||
{
|
||||
// Passer est le défaut, mais cela se compte : voir le compte rendu.
|
||||
return _rafale.PasserLesDoublons
|
||||
? code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Passe,
|
||||
Titre = candidat.Titre,
|
||||
Motif = doublons.Message,
|
||||
}
|
||||
: code with
|
||||
{
|
||||
Etat = EtatCodeRafale.AChoisir,
|
||||
Titre = candidat.Titre,
|
||||
Motif = doublons.Message,
|
||||
};
|
||||
}
|
||||
|
||||
if (resultat.Livre is { } livre)
|
||||
{
|
||||
// Un code scanné deux fois dans la MÊME rafale est déjà écarté par la découpe ;
|
||||
// ceci couvre le cas de deux codes différents qui désignent le même livre.
|
||||
_isbnDuCatalogue.Add(code.Code);
|
||||
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Cree,
|
||||
Titre = livre.Titre,
|
||||
LivreId = livre.Id,
|
||||
Motif = await RattacherAsync(livre),
|
||||
};
|
||||
}
|
||||
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Echec,
|
||||
Titre = candidat.Titre,
|
||||
Motif = resultat.Erreur ?? "L'enregistrement n'a pas abouti.",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Range le livre créé dans la série visée, s'il y en a une.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Un rattachement raté ne remet pas la ligne en échec : <b>le livre existe</b>, et le
|
||||
/// redire « à traiter » le recréerait à la reprise. On le dit dans le compte rendu, où la
|
||||
/// ligne mène déjà à sa fiche — c'est là qu'on répare, en un geste.
|
||||
/// </remarks>
|
||||
private async Task<string?> RattacherAsync(LivreDto livre)
|
||||
{
|
||||
if (_rafale.SerieId is not { } serieId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resultat = await Api.AjouterElementSerieAsync(
|
||||
serieId, new AjoutElementSerie { LivreId = livre.Id });
|
||||
|
||||
return resultat.EstOk
|
||||
? null
|
||||
: "Livre enregistré, mais il n'a pas pu être ajouté à la série.";
|
||||
}
|
||||
|
||||
private async Task RetirerAsync(CodeRafale code)
|
||||
{
|
||||
_rafale.Codes.RemoveAll(c => c.Code == code.Code);
|
||||
await Cache.EcrireRafaleAsync(_rafale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Où reprendre un résidu — l'écran qui sait déjà traiter ce cas.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ On ne réimplémente ici ni le choix d'édition, ni la fiche revue, ni la saisie
|
||||
/// manuelle : trois copies d'un écran existant divergeraient.
|
||||
/// </remarks>
|
||||
private static string LienDeReprise(CodeRafale code) => code.Etat switch
|
||||
{
|
||||
EtatCodeRafale.Introuvable => $"/ajout/manuel?isbn={code.Code}",
|
||||
_ => $"/ajout/isbn?code={code.Code}",
|
||||
};
|
||||
|
||||
private static string Libelle(EtatCodeRafale etat) => etat switch
|
||||
{
|
||||
EtatCodeRafale.AChoisir => "Plusieurs éditions",
|
||||
EtatCodeRafale.Introuvable => "Introuvable",
|
||||
EtatCodeRafale.Revue => "Magazine",
|
||||
EtatCodeRafale.Echec => "Échec",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static string ClasseEtat(EtatCodeRafale etat) => etat switch
|
||||
{
|
||||
EtatCodeRafale.Revue => "etiquette-type",
|
||||
EtatCodeRafale.Echec => "etiquette-manquant",
|
||||
_ => "etiquette-prete",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Navigation
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — catalogue</PageTitle>
|
||||
@@ -11,14 +12,36 @@
|
||||
rarement. Le compteur sur le bouton est ce qui rend le repli sans danger — un filtre actif
|
||||
et replié serait invisible, et l'on conclurait que des livres ont disparu.
|
||||
*@
|
||||
@*
|
||||
⚠️ « Ajouter un ouvrage » est ICI, à côté de « Filtrer », et non plus dans une barre
|
||||
flottante en bas : c'est le geste le plus fréquent après la recherche, et les deux boutons
|
||||
du haut se manœuvrent d'une main. Il n'y en a qu'UN — deux entrées identiques sur le même
|
||||
écran seraient du bruit, et le projet désencombre par le regroupement, jamais par
|
||||
l'effacement.
|
||||
|
||||
⚠️ Hors-ligne il est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable d'une
|
||||
fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau.
|
||||
*@
|
||||
<div class="entete-page">
|
||||
<h1 class="titre-page">Catalogue</h1>
|
||||
|
||||
<button type="button" class="bouton bouton-discret bouton-compact"
|
||||
aria-expanded="@Aria.Etat(_filtresOuverts)" aria-controls="filtres-catalogue"
|
||||
@onclick="() => _filtresOuverts = !_filtresOuverts">
|
||||
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
|
||||
</button>
|
||||
<div class="entete-page-actions">
|
||||
<button type="button" class="bouton bouton-discret bouton-compact"
|
||||
aria-expanded="@Aria.Etat(_filtresOuverts)" aria-controls="filtres-catalogue"
|
||||
@onclick="() => _filtresOuverts = !_filtresOuverts">
|
||||
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
|
||||
</button>
|
||||
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-principal bouton-compact" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_auteur is not null)
|
||||
@@ -30,11 +53,28 @@
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="barre-recherche">
|
||||
<input class="champ-saisie" type="search" placeholder="Titre ou auteur…"
|
||||
@*
|
||||
La recherche accepte aussi un CODE : douchette, caméra, ou code recopié à la main. C'est le
|
||||
geste « j'ai le livre en main, qu'est-ce que j'en sais déjà ? », l'inverse exact de l'ajout.
|
||||
|
||||
⚠️ Le scan reste offert HORS-LIGNE, contrairement à l'ajout : le décodage se fait dans le
|
||||
navigateur, et la recherche retombe sur l'instantané. Rien là-dedans n'a besoin du réseau.
|
||||
*@
|
||||
<div class="barre-recherche ligne-creation">
|
||||
<input class="champ-saisie" type="search" placeholder="Titre, auteur ou ISBN…"
|
||||
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
|
||||
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
@onclick="() => _scanOuvert = !_scanOuvert">
|
||||
@(_scanOuvert ? "Fermer" : "Scanner")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_scanOuvert)
|
||||
{
|
||||
<ScannerCodeBarres OnCodeDetecte="SurCodeScanneAsync" OnAnnuler="() => _scanOuvert = false" />
|
||||
}
|
||||
|
||||
@if (_filtresOuverts)
|
||||
{
|
||||
<div class="filtres" id="filtres-catalogue">
|
||||
@@ -110,6 +150,32 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
« Quels livres me reste-t-il à illustrer ? » — la seule question à laquelle ce
|
||||
filtre répond, et la raison pour laquelle il existe.
|
||||
|
||||
⚠️ « Sans couverture » veut dire « sans URL enregistrée », PAS « dont l'image ne
|
||||
se charge pas ». Savoir la seconde demanderait d'interroger chaque URL, ce que
|
||||
CLAUDE.md interdit : OpenLibrary rend des 502 intermittents, et le même livre
|
||||
entrerait et sortirait du filtre d'un rafraîchissement à l'autre.
|
||||
|
||||
⚠️ La ligne disparaît quand le fonds est homogène — tout illustré, ou rien.
|
||||
Contrairement aux formats, les DEUX extrêmes sont inutiles ici : si aucun livre
|
||||
n'a de couverture, la liste complète EST déjà celle des livres à illustrer.
|
||||
Et comme partout, cela ne se déduit QUE d'un chargement sans critère.
|
||||
*@
|
||||
@if (CouverturesInegales)
|
||||
{
|
||||
<div class="segments" role="group" aria-label="Filtrer par couverture">
|
||||
<button type="button" class="segment @(_sansCouverture is null ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerCouverture(null)">Tous</button>
|
||||
<button type="button" class="segment @(_sansCouverture == false ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerCouverture(false)">Avec couverture</button>
|
||||
<button type="button" class="segment @(_sansCouverture == true ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerCouverture(true)">Sans couverture</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
⚠️ Grouper ne CACHE rien — c'est pourquoi cette bascule n'entre pas dans le compteur
|
||||
du bouton « Filtrer », qui ne compte que ce qui rendrait des livres invisibles. Elle
|
||||
@@ -168,7 +234,7 @@ else if (_livres is not null)
|
||||
{
|
||||
@if (entree.EstGroupe)
|
||||
{
|
||||
<GroupeCatalogue Entree="entree" Replies="_replies" />
|
||||
<GroupeCatalogue Entree="entree" Replies="_replies" Totaux="_totauxSeries" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -180,29 +246,6 @@ else if (_livres is not null)
|
||||
</ul>
|
||||
}
|
||||
|
||||
@*
|
||||
Une seule entrée d'ajout, et plus aucun lien de navigation : les quatre écrans sont dans
|
||||
les onglets, en bas.
|
||||
|
||||
« Saisie manuelle » est retirée d'ici — elle reste accessible un écran plus loin, depuis
|
||||
« Ajouter », qui la propose dès qu'un ISBN ne donne rien.
|
||||
|
||||
⚠️ Hors-ligne le bouton est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable
|
||||
d'une fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau. Le
|
||||
désencombrement demandé passe par le regroupement (deux boutons en un), pas par l'effacement.
|
||||
*@
|
||||
<nav class="actions-flottantes">
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-principal" href="ajout">Ajouter un ouvrage</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-principal" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
|
||||
}
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
/// <summary>Restreint le catalogue à un auteur. Vient de « ?auteur=… », posé par les liens de la liste.</summary>
|
||||
[SupplyParameterFromQuery(Name = "auteur")]
|
||||
@@ -236,6 +279,16 @@ else if (_livres is not null)
|
||||
/// </remarks>
|
||||
private readonly HashSet<int> _replies = [];
|
||||
|
||||
/// <summary>
|
||||
/// Ce que chaque série possède et recense, descendance comprise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Recalculé <b>seulement</b> quand les séries changent, et non à chaque chargement du
|
||||
/// catalogue : celui-ci se relit à chaque frappe dans la recherche.
|
||||
/// </remarks>
|
||||
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totauxSeries =
|
||||
new Dictionary<int, (int, int)>();
|
||||
|
||||
/// <summary>Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés.</summary>
|
||||
private IReadOnlyList<EntreeCatalogue> Entrees =>
|
||||
GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null);
|
||||
@@ -246,6 +299,8 @@ else if (_livres is not null)
|
||||
private TypeDocument? _type;
|
||||
private Statut? _statut;
|
||||
private bool? _prete;
|
||||
|
||||
private bool? _sansCouverture;
|
||||
private bool _chargement;
|
||||
private bool _filtresOuverts;
|
||||
private string? _erreur;
|
||||
@@ -277,6 +332,19 @@ else if (_livres is not null)
|
||||
|
||||
private bool QuelqueChoseEstPrete => _fondsAvecPret is not false;
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si le fonds contient à la fois des livres avec et sans couverture — ou qu'on ne le
|
||||
/// sait pas encore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Il faut les DEUX, contrairement au filtre de prêt qui se contente d'un prêt en cours :
|
||||
/// sur un fonds entièrement dépourvu de couvertures, « sans couverture » rendrait la liste
|
||||
/// entière, et ne serait donc pas un filtre. Dans le doute, la ligne reste affichée.
|
||||
/// </remarks>
|
||||
private bool? _fondsCouverturesInegales;
|
||||
|
||||
private bool CouverturesInegales => _fondsCouverturesInegales is not false;
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
|
||||
/// </summary>
|
||||
@@ -287,14 +355,15 @@ else if (_livres is not null)
|
||||
/// </remarks>
|
||||
private int FiltresActifs =>
|
||||
(_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1)
|
||||
+ (_prete is null ? 0 : 1);
|
||||
+ (_prete is null ? 0 : 1) + (_sansCouverture is null ? 0 : 1);
|
||||
|
||||
// Une frappe au clavier annule la recherche précédente encore en vol.
|
||||
private CancellationTokenSource _cts = new();
|
||||
|
||||
private bool EstFiltre =>
|
||||
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _type is not null
|
||||
|| _statut is not null || _prete is not null || AuteurId is not null;
|
||||
|| _statut is not null || _prete is not null || _sansCouverture is not null
|
||||
|| AuteurId is not null;
|
||||
|
||||
/// <summary>
|
||||
/// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du
|
||||
@@ -315,10 +384,12 @@ else if (_livres is not null)
|
||||
try
|
||||
{
|
||||
_series = await Api.ListerSeriesAsync();
|
||||
_totauxSeries = TotauxSeries.Calculer(_series);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_series = null;
|
||||
_totauxSeries = new Dictionary<int, (int, int)>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +422,48 @@ else if (_livres is not null)
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
/// <summary>Le scanner est ouvert au-dessus de la liste, pour retrouver un livre en main.</summary>
|
||||
private bool _scanOuvert;
|
||||
|
||||
/// <summary>
|
||||
/// Un code lu remplit la recherche, exactement comme une douchette l'aurait tapé.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le code reste dans le champ : un décodage erroné se corrige et se relance, comme sur
|
||||
/// l'écran d'ajout. ⚠️ L'add-on EAN-2 est ignoré — il ne concerne que les revues, qui ne
|
||||
/// sont pas au catalogue.
|
||||
/// </remarks>
|
||||
private async Task SurCodeScanneAsync(CodeScanne lecture)
|
||||
{
|
||||
_scanOuvert = false;
|
||||
_recherche = lecture.Code;
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre la fiche quand le code désigne <b>un seul</b> livre du catalogue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C'est tout l'intérêt du scan : on tient le livre, on veut sa fiche, pas une liste d'un
|
||||
/// élément à cliquer.
|
||||
/// <para>
|
||||
/// ⚠️ Deux conditions, et les deux comptent. <b>Un seul résultat</b>, sinon on choisirait à
|
||||
/// la place de l'utilisateur — deux exemplaires du même ISBN sont légitimes. Et un
|
||||
/// <b>code complet et valide</b> : sur un fragment en cours de frappe, le résultat unique
|
||||
/// est un hasard, et l'écran sauterait sous les doigts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool OuvrirSiUnique()
|
||||
{
|
||||
if (_livres is not { Count: 1 } || !RechercheIsbn.EstCodeComplet(_recherche))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Navigation.NavigateTo($"livres/{_livres[0].Id}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private Task FiltrerFormat(Format? format)
|
||||
{
|
||||
_format = format;
|
||||
@@ -375,6 +488,12 @@ else if (_livres is not null)
|
||||
return ChargerAsync();
|
||||
}
|
||||
|
||||
private Task FiltrerCouverture(bool? sansCouverture)
|
||||
{
|
||||
_sansCouverture = sansCouverture;
|
||||
return ChargerAsync();
|
||||
}
|
||||
|
||||
/// <summary>Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte.</summary>
|
||||
private static bool EstSansCritere(CritereLivres criteres) =>
|
||||
string.IsNullOrWhiteSpace(criteres.Recherche)
|
||||
@@ -382,6 +501,7 @@ else if (_livres is not null)
|
||||
&& criteres.TypeDocument is null
|
||||
&& criteres.Statut is null
|
||||
&& criteres.Prete is null
|
||||
&& criteres.SansCouverture is null
|
||||
&& criteres.AuteurId is null;
|
||||
|
||||
private async Task ChargerAsync()
|
||||
@@ -403,16 +523,25 @@ else if (_livres is not null)
|
||||
TypeDocument = _type,
|
||||
Statut = _statut,
|
||||
Prete = _prete,
|
||||
SansCouverture = _sansCouverture,
|
||||
AuteurId = AuteurId,
|
||||
};
|
||||
|
||||
_livres = await Api.ListerAsync(criteres, jeton);
|
||||
|
||||
if (OuvrirSiUnique())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (EstSansCritere(criteres))
|
||||
{
|
||||
_formatsDuFonds = _livres.Select(l => l.Format).ToHashSet();
|
||||
_typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet();
|
||||
_fondsAvecPret = _livres.Any(l => l.PreteA is not null);
|
||||
_fondsCouverturesInegales =
|
||||
_livres.Any(l => string.IsNullOrEmpty(l.CoverUrl))
|
||||
&& _livres.Any(l => !string.IsNullOrEmpty(l.CoverUrl));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
|
||||
@@ -91,8 +91,17 @@ else
|
||||
}
|
||||
|
||||
<div class="fiche-entete">
|
||||
@*
|
||||
⚠️ Une couverture CASSÉE devient un point de correction : cliquer dessus ouvre son
|
||||
adresse, focalisée, et « Entrée » enregistre. C'est là qu'on constate le défaut, et
|
||||
c'est donc là qu'il faut pouvoir le réparer — passer par « Éditer » puis descendre
|
||||
au dernier champ du formulaire pour corriger un lien mort était un détour.
|
||||
|
||||
Une couverture qui s'affiche, elle, s'agrandit comme avant.
|
||||
*@
|
||||
<Couverture Url="@_livre.CoverUrl" Titre="@_livre.Titre"
|
||||
Classe="couverture-grande" Agrandissable="true" />
|
||||
Classe="couverture-grande" Agrandissable="true"
|
||||
OnCorriger="OuvrirCorrectionCouverture" />
|
||||
|
||||
<div class="fiche-infos">
|
||||
<h2 class="fiche-titre">@_livre.Titre</h2>
|
||||
@@ -181,6 +190,32 @@ else
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_correctionCouverture)
|
||||
{
|
||||
<div class="barre-recherche ligne-creation">
|
||||
<input class="champ-saisie" type="url" @ref="_champUrlCouverture"
|
||||
@bind="_urlCouverture" @bind:event="oninput"
|
||||
@onkeydown="SurToucheCouverture"
|
||||
placeholder="https://…" aria-label="Adresse de la couverture"
|
||||
autocomplete="off" />
|
||||
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enregistrement || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="EnregistrerCouvertureAsync">
|
||||
@(_enregistrement ? "Enregistrement…" : "Enregistrer")
|
||||
</button>
|
||||
|
||||
<button type="button" class="bouton bouton-discret" disabled="@_enregistrement"
|
||||
@onclick="() => _correctionCouverture = false">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="message-discret">
|
||||
Collez l'adresse d'une image, puis Entrée. Vider le champ retire la couverture.
|
||||
</p>
|
||||
}
|
||||
|
||||
@* Hors « Éditer » volontairement : changer son statut est l'action la plus fréquente
|
||||
de l'application, et elle ne touche qu'à MA lecture, jamais à la fiche commune. *@
|
||||
<section class="bloc-statut">
|
||||
@@ -368,6 +403,83 @@ else
|
||||
/// </summary>
|
||||
private async Task RechargerAsync() => _livre = await Api.ObtenirAsync(Id) ?? _livre;
|
||||
|
||||
/// <summary>Champ d'adresse de la couverture, ouvert depuis l'image cassée elle-même.</summary>
|
||||
private bool _correctionCouverture;
|
||||
|
||||
private string _urlCouverture = string.Empty;
|
||||
|
||||
private ElementReference _champUrlCouverture;
|
||||
|
||||
private bool _focusCouverture;
|
||||
|
||||
private void OuvrirCorrectionCouverture()
|
||||
{
|
||||
_erreur = null;
|
||||
_urlCouverture = _livre?.CoverUrl ?? string.Empty;
|
||||
_correctionCouverture = true;
|
||||
|
||||
// ⚠️ Le focus est demandé UNE fois, au prochain rendu : le reprendre à chaque rendu
|
||||
// l'arracherait pendant qu'on colle ou qu'on tape.
|
||||
_focusCouverture = true;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_focusCouverture)
|
||||
{
|
||||
_focusCouverture = false;
|
||||
await _champUrlCouverture.FocusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SurToucheCouverture(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
await EnregistrerCouvertureAsync();
|
||||
}
|
||||
else if (e.Key is "Escape" or "Esc")
|
||||
{
|
||||
_correctionCouverture = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enregistre la seule adresse de couverture, sans passer par l'écran d'édition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Même exception que le statut de lecture et les prêts : ce n'est pas « modifier la fiche »,
|
||||
/// c'est réparer ce qu'on a sous les yeux. La saisie du formulaire d'édition est mise à jour
|
||||
/// dans la foulée, sinon un passage ultérieur en édition réécrirait l'ancienne adresse.
|
||||
/// </remarks>
|
||||
private async Task EnregistrerCouvertureAsync()
|
||||
{
|
||||
if (_saisie is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var url = _urlCouverture.Trim();
|
||||
_saisie.CoverUrl = string.IsNullOrEmpty(url) ? null : url;
|
||||
|
||||
_enregistrement = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.ModifierAsync(Id, _saisie);
|
||||
|
||||
_enregistrement = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
_livre = resultat.Valeur;
|
||||
_saisie = Recopier(_livre!);
|
||||
_correctionCouverture = false;
|
||||
}
|
||||
|
||||
private async Task ChangerStatutAsync(Statut? statut)
|
||||
{
|
||||
_statutEnCours = true;
|
||||
|
||||
@@ -189,15 +189,53 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
@* La couverture d'un numéro ne vient d'aucune source — l'ISSN désigne la
|
||||
revue, pas la parution : sans URL collée, c'est le substitut à
|
||||
initiale qui s'affiche, et il n'est pas cliquable. *@
|
||||
@*
|
||||
La couverture d'un numéro ne vient d'AUCUNE source — l'ISSN désigne
|
||||
la revue, pas la parution : elle se colle à la main, ou il n'y en a
|
||||
pas. C'est donc ici que le clic pour la poser sert le plus.
|
||||
|
||||
⚠️ Même principe que la fiche livre : cliquer une couverture absente
|
||||
ou cassée ouvre son adresse, focalisée, et Entrée enregistre. Ce
|
||||
n'est pas « modifier le numéro » — c'est réparer ce qu'on a sous les
|
||||
yeux, et c'est pourquoi ce geste-là existe aussi en consultation,
|
||||
contrairement à « Modifier » et « Retirer ».
|
||||
*@
|
||||
<Couverture Url="@numero.CoverUrl" Titre="@($"N° {numero.Numero}")"
|
||||
Classe="couverture-petite" Agrandissable="true" />
|
||||
Classe="couverture-petite" Agrandissable="true"
|
||||
OnCorriger="() => OuvrirCorrectionCouverture(numero)" />
|
||||
|
||||
<div class="carte-corps">
|
||||
<p class="carte-titre">N° @numero.Numero</p>
|
||||
|
||||
@if (_correctionCouverture == numero.Id)
|
||||
{
|
||||
<div class="barre-recherche ligne-creation">
|
||||
<input class="champ-saisie" type="url" @ref="_champUrlCouverture"
|
||||
@bind="_urlCouverture" @bind:event="oninput"
|
||||
@onkeydown="@(e => SurToucheCouverture(e, numero))"
|
||||
placeholder="https://…"
|
||||
aria-label="@($"Adresse de la couverture du numéro {numero.Numero}")"
|
||||
autocomplete="off" />
|
||||
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => EnregistrerCouvertureAsync(numero)">
|
||||
@(_enCours ? "Enregistrement…" : "Enregistrer")
|
||||
</button>
|
||||
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
disabled="@_enCours"
|
||||
@onclick="() => _correctionCouverture = null">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="message-discret">
|
||||
Collez l'adresse d'une image, puis Entrée. Vider le champ
|
||||
retire la couverture.
|
||||
</p>
|
||||
}
|
||||
|
||||
<p class="carte-details">
|
||||
@if (numero.DateParution is { } parution)
|
||||
{
|
||||
@@ -300,6 +338,15 @@ else
|
||||
/// <summary>Numéro dont le retrait attend confirmation — <c>null</c> = aucun.</summary>
|
||||
private int? _confirmeRetrait;
|
||||
|
||||
/// <summary>Numéro dont l'adresse de couverture est ouverte — <c>null</c> = aucun.</summary>
|
||||
private int? _correctionCouverture;
|
||||
|
||||
private string _urlCouverture = string.Empty;
|
||||
|
||||
private ElementReference _champUrlCouverture;
|
||||
|
||||
private bool _focusCouverture;
|
||||
|
||||
/// <summary>
|
||||
/// Écran d'édition de la revue : nom, ISSN, éditeur, suppression, et la seule place où un
|
||||
/// numéro se modifie ou se retire.
|
||||
@@ -440,6 +487,87 @@ else
|
||||
|
||||
private void FermerEdition() => _edition = null;
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre l'adresse de la couverture d'un numéro, depuis l'image elle-même.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Le focus est demandé <b>une seule fois</b>, au prochain rendu : le reprendre à chaque
|
||||
/// rendu l'arracherait pendant qu'on colle ou qu'on tape.
|
||||
/// </remarks>
|
||||
private void OuvrirCorrectionCouverture(NumeroRevueDto numero)
|
||||
{
|
||||
_erreur = null;
|
||||
_confirmeRetrait = null;
|
||||
_correctionCouverture = numero.Id;
|
||||
_urlCouverture = numero.CoverUrl ?? string.Empty;
|
||||
_focusCouverture = true;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_focusCouverture)
|
||||
{
|
||||
_focusCouverture = false;
|
||||
await _champUrlCouverture.FocusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SurToucheCouverture(KeyboardEventArgs e, NumeroRevueDto numero)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
await EnregistrerCouvertureAsync(numero);
|
||||
}
|
||||
else if (e.Key is "Escape" or "Esc")
|
||||
{
|
||||
_correctionCouverture = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N'enregistre que l'adresse de la couverture, sans passer par l'écran d'édition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Le point d'entrée REMPLACE le numéro entier</b>, articles à la une compris : tout ce
|
||||
/// que porte la ligne doit être recopié dans la charge utile, faute de quoi poser une
|
||||
/// couverture effacerait la une. C'est la même précaution que dans <c>OuvrirEdition</c>, et
|
||||
/// c'est le genre d'oubli qu'aucun message d'erreur ne signalerait.
|
||||
/// </remarks>
|
||||
private async Task EnregistrerCouvertureAsync(NumeroRevueDto numero)
|
||||
{
|
||||
if (!Reseau.EnLigne || _enCours)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var url = _urlCouverture.Trim();
|
||||
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.ModifierNumeroAsync(
|
||||
numero.Id,
|
||||
new AjoutNumeroRevue
|
||||
{
|
||||
Numero = numero.Numero,
|
||||
DateParution = numero.DateParution,
|
||||
Note = numero.Note,
|
||||
CoverUrl = string.IsNullOrEmpty(url) ? null : url,
|
||||
Articles = [.. numero.Articles],
|
||||
});
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Appliquer(resultat.Valeur!);
|
||||
_correctionCouverture = null;
|
||||
}
|
||||
|
||||
private async Task EnregistrerNumeroAsync()
|
||||
{
|
||||
if (_edition is not { } numeroId
|
||||
@@ -490,6 +618,12 @@ else
|
||||
_edition = null;
|
||||
}
|
||||
|
||||
// Le numéro retiré portait peut-être le champ d'adresse ouvert : il n'a plus de cible.
|
||||
if (_correctionCouverture == numero.Id)
|
||||
{
|
||||
_correctionCouverture = null;
|
||||
}
|
||||
|
||||
if (_erreur is null)
|
||||
{
|
||||
await ChargerAsync();
|
||||
|
||||
+555
-115
@@ -58,6 +58,18 @@ else
|
||||
<div class="entete-fiche-actions">
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
@*
|
||||
« Ajouter » se déplie sur place, comme « Filtrer » au catalogue : les
|
||||
quatre voies et leur formulaire vivaient en bas de page, après une saga
|
||||
de vingt tomes qu'il fallait dérouler pour les atteindre. Recliquer
|
||||
referme tout, la voie ouverte comprise.
|
||||
*@
|
||||
<button type="button" class="bouton bouton-compact bouton-discret"
|
||||
aria-expanded="@Aria.Etat(_ajoutOuvert)" aria-controls="ajout-tome"
|
||||
@onclick="BasculerAjout">
|
||||
Ajouter
|
||||
</button>
|
||||
|
||||
@if (_serie.Elements.Count > 1 || _filles.Count > 1)
|
||||
{
|
||||
<a class="bouton bouton-compact bouton-discret" href="@($"series/{Id}/ordre")">Changer l'ordre</a>
|
||||
@@ -73,6 +85,187 @@ else
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (ModeConsultation && _ajoutOuvert)
|
||||
{
|
||||
<div class="panneau-ajout" id="ajout-tome">
|
||||
<h2 class="titre-section">Ajouter un tome</h2>
|
||||
|
||||
@*
|
||||
QUATRE voies, annoncées d'abord, la vue correspondante ensuite. Une saga se recense
|
||||
d'un coup — souvent avant d'en posséder la moitié — puis se remplit au fil des achats,
|
||||
et l'on n'entre pas les tomes de la même façon selon qu'on les a en main, au catalogue,
|
||||
ou seulement en tête. Un tome saisi sans livre N'EST PAS un brouillon : c'est ce qui
|
||||
rend le trou visible.
|
||||
|
||||
⚠️ Rien n'est déplié tant qu'on n'a pas choisi : les quatre formulaires empilés
|
||||
occupaient l'écran sous la liste des tomes, et l'on ne voyait plus la saga.
|
||||
*@
|
||||
<div class="segments" role="group" aria-label="Comment ajouter un tome">
|
||||
<button type="button" class="segment @(_voie == VoieAjout.Manuelle ? "segment-actif" : null)"
|
||||
@onclick="() => Ouvrir(VoieAjout.Manuelle)">Manuellement</button>
|
||||
|
||||
@*
|
||||
⚠️ La rafale MÈNE À L'ÉCRAN EXISTANT (`/ajout/rafale?serie=…`), elle n'est pas
|
||||
réimplémentée ici : la file, sa persistance et sa reprise sont un mécanisme entier,
|
||||
et une seconde copie divergerait. La série voyage dans l'adresse, et la rafale la
|
||||
retient dans sa file — c'est ce qui fait qu'une reprise rattache au bon endroit.
|
||||
*@
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="segment" href="@($"ajout/rafale?serie={Id}")">En rafale</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="segment" disabled title="@MotifBlocage">En rafale</button>
|
||||
}
|
||||
|
||||
<button type="button" class="segment @(_voie == VoieAjout.Catalogue ? "segment-actif" : null)"
|
||||
@onclick="() => Ouvrir(VoieAjout.Catalogue)">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="segment @(_voie == VoieAjout.Envies ? "segment-actif" : null)"
|
||||
@onclick="OuvrirEnviesAsync">Depuis mes envies</button>
|
||||
</div>
|
||||
|
||||
@if (_voie == VoieAjout.Manuelle)
|
||||
{
|
||||
@*
|
||||
⚠️ Le champ accepte un TITRE ou un ISBN, et fait deux choses différentes selon ce
|
||||
qu'on y met : un titre recense une place (le tome qu'on n'a pas encore), un code
|
||||
désigne un livre — et on le cherche alors d'abord au catalogue, ensuite à la BnF.
|
||||
Sans cela, scanner un code créait un tome intitulé « 9782… ».
|
||||
*@
|
||||
<div class="barre-recherche">
|
||||
<input class="champ-saisie" type="text" placeholder="ISBN ou titre d'un tome…"
|
||||
@ref="_champNouveauTome"
|
||||
@bind="_nouveauTome" @bind:event="oninput" @onkeydown="SurToucheTome"
|
||||
aria-label="ISBN ou titre du tome à ajouter" autocomplete="off" />
|
||||
|
||||
@* Facultatif, et volontairement à côté du titre : on le connaît en tenant le livre. *@
|
||||
<input class="champ-saisie champ-numero" type="text" placeholder="N° (facultatif)"
|
||||
@bind="_nouveauNumero" @bind:event="oninput" @onkeydown="SurToucheTome"
|
||||
aria-label="Numéro du tome à ajouter" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || _lookupEnCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouveauTome))"
|
||||
title="@MotifBlocage"
|
||||
@onclick="AjouterSaisieAsync">
|
||||
@(SaisieEstUnCode ? "Chercher ce code" : "Ajouter ce tome")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_lookupEnCours)
|
||||
{
|
||||
<Patience Message="Recherche au catalogue, puis à la BnF et chez OpenLibrary…" />
|
||||
}
|
||||
|
||||
@* Le code désigne plusieurs livres DÉJÀ possédés : on ne choisit pas à la place de
|
||||
l'utilisateur — deux exemplaires d'un même ISBN sont légitimes. *@
|
||||
@if (_ajoutParCode && _resultats.Count > 0)
|
||||
{
|
||||
<div class="recherche-livre">
|
||||
<p class="message-discret">Ce code est déjà au catalogue. Quel exemplaire rattacher ?</p>
|
||||
|
||||
@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>
|
||||
}
|
||||
|
||||
@* Rien au catalogue : la cascade a répondu, on crée le livre puis on le rattache. *@
|
||||
@if (_ajoutParCode && _lookupCandidats.Count > 0)
|
||||
{
|
||||
<div class="recherche-livre">
|
||||
@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 ce livre à la série
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_voie == VoieAjout.Envies)
|
||||
{
|
||||
<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 (_voie == VoieAjout.Catalogue)
|
||||
{
|
||||
<div class="recherche-livre">
|
||||
<input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
|
||||
@ref="_champRecherche"
|
||||
value="@_recherche" @oninput="ChercherAsync" @onfocus="ProposerAsync"
|
||||
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>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ModeOrdre)
|
||||
{
|
||||
<p class="message-discret">
|
||||
@@ -117,7 +310,27 @@ else
|
||||
|
||||
@if (!ModeEdition && _serie.Elements.Count > 0)
|
||||
{
|
||||
<h2 class="titre-section">Ordre de lecture</h2>
|
||||
<div class="entete-section">
|
||||
<h2 class="titre-section">@(TriParNumero ? "Tomes" : "Ordre de lecture")</h2>
|
||||
|
||||
@*
|
||||
⚠️ Le tri est un CHOIX D'ÉCRAN, non persistant — comme le pliage des séries, et
|
||||
contrairement au rang des envies, qui est un ordre choisi et stocké. Ranger la
|
||||
liste autrement pour la lire ne change rien à l'ordre de lecture de la saga.
|
||||
|
||||
Il n'apparaît qu'à partir de deux tomes ET si au moins un porte un numéro :
|
||||
sinon les deux tris donneraient la même liste.
|
||||
*@
|
||||
@if (TriPossible)
|
||||
{
|
||||
<div class="segments" role="group" aria-label="Trier les tomes">
|
||||
<button type="button" class="segment @(TriParNumero ? null : "segment-actif")"
|
||||
@onclick="() => _triParNumero = false">Ordre de lecture</button>
|
||||
<button type="button" class="segment @(TriParNumero ? "segment-actif" : null)"
|
||||
@onclick="() => _triParNumero = true">Numéro</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@*
|
||||
⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se
|
||||
@@ -126,7 +339,7 @@ else
|
||||
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)))
|
||||
@foreach (var (element, index) in TomesAffiches.Select((e, i) => (e, i)))
|
||||
{
|
||||
<li class="carte-tome @(element.Possede ? null : "tome-manquant")" @key="element.Id">
|
||||
<div class="rang-actions">
|
||||
@@ -139,7 +352,17 @@ else
|
||||
@onclick="() => DeplacerAsync(index, index - 1)">▲</button>
|
||||
}
|
||||
|
||||
<span class="rang-numero" aria-hidden="true">@(index + 1)</span>
|
||||
@*
|
||||
⚠️ La pastille ne porte QUE le numéro imprimé sur le tome, et rien
|
||||
quand il n'y en a pas — en consultation comme en mode ordre. Le rang
|
||||
de lecture ne s'affiche plus nulle part : deux natures de valeur dans
|
||||
la même colonne, « 4 » se lisait indifféremment « quatrième à lire »
|
||||
ou « tome 4 ». L'ordre, lui, reste stocké et se lit dans la liste.
|
||||
*@
|
||||
@if (element.Numero is { } numero && !string.IsNullOrWhiteSpace(numero))
|
||||
{
|
||||
<span class="rang-numero rang-tome" title="Numéro du tome">@numero</span>
|
||||
}
|
||||
|
||||
@if (ModeOrdre)
|
||||
{
|
||||
@@ -221,6 +444,12 @@ else
|
||||
</button>
|
||||
}
|
||||
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => OuvrirNumero(element)">
|
||||
Numéro
|
||||
</button>
|
||||
|
||||
<button type="button" class="bouton bouton-compact bouton-danger"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => RetirerAsync(element)">
|
||||
@@ -229,11 +458,28 @@ else
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_numeroOuvert == element.Id)
|
||||
{
|
||||
<div class="barre-recherche">
|
||||
<input class="champ-saisie" type="text"
|
||||
placeholder="7, Hors-série, 3.5…"
|
||||
@bind="_numeroSaisi" @bind:event="oninput"
|
||||
@onkeydown="@(e => SurToucheNumero(e, element))"
|
||||
aria-label="@($"Numéro du tome « {element.Titre} »")"
|
||||
autocomplete="off" />
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)"
|
||||
@onclick="() => EnregistrerNumeroAsync(element)">Enregistrer</button>
|
||||
<button type="button" class="bouton"
|
||||
@onclick="() => _numeroOuvert = null">Annuler</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"
|
||||
value="@_recherche" @oninput="ChercherAsync" @onfocus="ProposerAsync"
|
||||
aria-label="Chercher un livre à rattacher" />
|
||||
|
||||
@foreach (var resultat in _resultats)
|
||||
@@ -321,102 +567,6 @@ else
|
||||
</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)
|
||||
{
|
||||
@@ -475,9 +625,6 @@ else
|
||||
}
|
||||
|
||||
@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 = [];
|
||||
@@ -500,6 +647,48 @@ else
|
||||
|
||||
private string _nouveauTome = string.Empty;
|
||||
|
||||
private string _nouveauNumero = string.Empty;
|
||||
|
||||
/// <summary>Par où l'on ajoute un tome. Rien n'est déplié tant qu'on n'a pas choisi.</summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ « En rafale » n'y figure pas : c'est un LIEN vers l'écran de rafale, pas une vue de
|
||||
/// celui-ci. La file, sa persistance et sa reprise sont un mécanisme entier, qu'une seconde
|
||||
/// implémentation ferait diverger.
|
||||
/// </remarks>
|
||||
private enum VoieAjout { Aucune, Manuelle, Catalogue, Envies }
|
||||
|
||||
private VoieAjout _voie = VoieAjout.Aucune;
|
||||
|
||||
/// <summary>Le panneau d'ajout est déplié, comme les filtres du catalogue.</summary>
|
||||
private bool _ajoutOuvert;
|
||||
|
||||
/// <summary>La saisie manuelle a été reconnue comme un code, et non comme un titre.</summary>
|
||||
private bool _ajoutParCode;
|
||||
|
||||
private ElementReference _champNouveauTome;
|
||||
private ElementReference _champRecherche;
|
||||
|
||||
/// <summary>Champ à focaliser au prochain rendu, s'il y en a un.</summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Une seule fois par ouverture : reprendre le focus à chaque rendu l'arracherait en
|
||||
/// pleine frappe, ces champs se reliant à chaque caractère.
|
||||
/// </remarks>
|
||||
private VoieAjout? _focusAFaire;
|
||||
|
||||
/// <summary>Tome dont le champ « numéro » est déplié, s'il y en a un.</summary>
|
||||
private int? _numeroOuvert;
|
||||
|
||||
private string _numeroSaisi = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Trier par numéro de tome plutôt que par ordre de lecture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Confort de lecture, <b>non persistant</b> — même règle que le pliage des séries au
|
||||
/// catalogue. L'ordre de lecture, lui, est stocké : c'est une propriété de la saga.
|
||||
/// </remarks>
|
||||
private bool _triParNumero;
|
||||
|
||||
private int? _rattachement;
|
||||
private string _recherche = string.Empty;
|
||||
private IReadOnlyList<LivreDto> _resultats = [];
|
||||
@@ -511,7 +700,6 @@ else
|
||||
/// <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 = [];
|
||||
|
||||
@@ -531,6 +719,144 @@ else
|
||||
/// </remarks>
|
||||
private bool ModeOrdre => Suffixe("/ordre");
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ Le tri par numéro est ignoré en mode ordre : on y déplace des rangs, et réordonner
|
||||
/// une liste triée autrement ferait sauter les tomes sous les flèches.
|
||||
/// </summary>
|
||||
private bool TriParNumero => _triParNumero && !ModeOrdre;
|
||||
|
||||
/// <summary>
|
||||
/// Le choix n'apparaît qu'à partir de deux tomes, et si au moins un porte un numéro :
|
||||
/// sinon les deux tris rendraient exactement la même liste.
|
||||
/// </summary>
|
||||
private bool TriPossible =>
|
||||
!ModeOrdre
|
||||
&& _serie is { } serie
|
||||
&& serie.Elements.Count > 1
|
||||
&& serie.Elements.Any(e => !string.IsNullOrWhiteSpace(e.Numero));
|
||||
|
||||
/// <summary>Les tomes dans l'ordre demandé — celui de lecture par défaut.</summary>
|
||||
private IReadOnlyList<ElementSerieDto> TomesAffiches
|
||||
{
|
||||
get
|
||||
{
|
||||
var elements = _serie?.Elements ?? [];
|
||||
|
||||
// Le tri du serveur (Position) est déjà le bon : on ne le refait pas pour rien.
|
||||
return TriParNumero
|
||||
? [.. elements.OrderBy(e => e.Numero, TriNaturel.Comparateur).ThenBy(e => e.Id)]
|
||||
: elements;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre ou ferme le panneau d'ajout.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Le refermer referme <b>tout</b> : la voie choisie, ce qui était tapé, et les résultats
|
||||
/// d'une recherche de code. Un panneau qui se rouvre sur l'état d'avant ferait croire à une
|
||||
/// saisie en cours alors qu'on l'avait quittée.
|
||||
/// </remarks>
|
||||
private void BasculerAjout()
|
||||
{
|
||||
_ajoutOuvert = !_ajoutOuvert;
|
||||
|
||||
if (!_ajoutOuvert)
|
||||
{
|
||||
Ouvrir(VoieAjout.Aucune);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Vrai quand ce qui est tapé dans la saisie manuelle est un code et non un titre.</summary>
|
||||
private bool SaisieEstUnCode => RechercheIsbn.Formes(_nouveauTome) is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Déplie une voie d'ajout, ou la referme si c'est celle qui était ouverte.
|
||||
/// </summary>
|
||||
private void Ouvrir(VoieAjout voie)
|
||||
{
|
||||
_erreur = null;
|
||||
_message = null;
|
||||
|
||||
if (_voie == voie)
|
||||
{
|
||||
_voie = VoieAjout.Aucune;
|
||||
FermerRecherche();
|
||||
return;
|
||||
}
|
||||
|
||||
_voie = voie;
|
||||
_focusAFaire = voie;
|
||||
_nouveauTome = string.Empty;
|
||||
_nouveauNumero = string.Empty;
|
||||
_ajoutParCode = false;
|
||||
_lookupCandidats = [];
|
||||
|
||||
// Les deux recherches de catalogue partagent le champ et les résultats : ouvrir celle-ci
|
||||
// ferme celle d'une place précise, sinon les deux listes se marcheraient dessus.
|
||||
FermerRecherche();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_focusAFaire is not { } voie)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_focusAFaire = null;
|
||||
|
||||
switch (voie)
|
||||
{
|
||||
case VoieAjout.Manuelle:
|
||||
await _champNouveauTome.FocusAsync();
|
||||
break;
|
||||
|
||||
case VoieAjout.Catalogue:
|
||||
await _champRecherche.FocusAsync();
|
||||
await ProposerAsync();
|
||||
StateHasChanged();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OuvrirNumero(ElementSerieDto element)
|
||||
{
|
||||
_numeroOuvert = element.Id;
|
||||
_numeroSaisi = element.Numero ?? string.Empty;
|
||||
}
|
||||
|
||||
private async Task SurToucheNumero(KeyboardEventArgs e, ElementSerieDto element)
|
||||
{
|
||||
if (e.Key == "Enter" && Reseau.EnLigne)
|
||||
{
|
||||
await EnregistrerNumeroAsync(element);
|
||||
}
|
||||
else if (e.Key == "Escape")
|
||||
{
|
||||
_numeroOuvert = null;
|
||||
}
|
||||
}
|
||||
|
||||
private Task EnregistrerNumeroAsync(ElementSerieDto element) =>
|
||||
EcrireAsync(async () =>
|
||||
{
|
||||
// Le titre est renvoyé tel quel : ce point d'entrée corrige le numéro, pas le tome.
|
||||
// ⚠️ `element.Titre` peut être celui du LIVRE rattaché ; c'est bien lui qu'on veut
|
||||
// conserver comme filet, la place n'en portant pas d'autre une fois détachée.
|
||||
var resultat = await Api.ModifierElementSerieAsync(
|
||||
element.Id,
|
||||
new AjoutElementSerie { Titre = element.Titre, Numero = _numeroSaisi });
|
||||
|
||||
if (resultat.EstOk)
|
||||
{
|
||||
_numeroOuvert = null;
|
||||
_numeroSaisi = string.Empty;
|
||||
}
|
||||
|
||||
return resultat;
|
||||
});
|
||||
|
||||
/// <summary>Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder.</summary>
|
||||
private bool ModeConsultation => !ModeEdition && !ModeOrdre;
|
||||
|
||||
@@ -632,19 +958,91 @@ else
|
||||
{
|
||||
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveauTome) && Reseau.EnLigne)
|
||||
{
|
||||
await AjouterTomeAsync(null);
|
||||
await AjouterSaisieAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traite la saisie manuelle : un titre recense une place, un code désigne un livre.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Le catalogue d'abord, la BnF ensuite</b> (choisi avec l'utilisateur le 2026-09-08).
|
||||
/// Un tome d'une saga est souvent déjà dans la bibliothèque — c'est même le cas ordinaire
|
||||
/// quand on range une série après coup : passer directement à la cascade distante recréerait
|
||||
/// un livre qu'on possède, et l'on n'aurait plus qu'un avertissement de doublon à trancher.
|
||||
/// <para>
|
||||
/// Un seul livre au catalogue est rattaché <b>sans rien demander</b> ; plusieurs sont
|
||||
/// proposés, parce que posséder deux exemplaires d'un même ISBN est légitime et qu'on ne
|
||||
/// choisit pas à la place de quelqu'un.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task AjouterSaisieAsync()
|
||||
{
|
||||
if (!SaisieEstUnCode)
|
||||
{
|
||||
_ajoutParCode = false;
|
||||
await AjouterTomeAsync(null);
|
||||
return;
|
||||
}
|
||||
|
||||
_ajoutParCode = true;
|
||||
_erreur = null;
|
||||
_message = null;
|
||||
_resultats = [];
|
||||
_lookupCandidats = [];
|
||||
_lookupEnCours = true;
|
||||
|
||||
var possedes = await Api.ListerAsync(new CritereLivres { Recherche = _nouveauTome });
|
||||
|
||||
if (possedes.Count == 1)
|
||||
{
|
||||
_lookupEnCours = false;
|
||||
await AjouterTomeAsync(possedes[0].Id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (possedes.Count > 1)
|
||||
{
|
||||
_lookupEnCours = false;
|
||||
_resultats = possedes;
|
||||
return;
|
||||
}
|
||||
|
||||
// Rien au catalogue : la cascade BnF → OpenLibrary. ⚠️ Aucune place n'est visée — le
|
||||
// livre choisi en créera une, contrairement au lookup d'un tome manquant déjà recensé.
|
||||
_lookupEnCours = false;
|
||||
_lookupElement = null;
|
||||
_lookupIsbn = _nouveauTome;
|
||||
await ChercherLookupAsync();
|
||||
}
|
||||
|
||||
private async Task AjouterTomeAsync(int? livreId)
|
||||
{
|
||||
// ⚠️ Le numéro saisi est transmis DANS LES DEUX CAS : on le connaît en tenant le livre,
|
||||
// et le perdre parce qu'on a rattaché un exemplaire du catalogue n'aurait aucun sens.
|
||||
await EcrireAsync(() => Api.AjouterElementSerieAsync(
|
||||
Id, new AjoutElementSerie { LivreId = livreId, Titre = livreId is null ? _nouveauTome : null }));
|
||||
Id,
|
||||
new AjoutElementSerie
|
||||
{
|
||||
LivreId = livreId,
|
||||
Titre = livreId is null ? _nouveauTome : null,
|
||||
Numero = _nouveauNumero,
|
||||
}));
|
||||
|
||||
if (_erreur is null)
|
||||
{
|
||||
_nouveauTome = string.Empty;
|
||||
_nouveauNumero = string.Empty;
|
||||
_ajoutParCode = false;
|
||||
_lookupCandidats = [];
|
||||
FermerRecherche();
|
||||
|
||||
// On ajoute rarement un seul tome depuis le catalogue : la voie reste ouverte, et
|
||||
// reprend sa proposition de départ plutôt que de rester sur une liste vide.
|
||||
if (_voie == VoieAjout.Catalogue)
|
||||
{
|
||||
await ProposerAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,9 +1145,16 @@ else
|
||||
/// </remarks>
|
||||
private async Task OuvrirEnviesAsync()
|
||||
{
|
||||
if (_voie == VoieAjout.Envies)
|
||||
{
|
||||
_voie = VoieAjout.Aucune;
|
||||
return;
|
||||
}
|
||||
|
||||
_erreur = null;
|
||||
_message = null;
|
||||
_enviesOuvertes = true;
|
||||
_voie = VoieAjout.Envies;
|
||||
_rattachement = null;
|
||||
_enviesChargement = true;
|
||||
|
||||
var toutes = await Api.ListerSouhaitsAsync();
|
||||
@@ -786,7 +1191,9 @@ else
|
||||
|
||||
if (_erreur is null)
|
||||
{
|
||||
_enviesOuvertes = false;
|
||||
// La voie se referme : le message dit ce qui s'est passé, et la liste d'envies
|
||||
// rouverte au même endroit laisserait croire qu'il reste quelque chose à cliquer.
|
||||
_voie = VoieAjout.Aucune;
|
||||
_envies = [];
|
||||
_message = $"« {envie.Titre} » est recensé comme tome manquant. "
|
||||
+ "Il reste dans votre liste d'envies.";
|
||||
@@ -861,13 +1268,13 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ⚠️ Deux usages, et la différence tient à <c>_lookupElement</c> : rattaché à une place
|
||||
/// <b>déjà recensée</b> (bouton « Ajouter par ISBN » d'un tome manquant), ou — s'il n'y en a
|
||||
/// pas — <b>créant la place</b>, ce qui est le cas de la saisie manuelle par code.
|
||||
/// </remarks>
|
||||
private async Task AjouterCommeLivreAsync(CandidatLivre candidat)
|
||||
{
|
||||
if (_lookupElement is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
var saisie = new EnregistrementLivre
|
||||
@@ -895,10 +1302,21 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
await EcrireAsync(() => Api.RattacherLivreSerieAsync(_lookupElement.Id, resultat.Livre!.Id));
|
||||
if (_lookupElement is { } place)
|
||||
{
|
||||
await EcrireAsync(() => Api.RattacherLivreSerieAsync(place.Id, resultat.Livre!.Id));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Saisie manuelle par code : la place n'existe pas encore, ce livre la crée.
|
||||
await AjouterTomeAsync(resultat.Livre!.Id);
|
||||
}
|
||||
|
||||
if (_erreur is null)
|
||||
{
|
||||
_lookupElement = null;
|
||||
_lookupCandidats = [];
|
||||
_ajoutParCode = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,6 +1369,9 @@ else
|
||||
|
||||
private void OuvrirRattachement(int cible)
|
||||
{
|
||||
// Une place précise et « depuis le catalogue » partagent champ et résultats : l'une
|
||||
// ferme l'autre.
|
||||
_voie = VoieAjout.Aucune;
|
||||
_rattachement = cible;
|
||||
_recherche = string.Empty;
|
||||
_resultats = [];
|
||||
@@ -963,13 +1384,32 @@ else
|
||||
_resultats = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Montre déjà des livres alors que rien n'est tapé.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ C'est ce qui <b>dit</b> qu'il y a une recherche : un champ vide sous une liste vide ne
|
||||
/// se distingue pas d'un formulaire qui attend un titre exact. On propose donc les premiers
|
||||
/// livres, que la frappe restreint ensuite.
|
||||
/// </remarks>
|
||||
private async Task ProposerAsync()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_recherche))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_resultats = (await Api.ListerAsync(new CritereLivres())).Take(8).ToList();
|
||||
}
|
||||
|
||||
private async Task ChercherAsync(ChangeEventArgs e)
|
||||
{
|
||||
_recherche = e.Value?.ToString() ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_recherche))
|
||||
{
|
||||
_resultats = [];
|
||||
// Effacer ce qu'on a tapé ramène la proposition de départ, pas le vide.
|
||||
await ProposerAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using MaBibli.Shared.Catalogue;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace MaBibli.Client.Services;
|
||||
@@ -145,6 +146,57 @@ public sealed class CacheHorsLigne(IJSRuntime js) : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// File de catalogage en rafale (lot AB)
|
||||
// -----------------------------------------------------------------------
|
||||
// ⚠️ Magasin SÉPARÉ des instantanés, et volontairement : ceux-ci portent ce que le serveur
|
||||
// a rendu, effaçable sans rien perdre. Celle-ci porte ce que l'utilisateur a scanné, et qui
|
||||
// n'existe nulle part ailleurs.
|
||||
|
||||
/// <summary>Range la rafale en cours. Appelée à chaque scan : perdre la file serait cuisant.</summary>
|
||||
public async Task EcrireRafaleAsync(Rafale rafale)
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await ModuleAsync();
|
||||
await module.InvokeVoidAsync("rafaleEcrire", JsonSerializer.Serialize(rafale, Json));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ⚠️ On ne marque PAS le cache indisponible ici : ne pas pouvoir ranger la file
|
||||
// n'empêche pas de la traiter dans la session en cours. C'est la reprise ultérieure
|
||||
// qui est perdue, et l'écran le dit.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Relit la rafale interrompue, s'il y en a une.</summary>
|
||||
public async Task<Rafale?> LireRafaleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await ModuleAsync();
|
||||
var brut = await module.InvokeAsync<string?>("rafaleLire");
|
||||
return brut is null ? null : JsonSerializer.Deserialize<Rafale>(brut, Json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EffacerRafaleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await ModuleAsync();
|
||||
await module.InvokeVoidAsync("rafaleEffacer");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Sans conséquence : une file qu'on n'a pas pu effacer sera écrasée à la suivante.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>État déclaré par le navigateur. Voir la nuance dans <c>cache-hors-ligne.js</c>.</summary>
|
||||
public async Task<bool> EnLigneAsync()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MaBibli.Shared.Catalogue;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Isbn;
|
||||
using MaBibli.Shared.Textes;
|
||||
|
||||
namespace MaBibli.Client.Services;
|
||||
@@ -61,6 +62,23 @@ public static class FiltreLivresLocal
|
||||
livres = livres.Where(l => (l.PreteA is not null) == prete);
|
||||
}
|
||||
|
||||
if (criteres.SansCouverture is { } sansCouverture)
|
||||
{
|
||||
// Même convention que côté serveur : une URL vide vaut une URL absente.
|
||||
livres = livres.Where(l => string.IsNullOrEmpty(l.CoverUrl) == sansCouverture);
|
||||
}
|
||||
|
||||
// Même exclusion que côté serveur : un code désigne un objet, des mots désignent une
|
||||
// œuvre. Les deux branches ne se mélangent pas.
|
||||
if (RechercheIsbn.Formes(criteres.Recherche) is { } formes)
|
||||
{
|
||||
livres = livres.Where(l =>
|
||||
l.Isbn is not null
|
||||
&& formes.Any(forme => l.Isbn.Contains(forme, StringComparison.Ordinal)));
|
||||
|
||||
return Ordonner(livres);
|
||||
}
|
||||
|
||||
var terme = NormalisationTexte.Normaliser(criteres.Recherche);
|
||||
if (terme.Length > 0)
|
||||
{
|
||||
@@ -73,12 +91,20 @@ public static class FiltreLivresLocal
|
||||
|| RapprochementAuteurs.Cle(a.Nom).Contains(cle, StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
// Même ordre que le serveur : titre normalisé, puis identifiant pour rendre le tri
|
||||
// déterministe. La comparaison est ordinale parce que c'est ce que fait SQLite sur une
|
||||
// colonne sans collation — un tri « intelligent » ici ferait diverger les deux vues.
|
||||
return livres
|
||||
return Ordonner(livres);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Même ordre que le serveur : titre normalisé, puis identifiant pour rendre le tri
|
||||
/// déterministe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// La comparaison est <b>ordinale</b> parce que c'est ce que fait SQLite sur une colonne sans
|
||||
/// collation — un tri « intelligent » ici ferait diverger les deux vues.
|
||||
/// </remarks>
|
||||
private static IReadOnlyList<LivreDto> Ordonner(IEnumerable<LivreDto> livres) =>
|
||||
livres
|
||||
.OrderBy(l => NormalisationTexte.Normaliser(l.Titre), StringComparer.Ordinal)
|
||||
.ThenBy(l => l.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ public static class RemonteeRoutes
|
||||
// catalogue est la destination de menu de ce qu'on y saisit.
|
||||
("/ajout/manuel", Racine),
|
||||
("/ajout/isbn", Racine),
|
||||
|
||||
// ⚠️ La rafale remonte à « /ajout », et non au catalogue : on y arrive depuis l'écran
|
||||
// d'ajout, et l'on veut souvent y revenir pour saisir autrement ce qui a résisté.
|
||||
("/ajout/rafale", "/ajout"),
|
||||
("/ajout", Racine),
|
||||
|
||||
// Revues. ⚠️ « /revues/ajout » est écrit AVANT « /revues/{id} » : « ajout » n'est pas un
|
||||
|
||||
@@ -103,6 +103,11 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
|
||||
parametres.Add($"prete={(prete ? "true" : "false")}");
|
||||
}
|
||||
|
||||
if (criteres.SansCouverture is { } sansCouverture)
|
||||
{
|
||||
parametres.Add($"sansCouverture={(sansCouverture ? "true" : "false")}");
|
||||
}
|
||||
|
||||
var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty);
|
||||
|
||||
var (ok, liste) = await EssayerAsync(() => http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct));
|
||||
@@ -387,6 +392,16 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
|
||||
() => http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Efface un prêt saisi par erreur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ À ne pas confondre avec <see cref="RendreAsync"/>, qui <b>conserve</b> la ligne en lui
|
||||
/// posant une date de retour. Celle-ci défait une saisie fautive, et rien d'autre.
|
||||
/// </remarks>
|
||||
public Task<string?> SupprimerPretAsync(int pretId, CancellationToken ct = default) =>
|
||||
SupprimerAsync($"api/prets/{pretId}", ct);
|
||||
|
||||
/// <summary>Clôt un prêt. Le prêt n'est pas supprimé : il devient l'historique du livre.</summary>
|
||||
public async Task<ResultatApi<PretDto>> RendreAsync(
|
||||
int pretId, DateTime? dateRetour = null, CancellationToken ct = default) =>
|
||||
@@ -1038,6 +1053,17 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
|
||||
() => http.PostAsJsonAsync($"api/series/{serieId}/elements", saisie, Json, ct), ct);
|
||||
|
||||
/// <summary>Rattache un livre possédé à une place, ou l'en détache avec <c>null</c>.</summary>
|
||||
/// <summary>
|
||||
/// Corrige le titre et le numéro d'une place, sans toucher au livre rattaché.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Distinct de <see cref="RattacherLivreSerieAsync"/> : un point d'entrée, un geste.
|
||||
/// </remarks>
|
||||
public async Task<ResultatApi<SerieDto>> ModifierElementSerieAsync(
|
||||
int elementId, AjoutElementSerie saisie, CancellationToken ct = default) =>
|
||||
await EcrireAsync<SerieDto>(
|
||||
() => http.PutAsJsonAsync($"api/series/elements/{elementId}", saisie, Json, ct), ct);
|
||||
|
||||
public async Task<ResultatApi<SerieDto>> RattacherLivreSerieAsync(
|
||||
int elementId, int? livreId, CancellationToken ct = default) =>
|
||||
await EcrireAsync<SerieDto>(
|
||||
|
||||
@@ -251,7 +251,15 @@ body {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ⚠️ Écrit pour des <button> À L'ORIGINE, et un segment peut être un LIEN (« En rafale », qui
|
||||
mène à l'écran de rafale) : sans les trois lignes de mise en forme ci-dessous, il gardait sa
|
||||
couleur de lien, son soulignement et son texte collé en haut. Même famille de défaut que les
|
||||
NavLink du lot H — un style pensé pour une balise, appliqué à une autre. */
|
||||
.segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
flex: 1 1 auto;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
@@ -263,6 +271,11 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.segment:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.segment-actif {
|
||||
background: var(--mb-accent);
|
||||
border-color: var(--mb-accent);
|
||||
@@ -447,7 +460,7 @@ body {
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
padding: 0.35rem 0;
|
||||
color: var(--mb-texte);
|
||||
color: var(--mb-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -634,12 +647,50 @@ body {
|
||||
border-bottom: 1px solid var(--mb-bord);
|
||||
}
|
||||
|
||||
/* Emprunteur, dates et croix sur UNE ligne : la croix appartient à ce prêt-là. */
|
||||
.historique-ligne {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.historique-qui {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.historique-dates {
|
||||
display: block;
|
||||
color: var(--mb-texte-doux);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Effacer une ligne de prêt saisie par erreur. Discrète au repos — c'est une correction, pas
|
||||
un geste courant — mais avec une cible tactile entière : 2,75 rem, comme la croix du calque
|
||||
des couvertures. */
|
||||
.bouton-croix {
|
||||
min-width: 2.25rem;
|
||||
min-height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--mb-texte-doux);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bouton-croix:hover:not(:disabled),
|
||||
.bouton-croix:focus-visible {
|
||||
background: var(--mb-fond-doux);
|
||||
color: var(--mb-danger);
|
||||
}
|
||||
|
||||
.bouton-croix:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Bouton d'action au sein d'une carte : il ne doit pas peser autant qu'un bouton de page,
|
||||
tout en gardant une cible tactile utilisable. */
|
||||
.bouton-compact {
|
||||
@@ -700,6 +751,11 @@ body {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Image cassée, ou absente : le clic ne promet plus un agrandissement — il ouvre l'adresse. */
|
||||
.couverture-declencheur.declencheur-correction {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calque-couverture {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1175,12 +1231,36 @@ body {
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.entete-page .titre-page {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Panneau « Ajouter un tome » d'une série : déplié sur place sous l'en-tête, comme les filtres
|
||||
du catalogue. Encadré, pour qu'on voie où il commence et où il finit. */
|
||||
.panneau-ajout {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--mb-bord);
|
||||
border-radius: 8px;
|
||||
background: var(--mb-fond-doux);
|
||||
}
|
||||
|
||||
.panneau-ajout .titre-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Les deux boutons de l'en-tête du catalogue — régler la liste, et y ajouter. Ils
|
||||
s'enroulent plutôt que de pousser le titre hors de l'écran à 320 px. */
|
||||
.entete-page-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* --- Liste d'envies : ordre choisi --- */
|
||||
|
||||
/* Colonne de déplacement à gauche de chaque envie. Les flèches sont larges à dessein :
|
||||
@@ -1205,6 +1285,18 @@ body {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Le NUMÉRO du tome, à distinguer du rang de lecture — deux natures de valeur qui
|
||||
partagent la même colonne. Sans ce repère, « 4 » se lit indifféremment « quatrième
|
||||
à lire » ou « tome 4 », et les deux diffèrent dès qu'une préquelle est en jeu. */
|
||||
.rang-tome {
|
||||
color: var(--mb-accent);
|
||||
font-weight: 600;
|
||||
background: var(--mb-fond-doux);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* La carte saisie s'efface pendant le glissé : sans ce repère, on ne sait plus laquelle
|
||||
on déplace une fois le curseur loin de son point de départ. */
|
||||
.carte-glissee {
|
||||
@@ -1737,3 +1829,108 @@ body {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Vocabulaire déjà employé, proposé sous un champ de liste (les thèmes). Même aspect que les
|
||||
segments : ce sont des valeurs qu'on choisit, pas des actions. */
|
||||
.suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Catalogage en rafale (lot AB)
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
/* La douchette tape « Entrée » après chaque lecture : dans un textarea, cela fait un saut de
|
||||
ligne, et la rafale se collecte donc toute seule. Monospace pour que des chiffres alignés
|
||||
se relisent, et `resize: vertical` parce qu'on ne sait pas d'avance combien de livres. */
|
||||
.zone-codes {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.case-a-cocher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Barre d'avancement. ⚠️ `role="progressbar"` porte l'information pour les lecteurs d'écran ;
|
||||
ceci n'en est que la peinture, d'où l'absence de texte à l'intérieur. */
|
||||
.avancement {
|
||||
height: 0.5rem;
|
||||
background: var(--mb-fond-doux);
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
margin: 0.75rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.avancement-barre {
|
||||
height: 100%;
|
||||
background: var(--mb-accent);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.avancement-barre { transition: none; }
|
||||
}
|
||||
|
||||
.bilan {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.liste-residus {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.carte-residu {
|
||||
border: 1px solid var(--mb-bord);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* ⚠️ `min-width: 0` sur l'enfant élastique — la leçon du lot M, qui se repose à chaque étage
|
||||
d'une cascade de flex : sans lui, un ISBN à tirets pousse l'étiquette hors d'un écran de
|
||||
320 px, et c'est toute la page qui défile. */
|
||||
.residu-tete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.residu-tete .code-isbn {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.encart-reprise {
|
||||
border: 1px solid var(--mb-bord);
|
||||
border-left: 3px solid var(--mb-accent);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,12 @@ const MAGASIN = 'instantanes';
|
||||
// valeur = le Blob de l'image. Sert uniquement à la consultation hors-ligne — en ligne,
|
||||
// l'<img> normale reste la voie rapide, ce cache se remplit en tâche de fond à côté.
|
||||
const MAGASIN_COUVERTURES = 'couvertures';
|
||||
const VERSION = 2;
|
||||
// File de catalogage en rafale (lot AB). ⚠️ MAGASIN SÉPARÉ, et ce n'est pas un détail de
|
||||
// rangement : `instantanes` porte ce que le SERVEUR a rendu, donc effaçable sans rien perdre,
|
||||
// alors que cette file porte ce que l'UTILISATEUR a scanné et qui n'existe nulle part ailleurs.
|
||||
// Les mêler ferait disparaître cinquante scans le jour où l'on ajouterait un « vider le cache ».
|
||||
const MAGASIN_RAFALE = 'rafale';
|
||||
const VERSION = 3;
|
||||
|
||||
let promesseBase = null;
|
||||
|
||||
@@ -34,6 +39,7 @@ function ouvrir() {
|
||||
const base = requete.result;
|
||||
if (!base.objectStoreNames.contains(MAGASIN)) base.createObjectStore(MAGASIN);
|
||||
if (!base.objectStoreNames.contains(MAGASIN_COUVERTURES)) base.createObjectStore(MAGASIN_COUVERTURES);
|
||||
if (!base.objectStoreNames.contains(MAGASIN_RAFALE)) base.createObjectStore(MAGASIN_RAFALE);
|
||||
};
|
||||
requete.onsuccess = () => resoudre(requete.result);
|
||||
requete.onerror = () => rejeter(requete.error);
|
||||
@@ -175,3 +181,31 @@ export function couvertureLiberer(url) {
|
||||
// Rien à faire : libérer est un agrément, jamais une condition de bon fonctionnement.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File de catalogue en rafale (lot AB)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Une seule entrée, sous une clé fixe : il n'y a jamais qu'une rafale en cours. La valeur est
|
||||
// la chaîne JSON produite par C#, comme pour les instantanés — une seule forme fait autorité.
|
||||
|
||||
const CLE_RAFALE = 'en-cours';
|
||||
|
||||
export async function rafaleEcrire(json) {
|
||||
const base = await ouvrir();
|
||||
const tx = base.transaction(MAGASIN_RAFALE, 'readwrite');
|
||||
const requete = tx.objectStore(MAGASIN_RAFALE).put(json, CLE_RAFALE);
|
||||
await attendre(requete, tx);
|
||||
}
|
||||
|
||||
export async function rafaleLire() {
|
||||
const base = await ouvrir();
|
||||
const tx = base.transaction(MAGASIN_RAFALE, 'readonly');
|
||||
const valeur = await attendre(tx.objectStore(MAGASIN_RAFALE).get(CLE_RAFALE), tx);
|
||||
return valeur ?? null;
|
||||
}
|
||||
|
||||
export async function rafaleEffacer() {
|
||||
const base = await ouvrir();
|
||||
const tx = base.transaction(MAGASIN_RAFALE, 'readwrite');
|
||||
await attendre(tx.objectStore(MAGASIN_RAFALE).delete(CLE_RAFALE), tx);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user