feat(search): panneau de filtres unifié, clavier/focus du typeahead
CI / build-and-test (push) Successful in 14m10s

Barre de recherche
- Panneau de filtres unifié (remplace le ProviderPicker) : sources, type
  (vidéos/shorts/direct/chaînes), période (heure/jour/semaine/mois/année),
  durée (<4 / 4-20 / >20 min) et tri. Pastilles + roving tabindex
  (←/→ dans un groupe, ↑/↓ entre groupes), focus trap, Échap, Ctrl/⌘+Maj+F,
  recherches récentes, « mémoriser par défaut », « Réinitialiser ».
- Pastilles des filtres actifs sous la barre (retrait en un clic) + compteur.
- Typeahead : un seul keydown (fin du double Enter), ↑/↓ bouclants,
  Home/End, PageUp/PageDown, Entrée valide la ligne surlignée (sinon
  recherche brute), Tab complète sans chercher, Échap ferme puis vide,
  ligne « Rechercher <q> », défilement de l'option active, aria à jour.
- Focus : le panneau se referme dès que le focus quitte la barre
  (focusout + relatedTarget + activeElement), pointerdown neutralisé sur les
  lignes pour garder le focus dans l'input (plus de scintillement).
- Opérateurs en clair : `linux live:`, `tuto today: long:` deviennent des
  filtres et sont retirés de la requête (autocomplétion après le `:`).

Filtres côté serveur
- Modèle partagé : src/app/search/filters.ts + server/search-filters.mjs.
- /api/search?type=&duration=&period=&sort= : InnerTube (upload_date, type,
  duration, features), Data API v3 (type, videoDuration, publishedAfter),
  yt-dlp --dateafter ; post-filtrage pour les providers sans filtre natif.
  Un champ manquant ne fait jamais disparaître un résultat.
