chore: update Angular cache and TypeScript build info files

This commit is contained in:
2025-09-22 09:34:22 -04:00
parent 08fd0682a4
commit 4eb339eb22
34 changed files with 1511 additions and 154 deletions
+4 -108
View File
@@ -14,114 +14,10 @@
</a>
</div>
<!-- Center: search (grid-centered, avoids overlap) -->
<form class="justify-self-center mx-auto w-full max-w-2xl md:max-w-3xl lg:max-w-4xl px-4 relative group" (submit)="onSubmitSearch($event, searchInput)">
<input #searchInput type="search" [placeholder]="('search.placeholder' | t)" aria-label="Search"
(focus)="onSearchFocus()" (input)="onSearchInput(searchInput)" (blur)="onSearchBlur()" (keydown)="onSearchKeydown($event, searchInput)"
(search)="onSearchCleared(searchInput)"
[ngClass]="{
'bg-white text-slate-900 placeholder-slate-500 ring-slate-300 hover:bg-slate-50': isLightTheme(),
'bg-slate-700/80 text-slate-200 placeholder-slate-400 ring-slate-600/50 hover:bg-slate-700/70': isDarkTheme(),
'bg-black text-slate-200 placeholder-slate-400 ring-slate-800/50 hover:bg-slate-900': isBlackTheme(),
'bg-blue-950/80 text-blue-100 placeholder-blue-300 ring-blue-900/60 hover:bg-blue-900/70': isBlueTheme()
}"
class="w-full rounded-lg py-2.5 px-4 pl-10 pr-16 focus:outline-none ring-1 focus:ring-2 focus:ring-red-500/70 transition-all duration-200 shadow-sm">
<svg class="w-5 h-5 absolute left-6 md:left-5 top-1/2 -translate-y-1/2"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<!-- Keyboard shortcut hint (only when no provider indicator) -->
<div *ngIf="!providerContextLabel()" class="absolute right-6 top-1/2 -translate-y-1/2 hidden md:flex items-center gap-1 text-xs select-none"
[ngClass]="{
'text-slate-700/80': isLightTheme(),
'text-slate-300/80': isDarkTheme() || isBlackTheme(),
'text-blue-200/80': isBlueTheme()
}">
<kbd [ngClass]="{
'bg-slate-200/80 border-slate-300 text-slate-700': isLightTheme(),
'bg-slate-700/80 border-slate-600/60': isDarkTheme(),
'bg-slate-900/80 border-slate-800/60 text-slate-300': isBlackTheme(),
'bg-blue-800/80 border-blue-700/60 text-blue-200': isBlueTheme()
}" class="px-1.5 py-0.5 rounded border">Ctrl</kbd>
<span>+</span>
<kbd [ngClass]="{
'bg-slate-200/80 border-slate-300 text-slate-700': isLightTheme(),
'bg-slate-700/80 border-slate-600/60 text-slate-300': isDarkTheme(),
'bg-slate-900/80 border-slate-800/60 text-slate-300': isBlackTheme(),
'bg-blue-800/80 border-blue-700/60 text-blue-200': isBlueTheme()
}" class="px-1.5 py-0.5 rounded border">K</kbd>
</div>
<!-- Suggestions dropdown -->
@if (suggestionsOpen() && suggestionItems().length > 0) {
<div class="absolute z-50 mt-2 left-1/2 -translate-x-1/2 w-full max-w-2xl md:max-w-3xl lg:max-w-4xl rounded-xl shadow-2xl overflow-hidden"
[ngClass]="{
'bg-white border border-slate-200 text-slate-900': isLightTheme(),
'bg-slate-800/95 border border-slate-700 text-slate-200': isDarkTheme(),
'bg-black/95 border border-slate-900 text-slate-200': isBlackTheme(),
'bg-blue-950/90 border border-blue-800 text-blue-100': isBlueTheme(),
'backdrop-blur-md': !isLightTheme()
}">
<ul class="max-h-[70vh] overflow-y-auto py-1">
@for (item of suggestionItems(); track $index; let i = $index) {
<!-- Divider before first generated suggestion -->
@if (isFirstGenerated(i)) {
<li class="px-4 py-2 text-xs uppercase tracking-wide border-t border-b"
[ngClass]="{
'text-slate-500 border-slate-100': isLightTheme(),
'text-slate-400 border-slate-700': isDarkTheme(),
'text-slate-400 border-slate-800': isBlackTheme(),
'text-blue-300 border-blue-800': isBlueTheme()
}">Suggestions</li>
}
<li (mouseenter)="highlightedIndex.set(i)">
<button type="button" (mousedown)="pickSuggestion(item.text, searchInput)"
class="w-full text-left px-4 py-2 flex items-center gap-3 transition-colors duration-150"
[ngClass]="{
'hover:bg-slate-100': isLightTheme(),
'hover:bg-slate-700/70': isDarkTheme(),
'hover:bg-slate-900': isBlackTheme(),
'hover:bg-blue-800/60': isBlueTheme(),
'bg-slate-200': isLightTheme() && highlightedIndex() === i,
'bg-slate-700/50': isDarkTheme() && highlightedIndex() === i,
'bg-slate-900/70': isBlackTheme() && highlightedIndex() === i,
'bg-blue-800/50': isBlueTheme() && highlightedIndex() === i,
'text-slate-900': isLightTheme(),
'text-slate-200': isDarkTheme() || isBlackTheme(),
'text-blue-100': isBlueTheme()
}">
<!-- Clock for history, magnifier for generated -->
<svg *ngIf="item.source === 'history'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg *ngIf="item.source === 'generated'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span class="truncate">{{ item.text }}</span>
</button>
</li>
}
</ul>
</div>
}
</form>
<!-- Center: search box with providers -->
<div class="justify-self-center mx-auto w-full max-w-2xl md:max-w-3xl lg:max-w-4xl px-4 relative">
<app-search-box [placeholder]="('search.placeholder' | t)" (submitted)="onSearchBoxSubmit($event)"></app-search-box>
</div>
<!-- Right: user -->
<div class="justify-self-end flex items-center gap-3 pr-3 md:pr-4">
+22 -2
View File
@@ -10,6 +10,7 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
import { I18nService } from '../../services/i18n.service';
import { ThemesService } from '../../services/themes.service';
import { HistoryService, SearchHistoryItem } from '../../services/history.service';
import { SearchBoxComponent } from '../search/search-box.component';
@Component({
selector: 'app-header',
@@ -20,7 +21,9 @@ import { HistoryService, SearchHistoryItem } from '../../services/history.servic
RouterLink,
CommonModule,
FormsModule,
TranslatePipe
TranslatePipe,
// New unified search box with provider chips and picker
SearchBoxComponent
]
})
export class HeaderComponent {
@@ -60,7 +63,7 @@ export class HeaderComponent {
const lower = q.toLowerCase();
historyList = fromHistory.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15);
}
const items: Item[] = historyList.map(text => ({ text, source: 'history' }));
// 2) If we have fewer than 15, fill with generated suggestions
@@ -85,6 +88,23 @@ export class HeaderComponent {
return items;
});
// New SearchBox submit handler (navigate with providers list)
onSearchBoxSubmit(evt: { q: string; providers: ("yt"|"dm"|"tw"|"pt"|"od"|"ru")[] | 'all' }) {
const q = (evt?.q || '').trim();
if (!q) return;
const provider = this.selectedProvider();
const theme = this.themes.activeSlug();
const qp: any = { q, provider };
if (Array.isArray(evt.providers)) {
qp.providers = evt.providers.join(',');
} else if (evt.providers === 'all') {
// Explicitly include all providers so SearchComponent uses unified multi-provider mode
qp.providers = 'yt,dm,tw,pt,od,ru';
}
if (theme) qp.theme = theme;
this.router.navigate(['/search'], { queryParams: qp });
}
// Generate query suggestions based on the current text and provider
private generateQuerySuggestions(q: string, provider: string | null): string[] {
const base = (q || '').trim();
@@ -0,0 +1,40 @@
<div class="fixed inset-0 z-[9999] bg-black/60 flex justify-center items-center" (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"
(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>
</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"/>
<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()"/>
<span>All</span>
</label>
</div>
<div class="grid grid-cols-2 gap-2">
<label *ngFor="let p of providers()" class="flex items-center gap-2 px-2 py-2 rounded border border-slate-700 bg-slate-800 text-slate-100">
<input type="checkbox" [checked]="isChecked(p.id)" (change)="toggle(p.id)"/>
<span class="text-xs font-semibold" [class]="p.colorClass">{{ p.shortLabel }}</span>
<span class="text-sm">{{ p.displayName }}</span>
</label>
</div>
<label class="mt-2 inline-flex items-center gap-2 text-slate-300">
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
<span>Remember as default</span>
</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>
</div>
</div>
</div>
@@ -0,0 +1,79 @@
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ProviderId, PROVIDERS } 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 {
@Input() open = false;
@Input() selected: ProviderId[] | 'all' = 'all';
@Output() close = new EventEmitter<void>();
@Output() apply = new EventEmitter<{ selection: ProviderId[] | 'all'; rememberDefault: boolean }>();
filter = signal('');
rememberDefault = signal(false);
@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() });
}
isChecked(id: ProviderId): boolean {
if (this.selected === 'all') return true;
return Array.isArray(this.selected) && this.selected.includes(id);
}
toggle(id: ProviderId) {
if (this.selected === 'all') {
this.apply.emit({ selection: [id], rememberDefault: this.rememberDefault() });
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;
}
onApply() {
this.apply.emit({ selection: this.selected, rememberDefault: this.rememberDefault() });
}
ngAfterViewInit() {
// Focus the dialog for accessibility
queueMicrotask(() => {
try { this.dialogRef?.nativeElement?.focus(); } catch {}
});
}
// Basic focus trap within the dialog
onDialogKeydown(ev: KeyboardEvent) {
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) {
ev.preventDefault();
last.focus();
}
}
}
@@ -0,0 +1,105 @@
<form class="w-full relative" (submit)="onSubmit($event)" (keydown)="handleKeydown($event)">
<!-- 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 -->
<button type="button"
(click)="toggleAll()"
class="text-xs px-2 py-0.5 rounded border"
[ngClass]="{
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
'bg-slate-700 text-slate-100 border-slate-600': 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="text-xs px-2 py-0.5 rounded border"
[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)
}"
[title]="'Inclure ' + c.label">
<span [class]="c.colorClass">{{ c.label }}</span>
</button>
</ng-container>
<!-- Text input -->
<input 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)" name="q"/>
<!-- Actions -->
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">@</button>
<button type="submit" class="ml-1 px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white">Search</button>
</div>
<!-- Provider Picker Modal -->
<app-provider-picker *ngIf="pickerOpen()"
[open]="pickerOpen()"
[selected]="providersForRequest()"
(close)="closePicker()"
(apply)="applyPicker($event)"></app-provider-picker>
<!-- Inline @autocomplete popover -->
<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"
[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)"
class="w-full text-left px-3 py-2 text-sm"
[ngClass]="{
'bg-slate-800': i === atIndex(),
'hover:bg-slate-800': i !== atIndex()
}">
@ {{ opt.label }}
</button>
</li>
</ul>
</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 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>
</div>
<div class="grid grid-cols-2 gap-0 overflow-y-auto">
<!-- History column -->
<div class="p-4 border-r border-slate-700">
<div class="text-slate-300 text-sm mb-2">History</div>
<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>
</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 mb-2">
<div class="text-slate-300 text-sm">Providers</div>
<button type="button" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100"
(click)="quickToggleAll()">All</button>
</div>
<div class="grid grid-cols-3 gap-2">
<label *ngFor="let c of chips" class="flex items-center gap-2 px-2 py-2 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-xs font-semibold" [class]="c.colorClass">{{ c.label }}</span>
<span class="text-sm">{{ c.label }}</span>
</label>
</div>
<div class="mt-4 text-right">
<button type="button" class="px-3 py-2 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>
</div>
</form>
@@ -0,0 +1,247 @@
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener } 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 { SearchService } from '../../app/search/search.service';
import { UserService, type UserPreferences } from '../../services/user.service';
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
// No overlays; we render fixed-position modals with high z-index
@Component({
selector: 'app-search-box',
standalone: true,
imports: [CommonModule, FormsModule, ProviderPickerComponent],
templateUrl: './search-box.component.html'
})
export class SearchBoxComponent {
private search = inject(SearchService);
private users = inject(UserService);
private history = inject(HistoryService);
// Input/Output bindings
@Input() placeholder: string = 'Search videos…';
@Output() submitted = new EventEmitter<{ q: string; providers: ProviderId[] | 'all' }>();
// Local state signals
query = signal('');
providerMode = signal<'all' | 'custom'>('all');
selected = signal<ProviderId[]>([]);
pickerOpen = signal(false);
suggestionsOpen = signal(false);
// Inline @autocomplete state
atOpen = signal(false);
atIndex = signal(0);
atOptions = computed(() => {
const q = this.query();
const m = /(^|\s)@([a-z]{1,24})$/i.exec(q);
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;
});
// Quick menu modal (Ctrl/Cmd+K)
quickOpen = signal(false);
recentSearches = signal<SearchHistoryItem[]>([]);
readonly chips = PROVIDERS.map(p => ({ id: p.id, label: p.shortLabel, colorClass: p.colorClass }));
// Derived state
readonly providersForRequest = computed<ProviderId[] | 'all'>(() => {
return this.providerMode() === 'all' ? 'all' : (this.selected().length ? this.selected() : 'all');
});
constructor() {
// Reflect SearchService into local state to restore from URL/preference
effect(() => {
const q = this.search.q$.value;
const prov = this.search.providers$.value;
if (typeof q === 'string' && q !== this.query()) this.query.set(q);
if (prov === 'all') {
this.providerMode.set('all');
this.selected.set([]);
} else if (Array.isArray(prov)) {
this.providerMode.set('custom');
this.selected.set(prov);
}
});
// Apply default providers from user preferences if none explicitly set
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) {
// 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);
}
}
});
}
// Update query and handle inline '@provider' prefix to open filtered picker
queryUpdate(value: string) {
this.query.set(value);
const m = /(^|\s)@([a-z]{1,12})$/i.exec(value);
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]);
}
}
}
// Toggle All chip
toggleAll() {
if (this.providerMode() === 'all') {
// Switch to none selected (effectively prompt to pick)
this.providerMode.set('custom');
this.selected.set([]);
} else {
this.providerMode.set('all');
this.selected.set([]);
}
}
// Toggle individual chip
toggleChip(id: ProviderId) {
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
}
}
// Keyboard shortcuts: Alt+1..6 map to chips by order
handleKeydown(ev: KeyboardEvent) {
if (ev.key === '@') {
ev.preventDefault();
this.pickerOpen.set(true);
return;
}
// Inline @autocomplete navigation
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') {
ev.preventDefault();
const pick = opts[this.atIndex()];
if (pick) this.toggleChip(pick.id);
this.atOpen.set(false);
return;
}
if (ev.key === 'Escape') { this.atOpen.set(false); }
}
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); }
}
if (ev.key === 'Escape') {
this.pickerOpen.set(false);
this.suggestionsOpen.set(false);
}
}
// Global shortcut Ctrl/Cmd+K to open the Providers picker
@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);
}
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);
}
}
}
onSubmit(ev: Event) {
ev.preventDefault();
const q = (this.query() || '').trim();
if (!q) return;
const prov = this.providersForRequest();
// Update service for global listeners
this.search.setQuery(q);
this.search.setProviders(prov);
this.submitted.emit({ q, providers: prov });
}
openPicker() { this.pickerOpen.set(true); }
closePicker() { this.pickerOpen.set(false); }
// 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 {
this.providerMode.set('custom');
this.selected.set(prov);
}
// Persist default selection if requested and user is logged-in
if (rememberDefault) {
const body: any = { defaultProviders: (prov === 'all') ? [] : prov };
try { this.users.updatePreferences(body).subscribe({ next: () => {}, error: () => {} }); } catch {}
}
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();
}
// 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));
}
// 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 {}
});
}
@@ -0,0 +1,28 @@
<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>
</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>
</div>
@@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import type { ProviderId } from '../../app/core/providers/provider-registry';
import type { SuggestionItem } from '../../app/search/search.service';
@Component({
selector: 'app-search-suggestions',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './search-suggestions.component.html'
})
export class SearchSuggestionsComponent {
@Input() groups: Record<ProviderId, SuggestionItem[]> = {
yt: [], dm: [], tw: [], pt: [], od: [], ru: []
} as any;
@Input() query: string = '';
}
@@ -27,6 +27,11 @@
</div>
}
<!-- Unified multi-provider grouped suggestions -->
@if (showUnified()) {
<app-search-suggestions [groups]="groups()" [query]="q()"></app-search-suggestions>
}
<input
#searchInput
type="text"
+141 -28
View File
@@ -11,13 +11,16 @@ import { Title } from '@angular/platform-browser';
import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component';
import { SearchSuggestionsComponent } from './search-suggestions.component';
import { SearchService, type SearchResponse } from '../../app/search/search.service';
import type { ProviderId } from '../../app/core/providers/provider-registry';
@Component({
selector: 'app-search',
standalone: true,
templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent]
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent, SearchSuggestionsComponent]
})
export class SearchComponent {
private route = inject(ActivatedRoute);
@@ -25,6 +28,7 @@ export class SearchComponent {
private instances = inject(InstanceService);
private history = inject(HistoryService);
private title = inject(Title);
private unified = inject(SearchService);
q = signal<string>('');
loading = signal<boolean>(false);
@@ -41,6 +45,11 @@ export class SearchComponent {
notice = signal<string | null>(null);
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
providersListParam = signal<ProviderId[] | 'all' | null>(null);
// Unified multi-provider response (grouped suggestions)
groups = signal<Record<ProviderId, any[]>>({} as any);
showUnified = signal<boolean>(false);
hasQuery = computed(() => this.q().length > 0);
providerLabel = computed(() => {
@@ -63,16 +72,51 @@ export class SearchComponent {
filteredResults = computed(() => {
const tag = this.filterTag();
const provider = this.selectedProviderForView();
// Use unified results if available
if (this.showUnified()) {
const groups = this.groups();
const list: Video[] = [];
// Convert unified groups to Video objects
Object.entries(groups).forEach(([providerId, items]) => {
items.forEach((item: any) => {
list.push({
videoId: item.id,
title: item.title,
thumbnail: item.thumbnail || '',
uploaderName: item.uploaderName || 'Unknown',
uploaderAvatar: '',
views: 0,
uploaded: Date.now(),
uploadedDate: new Date().toISOString(),
duration: 0,
url: item.url || '',
type: 'video',
provider: providerId as 'youtube'|'dailymotion'|'twitch'|'rumble'|'odysee'|'peertube'
});
});
});
// Apply tag filtering to unified results
if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
if (tag === 'all') return list;
// For now, return all results since we don't have duration/uploaded info from unified search
return list;
}
// Fallback to legacy results
const list = this.results();
if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
if (tag === 'all') return list;
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
// Duration filters
if (tag === 'short') return list.filter(v => {
const d = Number(v.duration || 0);
@@ -86,7 +130,7 @@ export class SearchComponent {
const d = Number(v.duration || 0);
return d >= 20 * 60;
});
// Date filters
if (tag === 'today') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
@@ -104,7 +148,7 @@ export class SearchComponent {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= oneYear;
});
return list;
});
@@ -113,8 +157,34 @@ export class SearchComponent {
const provider = this.selectedProviderForView();
const tags: { key: string; label: string; show: boolean }[] = [];
const now = Date.now();
const list = this.results();
// Use unified results if available
const list = this.showUnified() ?
(() => {
const groups = this.groups();
const results: Video[] = [];
Object.entries(groups).forEach(([_, items]) => {
items.forEach((item: any) => {
results.push({
videoId: item.id,
title: item.title,
thumbnail: item.thumbnail || '',
uploaderName: item.uploaderName || 'Unknown',
uploaderAvatar: '',
views: 0,
uploaded: Date.now(),
uploadedDate: new Date().toISOString(),
duration: 0,
url: item.url || '',
type: 'video',
provider: 'youtube' as any
});
});
});
return results;
})() :
this.results();
if (provider === 'twitch') {
const hasLive = this.twitchChannels().length > 0;
const hasVods = this.twitchVods().length > 0;
@@ -132,20 +202,20 @@ export class SearchComponent {
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
let hasShort = false, hasMedium = false, hasLong = false,
let hasShort = false, hasMedium = false, hasLong = false,
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
for (const v of list) {
const d = Number(v.duration || 0);
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 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;
@@ -160,13 +230,13 @@ export class SearchComponent {
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);
});
@@ -176,24 +246,54 @@ export class SearchComponent {
this.title.setTitle(this.pageHeading());
});
// Subscribe once to unified multi-provider results and reflect to UI
this.unified.request$.subscribe({
next: (resp: SearchResponse) => {
this.groups.set(resp.groups as any);
this.loading.set(false);
},
error: () => {
this.groups.set({} as any);
this.loading.set(false);
}
});
// Listen to query param changes (so subsequent searches update)
this.route.queryParamMap.subscribe((pm) => {
const q = (pm.get('q') || '').trim();
this.q.set(q);
const prov = (pm.get('provider') as Provider) || null;
const theme = pm.get('theme');
const providersParam = (pm.get('providers') || '').trim();
// Parse providers CSV if present
let providersList: ProviderId[] | 'all' | null = null;
if (providersParam) {
const arr = providersParam.split(',').map(s => s.trim()).filter(Boolean) as ProviderId[];
providersList = (arr.length > 0) ? arr : 'all';
}
this.providerParam.set(prov);
this.themeParam.set(theme);
this.providersListParam.set(providersList);
if (q) {
this.notice.set(null);
// Reset active tag on new query
const provider = (prov || this.instances.selectedProvider());
const provider = prov || this.instances.selectedProvider();
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
this.reloadSearch();
// Use unified search in all cases
const providersToUse = providersList || [provider as ProviderId];
this.unified.setQuery(q);
this.unified.setProviders(providersToUse);
this.showUnified.set(true);
this.loading.set(true);
this.groups.set({} as any); // Clear previous results
} else {
this.results.set([]);
this.nextCursor.set(null);
this.loading.set(false);
this.groups.set({} as any);
this.showUnified.set(false);
}
});
@@ -206,8 +306,18 @@ export class SearchComponent {
untracked(() => {
// If provider is explicitly specified in query, do not override it with global changes
if (this.providerParam()) return;
if (this.q()) {
this.notice.set(null);
if (!this.q()) return;
this.notice.set(null);
// If unified mode is active, re-emit providers to trigger a refresh instead of legacy reload
if (this.showUnified()) {
const provParam = this.providersListParam();
const provider = this.instances.selectedProvider();
const providersToUse = (provParam && (provParam === 'all' || (Array.isArray(provParam) && provParam.length > 0)))
? (provParam as any)
: [provider as unknown as ProviderId];
this.unified.setProviders(providersToUse);
this.loading.set(true);
} else {
this.reloadSearch();
}
});
@@ -354,11 +464,6 @@ export class SearchComponent {
return qp;
}
// Update active filter tag from the template
setFilterTag(key: string) {
this.filterTag.set(key);
}
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
onSearchEnter(event: Event) {
@@ -366,8 +471,16 @@ export class SearchComponent {
keyboardEvent.preventDefault();
const query = this.q().trim();
if (query) {
// Utiliser reloadSearch() pour effectuer une nouvelle recherche
this.reloadSearch();
// Route unified search regardless of providers param
const provParam = this.providersListParam();
const provider = this.providerParam() || this.instances.selectedProvider();
const providersToUse = (provParam && (provParam === 'all' || (Array.isArray(provParam) && provParam.length > 0)))
? (provParam as any)
: [provider as unknown as ProviderId];
this.unified.setQuery(query);
this.unified.setProviders(providersToUse);
this.showUnified.set(true);
this.loading.set(true);
this.closeSearchResults();
// Retirer le focus de l'input
if (this.searchInput) {
@@ -389,8 +502,8 @@ export class SearchComponent {
// Vous pouvez ajouter ici la logique de recherche au fur et à mesure si nécessaire
}
performSearch(query: string) {
// Cette méthode sera appelée pour effectuer la recherche
// Vous pouvez ajouter ici la logique de recherche
// Update active filter tag from the template
setFilterTag(key: string) {
this.filterTag.set(key);
}
}