From d400c26249344a3e15d25a44f115bdd0cd0b73cd Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 28 Sep 2026 21:28:01 -0400 Subject: [PATCH] feat(search): en-tete de resultats compact, fournisseurs en filtres - rail 1 : pills de type resserrees (h-6/text-xs), role=group coherent - rail 2 : les pastilles fournisseur deviennent des boutons d'affichage (couleur de marque = visible, gris + opacite = masque), avec le compte de resultats par source, aria-pressed, hover/focus - filtre d'affichage local (aucun refetch, aucun squelette) persiste en localStorage ; impossible de masquer la derniere source visible - panneau de filtres repliable par defaut, selects h-8 inline labels sr-only, badge du nombre de filtres actifs sur le bouton - nouveau computed scopedVideos : compteurs de types et tri inter-provider alignes sur les sources visibles - suppression d'activeProvidersInfo devenu inutilise --- src/components/search/search.component.html | 209 ++++++++++++-------- src/components/search/search.component.ts | 158 ++++++++++++++- 2 files changed, 275 insertions(+), 92 deletions(-) diff --git a/src/components/search/search.component.html b/src/components/search/search.component.html index 6dbe5ba..4ecc1b5 100644 --- a/src/components/search/search.component.html +++ b/src/components/search/search.component.html @@ -1,22 +1,40 @@
-
-

{{ pageHeading() }}

