- 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:
mathieu
2026-09-09 00:05:26 +02:00
parent bb071c3db9
commit 42dec0dbd1
43 changed files with 4154 additions and 189 deletions
+52
View File
@@ -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()
{
+31 -5
View File
@@ -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>(