fix(search): loading par frame sur les cartes, plus de cadres vides
CI / build-and-test (push) Successful in 14m8s

- video-card: etat de chargement de la miniature (shimmer + spinner),
  fallback 'Apertu indisponible' avec retry, referrerpolicy no-referrer
- grid: eager sur les premieres cartes, bandeau de rafraichissement quand
  la grille est deja peuplee
- search: garde MIN_QUERY_LENGTH=2 (le loading partait pour une requete que
  request$ filtrait, grille figee sur des skeletons) + filet de securite 20s
- skeleton: ligne chaine pour coller a la hauteur reelle de la carte
This commit is contained in:
2026-09-28 19:35:48 -04:00
parent f1f673040e
commit 1814334958
6 changed files with 151 additions and 11 deletions
+44 -1
View File
@@ -22,6 +22,11 @@ import { UserService, type UserPreferences } from '../../services/user.service';
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
import { Observable, Subscription } from 'rxjs';
/** Longueur minimale d'une requete (aligne sur `SearchService.request$` et le backend). */
const MIN_QUERY_LENGTH = 2;
/** Filet de securite : le loading ne doit jamais rester bloque indefiniment. */
const LOADING_TIMEOUT_MS = 20_000;
@Component({
selector: 'app-search',
standalone: true,
@@ -69,6 +74,8 @@ export class SearchComponent {
private lastRecordedKey: string | null = null;
// Signature to detect new searches (q + providers + sort); excludes page
private lastSearchSignature: string | null = null;
// Relache le loading si l'emetteur `request$` ne repond pas (filet de securite).
private loadingWatchdog: ReturnType<typeof setTimeout> | null = null;
hasQuery = computed(() => this.q().length > 0);
providerLabel = computed(() => {
@@ -291,6 +298,7 @@ export class SearchComponent {
}
this.loading.set(false);
this.loadingMore.set(false);
this.clearLoadingWatchdog();
this.error.set(null);
// Surface per-provider backend failures (e.g. YouTube quota) so
// users understand why only some providers return results.
@@ -300,6 +308,7 @@ export class SearchComponent {
this.groups.set({} as any);
this.loading.set(false);
this.loadingMore.set(false);
this.clearLoadingWatchdog();
this.error.set('Le service de recherche est temporairement indisponible. Réessayer.');
}
});
@@ -340,7 +349,7 @@ export class SearchComponent {
this.typeFilter.set(filters.type);
this.dateFilter.set(filters.period);
if (q) {
if (q.length >= MIN_QUERY_LENGTH) {
this.notice.set(null);
// Reset the language filter on a new query (the other filters are URL
// driven and must survive a query change).
@@ -385,15 +394,18 @@ export class SearchComponent {
this.endReached.set(false);
this.loadingMore.set(false);
this.loading.set(true);
this.armLoadingWatchdog();
// New search (or sort/filter reset): results restart, scroll to top.
// Continuation pages must NOT touch scroll (handled without router nav).
try { window.scrollTo(0, 0); } catch {}
} else if (!this.groups() || Object.keys(this.groups()).length === 0) {
this.loading.set(true);
this.armLoadingWatchdog();
}
this.error.set(null);
} else {
this.loading.set(false);
this.clearLoadingWatchdog();
this.groups.set({} as any);
this.showUnified.set(false);
this.error.set(null);
@@ -403,6 +415,36 @@ export class SearchComponent {
}
/**
* Arme le filet de securite du loading. Si `request$` ne repond pas (requete
* supprimee par un `switchMap`, cache incoherent, etc.) on stoppe le loading au
* lieu de laisser une grille figee sur des skeletons.
*/
private armLoadingWatchdog(): void {
this.clearLoadingWatchdog();
this.loadingWatchdog = setTimeout(() => {
this.loadingWatchdog = null;
if (this.loading()) {
this.loading.set(false);
this.loadingMore.set(false);
if (Object.keys(this.groups() || {}).length === 0) {
this.error.set('La recherche met trop de temps a repondre. Reessayez.');
}
}
}, LOADING_TIMEOUT_MS);
}
private clearLoadingWatchdog(): void {
if (this.loadingWatchdog != null) {
clearTimeout(this.loadingWatchdog);
this.loadingWatchdog = null;
}
}
ngOnDestroy(): void {
this.clearLoadingWatchdog();
}
// Lectures de métadonnées déléguées au modèle de filtres partagé
// (server/search-filters.mjs côté API, src/app/search/filters.ts côté front :
// même implémentation = mêmes résultats).
@@ -735,6 +777,7 @@ export class SearchComponent {
const next = (this.pageParam() || 1) + 1;
this.pageParam.set(next);
this.loadingMore.set(true);
this.armLoadingWatchdog();
this.unified.setPage(next);
}