feat(search): panneau de filtres unifié, clavier/focus du typeahead
CI / build-and-test (push) Successful in 14m10s

Barre de recherche
- Panneau de filtres unifié (remplace le ProviderPicker) : sources, type
  (vidéos/shorts/direct/chaînes), période (heure/jour/semaine/mois/année),
  durée (<4 / 4-20 / >20 min) et tri. Pastilles + roving tabindex
  (←/→ dans un groupe, ↑/↓ entre groupes), focus trap, Échap, Ctrl/⌘+Maj+F,
  recherches récentes, « mémoriser par défaut », « Réinitialiser ».
- Pastilles des filtres actifs sous la barre (retrait en un clic) + compteur.
- Typeahead : un seul keydown (fin du double Enter), ↑/↓ bouclants,
  Home/End, PageUp/PageDown, Entrée valide la ligne surlignée (sinon
  recherche brute), Tab complète sans chercher, Échap ferme puis vide,
  ligne « Rechercher <q> », défilement de l'option active, aria à jour.
- Focus : le panneau se referme dès que le focus quitte la barre
  (focusout + relatedTarget + activeElement), pointerdown neutralisé sur les
  lignes pour garder le focus dans l'input (plus de scintillement).
- Opérateurs en clair : `linux live:`, `tuto today: long:` deviennent des
  filtres et sont retirés de la requête (autocomplétion après le `:`).

Filtres côté serveur
- Modèle partagé : src/app/search/filters.ts + server/search-filters.mjs.
- /api/search?type=&duration=&period=&sort= : InnerTube (upload_date, type,
  duration, features), Data API v3 (type, videoDuration, publishedAfter),
  yt-dlp --dateafter ; post-filtrage pour les providers sans filtre natif.
  Un champ manquant ne fait jamais disparaître un résultat.
