feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s

- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts)
- ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage
- ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error
- Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
This commit is contained in:
2026-09-25 10:15:12 -04:00
parent fe35340948
commit 97e5de74f5
147 changed files with 7954 additions and 77497 deletions
@@ -1,22 +1,22 @@
<div class="fixed inset-0 z-[9999] bg-black/60 flex justify-center items-center" (click)="close.emit()">
<div class="fixed inset-0 z-[9999] bg-black/60" (click)="close.emit()">
<div class="fixed inset-0 grid place-items-center">
<div #dialog
class="w-[90%] max-w-lg bg-slate-900 rounded-xl shadow-2xl border border-slate-700"
role="dialog"
aria-modal="true"
aria-labelledby="providersTitle"
(click)="$event.stopPropagation()"
tabindex="0"
<div #dialog
class="w-[90%] max-w-lg bg-slate-900 rounded-xl shadow-2xl border border-slate-700"
role="dialog"
aria-modal="true"
aria-labelledby="providersTitle"
(click)="$event.stopPropagation()"
tabindex="0"
(keydown)="onDialogKeydown($event)">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
<button (click)="close.emit()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Esc</button>
<button type="button" (click)="close.emit()" class="px-2 py-1 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…" class="w-full px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" aria-label="Filtrer les fournisseurs" class="w-full 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">
<input type="checkbox" [checked]="all()" (change)="toggleAll()"/>
<input type="checkbox" [checked]="allMode()" (change)="toggleAllMode()"/>
<span>All</span>
</label>
</div>
@@ -33,8 +33,9 @@
</label>
</div>
<div class="p-4 border-t border-slate-700 flex items-center justify-end gap-2">
<button (click)="close.emit()" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
<button (click)="onApply()" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
<button type="button" (click)="close.emit()" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
<button type="button" (click)="onApply()" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
</div>
</div>
</div>
</div>
@@ -1,7 +1,7 @@
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
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 } from '../../app/core/providers/provider-registry';
import { ProviderId, PROVIDERS, PROVIDER_MAP } from '../../app/core/providers/provider-registry';
@Component({
selector: 'app-provider-picker',
@@ -9,45 +9,66 @@ import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registr
imports: [CommonModule, FormsModule],
templateUrl: './provider-picker.component.html'
})
export class ProviderPickerComponent implements AfterViewInit {
export class ProviderPickerComponent implements AfterViewInit, OnDestroy {
@Input() open = false;
@Input() selected: ProviderId[] | 'all' = 'all';
// 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 all = computed(() => this.selected === 'all');
readonly providers = computed(() => {
const q = this.filter().toLowerCase();
return PROVIDERS.filter(p => !q || p.displayName.toLowerCase().includes(q) || p.shortLabel.toLowerCase().includes(q));
});
toggleAll() {
this.apply.emit({ selection: 'all', rememberDefault: this.rememberDefault() });
toggleAllMode() {
this._allMode.set(true);
this._draft.set([]);
}
isChecked(id: ProviderId): boolean {
if (this.selected === 'all') return true;
return Array.isArray(this.selected) && this.selected.includes(id);
if (this.allMode()) return true;
return this._draft().includes(id);
}
toggle(id: ProviderId) {
if (this.selected === 'all') {
this.apply.emit({ selection: [id], rememberDefault: this.rememberDefault() });
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.selected || []);
if (set.has(id)) set.delete(id); else set.add(id);
const next = Array.from(set) as ProviderId[];
this.selected = next;
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() {
this.apply.emit({ selection: this.selected, rememberDefault: this.rememberDefault() });
const selection: ProviderId[] | 'all' = this.allMode() ? 'all' : this._draft();
this.apply.emit({ selection, rememberDefault: this.rememberDefault() });
}
ngAfterViewInit() {
@@ -57,8 +78,17 @@ export class ProviderPickerComponent implements AfterViewInit {
});
}
// Basic focus trap within the dialog
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;
@@ -71,7 +101,7 @@ export class ProviderPickerComponent implements AfterViewInit {
if (!ev.shiftKey && active === last) {
ev.preventDefault();
first.focus();
} else if (ev.shiftKey && active === first) {
} else if (ev.shiftKey && (active === first || active === root)) {
ev.preventDefault();
last.focus();
}
+21 -12
View File
@@ -1,4 +1,4 @@
<form class="w-full relative" novalidate (ngSubmit)="onSubmit($event)" (submit)="onSubmit($event)" (keydown)="handleKeydown($event)" role="search" aria-label="Recherche">
<form class="w-full relative" novalidate (submit)="onSubmit($event)" role="search" aria-label="Recherche">
<!-- Input -->
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-3 py-2">
<!-- Provider chips -->
@@ -9,6 +9,7 @@
'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>
@@ -19,20 +20,25 @@
'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)
}"
[title]="'Inclure ' + c.label">
[attr.aria-pressed]="selected().includes(c.id)"
[title]="'Inclure ' + c.label + ' (Alt+' + (i + 1) + ')'">
<span [class]="c.colorClass">{{ c.label }}</span>
</button>
</ng-container>
<!-- Text input -->
<!-- Text input: combobox pattern for the inline '@' autocomplete -->
<input #qInput type="search"
class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2"
[placeholder]="placeholder"
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" name="q"
autocomplete="off" aria-label="Rechercher des vidéos"/>
autocomplete="off" aria-label="Rechercher des vidéos"
role="combobox" aria-expanded="false" aria-autocomplete="list"
[attr.aria-expanded]="atOpen()"
[attr.aria-controls]="atOpen() ? 'atOptionsList' : null"
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : null"/>
<!-- Actions -->
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs">@</button>
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs (@)">@</button>
<button type="submit" class="ml-1 px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white"
(click)="onSubmit($event)"
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null">
@@ -51,10 +57,13 @@
<div *ngIf="atOpen() && atOptions().length > 0"
class="absolute z-50 mt-1 left-3 right-3 rounded-lg shadow-xl overflow-hidden"
style="top: 100%">
<ul class="max-h-64 overflow-y-auto"
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs"
class="max-h-64 overflow-y-auto"
[ngClass]="{ 'bg-slate-900 border border-slate-700 text-slate-200': true }">
<li *ngFor="let opt of atOptions(); let i = index">
<button type="button" (click)="toggleChip(opt.id); atOpen.set(false)"
<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-2 text-sm"
[ngClass]="{
'bg-slate-800': i === atIndex(),
@@ -67,11 +76,11 @@
</div>
<!-- Quick Menu (Ctrl/Cmd+K) -->
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="dialog" aria-modal="true" (click)="closeQuickMenu()">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="document" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" tabindex="0">
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="presentation" (click)="closeQuickMenu()">
<div #quickDialog style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" 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">
<h2 id="quickMenuTitle" class="text-slate-100 font-semibold">Quick Menu</h2>
<button (click)="closeQuickMenu()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" autofocus>Esc</button>
<button type="button" (click)="closeQuickMenu()" class="px-2 py-1 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-2 gap-0 overflow-y-auto">
<!-- History column -->
@@ -80,7 +89,7 @@
<ul class="space-y-1 max-h-80 overflow-y-auto">
<li *ngFor="let it of recentSearches()">
<button type="button" class="w-full text-left px-2 py-1 rounded hover:bg-slate-800 text-slate-200"
(click)="queryUpdate(it.query || '')">{{ it.query }}</button>
(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>
+129 -83
View File
@@ -7,17 +7,20 @@ import { ProviderPickerComponent } from './provider-picker.component';
import { SearchService } from '../../app/search/search.service';
import { UserService, type UserPreferences } from '../../services/user.service';
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
import { TelemetryService } from '../../services/telemetry.service';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
// No overlays; we render fixed-position modals with high z-index
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
@Component({
selector: 'app-search-box',
standalone: true,
imports: [
CommonModule,
FormsModule,
CommonModule,
FormsModule,
ProviderPickerComponent,
HttpClientModule // Nécessaire pour SearchService
],
@@ -32,7 +35,9 @@ export class SearchBoxComponent {
private search = inject(SearchService);
private users = inject(UserService);
private history = inject(HistoryService);
private telemetry = inject(TelemetryService);
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
@ViewChild('quickDialog', { static: false }) quickDialogRef?: ElementRef<HTMLDivElement>;
// Input/Output bindings
@Input() placeholder: string = 'Search videos…';
@@ -46,25 +51,24 @@ export class SearchBoxComponent {
selected = signal<ProviderId[]>([]);
pickerOpen = signal(false);
suggestionsOpen = signal(false);
// Inline @autocomplete state
// Inline @autocomplete state (atOpen is derived from the query, atOptions is pure)
atOpen = signal(false);
atIndex = signal(0);
atOptions = computed(() => {
const q = this.query();
const m = /(^|\s)@([a-z]{1,24})$/i.exec(q);
const m = AT_QUERY_RE.exec(this.query());
if (!m) return [] as { id: ProviderId; label: string }[];
const term = (m[2] || '').toLowerCase();
const opts = PROVIDERS.filter(p =>
p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term)
).map(p => ({ id: p.id, label: p.displayName }));
this.atOpen.set(opts.length > 0);
if (this.atIndex() >= opts.length) this.atIndex.set(0);
return opts as any;
return PROVIDERS
.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[]>([]);
// 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 }));
// Derived state
@@ -100,39 +104,68 @@ export class SearchBoxComponent {
hydratedFromService = true;
});
// Apply default providers from user preferences if none explicitly set
// Apply default providers from user preferences if none explicitly set (Step 9 client hook)
effect(() => {
const prefs: UserPreferences | null = this.users.preferences();
if (!prefs) return;
const list = (prefs as any).defaultProviders as string[] | undefined;
if (Array.isArray(list) && list.length > 0) {
// Keep only known provider ids
const valid = list.filter(id => PROVIDERS.some(p => p.id === (id as ProviderId))) as ProviderId[];
if (valid.length === 0) return;
// Only apply if current mode is 'all' and nothing selected
if (this.providerMode() === 'all' && this.selected().length === 0) {
// Cast to ProviderId[] while trusting server-provided list
this.providerMode.set('custom');
this.selected.set(list as any);
this.selected.set(valid);
}
}
});
// Focus the first focusable element of the quick menu dialog when it opens (a11y)
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();
try {
document.body.style.overflow = open ? 'hidden' : '';
} catch {}
});
}
// Update query and handle inline '@provider' prefix to open filtered picker
// Update query and refresh inline '@provider' autocomplete state
queryUpdate(value: string) {
try { console.log('[SearchBox] queryUpdate', { value }); } catch {}
this.query.set(value);
// Keep SearchService in sync with current text to avoid stale overwrite
try { this.search.q$.next(value); } catch {}
this._qChanges.next(value);
const m = /(^|\s)@([a-z]{1,12})$/i.exec(value);
const hasOptions = this.atOptions().length > 0;
this.atOpen.set(AT_QUERY_RE.test(value) && hasOptions);
if (this.atIndex() >= this.atOptions().length) this.atIndex.set(0);
}
// Pick an option from the inline '@' popover: strip the '@term' and toggle the chip
applyAtOption(id: ProviderId) {
const q = this.query();
const m = AT_QUERY_RE.exec(q);
if (m) {
const term = m[2] || '';
this.pickerOpen.set(true);
const lower = term.toLowerCase();
const matched = PROVIDERS.filter(p => p.shortLabel.toLowerCase().startsWith(lower) || p.displayName.toLowerCase().includes(lower)).map(p => p.id);
if (matched.length === 1) {
this.toggleChip(matched[0]);
}
const start = (m.index || 0) + (m[1] || '').length;
this.queryUpdate(q.slice(0, start).trimEnd());
}
this.atOpen.set(false);
this.toggleChip(id);
this.telemetry.track('at_autocomplete_use', { providers: [id] });
try { this.qInputRef?.nativeElement?.focus(); } catch {}
}
// Toggle All chip
@@ -152,43 +185,37 @@ export class SearchBoxComponent {
if (this.providerMode() === 'all') this.providerMode.set('custom');
const cur = new Set(this.selected());
if (cur.has(id)) cur.delete(id); else cur.add(id);
const next = Array.from(cur);
this.selected.set(next);
if (next.length === 0) {
// Empty selection warning UX could be handled by parent; keep custom mode
}
this.selected.set(Array.from(cur));
}
// Keyboard shortcuts: Alt+1..6 map to chips by order
// Single keyboard handler for the whole form (input events bubble here)
handleKeydown(ev: KeyboardEvent) {
if (ev.key === '@') {
ev.preventDefault();
this.pickerOpen.set(true);
return;
}
// Inline @autocomplete navigation
// Inline @autocomplete navigation (highest priority)
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; }
if (ev.key === 'Enter') {
// Prevent the sibling (keydown.enter) submit handler from also firing
ev.stopImmediatePropagation();
ev.preventDefault();
const pick = opts[this.atIndex()];
if (pick) this.toggleChip(pick.id);
this.atOpen.set(false);
if (pick) this.applyAtOption(pick.id);
return;
}
if (ev.key === 'Escape') { this.atOpen.set(false); }
if (ev.key === 'Escape') { this.atOpen.set(false); return; }
}
// Alt+1..6 map to chips by registry order
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;
}
if (ev.key === 'Escape') {
// Escape clears transient UI; if nothing is open, also clear the query
// Escape closes transient UI; if nothing is open, clear the query
const hadAny = this.pickerOpen() || this.suggestionsOpen() || this.atOpen() || this.quickOpen();
this.pickerOpen.set(false);
this.closePicker();
this.suggestionsOpen.set(false);
this.atOpen.set(false);
if (!hadAny && (this.query() || '').length > 0) {
@@ -197,19 +224,13 @@ export class SearchBoxComponent {
}
}
// Global shortcut Ctrl/Cmd+K to open the Providers picker
// Global shortcut Ctrl/Cmd+K to open the Providers quick menu
@HostListener('document:keydown', ['$event'])
onGlobalKey(ev: KeyboardEvent) {
if ((ev.key.toLowerCase() === 'k') && (ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
this.quickOpen.set(true);
// Load last 15 searches
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
}
// Also support Ctrl+Shift+K explicitly
if ((ev.key.toLowerCase() === 'k') && ev.ctrlKey && ev.shiftKey) {
ev.preventDefault();
this.quickOpen.set(true);
this.openQuickMenu();
return;
}
if (ev.key === 'Escape') {
if (this.quickOpen()) {
@@ -234,35 +255,68 @@ export class SearchBoxComponent {
try {
const raw = this.qInputRef.nativeElement.value || '';
q = String(raw).trim();
console.log('[SearchBox] fallback native value used', { q, qLength: q.length });
} catch {}
}
// Debug: minimal console trace to confirm handler runs
try {
console.log('[SearchBox] onSubmit called', {
q,
qLength: q.length,
hasParentHandler: !!this.submitted.observers.length
});
} catch(e) {
console.error('Error in SearchBox onSubmit:', e);
}
if (q.length < 2) {
console.log('[SearchBox] Query too short, not submitting');
return;
}
if (q.length < 2) return;
const prov = this.providersForRequest();
console.log('[SearchBox] Emitting submit event', { q, providers: prov });
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 });
console.log('[SearchBox] Submit event emitted');
}
openPicker() { this.pickerOpen.set(true); }
closePicker() { this.pickerOpen.set(false); }
openPicker() {
this.saveFocus();
this.pickerOpen.set(true);
this.telemetry.track('provider_picker_open');
}
closePicker() {
if (!this.pickerOpen()) return;
this.pickerOpen.set(false);
this.restoreFocus();
}
// 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
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
}
closeQuickMenu() {
if (!this.quickOpen()) return;
this.quickOpen.set(false);
this.restoreFocus();
}
private saveFocus() {
try { this.lastFocused = document.activeElement as HTMLElement | null; } catch { this.lastFocused = null; }
}
private restoreFocus() {
try { this.lastFocused?.focus(); } catch {}
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) {
@@ -272,20 +326,20 @@ export class SearchBoxComponent {
if (prov === 'all') {
this.providerMode.set('all');
this.selected.set([]);
} else {
} else if (Array.isArray(prov)) {
this.providerMode.set('custom');
this.selected.set(prov);
}
// Persist default selection if requested and user is logged-in
// 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)
closeQuickMenu() { this.quickOpen.set(false); }
applyQuickMenu(payload: any) {
this.applyPicker(payload);
this.closeQuickMenu();
@@ -299,14 +353,6 @@ export class SearchBoxComponent {
this.selected.set(Array.from(set));
}
// Lock body scroll when a modal is open (a11y UX)
private _scrollLock = effect(() => {
const open = this.quickOpen() || this.pickerOpen();
try {
document.body.style.overflow = open ? 'hidden' : '';
} catch {}
});
// Internal subject for debouncing query changes
private _qChanges = new Subject<string>();
}
@@ -1,28 +1,43 @@
<div class="mt-3 space-y-4">
<!-- Grouped suggestion sections by provider -->
<div *ngFor="let entry of (groups | keyvalue)" class="border border-slate-700 rounded-lg overflow-hidden">
<div class="px-3 py-2 bg-slate-800 text-slate-200 text-sm font-semibold">
{{ entry.key | uppercase }}
</div>
<ul class="divide-y divide-slate-700">
<li *ngFor="let s of (entry.value | slice:0:3); let i = index" class="p-3 flex items-start gap-3">
<img *ngIf="s.thumbnail" [src]="s.thumbnail" class="h-12 w-20 object-cover rounded" alt=""/>
<div class="min-w-0">
<div class="text-slate-100 truncate">{{ s.title }}</div>
<div class="text-slate-400 text-xs">{{ s.uploaderName }}</div>
<div class="mt-3 space-y-4" role="region" aria-label="Suggestions par fournisseur">
@for (s of sections(); track s.id) {
<section class="border border-slate-700 rounded-lg overflow-hidden" [attr.aria-label]="'Résultats ' + s.name">
<header class="px-3 py-2 bg-slate-800 text-slate-200 text-sm font-semibold flex items-center gap-2">
<span [class]="s.colorClass">{{ s.shortLabel }}</span>
<span>{{ s.name }}</span>
</header>
@if (!s.supported) {
<!-- Explicit unsupported-provider row (Step 6) -->
<div class="p-3 text-slate-500 text-sm italic flex items-center gap-2">
<span aria-hidden="true">⚠</span>
{{ s.name }} ne supporte pas la recherche pour le moment.
</div>
</li>
<li *ngIf="(entry.value?.length || 0) === 0" class="p-3 text-slate-500 text-sm">
Aucun résultat pour ce fournisseur.
</li>
</ul>
<div class="px-3 py-2 bg-slate-900 text-right">
<a class="text-xs text-red-300 hover:text-red-200"
[routerLink]="['/search']"
[queryParams]="{ q: query, providers: entry.key }"
[attr.aria-label]="'View all results on ' + (entry.key | uppercase)">
View all on {{ entry.key | uppercase }}
</a>
</div>
</div>
} @else {
<ul class="divide-y divide-slate-700">
@for (item of s.items | slice:0:3; track item.id) {
<li class="p-3 flex items-start gap-3">
@if (item.thumbnail) {
<img [src]="item.thumbnail" class="h-12 w-20 object-cover rounded" alt=""/>
}
<div class="min-w-0">
<div class="text-slate-100 truncate">{{ item.title }}</div>
<div class="text-slate-400 text-xs">{{ item.uploaderName }}</div>
</div>
</li>
}
@empty {
<li class="p-3 text-slate-500 text-sm">Aucun résultat pour ce fournisseur.</li>
}
</ul>
<div class="px-3 py-2 bg-slate-900 text-right">
<a class="text-xs text-red-300 hover:text-red-200"
[routerLink]="['/search']"
[queryParams]="{ q: query, providers: s.deepLinkProviders }"
[attr.aria-label]="'Voir tous les résultats sur ' + s.name">
View all on {{ s.name }}
</a>
</div>
}
</section>
}
</div>
@@ -1,9 +1,19 @@
import { Component, Input } from '@angular/core';
import { Component, Input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import type { ProviderId } from '../../app/core/providers/provider-registry';
import { PROVIDERS, PROVIDER_MAP, type ProviderId } from '../../app/core/providers/provider-registry';
import type { SuggestionItem } from '../../app/search/search.service';
interface SuggestionSection {
id: ProviderId;
name: string;
shortLabel: string;
colorClass: string;
items: SuggestionItem[];
supported: boolean;
deepLinkProviders: string;
}
@Component({
selector: 'app-search-suggestions',
standalone: true,
@@ -15,4 +25,26 @@ export class SearchSuggestionsComponent {
yt: [], dm: [], tw: [], pt: [], od: [], ru: []
} as any;
@Input() query: string = '';
// Providers that were actually requested; drives which sections render and
// which unsupported providers get an explicit row (Step 6)
@Input() activeProviders: ProviderId[] | 'all' = 'all';
readonly sections = computed<SuggestionSection[]>(() => {
const groups = this.groups || ({} as Record<ProviderId, SuggestionItem[]>);
const active = this.activeProviders === 'all'
? PROVIDERS.map(p => p.id)
: (this.activeProviders || []).filter(id => PROVIDER_MAP.has(id));
return active.map(id => {
const spec = PROVIDER_MAP.get(id)!;
return {
id,
name: spec.displayName,
shortLabel: spec.shortLabel,
colorClass: spec.colorClass,
items: Array.isArray(groups[id]) ? groups[id] : [],
supported: !!spec.supports?.search,
deepLinkProviders: id,
} as SuggestionSection;
});
});
}
+21 -3
View File
@@ -11,7 +11,8 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { SearchService, type SearchResponse } from '../../app/search/search.service';
import type { ProviderId } from '../../app/core/providers/provider-registry';
import { UserService, type UserPreferences } from '../../services/user.service';
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
import { Observable, Subscription } from 'rxjs';
@Component({
@@ -29,6 +30,7 @@ export class SearchComponent implements AfterViewInit {
private history = inject(HistoryService);
private title = inject(Title);
private unified = inject(SearchService);
private users = inject(UserService);
q = signal<string>('');
loading = signal<boolean>(false);
@@ -36,6 +38,8 @@ export class SearchComponent implements AfterViewInit {
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
providersListParam = signal<ProviderId[] | 'all' | null>(null);
// Step 7: default providers loaded from user preferences (server), applied when URL has none
private defaultProvidersPref = signal<ProviderId[] | null>(null);
pageParam = signal<number>(1);
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
@@ -258,6 +262,20 @@ export class SearchComponent implements AfterViewInit {
this.title.setTitle(this.pageHeading());
});
// Step 7: load default providers preference once (server) for deep-link fallback
try {
this.users.loadPreferences().subscribe({
next: (prefs: UserPreferences) => {
const list = (prefs as any)?.defaultProviders;
if (Array.isArray(list)) {
const valid = list.filter((id: string) => PROVIDERS.some(p => p.id === id)) as ProviderId[];
if (valid.length > 0) this.defaultProvidersPref.set(valid);
}
},
error: () => {} // anonymous users: no preference
});
} catch {}
// Subscribe once to unified multi-provider results and reflect to UI
const subscription: Subscription = this.unified.request$
.pipe(takeUntilDestroyed())
@@ -330,8 +348,8 @@ export class SearchComponent implements AfterViewInit {
const provider = prov || this.instances.selectedProvider();
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
// Use unified search in all cases
const providersToUse = providersList || [provider as ProviderId];
// Use unified search in all cases (URL providers > user default preference > single provider)
const providersToUse = providersList || this.defaultProvidersPref() || [provider as ProviderId];
this.unified.setQuery(q);
this.unified.setProviders(providersToUse);