chore: update Angular cache and TypeScript build info files

This commit is contained in:
2025-09-22 14:45:16 -04:00
parent 4eb339eb22
commit d78afda4cd
47 changed files with 5480 additions and 3995 deletions
+24
View File
@@ -0,0 +1,24 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult, SearchItem, ProviderId } from '../models';
export abstract class HttpAdapter implements ProviderAdapter {
abstract key: ProviderId;
abstract label: string;
constructor(protected http: HttpClient) {}
abstract search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult>;
protected mapItem(partial: Partial<SearchItem> & { id: string; title: string; watchUrl: string }): SearchItem {
return {
provider: this.key,
channel: undefined,
durationSec: undefined,
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: false,
thumbUrl: undefined,
...partial,
} as SearchItem;
}
}
+26
View File
@@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class DmAdapter implements ProviderAdapter {
key = 'dm' as const;
label = 'Dailymotion';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'dm' } }));
const items = (res?.groups?.dm || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: it.isShort === true
}));
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0 };
}
}
+26
View File
@@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class OdAdapter implements ProviderAdapter {
key = 'od' as const;
label = 'Odysee';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'od' } }));
const items = (res?.groups?.od || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: it.isShort === true
}));
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0 };
}
}
+26
View File
@@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class PtAdapter implements ProviderAdapter {
key = 'pt' as const;
label = 'PeerTube';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'pt' } }));
const items = (res?.groups?.pt || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: false
}));
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0 };
}
}
+26
View File
@@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class RuAdapter implements ProviderAdapter {
key = 'ru' as const;
label = 'Rumble';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'ru' } }));
const items = (res?.groups?.ru || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: false
}));
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0 };
}
}
+26
View File
@@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class TwAdapter implements ProviderAdapter {
key = 'tw' as const;
label = 'Twitch';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'tw' } }));
const items = (res?.groups?.tw || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: false
}));
return { items, total: Array.isArray(res?.groups?.tw) ? res.groups.tw.length : 0 };
}
}
+26
View File
@@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class YtAdapter implements ProviderAdapter {
key = 'yt' as const;
label = 'YouTube';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'yt' } }));
const items = (res?.groups?.yt || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: it.isShort === true
}));
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0 };
}
}
+21
View File
@@ -0,0 +1,21 @@
export type ProviderId = 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru';
export interface SuggestionItemV1 {
title: string;
id: string;
duration?: number;
isShort?: boolean;
thumbnail?: string;
uploaderName?: string;
url?: string;
type?: string;
}
export interface SearchResponseV1 {
q: string;
providers: ProviderId[];
groups: Record<ProviderId, SuggestionItemV1[]>;
page?: number;
pageSize?: number;
sort?: 'relevance' | 'date' | 'views';
}
+36
View File
@@ -0,0 +1,36 @@
export type ProviderId = 'yt'|'dm'|'tw'|'pt'|'od'|'ru';
export interface SearchItem {
id: string;
provider: ProviderId;
title: string;
channel?: string;
durationSec?: number;
views?: number;
publishedAt?: string; // ISO
isLive?: boolean;
isShort?: boolean;
thumbUrl?: string;
watchUrl: string; // internal or external
}
export interface SearchResult {
items: SearchItem[];
total?: number;
nextPageToken?: string; // or page: string|number
}
export interface ProviderSearchParams {
q: string;
pageToken?: string; // or page
sort?: 'relevance'|'date'|'views'|'duration';
time?: 'today'|'7d'|'30d'|'all';
length?: 'short'|'medium'|'long'|'all';
type?: 'video'|'live'|'shorts'|'all';
}
export interface ProviderAdapter {
key: ProviderId;
label: string;
search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult>;
}
+102 -25
View File
@@ -1,49 +1,126 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, combineLatest, distinctUntilChanged, debounceTime, map, switchMap } from 'rxjs';
import { BehaviorSubject, combineLatest, distinctUntilChanged, debounceTime, map, switchMap, filter, shareReplay, from, of } from 'rxjs';
import type { ProviderId } from '../core/providers/provider-registry';
import type { SuggestionItemV1, SearchResponseV1 } from './api.v1';
import { YtAdapter } from './adapters/yt';
import { DmAdapter } from './adapters/dm';
import { TwAdapter } from './adapters/tw';
import { PtAdapter } from './adapters/pt';
import { OdAdapter } from './adapters/od';
import { RuAdapter } from './adapters/ru';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
export interface SuggestionItem {
title: string;
id: string;
duration?: number;
isShort?: boolean;
thumbnail?: string;
uploaderName?: string;
url?: string;
type?: string; // 'video' | 'channel' | ...
}
export type SuggestionItem = SuggestionItemV1;
export type SearchResponse = SearchResponseV1;
export interface SearchResponse {
q: string;
providers: ProviderId[];
groups: Record<ProviderId, SuggestionItem[]>;
}
type CacheKey = string;
interface CacheEntry { t: number; data: SearchResult; }
@Injectable({ providedIn: 'root' })
export class SearchService {
private http = inject(HttpClient);
// Subjects for params from UI/URL
readonly q$ = new BehaviorSubject<string>('');
readonly providers$ = new BehaviorSubject<ProviderId[] | 'all'>('all');
readonly page$ = new BehaviorSubject<number>(1);
readonly pageSize$ = new BehaviorSubject<number>(24);
readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance');
readonly params$ = combineLatest([this.q$, this.providers$]).pipe(
// In-memory cache 60s per (provider, q, params)
private cache = new Map<CacheKey, CacheEntry>();
private cacheTtlMs = 60_000;
// Adapters registry
private adapters: Record<ProviderId, ProviderAdapter> = {
yt: new YtAdapter(this.http),
dm: new DmAdapter(this.http),
tw: new TwAdapter(this.http),
pt: new PtAdapter(this.http),
od: new OdAdapter(this.http),
ru: new RuAdapter(this.http)
};
readonly params$ = combineLatest([this.q$, this.providers$, this.page$, this.pageSize$, this.sort$]).pipe(
debounceTime(120),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
);
// Public request stream: aggregates groups by provider, runs adapters in parallel with timeout/abort/retry
readonly request$ = this.params$.pipe(
switchMap(([q, prov]) => {
const providersParam = (prov === 'all')
? 'yt,dm,tw,pt,od,ru'
: (Array.isArray(prov) && prov.length > 0 ? prov.join(',') : '');
return this.http.get<SearchResponse>('/api/search', {
params: { q, providers: providersParam }
});
})
filter(([q]) => typeof q === 'string' && q.trim().length >= 2),
switchMap(([q, prov, page, pageSize, sort]) => from(this.runAdapters({
q: String(q),
pageToken: String(page || 1),
sort: (sort as any) || 'relevance',
// global filter knobs could be added here: time/length/type in the future
}, prov)))
,
// Map normalized items to legacy "groups" response expected by UI
map((byProvider) => {
const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => {
const arr: SuggestionItemV1[] = (result?.items || []).map(it => ({
id: it.id,
title: it.title,
duration: it.durationSec,
isShort: it.isShort,
thumbnail: it.thumbUrl,
uploaderName: it.channel,
url: it.watchUrl,
type: it.isLive ? 'live' : 'video'
}));
(acc as any)[pid as ProviderId] = arr;
return acc;
}, {} as Record<ProviderId, SuggestionItemV1[]>);
const providers = Object.keys(byProvider) as ProviderId[];
const resp: SearchResponse = { q: this.q$.value, providers, groups };
return resp;
}),
shareReplay(1)
);
// Orchestrate all active providers in parallel with timeout(8s), retry(1) and abort on param change.
private async runAdapters(params: ProviderSearchParams, prov: ProviderId[] | 'all') {
const active: ProviderId[] = prov === 'all' ? ['yt','dm','tw','pt','od','ru'] : (Array.isArray(prov) ? prov : []);
const controller = new AbortController();
const signal = controller.signal;
const tasks = active.map(async (pid) => {
const adapter = this.adapters[pid];
if (!adapter) return [pid, { items: [] as any[] } as SearchResult] as const;
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}`;
const now = Date.now();
const cached = this.cache.get(key);
if (cached && (now - cached.t) < this.cacheTtlMs) {
return [pid, cached.data] as const;
}
const perProviderTimeout = 8000;
const withTimeout = <T>(p: Promise<T>): Promise<T> => new Promise((resolve, reject) => {
const tid = setTimeout(() => reject(new Error('timeout')), perProviderTimeout);
p.then(v => { clearTimeout(tid); resolve(v); }).catch(e => { clearTimeout(tid); reject(e); });
});
const attempt = async (): Promise<SearchResult> => withTimeout(adapter.search(params, signal));
try {
const res = await attempt().catch(async (e) => {
// retry once on network-like errors
if (e && (e.name === 'AbortError' || String(e.message || '').includes('abort'))) throw e;
try { return await attempt(); } catch (err) { throw err; }
});
this.cache.set(key, { t: Date.now(), data: res });
return [pid, res] as const;
} catch {
// Swallow errors per provider, return empty
return [pid, { items: [] }] as const;
}
});
const results = await Promise.all(tasks);
return Object.fromEntries(results) as Record<ProviderId, SearchResult>;
}
// Convenience setter helpers
setQuery(q: string) { this.q$.next(q || ''); }
setProviders(list: ProviderId[] | 'all') { this.providers$.next(list && (Array.isArray(list) ? list : 'all')); }
setPage(page: number) { this.page$.next(Math.max(1, Math.floor(page || 1))); }
setPageSize(size: number) { this.pageSize$.next(Math.min(50, Math.max(1, Math.floor(size || 24)))); }
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') { this.sort$.next(sort || 'relevance'); }
}
@@ -66,7 +66,7 @@
<ul *ngIf="!isLoading() && filteredSearchHistory().length > 0" class="space-y-3">
<li *ngFor="let s of filteredSearchHistory()"
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/search']" [queryParams]="{ q: s.query, provider: getSearchProvider(s).id }"
<a [routerLink]="['/search']" [queryParams]="getSearchProvider(s).providers ? { q: s.query, providers: getSearchProvider(s).providers } : { q: s.query, provider: getSearchProvider(s).id }"
class="block p-4 pr-16 hover:no-underline">
<div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5">
@@ -80,15 +80,7 @@
</div>
<div class="flex items-center mt-1 text-xs text-slate-400">
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
[ngStyle]="getProviderColors(getSearchProvider(s).id)"
[ngClass]="{
'bg-red-600/15 text-red-400 border-red-500/30': getSearchProvider(s).id === 'youtube',
'bg-sky-600/15 text-sky-300 border-sky-500/30': getSearchProvider(s).id === 'vimeo',
'bg-blue-600/15 text-blue-300 border-blue-500/30': getSearchProvider(s).id === 'dailymotion',
'bg-amber-600/15 text-amber-300 border-amber-500/30': getSearchProvider(s).id === 'peertube',
'bg-green-600/15 text-green-300 border-green-500/30': getSearchProvider(s).id === 'rumble',
'bg-slate-800/80 text-slate-300 border-slate-700': !['youtube', 'vimeo', 'dailymotion', 'peertube', 'rumble'].includes(getSearchProvider(s).id)
}">
[ngStyle]="getProviderColors(getSearchProvider(s).id)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
@@ -122,7 +114,7 @@
<ul *ngIf="searchHistory().length > 0" class="space-y-3">
<li *ngFor="let s of searchHistory()"
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/search']" [queryParams]="{ q: s.query, provider: getSearchProvider(s).id }"
<a [routerLink]="['/search']" [queryParams]="getSearchProvider(s).providers ? { q: s.query, providers: getSearchProvider(s).providers } : { q: s.query, provider: getSearchProvider(s).id }"
class="block p-4 pr-16 hover:no-underline">
<div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5">
@@ -136,15 +128,7 @@
</div>
<div class="flex items-center mt-1 text-xs text-slate-400">
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
[ngStyle]="getProviderColors(getSearchProvider(s).id)"
[ngClass]="{
'bg-red-600/15 text-red-400 border-red-500/30': getSearchProvider(s).id === 'youtube',
'bg-sky-600/15 text-sky-300 border-sky-500/30': getSearchProvider(s).id === 'vimeo',
'bg-blue-600/15 text-blue-300 border-blue-500/30': getSearchProvider(s).id === 'dailymotion',
'bg-amber-600/15 text-amber-300 border-amber-500/30': getSearchProvider(s).id === 'peertube',
'bg-green-600/15 text-green-300 border-green-500/30': getSearchProvider(s).id === 'rumble',
'bg-slate-800/80 text-slate-300 border-slate-700': !['youtube', 'vimeo', 'dailymotion', 'peertube', 'rumble'].includes(getSearchProvider(s).id)
}">
[ngStyle]="getProviderColors(getSearchProvider(s).id)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
@@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, inject, signal, computed, effect } from '@angular/core';
import { NgClass } from '@angular/common';
import { CommonModule, DatePipe } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
@@ -12,7 +11,7 @@ import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../se
standalone: true,
templateUrl: './history.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterModule, FormsModule, NgClass]
imports: [CommonModule, RouterModule, FormsModule]
})
export class HistoryComponent {
private history = inject(HistoryService);
@@ -32,6 +31,20 @@ export class HistoryComponent {
// Sujet pour la recherche avec debounce
private searchTerms = new Subject<string>();
// Obtenir le nom d'affichage d'un provider
getProviderName(providerId: string): string {
const names: Record<string, string> = {
'youtube': 'YouTube',
'dailymotion': 'Dailymotion',
'twitch': 'Twitch',
'peertube': 'PeerTube',
'odysee': 'Odysee',
'rumble': 'Rumble'
};
return names[providerId.toLowerCase()] || providerId;
}
constructor() {
// Chargement initial des données
this.reload();
@@ -151,13 +164,25 @@ export class HistoryComponent {
});
}
// Get provider from search filters or query params
getSearchProvider(item: SearchHistoryItem): { name: string; id: string } {
// Try to get provider from filters first
getSearchProvider(item: SearchHistoryItem): { name: string; id: string; providers?: string[] } {
// Try to get providers from filters first
if (item.filters_json) {
try {
const filters = JSON.parse(item.filters_json);
if (filters.provider) {
if (filters.providers) {
// Multiple providers available
const providers = Array.isArray(filters.providers) ? filters.providers : [filters.providers];
if (providers.length === 1) {
const providerName = this.formatProviderName(providers[0]);
return { name: providerName, id: providers[0].toLowerCase() };
} else {
const firstProviderName = this.formatProviderName(providers[0]);
const othersCount = providers.length - 1;
const name = `${firstProviderName} + ${othersCount} autre${othersCount > 1 ? 's' : ''}`;
return { name: name, id: providers[0], providers: providers };
}
} else if (filters.provider) {
// Single provider
const providerName = this.formatProviderName(filters.provider);
return { name: providerName, id: String(filters.provider).toLowerCase() };
}
@@ -178,16 +203,10 @@ export class HistoryComponent {
// Not a valid URL or no provider in query params
}
// Default to the selected provider if available
const defaultProvider = this.getDefaultProvider();
if (defaultProvider) {
return { name: this.formatProviderName(defaultProvider), id: defaultProvider.toLowerCase() };
}
// Fallback to 'all' if no provider can be determined
return { name: 'Tous', id: 'all' };
}
// Get the default provider from the current URL or settings
private getDefaultProvider(): string | null {
try {
@@ -195,11 +214,11 @@ export class HistoryComponent {
const url = new URL(window.location.href);
const provider = url.searchParams.get('provider');
if (provider) return provider.toLowerCase();
// Or get from localStorage if available
const savedProvider = localStorage.getItem('selectedProvider');
if (savedProvider) return savedProvider.toLowerCase();
// Default to youtube if nothing else
return 'youtube';
} catch (e) {
@@ -207,6 +226,10 @@ export class HistoryComponent {
}
}
getProvidersWithInfo(item: SearchHistoryItem) {
return [];
}
// Format provider ID to a display name
private formatProviderName(providerId: string): string {
const providerMap: { [key: string]: string } = {
@@ -225,27 +248,66 @@ export class HistoryComponent {
return this.formatProviderName(providerId);
}
// Brand colors for provider badges (inline to avoid Tailwind purge issues)
getProviderColors(providerId: string): { [key: string]: string } {
const id = (providerId || '').toLowerCase();
// Obtenir les styles pour un provider
getProviderColors(providerId: string | null | undefined): { [key: string]: string } {
if (!providerId) {
return {
'background-color': 'rgb(51 65 85 / 0.15)',
'color': 'rgb(148 163 184)',
'border-color': 'rgb(71 85 105 / 0.3)'
};
}
const id = providerId.toLowerCase();
let bgColor = 'rgb(51 65 85 / 0.15)';
let textColor = 'rgb(148 163 184)';
let borderColor = 'rgb(71 85 105 / 0.3)';
switch (id) {
case 'youtube':
return { backgroundColor: 'rgba(220, 38, 38, 0.15)', color: 'rgb(248, 113, 113)', borderColor: 'rgba(239, 68, 68, 0.3)' };
case 'vimeo':
return { backgroundColor: 'rgba(2, 132, 199, 0.15)', color: 'rgb(125, 211, 252)', borderColor: 'rgba(14, 165, 233, 0.3)' };
case 'yt':
bgColor = 'rgb(220 38 38 / 0.15)';
textColor = 'rgb(248 113 113)';
borderColor = 'rgb(239 68 68 / 0.3)';
break;
case 'dailymotion':
return { backgroundColor: 'rgba(37, 99, 235, 0.15)', color: 'rgb(147, 197, 253)', borderColor: 'rgba(59, 130, 246, 0.3)' };
case 'peertube':
return { backgroundColor: 'rgba(245, 158, 11, 0.15)', color: 'rgb(252, 211, 77)', borderColor: 'rgba(245, 158, 11, 0.3)' };
case 'rumble':
return { backgroundColor: 'rgba(22, 163, 74, 0.15)', color: 'rgb(134, 239, 172)', borderColor: 'rgba(34, 197, 94, 0.3)' };
case 'dm':
bgColor = 'rgb(37 99 235 / 0.15)';
textColor = 'rgb(96 165 250)';
borderColor = 'rgb(59 130 246 / 0.3)';
break;
case 'twitch':
return { backgroundColor: 'rgba(168, 85, 247, 0.15)', color: 'rgb(216, 180, 254)', borderColor: 'rgba(168, 85, 247, 0.3)' };
case 'tw':
bgColor = 'rgb(147 51 234 / 0.15)';
textColor = 'rgb(192 132 252)';
borderColor = 'rgb(168 85 247 / 0.3)';
break;
case 'peertube':
case 'pt':
bgColor = 'rgb(217 119 6 / 0.15)';
textColor = 'rgb(251 191 36)';
borderColor = 'rgb(245 158 11 / 0.3)';
break;
case 'odysee':
return { backgroundColor: 'rgba(236, 72, 153, 0.15)', color: 'rgb(251, 207, 232)', borderColor: 'rgba(236, 72, 153, 0.3)' };
default:
return { backgroundColor: 'rgba(30, 41, 59, 0.8)', color: 'rgb(203, 213, 225)', borderColor: 'rgb(51, 65, 85)' };
case 'od':
bgColor = 'rgb(22 163 74 / 0.15)';
textColor = 'rgb(74 222 128)';
borderColor = 'rgb(34 197 94 / 0.3)';
break;
case 'rumble':
case 'ru':
bgColor = 'rgb(234 88 12 / 0.15)';
textColor = 'rgb(251 146 60)';
borderColor = 'rgb(249 115 22 / 0.3)';
break;
}
return {
'background-color': bgColor,
'color': textColor,
'border-color': borderColor
};
}
// Format seconds to HH:MM:SS or MM:SS
@@ -255,7 +317,6 @@ export class HistoryComponent {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {
@@ -102,6 +102,8 @@ export class HeaderComponent {
qp.providers = 'yt,dm,tw,pt,od,ru';
}
if (theme) qp.theme = theme;
try { console.debug('[Header] onSearchBoxSubmit -> navigate', qp); } catch {}
this.suggestionsOpen.set(false);
this.router.navigate(['/search'], { queryParams: qp });
}
@@ -1,4 +1,4 @@
<form class="w-full relative" (submit)="onSubmit($event)" (keydown)="handleKeydown($event)">
<form class="w-full relative" novalidate (ngSubmit)="onSubmit($event)" (submit)="onSubmit($event)" (keydown)="handleKeydown($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 -->
@@ -25,14 +25,20 @@
</ng-container>
<!-- Text input -->
<input type="search"
<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)" name="q"/>
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" name="q"
autocomplete="off" aria-label="Rechercher des vidéos"/>
<!-- 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>
<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 *ngIf="(query() || '').length > 0" type="button" (click)="queryUpdate('')" class="text-xs px-2 py-1 rounded hover:bg-slate-700/70" aria-label="Effacer la recherche">×</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">
Search
</button>
</div>
<!-- Provider Picker Modal -->
+75 -10
View File
@@ -1,27 +1,44 @@
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener } from '@angular/core';
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
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';
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
@Component({
selector: 'app-search-box',
standalone: true,
imports: [CommonModule, FormsModule, ProviderPickerComponent],
imports: [
CommonModule,
FormsModule,
ProviderPickerComponent,
HttpClientModule // Nécessaire pour SearchService
],
providers: [
SearchService, // Fournir le service au niveau du composant
UserService, // S'assurer que UserService est fourni
HistoryService // S'assurer que HistoryService est fourni
],
templateUrl: './search-box.component.html'
})
export class SearchBoxComponent {
private search = inject(SearchService);
private users = inject(UserService);
private history = inject(HistoryService);
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
// Input/Output bindings
@Input() placeholder: string = 'Search videos…';
@Output() submitted = new EventEmitter<{ q: string; providers: ProviderId[] | 'all' }>();
// Emits debounced query changes for autosuggest
@Output() searchChange = new EventEmitter<string>();
// Local state signals
query = signal('');
@@ -56,11 +73,23 @@ export class SearchBoxComponent {
});
constructor() {
// Flag to avoid clobbering user input after initial hydration from SearchService
let hydratedFromService = false;
// Debounced query change emitter for suggestions/autocomplete
this._qChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntilDestroyed()
).subscribe((value) => {
this.searchChange.emit((value || '').trim());
});
// Reflect SearchService into local state to restore from URL/preference
effect(() => {
if (hydratedFromService) return; // only hydrate once
const q = this.search.q$.value;
const prov = this.search.providers$.value;
if (typeof q === 'string' && q !== this.query()) this.query.set(q);
if (typeof q === 'string') this.query.set(q);
if (prov === 'all') {
this.providerMode.set('all');
this.selected.set([]);
@@ -68,7 +97,7 @@ export class SearchBoxComponent {
this.providerMode.set('custom');
this.selected.set(prov);
}
hydratedFromService = true;
});
// Apply default providers from user preferences if none explicitly set
@@ -89,7 +118,11 @@ export class SearchBoxComponent {
// Update query and handle inline '@provider' prefix to open filtered picker
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);
if (m) {
const term = m[2] || '';
@@ -153,8 +186,14 @@ export class SearchBoxComponent {
if (pid) { ev.preventDefault(); this.toggleChip(pid); }
}
if (ev.key === 'Escape') {
// Escape clears transient UI; if nothing is open, also clear the query
const hadAny = this.pickerOpen() || this.suggestionsOpen() || this.atOpen() || this.quickOpen();
this.pickerOpen.set(false);
this.suggestionsOpen.set(false);
this.atOpen.set(false);
if (!hadAny && (this.query() || '').length > 0) {
this.queryUpdate('');
}
}
}
@@ -189,14 +228,37 @@ export class SearchBoxComponent {
}
onSubmit(ev: Event) {
ev.preventDefault();
const q = (this.query() || '').trim();
if (!q) return;
try { ev.preventDefault(); } catch {}
let q = (this.query() || '').trim();
if ((!q || q.length === 0) && this.qInputRef?.nativeElement) {
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;
}
const prov = this.providersForRequest();
// Update service for global listeners
this.search.setQuery(q);
this.search.setProviders(prov);
console.log('[SearchBox] Emitting submit event', { q, providers: prov });
// Emit payload to parent (Header/Search page)
this.submitted.emit({ q, providers: prov });
console.log('[SearchBox] Submit event emitted');
}
openPicker() { this.pickerOpen.set(true); }
@@ -244,4 +306,7 @@ export class SearchBoxComponent {
document.body.style.overflow = open ? 'hidden' : '';
} catch {}
});
// Internal subject for debouncing query changes
private _qChanges = new Subject<string>();
}
+63 -13
View File
@@ -1,5 +1,8 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">{{ pageHeading() }}</h2>
<header class="mb-4">
<h1 class="text-xl sm:text-2xl font-semibold text-slate-100">{{ pageHeading() }}</h1>
<p class="text-sm text-white/60 mt-1">{{ activeProvidersDisplay() }}</p>
</header>
@if (notice()) {
<div class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">
@@ -27,21 +30,68 @@
</div>
}
<!-- Unified multi-provider grouped suggestions -->
<!-- Unified per-provider sections -->
@if (showUnified()) {
<app-search-suggestions [groups]="groups()" [query]="q()"></app-search-suggestions>
<section *ngFor="let p of activeProviders()">
<div class="sticky top-0 z-10 flex items-center justify-between bg-black/50 backdrop-blur px-4 py-2 border-b border-white/10">
<h2 class="text-sm font-medium">{{ providerDisplay(p) }} <span class="text-white/40" *ngIf="groups()[p]">({{ groups()[p].length || 0 }})</span></h2>
<button class="inline-flex items-center rounded-xl px-3 py-1.5 text-xs border border-white/20 hover:bg-white/5"
(click)="onViewAll(p)"
aria-label="Voir tout sur {{ providerDisplay(p) }}">
Voir tout sur {{ providerDisplay(p) }}
</button>
</div>
<div *ngIf="groups()[p]?.length; else noResTpl" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 p-4">
<a *ngFor="let v of groups()[p]"
class="group block rounded-2xl border border-white/10 bg-white/5 hover:bg-white/10 transition overflow-hidden"
[href]="v.url || '#'" target="_blank" rel="noopener noreferrer"
[attr.aria-label]="v.title">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" class="w-full h-44 object-cover" loading="lazy">
</div>
<div class="p-3">
<div class="text-sm font-medium text-slate-100 line-clamp-2">{{ v.title }}</div>
<div class="mt-1 text-xs text-white/60 line-clamp-1">{{ v.uploaderName }}</div>
</div>
</a>
</div>
<ng-template #noResTpl>
<div class="px-4 py-6 text-white/50 text-sm">Aucun résultat pour ce fournisseur.</div>
</ng-template>
</section>
}
<input
#searchInput
type="text"
class="flex-1 bg-slate-900 text-slate-100 border border-slate-700 rounded-l-full px-4 py-2 focus:outline-none focus:ring-2 focus:ring-red-500"
placeholder="Rechercher..."
[value]="q()"
(input)="onSearchInput($event)"
(keydown.enter)="onSearchEnter($event)"
(keydown.escape)="closeSearchResults()"
/>
@if (hasQuery()) {
<div class="my-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div class="flex items-center gap-2">
<label class="text-sm text-slate-300" for="sortSelect">Trier par</label>
<select id="sortSelect" class="bg-slate-900 text-slate-100 border border-slate-700 rounded px-2 py-1 text-sm"
[value]="sortParam()" (change)="onSortChange($event)">
<option value="relevance">Pertinence</option>
<option value="date">Date</option>
<option value="views">Vues</option>
</select>
</div>
<div class="flex items-center gap-2 justify-end">
<button class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50"
(click)="prevPage()" [disabled]="!canPrev()">Précédent</button>
<div class="text-sm text-slate-300">Page {{ pageParam() }}</div>
<button class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" (click)="nextPage()">Suivant</button>
</div>
</div>
}
@if (showUnified() && !loading() && unifiedTotal() === 0 && hasQuery()) {
<p class="text-slate-400">Aucun résultat pour « {{ q() }} » avec les fournisseurs sélectionnés.</p>
}
@if (error()) {
<div class="mb-4 bg-red-900/40 border border-red-600 text-red-200 p-3 rounded flex items-center justify-between">
<span>{{ error() }}</span>
<button class="px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white text-sm" (click)="onSearchBarSubmit({ q: q(), providers: providersListParam() || 'all' })">Réessayer</button>
</div>
}
@if (loading()) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
+118 -29
View File
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal, untracked, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video } from '../../models/video.model';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
@@ -11,19 +11,20 @@ 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';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-search',
standalone: true,
templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent, SearchSuggestionsComponent]
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent]
})
export class SearchComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
private history = inject(HistoryService);
@@ -46,10 +47,16 @@ export class SearchComponent {
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
providersListParam = signal<ProviderId[] | 'all' | null>(null);
pageParam = signal<number>(1);
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
// Unified multi-provider response (grouped suggestions)
groups = signal<Record<ProviderId, any[]>>({} as any);
showUnified = signal<boolean>(false);
error = signal<string | null>(null);
// Dedup key to avoid recording the same search multiple times
private lastRecordedKey: string | null = null;
hasQuery = computed(() => this.q().length > 0);
providerLabel = computed(() => {
@@ -60,7 +67,33 @@ export class SearchComponent {
}
return this.instances.selectedProviderLabel();
});
pageHeading = computed(() => `Search - ${this.providerLabel()}${this.q() ? ' - ' + this.q() : ''}`);
// Active providers derived from `providers` query param or fallback to all available
activeProviders = computed<ProviderId[]>(() => {
const p = this.providersListParam();
if (p === 'all' || p == null) {
try { return (this.instances.providers() || []).map(x => x.id as unknown as ProviderId); }
catch { return ['yt','dm','tw','pt','od','ru']; }
}
if (Array.isArray(p) && p.length > 0) return p as ProviderId[];
return ['yt','dm','tw','pt','od','ru'];
});
// Subtitle display: codes separated by middot
activeProvidersDisplay = computed(() => this.activeProviders().join(' · '));
// Helper to display provider label from registry/instances
providerDisplay(p: ProviderId): string {
try {
const found = (this.instances.providers() || []).find(x => (x.id as any) === p);
return found?.label || p.toUpperCase();
} catch { return p.toUpperCase(); }
}
// Title: Search ${q ? ` — “${q}”` : ''}
pageHeading = computed(() => {
const q = this.q();
return `Search ${q ? ` — “${q}”` : ''}`;
});
// Public computed used by the template to avoid referencing private `instances`
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
@@ -68,6 +101,16 @@ export class SearchComponent {
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
filterTag = signal<string>('all');
// Unified total results count (sum of groups lengths)
unifiedTotal = computed(() => {
const g = this.groups();
try {
return Object.values(g).reduce((acc: number, arr: any) => acc + (Array.isArray(arr) ? arr.length : 0), 0);
} catch { return 0; }
});
canPrev = computed(() => (this.pageParam() || 1) > 1);
// Helper computed: filtered list based on active tag (non-Twitch only)
filteredResults = computed(() => {
const tag = this.filterTag();
@@ -247,24 +290,29 @@ export class SearchComponent {
});
// Subscribe once to unified multi-provider results and reflect to UI
this.unified.request$.subscribe({
this.unified.request$.pipe(takeUntilDestroyed()).subscribe({
next: (resp: SearchResponse) => {
this.groups.set(resp.groups as any);
this.loading.set(false);
this.error.set(null);
},
error: () => {
this.groups.set({} as any);
this.loading.set(false);
this.error.set('Le service de recherche est temporairement indisponible. Réessayer.');
}
});
// Listen to query param changes (so subsequent searches update)
this.route.queryParamMap.subscribe((pm) => {
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((pm) => {
const q = (pm.get('q') || '').trim();
this.q.set(q);
const prov = (pm.get('provider') as Provider) || null;
const prov = (pm.get('provider') as Provider) || (pm.get('p') as Provider) || null;
const theme = pm.get('theme');
const providersParam = (pm.get('providers') || '').trim();
const page = Number(pm.get('page') || '1');
const pageSize = Number(pm.get('pageSize') || '24');
const sort = pm.get('sort') || undefined;
// Parse providers CSV if present
let providersList: ProviderId[] | 'all' | null = null;
if (providersParam) {
@@ -274,6 +322,13 @@ export class SearchComponent {
this.providerParam.set(prov);
this.themeParam.set(theme);
this.providersListParam.set(providersList);
const pageNum = isFinite(page) ? page : 1;
this.pageParam.set(pageNum);
this.unified.setPage(pageNum);
if (isFinite(pageSize)) this.unified.setPageSize(pageSize);
const sortNorm = (sort === 'date' || sort === 'views') ? sort : 'relevance';
this.sortParam.set(sortNorm);
this.unified.setSort(sortNorm);
if (q) {
this.notice.set(null);
@@ -285,15 +340,33 @@ export class SearchComponent {
const providersToUse = providersList || [provider as ProviderId];
this.unified.setQuery(q);
this.unified.setProviders(providersToUse);
// Record this search once per unique (q, providers) combination
try {
const key = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}`;
if (this.lastRecordedKey !== key) {
this.lastRecordedKey = key;
const recFilters: any = { provider, providers: Array.isArray(providersToUse) ? providersToUse : 'all' };
this.history.recordSearch(q, recFilters).subscribe({
next: () => {},
error: (err) => console.error('Error recording search:', err)
});
}
} catch (err) {
console.error('Error in recordSearch:', err);
}
this.showUnified.set(true);
this.loading.set(true);
this.groups.set({} as any); // Clear previous results
this.error.set(null);
} else {
this.results.set([]);
this.nextCursor.set(null);
this.loading.set(false);
this.groups.set({} as any);
this.showUnified.set(false);
this.error.set(null);
}
});
@@ -464,30 +537,21 @@ export class SearchComponent {
return qp;
}
// Submit handler from the embedded SearchBox: keep URL as source of truth
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all' }) {
const q = (evt?.q || '').trim();
if (!q) return;
const provider = this.providerParam() || this.instances.selectedProvider();
const qp: any = { q, p: provider, provider: null };
if (Array.isArray(evt.providers)) qp.providers = evt.providers.join(',');
else if (evt.providers === 'all') qp.providers = 'yt,dm,tw,pt,od,ru';
qp.page = 1; // reset pagination on new search
this.router.navigate([], { relativeTo: this.route, queryParams: qp, queryParamsHandling: 'merge' });
}
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
onSearchEnter(event: Event) {
const keyboardEvent = event as KeyboardEvent;
keyboardEvent.preventDefault();
const query = this.q().trim();
if (query) {
// 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) {
this.searchInput.nativeElement.blur();
}
}
}
// Legacy input handlers removed; SearchBox handles keyboard and submit
closeSearchResults() {
// Cette méthode sera appelée pour fermer les résultats de recherche
@@ -506,4 +570,29 @@ export class SearchComponent {
setFilterTag(key: string) {
this.filterTag.set(key);
}
// Pagination controls
nextPage() {
const next = (this.pageParam() || 1) + 1;
this.router.navigate([], { relativeTo: this.route, queryParams: { page: next }, queryParamsHandling: 'merge' });
}
prevPage() {
const prev = Math.max(1, (this.pageParam() || 1) - 1);
this.router.navigate([], { relativeTo: this.route, queryParams: { page: prev }, queryParamsHandling: 'merge' });
}
// Sort control
onSortChange(event: Event) {
const value = (event.target as HTMLSelectElement)?.value as 'relevance' | 'date' | 'views' | '';
const sort = (value === 'date' || value === 'views') ? value : 'relevance';
this.router.navigate([], { relativeTo: this.route, queryParams: { sort, page: 1 }, queryParamsHandling: 'merge' });
}
// CTA handler: navigate to focus on a single provider while preserving q
onViewAll(p: ProviderId) {
const q = (this.q() || '').trim();
const qp: any = { provider: p, p, providers: p, page: 1 };
if (q) qp.q = q;
this.router.navigate([], { relativeTo: this.route, queryParams: qp, queryParamsHandling: 'merge' });
}
}
+15 -8
View File
@@ -7,6 +7,7 @@ export interface SearchHistoryItem {
id: string;
query: string;
filters_json?: string | null;
providers?: string[]; // Liste des providers utilisés (ex: ['yt', 'dm', 'tw'])
created_at: string;
}
@@ -39,10 +40,16 @@ export class HistoryService {
// --- Search ---
recordSearch(query: string, filters?: Record<string, any>): Observable<{ id: string; created_at: string }> {
const url = `${this.apiBase()}/user/history/search`;
return this.http.post<{ id: string; created_at: string }>(
'/proxy/api/user/history/search',
url,
{ query, filters },
{ withCredentials: true }
).pipe(
tap({
next: (res) => console.debug('[HistoryService] Search recorded', { query, filters, res }),
error: (err) => console.error('[HistoryService] Failed to record search', err)
})
);
}
@@ -50,13 +57,13 @@ export class HistoryService {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (before) params.set('before', before);
return this.http.get<SearchHistoryItem[]>(`/proxy/api/user/history/search?${params.toString()}`, { withCredentials: true });
return this.http.get<SearchHistoryItem[]>(`${this.apiBase()}/user/history/search?${params.toString()}`, { withCredentials: true });
}
// --- Watch ---
recordWatchStart(provider: string, videoId: string, title?: string | null, watchedAt?: string, thumbnail?: string | null, durationSeconds?: number): Observable<WatchHistoryItem> {
return this.http.post<WatchHistoryItem>(
'/proxy/api/user/history/watch',
`${this.apiBase()}/user/history/watch`,
{ provider, videoId, title: title ?? null, watchedAt, thumbnail: thumbnail ?? null, durationSeconds },
{ withCredentials: true }
);
@@ -89,16 +96,16 @@ export class HistoryService {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (before) params.set('before', before);
return this.http.get<WatchHistoryItem[]>(`/proxy/api/user/history/watch?${params.toString()}`, { withCredentials: true });
return this.http.get<WatchHistoryItem[]>(`${this.apiBase()}/user/history/watch?${params.toString()}`, { withCredentials: true });
}
// --- Delete single items ---
deleteSearchItem(id: string): Observable<void> {
return this.http.delete<void>(`/proxy/api/user/history/search/${encodeURIComponent(id)}`, { withCredentials: true });
return this.http.delete<void>(`${this.apiBase()}/user/history/search/${encodeURIComponent(id)}`, { withCredentials: true });
}
deleteWatchItem(id: string): Observable<void> {
return this.http.delete<void>(`/proxy/api/user/history/watch/${encodeURIComponent(id)}`, { withCredentials: true });
return this.http.delete<void>(`${this.apiBase()}/user/history/watch/${encodeURIComponent(id)}`, { withCredentials: true });
}
// Recherche dans l'historique des recherches
@@ -107,7 +114,7 @@ export class HistoryService {
params.set('q', query);
params.set('limit', String(limit));
return this.http.get<SearchHistoryItem[]>(
`/proxy/api/user/history/search?${params.toString()}`,
`${this.apiBase()}/user/history/search?${params.toString()}`,
{ withCredentials: true }
);
}
@@ -118,7 +125,7 @@ export class HistoryService {
params.set('q', query);
params.set('limit', String(limit));
return this.http.get<WatchHistoryItem[]>(
`/proxy/api/user/history/watch?${params.toString()}`,
`${this.apiBase()}/user/history/watch?${params.toString()}`,
{ withCredentials: true }
);
}