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>
@@ -1,3 +1,6 @@
|
||||
@using MaBibli.Client.Services
|
||||
@inject CacheHorsLigne Cache
|
||||
@inject EtatReseau Reseau
|
||||
@*
|
||||
Couverture d'un livre, avec substitut.
|
||||
|
||||
@@ -10,7 +13,19 @@
|
||||
le onerror a marqué comme cassé.
|
||||
|
||||
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">
|
||||
<span class="couverture-substitut" aria-hidden="true">@Initiale</span>
|
||||
@@ -24,19 +39,19 @@
|
||||
<button type="button" class="couverture-declencheur"
|
||||
aria-label="Agrandir la couverture de @Titre"
|
||||
@onclick="Ouvrir">
|
||||
<img @key="Url"
|
||||
<img @key="UrlAffichee"
|
||||
class="couverture-image"
|
||||
src="@Url"
|
||||
src="@UrlAffichee"
|
||||
alt="Couverture de @Titre"
|
||||
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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img @key="Url"
|
||||
<img @key="UrlAffichee"
|
||||
class="couverture-image"
|
||||
src="@Url"
|
||||
src="@UrlAffichee"
|
||||
alt="Couverture de @Titre"
|
||||
loading="lazy"
|
||||
onerror="this.classList.add('image-cassee')" />
|
||||
@@ -52,7 +67,7 @@
|
||||
aria-label="Couverture de @Titre"
|
||||
tabindex="-1" @ref="_calque"
|
||||
@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"
|
||||
onerror="this.classList.add('image-cassee')" />
|
||||
|
||||
@@ -73,9 +88,18 @@
|
||||
private bool _agrandie;
|
||||
private bool _aFocaliser;
|
||||
private ElementReference _calque;
|
||||
private string? _urlCachee;
|
||||
private (string? Url, bool EnLigne)? _urlResoluePour;
|
||||
|
||||
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 =>
|
||||
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)
|
||||
{
|
||||
if (_aFocaliser)
|
||||
@@ -104,3 +155,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
@foreach (var type in Types)
|
||||
{
|
||||
<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">
|
||||
@Libelles.TypeDocument(type)
|
||||
</button>
|
||||
|
||||
@@ -54,4 +54,16 @@ public static class Libelles
|
||||
MaBibli.Shared.Entites.Statut.Lu => "statut-lu",
|
||||
_ => 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.
|
||||
Le PC hérite de la même colonne, simplement centrée et limitée en largeur. *@
|
||||
<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)
|
||||
{
|
||||
|
||||
@@ -16,6 +16,15 @@
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
@onclick="() => FiltrerType(null)">Tous</button>
|
||||
@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>
|
||||
}
|
||||
</div>
|
||||
@@ -79,7 +80,8 @@
|
||||
@onclick="() => FiltrerStatut(null)">Tous</button>
|
||||
@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>
|
||||
}
|
||||
</div>
|
||||
@@ -150,7 +152,9 @@ else if (_livres is not null)
|
||||
« Non précisé » n'apprendrait rien et serait sur presque chaque ligne. *@
|
||||
@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.
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
if (_module is null)
|
||||
|
||||
@@ -250,6 +250,11 @@ body {
|
||||
.segment.statut-en-cours.segment-actif { background: #b06b00; border-color: #b06b00; }
|
||||
.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 --- */
|
||||
|
||||
.bouton {
|
||||
@@ -580,6 +585,12 @@ body {
|
||||
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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -638,6 +649,16 @@ body {
|
||||
.etiquette-statut.statut-en-cours { background: #b06b00; }
|
||||
.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 --- */
|
||||
|
||||
.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 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;
|
||||
|
||||
@@ -29,6 +33,7 @@ function ouvrir() {
|
||||
requete.onupgradeneeded = () => {
|
||||
const base = requete.result;
|
||||
if (!base.objectStoreNames.contains(MAGASIN)) base.createObjectStore(MAGASIN);
|
||||
if (!base.objectStoreNames.contains(MAGASIN_COUVERTURES)) base.createObjectStore(MAGASIN_COUVERTURES);
|
||||
};
|
||||
requete.onsuccess = () => resoudre(requete.result);
|
||||
requete.onerror = () => rejeter(requete.error);
|
||||
@@ -84,3 +89,45 @@ export function surveiller(reference) {
|
||||
self.addEventListener('offline', prevenir);
|
||||
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 |