- Filtres dans l'URL (partageables) + adapters + SearchService (clé de cache).
- Correction : la recherche Shorts renvoyait 0 (ShortsLockupView/GridShelfView
  non mappés, id/titre dans l'endpoint) -> mappage + shelf + vignette/vues.

Tests / docs
- 23 tests unitaires (clavier, focus, opérateurs, panneau de filtres),
  server/tests/search-filters.test.mjs (npm run test:filters) + CI,
  scénarios e2e des filtres, README/api/README/todo/MCP à jour.
This commit is contained in:
2026-09-28 17:42:19 -04:00
parent 9fb0ce7549
commit f1f673040e
40 changed files with 3111 additions and 938 deletions
@@ -1,41 +0,0 @@
<div class="fixed inset-0 z-[9999] bg-black/60" (click)="close.emit()">
<div class="fixed inset-0 grid place-items-center p-4" [style.paddingBottom]="'max(1rem, var(--safe-bottom))'">
<div #dialog
class="dialog-scroll flex w-full max-w-lg flex-col overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="providersTitle"
(click)="$event.stopPropagation()"
tabindex="0"
(keydown)="onDialogKeydown($event)">
<div class="sticky top-0 z-10 shrink-0 border-b border-slate-700 bg-slate-900 p-4 flex items-center justify-between gap-3">
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
<button type="button" (click)="close.emit()" class="chip px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer (Esc)">Esc</button>
</div>
<div class="p-4 space-y-3">
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" aria-label="Filtrer les fournisseurs" class="w-full min-w-0 px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
<div class="flex items-center gap-2">
<label class="inline-flex items-center gap-2 text-slate-200 py-1">
<input type="checkbox" [checked]="allMode()" (change)="toggleAllMode()"/>
<span>All</span>
</label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<label *ngFor="let p of providers()" class="flex items-center gap-2 px-3 py-3 rounded border border-slate-700 bg-slate-800 text-slate-100">
<input type="checkbox" class="shrink-0" [checked]="isChecked(p.id)" (change)="toggle(p.id)"/>
<span class="text-xs font-semibold shrink-0" [class]="p.colorClass">{{ p.shortLabel }}</span>
<span class="min-w-0 truncate text-sm">{{ p.displayName }}</span>
</label>
</div>
<label class="mt-2 inline-flex items-center gap-2 text-slate-300 py-1">
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
<span>Remember as default</span>
</label>
</div>
<div class="sticky bottom-0 z-10 shrink-0 border-t border-slate-700 bg-slate-900 p-4 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
<button type="button" (click)="close.emit()" class="px-4 py-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
<button type="button" (click)="onApply()" class="px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
</div>
</div>
</div>
</div>
@@ -1,109 +0,0 @@
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ProviderId, PROVIDERS, PROVIDER_MAP } from '../../app/core/providers/provider-registry';
@Component({
selector: 'app-provider-picker',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './provider-picker.component.html'
})
export class ProviderPickerComponent implements AfterViewInit, OnDestroy {
@Input() open = false;
// Incoming selection: 'all' or a list of provider ids
@Input() set selected(value: ProviderId[] | 'all') {
// Reset the draft each time the picker opens with a new incoming selection
this._draft.set(value === 'all' ? [] : Array.isArray(value) ? [...value] : []);
this._allMode.set(value === 'all');
}
@Output() close = new EventEmitter<void>();
@Output() apply = new EventEmitter<{ selection: ProviderId[] | 'all'; rememberDefault: boolean }>();
filter = signal('');
rememberDefault = signal(false);
// Draft state: working copy while the modal is open
private _draft = signal<ProviderId[]>([]);
private _allMode = signal(true);
readonly draft = computed(() => this._draft());
readonly allMode = computed(() => this._allMode() && this._draft().length === 0);
@ViewChild('dialog', { static: false }) dialogRef?: ElementRef<HTMLDivElement>;
readonly providers = computed(() => {
const q = this.filter().toLowerCase();
return PROVIDERS.filter(p => !q || p.displayName.toLowerCase().includes(q) || p.shortLabel.toLowerCase().includes(q));
});
toggleAllMode() {
this._allMode.set(true);
this._draft.set([]);
}
isChecked(id: ProviderId): boolean {
if (this.allMode()) return true;
return this._draft().includes(id);
}
toggle(id: ProviderId) {
if (this.allMode()) {
// Leaving All mode by unchecking one provider: start from every provider
// then remove the unchecked one ("all except this one")
const set = new Set(PROVIDERS.map(p => p.id));
set.delete(id);
this._draft.set(Array.from(set));
this._allMode.set(false);
return;
}
const set = new Set(this._draft());
if (set.has(id)) {
set.delete(id);
} else {
set.add(id);
}
this._draft.set(Array.from(set));
this._allMode.set(false);
}
onApply() {
const selection: ProviderId[] | 'all' = this.allMode() ? 'all' : this._draft();
this.apply.emit({ selection, rememberDefault: this.rememberDefault() });
}
ngAfterViewInit() {
// Focus the dialog for accessibility
queueMicrotask(() => {
try { this.dialogRef?.nativeElement?.focus(); } catch {}
});
}
ngOnDestroy() {
try { document.body.style.overflow = ''; } catch {}
}
// Focus trap within the dialog (Tab / Shift+Tab)
onDialogKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
ev.preventDefault();
this.close.emit();
return;
}
if (ev.key !== 'Tab') return;
const root = this.dialogRef?.nativeElement;
if (!root) return;
const focusables = Array.from(root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
.filter(el => !el.hasAttribute('disabled'));
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (!ev.shiftKey && active === last) {
ev.preventDefault();
first.focus();
} else if (ev.shiftKey && (active === first || active === root)) {
ev.preventDefault();
last.focus();
}
}
}
+167 -115
View File
@@ -1,153 +1,205 @@
<form class="w-full min-w-0 relative" novalidate (submit)="onSubmit($event)" role="search" aria-label="Recherche">
<!-- Input row -->
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-2 py-1.5 sm:px-3 sm:py-2">
<!-- Provider chips: only when there is genuinely room for them (>= lg).
On smaller screens the "@" picker below is the equivalent entry point. -->
<div class="hidden lg:flex items-center gap-1.5 shrink-0">
<button type="button"
(click)="toggleAll()"
class="chip text-xs px-2.5 rounded border transition-colors"
[ngClass]="{
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
'bg-slate-700 text-slate-100 border-slate-600': providerMode()!=='all'
}"
[attr.aria-pressed]="providerMode()==='all'"
title="Tous les fournisseurs">
All
</button>
<ng-container *ngFor="let c of chips; let i = index">
<button type="button" (click)="toggleChip(c.id)" (keydown.space)="$event.preventDefault(); toggleChip(c.id)" (keydown.enter)="$event.preventDefault(); toggleChip(c.id)" tabindex="0" role="button"
class="chip text-xs px-2.5 rounded border transition-colors"
[ngClass]="{
'bg-slate-700 text-slate-100 border-slate-600': !(selected().includes(c.id)),
'bg-slate-200 text-slate-800 border-slate-300': selected().includes(c.id)
}"
[attr.aria-pressed]="selected().includes(c.id)"
[title]="'Inclure ' + c.label + ' (Alt+' + (i + 1) + ')'">
<span [class]="c.colorClass">{{ c.label }}</span>
</button>
</ng-container>
</div>
<!-- Barre de recherche.
- combobox ARIA : le panneau de suggestions est la listbox contrôlée par
l'input (aria-expanded / aria-activedescendant) ;
- `focusin` / `focusout` sur le <form> : le panneau se referme dès que le
focus sort de la barre (clic ailleurs, Tab au-delà, perte de focus) ;
- `pointerdown` neutralisé sur les panneaux : cliquer une suggestion ne
vole pas le focus à l'input (sinon la fermeture au blur se déclenche). -->
<form #searchForm
class="w-full min-w-0 relative"
novalidate
(submit)="onSubmit($event)"
(focusin)="onFocusIn($event)"
(focusout)="onFocusOut($event)"
role="search"
aria-label="Recherche">
<!-- Ancre des panneaux : ils se positionnent juste sous le champ, pas sous
les pastilles de filtres. -->
<div class="relative">
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-2 py-1.5 sm:px-3 sm:py-2"
[class.ring-2]="suggestionsOpen() || atOpen()"
[class.ring-red-500]="suggestionsOpen() || atOpen()">
<!-- Text input: combobox pattern for the inline '@' autocomplete -->
<!-- Champ texte -->
<input #qInput type="search"
class="min-w-0 flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-1 sm:px-2 py-2"
[placeholder]="placeholder"
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" (focus)="onSuggestInputFocus()" name="q"
autocomplete="off" aria-label="Rechercher des vidéos" enterkeyhint="search"
role="combobox" aria-expanded="false" aria-autocomplete="list"
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)"
(focus)="onSuggestInputFocus()"
name="q"
autocomplete="off" autocapitalize="off" spellcheck="false"
aria-label="Rechercher des vidéos" enterkeyhint="search"
role="combobox" aria-autocomplete="list" aria-haspopup="listbox"
[attr.aria-expanded]="atOpen() || suggestionsOpen()"
[attr.aria-controls]="atOpen() ? 'atOptionsList' : (suggestionsOpen() ? 'sgOptionsList' : null)"
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : (suggestionsOpen() && suggestIndex() >= 0 ? 'sgOption-' + suggestIndex() : null)"/>
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0
? 'atOption-' + atIndex()
: (suggestionsOpen() && suggestIndex() >= 0 ? 'sgOption-' + suggestIndex() : null)"/>
<!-- Provider picker trigger: shows the active selection as a summary so the
user always knows where they are searching. -->
<button type="button" (click)="openPicker()"
class="chip shrink-0 text-xs font-medium px-2.5 rounded bg-slate-700 text-slate-100 hover:bg-slate-600"
<!-- Effacer la saisie -->
<button *ngIf="(query() || '').length > 0" type="button" (click)="clearQuery()"
(pointerdown)="onPanelPointerDown($event)"
class="chip shrink-0 rounded p-1.5 text-slate-400 hover:bg-slate-700 hover:text-slate-100"
aria-label="Effacer la recherche">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"/>
</svg>
</button>
<!-- Bouton filtres (sources + type + période + durée + tri).
Raccourcis : Ctrl/Cmd+K ou Ctrl/Cmd+Maj+F. -->
<button type="button" (click)="togglePanel()"
(pointerdown)="onPanelPointerDown($event)"
class="chip relative inline-flex shrink-0 items-center gap-1.5 rounded bg-slate-700 px-2.5 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-600"
aria-haspopup="dialog"
[attr.aria-label]="'Fournisseurs : ' + providerSummary()">
<span class="max-w-[5.5rem] truncate lg:hidden">{{ providerSummary() }}</span>
<span class="hidden lg:inline" aria-hidden="true">@</span>
[attr.aria-expanded]="panelOpen()"
[attr.aria-label]="'Filtres de recherche' + (activeFilterCount() > 0 ? ' (' + activeFilterCount() + ' actifs)' : '')"
[title]="'Filtres : ' + filterSummary() + ' (Ctrl+Maj+F)'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M3 17v2h6v-2H3ZM3 5v2h10V5H3Zm10 16v-2h8v-2h-8v-2h-2v6h2ZM7 9v2H3v2h4v2h2V9H7Zm14 4v-2H11v2h10Zm-6-4h2V7h4V5h-4V3h-2v6Z"/>
</svg>
<span class="hidden sm:inline">Filtres</span>
<span *ngIf="activeFilterCount() > 0"
class="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold text-white">
{{ activeFilterCount() }}
</span>
<span *ngIf="providerCount() > 0"
class="hidden rounded bg-slate-600 px-1 text-[10px] font-bold text-slate-100 lg:inline"
[title]="'Sources sélectionnées'">{{ providerCount() }}</span>
</button>
<button type="submit" class="chip ml-auto shrink-0 inline-flex items-center gap-1.5 rounded bg-red-600 px-3 text-sm text-white hover:bg-red-500"
(click)="onSubmit($event)"
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 lg:hidden" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : 'Rechercher (Entrée)'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5Zm-6 0A4.5 4.5 0 1 1 14 9.5 4.49 4.49 0 0 1 9.5 14Z"/>
</svg>
<span class="hidden lg:inline">Search</span>
<span class="hidden lg:inline">Rechercher</span>
</button>
</div>
<!-- Provider Picker Modal -->
<app-provider-picker *ngIf="pickerOpen()"
[open]="pickerOpen()"
[selected]="providersForRequest()"
(close)="closePicker()"
(apply)="applyPicker($event)"></app-provider-picker>
<!-- Panneaux ancrés sous le champ (avant les pastilles de filtres) -->
<!-- Inline @autocomplete popover -->
<div *ngIf="atOpen() && atOptions().length > 0"
class="absolute z-50 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg shadow-xl"
<!-- Autocomplétion des opérateurs (`linux live:`) -->
<div *ngIf="operatorSuggestions().length > 0"
class="absolute z-40 mt-1 left-0 right-0 rounded-lg border border-slate-700 bg-slate-900 p-1 shadow-xl"
style="top: 100%">
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs"
class="max-h-[60dvh] overflow-y-auto"
[ngClass]="{ 'bg-slate-900 border border-slate-700 text-slate-200': true }">
<button *ngFor="let op of operatorSuggestions()" type="button"
(click)="applyOperatorToken(op)"
(pointerdown)="onPanelPointerDown($event)"
class="w-full rounded px-3 py-2 text-left font-mono text-xs text-slate-300 hover:bg-slate-800">
{{ op }}:
</button>
</div>
<!-- Popover « @provider » (reste prioritaire sur les suggestions) -->
<div *ngIf="atOpen() && atOptions().length > 0"
class="absolute z-50 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg border border-slate-700 bg-slate-900 text-slate-200 shadow-xl"
style="top: 100%"
(pointerdown)="onPanelPointerDown($event)">
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs">
<li *ngFor="let opt of atOptions(); let i = index" role="option"
[attr.aria-selected]="i === atIndex()"
[id]="'atOption-' + i">
<button type="button" (click)="applyAtOption(opt.id)"
class="w-full text-left px-3 py-3 text-sm"
[ngClass]="{
'bg-slate-800': i === atIndex(),
'hover:bg-slate-800': i !== atIndex()
}">
(mouseenter)="atIndex.set(i)"
(keydown)="onAtOptionKeydown($event, i)"
class="w-full px-3 py-3 text-left text-sm"
[ngClass]="{ 'bg-slate-800': i === atIndex(), 'hover:bg-slate-800': i !== atIndex() }">
@ {{ opt.label }}
</button>
</li>
</ul>
</div>
<!-- Step 15: query typeahead panel (hidden while the @ popover is open) -->
<!-- Panneau de suggestions : ligne 0 = « Rechercher <q> », puis les suggestions -->
<div *ngIf="suggestionsOpen() && !atOpen() && suggestFlat().length > 0"
class="absolute z-40 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg shadow-xl bg-slate-900 border border-slate-700 text-slate-200"
style="top: 100%">
<ul id="sgOptionsList" role="listbox" aria-label="Suggestions de recherche"
class="max-h-[60dvh] overflow-y-auto py-1">
class="absolute z-40 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg border border-slate-700 bg-slate-900 text-slate-200 shadow-xl"
style="top: 100%"
(pointerdown)="onPanelPointerDown($event)">
<ul id="sgOptionsList" role="listbox" aria-label="Suggestions de recherche" class="py-1">
<li *ngIf="(query() || '').trim().length >= 2" role="option"
[attr.aria-selected]="suggestIndex() === 0"
id="sgOption-0"
class="border-b border-slate-800">
<button type="button" (click)="searchAsTyped()"
(mouseenter)="suggestIndex.set(0)"
(keydown)="onSuggestOptionKeydown($event, 0)"
class="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm"
[ngClass]="suggestIndex() === 0 ? 'bg-slate-800' : 'hover:bg-slate-800'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5Z"/>
</svg>
<span class="min-w-0 flex-1 truncate">
Rechercher <span class="font-semibold text-slate-100">{{ query() }}</span>
</span>
</button>
</li>
<li *ngFor="let item of suggestFlat(); let i = index" role="option"
[attr.aria-selected]="i === suggestIndex()"
[id]="'sgOption-' + i">
[attr.aria-selected]="suggestIndex() === i + 1"
[id]="'sgOption-' + (i + 1)">
<button type="button" (click)="applySuggestion(item.text, true)"
(mouseenter)="suggestIndex.set(i)"
class="w-full flex items-center gap-2 text-left px-3 py-3 text-sm">
<span *ngIf="!item.provider" class="text-slate-500 shrink-0" title="Recherche récente">🕘</span>
<span *ngIf="item.provider" class="shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-700 text-slate-300">{{ providerBadge(item.provider) }}</span>
<span class="min-w-0 flex-1 truncate" [title]="item.text"><span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent text-inherit font-semibold underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span></span>
(mouseenter)="suggestIndex.set(i + 1)"
(keydown)="onSuggestOptionKeydown($event, i + 1)"
class="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm"
[ngClass]="suggestIndex() === i + 1 ? 'bg-slate-800' : 'hover:bg-slate-800'">
<span *ngIf="item.kind === 'recent'" class="shrink-0 text-slate-500" title="Recherche récente" aria-hidden="true">🕘</span>
<span *ngIf="item.kind === 'provider'" class="shrink-0 rounded bg-slate-700 px-1.5 py-0.5 text-[10px] font-bold text-slate-300">{{ providerBadge(item.provider) }}</span>
<span *ngIf="item.kind === 'local'" class="shrink-0 text-slate-600" title="Complétion" aria-hidden="true">+</span>
<span class="min-w-0 flex-1 truncate" [title]="item.text">
<span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent font-semibold text-inherit underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span>
</span>
<span *ngIf="suggestIndex() === i + 1" class="hidden shrink-0 text-[10px] text-slate-500 sm:inline">Entrée</span>
</button>
</li>
</ul>
<div *ngIf="suggestLoading()" class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">Chargement…</div>
</div>
<!-- Quick Menu (Ctrl/Cmd+K) -->
<div *ngIf="quickOpen()" class="quick-menu-backdrop" role="presentation" (click)="closeQuickMenu()">
<div #quickDialog class="quick-menu-dialog" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
<div class="p-3 border-b border-slate-700 flex items-center justify-between gap-3">
<h2 id="quickMenuTitle" class="text-slate-100 font-semibold">Quick Menu</h2>
<button type="button" (click)="closeQuickMenu()" class="chip px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer le menu (Esc)">Esc</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 overflow-y-auto min-h-0 flex-1">
<!-- History column -->
<div class="p-4 md:border-r md:border-slate-700">
<div class="text-slate-300 text-sm mb-2">History</div>
<ul class="space-y-1 max-h-64 overflow-y-auto">
<li *ngFor="let it of recentSearches()">
<button type="button" class="w-full text-left px-3 py-3 rounded hover:bg-slate-800 text-slate-200 break-anywhere"
(click)="queryUpdate(it.query || ''); closeQuickMenu()">{{ it.query }}</button>
</li>
<li *ngIf="(recentSearches()?.length || 0) === 0" class="text-slate-500 text-sm">No recent searches.</li>
</ul>
</div>
<!-- Providers column -->
<div class="p-4">
<div class="flex items-center justify-between gap-3 mb-2">
<div class="text-slate-300 text-sm">Providers</div>
<button type="button" class="chip text-xs px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100"
(click)="quickToggleAll()">All</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<label *ngFor="let c of chips" class="flex items-center gap-2 px-3 py-3 rounded border border-slate-700 bg-slate-800 text-slate-100">
<input type="checkbox" [checked]="selected().includes(c.id)" (change)="quickToggle(c.id)"/>
<span class="text-sm truncate" [class]="c.colorClass">{{ c.label }}</span>
</label>
</div>
<div class="mt-4 text-right">
<button type="button" class="w-full sm:w-auto px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white"
(click)="applyQuickMenu({ selection: selected().length ? selected() : 'all', rememberDefault: false })">Apply filters</button>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t border-slate-800 px-3 py-1.5 text-[11px] text-slate-500">
<span *ngIf="suggestLoading()">Chargement des suggestions…</span>
<span *ngIf="!suggestLoading()">↑ ↓ naviguer · Entrée valider · Échap fermer</span>
</div>
</div>
</div><!-- /ancre des panneaux -->
<!-- Pastilles des filtres actifs : rappel visuel + retrait en un clic -->
<div *ngIf="activeFilterChips().length > 0 || providerCount() > 0"
class="mt-1.5 flex flex-wrap items-center gap-1.5">
<span *ngIf="providerCount() > 0" class="text-[11px] text-slate-400">Sources&nbsp;:</span>
<ng-container *ngIf="providerCount() > 0">
<span *ngFor="let pid of selected()"
class="chip inline-flex items-center gap-1 rounded-full border border-slate-600 bg-slate-800 px-2 py-0.5 text-[11px] text-slate-200">
{{ providerBadge(pid) }}
<button type="button" (click)="toggleChip(pid)"
(pointerdown)="onPanelPointerDown($event)"
class="rounded-full px-1 text-slate-400 hover:text-red-400" [attr.aria-label]="'Retirer ' + pid">
✕
</button>
</span>
</ng-container>
<ng-container *ngFor="let chip of activeFilterChips()">
<button type="button" (click)="removeFilter(chip.key)"
(pointerdown)="onPanelPointerDown($event)"
class="chip inline-flex items-center gap-1 rounded-full border border-red-500/60 bg-red-600/20 px-2 py-0.5 text-[11px] text-red-200 hover:bg-red-600/40"
[attr.aria-label]="'Retirer le filtre ' + chip.label">
{{ chip.label }} <span aria-hidden="true">✕</span>
</button>
</ng-container>
<button *ngIf="activeFilterChips().length > 0" type="button" (click)="clearAllFilters()"
(pointerdown)="onPanelPointerDown($event)"
class="chip rounded-full px-2 py-0.5 text-[11px] text-slate-400 hover:text-slate-200 underline">
Tout effacer
</button>
</div>
<!-- Panneau de filtres (dialog) -->
<app-search-filter-panel *ngIf="panelOpen()"
#filterPanel
[open]="panelOpen()"
[selected]="providersForRequest()"
[filters]="filters()"
[query]="query()"
[history]="historyQueries()"
(close)="closePanel()"
(apply)="applyPanel($event)"
(pickHistory)="pickHistory($event)"></app-search-filter-panel>
</form>
+495 -226
View File
@@ -1,8 +1,8 @@
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener, ViewChild, ElementRef } from '@angular/core';
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
import { ProviderPickerComponent } from './provider-picker.component';
import { SearchFilterPanelComponent, type FilterPanelApply } from './search-filter-panel.component';
import { SearchService } from '../../app/search/search.service';
import { UserService, type UserPreferences } from '../../services/user.service';
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
@@ -12,14 +12,27 @@ import { debounceTime, distinctUntilChanged, switchMap, catchError, finalize } f
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SuggestService } from '../../app/search/suggest.service';
import { mergeGroups, highlightParts, dedupeStrings, buildLocalQuerySuggestions, normSuggest } from '../../app/search/suggest.util';
// No overlays; we render fixed-position modals with high z-index
import {
DEFAULT_FILTERS, activeFilterCount, filtersEqual, filtersSummary, filterGroups,
normalizeFilters, parseQueryTokens, operatorTokenSuggestions,
type SearchFilters,
} from '../../app/search/filters';
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
/** One row of the Step 15 typeahead panel (provider null = recent search). */
/** Une ligne du panneau de suggestions. */
export interface SuggestFlatItem {
text: string;
provider: string | null;
/** 'recent' | 'provider' | 'local' — sert à l'icône et au tri d'affichage. */
kind: 'recent' | 'provider' | 'local';
}
/** Payload émis quand une recherche est lancée. */
export interface SearchSubmit {
q: string;
providers: ProviderId[] | 'all';
filters: SearchFilters;
}
@Component({
@@ -30,14 +43,15 @@ export interface SuggestFlatItem {
imports: [
CommonModule,
FormsModule,
ProviderPickerComponent
SearchFilterPanelComponent
],
providers: [
SearchService, // Fournir le service au niveau du composant
UserService, // S'assurer que UserService est fourni
HistoryService // S'assurer que HistoryService est fourni
],
templateUrl: './search-box.component.html'
templateUrl: './search-box.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchBoxComponent {
/**
@@ -53,11 +67,15 @@ export class SearchBoxComponent {
// Optional: keeps unit tests (which don't provide it) working with a null guard
private suggestSvc = inject(SuggestService, { optional: true });
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
@ViewChild('quickDialog', { static: false }) quickDialogRef?: ElementRef<HTMLDivElement>;
@ViewChild('searchForm', { static: false }) formRef?: ElementRef<HTMLFormElement>;
@ViewChild('filterPanel', { static: false }) panelRef?: ElementRef<HTMLElement>;
// Input/Output bindings
@Input() placeholder: string = 'Search videos…';
@Output() submitted = new EventEmitter<{ q: string; providers: ProviderId[] | 'all' }>();
@Output() submitted = new EventEmitter<SearchSubmit>();
/** Émis quand les filtres changent (sans recherche) — la page /search peut
* réécrire l'URL pour rester partageable. */
@Output() filtersChange = new EventEmitter<SearchFilters>();
// Emits debounced query changes for autosuggest
@Output() searchChange = new EventEmitter<string>();
@@ -65,8 +83,11 @@ export class SearchBoxComponent {
query = signal('');
providerMode = signal<'all' | 'custom'>('all');
selected = signal<ProviderId[]>([]);
pickerOpen = signal(false);
/** Panneau de filtres (remplace l'ancien sélecteur de providers). */
panelOpen = signal(false);
suggestionsOpen = signal(false);
/** Filtres courants (type / durée / période / tri). */
filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
// Inline @autocomplete state (atOpen is derived from the query, atOptions is pure)
atOpen = signal(false);
atIndex = signal(0);
@@ -78,32 +99,40 @@ export class SearchBoxComponent {
.filter(p => p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term))
.map(p => ({ id: p.id, label: p.displayName }));
});
// Quick menu modal (Ctrl/Cmd+K)
quickOpen = signal(false);
recentSearches = signal<SearchHistoryItem[]>([]);
// Step 15 — query typeahead (suggestions de requêtes, avant validation).
// `suggestionsOpen` doubles as the typeahead panel flag (closed when @ popover opens).
suggestLoading = signal(false);
suggestGroups = signal<Record<string, string[]>>({});
recentSuggest = signal<string[]>([]);
suggestIndex = signal(-1);
/** Rappelé après chaque frappe : sert à détecter une perte de focus. */
private blurTimer: ReturnType<typeof setTimeout> | null = null;
private allRecentCache: string[] = [];
private lastRecentFetch = 0;
private suggestShownFor: string | null = null;
/**
* Panneau de suggestions : historique → suggestions providers → complétions
* locales. Toujours trié « ce qui commence par la frappe d'abord », et jamais
* la requête brute elle-même (sinon Entrée ne ferait rien).
*/
readonly suggestFlat = computed((): SuggestFlatItem[] => {
const q = this.query();
// Tier 1 — recherches récentes (dédupliquées, une seule fois chacune).
const recents = (this.recentSuggest() || []).slice(0, 5).map((t) => ({ text: t, provider: null as string | null }));
const recents: SuggestFlatItem[] = (this.recentSuggest() || []).slice(0, 5)
.map((t) => ({ text: t, provider: null as string | null, kind: 'recent' as const }));
const seen = new Set(recents.map((r) => normSuggest(r.text)));
// Tier 2 — suggestions providers, dédupliquées entre providers (mergeGroups)
// et contre l'historique.
const merged = mergeGroups(this.suggestGroups() || {}).filter((m) => {
const k = normSuggest(m.text);
if (seen.has(k)) return false;
seen.add(k);
return true;
});
const merged: SuggestFlatItem[] = mergeGroups(this.suggestGroups() || {})
.filter((m) => {
const k = normSuggest(m.text);
if (seen.has(k)) return false;
seen.add(k);
return true;
})
.map((m) => ({ text: m.text, provider: m.provider, kind: 'provider' as const }));
// Tri « style YouTube/Google » : les complétions qui commencent par la
// frappe d'abord, puis le reste (tri stable : l'ordre providers est gardé).
const nq = normSuggest(q);
@@ -123,7 +152,7 @@ export class SearchBoxComponent {
const k = normSuggest(t);
if (seen.has(k)) continue;
seen.add(k);
out.push({ text: t, provider: null });
out.push({ text: t, provider: null, kind: 'local' });
if (out.length >= 15) break;
}
}
@@ -134,7 +163,6 @@ export class SearchBoxComponent {
// Element to restore focus to when a modal closes (a11y)
private lastFocused: HTMLElement | null = null;
readonly chips = PROVIDERS.map(p => ({ id: p.id, label: p.shortLabel, colorClass: p.colorClass }));
/**
* Focus the query input programmatically (Ctrl/Cmd+K, `/`, mobile search
@@ -149,21 +177,29 @@ export class SearchBoxComponent {
return this.providerMode() === 'all' ? 'all' : (this.selected().length ? this.selected() : 'all');
});
/**
* Short human label for the current provider selection. On phones the chip
* row is hidden, so this label is the only feedback the user gets about
* where the search will be routed.
*/
readonly providerSummary = computed<string>(() => {
if (this.providerMode() === 'all') return 'All';
const sel = this.selected();
if (!sel.length) return 'All';
if (sel.length === 1) {
const found = PROVIDERS.find((p) => p.id === sel[0]);
return found ? found.shortLabel : String(sel[0]).toUpperCase();
readonly activeFilterCount = computed(() => activeFilterCount(this.filters()));
/** Résumé court affiché dans le bouton (mobile). */
readonly filterSummary = computed(() => filtersSummary(this.filters()));
readonly hasActiveFilters = computed(() => this.activeFilterCount() > 0);
/** Pastilles des filtres actifs, pour les rappeler sous la barre. */
readonly activeFilterChips = computed(() => {
const n = normalizeFilters(this.filters());
const groups = filterGroups(n);
const out: { key: keyof SearchFilters; label: string; i18n: string }[] = [];
for (const g of groups) {
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, i18n: opt.key });
}
return `${sel.length} src`;
return out;
});
/** Nombre de sources sélectionnées (pour la pastille du bouton). */
readonly providerCount = computed(() => {
if (this.providerMode() === 'all') return 0;
return this.selected().length;
});
constructor() {
// Flag to avoid clobbering user input after initial hydration from SearchService
@@ -191,7 +227,9 @@ export class SearchBoxComponent {
if (hydratedFromService) return; // only hydrate once
const q = this.search.q$.value;
const prov = this.search.providers$.value;
const filters = this.search.filters$?.value;
if (typeof q === 'string') this.query.set(q);
if (filters) this.filters.set(normalizeFilters(filters));
if (prov === 'all') {
this.providerMode.set('all');
this.selected.set([]);
@@ -219,28 +257,26 @@ export class SearchBoxComponent {
}
});
// Focus the first focusable element of the quick menu dialog when it opens (a11y)
// Lock body scroll when the filter panel is open (a11y UX)
effect(() => {
if (this.quickOpen()) {
setTimeout(() => {
try {
const root = this.quickDialogRef?.nativeElement;
const first = root?.querySelector<HTMLElement>('button');
(first || root)?.focus();
} catch {}
}, 0);
}
});
// Lock body scroll when a modal is open (a11y UX)
effect(() => {
const open = this.quickOpen() || this.pickerOpen();
const open = this.panelOpen();
try {
document.body.style.overflow = open ? 'hidden' : '';
} catch {}
});
}
// ---------------------------------------------------------------------------
// Saisie
// ---------------------------------------------------------------------------
/** Écrit dans l'input sans passer par ngModel (focus conservé). */
private setQueryValue(value: string): void {
this.query.set(value);
try { this.search.q$.next(value); } catch {}
try { const el = this.qInputRef?.nativeElement; if (el) el.value = value; } catch {}
}
// Update query and refresh inline '@provider' autocomplete state
queryUpdate(value: string) {
this.query.set(value);
@@ -260,6 +296,17 @@ export class SearchBoxComponent {
}
}
/** Efface l'input (croix du champ) et refocus. */
clearQuery(): void {
this.queryUpdate('');
this.setQueryValue('');
this.focusInputSafe();
}
private focusInputSafe(): void {
try { this.qInputRef?.nativeElement?.focus({ preventScroll: true }); } catch {}
}
// --- Step 15 helpers ---
private fetchSuggest(value: string) {
@@ -279,8 +326,7 @@ export class SearchBoxComponent {
private applySuggestResponse(res: { q: string; groups: Record<string, string[]> } | null) {
if (!res) {
this.suggestGroups.set({});
this.suggestionsOpen.set(false);
this.suggestIndex.set(-1);
this.closeSuggestions();
return;
}
this.suggestGroups.set(res.groups || {});
@@ -295,11 +341,16 @@ export class SearchBoxComponent {
this.telemetry.track('suggest_shown', { queryLength: key.length, resultCount: flat.length });
}
} else {
this.suggestionsOpen.set(false);
this.suggestIndex.set(-1);
this.closeSuggestions();
}
}
/** Referme le panneau de suggestions et annule la surbrillance. */
closeSuggestions(): void {
this.suggestionsOpen.set(false);
this.suggestIndex.set(-1);
}
/** Filter cached history synchronously; refresh the cache at most every 10s. */
private refreshRecentSuggest(value: string) {
const q = String(value || '').trim().toLowerCase();
@@ -332,24 +383,55 @@ export class SearchBoxComponent {
} catch {}
}
/** Fill the input with a suggestion; submit=true also runs the search. */
/** Charge l'historique récent (affiché dans le panneau de filtres). */
loadRecentHistory(): void {
try {
this.history.getSearchHistory(12).subscribe({
next: (items) => {
const seen = new Set<string>();
const clean: SearchHistoryItem[] = [];
for (const it of items || []) {
const k = normSuggest(it?.query || '');
if (!k || seen.has(k)) continue;
seen.add(k);
clean.push(it);
}
this.recentSearches.set(clean);
},
error: () => {},
});
} catch {}
}
/** Remplit l'input avec une suggestion ; submit=true lance aussi la recherche. */
applySuggestion(text: string, submit: boolean) {
const t = String(text || '').trim();
if (!t) return;
const prov = this.providersForRequest();
this.telemetry.track('suggest_used', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: t.length });
this.suggestionsOpen.set(false);
this.suggestIndex.set(-1);
this.query.set(t);
try { this.search.q$.next(t); } catch {}
try { this.qInputRef?.nativeElement && (this.qInputRef.nativeElement.value = t); } catch {}
this.closeSuggestions();
this.setQueryValue(t);
if (submit) {
this.submitted.emit({ q: t, providers: this.providersForRequest() });
this.telemetry.track('search_submit', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: t.length });
this.runSearch(t);
} else {
this._suggestReq.next(t);
// Complétion (Tab) : on rouvre immédiatement le panneau sur les
// suggestions locales, sans attendre le réseau.
if (this.suggestFlat().length > 0) {
this.suggestionsOpen.set(true);
this.suggestIndex.set(-1);
}
}
try { this.qInputRef?.nativeElement?.focus(); } catch {}
this.focusInputSafe();
}
/** Première ligne du panneau : lance la recherche telle que saisie. */
searchAsTyped(): void {
const q = (this.query() || '').trim();
if (q.length < 2) return;
this.closeSuggestions();
this.runSearch(q);
this.focusInputSafe();
}
suggestHighlight(text: string): { pre: string; match: string; post: string } {
@@ -368,9 +450,158 @@ export class SearchBoxComponent {
this.refreshRecentSuggest(this.query());
if (!this.atOpen() && this.suggestFlat().length > 0) {
this.suggestionsOpen.set(true);
this.suggestIndex.set(-1);
}
}
// ---------------------------------------------------------------------------
// Focus : le panneau ne vit que tant que le focus est dans la barre
// ---------------------------------------------------------------------------
/**
* Le focus sort de la barre (clic ailleurs, Tab au-delà, perte de focus
* fenêtre…) -> on referme les panneaux. `relatedTarget` couvre le Tab et le
* clic ; le timer couvre le cas où le navigateur ne fournit pas de cible
* (clic dans la zone vide de la page, perte de focus de la fenêtre).
*/
onFocusOut(ev: FocusEvent): void {
if (this.panelOpen()) return; // le panneau est un dialogue : il gère son focus
const next = ev?.relatedTarget as HTMLElement | null;
const host = this.formRef?.nativeElement;
if (next && host && host.contains(next)) return;
if (this.blurTimer) { clearTimeout(this.blurTimer); this.blurTimer = null; }
this.blurTimer = setTimeout(() => {
this.blurTimer = null;
try {
const active = document.activeElement as HTMLElement | null;
const root = this.formRef?.nativeElement;
if (active && root && root.contains(active)) return;
} catch {
// Pas de DOM (tests / SSR) : rien à vérifier, on ferme.
}
this.closeSuggestions();
this.atOpen.set(false);
}, 0);
}
/**
* Le focus revient dans la barre (re-clic) : on annule le timer pour ne pas
* refermer le panneau qui vient d'être rouvert.
*/
onFocusIn(ev: FocusEvent): void {
if (this.blurTimer) { clearTimeout(this.blurTimer); this.blurTimer = null; }
void ev;
}
/**
* Un clic dans le panneau ne doit pas voler le focus à l'input : on neutralise
* le `mousedown` (le bouton garde son état actif, l'input garde le focus).
*/
onPanelPointerDown(ev: MouseEvent): void {
try { ev.preventDefault(); } catch {}
}
// ---------------------------------------------------------------------------
// Filtres
// ---------------------------------------------------------------------------
openPanel(): void {
this.saveFocus();
this.closeSuggestions();
this.atOpen.set(false);
this.loadRecentHistory();
this.panelOpen.set(true);
this.telemetry.track('filter_panel_open', { activeFilters: this.activeFilterCount() });
}
closePanel(): void {
if (!this.panelOpen()) return;
this.panelOpen.set(false);
this.restoreFocus();
}
togglePanel(): void {
if (this.panelOpen()) this.closePanel();
else this.openPanel();
}
/** Application du panneau : filtres + sources. */
applyPanel(payload: FilterPanelApply): void {
if (payload.selection === 'all') {
this.providerMode.set('all');
this.selected.set([]);
} else if (Array.isArray(payload.selection)) {
this.providerMode.set('custom');
this.selected.set(payload.selection);
}
const next = normalizeFilters(payload.filters);
const changed = !filtersEqual(next, this.filters());
this.filters.set(next);
if (payload.rememberDefault) {
const body: any = { defaultProviders: (payload.selection === 'all') ? [] : payload.selection };
try { this.users.updatePreferences(body).subscribe({ next: () => {}, error: () => {} }); } catch {}
}
const prov = this.providersForRequest();
this.telemetry.track('filter_apply', {
providers: Array.isArray(prov) ? prov : ['all'],
filters: next,
rememberDefault: payload.rememberDefault,
});
this.closePanel();
if (!changed) return;
this.applyFiltersAndMaybeSearch(next);
}
/**
* Point unique après un changement de filtre : si une recherche est déjà
* saisie, on la relance (le parent navigue avec les filtres) ; sinon on
* n'émet que `filtersChange` pour que la page courante reste partageable.
* (Une seule navigation : les deux ne doivent pas se cumuler.)
*/
private applyFiltersAndMaybeSearch(next: SearchFilters): void {
this.filters.set(next);
const q = this.strippedQuery();
if (q.length >= 2) {
this.runSearch(q);
return;
}
this.filtersChange.emit(next);
}
/** Retirer une pastille de filtre (sous la barre). */
removeFilter(key: keyof SearchFilters): void {
const next = normalizeFilters({ ...this.filters(), [key]: key === 'sort' ? 'relevance' : 'all' });
if (filtersEqual(next, this.filters())) return;
this.applyFiltersAndMaybeSearch(next);
}
/** Remise à zéro de tous les filtres (sans toucher aux sources). */
clearAllFilters(): void {
if (filtersEqual(this.filters(), DEFAULT_FILTERS)) return;
this.applyFiltersAndMaybeSearch({ ...DEFAULT_FILTERS });
}
/** Recherches récentes (chaînes) affichées dans le panneau de filtres. */
historyQueries(): string[] {
return (this.recentSearches() || [])
.map((it) => String(it?.query || '').trim())
.filter(Boolean);
}
/** Rappel d'une recherche depuis le panneau de filtres. */
pickHistory(text: string): void {
const t = String(text || '').trim();
if (!t) return;
this.closePanel();
this.queryUpdate(t);
this.setQueryValue(t);
this.runSearch(t);
}
// ---------------------------------------------------------------------------
// Fournisseurs (raccourcis / popover « @ »)
// ---------------------------------------------------------------------------
// Pick an option from the inline '@' popover: strip the '@term' and toggle the chip
applyAtOption(id: ProviderId) {
const q = this.query();
@@ -382,7 +613,7 @@ export class SearchBoxComponent {
this.atOpen.set(false);
this.toggleChip(id);
this.telemetry.track('at_autocomplete_use', { providers: [id] });
try { this.qInputRef?.nativeElement?.focus(); } catch {}
this.focusInputSafe();
}
// Toggle All chip
@@ -405,165 +636,259 @@ export class SearchBoxComponent {
this.selected.set(Array.from(cur));
}
// Single keyboard handler for the whole form (input events bubble here)
// ---------------------------------------------------------------------------
// Clavier
// ---------------------------------------------------------------------------
/**
* Handler clavier unique de la barre.
*
* Priorité : 1) popover « @ », 2) panneau de suggestions, 3) surbrillance
* d'une option du panneau de filtres (bouton focused), 4) raccourcis globaux.
* Entrée n'est jamais traitée deux fois : le formulaire n'a qu'un seul
* `keydown` (le `(keydown.enter)` d'avant a été supprimé).
*/
handleKeydown(ev: KeyboardEvent) {
// Inline @autocomplete navigation (highest priority)
// --- 1. Popover « @provider » (priorité maximale) ---
if (this.atOpen()) {
const opts = this.atOptions();
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(opts.length - 1, this.atIndex() + 1)); return; }
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, this.atIndex() - 1)); return; }
const last = Math.max(0, opts.length - 1);
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(last, this.atIndex() + 1)); this.scrollActiveIntoView(); return; }
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, this.atIndex() - 1)); this.scrollActiveIntoView(); return; }
if (ev.key === 'Enter') {
// Prevent the sibling (keydown.enter) submit handler from also firing
ev.stopImmediatePropagation();
ev.preventDefault();
ev.stopPropagation();
const pick = opts[this.atIndex()];
if (pick) this.applyAtOption(pick.id);
return;
}
if (ev.key === 'Escape') { this.atOpen.set(false); return; }
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); this.atOpen.set(false); return; }
}
// Step 15 — typeahead navigation (second priority after the @ popover)
// --- 2. Panneau de suggestions ---
if (this.suggestionsOpen() && !this.atOpen()) {
const flat = this.suggestFlat();
if (flat.length > 0) {
const last = flat.length - 1;
if (ev.key === 'ArrowDown') {
// Ligne 0 = « Rechercher <q> », les suivantes = suggestions.
const total = flat.length + 1;
const last = total - 1;
const idx = this.suggestIndex();
const move = (next: number) => {
ev.preventDefault();
this.suggestIndex.set(((next % total) + total) % total);
this.scrollActiveIntoView();
};
if (ev.key === 'ArrowDown') { move(idx >= last ? 0 : idx + 1); return; }
if (ev.key === 'ArrowUp') { move(idx <= 0 ? last : idx - 1); return; }
if (ev.key === 'Home' && idx >= 0) { ev.preventDefault(); this.suggestIndex.set(0); this.scrollActiveIntoView(); return; }
if (ev.key === 'End' && idx >= 0) { ev.preventDefault(); this.suggestIndex.set(last); this.scrollActiveIntoView(); return; }
if (ev.key === 'PageDown' && idx >= 0) { move(Math.min(last, idx + 5)); return; }
if (ev.key === 'PageUp' && idx >= 0) { move(Math.max(0, idx - 5)); return; }
if (ev.key === 'Enter') {
// Entrée sur une ligne sélectionnée = validation ; sinon recherche brute.
if (idx >= 0 && idx <= last) {
ev.preventDefault();
this.suggestIndex.set(this.suggestIndex() >= last ? 0 : this.suggestIndex() + 1);
ev.stopPropagation();
if (idx === 0) this.searchAsTyped();
else this.applySuggestion(flat[idx - 1].text, true);
return;
}
if (ev.key === 'ArrowUp') {
ev.preventDefault();
ev.stopPropagation();
this.onSubmit(ev);
return;
}
// Tab complète l'option active (ou la première) sans lancer la recherche.
if (ev.key === 'Tab') {
const target = idx >= 0 ? idx - 1 : 0;
const item = flat[target];
if (item) {
ev.preventDefault();
this.suggestIndex.set(this.suggestIndex() <= 0 ? last : this.suggestIndex() - 1);
this.applySuggestion(item.text, false);
return;
}
if (ev.key === 'Enter') {
const idx = this.suggestIndex();
if (idx >= 0 && idx <= last) {
ev.stopImmediatePropagation();
ev.preventDefault();
this.applySuggestion(flat[idx].text, true);
return;
}
}
// Tab completes the active (or first) suggestion without searching
if (ev.key === 'Tab') {
const idx = this.suggestIndex() >= 0 ? this.suggestIndex() : 0;
if (flat[idx]) {
ev.preventDefault();
this.applySuggestion(flat[idx].text, false);
return;
}
}
}
// Esc closes the panel but keeps the typed text
// Échap ferme le panneau mais garde la frappe.
if (ev.key === 'Escape') {
this.suggestionsOpen.set(false);
this.suggestIndex.set(-1);
ev.preventDefault();
ev.stopPropagation();
this.closeSuggestions();
return;
}
}
// Alt+1..6 map to chips by registry order
// --- 3. Alt+1..6 = cocher/décocher une source ---
if (ev.altKey) {
const map: Record<string, ProviderId | undefined> = { '1': 'yt', '2': 'dm', '3': 'tw', '4': 'pt', '5': 'od', '6': 'ru' };
const pid = map[ev.key];
if (pid) { ev.preventDefault(); this.toggleChip(pid); }
return;
}
// --- 4. Échap : fermer, puis vider ---
if (ev.key === 'Escape') {
// Escape closes transient UI; if nothing is open, clear the query
const hadAny = this.pickerOpen() || this.suggestionsOpen() || this.atOpen() || this.quickOpen();
this.closePicker();
this.suggestionsOpen.set(false);
ev.stopPropagation();
const hadAny = this.panelOpen() || this.suggestionsOpen() || this.atOpen();
this.closePanel();
this.closeSuggestions();
this.atOpen.set(false);
if (!hadAny && (this.query() || '').length > 0) {
this.queryUpdate('');
}
if (!hadAny && (this.query() || '').length > 0) this.clearQuery();
}
}
// Global shortcut Ctrl/Cmd+K to open the Providers quick menu
/** Clavier du popover « @ » (boutons) : mêmes règles que l'input. */
onAtOptionKeydown(ev: KeyboardEvent, index: number): void {
const opts = this.atOptions();
const last = Math.max(0, opts.length - 1);
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(last, index + 1)); this.focusAtOption(); return; }
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, index - 1)); this.focusAtOption(); return; }
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); this.atOpen.set(false); this.focusInputSafe(); return; }
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
ev.stopPropagation();
const pick = opts[index];
if (pick) this.applyAtOption(pick.id);
}
}
/** Clavier d'une ligne du panneau de suggestions (bouton). */
onSuggestOptionKeydown(ev: KeyboardEvent, index: number): void {
// index 0 = ligne « Rechercher … » ; 1..n = suggestions
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
ev.preventDefault();
const total = this.suggestFlat().length + 1;
const next = (ev.key === 'ArrowDown') ? index + 1 : index - 1;
this.suggestIndex.set(Math.max(0, Math.min(total - 1, next)));
this.focusInputSafe();
this.scrollActiveIntoView();
return;
}
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); this.closeSuggestions(); this.focusInputSafe(); }
}
private focusAtOption(): void {
setTimeout(() => {
try { document.getElementById('atOption-' + this.atIndex())?.focus(); } catch {}
});
}
/** Garde l'option surbrillée visible dans la liste (clavier long). */
private scrollActiveIntoView(): void {
if (this.suggestIndex() < 0 && this.atIndex() < 0) return;
const id = this.suggestionsOpen() && this.suggestIndex() >= 0
? 'sgOption-' + this.suggestIndex()
: 'atOption-' + this.atIndex();
setTimeout(() => {
try {
const el = document.getElementById(id);
if (el && typeof (el as any).scrollIntoView === 'function') {
(el as any).scrollIntoView({ block: 'nearest' });
}
} catch {}
});
}
// Raccourcis globaux. Ctrl/Cmd+K reste « focus recherche » (géré par le
// header) ; le panneau de filtres prend Ctrl/Cmd+Maj+F.
@HostListener('document:keydown', ['$event'])
onGlobalKey(ev: KeyboardEvent) {
if ((ev.key.toLowerCase() === 'k') && (ev.ctrlKey || ev.metaKey)) {
const key = String(ev.key || '').toLowerCase();
if (key === 'f' && ev.shiftKey && (ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
this.openQuickMenu();
ev.stopPropagation();
if (this.panelOpen()) this.closePanel();
else this.openPanel();
return;
}
if (ev.key === 'Escape') {
if (this.quickOpen()) {
ev.preventDefault();
this.closeQuickMenu();
}
if (this.pickerOpen()) {
ev.preventDefault();
this.closePicker();
}
if (this.atOpen()) {
ev.preventDefault();
this.atOpen.set(false);
}
if (this.panelOpen()) { ev.preventDefault(); ev.stopPropagation(); this.closePanel(); }
}
}
// ---------------------------------------------------------------------------
// Soumission
// ---------------------------------------------------------------------------
/** Requête sans les opérateurs `live:`/`today:`… (déjà convertis en filtres). */
private strippedQuery(): string {
return parseQueryTokens(this.query() || '').query;
}
/** Point d'entrée unique de la recherche (formulaire, Entrée, suggestion). */
private runSearch(rawQuery: string): void {
const parsed = parseQueryTokens(rawQuery);
// Les opérateurs saisis au clavier deviennent des filtres (et disparaissent
// de la requête affichée : ils sont « consommés », donc plus de conflit avec
// la suppression manuelle d'un filtre).
if (Object.keys(parsed.filters).length > 0) {
const merged = normalizeFilters({ ...this.filters(), ...parsed.filters });
this.filters.set(merged);
this.filtersChange.emit(merged);
const clean = parsed.query;
if (clean && clean !== String(rawQuery || '').trim()) this.setQueryValue(clean);
}
const q = parsed.query;
if (q.length < 2) return;
const prov = this.providersForRequest();
this.telemetry.track('search_submit', {
providers: Array.isArray(prov) ? prov : ['all'],
queryLength: q.length,
filters: normalizeFilters(this.filters()),
});
this.submitted.emit({ q, providers: prov, filters: normalizeFilters(this.filters()) });
}
onSubmit(ev: Event) {
try { ev.preventDefault(); } catch {}
this.suggestionsOpen.set(false);
this.suggestIndex.set(-1);
let q = (this.query() || '').trim();
if ((!q || q.length === 0) && this.qInputRef?.nativeElement) {
try {
const raw = this.qInputRef.nativeElement.value || '';
q = String(raw).trim();
} catch {}
this.closeSuggestions();
this.atOpen.set(false);
// On passe la saisie BRUTE à runSearch : c'est lui qui extrait les
// opérateurs (`live:`…) et les convertit en filtres.
let raw = (this.query() || '').trim();
if (!raw && this.qInputRef?.nativeElement) {
try { raw = String(this.qInputRef.nativeElement.value || '').trim(); } catch {}
}
if (q.length < 2) return;
const prov = this.providersForRequest();
this.telemetry.track('search_submit', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: q.length });
// Emit payload to parent (Header/Search page)
this.submitted.emit({ q, providers: prov });
if (parseQueryTokens(raw).query.length < 2) {
this.focusInputSafe();
return;
}
this.runSearch(raw);
}
openPicker() {
this.saveFocus();
this.pickerOpen.set(true);
this.telemetry.track('provider_picker_open');
}
closePicker() {
if (!this.pickerOpen()) return;
this.pickerOpen.set(false);
this.restoreFocus();
/**
* Autocomplétion des opérateurs : uniquement quand l'utilisateur a tapé un
* « : » (`linux live:` -> suggestions live/live-only). Pas de bruit pendant
* une saisie normale.
*/
operatorSuggestions(): string[] {
const partial = this.operatorPartial();
if (partial === null) return [];
return operatorTokenSuggestions(partial, 6);
}
// Quick menu open/close with focus management (a11y)
openQuickMenu() {
this.saveFocus();
this.quickOpen.set(true);
this.telemetry.track('quick_menu_open');
// Load last 15 searches (deduped: same query shown once)
try {
this.history.getSearchHistory(15).subscribe({
next: (items) => {
const seen = new Set<string>();
const clean: SearchHistoryItem[] = [];
for (const it of items || []) {
const k = normSuggest(it?.query || '');
if (!k || seen.has(k)) continue;
seen.add(k);
clean.push(it);
}
this.recentSearches.set(clean);
},
error: () => {},
});
} catch {}
/** Partie saisie après le dernier « : », ou null si aucun jeton en cours. */
private operatorPartial(): string | null {
const m = /(^|\s)[a-zA-Zà-ÿ]{1,12}:([a-zA-Zà-ÿ]{0,12})$/.exec(this.query() || '');
return m ? (m[2] || '') : null;
}
closeQuickMenu() {
if (!this.quickOpen()) return;
this.quickOpen.set(false);
this.restoreFocus();
/** Insère un opérateur proposé et applique le filtre correspondant. */
applyOperatorToken(token: string): void {
const source = String(this.query() || '');
const next = source.replace(/([a-zA-Zà-ÿ]{1,12}:)[a-zA-Zà-ÿ]{0,12}$/, (_m, head: string) => `${head}${token}`);
this.setQueryValue(next);
this.queryUpdate(next);
const merged = normalizeFilters({ ...this.filters(), ...parseQueryTokens(next).filters });
if (!filtersEqual(merged, this.filters())) {
this.filters.set(merged);
this.filtersChange.emit(merged);
}
this.focusInputSafe();
}
// ---------------------------------------------------------------------------
// Focus internals (modales)
// ---------------------------------------------------------------------------
private saveFocus() {
try { this.lastFocused = document.activeElement as HTMLElement | null; } catch { this.lastFocused = null; }
}
@@ -572,62 +897,6 @@ export class SearchBoxComponent {
this.lastFocused = null;
}
// Generic focus trap used by the quick menu dialog (Tab / Shift+Tab)
trapFocus(ev: KeyboardEvent, root: HTMLElement | null | undefined) {
if (ev.key !== 'Tab' || !root) return;
const focusables = Array.from(root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
.filter(el => !el.hasAttribute('disabled'));
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (!ev.shiftKey && active === last) {
ev.preventDefault();
first.focus();
} else if (ev.shiftKey && (active === first || active === root)) {
ev.preventDefault();
last.focus();
}
}
onQuickMenuKeydown(ev: KeyboardEvent) {
this.trapFocus(ev, this.quickDialogRef?.nativeElement);
}
// Accept both legacy payload (array|'all') and new object payload; use any to satisfy strict template binder
applyPicker(payload: any) {
const isObj = payload && typeof payload === 'object' && !Array.isArray(payload);
const prov = (isObj ? payload.selection : payload) as ProviderId[] | 'all';
const rememberDefault = isObj ? !!payload.rememberDefault : false;
if (prov === 'all') {
this.providerMode.set('all');
this.selected.set([]);
} else if (Array.isArray(prov)) {
this.providerMode.set('custom');
this.selected.set(prov);
}
// Persist default selection if requested and user is logged-in (Step 9)
if (rememberDefault) {
const body: any = { defaultProviders: (prov === 'all') ? [] : prov };
try { this.users.updatePreferences(body).subscribe({ next: () => {}, error: () => {} }); } catch {}
}
this.telemetry.track('provider_apply', { providers: Array.isArray(prov) ? prov : ['all'], rememberDefault });
this.closePicker();
}
// Quick menu close/apply (re-using same payload shape as picker)
applyQuickMenu(payload: any) {
this.applyPicker(payload);
this.closeQuickMenu();
}
// Quick menu provider toggles
quickToggleAll() { this.applyQuickMenu({ selection: 'all', rememberDefault: false }); }
quickToggle(id: ProviderId) {
const set = new Set(this.selected());
if (set.has(id)) set.delete(id); else set.add(id);
this.selected.set(Array.from(set));
}
// Internal subject for debouncing query changes
private _qChanges = new Subject<string>();
// Internal subject for debounced typeahead fetches (Step 15)
@@ -0,0 +1,138 @@
<!-- Panneau de filtres de recherche (remplace le ancien sélecteur de providers).
groups/options = mono-sélection (rôle radiogroup), sources = multi-sélection.
Chaque option porte data-group/data-index : le roving tabindex et les
raccourcis flèches du panel s'appuient dessus. -->
<div class="quick-menu-backdrop" role="presentation" (click)="close.emit()">
<div #dialog
class="quick-menu-dialog filter-panel-dialog"
role="dialog" aria-modal="true" aria-labelledby="filterPanelTitle"
(click)="$event.stopPropagation()"
(keydown)="onDialogKeydown($event)"
tabindex="-1">
<!-- En-tête -->
<div class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-700 p-4">
<h2 id="filterPanelTitle" class="text-base font-semibold text-slate-100">
{{ 'filters.title' | t: 'Filtres de recherche' }}
</h2>
<button type="button" (click)="close.emit()"
class="chip shrink-0 rounded bg-slate-700 px-3 py-2 text-slate-100 hover:bg-slate-600"
[attr.aria-label]="'filters.close' | t: 'Fermer les filtres (Esc)'">
Esc
</button>
</div>
<div class="dialog-scroll min-h-0 flex-1 space-y-5 p-4">
<!-- Recherches récentes : raccourci direct vers une recherche passée -->
<section *ngIf="(history || []).length > 0" data-group="history"
aria-label="Recherches récentes" class="space-y-2">
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
{{ 'filters.recent' | t: 'Recherches récentes' }}
</h3>
<div class="flex flex-wrap gap-2">
<button *ngFor="let h of history; let i = index" type="button"
[attr.data-index]="i"
[attr.tabindex]="activeIndex('history') === i ? 0 : -1"
(click)="onPickHistory(h)"
class="chip max-w-full truncate rounded-full border border-slate-600 bg-slate-800 px-3 py-2 text-left text-sm text-slate-200 hover:bg-slate-700"
[title]="h">
<span class="truncate">{{ h }}</span>
</button>
</div>
</section>
<!-- Sources (multi-sélection) -->
<section class="space-y-2">
<div class="flex items-baseline justify-between gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
{{ 'filters.sources' | t: 'Sources' }}
</h3>
<span class="text-[11px] text-slate-500">
{{ allMode() ? ('filters.sources.allHint' | t: 'Toutes les plateformes') : (draft().length + ' / ' + providers.length) }}
</span>
</div>
<div data-group="sources" role="group" [attr.aria-label]="'filters.sources' | t: 'Sources'" class="flex flex-wrap gap-2">
<button type="button" data-index="0"
[attr.tabindex]="activeIndex('sources') === 0 ? 0 : -1"
(click)="toggleAllMode()"
class="chip rounded-full border px-3 py-2 text-sm"
[ngClass]="allMode()
? 'border-slate-300 bg-slate-200 text-slate-900'
: 'border-slate-600 bg-slate-800 text-slate-200 hover:bg-slate-700'"
[attr.aria-pressed]="allMode()">
{{ 'filters.sources.all' | t: 'Toutes' }}
</button>
<button *ngFor="let p of providers; let i = index" type="button"
[attr.data-index]="i + 1"
[attr.tabindex]="activeIndex('sources') === i + 1 ? 0 : -1"
(click)="selectSourceTab(i + 1)"
class="chip inline-flex items-center gap-1.5 rounded-full border px-3 py-2 text-sm"
[ngClass]="isChecked(p.id)
? 'border-slate-300 bg-slate-200 text-slate-900'
: 'border-slate-600 bg-slate-800 text-slate-200 hover:bg-slate-700'"
[attr.aria-pressed]="isChecked(p.id)"
[title]="p.displayName">
<span [class]="isChecked(p.id) ? 'opacity-70' : ''">{{ p.shortLabel }}</span>
<span class="hidden sm:inline">{{ p.displayName }}</span>
</button>
</div>
<label class="inline-flex items-center gap-2 pt-1 text-xs text-slate-300">
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
<span>{{ 'filters.sources.remember' | t: 'Mémoriser comme source par défaut' }}</span>
</label>
</section>
<!-- Filtres (type / période / durée / tri) : un groupe par ligne -->
<section *ngFor="let group of groups()" class="space-y-2">
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
{{ group.labelKey | t: group.label }}
</h3>
<div [attr.data-group]="group.key" role="radiogroup"
[attr.aria-label]="group.labelKey | t: group.label"
class="flex flex-wrap gap-2">
<button *ngFor="let opt of group.options; let i = index" type="button"
role="radio"
[attr.data-index]="i"
[attr.tabindex]="activeIndex(group.key) === i ? 0 : -1"
(click)="selectFilter(group.key, opt.value)"
class="chip rounded-full border px-3 py-2 text-sm"
[ngClass]="opt.selected
? 'border-red-500 bg-red-600 text-white'
: 'border-slate-600 bg-slate-800 text-slate-200 hover:bg-slate-700'"
[attr.aria-checked]="opt.selected">
{{ opt.key | t: opt.label }}
</button>
</div>
</section>
<p class="pb-1 text-[11px] leading-relaxed text-slate-500">
{{ 'filters.shortcutHint' | t: 'Astuce : les opérateurs' }}
<span class="font-mono text-slate-400">live:</span>,
<span class="font-mono text-slate-400">shorts:</span>,
<span class="font-mono text-slate-400">today:</span>,
<span class="font-mono text-slate-400">long:</span>
{{ 'filters.shortcutHintEnd' | t: 'écrits dans la barre de recherche sont convertis en filtres.' }}
</p>
</div>
<!-- Pied : réinitialiser / appliquer -->
<div class="flex shrink-0 flex-col-reverse gap-2 border-t border-slate-700 p-4 sm:flex-row sm:items-center sm:justify-between">
<button type="button" (click)="reset()"
class="chip rounded px-3 py-3 text-sm text-slate-300 hover:bg-slate-800 disabled:opacity-40"
[disabled]="!dirty() && allMode()">
{{ 'filters.reset' | t: 'Réinitialiser' }}
</button>
<div class="flex flex-col-reverse gap-2 sm:flex-row">
<button type="button" (click)="close.emit()"
class="rounded bg-slate-700 px-4 py-3 text-slate-100 hover:bg-slate-600">
{{ 'filters.cancel' | t: 'Annuler' }}
</button>
<button type="button" data-action="apply" (click)="onApply()"
class="inline-flex items-center justify-center gap-2 rounded bg-red-600 px-4 py-3 text-white hover:bg-red-500">
{{ 'filters.apply' | t: 'Appliquer' }}
<span *ngIf="activeCount() > 0" class="rounded bg-red-800 px-1.5 text-xs">{{ activeCount() }}</span>
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,314 @@
import {
Component, EventEmitter, Output, Input, signal, computed,
ElementRef, ViewChild, AfterViewInit, OnDestroy, ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '../../pipes/translate.pipe';
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
import {
DEFAULT_FILTERS, filterGroups, normalizeFilters, activeFilterCount,
type SearchFilters, type FilterChoice,
} from '../../app/search/filters';
export interface FilterPanelApply {
selection: ProviderId[] | 'all';
filters: SearchFilters;
rememberDefault: boolean;
}
/** Identifiant interne d'un groupe de filtres (raccourcis clavier). */
type GroupKey = 'sources' | 'type' | 'period' | 'duration' | 'sort' | 'history';
interface RovingGroup {
key: GroupKey;
/** Nombre d'onglets (tabindex) dans le groupe. */
size: number;
index: number;
}
/**
* Panneau de filtres de recherche — remplace l'ancien « picker » de providers.
*
* Un seul panneau pour *tous* les filtres (sources, type de contenu, période,
* durée, tri) + les recherches récentes, pilotable entièrement au clavier :
* - Tab / Maj+Tab : navigation normale entre les groupes (piégé dans le dialogue)
* - ← / → : option précédente / suivante dans le groupe (roving tabindex)
* - ↑ / ↓ : groupe précédent / suivant
* - Entrée / Espace : sélectionner l'option
* - Échap : fermer
*/
@Component({
selector: 'app-search-filter-panel',
standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe],
templateUrl: './search-filter-panel.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchFilterPanelComponent implements AfterViewInit, OnDestroy {
@Input() open = false;
/** Sélection de sources courante ('all' ou une liste d'ids). */
@Input() set selected(value: ProviderId[] | 'all') {
const all = value === 'all' || !Array.isArray(value) || value.length === 0;
this._allMode.set(all);
this._draft.set(all ? [] : [...value]);
// Onglet actif du groupe « sources » : « Toutes » ou la 1re source cochée.
const firstChecked = all ? 0 : (PROVIDERS.findIndex((p) => this._draft().includes(p.id)) + 1);
this._active.update((a) => ({ ...a, sources: Math.max(0, firstChecked) }));
}
/** Filtres courants (valeurs déjà appliquées). */
@Input() set filters(value: Partial<SearchFilters> | null | undefined) {
const n = normalizeFilters(value);
this._filters.set(n);
// Chaque groupe démarre sur son option sélectionnée (Tab retrouve le bon point).
this._active.update((a) => {
const next = { ...a };
for (const g of filterGroups(n)) {
const idx = g.options.findIndex((o) => o.selected);
if (idx >= 0) (next as any)[g.key] = idx;
}
return next;
});
}
/** Recherches récentes (raccourci vers une recherche déjà faite). */
@Input() history: string[] = [];
/** Requête courante (affichée dans le champ de recherche du panneau). */
@Input() query = '';
@Output() close = new EventEmitter<void>();
@Output() apply = new EventEmitter<FilterPanelApply>();
/** Rappelé quand l'utilisateur choisit une source de recherche récente. */
@Output() pickHistory = new EventEmitter<string>();
// --- brouillon (rien n'est appliqué avant « Appliquer ») ---
private _draft = signal<ProviderId[]>([]);
private _allMode = signal(true);
private _filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
rememberDefault = signal(false);
/** Index de l'onglet actif par groupe (roving tabindex). */
private _active = signal<Record<GroupKey, number>>({
sources: 0, type: 0, period: 0, duration: 0, sort: 0, history: 0,
});
@ViewChild('dialog', { static: false }) dialogRef?: ElementRef<HTMLDivElement>;
readonly providers = PROVIDERS;
readonly draft = computed(() => this._draft());
readonly allMode = computed(() => this._allMode() && this._draft().length === 0);
/** Filtres en cours d'édition (le brouillon). */
readonly draftFilters = computed(() => this._filters());
/** Groupes de filtres (type / période / durée / tri) avec l'état sélectionné. */
readonly groups = computed(() => filterGroups(this._filters()));
readonly activeCount = computed(() => activeFilterCount(this._filters()));
/** Le panneau a-t-il été modifié depuis l'ouverture ? */
readonly dirty = computed(() => {
return JSON.stringify(this._filters()) !== JSON.stringify(DEFAULT_FILTERS) || !this.allMode();
});
/** Nombre total d'onglets du groupe « sources » (Toutes + N providers). */
readonly sourcesSize = PROVIDERS.length + 1;
isChecked(id: ProviderId): boolean {
if (this.allMode()) return true;
return this._draft().includes(id);
}
/** Onglet coché/actif du groupe sources (0 = « Toutes »). */
isSourceTabActive(index: number): boolean {
if (index === 0) return this.allMode();
return this.selectedProviderIdAt(index - 1) != null && !this.allMode() && this.isChecked(this.selectedProviderIdAt(index - 1)!);
}
private selectedProviderIdAt(i: number): ProviderId | null {
return this.providers[i]?.id ?? null;
}
toggleAllMode(): void {
this._allMode.set(true);
this._draft.set([]);
}
toggle(id: ProviderId): void {
if (this.allMode()) {
// Sortir du mode « Toutes » en décochant une source : on part de la liste
// complète puis on retire celle-ci (« toutes sauf celle-là »).
const set = new Set(PROVIDERS.map((p) => p.id));
set.delete(id);
this._draft.set(Array.from(set));
this._allMode.set(false);
return;
}
const set = new Set(this._draft());
if (set.has(id)) set.delete(id);
else set.add(id);
this._draft.set(Array.from(set));
this._allMode.set(false);
}
/** Sélectionne un onglet du groupe « sources » (index 0 = Toutes). */
selectSourceTab(index: number): void {
this._active.update((a) => ({ ...a, sources: index }));
if (index <= 0) { this.toggleAllMode(); return; }
const id = this.selectedProviderIdAt(index - 1);
if (id) this.toggle(id);
}
/** Sélectionne une option d'un groupe de filtres mono-sélection. */
selectFilter(groupKey: string, value: string): void {
this._filters.set({ ...this._filters(), [groupKey]: value } as SearchFilters);
// Le roving tabindex suit la sélection : Tab retombe sur l'option active.
const group = this.groups().find((g) => g.key === groupKey);
const idx = group ? this.optionIndexOf(group, value) : 0;
if (idx >= 0) this._active.update((a) => ({ ...a, [groupKey as GroupKey]: idx }));
}
/** Onglet actif (roving tabindex) d'un groupe. */
activeIndex(key: string): number {
return this._active()[key as GroupKey] ?? 0;
}
optionIndexOf(group: { options: readonly FilterChoice[] }, value: string): number {
return (group?.options || []).findIndex((o) => o.value === value);
}
reset(): void {
this._filters.set({ ...DEFAULT_FILTERS });
this._allMode.set(true);
this._draft.set([]);
this.rememberDefault.set(false);
}
onApply(): void {
const selection: ProviderId[] | 'all' = this.allMode() ? 'all' : this._draft();
this.apply.emit({ selection, filters: normalizeFilters(this._filters()), rememberDefault: this.rememberDefault() });
}
onPickHistory(text: string): void {
this.pickHistory.emit(text);
}
// -------------------------------------------------------------------------
// Clavier
// -------------------------------------------------------------------------
private groupOf(target: EventTarget | null): GroupKey | null {
const el = (target as HTMLElement | null)?.closest?.('[data-group]');
const key = el?.getAttribute('data-group');
return (key as GroupKey) || null;
}
private groupIndex(key: GroupKey): number {
const order: GroupKey[] = ['history', 'sources', 'type', 'period', 'duration', 'sort'];
return order.indexOf(key);
}
private focusTab(key: GroupKey, index: number): void {
this._active.update((a) => ({ ...a, [key]: index }));
// Le DOM n'est repeint qu'après le cycle de détection : on attend un tick.
setTimeout(() => {
try {
const el = this.dialogRef?.nativeElement
?.querySelector<HTMLElement>(`[data-group="${key}"][data-index="${index}"]`);
el?.focus();
} catch {}
});
}
onDialogKeydown(ev: KeyboardEvent): void {
if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
this.close.emit();
return;
}
if (ev.key === 'Tab') { this.trapFocus(ev); return; }
const key = this.groupOf(ev.target);
if (!key) return;
const cur = this._active()[key] ?? 0;
const size = this.groupSize(key);
const orderIdx = this.groupIndex(key);
const move = (delta: number) => {
const next = (cur + delta + size) % size;
ev.preventDefault();
this.focusTab(key, next);
};
switch (ev.key) {
case 'ArrowRight':
case 'ArrowDown':
// ↓ en milieu de groupe descend d'un onglet, au dernier il passe au
// groupe suivant (comportement « menu » classique, moins de tabulations).
if (ev.key === 'ArrowDown' && cur < size - 1) { move(1); return; }
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.focusGroupByOrder(orderIdx + 1, cur); return; }
move(1);
return;
case 'ArrowLeft':
if (ev.key === 'ArrowLeft' && cur > 0) { move(-1); return; }
move(-1);
return;
case 'ArrowUp': {
ev.preventDefault();
this.focusGroupByOrder(orderIdx - 1, cur);
return;
}
case 'Home': ev.preventDefault(); this.focusTab(key, 0); return;
case 'End': ev.preventDefault(); this.focusTab(key, size - 1); return;
default:
}
}
/** Saute au groupe voisin en gardant une position d'onglet cohérente. */
private focusGroupByOrder(orderIdx: number, fallbackIndex: number): void {
const order: GroupKey[] = ['history', 'sources', 'type', 'period', 'duration', 'sort'];
if (orderIdx < 0) return;
if (orderIdx >= order.length) {
// Après le dernier groupe : on cible le bouton « Appliquer ».
setTimeout(() => {
try { this.dialogRef?.nativeElement?.querySelector<HTMLElement>('[data-action="apply"]')?.focus(); } catch {}
});
return;
}
const key = order[orderIdx];
const size = this.groupSize(key);
this.focusTab(key, Math.min(fallbackIndex, size - 1));
}
private groupSize(key: GroupKey): number {
switch (key) {
case 'sources': return this.sourcesSize;
case 'history': return Math.max(1, (this.history || []).length);
default: {
const g = this.groups().find((x) => x.key === key);
return Math.max(1, g?.options.length || 1);
}
}
}
/** Piégeage du focus (Tab / Maj+Tab) à l'intérieur du dialogue. */
private trapFocus(ev: KeyboardEvent): void {
const root = this.dialogRef?.nativeElement;
if (!root) return;
const focusables = Array.from(
root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1');
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (!ev.shiftKey && active === last) { ev.preventDefault(); first.focus(); }
else if (ev.shiftKey && (active === first || active === root)) { ev.preventDefault(); last.focus(); }
}
// -------------------------------------------------------------------------
// Focus
// -------------------------------------------------------------------------
ngAfterViewInit(): void {
// Le dialogue reçoit le focus à l'ouverture (pas le premier bouton : on
// laisse l'utilisateur choisir où il atterrit avec Tab / flèches).
queueMicrotask(() => {
try { this.dialogRef?.nativeElement?.focus(); } catch {}
});
}
ngOnDestroy(): void {
try { document.body.style.overflow = ''; } catch {}
}
}
+64 -55
View File
@@ -25,23 +25,28 @@
<p class="text-slate-400">{{ 'search.hint' | t }}</p>
}
@if (hasQuery() && availableTags().length > 0) {
<div class="scroll-rail no-scrollbar mb-4 flex items-center gap-2 pb-1">
@for (tag of availableTags(); track tag.key) {
<button
class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
[ngClass]="{
'bg-slate-200 text-slate-900 border-slate-200': filterTag() === tag.key,
'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700': filterTag() !== tag.key
}"
(click)="setFilterTag(tag.key)">
{{ tag.label }}
</button>
}
</div>
}
@if (hasQuery()) {
<!-- Pastilles de filtres actifs : rappel immédiat + retrait en un clic.
Elles écrivent l'URL, donc la requête serveur repart filtrée. -->
@if (activeFilterChips().length > 0) {
<div class="mb-3 flex flex-wrap items-center gap-2">
<span class="text-xs uppercase tracking-wide text-slate-400">Filtres</span>
@for (chip of activeFilterChips(); track chip.key) {
<button type="button" (click)="clearFilter(chip.key)"
class="chip inline-flex items-center gap-1 rounded-full border border-red-500/60 bg-red-600/20 px-2.5 py-1 text-xs text-red-200 hover:bg-red-600/40">
{{ chip.label }} <span aria-hidden="true">✕</span>
</button>
}
<button type="button" (click)="clearAllFilters()"
class="chip rounded-full px-2.5 py-1 text-xs text-slate-400 underline hover:text-slate-200">
Tout effacer
</button>
<span class="text-[11px] text-slate-500">
{{ filteredResults().length }} / {{ allVideos().length }} résultats
</span>
</div>
}
<!-- Pills de type façon directory Twitch : Directs / Vidéos / Clips / Chaînes -->
<div class="scroll-rail no-scrollbar flex items-center gap-2 pb-1" role="tablist" aria-label="Filtrer par type de contenu">
<button class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
@@ -59,9 +64,9 @@
[attr.aria-pressed]="typePill() === 'video'" (click)="setTypePill('video')">
Vidéos ({{ typeCounts().video }})
</button>
<button *ngIf="typeCounts().clip > 0 || typePill() === 'short'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
[ngClass]="typePill() === 'short' ? 'bg-red-600 text-white border-red-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'short'" (click)="setTypePill('short')">
<button *ngIf="typeCounts().clip > 0 || typePill() === 'shorts'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
[ngClass]="typePill() === 'shorts' ? 'bg-red-600 text-white border-red-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'shorts'" (click)="setTypePill('shorts')">
Clips ({{ typeCounts().clip }})
</button>
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
@@ -70,61 +75,65 @@
Chaînes ({{ typeCounts().channel }})
</button>
</div>
<!-- Barre de filtrage (même UX que la page thème / fournisseur) -->
<!-- Barre de filtrage : les 4 dimensions principales sont appliquées par le
serveur (elles vivent dans l'URL), la langue reste locale. -->
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-4 my-4">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
<div>
<label for="sortSelect" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.sort' | t }}</label>
<label for="sortSelect" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.sort' | t: 'Trier par' }}</label>
<select id="sortSelect" 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"
[value]="sortParam()" (change)="onSortChange($event)">
<option value="relevance">{{ 'filter.sort.relevance' | t }}</option>
<option value="date">{{ 'filter.sort.date' | t }}</option>
<option value="views">{{ 'filter.sort.views' | t }}</option>
[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>
</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" 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"
[value]="durationFilter()" (change)="onDurationChange($event)">
<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>
<label for="period-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.period' | t: 'Période' }}</label>
<select id="period-filter" 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"
[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>
</div>
<div>
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.type' | t }}</label>
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.type' | t: 'Type de contenu' }}</label>
<select id="type-filter" 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"
[value]="typeFilter()" (change)="onTypeChange($event)">
<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>
[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>
</div>
<div>
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t }}</label>
<label for="duration-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.duration' | t: 'Durée' }}</label>
<select id="duration-filter" 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"
[value]="filters().duration" (change)="onDurationChange($event)">
<option value="all">{{ 'filters.duration.all' | t: 'Toutes' }}</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>
</div>
<div>
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t: 'Langue' }}</label>
<select id="language-filter" 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"
[value]="languageFilter()" (change)="onLanguageChange($event)">
<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" 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"
[value]="dateFilter()" (change)="onDateChange($event)">
<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>
<option value="any">{{ 'filter.language.any' | t: 'Toutes' }}</option>
<option value="en">{{ 'filter.language.en' | t: 'Anglais' }}</option>
<option value="fr">{{ 'filter.language.fr' | t: 'Français' }}</option>
</select>
</div>
</div>
<p class="mt-3 text-[11px] text-slate-500">
{{ '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()) {
+166 -277
View File
@@ -11,8 +11,13 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { isShortVideo } from 'src/app/shared/utils/video-kind';
import { SearchService, type SearchResponse } from '../../app/search/search.service';
import {
DEFAULT_FILTERS, activeFilterCount, filtersToQueryParams, filtersFromQueryParams,
normalizeFilters, matchesFilters, filterGroups, itemDurationSec, itemPublishedTs,
isLiveItem, isShortItem, isChannelItem, isClipItem,
type SearchFilters, type FilterDuration, type FilterPeriod, type FilterType, type FilterSort,
} from '../../app/search/filters';
import { UserService, type UserPreferences } from '../../services/user.service';
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
import { Observable, Subscription } from 'rxjs';
@@ -46,6 +51,13 @@ export class SearchComponent {
private defaultProvidersPref = signal<ProviderId[] | null>(null);
pageParam = signal<number>(1);
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
/**
* Filtres de recherche. Ils vivent dans l'URL (`?type=&duration=&period=&sort=`)
* ET sont poussés dans `SearchService` : le serveur filtre vraiment ses
* résultats (InnerTube/Data API quand c'est possible, post-filtrage sinon).
* Les filtres locaux ci-dessous ne font plus qu'un dernier raffinement.
*/
filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
// Unified multi-provider response (grouped suggestions)
groups = signal<Record<string, any[]>>({});
@@ -168,16 +180,29 @@ export class SearchComponent {
// Public computed used by the template to avoid referencing private `instances`
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
// Active tag filter. For non-Twitch providers: 'all' | 'short' | 'medium' | 'long' | 'recent'.
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
filterTag = signal<string>('all');
// Filter bar (same UX as the provider/theme page): client-side selects.
// Server-side sort stays on `sortParam` (URL); these four filter locally.
durationFilter = signal<'any' | 'short' | 'medium' | 'long'>('any');
typeFilter = signal<'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'>('any');
// 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');
languageFilter = signal<'any' | 'en' | 'fr'>('any');
dateFilter = signal<'any' | 'day' | 'week' | 'month' | 'year'>('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(() => {
@@ -201,74 +226,22 @@ export class SearchComponent {
const list = this.allVideos();
if (!Array.isArray(list) || list.length === 0) return [];
const filtered = this.applyFilterBar(this.applyTagFilter(list));
const filtered = this.applyFilterBar(list);
return this.sortVideos(filtered);
});
// Filter each provider group separately for sectioned rendering
filteredGroups = computed(() => {
const tagPredicate = this.buildTagPredicate();
const barPredicate = this.buildFilterBarPredicate();
const g = this.groups();
const result: Record<string, any[]> = {};
Object.entries(g || {}).forEach(([pid, list]) => {
result[pid] = Array.isArray(list) ? list.filter((v) => tagPredicate(v) && barPredicate(v)) : [];
result[pid] = Array.isArray(list) ? list.filter((v) => barPredicate(v)) : [];
});
return result;
});
// Available tags computed from current results (only show tags that can apply)
availableTags = computed(() => {
const list = this.allVideos();
if (list.length === 0) return [];
const tags: { key: string; label: string; show: boolean }[] = [];
const now = Date.now();
// Non-Twitch providers: duration + date filters
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
let hasShort = false, hasMedium = false, hasLong = false,
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
for (const v of list) {
const d = Number(v.durationSec || 0);
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
const timeDiff = now - uploadTime;
// Duration filters
if (d > 0 && d < 4 * 60) hasShort = true;
else if (d >= 4 * 60 && d < 20 * 60) hasMedium = true;
else if (d >= 20 * 60) hasLong = true;
// Date filters
if (uploadTime > 0) {
if (timeDiff <= oneDay) hasToday = true;
if (timeDiff <= sevenDays) hasThisWeek = true;
if (timeDiff <= thirtyDays) hasThisMonth = true;
if (timeDiff <= oneYear) hasThisYear = true;
}
}
// Add duration filters
tags.push({ key: 'all', label: 'Tout', show: list.length > 0 });
tags.push({ key: 'short', label: 'Moins de 4 min', show: hasShort });
tags.push({ key: 'medium', label: 'De 4 à 20 min', show: hasMedium });
tags.push({ key: 'long', label: '20 min et plus', show: hasLong });
// Add date filters
tags.push({ key: 'today', label: 'Aujourd\'hui', show: hasToday });
tags.push({ key: 'this_week', label: 'Cette semaine', show: hasThisWeek });
tags.push({ key: 'this_month', label: 'Ce mois-ci', show: hasThisMonth });
tags.push({ key: 'this_year', label: 'Cette année', show: hasThisYear });
return tags.filter(t => t.show);
});
constructor() {
// Update document title when provider or query changes
effect(() => {
@@ -356,32 +329,38 @@ export class SearchComponent {
this.pageParam.set(pageNum);
this.unified.setPage(pageNum);
if (isFinite(pageSize)) this.unified.setPageSize(pageSize);
const sortNorm = (sort === 'date' || sort === 'views') ? sort : 'relevance';
this.sortParam.set(sortNorm);
this.unified.setSort(sortNorm);
// Filtres (type / durée / période / tri) : lus dans l'URL puis poussés
// 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.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) {
this.notice.set(null);
// Reset active tag + filter bar on new query
const provider = prov || this.instances.selectedProvider();
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
this.durationFilter.set('any');
this.typeFilter.set('any');
// Reset the language filter on a new query (the other filters are URL
// driven and must survive a query change).
this.languageFilter.set('any');
this.dateFilter.set('any');
const activeProvider = prov || this.instances.selectedProvider();
// Use unified search in all cases (URL providers > user default preference > single provider).
// Normalized to short ids so every adapter resolves (legacy full names like
// 'youtube' would otherwise match no adapter and return nothing).
const rawProviders = providersList || this.defaultProvidersPref() || [provider as ProviderId];
const rawProviders = providersList || this.defaultProvidersPref() || [activeProvider as ProviderId];
const providersToUse: ProviderId[] | 'all' = rawProviders === 'all'
? 'all'
: (rawProviders as unknown[]).map(p => this.toProviderId(p)).filter((p): p is ProviderId => !!p);
this.unified.setQuery(q);
this.unified.setProviders(providersToUse);
// Determine if this is a new search (signature excludes page)
const signature = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}|${sortNorm}`;
// Determine if this is a new search (signature excludes page but
// includes the filters: changing a filter restarts the results).
const signature = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}|${filters.sort}|${filters.type}.${filters.duration}.${filters.period}`;
const isNewSearch = this.lastSearchSignature !== signature || pageNum === 1;
this.lastSearchSignature = signature;
@@ -390,7 +369,7 @@ export class SearchComponent {
const key = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}`;
if (this.lastRecordedKey !== key) {
this.lastRecordedKey = key;
const recFilters: any = { provider, providers: Array.isArray(providersToUse) ? providersToUse : 'all' };
const recFilters: any = { provider: activeProvider, providers: Array.isArray(providersToUse) ? providersToUse : 'all' };
this.history.recordSearch(q, recFilters).subscribe({
next: () => {},
error: (err) => console.error('Error recording search:', err)
@@ -424,28 +403,11 @@ export class SearchComponent {
}
private durationSeconds(item: any): number {
const raw = item?.durationSec ?? item?.duration ?? item?.length ?? 0;
if (typeof raw === 'number') {
return Number.isFinite(raw) ? raw : 0;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : 0;
}
private publishedTimestamp(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 === 'string' && raw.trim()) {
const parsed = Date.parse(raw);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return 0;
}
// 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;
@@ -456,95 +418,13 @@ export class SearchComponent {
return Number.isFinite(parsed) ? parsed : 0;
}
private buildTagPredicate(): (item: any) => boolean {
const tag = this.filterTag();
const provider = this.selectedProviderForView();
if (provider === 'twitch' || tag === 'all' || tag?.startsWith('twitch')) {
return () => true;
}
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
switch (tag) {
case 'short':
return (item) => {
const d = this.durationSeconds(item);
return d > 0 && d < 4 * 60;
};
case 'medium':
return (item) => {
const d = this.durationSeconds(item);
return d >= 4 * 60 && d < 20 * 60;
};
case 'long':
return (item) => {
const d = this.durationSeconds(item);
return d >= 20 * 60;
};
case 'today':
return (item) => {
const ts = this.publishedTimestamp(item);
return ts > 0 && (now - ts) <= oneDay;
};
case 'this_week':
return (item) => {
const ts = this.publishedTimestamp(item);
return ts > 0 && (now - ts) <= sevenDays;
};
case 'this_month':
return (item) => {
const ts = this.publishedTimestamp(item);
return ts > 0 && (now - ts) <= thirtyDays;
};
case 'this_year':
return (item) => {
const ts = this.publishedTimestamp(item);
return ts > 0 && (now - ts) <= oneYear;
};
default:
return () => true;
}
}
private applyTagFilter(list: any[]): any[] {
const predicate = this.buildTagPredicate();
return Array.isArray(list) ? list.filter(predicate) : [];
}
// ---- Filter bar (same UX as the provider/theme page) ----
private isLiveItem(item: any): boolean {
if (!item) return false;
if (item.isLive === true) return true;
// Strict: only an explicit 'live' type counts (unlike the shared util,
// offline Twitch channels typed 'channel' must NOT match).
return String(item.type || '').toLowerCase() === 'live';
}
private isShortItem(item: any): boolean {
if (!item) return false;
return isShortVideo({
duration: this.durationSeconds(item),
type: typeof item.type === 'string' ? item.type : undefined,
provider: typeof item.provider === 'string' ? item.provider : undefined,
});
}
private isChannelItem(item: any): boolean {
if (!item) return false;
// Chaîne Twitch hors-ligne : type explicite 'channel' et jamais en direct.
if (String(item.type || '').toLowerCase() === 'channel' && item.isLive !== true) return true;
return false;
}
private isClipItem(item: any): boolean {
if (!item) return false;
return String((item as any)?.kind || '').toLowerCase() === 'clip';
}
// Les 4 dimensions principales vivent dans l'URL (filtres serveur) ; la
// langue reste purement locale (aucun provider ne filtre par langue).
private isLiveItem(item: any): boolean { return isLiveItem(item); }
private isShortItem(item: any): boolean { return isShortItem(item); }
private isChannelItem(item: any): boolean { return isChannelItem(item); }
private isClipItem(item: any): boolean { return isClipItem(item); }
/** Compteurs par type pour les pills façon directory Twitch. */
typeCounts = computed(() => {
@@ -560,71 +440,42 @@ export class SearchComponent {
});
/** Pills Tous / Directs / Vidéos / Clips / Chaînes synchronisées avec typeFilter. */
typePill = computed<'all' | 'live' | 'video' | 'short' | 'channel'>(() => {
const t = this.typeFilter();
typePill = computed<'all' | 'live' | 'video' | 'shorts' | 'channel'>(() => {
const t = this.filters().type;
if (t === 'live') return 'live';
if (t === 'video' || t === 'recorded') return 'video';
if (t === 'short') return 'short';
if (t === 'video') return 'video';
if (t === 'shorts') return 'shorts';
if (t === 'channel') return 'channel';
return 'all';
});
setTypePill(v: 'all' | 'live' | 'video' | 'short' | 'channel'): void {
if (v === 'all') this.typeFilter.set('any');
else if (v === 'video') this.typeFilter.set('video');
else this.typeFilter.set(v);
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
* pas supportée par les providers.
*/
private buildFilterBarPredicate(): (item: any) => boolean {
const duration = this.durationFilter();
const type = this.typeFilter();
const filters = this.filters();
const lang = this.languageFilter();
const date = this.dateFilter();
if (duration === 'any' && type === 'any' && lang === 'any' && date === 'any') {
return () => true;
}
const now = Date.now();
const cutoff = date === 'any' ? 0 : {
day: now - 24 * 3600 * 1000,
week: now - 7 * 24 * 3600 * 1000,
month: now - 30 * 24 * 3600 * 1000,
year: now - 365 * 24 * 3600 * 1000,
}[date] || 0;
const isFrText = (s: string) => /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
const isEnText = (s: string) => /\b(the|a|an|and|for)\b/i.test(s);
const hasServerFilter = activeFilterCount(filters) > 0;
if (!hasServerFilter && lang === 'any') return () => true;
return (item: any) => {
if (duration !== 'any') {
const d = this.durationSeconds(item);
if (duration === 'short' && !(d > 0 && d < 240)) return false;
if (duration === 'medium' && !(d >= 240 && d < 1200)) return false;
if (duration === 'long' && !(d >= 1200)) return false;
}
if (type !== 'any') {
const live = this.isLiveItem(item);
const short = this.isShortItem(item);
const channel = this.isChannelItem(item);
const clip = this.isClipItem(item);
if (type === 'live' && !live) return false;
if (type === 'channel' && !channel) return false;
if (type === 'short' && !(short || clip) && !live && !channel) {
// Les clips Twitch (kind='clip', parfois sans durée) comptent comme Shorts.
if (!clip) return false;
}
if (type === 'short' && (live || channel)) return false;
// 'video' / 'recorded' = VODs (ni live, ni short/clip, ni chaîne).
if ((type === 'video' || type === 'recorded') && (live || short || clip || channel)) return false;
}
if (lang !== 'any') {
const text = `${item?.title || ''} ${item?.channelName || ''}`.toLowerCase();
if (lang === 'fr' && !isFrText(text)) return false;
if (lang === 'en' && !(isEnText(text) && !isFrText(text))) return false;
}
if (cutoff > 0 && this.publishedTimestamp(item) < cutoff) return false;
return true;
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);
};
}
@@ -633,19 +484,66 @@ export class SearchComponent {
return Array.isArray(list) ? list.filter(predicate) : [];
}
/**
* Écrit un filtre : l'URL devient la source de vérité (donc la requête
* serveur repart avec le nouveau périmètre) et le signal local suit.
*/
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');
const cleanParams = this.cleanUrlParams({
...this.route.snapshot.queryParams,
...filtersToQueryParams(next),
page: 1,
});
// Supprime les clés devenues 'all' (elles ne sont plus réinjectées).
for (const k of ['type', 'duration', 'period', 'sort']) {
if (!filtersToQueryParams(next)[k]) delete (cleanParams as any)[k];
}
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
replaceUrl: true,
});
}
/** 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 });
for (const k of ['type', 'duration', 'period', 'sort']) delete (cleanParams as any)[k];
this.router.navigate([], {
relativeTo: this.route, queryParams: cleanParams, replaceUrl: true,
});
}
onDurationChange(event: Event) {
this.durationFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
void this.setFilter('duration', ((event.target as HTMLSelectElement)?.value as any) || 'all');
}
onTypeChange(event: Event) {
this.typeFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
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) {
this.dateFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
void this.setFilter('period', ((event.target as HTMLSelectElement)?.value as any) || 'all');
}
private sortVideos(list: any[]): any[] {
if (!Array.isArray(list)) {
return [];
@@ -771,30 +669,37 @@ export class SearchComponent {
}
// Submit handler from the embedded SearchBox: keep URL as source of truth
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all' }) {
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all'; filters?: SearchFilters }) {
const q = (evt?.q || '').trim();
if (!q) return;
// Créer un nouvel objet de paramètres de requête
const queryParams: any = {
q,
// Les filtres actifs sont conservés : relancer une recherche ne doit pas
// perdre le périmètre choisi.
const filters = evt?.filters ? normalizeFilters(evt.filters) : this.filters();
const queryParams: any = {
...this.route.snapshot.queryParams,
q,
p: this.providerParam() || this.instances.selectedProvider(),
page: 1 // reset pagination on new search
...filtersToQueryParams(filters),
page: 1, // reset pagination on new search
};
for (const k of ['type', 'duration', 'period', 'sort']) {
if (!filtersToQueryParams(filters)[k]) delete queryParams[k];
}
// Gérer les fournisseurs
if (Array.isArray(evt.providers)) {
queryParams.providers = evt.providers.join(',');
} else if (evt.providers === 'all') {
queryParams.providers = 'yt,dm,tw,pt,od,ru';
}
// Nettoyer les paramètres avant la navigation
const cleanParams = this.cleanUrlParams(queryParams);
// Utiliser replaceUrl: true pour éviter d'ajouter une entrée dans l'historique
// et forcer le rechargement avec les nouveaux paramètres
this.router.navigate([], {
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
@@ -819,11 +724,6 @@ export class SearchComponent {
// Vous pouvez ajouter ici la logique de recherche au fur et à mesure si nécessaire
}
// Update active filter tag from the template
setFilterTag(key: string) {
this.filterTag.set(key);
}
// Infinite-scroll pagination: append the next page WITHOUT touching the
// router URL. Navigating (even with replaceUrl) rewrites the location hash
// (HashLocationStrategy) and remounts list state, which throws the user
@@ -866,21 +766,10 @@ export class SearchComponent {
});
}
// Sort control
// Sort control -> même chemin que les autres filtres (URL + service)
onSortChange(event: Event) {
const value = (event.target as HTMLSelectElement)?.value as 'relevance' | 'date' | 'views' | '';
const sort = (value === 'date' || value === 'views') ? value : 'relevance';
const cleanParams = this.cleanUrlParams({
...this.route.snapshot.queryParams,
sort,
page: 1
});
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
replaceUrl: true
});
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