feat(filtres): barre [Filtres] [Tri] unique sur recherche et themes
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:
2026-10-01 16:26:29 -04:00
parent 735255b7d8
commit f6fdf8ee08
11 changed files with 698 additions and 582 deletions
+89 -11
View File
@@ -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<ProviderBlock[]>([]);
// 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<SearchFilters>({ ...DEFAULT_FILTERS });
readonly language = signal<'any' | 'en' | 'fr'>('any');
/** Fournisseurs masqués (bouton « Aucune » puis re-sélection à la main). */
readonly hiddenProviders = signal<Provider[]>([]);
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). */