feat(filtres): barre [Filtres] [Tri] unique sur recherche et themes
CI / build-and-test (push) Successful in 14m54s
CI / build-and-test (push) Successful in 14m54s
Nouveau composant partage app-filter-toolbar, utilise tel quel par la page de recherche ET les deux pages de themes (meme visuel, seul le cablage differe : URL pour la recherche, signaux locaux pour les themes). - Boutons : [Filtres] (pastilles de type, ligne Sources, selects periode / duree / langue, filtres actifs) et [Tri] (Pertinence, Date de mise en ligne, Vues, Note (likes), Duree). - Page de recherche : le bouton oeil « filtres rapides » et le panneau de selects disparaissent, tout est sous [Filtres] ; le tri a son bouton. - Sources : boutons Toutes / Aucune. « Aucune » masque tout (la garde « derniere source masquable » est retiree) et un bandeau vide dit de reactiver au moins un fournisseur. - /t/:theme : les 3 pastilles maison sont remplacees par la barre ; les sources masquent des blocs entiers ; filtres et tri appliques sur les blocs deja charges. - /p/:provider/t/:theme : les 5 selects et les pastilles sont remplaces par la barre ; signaux maison unifies sur le modele SearchFilters (defaut relevance) ; les onglets Twitch lisent/ecrivent filters().type. - Tri etendu : FilterSort + rating + duration. Les providers ne connaissent que relevance/date/views (serveur inchange, repli relevance) : Note et Duree sont reordonnes cote client via sortItems(), partage avec les pages themes. - Communs : sortItems / itemViews / itemRating / matchesLanguage / formatCount extraits de filters.ts (3 copies d'heuristiques supprimees) ; cles i18n fr+en (filters.sort.rating, filters.sort.duration, filters.sortHint, filters.localHint, « Date de mise en ligne »). Tests : test:search, test:filters, test:suggest, test:kind, test:section verts + test:api 41/41, build ng OK.
This commit is contained in:
@@ -15,8 +15,14 @@ export type FilterType = 'all' | 'video' | 'shorts' | 'live' | 'channel';
|
||||
export type FilterDuration = 'all' | 'short' | 'medium' | 'long';
|
||||
/** Période de mise en ligne. */
|
||||
export type FilterPeriod = 'all' | 'hour' | 'today' | 'week' | 'month' | 'year';
|
||||
/** Tri. */
|
||||
export type FilterSort = 'relevance' | 'date' | 'views';
|
||||
/**
|
||||
* Tri.
|
||||
* - `relevance` / `date` / `views` : triés nativement par les providers.
|
||||
* - `rating` / `duration` : inconnus des providers (repli `relevance` côté
|
||||
* serveur, cf. `server/search-filters.mjs`) mais réordonnés côté client par
|
||||
* `sortItems()` : la donnée est déjà dans les résultats.
|
||||
*/
|
||||
export type FilterSort = 'relevance' | 'date' | 'views' | 'rating' | 'duration';
|
||||
|
||||
export interface SearchFilters {
|
||||
type: FilterType;
|
||||
@@ -28,7 +34,7 @@ export interface SearchFilters {
|
||||
export const FILTER_TYPES: FilterType[] = ['all', 'video', 'shorts', 'live', 'channel'];
|
||||
export const FILTER_DURATIONS: FilterDuration[] = ['all', 'short', 'medium', 'long'];
|
||||
export const FILTER_PERIODS: FilterPeriod[] = ['all', 'hour', 'today', 'week', 'month', 'year'];
|
||||
export const FILTER_SORTS: FilterSort[] = ['relevance', 'date', 'views'];
|
||||
export const FILTER_SORTS: FilterSort[] = ['relevance', 'date', 'views', 'rating', 'duration'];
|
||||
|
||||
export const DEFAULT_FILTERS: SearchFilters = {
|
||||
type: 'all',
|
||||
@@ -206,13 +212,24 @@ export function filterGroups(f: Partial<SearchFilters> | null | undefined): Arra
|
||||
key: 'sort', labelKey: 'filters.sort', label: 'Trier par', multi: false,
|
||||
options: [
|
||||
opt('relevance', 'filters.sort.relevance', 'Pertinence'),
|
||||
opt('date', 'filters.sort.date', 'Date'),
|
||||
opt('date', 'filters.sort.date', 'Date de mise en ligne'),
|
||||
opt('views', 'filters.sort.views', 'Vues'),
|
||||
opt('rating', 'filters.sort.rating', 'Note (likes)'),
|
||||
opt('duration', 'filters.sort.duration', 'Durée'),
|
||||
].map(o => ({ ...o, selected: n.sort === o.value })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 1 284 -> « 1.3K » : compteurs compacts (pastilles de type / sources). */
|
||||
export function formatCount(n: number): string {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v) || v <= 0) return '0';
|
||||
if (v < 1000) return String(v);
|
||||
if (v < 1_000_000) return `${(v / 1000).toFixed(v < 10_000 ? 1 : 0).replace('.0', '')}K`;
|
||||
return `${(v / 1_000_000).toFixed(1).replace('.0', '')}M`;
|
||||
}
|
||||
|
||||
/** Résumé court (une ligne) des filtres actifs, pour le bouton sur mobile. */
|
||||
export function filtersSummary(f: Partial<SearchFilters> | null | undefined, groups = filterGroups(f)): string {
|
||||
const n = normalizeFilters(f);
|
||||
@@ -359,7 +376,7 @@ export function itemDurationSec(item: any): number {
|
||||
|
||||
export function itemPublishedTs(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 === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
@@ -367,6 +384,60 @@ export function itemPublishedTs(item: any): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Vues (les providers n'exposent pas le même nom de champ). */
|
||||
export function itemViews(item: any): number {
|
||||
const raw = item?.viewCount ?? item?.views ?? item?.stats?.views ?? item?.metrics?.views ?? 0;
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
/** Note : likes bruts si le provider les expose, sinon le champ `rating`. */
|
||||
export function itemRating(item: any): number {
|
||||
const likes = numOr(item?.likes ?? item?.likeCount ?? item?.likesCount);
|
||||
if (likes > 0) return likes;
|
||||
return numOr(item?.rating);
|
||||
}
|
||||
|
||||
function numOr(v: unknown): number {
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tri partagé (page de recherche + pages thèmes) : la même option produit le
|
||||
* même réordonnancement partout. `relevance` = l'ordre reçu (aucun tri).
|
||||
*/
|
||||
export function sortItems(list: any[], sort: FilterSort): any[] {
|
||||
if (!Array.isArray(list)) return [];
|
||||
if (sort === 'relevance') return list;
|
||||
const arr = [...list];
|
||||
switch (sort) {
|
||||
case 'date': arr.sort((a, b) => itemPublishedTs(b) - itemPublishedTs(a)); break;
|
||||
case 'views': arr.sort((a, b) => itemViews(b) - itemViews(a)); break;
|
||||
case 'rating': arr.sort((a, b) => itemRating(b) - itemRating(a)); break;
|
||||
case 'duration': arr.sort((a, b) => (itemDurationSec(b) || 0) - (itemDurationSec(a) || 0)); break;
|
||||
default: break;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/** Heuristique de langue sur le texte d'une carte (aucun provider ne filtre par langue). */
|
||||
export function isFrText(s: string): boolean {
|
||||
return /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
|
||||
}
|
||||
export function isEnText(s: string): boolean {
|
||||
return /\b(the|a|an|and|for)\b/i.test(s);
|
||||
}
|
||||
/** `lang` 'any' = toujours vrai (miroir de la barre de filtres). */
|
||||
export function matchesLanguage(item: any, lang: 'any' | 'en' | 'fr'): boolean {
|
||||
if (lang === 'any') return true;
|
||||
const text = `${item?.title || ''} ${item?.channelName || ''} ${item?.uploaderName || ''}`.toLowerCase();
|
||||
if (lang === 'fr') return isFrText(text);
|
||||
return isEnText(text) && !isFrText(text);
|
||||
}
|
||||
|
||||
export function isLiveItem(item: any): boolean {
|
||||
if (!item) return false;
|
||||
if (item.isLive === true) return true;
|
||||
|
||||
Reference in New Issue
Block a user