feat(search): panneau de filtres unifié, clavier/focus du typeahead
CI / build-and-test (push) Successful in 14m10s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user