- Filtres dans l'URL (partageables) + adapters + SearchService (clé de cache).
- Correction : la recherche Shorts renvoyait 0 (ShortsLockupView/GridShelfView
  non mappés, id/titre dans l'endpoint) -> mappage + shelf + vignette/vues.

Tests / docs
- 23 tests unitaires (clavier, focus, opérateurs, panneau de filtres),
  server/tests/search-filters.test.mjs (npm run test:filters) + CI,
  scénarios e2e des filtres, README/api/README/todo/MCP à jour.
This commit is contained in:
2026-09-28 17:42:19 -04:00
parent 9fb0ce7549
commit f1f673040e
40 changed files with 3111 additions and 938 deletions
+166 -277
View File
@@ -11,8 +11,13 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { isShortVideo } from 'src/app/shared/utils/video-kind';
import { SearchService, type SearchResponse } from '../../app/search/search.service';
import {
DEFAULT_FILTERS, activeFilterCount, filtersToQueryParams, filtersFromQueryParams,
normalizeFilters, matchesFilters, filterGroups, itemDurationSec, itemPublishedTs,
isLiveItem, isShortItem, isChannelItem, isClipItem,
type SearchFilters, type FilterDuration, type FilterPeriod, type FilterType, type FilterSort,
} from '../../app/search/filters';
import { UserService, type UserPreferences } from '../../services/user.service';
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
import { Observable, Subscription } from 'rxjs';
@@ -46,6 +51,13 @@ export class SearchComponent {
private defaultProvidersPref = signal<ProviderId[] | null>(null);
pageParam = signal<number>(1);
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
/**
* Filtres de recherche. Ils vivent dans l'URL (`?type=&duration=&period=&sort=`)
* ET sont poussés dans `SearchService` : le serveur filtre vraiment ses
* résultats (InnerTube/Data API quand c'est possible, post-filtrage sinon).
* Les filtres locaux ci-dessous ne font plus qu'un dernier raffinement.
*/
filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
// Unified multi-provider response (grouped suggestions)
groups = signal<Record<string, any[]>>({});
@@ -168,16 +180,29 @@ export class SearchComponent {
// Public computed used by the template to avoid referencing private `instances`
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
// Active tag filter. For non-Twitch providers: 'all' | 'short' | 'medium' | 'long' | 'recent'.
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
filterTag = signal<string>('all');
// Filter bar (same UX as the provider/theme page): client-side selects.
// Server-side sort stays on `sortParam` (URL); these four filter locally.
durationFilter = signal<'any' | 'short' | 'medium' | 'long'>('any');
typeFilter = signal<'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'>('any');
// Filter bar (same UX as the provider/theme page): the four selects write the
// URL (server-side filtering) and are mirrored locally as a safety net for
// providers that ignore a dimension.
durationFilter = signal<'any' | FilterDuration>('any');
typeFilter = signal<'any' | FilterType>('any');
languageFilter = signal<'any' | 'en' | 'fr'>('any');
dateFilter = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
dateFilter = signal<'any' | FilterPeriod>('any');
/** Filtres actifs (pour les pastilles + l'indicateur « filtré »). */
activeFilterCount = computed(() => activeFilterCount(this.filters()));
readonly filterGroupsView = computed(() => filterGroups(this.filters()));
/** Étiquettes des filtres actifs, prêtes à afficher. */
activeFilterChips = computed(() => {
const n = this.filters();
const out: { key: keyof SearchFilters; label: string }[] = [];
for (const g of this.filterGroupsView()) {
const k = g.key as keyof SearchFilters;
if (n[k] === 'all' || (k === 'sort' && n.sort === 'relevance')) continue;
const opt = g.options.find((o) => o.value === n[k]);
if (opt) out.push({ key: k, label: opt.label });
}
return out;
});
// Unified total results count (sum of groups lengths)
unifiedTotal = computed(() => {
@@ -201,74 +226,22 @@ export class SearchComponent {
const list = this.allVideos();
if (!Array.isArray(list) || list.length === 0) return [];
const filtered = this.applyFilterBar(this.applyTagFilter(list));
const filtered = this.applyFilterBar(list);
return this.sortVideos(filtered);
});
// Filter each provider group separately for sectioned rendering
filteredGroups = computed(() => {
const tagPredicate = this.buildTagPredicate();
const barPredicate = this.buildFilterBarPredicate();
const g = this.groups();
const result: Record<string, any[]> = {};
Object.entries(g || {}).forEach(([pid, list]) => {
result[pid] = Array.isArray(list) ? list.filter((v) => tagPredicate(v) && barPredicate(v)) : [];
result[pid] = Array.isArray(list) ? list.filter((v) => barPredicate(v)) : [];
});
return result;
});
// Available tags computed from current results (only show tags that can apply)
availableTags = computed(() => {
const list = this.allVideos();
if (list.length === 0) return [];
const tags: { key: string; label: string; show: boolean }[] = [];
const now = Date.now();
// Non-Twitch providers: duration + date filters
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
let hasShort = false, hasMedium = false, hasLong = false,
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
for (const v of list) {
const d = Number(v.durationSec || 0);
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
const timeDiff = now - uploadTime;
// Duration filters
if (d > 0 && d < 4 * 60) hasShort = true;
else if (d >= 4 * 60 && d < 20 * 60) hasMedium = true;
else if (d >= 20 * 60) hasLong = true;
// Date filters
if (uploadTime > 0) {
if (timeDiff <= oneDay) hasToday = true;
if (timeDiff <= sevenDays) hasThisWeek = true;
if (timeDiff <= thirtyDays) hasThisMonth = true;
if (timeDiff <= oneYear) hasThisYear = true;
}
}
// Add duration filters
tags.push({ key: 'all', label: 'Tout', show: list.length > 0 });
tags.push({ key: 'short', label: 'Moins de 4 min', show: hasShort });
tags.push({ key: 'medium', label: 'De 4 à 20 min', show: hasMedium });
tags.push({ key: 'long', label: '20 min et plus', show: hasLong });
// Add date filters
tags.push({ key: 'today', label: 'Aujourd\'hui', show: hasToday });
tags.push({ key: 'this_week', label: 'Cette semaine', show: hasThisWeek });
tags.push({ key: 'this_month', label: 'Ce mois-ci', show: hasThisMonth });
tags.push({ key: 'this_year', label: 'Cette année', show: hasThisYear });
return tags.filter(t => t.show);
});
constructor() {
// Update document title when provider or query changes
effect(() => {
@@ -356,32 +329,38 @@ export class SearchComponent {
this.pageParam.set(pageNum);
this.unified.setPage(pageNum);
if (isFinite(pageSize)) this.unified.setPageSize(pageSize);
const sortNorm = (sort === 'date' || sort === 'views') ? sort : 'relevance';
this.sortParam.set(sortNorm);
this.unified.setSort(sortNorm);
// Filtres (type / durée / période / tri) : lus dans l'URL puis poussés
// dans le service -> la requête serveur change vraiment de périmètre.
const filters = filtersFromQueryParams(pm);
this.filters.set(filters);
this.sortParam.set(filters.sort as 'relevance' | 'date' | 'views');
this.unified.setFilters(filters);
// Reflets locaux de la barre de filtres (le `any` = pas de filtre local).
this.durationFilter.set(filters.duration);
this.typeFilter.set(filters.type);
this.dateFilter.set(filters.period);
if (q) {
this.notice.set(null);
// Reset active tag + filter bar on new query
const provider = prov || this.instances.selectedProvider();
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
this.durationFilter.set('any');
this.typeFilter.set('any');
// Reset the language filter on a new query (the other filters are URL
// driven and must survive a query change).
this.languageFilter.set('any');
this.dateFilter.set('any');
const activeProvider = prov || this.instances.selectedProvider();
// Use unified search in all cases (URL providers > user default preference > single provider).
// Normalized to short ids so every adapter resolves (legacy full names like
// 'youtube' would otherwise match no adapter and return nothing).
const rawProviders = providersList || this.defaultProvidersPref() || [provider as ProviderId];
const rawProviders = providersList || this.defaultProvidersPref() || [activeProvider as ProviderId];
const providersToUse: ProviderId[] | 'all' = rawProviders === 'all'
? 'all'
: (rawProviders as unknown[]).map(p => this.toProviderId(p)).filter((p): p is ProviderId => !!p);
this.unified.setQuery(q);
this.unified.setProviders(providersToUse);
// Determine if this is a new search (signature excludes page)
const signature = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}|${sortNorm}`;
// Determine if this is a new search (signature excludes page but
// includes the filters: changing a filter restarts the results).
const signature = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}|${filters.sort}|${filters.type}.${filters.duration}.${filters.period}`;
const isNewSearch = this.lastSearchSignature !== signature || pageNum === 1;
this.lastSearchSignature = signature;
@@ -390,7 +369,7 @@ export class SearchComponent {
const key = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}`;
if (this.lastRecordedKey !== key) {
this.lastRecordedKey = key;
const recFilters: any = { provider, providers: Array.isArray(providersToUse) ? providersToUse : 'all' };
const recFilters: any = { provider: activeProvider, providers: Array.isArray(providersToUse) ? providersToUse : 'all' };
this.history.recordSearch(q, recFilters).subscribe({
next: () => {},
error: (err) => console.error('Error recording search:', err)
@@ -424,28 +403,11 @@ export class SearchComponent {
}
private durationSeconds(item: any): number {
const raw = item?.durationSec ?? item?.duration ?? item?.length ?? 0;
if (typeof raw === 'number') {
return Number.isFinite(raw) ? raw : 0;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : 0;
}
private publishedTimestamp(item: any): number {
const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate ?? item?.published_at ?? item?.uploaded ?? null;
if (typeof raw === 'number') {
return Number.isFinite(raw) ? raw : 0;
}
if (typeof raw === 'string' && raw.trim()) {
const parsed = Date.parse(raw);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return 0;
}
// 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).
private durationSeconds(item: any): number { return itemDurationSec(item); }
private publishedTimestamp(item: any): number { return itemPublishedTs(item); }
private viewCount(item: any): number {
const raw = item?.viewCount ?? item?.views ?? item?.stats?.views ?? item?.metrics?.views ?? 0;
@@ -456,95 +418,13 @@ export class SearchComponent {
return Number.isFinite(parsed) ? parsed : 0;
}
private buildTagPredicate(): (item: any) => boolean {
const tag = this.filterTag();
const provider = this.selectedProviderForView();
if (provider === 'twitch' || tag === 'all' || tag?.startsWith('twitch')) {
return () => true;
}
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
switch (tag) {
case 'short':
return (item) => {
const d = this.durationSeconds(item);
return d > 0 && d < 4 * 60;
};
case 'medium':
return (item) => {
const d = this.durationSeconds(item);
return d >= 4 * 60 && d < 20 * 60;
};
case 'long':
return (item) => {
const d = this.durationSeconds(item);
return d >= 20 * 60;
};
case 'today':
return (item) => {
const ts = this.publishedTimestamp(item);
return ts > 0 && (now - ts) <= oneDay;
};
case 'this_week':
return (item) => {
const ts = this.publishedTimestamp(item);
return ts > 0 && (now - ts) <= sevenDays;
};
case 'this_month':
return (item) => {
const ts = this.publishedTimestamp(item);
return ts > 0 && (now - ts) <= thirtyDays;
};
case 'this_year':
return (item) => {
const ts = this.publishedTimestamp(item);
return ts > 0 && (now - ts) <= oneYear;
};
default:
return () => true;
}
}
private applyTagFilter(list: any[]): any[] {
const predicate = this.buildTagPredicate();
return Array.isArray(list) ? list.filter(predicate) : [];
}
// ---- Filter bar (same UX as the provider/theme page) ----
private isLiveItem(item: any): boolean {
if (!item) return false;
if (item.isLive === true) return true;
// Strict: only an explicit 'live' type counts (unlike the shared util,
// offline Twitch channels typed 'channel' must NOT match).
return String(item.type || '').toLowerCase() === 'live';
}
private isShortItem(item: any): boolean {
if (!item) return false;
return isShortVideo({
duration: this.durationSeconds(item),
type: typeof item.type === 'string' ? item.type : undefined,
provider: typeof item.provider === 'string' ? item.provider : undefined,
});
}
private isChannelItem(item: any): boolean {
if (!item) return false;
// Chaîne Twitch hors-ligne : type explicite 'channel' et jamais en direct.
if (String(item.type || '').toLowerCase() === 'channel' && item.isLive !== true) return true;
return false;
}
private isClipItem(item: any): boolean {
if (!item) return false;
return String((item as any)?.kind || '').toLowerCase() === 'clip';
}
// Les 4 dimensions principales vivent dans l'URL (filtres serveur) ; la
// langue reste purement locale (aucun provider ne filtre par langue).
private isLiveItem(item: any): boolean { return isLiveItem(item); }
private isShortItem(item: any): boolean { return isShortItem(item); }
private isChannelItem(item: any): boolean { return isChannelItem(item); }
private isClipItem(item: any): boolean { return isClipItem(item); }
/** Compteurs par type pour les pills façon directory Twitch. */
typeCounts = computed(() => {
@@ -560,71 +440,42 @@ export class SearchComponent {
});
/** Pills Tous / Directs / Vidéos / Clips / Chaînes synchronisées avec typeFilter. */
typePill = computed<'all' | 'live' | 'video' | 'short' | 'channel'>(() => {
const t = this.typeFilter();
typePill = computed<'all' | 'live' | 'video' | 'shorts' | 'channel'>(() => {
const t = this.filters().type;
if (t === 'live') return 'live';
if (t === 'video' || t === 'recorded') return 'video';
if (t === 'short') return 'short';
if (t === 'video') return 'video';
if (t === 'shorts') return 'shorts';
if (t === 'channel') return 'channel';
return 'all';
});
setTypePill(v: 'all' | 'live' | 'video' | 'short' | 'channel'): void {
if (v === 'all') this.typeFilter.set('any');
else if (v === 'video') this.typeFilter.set('video');
else this.typeFilter.set(v);
setTypePill(v: 'all' | 'live' | 'video' | 'shorts' | 'channel'): void {
void this.setFilter('type', v === 'all' ? 'all' : v);
}
private isFrText(s: string): boolean {
return /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
}
private isEnText(s: string): boolean {
return /\b(the|a|an|and|for)\b/i.test(s);
}
/**
* Prédicat local : raffine les résultats *déjà filtrés par le serveur* avec
* exactement les mêmes règles (durée / période / type) + la langue, qui n'est
* pas supportée par les providers.
*/
private buildFilterBarPredicate(): (item: any) => boolean {
const duration = this.durationFilter();
const type = this.typeFilter();
const filters = this.filters();
const lang = this.languageFilter();
const date = this.dateFilter();
if (duration === 'any' && type === 'any' && lang === 'any' && date === 'any') {
return () => true;
}
const now = Date.now();
const cutoff = date === 'any' ? 0 : {
day: now - 24 * 3600 * 1000,
week: now - 7 * 24 * 3600 * 1000,
month: now - 30 * 24 * 3600 * 1000,
year: now - 365 * 24 * 3600 * 1000,
}[date] || 0;
const isFrText = (s: string) => /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
const isEnText = (s: string) => /\b(the|a|an|and|for)\b/i.test(s);
const hasServerFilter = activeFilterCount(filters) > 0;
if (!hasServerFilter && lang === 'any') return () => true;
return (item: any) => {
if (duration !== 'any') {
const d = this.durationSeconds(item);
if (duration === 'short' && !(d > 0 && d < 240)) return false;
if (duration === 'medium' && !(d >= 240 && d < 1200)) return false;
if (duration === 'long' && !(d >= 1200)) return false;
}
if (type !== 'any') {
const live = this.isLiveItem(item);
const short = this.isShortItem(item);
const channel = this.isChannelItem(item);
const clip = this.isClipItem(item);
if (type === 'live' && !live) return false;
if (type === 'channel' && !channel) return false;
if (type === 'short' && !(short || clip) && !live && !channel) {
// Les clips Twitch (kind='clip', parfois sans durée) comptent comme Shorts.
if (!clip) return false;
}
if (type === 'short' && (live || channel)) return false;
// 'video' / 'recorded' = VODs (ni live, ni short/clip, ni chaîne).
if ((type === 'video' || type === 'recorded') && (live || short || clip || channel)) return false;
}
if (lang !== 'any') {
const text = `${item?.title || ''} ${item?.channelName || ''}`.toLowerCase();
if (lang === 'fr' && !isFrText(text)) return false;
if (lang === 'en' && !(isEnText(text) && !isFrText(text))) return false;
}
if (cutoff > 0 && this.publishedTimestamp(item) < cutoff) return false;
return true;
if (hasServerFilter && !matchesFilters(item, filters)) return false;
if (lang === 'any') return true;
const text = `${item?.title || ''} ${item?.channelName || ''}`.toLowerCase();
if (lang === 'fr') return this.isFrText(text);
return this.isEnText(text) && !this.isFrText(text);
};
}
@@ -633,19 +484,66 @@ export class SearchComponent {
return Array.isArray(list) ? list.filter(predicate) : [];
}
/**
* Écrit un filtre : l'URL devient la source de vérité (donc la requête
* serveur repart avec le nouveau périmètre) et le signal local suit.
*/
setFilter(key: keyof SearchFilters, value: string): void {
const next = normalizeFilters({ ...this.filters(), [key]: value } as Partial<SearchFilters>);
// Reflets des <select> locaux.
if (key === 'duration') this.durationFilter.set(next.duration);
if (key === 'type') this.typeFilter.set(next.type);
if (key === 'period') this.dateFilter.set(next.period);
if (key === 'sort') this.sortParam.set(next.sort as 'relevance' | 'date' | 'views');
const cleanParams = this.cleanUrlParams({
...this.route.snapshot.queryParams,
...filtersToQueryParams(next),
page: 1,
});
// Supprime les clés devenues 'all' (elles ne sont plus réinjectées).
for (const k of ['type', 'duration', 'period', 'sort']) {
if (!filtersToQueryParams(next)[k]) delete (cleanParams as any)[k];
}
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
replaceUrl: true,
});
}
/** Retire un filtre depuis une pastille. */
clearFilter(key: keyof SearchFilters): void {
void this.setFilter(key, key === 'sort' ? 'relevance' : 'all');
}
clearAllFilters(): void {
const next = { ...DEFAULT_FILTERS };
this.filters.set(next);
this.durationFilter.set('all');
this.typeFilter.set('all');
this.dateFilter.set('all');
this.sortParam.set('relevance');
this.unified.setFilters(next);
const cleanParams = this.cleanUrlParams({ ...this.route.snapshot.queryParams, page: 1 });
for (const k of ['type', 'duration', 'period', 'sort']) delete (cleanParams as any)[k];
this.router.navigate([], {
relativeTo: this.route, queryParams: cleanParams, replaceUrl: true,
});
}
onDurationChange(event: Event) {
this.durationFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
void this.setFilter('duration', ((event.target as HTMLSelectElement)?.value as any) || 'all');
}
onTypeChange(event: Event) {
this.typeFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
void this.setFilter('type', ((event.target as HTMLSelectElement)?.value as any) || 'all');
}
onLanguageChange(event: Event) {
this.languageFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
}
onDateChange(event: Event) {
this.dateFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
void this.setFilter('period', ((event.target as HTMLSelectElement)?.value as any) || 'all');
}
private sortVideos(list: any[]): any[] {
if (!Array.isArray(list)) {
return [];
@@ -771,30 +669,37 @@ export class SearchComponent {
}
// Submit handler from the embedded SearchBox: keep URL as source of truth
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all' }) {
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all'; filters?: SearchFilters }) {
const q = (evt?.q || '').trim();
if (!q) return;
// Créer un nouvel objet de paramètres de requête
const queryParams: any = {
q,
// Les filtres actifs sont conservés : relancer une recherche ne doit pas
// perdre le périmètre choisi.
const filters = evt?.filters ? normalizeFilters(evt.filters) : this.filters();
const queryParams: any = {
...this.route.snapshot.queryParams,
q,
p: this.providerParam() || this.instances.selectedProvider(),
page: 1 // reset pagination on new search
...filtersToQueryParams(filters),
page: 1, // reset pagination on new search
};
for (const k of ['type', 'duration', 'period', 'sort']) {
if (!filtersToQueryParams(filters)[k]) delete queryParams[k];
}
// Gérer les fournisseurs
if (Array.isArray(evt.providers)) {
queryParams.providers = evt.providers.join(',');
} else if (evt.providers === 'all') {
queryParams.providers = 'yt,dm,tw,pt,od,ru';
}
// Nettoyer les paramètres avant la navigation
const cleanParams = this.cleanUrlParams(queryParams);
// Utiliser replaceUrl: true pour éviter d'ajouter une entrée dans l'historique
// et forcer le rechargement avec les nouveaux paramètres
this.router.navigate([], {
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
@@ -819,11 +724,6 @@ export class SearchComponent {
// Vous pouvez ajouter ici la logique de recherche au fur et à mesure si nécessaire
}
// Update active filter tag from the template
setFilterTag(key: string) {
this.filterTag.set(key);
}
// Infinite-scroll pagination: append the next page WITHOUT touching the
// router URL. Navigating (even with replaceUrl) rewrites the location hash
// (HashLocationStrategy) and remounts list state, which throws the user
@@ -866,21 +766,10 @@ export class SearchComponent {
});
}
// Sort control
// Sort control -> même chemin que les autres filtres (URL + service)
onSortChange(event: Event) {
const value = (event.target as HTMLSelectElement)?.value as 'relevance' | 'date' | 'views' | '';
const sort = (value === 'date' || value === 'views') ? value : 'relevance';
const cleanParams = this.cleanUrlParams({
...this.route.snapshot.queryParams,
sort,
page: 1
});
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
replaceUrl: true
});
const value = (event.target as HTMLSelectElement)?.value as FilterSort | '';
void this.setFilter('sort', (value === 'date' || value === 'views') ? value : 'relevance');
}
// CTA handler: navigate to focus on a single provider while preserving q