feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user