- -
- @for (provider of activeProvidersInfo(); track provider.id) { -
- - - - {{ provider.label }} -
+ +
+
+

{{ pageHeading() }}

+ + @if (hasQuery()) { + + {{ filteredResults().length }} résultat{{ filteredResults().length === 1 ? '' : 's' }} + + } + + @if (hasQuery()) { + }
@if (notice()) { -
+
{{ notice() }}
} @@ -26,71 +44,98 @@ } @if (hasQuery()) { - - @if (activeFilterChips().length > 0) { -
- Filtres - @for (chip of activeFilterChips(); track chip.key) { - - } - - - {{ filteredResults().length }} / {{ allVideos().length }} résultats - -
- } - - -
- - - - -
- -
-
-
- - - - - + + + -
-
- - @@ -99,10 +144,9 @@ -
-
- - @@ -110,31 +154,30 @@ -
-
- - - + -
-
- - - + + + + {{ 'filters.serverHint' | t: 'Type, période et durée sont appliqués par les sources ; la langue filtre les résultats affichés.' }} +
-

- {{ 'filters.serverHint' | t: 'Type, période et durée sont appliqués par les sources ; la langue filtre les résultats affichés.' }} -

-
+ } @if (error()) {
diff --git a/src/components/search/search.component.ts b/src/components/search/search.component.ts index e7d4064..482bc2c 100644 --- a/src/components/search/search.component.ts +++ b/src/components/search/search.component.ts @@ -26,6 +26,9 @@ import { Observable, Subscription } from 'rxjs'; const MIN_QUERY_LENGTH = 2; /** Filet de securite : le loading ne doit jamais rester bloque indefiniment. */ const LOADING_TIMEOUT_MS = 20_000; +/** Clefs de persistance locale des preferences d'affichage de l'en-tete. */ +const STORAGE_HIDDEN_PROVIDERS = 'newtube:search.hiddenProviders'; +const STORAGE_FILTERS_OPEN = 'newtube:search.filtersOpen'; @Component({ selector: 'app-search', @@ -125,14 +128,136 @@ export class SearchComponent { 'ru': 'rumble' }; - // Get active providers with full info - activeProvidersInfo = computed(() => { - const activeIds = this.activeProviders(); - return this.instances.providers().filter(p => - activeIds.some(id => this.providerIdMap[id] === p.id) - ); + // ---- En-tete compact : filtres repliables + fournisseurs en boutons ---- + /** + * Sources masquees dans la grille. C'est un filtre d'AFFICHAGE : il ne + * relance aucune requete, donc aucun squelette, aucun saut de mise en page — + * on masque ou on réaffiche ce que l'on a déjà chargé. La préférence est + * persistée (localStorage) car c'est un réglage de lecture, pas une recherche. + */ + hiddenProviders = signal(this.loadHiddenProviders()); + /** Panneau de filtres replié par défaut : les chips suffisent au quotidien. */ + filtersOpen = signal(this.loadFiltersOpen()); + + private readStorage(key: string): string | null { + try { return localStorage.getItem(key); } catch { return null; } + } + private writeStorage(key: string, value: string | null): void { + try { + if (value == null) localStorage.removeItem(key); + else localStorage.setItem(key, value); + } catch {} + } + + private loadHiddenProviders(): ProviderId[] { + const raw = this.readStorage(STORAGE_HIDDEN_PROVIDERS); + if (!raw) return []; + try { + const arr = JSON.parse(raw); + if (!Array.isArray(arr)) return []; + const valid = new Set(PROVIDERS.map(p => p.id)); + return arr.filter((v: unknown) => typeof v === 'string' && valid.has(v)) as ProviderId[]; + } catch { return []; } + } + + private loadFiltersOpen(): boolean { + return this.readStorage(STORAGE_FILTERS_OPEN) === '1'; + } + + private persistHiddenProviders(): void { + const hidden = this.hiddenProviders(); + this.writeStorage(STORAGE_HIDDEN_PROVIDERS, hidden.length ? JSON.stringify(hidden) : null); + } + + toggleFilters(): void { + this.filtersOpen.set(!this.filtersOpen()); + this.writeStorage(STORAGE_FILTERS_OPEN, this.filtersOpen() ? '1' : '0'); + } + + // ---- Pastilles fournisseurs = filtres d'affichage ---- + + /** Sources effectivement visibles (celles de la recherche, moins les masquées). */ + visibleProviders = computed(() => { + const hidden = this.hiddenProviders(); + return this.activeProviders().filter(id => !hidden.includes(id)); }); + allProvidersVisible = computed(() => this.hiddenProviders().length === 0); + + isProviderVisible(id: ProviderId): boolean { + return !this.hiddenProviders().includes(id); + } + + /** + * Bascule l'affichage d'une source. On refuse de masquer la dernière source + * visible : une grille entièrement vide n'est jamais une réponse utile. + */ + toggleProvider(id: ProviderId): void { + const visible = this.visibleProviders(); + if (visible.includes(id) && visible.length <= 1) return; + const next = visible.includes(id) + ? this.hiddenProviders().concat(id) + : this.hiddenProviders().filter(p => p !== id); + this.hiddenProviders.set(Array.from(new Set(next))); + this.persistHiddenProviders(); + } + + showAllProviders(): void { + if (this.allProvidersVisible()) return; + this.hiddenProviders.set([]); + this.persistHiddenProviders(); + } + + /** + * Pastilles de l'en-tete : une par source interrogée, avec le nombre de + * résultats qu'elle a fournis. L'ordre suit celui de la recherche. + */ + providerChips = computed(() => { + const counts = new Map(); + for (const v of this.allVideos()) { + const pid = this.toProviderId((v as any)?.provider); + if (pid) counts.set(pid, (counts.get(pid) || 0) + 1); + } + const hidden = this.hiddenProviders(); + return this.activeProviders().map(id => { + const spec = PROVIDERS.find(p => p.id === id); + return { + id, + shortLabel: spec?.shortLabel || id.toUpperCase(), + displayName: spec?.displayName || id, + count: counts.get(id) || 0, + visible: !hidden.includes(id), + }; + }); + }); + + /** Vrai quand toutes les sources connues sont déjà interrogées. */ + allSourcesActive = computed(() => this.activeProviders().length >= PROVIDERS.length); + + /** Élargit la recherche à toutes les sources (écrit `providers` dans l'URL). */ + enableAllSources(): void { + const params = this.cleanUrlParams({ + ...this.route.snapshot.queryParams, + providers: PROVIDERS.map(p => p.id).join(','), + page: 1, + }); + this.router.navigate([], { + relativeTo: this.route, queryParams: params, replaceUrl: true, + }); + } + + /** Style de pastille : couleurs de la marque quand visible, neutre quand masquée. */ + providerChipStyle(id: ProviderId, visible: boolean): { [key: string]: string } { + if (!visible) { + return { + backgroundColor: 'transparent', + color: 'rgb(113, 113, 122)', + borderColor: 'rgb(63, 63, 70)', + }; + } + return this.getProviderColors(this.providerIdMap[id]); + } + // Brand colors for provider badge (inline styles to match History badges) getProviderColors(providerId: string | null | undefined): { [key: string]: string } { const id = (providerId || '').toLowerCase(); @@ -228,15 +353,30 @@ export class SearchComponent { return Object.values(g).flat(); }); - // Helper computed: filtered list based on active tag + filter bar + /** Helper computed: filtered list based on active tag + filter bar */ filteredResults = computed(() => { - const list = this.allVideos(); + const list = this.scopedVideos(); if (!Array.isArray(list) || list.length === 0) return []; const filtered = this.applyFilterBar(list); return this.sortVideos(filtered); }); + /** + * Résultats restreints aux sources visibles (pastilles fournisseurs). Filtre + * purement local : les données sont déjà là, on ne refetch pas. + */ + scopedVideos = computed(() => { + const list = this.allVideos(); + const hidden = this.hiddenProviders(); + if (hidden.length === 0) return list; + return list.filter((v: any) => { + const pid = this.toProviderId(v?.provider); + // Un résultat sans source identifiable reste visible. + return !pid || !hidden.includes(pid); + }); + }); + // Filter each provider group separately for sectioned rendering filteredGroups = computed(() => { const barPredicate = this.buildFilterBarPredicate(); @@ -470,7 +610,7 @@ export class SearchComponent { /** Compteurs par type pour les pills façon directory Twitch. */ typeCounts = computed(() => { - const list = this.allVideos(); + const list = this.scopedVideos(); let live = 0, video = 0, clip = 0, channel = 0; for (const v of list || []) { if (this.isChannelItem(v)) { channel++; continue; }