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