From f6fdf8ee0830762ede4b6ea8ed312af57f8fe6d6 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 16:26:29 -0400 Subject: [PATCH] feat(filtres): barre [Filtres] [Tri] unique sur recherche et themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 1 + src/app/search/filters.ts | 81 +++++- .../filter-toolbar.component.html | 221 +++++++++++++++ .../filter-toolbar.component.ts | 160 +++++++++++ src/components/search/search.component.html | 260 ++---------------- src/components/search/search.component.ts | 186 +++---------- .../themes/provider-theme-page.component.html | 79 +----- .../themes/provider-theme-page.component.ts | 144 ++++------ .../themes/theme-page.component.html | 36 +-- src/components/themes/theme-page.component.ts | 100 ++++++- src/services/i18n.service.ts | 12 +- 11 files changed, 698 insertions(+), 582 deletions(-) create mode 100644 src/app/shared/components/filter-toolbar/filter-toolbar.component.html create mode 100644 src/app/shared/components/filter-toolbar/filter-toolbar.component.ts diff --git a/README.md b/README.md index ef5abdc..fc0fead 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,7 @@ Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc. * ✅ Auth légère (JWT), rate-limit API * ✅ **Recherche unifiée multi-providers** — panneau de filtres (sources / type / période / durée / tri, Ctrl/⌘+Maj+F), autocomplete `@` + opérateurs `live:`/`today:`, deep-links `?providers=…&type=…&period=…`, préférence `defaultProviders` persistée, filtres serveur, télémétrie minimale, a11y (focus trap, Esc, clavier complet sur le panneau de suggestions) * ✅ **Recherche unifiée multi-providers** — panneau de filtres (sources / type / période / durée / tri, Ctrl/⌘+Maj+F), autocomplete `@` + opérateurs `live:`/`today:`, deep-links `?providers=…&type=…&period=…`, préférence `defaultProviders` persistée, filtres serveur, télémétrie minimale, a11y (focus trap, Esc, clavier complet sur le panneau de suggestions) +* ✅ **Filtres & tri unifiés** — barre partagée **[Filtres] [Tri]** sur la page de recherche ET sur les pages thèmes (pastilles de type, ligne Sources **Toutes / Aucune**, période / durée / langue, filtres actifs en pastilles) ; bouton « filtres rapides » supprimé, tout est sous [Filtres]. Tri : Pertinence, Date de mise en ligne, Vues, **Note (likes)**, **Durée** (les deux derniers réordonnent côté client) * ✅ Navigation par thèmes (Trending, Live, Gaming, News, Finance, Tech, Science, Health, Music, Podcasts, Movies/TV, Education, Travel, Food, DIY, Auto…) * ⏳ **Abonnements** (routes + DB) * ⏳ **Tags** & recherche par tags diff --git a/src/app/search/filters.ts b/src/app/search/filters.ts index 95aed02..c476acd 100644 --- a/src/app/search/filters.ts +++ b/src/app/search/filters.ts @@ -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 | 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 | 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; diff --git a/src/app/shared/components/filter-toolbar/filter-toolbar.component.html b/src/app/shared/components/filter-toolbar/filter-toolbar.component.html new file mode 100644 index 0000000..ce36a63 --- /dev/null +++ b/src/app/shared/components/filter-toolbar/filter-toolbar.component.html @@ -0,0 +1,221 @@ + +
+ + + +
+ + +@if (panel() === 'filters') { +
+ + @if (chips().length > 0 || (counts !== null && counts.shown !== counts.total)) { +
+ @if (chips().length > 0) { + Actifs + @for (chip of chips(); track chip.key) { + + } + + } + @if (counts && counts.shown !== counts.total) { + {{ counts.shown }} / {{ counts.total }} + } +
+ } + + +
+
+ @for (kind of pillOrder; track kind) { + @if (pillVisible(kind)) { + + } + } +
+
+ + + @if (showSources) { +
+
+ Sources + + + +
+
+ @for (s of srcs; track s.id) { + + } + @if (canQueryAllSources) { + + } +
+
+
+ } + + +
+
+ + + +
+ +
+ + + +
+ + @if (showLanguage) { +
+ + + +
+ } +
+ + @if (hint) { +

{{ hint }}

+ } +
+} + + +@if (panel() === 'tri') { +
+
+ @for (opt of sortOptions(); track opt.value) { + + } +
+

+ {{ 'filters.sortHint' | t: 'Pertinence garde l’ordre des sources ; les autres tris réordonnent les résultats affichés.' }} +

+
+} diff --git a/src/app/shared/components/filter-toolbar/filter-toolbar.component.ts b/src/app/shared/components/filter-toolbar/filter-toolbar.component.ts new file mode 100644 index 0000000..8bfd984 --- /dev/null +++ b/src/app/shared/components/filter-toolbar/filter-toolbar.component.ts @@ -0,0 +1,160 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslatePipe } from '../../../../pipes/translate.pipe'; +import { ProviderIconComponent } from '../provider-icon/provider-icon.component'; +import { + DEFAULT_FILTERS, filterGroups, formatCount, normalizeFilters, + type SearchFilters, type FilterSort, +} from '../../../search/filters'; + +/** Une source affichable dans la ligne « Sources ». */ +export interface ToolbarSource { + /** Clé provider (courte `yt` ou longue `youtube` : l'icône normalise). */ + id: string; + label: string; + visible: boolean; + /** Compteur compact optionnel (ex. « 1.3K »). */ + countLabel?: string; +} + +/** Type de pastille de contenu (même vocabulaire que `SearchFilters.type`). */ +export type PillKind = 'all' | 'live' | 'video' | 'shorts' | 'channel'; + +/** + * Barre de filtres + tri partagée : page de recherche et pages thèmes rendent + * EXACTEMENT le même contrôle (deux boutons, deux panneaux), seul le câblage + * diffère (URL côté recherche, signaux locaux côté thèmes). + * + * - [Filtres] : pastilles de type, ligne Sources (Toutes / Aucune), selects + * période / durée / langue, pastilles des filtres actifs. + * - [Tri] : Pertinence, Date de mise en ligne, Vues, Note, Durée. + */ +@Component({ + selector: 'app-filter-toolbar', + standalone: true, + imports: [CommonModule, TranslatePipe, ProviderIconComponent], + templateUrl: './filter-toolbar.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'mt-2 block w-full' }, +}) +export class FilterToolbarComponent { + /** Filtres appliqués (le composant ne détient aucun état de filtre). */ + @Input() filters: SearchFilters = { ...DEFAULT_FILTERS }; + /** Langue (filtre local, absent des `SearchFilters`). */ + @Input() language: 'any' | 'en' | 'fr' = 'any'; + @Input() showLanguage = true; + /** Compteurs par type pour les pastilles (null = pas de compteur). */ + @Input() typeCounts: { all: number; live: number; video: number; clip: number; channel: number } | null = null; + /** Masque une pastille dont aucun résultat / fournisseur ne parle. */ + @Input() canShowPill: ((kind: Exclude) => boolean) | null = null; + /** Sources (null = pas de section Sources : page à fournisseur unique). */ + @Input() sources: ToolbarSource[] | null = null; + /** Section Sources affichée (le template n'aime pas les unions null). */ + get showSources(): boolean { return this.sources !== null; } + get srcs(): ToolbarSource[] { return this.sources || []; } + /** État des pastilles « Toutes » / « Aucune » (les templates n'ont pas de flèches). */ + get allSourcesVisible(): boolean { return this.srcs.length > 0 && this.srcs.every(s => s.visible); } + get noSourceVisible(): boolean { return this.srcs.length > 0 && this.srcs.every(s => !s.visible); } + /** Bouton « interroger toutes les sources » (la requête est trop restreinte). */ + @Input() canQueryAllSources = false; + /** Compteur « X / Y » de la ligne des filtres actifs (null = masqué). */ + @Input() counts: { shown: number; total: number } | null = null; + /** Ligne d'aide sous les selects (texte adapté par la page). */ + @Input() hint: string | null = null; + + @Output() filterChange = new EventEmitter<{ key: keyof SearchFilters; value: string }>(); + @Output() languageChange = new EventEmitter<'any' | 'en' | 'fr'>(); + @Output() clearAll = new EventEmitter(); + @Output() sourceChange = new EventEmitter<{ action: 'all' | 'none' | 'toggle' | 'queryAll'; id?: string }>(); + + /** Panneau ouvert : un seul à la fois (Filtres OU Tri). */ + readonly panel = signal<'filters' | 'tri' | null>(null); + + readonly pillOrder: PillKind[] = ['all', 'live', 'video', 'shorts', 'channel']; + + private readonly n = computed(() => normalizeFilters(this.filters)); + readonly groups = computed(() => filterGroups(this.n())); + readonly activeCount = computed(() => { + const f = this.n(); + let c = 0; + if (f.type !== 'all') c++; + if (f.duration !== 'all') c++; + if (f.period !== 'all') c++; + return c; + }); + readonly sortActive = computed(() => this.n().sort !== 'relevance'); + readonly sortLabel = computed(() => { + const g = this.groups().find(x => x.key === 'sort'); + return g?.options.find(o => o.selected)?.label || 'Pertinence'; + }); + /** Options du groupe « tri » (même source de vérité que le panneau filtres). */ + readonly sortOptions = computed(() => { + const g = this.groups().find(x => x.key === 'sort'); + return (g?.options || []).map(o => ({ + value: o.value as FilterSort, key: o.key, label: o.label, selected: o.selected, + })); + }); + /** Pastilles des filtres actifs (retrait en un clic). */ + readonly chips = computed(() => { + const f = this.n(); + const out: { key: keyof SearchFilters; label: string }[] = []; + for (const g of this.groups()) { + const k = g.key as keyof SearchFilters; + if (k === 'sort') continue; // le tri a son propre bouton + if (f[k] === 'all') continue; + const opt = g.options.find(o => o.value === f[k]); + if (opt) out.push({ key: k, label: opt.label }); + } + return out; + }); + readonly activeFilterCount = computed(() => this.chips().length); + + optionsOf(key: 'period' | 'duration'): { value: string; label: string; key: string }[] { + return this.groups().find(g => g.key === key)?.options || []; + } + + pillCount(kind: PillKind): number | null { + return this.typeCounts ? (this.typeCounts as any)[kind] ?? 0 : null; + } + + /** Compteur compact d'une pastille (« 1.3K »). */ + fmt(n: number): string { + return formatCount(n); + } + + pillVisible(kind: PillKind): boolean { + if (kind === 'all') return true; + if (this.typePillSelected(kind)) return true; // toujours atteignable si déjà choisi + return this.canShowPill ? this.canShowPill(kind as Exclude) : true; + } + + typePillSelected(kind: PillKind): boolean { + return this.n().type === kind; + } + + /** Clic sur une pastille de type : réémet la valeur (déjà normalisée). */ + setType(kind: PillKind): void { + this.filterChange.emit({ key: 'type', value: kind }); + } + + setSort(sort: FilterSort): void { + this.filterChange.emit({ key: 'sort', value: sort }); + } + + onPeriodChange(ev: Event): void { + this.filterChange.emit({ key: 'period', value: (ev.target as HTMLSelectElement)?.value || 'all' }); + } + onDurationChange(ev: Event): void { + this.filterChange.emit({ key: 'duration', value: (ev.target as HTMLSelectElement)?.value || 'all' }); + } + onLanguageChange(ev: Event): void { + this.languageChange.emit(((ev.target as HTMLSelectElement)?.value || 'any') as 'any' | 'en' | 'fr'); + } + + togglePanel(which: 'filters' | 'tri'): void { + this.panel.update(p => (p === which ? null : which)); + } + closePanel(): void { + this.panel.set(null); + } +} diff --git a/src/components/search/search.component.html b/src/components/search/search.component.html index 1abfc7e..0f75441 100644 --- a/src/components/search/search.component.html +++ b/src/components/search/search.component.html @@ -8,53 +8,29 @@ @if (hasQuery()) { - + {{ countLabel(filteredResults().length) }} résultat{{ filteredResults().length === 1 ? '' : 's' }} } - - @if (hasQuery()) { -
- - - - -
- } + + + @if (hasQuery()) { + + } @if (notice()) { @@ -69,203 +45,13 @@ } @if (hasQuery()) { - - - @if (railsOpen()) { -
- -
-
- - - - - -
-
- -
- - -
- Sources -
-
- @for (chip of providerChips(); track chip.id) { - - } - @if (!allProvidersVisible()) { - - } - @if (!allSourcesActive()) { - - } -
-
-
+ + @if (visibleProviders().length === 0 && !loading()) { +
+ Aucune source sélectionnée — réactivez au moins un fournisseur dans « Filtres ».
} - - @if (filtersOpen()) { -
- @if (activeFilterChips().length > 0) { -
- Actifs - @for (chip of activeFilterChips(); track chip.key) { - - } - - - {{ countLabel(filteredResults().length) }} / {{ countLabel(scopedVideos().length) }} - -
- } - -
-
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
-
- -

- {{ '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 16c56f9..e91d6a6 100644 --- a/src/components/search/search.component.ts +++ b/src/components/search/search.component.ts @@ -9,15 +9,17 @@ import { HistoryService } from '../../services/history.service'; import { Title } from '@angular/platform-browser'; import { TranslatePipe } from '../../pipes/translate.pipe'; import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component'; +import { FilterToolbarComponent } from 'src/app/shared/components/filter-toolbar/filter-toolbar.component'; import { ProviderIconComponent } from 'src/app/shared/components/provider-icon/provider-icon.component'; import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component'; import { VideoItem } from 'src/app/shared/models/video-item.model'; import { SearchService, type SearchResponse } from '../../app/search/search.service'; import { DEFAULT_FILTERS, activeFilterCount, filtersToQueryParams, filtersFromQueryParams, - normalizeFilters, matchesFilters, filterGroups, itemDurationSec, itemPublishedTs, + normalizeFilters, matchesFilters, matchesLanguage, formatCount, + sortItems, isLiveItem, isShortItem, isChannelItem, isClipItem, - type SearchFilters, type FilterDuration, type FilterPeriod, type FilterType, type FilterSort, + type SearchFilters, type FilterSort, } from '../../app/search/filters'; import { UserService, type UserPreferences } from '../../services/user.service'; import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry'; @@ -32,24 +34,13 @@ const MIN_QUERY_LENGTH = 2; 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'; -const STORAGE_RAILS_OPEN = 'newtube:search.railsOpen'; - -/** 1 284 -> « 1.3K » : garde les pastilles étroites sans perdre l'info. */ -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`; -} @Component({ selector: 'app-search', standalone: true, templateUrl: './search.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, TranslatePipe, SearchResultGridComponent, InfiniteAnchorComponent, ProviderIconComponent] + imports: [CommonModule, TranslatePipe, SearchResultGridComponent, InfiniteAnchorComponent, FilterToolbarComponent] }) export class SearchComponent { private route = inject(ActivatedRoute); @@ -74,7 +65,7 @@ export class SearchComponent { // Step 7: default providers loaded from user preferences (server), applied when URL has none private defaultProvidersPref = signal(null); pageParam = signal(1); - sortParam = signal<'relevance' | 'date' | 'views'>('relevance'); + sortParam = signal('relevance'); /** * Filtres de recherche. Ils vivent dans l'URL (`?type=&duration=&period=&sort=`) * ET sont poussés dans `SearchService` : le serveur filtre vraiment ses @@ -141,14 +132,6 @@ export class SearchComponent { * 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()); - /** - * Rail « types + sources » visible. Sur un écran chargé, on peut le replier - * pour ne garder que le titre et la grille — c'est un réglage de confort - * d'affichage, d'où la persistance locale. - */ - railsOpen = signal(this.loadRailsOpen()); private readStorage(key: string): string | null { try { return localStorage.getItem(key); } catch { return null; } @@ -171,29 +154,33 @@ export class SearchComponent { } catch { return []; } } - private loadFlags(key: string): boolean { - return this.readStorage(key) === '1'; - } - private loadFiltersOpen(): boolean { return this.loadFlags(STORAGE_FILTERS_OPEN); } - private loadRailsOpen(): boolean { return this.loadFlags(STORAGE_RAILS_OPEN); } - 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'); + // ---- Barre partagée [Filtres] [Tri] (app-filter-toolbar) ---- + + /** Clic dans le panneau Filtres : l'URL reste la source de vérité. */ + onToolbarFilter(ev: { key: keyof SearchFilters; value: string }): void { + void this.setFilter(ev.key, ev.value); } - toggleRails(): void { - this.railsOpen.set(!this.railsOpen()); - this.writeStorage(STORAGE_RAILS_OPEN, this.railsOpen() ? '1' : '0'); + /** Clic dans la ligne Sources : afficher / masquer (filtre d'affichage local). */ + onSourceChange(ev: { action: 'all' | 'none' | 'toggle' | 'queryAll'; id?: string }): void { + if (ev.action === 'all') { this.showAllProviders(); return; } + if (ev.action === 'none') { this.hideAllProviders(); return; } + if (ev.action === 'queryAll') { this.enableAllSources(); return; } + if (ev.id) this.toggleProvider(ev.id as ProviderId); } - /** Un filtre de type est actif : signale son existence même rails repliés. */ - typeFilterActive = computed(() => this.filters().type !== 'all'); + /** Pastilles de la ligne Sources, au format de la barre partagée. */ + sourceChips = computed(() => this.providerChips().map(c => ({ + id: c.id, label: c.displayName, visible: c.visible, countLabel: c.countLabel, + }))); + + /** Affiche une pastille de type seulement si elle a un sens (phase 7.5). */ + readonly pillVisibleFn = (kind: 'live' | 'video' | 'shorts' | 'channel'): boolean => this.canShowPill(kind); // ---- Pastilles fournisseurs = filtres d'affichage ---- @@ -210,12 +197,12 @@ export class SearchComponent { } /** - * 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. + * Bascule l'affichage d'une source. La dernière source MASQUABLE l'est + * vraiment (bouton « Aucune ») : un état vide affiche un message explicite, + * l'utilisateur réactive ensuite 1 ou 2 fournisseurs. */ 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); @@ -223,6 +210,12 @@ export class SearchComponent { this.persistHiddenProviders(); } + /** Masque toutes les sources (bouton « Aucune » du panneau Filtres). */ + hideAllProviders(): void { + this.hiddenProviders.set([...this.activeProviders()]); + this.persistHiddenProviders(); + } + showAllProviders(): void { if (this.allProvidersVisible()) return; this.hiddenProviders.set([]); @@ -304,29 +297,9 @@ export class SearchComponent { // Public computed used by the template to avoid referencing private `instances` selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider()); - // 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'); + // Seule dimension purement locale : aucun provider ne filtre par langue. + // Type / durée / période / tri vivent dans l'URL (`filters`). languageFilter = signal<'any' | 'en' | 'fr'>('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(() => { @@ -497,12 +470,8 @@ export class SearchComponent { // 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.sortParam.set(filters.sort); 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.length >= MIN_QUERY_LENGTH) { this.notice.set(null); @@ -600,21 +569,6 @@ export class SearchComponent { 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). - 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; - if (typeof raw === 'number') { - return Number.isFinite(raw) ? raw : 0; - } - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : 0; - } - // ---- Filter bar (same UX as the provider/theme page) ---- // Les 4 dimensions principales vivent dans l'URL (filtres serveur) ; la // langue reste purement locale (aucun provider ne filtre par langue). @@ -650,12 +604,12 @@ export class SearchComponent { return this.activeProviders().length > 0; } - canShowPill(kind: 'live' | 'video' | 'clip' | 'channel'): boolean { + canShowPill(kind: 'live' | 'video' | 'shorts' | 'channel'): boolean { const counts = this.typeCounts(); if (this.typePill() === kind) return true; // toujours atteignable si déjà sélectionné if (kind === 'live') return counts.live > 0 || this.hasLiveProvider(); if (kind === 'video') return counts.video > 0; - if (kind === 'clip') return counts.clip > 0; + if (kind === 'shorts') return counts.clip > 0; if (kind === 'channel') return counts.channel > 0 || this.hasChannelCapableProvider(); return true; } @@ -665,7 +619,7 @@ export class SearchComponent { return formatCount(n); } - /** Pills Tous / Directs / Vidéos / Clips / Chaînes synchronisées avec typeFilter. */ + /** Pills Tous / Directs / Vidéos / Clips / Chaînes synchronisées avec `filters.type`. */ typePill = computed<'all' | 'live' | 'video' | 'shorts' | 'channel'>(() => { const t = this.filters().type; if (t === 'live') return 'live'; @@ -675,17 +629,6 @@ export class SearchComponent { return 'all'; }); - 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 @@ -698,10 +641,7 @@ export class SearchComponent { if (!hasServerFilter && lang === 'any') return () => true; return (item: any) => { 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); + return matchesLanguage(item, lang); }; } @@ -716,11 +656,7 @@ export class SearchComponent { */ setFilter(key: keyof SearchFilters, value: string): void { const next = normalizeFilters({ ...this.filters(), [key]: value } as Partial); - // Reflets des - - - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- + +
diff --git a/src/components/themes/provider-theme-page.component.ts b/src/components/themes/provider-theme-page.component.ts index 20f4296..cfb0c56 100644 --- a/src/components/themes/provider-theme-page.component.ts +++ b/src/components/themes/provider-theme-page.component.ts @@ -12,7 +12,12 @@ import { DurationPipe } from '../../app/shared/pipes/duration.pipe'; import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component'; import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component'; import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component'; +import { FilterToolbarComponent } from '../../app/shared/components/filter-toolbar/filter-toolbar.component'; import { classifyOnce, type WithKind } from '../../app/shared/utils/section-policy'; +import { + DEFAULT_FILTERS, matchesFilters, matchesLanguage, sortItems, + type SearchFilters, +} from '../../app/search/filters'; import { providerSupportsLive } from '../../app/core/providers/provider-registry'; import { map, of, switchMap, catchError, forkJoin, Observable } from 'rxjs'; import { timeout } from 'rxjs/operators'; @@ -50,7 +55,7 @@ const TWITCH_THEME_KEYWORDS: Record = { standalone: true, templateUrl: './provider-theme-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent, ChannelIdentityComponent, InfiniteAnchorComponent] + imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent, ChannelIdentityComponent, InfiniteAnchorComponent, FilterToolbarComponent] }) export class ProviderThemePageComponent implements OnDestroy { private route = inject(ActivatedRoute); @@ -66,12 +71,10 @@ export class ProviderThemePageComponent implements OnDestroy { // Guard to ignore stale async responses private version = 0; - // Filters state - sort = signal<'recent' | 'viewed' | 'longest'>('recent'); - duration = signal<'any' | 'short' | 'medium' | 'long'>('any'); - type = signal<'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'>('any'); - language = signal<'any' | 'en' | 'fr'>('any'); - date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any'); + // Filtres : exactement le même modèle que la page de recherche (barre + // partagée), appliqués ici sur les résultats déjà chargés. + readonly filters = signal({ ...DEFAULT_FILTERS }); + readonly language = signal<'any' | 'en' | 'fr'>('any'); // Data state allVideos = signal[]>([]); @@ -94,7 +97,7 @@ export class ProviderThemePageComponent implements OnDestroy { /** Listes Twitch par section (filtres hors-type appliqués), façon directory. */ private twitchFilteredNoType(): Video[] { - return this.applyFiltersWithType(this.allVideos(), 'any'); + return this.applyFiltersWithType(this.allVideos(), 'all'); } // Sections Twitch regroupées sur `_kind` pré-calculé (les chaînes hors-ligne @@ -112,21 +115,21 @@ export class ProviderThemePageComponent implements OnDestroy { this.twitchFilteredNoType().filter(v => this.isChannelCard(v)) ); - /** Onglet Twitch synchronisé avec le filtre "type" existant. */ + /** Onglet Twitch synchronisé avec le filtre « type » de la barre partagée. */ twitchTab = computed<'all' | 'live' | 'video' | 'clip' | 'channel'>(() => { - const t = this.type(); + const t = this.filters().type; if (t === 'live') return 'live'; - if (t === 'video' || t === 'recorded') return 'video'; - if (t === 'short') return 'clip'; + if (t === 'video') return 'video'; + if (t === 'shorts') return 'clip'; if (t === 'channel') return 'channel'; return 'all'; }); setTwitchTab(v: 'all' | 'live' | 'video' | 'clip' | 'channel'): void { - if (v === 'all') this.type.set('any'); - else if (v === 'video') this.type.set('video'); - else if (v === 'clip') this.type.set('short'); - else this.type.set(v); + if (v === 'all') this.filters.update(f => ({ ...f, type: 'all' })); + else if (v === 'video') this.filters.update(f => ({ ...f, type: 'video' })); + else if (v === 'clip') this.filters.update(f => ({ ...f, type: 'shorts' })); + else this.filters.update(f => ({ ...f, type: v })); } constructor() { @@ -139,23 +142,18 @@ export class ProviderThemePageComponent implements OnDestroy { // Thème "En direct" : activer le filtre "Live" par défaut et lever les // filtres qui masqueraient les lives (durée/date = any). if (theme === 'live') { - this.type.set('live'); - this.duration.set('any'); - this.date.set('any'); - } else if (this.type() === 'live') { + this.filters.set({ ...DEFAULT_FILTERS, type: 'live' }); + } else if (this.filters().type === 'live') { // En quittant le thème live, ne pas rester bloqué sur le filtre Live. - this.type.set('any'); + this.filters.update(f => ({ ...f, type: 'all' })); } this.resetAndLoad(); }); // Re-apply filters on change effect(() => { - this.sort(); - this.duration(); - this.type(); + this.filters(); this.language(); - this.date(); this.cdr.markForCheck(); }); } @@ -268,7 +266,7 @@ export class ProviderThemePageComponent implements OnDestroy { } private applyFilters(list: Video[]): Video[] { - return this.applyFiltersWithType(list, this.type()); + return this.applyFiltersWithType(list, this.filters().type); } /** @@ -386,72 +384,34 @@ export class ProviderThemePageComponent implements OnDestroy { } } - private applyFiltersWithType(list: Video[], typeOverride: 'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'): Video[] { + private applyFiltersWithType(list: Video[], typeOverride: SearchFilters['type']): Video[] { let arr = [...list]; - const isLiveTheme = this.theme() === 'live'; - const durationFilter = this.duration(); - // Les lives n'ont pas de durée connue (0) : le filtre durée ne s'applique pas sur le thème live. - if (durationFilter !== 'any' && !isLiveTheme) { - arr = arr.filter(v => { - const dur = v.duration || 0; - if (durationFilter === 'short') return dur > 0 && dur < 240; // < 4 min - if (durationFilter === 'medium') return dur >= 240 && dur < 1200; // 4-20 min - if (durationFilter === 'long') return dur >= 1200; // > 20 min - return true; - }); - } - // Filtres de type basés sur `_kind` (classification pré-calculée, plus de - // règle maison ici). Les chaînes hors-ligne gardent leur traitement brut. - const typeFilter = typeOverride; - if (typeFilter !== 'any') { + // Type : classification pré-calculée (`_kind`) — la même que celle des + // badges des cartes et des onglets Twitch. Les chaînes hors-ligne gardent + // leur traitement brut. + if (typeOverride !== 'all') { arr = arr.filter(v => { const type = String(v.type || '').toLowerCase(); - if (typeFilter === 'video') return v._kind === 'video' && type !== 'channel' && (type === 'video' || type === 'recorded'); - if (typeFilter === 'live') return v._kind === 'live'; - if (typeFilter === 'recorded') return v._kind !== 'live' && type !== 'channel' && (type === 'recorded' || (type === 'video' && v._kind !== 'short')); - if (typeFilter === 'short') return v._kind === 'short'; - if (typeFilter === 'channel') return this.isChannelCard(v); + if (typeOverride === 'video') return v._kind === 'video' && type !== 'channel' && (type === 'video' || type === 'recorded'); + if (typeOverride === 'live') return v._kind === 'live'; + if (typeOverride === 'shorts') return v._kind === 'short'; + if (typeOverride === 'channel') return this.isChannelCard(v); return true; }); } else if (this.theme() === 'live') { - // Thème live même si l'utilisateur remet le filtre sur "Any" : LIVE seulement. + // Thème live même si l'utilisateur remet le filtre sur « Tout » : LIVE seulement. arr = arr.filter(v => v._kind === 'live'); } - const langFilter = this.language(); - if (langFilter !== 'any') { - // Simple heuristic, can be improved - const isFr = (s: string) => /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s); - const isEn = (s: string) => /\b(the|a|an|and|for)\b/i.test(s); - arr = arr.filter(v => { - const text = `${v.title} ${v.uploaderName}`.toLowerCase(); - if (langFilter === 'fr') return isFr(text); - if (langFilter === 'en') return isEn(text) && !isFr(text); - return true; - }); - } + // Période + durée + langue : modèle partagé avec la page de recherche + // (`type` neutralisé ci-dessus pour ne pas le filtrer deux fois). + const f = this.filters(); + arr = arr.filter(v => + matchesFilters(v, { ...f, type: 'all' }) && matchesLanguage(v, this.language())); - const dateFilter = this.date(); - if (dateFilter !== 'any') { - const now = Date.now(); - const cutoff = { - day: now - 24 * 3600 * 1000, - week: now - 7 * 24 * 3600 * 1000, - month: now - 30 * 24 * 3600 * 1000, - year: now - 365 * 24 * 3600 * 1000, - }[dateFilter] || 0; - if (cutoff > 0) { - arr = arr.filter(v => (v.uploaded || 0) >= cutoff); - } - } - - const sortOrder = this.sort(); - if (sortOrder === 'recent') arr.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0)); - if (sortOrder === 'viewed') arr.sort((a, b) => (b.views || 0) - (a.views || 0)); - if (sortOrder === 'longest') arr.sort((a, b) => (b.duration || 0) - (a.duration || 0)); - - return arr; + // Tri : même réordonnancement que la recherche (Pertinence = ordre reçu). + return sortItems(arr, f.sort); } changeProvider(p: any) { @@ -460,19 +420,15 @@ export class ProviderThemePageComponent implements OnDestroy { this.router.navigate(['/p', provider, 't', this.theme()]); } - onSortChange(val: any) { this.sort.set(val as any); } - onDurationChange(val: any) { this.duration.set(val as any); } - onTypeChange(val: any) { this.type.set(val as any); } + /** Événement de la barre partagée [Filtres] : écrit dans `filters`. */ + onToolbarFilter(ev: { key: keyof SearchFilters; value: string }): void { + this.filters.update(f => ({ ...f, [ev.key]: ev.value } as SearchFilters)); + } - // Raccourci Vidéos / Shorts synchronisé avec le filtre "type" existant. - contentFilter = computed<'all' | 'videos' | 'shorts'>(() => { - const t = this.type(); - if (t === 'short') return 'shorts'; - if (t === 'video') return 'videos'; - return 'all'; - }); - setContentFilter(v: 'all' | 'videos' | 'shorts'): void { - this.type.set(v === 'shorts' ? 'short' : v === 'videos' ? 'video' : 'any'); + /** « Tout effacer » du panneau Filtres. */ + clearFilters(): void { + this.filters.set({ ...DEFAULT_FILTERS }); + this.language.set('any'); } /** Badges Twitch : LIVE seulement si réellement en direct (pas les chaînes hors-ligne). */ @@ -496,8 +452,6 @@ export class ProviderThemePageComponent implements OnDestroy { shortsCount(): number { return this.allVideos().filter(v => v._kind === 'short').length; } - onLanguageChange(val: any) { this.language.set(val as any); } - onDateChange(val: any) { this.date.set(val as any); } retry() { this.error.set(null); diff --git a/src/components/themes/theme-page.component.html b/src/components/themes/theme-page.component.html index afd9a80..0d9a9c2 100644 --- a/src/components/themes/theme-page.component.html +++ b/src/components/themes/theme-page.component.html @@ -3,27 +3,21 @@ {{ themeLabel() }} - -
- - - -
+ + -
-
+
+

{{ b.label }}

{{ 'themes.viewAll' | t }} @@ -39,7 +33,7 @@
Ce fournisseur ne propose pas de directs.
{{ 'empty.noItems' | t }}
- Aucun {{ contentFilter() === 'shorts' ? 'Short' : 'vidéo' }} pour ce fournisseur — essayez l'autre filtre. + Aucun résultat pour ce fournisseur avec ces filtres — levez-en un dans « Filtres ».
diff --git a/src/components/themes/theme-page.component.ts b/src/components/themes/theme-page.component.ts index 6ef25e1..e7f37fd 100644 --- a/src/components/themes/theme-page.component.ts +++ b/src/components/themes/theme-page.component.ts @@ -11,7 +11,13 @@ import { ProviderBadgeComponent } from '../../app/shared/components/provider-bad import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component'; import { DurationPipe } from '../../app/shared/pipes/duration.pipe'; import { sectionAccepts, classifyOnce, type WithKind } from '../../app/shared/utils/section-policy'; +import { + DEFAULT_FILTERS, formatCount, matchesFilters, matchesLanguage, sortItems, + type SearchFilters, +} from '../../app/search/filters'; +import { toShortProviderId } from '../../app/shared/providers/provider-ids'; import { providerSupportsLive } from '../../app/core/providers/provider-registry'; +import { FilterToolbarComponent } from '../../app/shared/components/filter-toolbar/filter-toolbar.component'; interface ProviderBlock { provider: Provider; @@ -26,7 +32,7 @@ interface ProviderBlock { standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './theme-page.component.html', - imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent, ProviderBadgeComponent, DurationPipe, ChannelIdentityComponent] + imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent, ProviderBadgeComponent, DurationPipe, ChannelIdentityComponent, FilterToolbarComponent] }) export class ThemePageComponent { private route = inject(ActivatedRoute); @@ -42,11 +48,68 @@ export class ThemePageComponent { blocks = signal([]); - // Filtre d'affichage Vidéos / Shorts (les Shorts ne sont plus mélangés sans contrôle). - readonly contentFilter = signal<'all' | 'videos' | 'shorts'>('all'); + // Filtres + tri : identiques à la page de recherche (barre partagée + // app-filter-toolbar), appliqués ici sur les blocs déjà chargés. + readonly filters = signal({ ...DEFAULT_FILTERS }); + readonly language = signal<'any' | 'en' | 'fr'>('any'); + /** Fournisseurs masqués (bouton « Aucune » puis re-sélection à la main). */ + readonly hiddenProviders = signal([]); - setContentFilter(v: 'all' | 'videos' | 'shorts'): void { - this.contentFilter.set(v); + onToolbarFilter(ev: { key: keyof SearchFilters; value: string }): void { + this.filters.update(f => ({ ...f, [ev.key]: ev.value } as SearchFilters)); + } + + onSourceChange(ev: { action: 'all' | 'none' | 'toggle' | 'queryAll'; id?: string }): void { + if (ev.action === 'all') { this.hiddenProviders.set([]); return; } + if (ev.action === 'none') { this.hiddenProviders.set(this.blocks().map(b => b.provider)); return; } + if (ev.action !== 'toggle' || !ev.id) return; + const p = toShortProviderId(ev.id); + const long = this.blocks().map(b => b.provider).find(x => toShortProviderId(x) === p); + if (!long) return; + this.hiddenProviders.update(h => (h.includes(long) ? h.filter(x => x !== long) : [...h, long])); + } + + /** Blocs visibles (la ligne Sources masque des blocs entiers). */ + readonly visibleBlocks = computed(() => + this.blocks().filter(b => !this.hiddenProviders().includes(b.provider))); + + /** Ligne Sources de la barre partagée. */ + readonly sourceChips = computed(() => this.blocks().map(b => ({ + id: toShortProviderId(b.provider) || b.provider, + label: this.labelFor(b.provider), + visible: !this.hiddenProviders().includes(b.provider), + countLabel: formatCount(b.items.length), + }))); + + /** Compteurs par type (pastilles), sur les blocs visibles. */ + readonly typeCounts = computed(() => { + let live = 0, video = 0, clip = 0, channel = 0, all = 0; + for (const b of this.visibleBlocks()) { + for (const v of b.items) { + all++; + if (this.isChannelCard(v)) { channel++; continue; } + if (this.isLiveCard(v)) { live++; continue; } + if (this.isClipCard(v)) { clip++; continue; } + video++; + } + } + return { all, live, video, clip, channel }; + }); + + /** « X / Y » du panneau Filtres (résultats visibles / chargés). */ + readonly resultCounts = computed(() => { + let shown = 0, total = 0; + for (const b of this.visibleBlocks()) { + total += b.items.length; + shown += this.filteredItems(b).length; + } + return { shown, total }; + }); + + /** « Tout effacer » du panneau Filtres. */ + clearFilters(): void { + this.filters.set({ ...DEFAULT_FILTERS }); + this.language.set('any'); } /** Fournisseurs capables de fournir de vrais directs (source unique = registre). */ @@ -63,18 +126,33 @@ export class ThemePageComponent { ); } - /** Items du bloc après filtre Vidéos/Shorts (+ LIVE seulement sur thème live). */ + /** Items du bloc après les filtres de la barre partagée, puis le tri courant. */ filteredItems(b: ProviderBlock): Video[] { let items: Video[] = b.items; // Thème "En direct" : ne jamais mélanger avec des VODs (garde-fou si une API renvoie du non-live). if (this.themeSlug() === 'live') { items = items.filter(v => sectionAccepts(v, 'theme-live')); } - const f = this.contentFilter(); - if (f === 'all') return items; - return items.filter(v => f === 'shorts' - ? sectionAccepts(v, 'theme-shorts') - : sectionAccepts(v, 'theme-videos')); + const f = this.filters(); + const lang = this.language(); + // Le type suit la classification de la page (`_kind`, celle des badges des + // cartes) ; période / durée passent par le modèle partagé avec `type` + // neutralisé pour ne pas le filtrer deux fois avec deux sémantiques. + const rest = { ...f, type: 'all' as const }; + items = items.filter(v => + this.matchesType(v) && matchesFilters(v, rest) && matchesLanguage(v, lang)); + return sortItems(items, f.sort); + } + + /** Type de contenu selon la classification de la page. */ + private matchesType(v: Video): boolean { + switch (this.filters().type) { + case 'channel': return this.isChannelCard(v); + case 'live': return sectionAccepts(v, 'theme-live'); + case 'video': return sectionAccepts(v, 'theme-videos'); + case 'shorts': return sectionAccepts(v, 'theme-shorts'); + default: return true; + } } /** LIVE seulement si réellement en direct (les chaînes hors-ligne ne le sont pas). */ diff --git a/src/services/i18n.service.ts b/src/services/i18n.service.ts index a94cf0f..e37c148 100644 --- a/src/services/i18n.service.ts +++ b/src/services/i18n.service.ts @@ -156,8 +156,12 @@ const EN: Record = { 'filters.duration.long': 'Long > 20 min', 'filters.sort': 'Sort by', 'filters.sort.relevance': 'Relevance', - 'filters.sort.date': 'Date', + 'filters.sort.date': 'Upload date', 'filters.sort.views': 'Views', + 'filters.sort.rating': 'Rating (likes)', + 'filters.sort.duration': 'Duration', + 'filters.sortHint': 'Relevance keeps the source order; the other options reorder the displayed results.', + 'filters.localHint': 'Filters and sorting apply to the results already loaded.', 'filters.reset': 'Reset', 'filters.cancel': 'Cancel', 'filters.apply': 'Apply', @@ -347,8 +351,12 @@ const FR: Record = { 'filters.duration.long': 'Longue > 20 min', 'filters.sort': 'Trier par', 'filters.sort.relevance': 'Pertinence', - 'filters.sort.date': 'Date', + 'filters.sort.date': 'Date de mise en ligne', 'filters.sort.views': 'Vues', + 'filters.sort.rating': 'Note (likes)', + 'filters.sort.duration': 'Durée', + 'filters.sortHint': 'Pertinence garde l’ordre des sources ; les autres tris réordonnent les résultats affichés.', + 'filters.localHint': 'Les filtres et le tri s’appliquent aux résultats déjà chargés.', 'filters.reset': 'Réinitialiser', 'filters.cancel': 'Annuler', 'filters.apply': 'Appliquer',