Traiter le lot A d'IDEES.md : CSS onglets, pastilles, identité visuelle, couverture cassée, cache couvertures
- A1 : forcer la revalidation HTTP (Cache-Control: no-cache) sur les fichiers statiques, pour empêcher un navigateur de garder indéfiniment un CSS/JS périmé quand le service worker ne s'active pas. - A2 : le filtre du catalogue (statut, type de document) reprend les mêmes pastilles colorées que les cartes (Libelles.ClasseStatut / nouvelle Libelles.ClasseTypeDocument). - A3 : remplacement du logo par défaut de Blazor par un pictogramme de livre ouvert (favicon, icônes PWA, logo du bandeau, logo mabibli_ynh). - A4 : une couverture cassée désactive aussi son bouton d'agrandissement. - A5 : cache IndexedDB des couvertures pour la consultation hors-ligne, sans ralentir l'affichage en ligne (mise en cache en tâche de fond). L'allègement (redimensionnement, WebP) reste hors scope, à mesurer avant de s'y engager. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@@ -1684,6 +1684,88 @@ extérieur ou par Échap. Le calque est focalisé à l'ouverture (`FocusAsync`)
|
|||||||
**Le substitut à initiale n'est jamais cliquable** : le bouton déclencheur n'est rendu que
|
**Le substitut à initiale n'est jamais cliquable** : le bouton déclencheur n'est rendu que
|
||||||
lorsqu'une URL de couverture existe. Sans couverture, il n'y a rien à agrandir.
|
lorsqu'une URL de couverture existe. Sans couverture, il n'y a rien à agrandir.
|
||||||
|
|
||||||
|
## Habillage — retours d'usage du 2026-08-19 traités (2026-08-20)
|
||||||
|
|
||||||
|
Lot A d'IDEES.md (4ᵉ série). Quatre items traités ; le cinquième (allègement des couvertures,
|
||||||
|
WebP) reste volontairement hors scope, voir plus bas.
|
||||||
|
|
||||||
|
### A1 : ce n'était pas un bug de style, mais l'absence de revalidation HTTP
|
||||||
|
|
||||||
|
Les six onglets s'affichaient parfois en liens bruts, sans aucune règle de
|
||||||
|
`MainLayout.razor.css`. Cause retenue : `UseStaticFiles()` ne posait **aucun** en-tête
|
||||||
|
`Cache-Control` — un navigateur peut donc garder `MaBibli.Client.styles.css` en cache
|
||||||
|
heuristique, **indépendamment du service worker**. Le service worker revalide bien via
|
||||||
|
`cache: 'no-cache'`, mais seulement quand il parvient à s'activer, ce qui n'est pas garanti
|
||||||
|
(voir « Le service worker ne s'enregistre pas… »).
|
||||||
|
|
||||||
|
**Correction** : `Program.cs` pose désormais `Cache-Control: no-cache, must-revalidate` sur tous
|
||||||
|
les fichiers statiques (`StaticFileOptions.OnPrepareResponse`). Les empreintes WASM étant
|
||||||
|
désactivées (noms de fichiers stables, voir plus haut), c'est cette revalidation forcée qui
|
||||||
|
empêche un navigateur de servir indéfiniment une version périmée — le service worker reste une
|
||||||
|
seconde ligne de défense, pas la seule.
|
||||||
|
|
||||||
|
⚠️ `no-cache` ne veut pas dire « ne jamais mettre en cache » : ETag et Last-Modified, déjà posés
|
||||||
|
par défaut par `StaticFileMiddleware`, permettent une revalidation conditionnelle (304) sans
|
||||||
|
retélécharger un fichier inchangé.
|
||||||
|
|
||||||
|
### A2 : le filtre reprend les pastilles de couleur des cartes
|
||||||
|
|
||||||
|
Le filtre du catalogue (statut de lecture, type de document) affichait des segments neutres,
|
||||||
|
alors que `ChoixStatut` (statut personnel sur la fiche) colorait déjà ses segments avec
|
||||||
|
`Libelles.ClasseStatut`. Les segments et l'étiquette de la carte reprennent maintenant la même
|
||||||
|
classe — le filtre statut réutilise `ClasseStatut`, et une nouvelle méthode
|
||||||
|
`Libelles.ClasseTypeDocument` (bleu pour Roman, magenta pour BD, aucune couleur pour
|
||||||
|
« Non précisé », qui n'est pas une valeur choisie) est appliquée aux deux endroits : le segment
|
||||||
|
du filtre et l'étiquette `etiquette-type` de la carte. Le formulaire de saisie reprend la même
|
||||||
|
classe, pour ne pas diverger une troisième fois.
|
||||||
|
|
||||||
|
### A3 : une identité visuelle, encore le logo par défaut de Blazor
|
||||||
|
|
||||||
|
`favicon.png` / `icon-192.png` / `icon-512.png` étaient encore le logo « @ » violet du template
|
||||||
|
`dotnet new blazorwasm`. Remplacés par un pictogramme de livre ouvert (deux pages en V, tranche
|
||||||
|
centrale blanche), dans le bleu d'accent existant (`#1b3a5c`) — généré par script (Pillow), pas
|
||||||
|
par un outil de génération d'images IA, pour rester un simple dessin géométrique. Un petit
|
||||||
|
`logo-bandeau.svg` (blanc, net à toute résolution) accompagne « MaBibli » dans le bandeau.
|
||||||
|
`mabibli_ynh/logo.png` (256×256, fond transparent — convention du catalogue d'applications
|
||||||
|
YunoHost) reprend le même dessin.
|
||||||
|
|
||||||
|
### A4 : une couverture cassée ne doit plus rien pouvoir agrandir
|
||||||
|
|
||||||
|
Le `onerror` masquait déjà l'image (`image-cassee`), mais le bouton `.couverture-declencheur`
|
||||||
|
qui l'enveloppe restait cliquable : cliquer une vignette cassée ouvrait quand même le calque
|
||||||
|
d'agrandissement, avec la même image cassée dedans. Le `onerror` désactive maintenant aussi ce
|
||||||
|
bouton (`this.closest('.couverture-declencheur').disabled = true`), et `:disabled` porte un
|
||||||
|
curseur neutre plutôt que `zoom-in`.
|
||||||
|
|
||||||
|
### A5 : les couvertures se mettent en cache pour la consultation hors-ligne
|
||||||
|
|
||||||
|
Nouveau magasin IndexedDB `couvertures` (clé = URL OpenLibrary, valeur = `Blob`), à côté du
|
||||||
|
magasin `instantanes` existant — `cache-hors-ligne.js` passe en version 2.
|
||||||
|
|
||||||
|
⚠️ **En ligne, l'affichage ne change pas** : `<img src>` pointe directement sur l'URL réseau,
|
||||||
|
aussi vite qu'avant. La mise en cache (`Cache.MettreEnCacheCouvertureAsync`) est un
|
||||||
|
fire-and-forget déclenché en parallèle, jamais attendu — elle ne doit rien à la rapidité
|
||||||
|
d'affichage. Le fetch qu'elle déclenche profite en général du cache HTTP du navigateur (même URL
|
||||||
|
que l'`<img>` déjà chargée), donc pas de second téléchargement réel dans le cas courant.
|
||||||
|
|
||||||
|
**Hors ligne**, `Couverture.razor` tente d'abord `Cache.LireCouvertureCacheeAsync` (URL `blob:`
|
||||||
|
via `URL.createObjectURL`) et ne retombe sur l'URL réseau — qui échouera simplement, comme avant
|
||||||
|
cette fonctionnalité — que si rien n'a été mis en cache.
|
||||||
|
|
||||||
|
⚠️ **Le jeton de résolution compare `(Url, EnLigne)`, pas seulement `Url`** : passer en ligne →
|
||||||
|
hors ligne sans que l'URL change (le cas le plus courant, puisqu'on ne change pas de livre en
|
||||||
|
perdant le réseau) est précisément le cas qui aurait été raté avec une mémoïsation sur la seule
|
||||||
|
URL — la couverture serait restée invisible hors-ligne malgré un cache rempli.
|
||||||
|
|
||||||
|
**Vérifié** : OpenLibrary envoie `Access-Control-Allow-Origin: *` (mesuré via `curl -I`), donc un
|
||||||
|
`fetch()` cross-origin ordinaire suffit pour lire les octets — pas besoin d'un contournement en
|
||||||
|
réponse opaque (`mode: 'no-cors'`).
|
||||||
|
|
||||||
|
**Ce qui n'est délibérément pas fait** : l'allègement (redimensionner à la taille réellement
|
||||||
|
affichée, convertir en WebP). IDEES.md posait la question à mesurer avant de s'y engager, même
|
||||||
|
règle que pour l'AOT WASM — non fait tant que la taille du cache image n'a pas montré de
|
||||||
|
problème réel en usage.
|
||||||
|
|
||||||
## Installation YunoHost — éprouvée en production le 2026-08-18
|
## Installation YunoHost — éprouvée en production le 2026-08-18
|
||||||
|
|
||||||
Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé,
|
Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé,
|
||||||
|
|||||||
@@ -116,69 +116,17 @@ sans rapport.
|
|||||||
|
|
||||||
## Lot A — L'habillage : la navigation ne ressemble à rien
|
## Lot A — L'habillage : la navigation ne ressemble à rien
|
||||||
|
|
||||||
Le lot qui se voit à chaque écran, et le seul dont le défaut ressemble à une régression.
|
**A1 à A4 traités le 2026-08-20** (voir `CLAUDE.md`, section « Habillage — retours d'usage du
|
||||||
|
2026-08-19 traités »). Reste ouvert :
|
||||||
|
|
||||||
### A1. Les six onglets s'affichent en liens bruts (anomalie, pas une demande)
|
### A5. Alléger les couvertures (redimensionnement / WebP)
|
||||||
|
|
||||||
Constaté sur la capture du 2026-08-19 : `CatalogueAuteursSériesRevuesPrêtsEnvies` s'affiche en
|
Le cache hors-ligne des couvertures est fait (voir `CLAUDE.md`), mais chaque affichage en ligne
|
||||||
bas de page, en bleu souligné, collés les uns aux autres — c'est-à-dire **sans aucune des règles
|
retélécharge toujours l'image OpenLibrary à sa taille d'origine — rien n'est redimensionné à la
|
||||||
de `MainLayout.razor.css`** : ni `position: fixed`, ni `display: flex`, ni les libellés espacés.
|
taille réellement affichée, ni converti en WebP.
|
||||||
|
|
||||||
⚠️ **Ce n'est probablement pas un défaut de mise en forme à refaire, mais une feuille de style
|
⚠️ À peser avant de s'y engager, même règle que pour l'AOT : une conversion n'a de sens que si la
|
||||||
périmée.** Sur la même capture, le bandeau supérieur **est** bleu foncé — or `.bandeau` et
|
mesure montre un gain réel. Pas encore mesuré à l'échelle d'un vrai fonds.
|
||||||
`.onglets` vivent dans le **même fichier** scopé. Une moitié appliquée et l'autre pas ne
|
|
||||||
s'explique pas par le CSS lui-même : elle s'explique par un `MaBibli.Client.styles.css` **d'avant
|
|
||||||
l'ajout des onglets**, servi à côté d'un WASM à jour (le balisage, lui, montre bien les six
|
|
||||||
onglets).
|
|
||||||
|
|
||||||
Hypothèse à vérifier **avant** de toucher au style, sous peine de « corriger » du code sain :
|
|
||||||
|
|
||||||
1. le nom `MaBibli.Client.styles.css` est **stable** (les empreintes sont désactivées, voir
|
|
||||||
`CLAUDE.md`) — rien d'autre que le service worker ne le fait expirer ;
|
|
||||||
2. or le service worker **ne s'enregistre pas** dans certains contextes, et un onglet resté
|
|
||||||
ouvert diffère l'activation d'un nouveau worker.
|
|
||||||
|
|
||||||
Contrôles qui trancheraient : ouvrir `/MaBibli.Client.styles.css` sur le serveur et y chercher
|
|
||||||
`.onglets` ; comparer avec le fichier du dernier publish ; recharger en vidant le cache.
|
|
||||||
Si la règle **est** présente et pourtant sans effet, alors seulement chercher du côté de
|
|
||||||
l'attribut de portée (`b-xxxxx`) que `NavLink` doit relayer via ses attributs additionnels.
|
|
||||||
|
|
||||||
### A2. Les filtres reprennent le vocabulaire visuel des cartes
|
|
||||||
|
|
||||||
Les états *À lire / En cours / Lu* et les types *Roman / BD* s'affichent en pastilles colorées
|
|
||||||
sur les cartes du catalogue, mais en segments neutres dans le panneau « Filtrer ». Demander la
|
|
||||||
**même pastille** dans le filtre, pour qu'on reconnaisse ce qu'on filtre.
|
|
||||||
|
|
||||||
### A3. Une identité visuelle : trois images, trois emplacements distincts
|
|
||||||
|
|
||||||
À ne pas confondre, les trois ne vivent pas au même endroit :
|
|
||||||
|
|
||||||
| Où | Quoi | Porté par |
|
|
||||||
|---|---|---|
|
|
||||||
| Catalogue d'applications YunoHost | logo de l'app | `mabibli_ynh` (`doc/`, manifeste) |
|
|
||||||
| Bandeau de l'application | petit logo à côté de « MaBibli » | `MaBibli.Client/wwwroot` |
|
|
||||||
| Installation PWA (téléphone, PC) | icône d'accueil et écran de démarrage | `manifest.webmanifest` + icônes 192/512 |
|
|
||||||
|
|
||||||
⚠️ La troisième est la seule qui a des contraintes dures (tailles, `purpose: maskable`), et
|
|
||||||
c'est aussi la seule que le service worker met en cache — donc celle dont un changement se voit
|
|
||||||
le plus tard.
|
|
||||||
|
|
||||||
### A4. Couvertures : ne pas agrandir ce qui n'existe pas
|
|
||||||
|
|
||||||
Cliquer une vignette absente ne doit rien ouvrir. `CLAUDE.md` affirme que le bouton n'est rendu
|
|
||||||
que lorsqu'une `CoverUrl` existe — **le cas remonté est donc probablement l'image *cassée*** :
|
|
||||||
URL présente, mais 502 ou 404 d'OpenLibrary (intermittence documentée). Le `onerror` remplace
|
|
||||||
l'image ; il devrait aussi **retirer le déclencheur d'agrandissement**.
|
|
||||||
|
|
||||||
### A5. Garder les couvertures en cache, et les alléger
|
|
||||||
|
|
||||||
Aujourd'hui chaque affichage retélécharge l'image chez OpenLibrary — donc rien hors-ligne, et
|
|
||||||
des vignettes manquantes au gré des 502. Piste : stocker l'image (IndexedDB, à côté des
|
|
||||||
instantanés) et la réduire à la taille réellement affichée.
|
|
||||||
|
|
||||||
⚠️ À peser : c'est le premier contenu **binaire et volumineux** du cache hors-ligne, dont
|
|
||||||
`CLAUDE.md` note qu'il pèse aujourd'hui ~2,4 Ko pour 10 livres. Une conversion (WebP) n'a de sens
|
|
||||||
que si la mesure montre un gain — même règle que pour l'AOT.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,21 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
// Le client Blazor WebAssembly est servi comme fichiers statiques par cette API :
|
// Le client Blazor WebAssembly est servi comme fichiers statiques par cette API :
|
||||||
// un seul processus tourne sur le serveur.
|
// un seul processus tourne sur le serveur.
|
||||||
|
//
|
||||||
|
// ⚠️ Les empreintes WASM sont désactivées (voir CLAUDE.md) : tous ces fichiers portent des noms
|
||||||
|
// stables. Sans en-tête Cache-Control, un navigateur peut garder une version périmée en cache
|
||||||
|
// heuristique (par exemple MaBibli.Client.styles.css) même si le service worker, lui, revalide
|
||||||
|
// déjà à l'installation — mais seulement quand il parvient à s'activer. `no-cache` force une
|
||||||
|
// revalidation conditionnelle (ETag/Last-Modified, déjà posés par défaut) à chaque requête :
|
||||||
|
// pas de re-téléchargement si le fichier n'a pas changé, mais jamais de contenu périmé servi
|
||||||
|
// sans repasser par le serveur.
|
||||||
|
var optionsFichiersStatiques = new StaticFileOptions
|
||||||
|
{
|
||||||
|
OnPrepareResponse = ctx =>
|
||||||
|
ctx.Context.Response.Headers.CacheControl = "no-cache, must-revalidate",
|
||||||
|
};
|
||||||
app.UseBlazorFrameworkFiles();
|
app.UseBlazorFrameworkFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles(optionsFichiersStatiques);
|
||||||
|
|
||||||
app.MapIsbnEndpoints();
|
app.MapIsbnEndpoints();
|
||||||
app.MapLivresEndpoints();
|
app.MapLivresEndpoints();
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
@using MaBibli.Client.Services
|
||||||
|
@inject CacheHorsLigne Cache
|
||||||
|
@inject EtatReseau Reseau
|
||||||
@*
|
@*
|
||||||
Couverture d'un livre, avec substitut.
|
Couverture d'un livre, avec substitut.
|
||||||
|
|
||||||
@@ -10,7 +13,19 @@
|
|||||||
le onerror a marqué comme cassé.
|
le onerror a marqué comme cassé.
|
||||||
|
|
||||||
Agrandissement (IDEES.md) : seulement quand il y a une URL. Sans couverture, il n'y a rien
|
Agrandissement (IDEES.md) : seulement quand il y a une URL. Sans couverture, il n'y a rien
|
||||||
à agrandir — le substitut à initiale reste un simple décor, jamais un bouton.
|
à agrandir - le substitut à initiale reste un simple décor, jamais un bouton.
|
||||||
|
|
||||||
|
⚠️ Image cassée après coup (404/502 intermittent d'OpenLibrary) : le onerror désactive aussi
|
||||||
|
le bouton déclencheur (this.closest('.couverture-declencheur').disabled = true), pas
|
||||||
|
seulement l'image. Sans ça, cliquer une vignette cassée agrandissait quand même une image
|
||||||
|
cassée (A4, IDEES.md).
|
||||||
|
|
||||||
|
Cache hors-ligne (A5, IDEES.md) : EN LIGNE, l'affichage utilise l'URL réseau telle quelle —
|
||||||
|
rien ne doit ralentir la voie rapide — et une mise en cache démarre en tâche de fond,
|
||||||
|
sans attendre son résultat, pour que la même couverture reste visible hors-ligne. HORS
|
||||||
|
LIGNE, on tente d'abord le cache : l'URL réseau échouerait de toute façon.
|
||||||
|
⚠️ L'allègement (redimensionner, convertir en WebP) n'est PAS fait ici : à mesurer avant
|
||||||
|
de s'y engager, comme pour l'AOT — voir IDEES.md.
|
||||||
*@
|
*@
|
||||||
<div class="couverture @Classe">
|
<div class="couverture @Classe">
|
||||||
<span class="couverture-substitut" aria-hidden="true">@Initiale</span>
|
<span class="couverture-substitut" aria-hidden="true">@Initiale</span>
|
||||||
@@ -24,19 +39,19 @@
|
|||||||
<button type="button" class="couverture-declencheur"
|
<button type="button" class="couverture-declencheur"
|
||||||
aria-label="Agrandir la couverture de @Titre"
|
aria-label="Agrandir la couverture de @Titre"
|
||||||
@onclick="Ouvrir">
|
@onclick="Ouvrir">
|
||||||
<img @key="Url"
|
<img @key="UrlAffichee"
|
||||||
class="couverture-image"
|
class="couverture-image"
|
||||||
src="@Url"
|
src="@UrlAffichee"
|
||||||
alt="Couverture de @Titre"
|
alt="Couverture de @Titre"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onerror="this.classList.add('image-cassee')" />
|
onerror="this.classList.add('image-cassee'); const b=this.closest('.couverture-declencheur'); if(b){b.disabled=true;}" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<img @key="Url"
|
<img @key="UrlAffichee"
|
||||||
class="couverture-image"
|
class="couverture-image"
|
||||||
src="@Url"
|
src="@UrlAffichee"
|
||||||
alt="Couverture de @Titre"
|
alt="Couverture de @Titre"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onerror="this.classList.add('image-cassee')" />
|
onerror="this.classList.add('image-cassee')" />
|
||||||
@@ -52,7 +67,7 @@
|
|||||||
aria-label="Couverture de @Titre"
|
aria-label="Couverture de @Titre"
|
||||||
tabindex="-1" @ref="_calque"
|
tabindex="-1" @ref="_calque"
|
||||||
@onclick="Fermer" @onkeydown="SurToucheAsync">
|
@onclick="Fermer" @onkeydown="SurToucheAsync">
|
||||||
<img class="calque-image" src="@Url" alt="Couverture de @Titre"
|
<img class="calque-image" src="@UrlAffichee" alt="Couverture de @Titre"
|
||||||
@onclick:stopPropagation="true"
|
@onclick:stopPropagation="true"
|
||||||
onerror="this.classList.add('image-cassee')" />
|
onerror="this.classList.add('image-cassee')" />
|
||||||
|
|
||||||
@@ -73,9 +88,18 @@
|
|||||||
private bool _agrandie;
|
private bool _agrandie;
|
||||||
private bool _aFocaliser;
|
private bool _aFocaliser;
|
||||||
private ElementReference _calque;
|
private ElementReference _calque;
|
||||||
|
private string? _urlCachee;
|
||||||
|
private (string? Url, bool EnLigne)? _urlResoluePour;
|
||||||
|
|
||||||
private bool ADesPixels => !string.IsNullOrWhiteSpace(Url);
|
private bool ADesPixels => !string.IsNullOrWhiteSpace(Url);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL réellement posée en <c>src</c> : l'URL réseau en ligne (voie rapide, inchangée), le
|
||||||
|
/// blob mis en cache hors-ligne quand il existe, sinon l'URL réseau telle quelle (qui échoue
|
||||||
|
/// simplement, comme avant cette fonctionnalité).
|
||||||
|
/// </summary>
|
||||||
|
private string? UrlAffichee => !Reseau.EnLigne && _urlCachee is not null ? _urlCachee : Url;
|
||||||
|
|
||||||
private string Initiale =>
|
private string Initiale =>
|
||||||
string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant();
|
string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant();
|
||||||
|
|
||||||
@@ -95,6 +119,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
// ⚠️ Le jeton compare aussi l'état réseau, pas seulement l'URL : sans quoi, passer
|
||||||
|
// en ligne → hors ligne sans que l'URL change (le cas le plus courant) ne relirait
|
||||||
|
// jamais le cache, et la couverture resterait invisible hors-ligne.
|
||||||
|
var jeton = (Url, Reseau.EnLigne);
|
||||||
|
if (!ADesPixels || _urlResoluePour == jeton)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_urlResoluePour = jeton;
|
||||||
|
_urlCachee = null;
|
||||||
|
|
||||||
|
if (Reseau.EnLigne)
|
||||||
|
{
|
||||||
|
// Tâche de fond, jamais attendue : la voie d'affichage en ligne ne doit rien à
|
||||||
|
// ce cache, elle reste aussi rapide qu'avant.
|
||||||
|
_ = Cache.MettreEnCacheCouvertureAsync(Url!);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_urlCachee = await Cache.LireCouvertureCacheeAsync(Url!);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||||
{
|
{
|
||||||
if (_aFocaliser)
|
if (_aFocaliser)
|
||||||
@@ -104,3 +155,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
@foreach (var type in Types)
|
@foreach (var type in Types)
|
||||||
{
|
{
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="segment @(Saisie.TypeDocument == type ? "segment-actif" : null)"
|
class="segment @Libelles.ClasseTypeDocument(type) @(Saisie.TypeDocument == type ? "segment-actif" : null)"
|
||||||
@onclick="() => Saisie.TypeDocument = type">
|
@onclick="() => Saisie.TypeDocument = type">
|
||||||
@Libelles.TypeDocument(type)
|
@Libelles.TypeDocument(type)
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -54,4 +54,16 @@ public static class Libelles
|
|||||||
MaBibli.Shared.Entites.Statut.Lu => "statut-lu",
|
MaBibli.Shared.Entites.Statut.Lu => "statut-lu",
|
||||||
_ => string.Empty,
|
_ => string.Empty,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classe CSS associée au type de document, pour la pastille de couleur (A2, IDEES.md) :
|
||||||
|
/// le filtre doit reprendre le même vocabulaire visuel que la carte, pas des segments neutres.
|
||||||
|
/// « Non précisé » n'a pas de couleur : ce n'est pas une valeur choisie.
|
||||||
|
/// </summary>
|
||||||
|
public static string ClasseTypeDocument(TypeDocument type) => type switch
|
||||||
|
{
|
||||||
|
MaBibli.Shared.Entites.TypeDocument.Roman => "type-roman",
|
||||||
|
MaBibli.Shared.Entites.TypeDocument.BandeDessinee => "type-bd",
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
@* Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
|
@* Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
|
||||||
Le PC hérite de la même colonne, simplement centrée et limitée en largeur. *@
|
Le PC hérite de la même colonne, simplement centrée et limitée en largeur. *@
|
||||||
<header class="bandeau">
|
<header class="bandeau">
|
||||||
<a class="marque" href="">MaBibli</a>
|
<a class="marque" href="">
|
||||||
|
<img src="logo-bandeau.svg" alt="" class="marque-logo" />
|
||||||
|
MaBibli
|
||||||
|
</a>
|
||||||
|
|
||||||
@if (!Reseau.EnLigne)
|
@if (!Reseau.EnLigne)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,15 @@
|
|||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marque-logo {
|
||||||
|
width: 1.3rem;
|
||||||
|
height: 1.3rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.utilisateur {
|
.utilisateur {
|
||||||
|
|||||||
@@ -65,7 +65,8 @@
|
|||||||
@onclick="() => FiltrerType(null)">Tous</button>
|
@onclick="() => FiltrerType(null)">Tous</button>
|
||||||
@foreach (var type in Enum.GetValues<TypeDocument>())
|
@foreach (var type in Enum.GetValues<TypeDocument>())
|
||||||
{
|
{
|
||||||
<button type="button" class="segment @(_type == type ? "segment-actif" : null)"
|
<button type="button"
|
||||||
|
class="segment @Libelles.ClasseTypeDocument(type) @(_type == type ? "segment-actif" : null)"
|
||||||
@onclick="() => FiltrerType(type)">@Libelles.TypeDocument(type)</button>
|
@onclick="() => FiltrerType(type)">@Libelles.TypeDocument(type)</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +80,8 @@
|
|||||||
@onclick="() => FiltrerStatut(null)">Tous</button>
|
@onclick="() => FiltrerStatut(null)">Tous</button>
|
||||||
@foreach (var statut in Enum.GetValues<Statut>())
|
@foreach (var statut in Enum.GetValues<Statut>())
|
||||||
{
|
{
|
||||||
<button type="button" class="segment @(_statut == statut ? "segment-actif" : null)"
|
<button type="button"
|
||||||
|
class="segment @Libelles.ClasseStatut(statut) @(_statut == statut ? "segment-actif" : null)"
|
||||||
@onclick="() => FiltrerStatut(statut)">@Libelles.Statut(statut)</button>
|
@onclick="() => FiltrerStatut(statut)">@Libelles.Statut(statut)</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +152,9 @@ else if (_livres is not null)
|
|||||||
« Non précisé » n'apprendrait rien et serait sur presque chaque ligne. *@
|
« Non précisé » n'apprendrait rien et serait sur presque chaque ligne. *@
|
||||||
@if (livre.TypeDocument != TypeDocument.NonPrecise)
|
@if (livre.TypeDocument != TypeDocument.NonPrecise)
|
||||||
{
|
{
|
||||||
<span class="etiquette">@Libelles.TypeDocument(livre.TypeDocument)</span>
|
<span class="etiquette etiquette-type @Libelles.ClasseTypeDocument(livre.TypeDocument)">
|
||||||
|
@Libelles.TypeDocument(livre.TypeDocument)
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* Personnel : ce que montre cette pastille est MA lecture, pas celle du foyer.
|
@* Personnel : ce que montre cette pastille est MA lecture, pas celle du foyer.
|
||||||
|
|||||||
@@ -151,6 +151,46 @@ public sealed class CacheHorsLigne(IJSRuntime js) : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Range une couverture en cache pour la consultation hors-ligne, en tâche de fond.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Fire-and-forget par nature (A5, IDEES.md) : appelé après l'affichage réussi d'une
|
||||||
|
/// couverture, sans jamais bloquer ni faire échouer ce qui s'est déjà bien passé. Une
|
||||||
|
/// couverture qui ne peut pas être mise en cache (quota, stockage refusé, 502
|
||||||
|
/// intermittent d'OpenLibrary) reste simplement absente hors-ligne — jamais une erreur
|
||||||
|
/// visible en ligne.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task MettreEnCacheCouvertureAsync(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var module = await ModuleAsync();
|
||||||
|
await module.InvokeVoidAsync("couvertureMettreEnCache", url);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Voir la remarque ci-dessus : rien à propager.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relit une couverture déjà mise en cache, sous forme d'URL d'objet (<c>blob:</c>) utilisable
|
||||||
|
/// directement comme <c>src</c>. <c>null</c> si elle n'a jamais été mise en cache.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> LireCouvertureCacheeAsync(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var module = await ModuleAsync();
|
||||||
|
return await module.InvokeAsync<string?>("couvertureLire", url);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_module is null)
|
if (_module is null)
|
||||||
|
|||||||
@@ -250,6 +250,11 @@ body {
|
|||||||
.segment.statut-en-cours.segment-actif { background: #b06b00; border-color: #b06b00; }
|
.segment.statut-en-cours.segment-actif { background: #b06b00; border-color: #b06b00; }
|
||||||
.segment.statut-lu.segment-actif { background: #1f7a44; border-color: #1f7a44; }
|
.segment.statut-lu.segment-actif { background: #1f7a44; border-color: #1f7a44; }
|
||||||
|
|
||||||
|
/* Type de document : même vocabulaire que les pastilles de statut, deux couleurs distinctes
|
||||||
|
des trois du statut et de celle du prêt, pour qu'on reconnaisse ce qu'on filtre (A2, IDEES.md). */
|
||||||
|
.segment.type-roman.segment-actif { background: #2a6f97; border-color: #2a6f97; }
|
||||||
|
.segment.type-bd.segment-actif { background: #a13d63; border-color: #a13d63; }
|
||||||
|
|
||||||
/* --- Boutons --- */
|
/* --- Boutons --- */
|
||||||
|
|
||||||
.bouton {
|
.bouton {
|
||||||
@@ -580,6 +585,12 @@ body {
|
|||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image cassée (404/502 intermittent) : le onerror du <img> désactive ce bouton.
|
||||||
|
Curseur neutre, pour ne plus promettre un agrandissement qui n'ouvrirait rien. */
|
||||||
|
.couverture-declencheur:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.calque-couverture {
|
.calque-couverture {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -638,6 +649,16 @@ body {
|
|||||||
.etiquette-statut.statut-en-cours { background: #b06b00; }
|
.etiquette-statut.statut-en-cours { background: #b06b00; }
|
||||||
.etiquette-statut.statut-lu { background: #1f7a44; }
|
.etiquette-statut.statut-lu { background: #1f7a44; }
|
||||||
|
|
||||||
|
/* Pastille du type de document sur la carte du catalogue : mêmes couleurs que le
|
||||||
|
filtre (A2, IDEES.md), pour reconnaître d'un coup d'œil ce qui a été filtré. */
|
||||||
|
.etiquette-type {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etiquette-type.type-roman { background: #2a6f97; }
|
||||||
|
.etiquette-type.type-bd { background: #a13d63; }
|
||||||
|
|
||||||
/* --- Fiche du livre en lecture seule --- */
|
/* --- Fiche du livre en lecture seule --- */
|
||||||
|
|
||||||
.couverture-grande { width: 8rem; height: 12rem; }
|
.couverture-grande { width: 8rem; height: 12rem; }
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 230 B |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -13,7 +13,11 @@
|
|||||||
|
|
||||||
const NOM_BASE = 'mabibli';
|
const NOM_BASE = 'mabibli';
|
||||||
const MAGASIN = 'instantanes';
|
const MAGASIN = 'instantanes';
|
||||||
const VERSION = 1;
|
// Couvertures mises en cache (A5, IDEES.md) : magasin séparé, clé = l'URL OpenLibrary,
|
||||||
|
// 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;
|
||||||
|
|
||||||
let promesseBase = null;
|
let promesseBase = null;
|
||||||
|
|
||||||
@@ -29,6 +33,7 @@ function ouvrir() {
|
|||||||
requete.onupgradeneeded = () => {
|
requete.onupgradeneeded = () => {
|
||||||
const base = requete.result;
|
const base = requete.result;
|
||||||
if (!base.objectStoreNames.contains(MAGASIN)) base.createObjectStore(MAGASIN);
|
if (!base.objectStoreNames.contains(MAGASIN)) base.createObjectStore(MAGASIN);
|
||||||
|
if (!base.objectStoreNames.contains(MAGASIN_COUVERTURES)) base.createObjectStore(MAGASIN_COUVERTURES);
|
||||||
};
|
};
|
||||||
requete.onsuccess = () => resoudre(requete.result);
|
requete.onsuccess = () => resoudre(requete.result);
|
||||||
requete.onerror = () => rejeter(requete.error);
|
requete.onerror = () => rejeter(requete.error);
|
||||||
@@ -84,3 +89,45 @@ export function surveiller(reference) {
|
|||||||
self.addEventListener('offline', prevenir);
|
self.addEventListener('offline', prevenir);
|
||||||
return navigator.onLine !== false;
|
return navigator.onLine !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Couvertures (A5, IDEES.md) ---
|
||||||
|
//
|
||||||
|
// En ligne, l'<img> normale reste la voie d'affichage : rien ici ne doit la ralentir. Cette
|
||||||
|
// fonction tourne en tâche de fond, appelée sans attendre son résultat (fire-and-forget) après
|
||||||
|
// qu'une couverture s'est affichée avec succès. Le second fetch profite en général du cache HTTP
|
||||||
|
// du navigateur (même URL que l'<img>) : pas de second téléchargement réel dans le cas courant.
|
||||||
|
//
|
||||||
|
// OpenLibrary envoie `Access-Control-Allow-Origin: *` (vérifié) : un fetch cross-origin normal
|
||||||
|
// suffit, pas besoin du contournement `no-cors`/réponse opaque.
|
||||||
|
export async function couvertureMettreEnCache(url) {
|
||||||
|
try {
|
||||||
|
const base = await ouvrir();
|
||||||
|
const tx = base.transaction(MAGASIN_COUVERTURES, 'readonly');
|
||||||
|
const existe = await attendre(tx.objectStore(MAGASIN_COUVERTURES).getKey(url), tx);
|
||||||
|
if (existe !== undefined) return; // Déjà en cache : pas de re-téléchargement.
|
||||||
|
|
||||||
|
const reponse = await fetch(url, { cache: 'force-cache' });
|
||||||
|
if (!reponse.ok) return; // 404/502 intermittent (CLAUDE.md) : rien à ranger.
|
||||||
|
|
||||||
|
const blob = await reponse.blob();
|
||||||
|
const ecriture = base.transaction(MAGASIN_COUVERTURES, 'readwrite');
|
||||||
|
await attendre(ecriture.objectStore(MAGASIN_COUVERTURES).put(blob, url), ecriture);
|
||||||
|
} catch (e) {
|
||||||
|
// Quota dépassé, stockage refusé : une couverture non mise en cache reste un
|
||||||
|
// agrément perdu, jamais une raison de faire échouer l'affichage en ligne.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renvoie une URL d'objet (`blob:`) utilisable comme `src`, ou `null` si cette couverture n'a
|
||||||
|
// jamais été mise en cache — c'est alors à l'appelant de retomber sur l'URL réseau (qui échouera
|
||||||
|
// simplement si hors-ligne, exactement comme aujourd'hui).
|
||||||
|
export async function couvertureLire(url) {
|
||||||
|
try {
|
||||||
|
const base = await ouvrir();
|
||||||
|
const tx = base.transaction(MAGASIN_COUVERTURES, 'readonly');
|
||||||
|
const blob = await attendre(tx.objectStore(MAGASIN_COUVERTURES).get(url), tx);
|
||||||
|
return blob ? URL.createObjectURL(blob) : null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!--
|
||||||
|
Petit logo du bandeau : même livre ouvert que les icônes PWA (favicon.png, icon-*.png),
|
||||||
|
mais en blanc plutôt qu'en bleu — le bandeau est déjà bleu foncé (--mb-accent), un logo
|
||||||
|
de la même couleur s'y fondrait.
|
||||||
|
|
||||||
|
SVG plutôt qu'un PNG : affiché à une seule petite taille dans le bandeau, mais net sur
|
||||||
|
tous les écrans (y compris les écrans très denses), sans multiplier les fichiers raster.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="24" height="24" aria-hidden="true">
|
||||||
|
<polygon points="32,15 10,20 10,47 32,44" fill="#fff" />
|
||||||
|
<polygon points="32,15 54,20 54,47 32,44" fill="#fff" />
|
||||||
|
<line x1="32" y1="15" x2="32" y2="44" stroke="#1b3a5c" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 727 B |