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
+1
View File
@@ -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
+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);
}
}
+23 -237
View File
@@ -8,53 +8,29 @@
</h1>
@if (hasQuery()) {
<span class="shrink-0 text-[13px] tabular-nums text-slate-400" aria-live="polite">
<span class="ml-auto shrink-0 text-[13px] tabular-nums text-slate-400" aria-live="polite">
{{ countLabel(filteredResults().length) }} résultat{{ filteredResults().length === 1 ? '' : 's' }}
</span>
}
@if (hasQuery()) {
<div class="ml-auto flex shrink-0 items-center gap-1.5">
<button type="button" (click)="toggleFilters()"
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]="filtersOpen() ? 'border-transparent bg-slate-200 text-slate-900' : 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700'"
[attr.aria-expanded]="filtersOpen()" aria-controls="search-filters">
<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 (activeFilterChips().length > 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">
{{ activeFilterChips().length }}
</span>
}
<svg class="h-3.5 w-3.5 transition-transform duration-150" [class.rotate-180]="filtersOpen()"
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>
<!-- Replie le rail « types + sources » pour ne garder que la grille. -->
<button type="button" (click)="toggleRails()"
class="chip relative inline-flex h-8 w-8 items-center justify-center rounded-full border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="railsOpen() ? 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700' : 'border-slate-700 text-slate-400 hover:bg-slate-800'"
[attr.aria-expanded]="railsOpen()" aria-controls="search-rails"
[attr.title]="railsOpen() ? 'Masquer les filtres rapides' : 'Afficher les filtres rapides'"
[attr.aria-label]="railsOpen() ? 'Masquer les filtres rapides' : 'Afficher les filtres rapides'">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="2.8" />
@if (!railsOpen()) {
<path d="M4 20 20 4" />
}
</svg>
@if (!railsOpen() && typeFilterActive()) {
<span class="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full" style="background-color: var(--accent)"></span>
}
</button>
</div>
}
</div>
<!-- Un seul accès aux filtres : barre partagée [Filtres] [Tri] (identique
aux pages thèmes). Elle contient les types, les sources et les selects. -->
@if (hasQuery()) {
<app-filter-toolbar
[filters]="filters()"
[language]="languageFilter()"
[typeCounts]="typeCounts()"
[canShowPill]="pillVisibleFn"
[sources]="sourceChips()"
[canQueryAllSources]="!allSourcesActive()"
[counts]="{ shown: filteredResults().length, total: scopedVideos().length }"
[hint]="'filters.serverHint' | t: 'Type, période et durée sont appliqués par les sources ; la langue filtre les résultats affichés.'"
(filterChange)="onToolbarFilter($event)"
(languageChange)="languageFilter.set($event)"
(clearAll)="clearAllFilters()"
(sourceChange)="onSourceChange($event)"></app-filter-toolbar>
}
</header>
@if (notice()) {
@@ -69,203 +45,13 @@
}
@if (hasQuery()) {
<!-- ============ Rail de filtres (types + sources) ============ -->
<!-- Repliable via l'icône œil : seule cette section disparaît, jamais la
grille ni la pagination qui suivent. -->
@if (railsOpen()) {
<div id="search-rails" class="mt-2.5 rounded-xl border border-slate-700 bg-slate-800/50 p-1.5">
<!-- Types de contenu : sélection unique, façon YouTube.
Le rail défile horizontalement (scrollbar masqué) : le fade de droite
signale qu'il reste des pastilles hors champ. -->
<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">
<button type="button" (click)="setTypePill('all')"
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]="typePill() === 'all' ? '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]="typePill() === 'all'">
Tout <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().all) }}</span>
</button>
<button type="button" *ngIf="canShowPill('live')" (click)="setTypePill('live')"
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]="typePill() === 'live' ? 'border border-transparent bg-purple-600 text-white' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'live'">
Directs <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().live) }}</span>
</button>
<button type="button" *ngIf="canShowPill('video')" (click)="setTypePill('video')"
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]="typePill() === 'video' ? '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]="typePill() === 'video'">
Vidéos <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().video) }}</span>
</button>
<button type="button" *ngIf="canShowPill('clip')" (click)="setTypePill('shorts')"
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]="typePill() === 'shorts' ? 'border border-transparent bg-purple-600 text-white' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'shorts'">
Clips <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().clip) }}</span>
</button>
<button type="button" *ngIf="canShowPill('channel')" (click)="setTypePill('channel')"
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]="typePill() === 'channel' ? '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]="typePill() === 'channel'">
Chaînes <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().channel) }}</span>
</button>
</div>
</div>
<div class="my-1.5 h-px bg-slate-700"></div>
<!-- Sources : bascules d'affichage. Boutons icône seule — la marque
hérite de la couleur du bouton, donc l'état actif / masqué se lit
sans pastille supplémentaire. Le libellé et le compte passent par
`title` / `aria-label`. -->
<div class="flex items-center gap-1.5">
<span class="shrink-0 pl-2 text-[11px] font-medium uppercase tracking-wide text-slate-400">Sources</span>
<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 (chip of providerChips(); track chip.id) {
<button type="button" (click)="toggleProvider(chip.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]="chip.visible ? 'border-slate-700 bg-slate-800 hover:bg-slate-700' : 'border-slate-700 hover:bg-slate-800'"
[attr.aria-pressed]="chip.visible"
[attr.aria-label]="(chip.visible ? 'Masquer ' : 'Afficher ') + chip.displayName + ' (' + chip.countLabel + ')'"
[attr.title]="(chip.visible ? 'Masquer ' : 'Afficher ') + chip.displayName + ' — ' + chip.countLabel + ' résultat(s)'">
<!-- Source masquée : logo désaturé, la marque reste identifiable. -->
<app-provider-icon [key]="chip.id" [size]="20"
[class.opacity-40]="!chip.visible"
[class.grayscale]="!chip.visible"></app-provider-icon>
</button>
}
@if (!allProvidersVisible()) {
<button type="button" (click)="showAllProviders()"
class="chip inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full px-3 text-[13px] text-slate-300 underline decoration-dotted underline-offset-2 hover:text-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]">
Tout afficher
<span class="rounded-full bg-slate-700 px-1.5 text-[11px] tabular-nums text-slate-400 no-underline">{{ hiddenProviderCount() }}</span>
</button>
}
@if (!allSourcesActive()) {
<button type="button" (click)="enableAllSources()"
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>
<!-- Aucune source sélectionnée : la grille reste vide jusqu'au prochain clic. -->
@if (visibleProviders().length === 0 && !loading()) {
<div class="mt-2 flex flex-wrap items-center gap-2 rounded-lg border border-dashed border-slate-700 bg-slate-800/40 px-3 py-2 text-[13px] text-slate-300">
<span>Aucune source sélectionnée — réactivez au moins un fournisseur dans « Filtres ».</span>
</div>
}
<!-- ============ Panneau de filtres (repliable) ============ -->
@if (filtersOpen()) {
<div id="search-filters" class="mt-2 rounded-xl border border-slate-700 bg-slate-800/50 p-2.5">
@if (activeFilterChips().length > 0) {
<div class="mb-2.5 flex flex-wrap items-center gap-1.5">
<span class="pl-1 text-[11px] font-medium uppercase tracking-wide text-slate-400">Actifs</span>
@for (chip of activeFilterChips(); track chip.key) {
<button type="button" (click)="clearFilter(chip.key)"
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)="clearAllFilters()"
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>
<span class="ml-auto text-[12px] tabular-nums text-slate-400">
{{ countLabel(filteredResults().length) }} / {{ countLabel(scopedVideos().length) }}
</span>
</div>
}
<div class="flex flex-wrap items-center gap-1.5">
<div class="relative">
<label class="sr-only" for="sortSelect">{{ 'filters.sort' | t: 'Trier par' }}</label>
<select id="sortSelect"
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().sort" (change)="onSortChange($event)">
<option value="relevance">{{ 'filters.sort.relevance' | t: 'Pertinence' }}</option>
<option value="date">{{ 'filters.sort.date' | t: 'Date' }}</option>
<option value="views">{{ 'filters.sort.views' | t: 'Vues' }}</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="period-filter">{{ 'filters.period' | t: 'Période' }}</label>
<select id="period-filter"
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)="onDateChange($event)">
<option value="all">{{ 'filters.period.all' | t: "N'importe quand" }}</option>
<option value="hour">{{ 'filters.period.hour' | t: 'Dernière heure' }}</option>
<option value="today">{{ 'filters.period.today' | t: "Aujourd'hui" }}</option>
<option value="week">{{ 'filters.period.week' | t: 'Cette semaine' }}</option>
<option value="month">{{ 'filters.period.month' | t: 'Ce mois' }}</option>
<option value="year">{{ 'filters.period.year' | t: 'Cette année' }}</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="type-filter">{{ 'filters.type' | t: 'Type de contenu' }}</label>
<select id="type-filter"
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().type" (change)="onTypeChange($event)">
<option value="all">{{ 'filters.type.all' | t: 'Tout' }}</option>
<option value="video">{{ 'filters.type.video' | t: 'Vidéos' }}</option>
<option value="shorts">{{ 'filters.type.shorts' | t: 'Shorts / Clips' }}</option>
<option value="live">{{ 'filters.type.live' | t: 'En direct' }}</option>
<option value="channel">{{ 'filters.type.channel' | t: 'Chaînes' }}</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="duration-filter">{{ 'filters.duration' | t: 'Durée' }}</label>
<select id="duration-filter"
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)">
<option value="all">{{ 'filters.duration.all' | t: 'Toutes durées' }}</option>
<option value="short">{{ 'filters.duration.short' | t: 'Courte < 4 min' }}</option>
<option value="medium">{{ 'filters.duration.medium' | t: 'Moyenne 4-20 min' }}</option>
<option value="long">{{ 'filters.duration.long' | t: 'Longue > 20 min' }}</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="language-filter">{{ 'filter.language' | t: 'Langue' }}</label>
<select id="language-filter"
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]="languageFilter()" (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>
<p class="mt-2 pl-1 text-[11px] text-slate-400">
{{ 'filters.serverHint' | t: 'Type, période et durée sont appliqués par les sources ; la langue filtre les résultats affichés.' }}
</p>
</div>
}
@if (error()) {
<div class="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-[13px] text-slate-200">
+45 -141
View File
@@ -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<ProviderId[] | null>(null);
pageParam = signal<number>(1);
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
sortParam = signal<FilterSort>('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<ProviderId[]>(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<SearchFilters>);
// Reflets des <select> locaux.
if (key === 'duration') this.durationFilter.set(next.duration);
if (key === 'type') this.typeFilter.set(next.type);
if (key === 'period') this.dateFilter.set(next.period);
if (key === 'sort') this.sortParam.set(next.sort as 'relevance' | 'date' | 'views');
if (key === 'sort') this.sortParam.set(next.sort);
const cleanParams = this.cleanUrlParams({
...this.route.snapshot.queryParams,
...filtersToQueryParams(next),
@@ -738,17 +674,9 @@ export class SearchComponent {
});
}
/** Retire un filtre depuis une pastille. */
clearFilter(key: keyof SearchFilters): void {
void this.setFilter(key, key === 'sort' ? 'relevance' : 'all');
}
clearAllFilters(): void {
const next = { ...DEFAULT_FILTERS };
this.filters.set(next);
this.durationFilter.set('all');
this.typeFilter.set('all');
this.dateFilter.set('all');
this.sortParam.set('relevance');
this.unified.setFilters(next);
const cleanParams = this.cleanUrlParams({ ...this.route.snapshot.queryParams, page: 1 });
@@ -758,37 +686,19 @@ export class SearchComponent {
});
}
onDurationChange(event: Event) {
void this.setFilter('duration', ((event.target as HTMLSelectElement)?.value as any) || 'all');
}
onTypeChange(event: Event) {
void this.setFilter('type', ((event.target as HTMLSelectElement)?.value as any) || 'all');
}
onLanguageChange(event: Event) {
this.languageFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
}
onDateChange(event: Event) {
void this.setFilter('period', ((event.target as HTMLSelectElement)?.value as any) || 'all');
}
private sortVideos(list: any[]): any[] {
if (!Array.isArray(list)) {
return [];
}
const mode = this.sortParam();
if (mode === 'relevance') {
if (this.sortParam() === 'relevance') {
// Single merged list: round-robin interleave across providers (in the
// user's provider order) instead of provider blocks. Prefix-stable
// under infinite-scroll appends (per-provider order never changes).
return this.interleaveByProvider(list);
}
const arr = [...list];
if (mode === 'date') {
arr.sort((a, b) => this.publishedTimestamp(b) - this.publishedTimestamp(a));
} else if (mode === 'views') {
arr.sort((a, b) => this.viewCount(b) - this.viewCount(a));
}
return arr;
// Date / Vues / Note / Durée : même réordonnancement que les pages thèmes
// (sortItems partagé, alimenté par les métadonnées déjà présentes).
return sortItems(list, this.sortParam());
}
/** Normalize a provider token (short 'yt' or full 'youtube') to ProviderId. */
@@ -1072,12 +982,6 @@ export class SearchComponent {
});
}
// Sort control -> même chemin que les autres filtres (URL + service)
onSortChange(event: Event) {
const value = (event.target as HTMLSelectElement)?.value as FilterSort | '';
void this.setFilter('sort', (value === 'date' || value === 'views') ? value : 'relevance');
}
// CTA handler: navigate to focus on a single provider while preserving q
onViewAll(p: ProviderId) {
const q = (this.q() || '').trim();
@@ -27,25 +27,6 @@
</div>
</div>
<!-- Filtre rapide Vidéos / Shorts (masqué sur Twitch : onglets à sections ci-dessous) -->
<div *ngIf="provider() !== 'twitch'" class="scroll-rail no-scrollbar mb-4 flex items-center gap-2" role="tablist" aria-label="Filtrer vidéos ou shorts">
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Tout
</button>
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Vidéos
</button>
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Shorts<span *ngIf="allVideos().length"> ({{ shortsCount() }})</span>
</button>
</div>
<!-- Onglets Twitch façon directory : Directs / Vidéos / Clips / Chaînes -->
<div *ngIf="isTwitchSections()" class="mb-4">
<div *ngIf="twitchGame()" class="mb-3 flex items-center gap-3 rounded-xl border border-purple-500/30 bg-purple-950/30 px-4 py-2.5">
@@ -83,57 +64,15 @@
</button>
</div>
</div>
<!-- Filters -->
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 mb-8">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
<div>
<label for="sort-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.sort' | t }}</label>
<select id="sort-filter" [ngModel]="sort()" (ngModelChange)="onSortChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="recent">{{ 'filter.sort.recent' | t }}</option>
<option value="viewed">{{ 'filter.sort.viewed' | t }}</option>
<option value="longest">{{ 'filter.sort.longest' | t }}</option>
</select>
</div>
<div>
<label for="duration-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.duration' | t }}</label>
<select id="duration-filter" [ngModel]="duration()" (ngModelChange)="onDurationChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="any">{{ 'filter.duration.any' | t }}</option>
<option value="short">{{ 'filter.duration.short' | t }}</option>
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
<option value="long">{{ 'filter.duration.long' | t }}</option>
</select>
</div>
<div>
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.type' | t }}</label>
<select id="type-filter" [ngModel]="type()" (ngModelChange)="onTypeChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="any">{{ 'filter.type.any' | t }}</option>
<option value="video">{{ 'filter.type.video' | t }}</option>
<option value="live">{{ 'filter.type.live' | t }}</option>
<option value="recorded">{{ 'filter.type.recorded' | t }}</option>
<option value="short">{{ 'filter.type.short' | t }}</option>
<option value="channel">{{ 'filter.type.channel' | t }}</option>
</select>
</div>
<div>
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t }}</label>
<select id="language-filter" [ngModel]="language()" (ngModelChange)="onLanguageChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="any">{{ 'filter.language.any' | t }}</option>
<option value="en">{{ 'filter.language.en' | t }}</option>
<option value="fr">{{ 'filter.language.fr' | t }}</option>
</select>
</div>
<div>
<label for="date-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.date' | t }}</label>
<select id="date-filter" [ngModel]="date()" (ngModelChange)="onDateChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="any">{{ 'filter.date.any' | t }}</option>
<option value="day">{{ 'filter.date.day' | t }}</option>
<option value="week">{{ 'filter.date.week' | t }}</option>
<option value="month">{{ 'filter.date.month' | t }}</option>
<option value="year">{{ 'filter.date.year' | t }}</option>
</select>
</div>
</div>
</div>
<!-- Filtres + tri : EXACTEMENT la même barre que la page de recherche. -->
<app-filter-toolbar
[filters]="filters()"
[language]="language()"
[sources]="null"
[hint]="'filters.localHint' | t: 'Les filtres et le tri s’appliquent aux résultats déjà chargés.'"
(filterChange)="onToolbarFilter($event)"
(languageChange)="language.set($event)"
(clearAll)="clearFilters()"></app-filter-toolbar>
<!-- Video Grid -->
<div *ngIf="loading() && videos().length === 0" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
@@ -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<string, string[]> = {
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<SearchFilters>({ ...DEFAULT_FILTERS });
readonly language = signal<'any' | 'en' | 'fr'>('any');
// Data state
allVideos = signal<WithKind<Video>[]>([]);
@@ -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);
+15 -21
View File
@@ -3,27 +3,21 @@
<span>{{ themeLabel() }}</span>
</h2>
<!-- Filtre Vidéos / Shorts : les deux formats ne sont plus mélangés sans contrôle -->
<div class="flex items-center gap-2 mb-6" role="tablist" aria-label="Filtrer vidéos ou shorts">
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Tout
</button>
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Vidéos
</button>
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Shorts
</button>
</div>
<!-- Filtres + tri : EXACTEMENT la même barre que la page de recherche. -->
<app-filter-toolbar
[filters]="filters()"
[language]="language()"
[typeCounts]="typeCounts()"
[sources]="sourceChips()"
[counts]="resultCounts()"
[hint]="'filters.localHint' | t: 'Les filtres et le tri s’appliquent aux résultats déjà chargés.'"
(filterChange)="onToolbarFilter($event)"
(languageChange)="language.set($event)"
(clearAll)="clearFilters()"
(sourceChange)="onSourceChange($event)"></app-filter-toolbar>
<div class="space-y-10">
<section *ngFor="let b of blocks()">
<div class="mt-6 space-y-10">
<section *ngFor="let b of visibleBlocks()">
<div class="flex items-center justify-between mb-3">
<h3 class="text-xl font-semibold">{{ b.label }}</h3>
<a [routerLink]="['/p', b.provider, 't', themeSlug()]" class="text-sm px-3 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700">{{ 'themes.viewAll' | t }}</a>
@@ -39,7 +33,7 @@
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && themeSlug() === 'live' && !providerSupportsLive(b.provider)" class="text-slate-400">Ce fournisseur ne propose pas de directs.</div>
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && !(themeSlug() === 'live' && !providerSupportsLive(b.provider))" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<div *ngIf="!b.loading && !b.error && b.items.length > 0 && filteredItems(b).length === 0" class="text-slate-400">
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 ».
</div>
<!-- Grid -->
+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). */
+10 -2
View File
@@ -156,8 +156,12 @@ const EN: Record<string, string> = {
'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<string, string> = {
'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',