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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user