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
+76 -5
View File
@@ -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;
@@ -0,0 +1,221 @@
<!-- Barre partagée [Filtres] [Tri] — rendu identique sur la page de recherche
et les pages thèmes (seul le câblage des sorties change).
Un seul panneau ouvert à la fois ; Échap referme. -->
<div class="flex flex-wrap items-center justify-end gap-1.5">
<button type="button" (click)="togglePanel('filters')"
class="chip inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="panel() === 'filters' ? 'border-transparent bg-slate-200 text-slate-900' : 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700'"
[attr.aria-expanded]="panel() === 'filters'"
aria-controls="filter-toolbar-panel">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 6h18M7 12h10M11 18h2" />
</svg>
Filtres
@if (activeFilterCount() > 0) {
<span class="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold leading-none text-white">
{{ activeFilterCount() }}
</span>
}
<svg class="h-3.5 w-3.5 transition-transform duration-150" [class.rotate-180]="panel() === 'filters'"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<button type="button" (click)="togglePanel('tri')"
class="chip inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="panel() === 'tri' ? 'border-transparent bg-slate-200 text-slate-900' : 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700'"
[attr.aria-expanded]="panel() === 'tri'"
aria-controls="filter-toolbar-sort"
[attr.title]="'Trier : ' + sortLabel()">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h13M3 12h9M3 18h5M17 14l3 3 3-3M20 17V7" />
</svg>
Tri
<span class="hidden text-slate-400 sm:inline" [class.text-slate-500]="panel() === 'tri'">: {{ sortLabel() }}</span>
@if (sortActive() && panel() !== 'tri') {
<span class="h-1.5 w-1.5 rounded-full" style="background-color: var(--accent)"></span>
}
<svg class="h-3.5 w-3.5 transition-transform duration-150" [class.rotate-180]="panel() === 'tri'"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<!-- ============ Panneau Filtres ============ -->
@if (panel() === 'filters') {
<div id="filter-toolbar-panel" class="mt-2 rounded-xl border border-slate-700 bg-slate-800/50 p-2.5">
<!-- Filtres actifs : rappel + retrait en un clic -->
@if (chips().length > 0 || (counts !== null && counts.shown !== counts.total)) {
<div class="mb-2.5 flex flex-wrap items-center gap-1.5">
@if (chips().length > 0) {
<span class="pl-1 text-[11px] font-medium uppercase tracking-wide text-slate-400">Actifs</span>
@for (chip of chips(); track chip.key) {
<button type="button" (click)="filterChange.emit({ key: chip.key, value: 'all' })"
class="chip inline-flex h-6 items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-700 bg-slate-800 pl-2.5 pr-2 text-[12px] text-slate-200 hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]">
<span class="h-1.5 w-1.5 shrink-0 rounded-full" style="background-color: var(--accent)"></span>
{{ chip.label }}
<svg class="h-3 w-3 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
}
<button type="button" (click)="clearAll.emit()"
class="chip h-6 whitespace-nowrap rounded-full px-2 text-[12px] text-slate-400 underline decoration-dotted underline-offset-2 hover:text-slate-200">
Tout effacer
</button>
}
@if (counts && counts.shown !== counts.total) {
<span class="ml-auto text-[12px] tabular-nums text-slate-400">{{ counts.shown }} / {{ counts.total }}</span>
}
</div>
}
<!-- Types de contenu : sélection unique, façon YouTube (rail défilant). -->
<div class="scroll-rail-fade">
<div class="scroll-rail no-scrollbar flex items-center gap-1.5" role="group" aria-label="Filtrer par type de contenu">
@for (kind of pillOrder; track kind) {
@if (pillVisible(kind)) {
<button type="button" (click)="setType(kind)"
class="chip inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="typePillSelected(kind) ? (kind === 'live' || kind === 'shorts' ? 'border border-transparent bg-purple-600 text-white' : 'border border-transparent bg-slate-200 text-slate-900') : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePillSelected(kind)">
@switch (kind) {
@case ('all') { Tout }
@case ('live') { Directs }
@case ('video') { Vidéos }
@case ('shorts') { Clips }
@case ('channel') { Chaînes }
}
@if (pillCount(kind) !== null) {
<span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ fmt(pillCount(kind)!) }}</span>
}
</button>
}
}
</div>
</div>
<!-- Sources : Toutes / Aucune + une bascule par fournisseur. -->
@if (showSources) {
<div class="my-1.5 h-px bg-slate-700"></div>
<div class="flex flex-wrap items-center gap-1.5">
<span class="shrink-0 pl-2 text-[11px] font-medium uppercase tracking-wide text-slate-400">Sources</span>
<button type="button" (click)="sourceChange.emit({ action: 'all' })"
class="chip inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full border px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="allSourcesVisible ? 'border-transparent bg-slate-200 text-slate-900' : 'border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="allSourcesVisible">
Toutes
</button>
<button type="button" (click)="sourceChange.emit({ action: 'none' })"
class="chip inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full border px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="noSourceVisible ? 'border-transparent bg-slate-200 text-slate-900' : 'border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="noSourceVisible">
Aucune
</button>
<div class="scroll-rail-fade min-w-0 flex-1">
<div class="scroll-rail no-scrollbar flex items-center gap-1.5" role="group" aria-label="Afficher ou masquer une source">
@for (s of srcs; track s.id) {
<button type="button" (click)="sourceChange.emit({ action: 'toggle', id: s.id })"
class="chip inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="s.visible ? 'border-slate-700 bg-slate-800 hover:bg-slate-700' : 'border-slate-700 hover:bg-slate-800'"
[attr.aria-pressed]="s.visible"
[attr.aria-label]="(s.visible ? 'Masquer ' : 'Afficher ') + s.label"
[attr.title]="(s.visible ? 'Masquer ' : 'Afficher ') + s.label + (s.countLabel ? ' — ' + s.countLabel + ' résultat(s)' : '')">
<app-provider-icon [key]="s.id" [size]="20"
[class.opacity-40]="!s.visible"
[class.grayscale]="!s.visible"></app-provider-icon>
</button>
}
@if (canQueryAllSources) {
<button type="button" (click)="sourceChange.emit({ action: 'queryAll' })"
class="chip inline-flex h-8 shrink-0 items-center gap-1 whitespace-nowrap rounded-full border border-slate-700 px-3 text-[13px] text-slate-300 hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" />
</svg>
Toutes les sources
</button>
}
</div>
</div>
</div>
}
<!-- Sélecteurs : période, durée, langue. -->
<div class="mt-2 flex flex-wrap items-center gap-1.5">
<div class="relative">
<label class="sr-only" for="ft-period">{{ 'filters.period' | t: 'Période' }}</label>
<select id="ft-period"
class="h-9 appearance-none rounded-lg border border-slate-700 bg-slate-800 pl-2.5 pr-7 text-[13px] text-slate-200 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
[value]="filters.period" (change)="onPeriodChange($event)">
@for (o of optionsOf('period'); track o.value) {
<option [value]="o.value">{{ o.key | t: o.label }}</option>
}
</select>
<svg class="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
<div class="relative">
<label class="sr-only" for="ft-duration">{{ 'filters.duration' | t: 'Durée' }}</label>
<select id="ft-duration"
class="h-9 appearance-none rounded-lg border border-slate-700 bg-slate-800 pl-2.5 pr-7 text-[13px] text-slate-200 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
[value]="filters.duration" (change)="onDurationChange($event)">
@for (o of optionsOf('duration'); track o.value) {
<option [value]="o.value">{{ o.key | t: o.label }}</option>
}
</select>
<svg class="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
@if (showLanguage) {
<div class="relative">
<label class="sr-only" for="ft-language">{{ 'filter.language' | t: 'Langue' }}</label>
<select id="ft-language"
class="h-9 appearance-none rounded-lg border border-slate-700 bg-slate-800 pl-2.5 pr-7 text-[13px] text-slate-200 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
[value]="language" (change)="onLanguageChange($event)">
<option value="any">{{ 'filter.language.any' | t: 'Toutes langues' }}</option>
<option value="en">{{ 'filter.language.en' | t: 'Anglais' }}</option>
<option value="fr">{{ 'filter.language.fr' | t: 'Français' }}</option>
</select>
<svg class="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
}
</div>
@if (hint) {
<p class="mt-2 pl-1 text-[11px] text-slate-400">{{ hint }}</p>
}
</div>
}
<!-- ============ Panneau Tri ============ -->
@if (panel() === 'tri') {
<div id="filter-toolbar-sort" class="mt-2 rounded-xl border border-slate-700 bg-slate-800/50 p-2.5">
<div class="flex flex-wrap items-center gap-1.5" role="radiogroup" [attr.aria-label]="'filters.sort' | t: 'Trier par'">
@for (opt of sortOptions(); track opt.value) {
<button type="button" role="radio" (click)="setSort(opt.value)"
class="chip inline-flex h-8 items-center gap-1.5 whitespace-nowrap rounded-full border px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="opt.selected ? 'border-transparent bg-slate-200 text-slate-900' : 'border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-checked]="opt.selected">
@if (opt.selected) {
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
}
{{ opt.key | t: opt.label }}
</button>
}
</div>
<p class="mt-2 pl-1 text-[11px] text-slate-400">
{{ 'filters.sortHint' | t: 'Pertinence garde l’ordre des sources ; les autres tris réordonnent les résultats affichés.' }}
</p>
</div>
}
@@ -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<PillKind, 'all'>) => 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<void>();
@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<PillKind, 'all'>) : 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);
}
}