feat: update Angular cache with latest TypeScript definitions
This commit is contained in:
@@ -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]="getSearchProvider(s).providers ? { q: s.query, providers: getSearchProvider(s).providers } : { q: s.query, provider: getSearchProvider(s).id }"
|
||||
<a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)"
|
||||
class="block p-4 pr-16 hover:no-underline">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-slate-400 mt-0.5">
|
||||
@@ -114,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]="getSearchProvider(s).providers ? { q: s.query, providers: getSearchProvider(s).providers } : { q: s.query, provider: getSearchProvider(s).id }"
|
||||
<a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)"
|
||||
class="block p-4 pr-16 hover:no-underline">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-slate-400 mt-0.5">
|
||||
|
||||
@@ -230,6 +230,17 @@ getSearchProvider(item: SearchHistoryItem): { name: string; id: string; provider
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get query params for search navigation
|
||||
getSearchQueryParams(item: SearchHistoryItem): any {
|
||||
const providerInfo = this.getSearchProvider(item);
|
||||
if (providerInfo.providers && providerInfo.providers.length > 1) {
|
||||
// Convert array to comma-separated string for URL
|
||||
return { q: item.query, providers: providerInfo.providers.join(',') };
|
||||
} else {
|
||||
return { q: item.query, provider: providerInfo.id };
|
||||
}
|
||||
}
|
||||
|
||||
// Format provider ID to a display name
|
||||
private formatProviderName(providerId: string): string {
|
||||
const providerMap: { [key: string]: string } = {
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<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 class="mb-6">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-white mb-2">{{ pageHeading() }}</h1>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
@for (provider of activeProvidersInfo(); track provider.id) {
|
||||
<div class="flex items-center gap-2 rounded px-2 py-1 text-xs font-medium border"
|
||||
[ngStyle]="getProviderColors(provider.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" 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>
|
||||
{{ provider.label }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (notice()) {
|
||||
@@ -30,38 +41,6 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Unified per-provider sections -->
|
||||
@if (showUnified()) {
|
||||
<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>
|
||||
}
|
||||
|
||||
@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">
|
||||
@@ -80,150 +59,14 @@
|
||||
<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">
|
||||
@for (item of [1,2,3,4,5,6,7,8]; track item) {
|
||||
<div class="animate-pulse bg-slate-800 rounded-lg overflow-hidden">
|
||||
<div class="w-full h-48 bg-slate-700"></div>
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="h-4 bg-slate-700 rounded w-3/4"></div>
|
||||
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (selectedProviderForView() === 'twitch') {
|
||||
<!-- Twitch: two sections -->
|
||||
<div class="space-y-10">
|
||||
<!-- Live Channels -->
|
||||
<section *ngIf="filterTag() === 'twitch_all' || filterTag() === 'twitch_live'">
|
||||
<h3 class="text-xl font-semibold text-slate-200 mb-3">Chaînes en direct</h3>
|
||||
@if (twitchChannels().length > 0) {
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
|
||||
@for (video of twitchChannels(); track video.videoId) {
|
||||
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div class="relative">
|
||||
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute top-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
|
||||
{{ formatViews(video.views) }} en direct
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
|
||||
<app-add-to-playlist class="ml-2" [provider]="'twitch'" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 flex-grow flex flex-col">
|
||||
<h4 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h4>
|
||||
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
|
||||
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
|
||||
<span>{{ video.uploaderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
@if (twitchCursorChannels()) {
|
||||
<div class="mt-4 flex justify-center">
|
||||
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50" (click)="loadMoreTwitchChannels()" [disabled]="twitchBusyChannels()">Afficher plus</button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<p class="text-slate-400">Aucune chaîne en direct trouvée.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Past Videos (VODs) -->
|
||||
<section *ngIf="filterTag() === 'twitch_all' || filterTag() === 'twitch_vod'">
|
||||
<h3 class="text-xl font-semibold text-slate-200 mb-3">Vidéos (VOD)</h3>
|
||||
@if (twitchVods().length > 0) {
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
|
||||
@for (video of twitchVods(); track video.videoId) {
|
||||
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div class="relative">
|
||||
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
|
||||
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
|
||||
<app-add-to-playlist class="ml-2" [provider]="'twitch'" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 flex-grow flex flex-col">
|
||||
<h4 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h4>
|
||||
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
|
||||
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
|
||||
<span>{{ video.uploaderName }}</span>
|
||||
</div>
|
||||
<div class="mt-auto pt-2 text-sm text-slate-400">
|
||||
<span>{{ formatViews(video.views) }} visionnements</span> • <span>{{ formatRelative(video.uploadedDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
@if (twitchCursorVods()) {
|
||||
<div class="mt-4 flex justify-center">
|
||||
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50" (click)="loadMoreTwitchVods()" [disabled]="twitchBusyVods()">Afficher plus</button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<p class="text-slate-400">Aucune vidéo VOD trouvée.</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
} @else if (results().length > 0) {
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
|
||||
@for (video of filteredResults(); track video.videoId) {
|
||||
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div class="relative">
|
||||
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
|
||||
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<app-like-button [videoId]="video.videoId" [provider]="selectedProviderForView()" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
|
||||
<app-add-to-playlist class="ml-2" [provider]="selectedProviderForView()" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 flex-grow flex flex-col">
|
||||
<h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h3>
|
||||
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
|
||||
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
|
||||
<span>{{ video.uploaderName }}</span>
|
||||
</div>
|
||||
<div class="mt-auto pt-2 text-sm text-slate-400">
|
||||
<span>{{ formatViews(video.views) }} visionnements</span> • <span>{{ formatRelative(video.uploadedDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<!-- Infinite scroll anchor -->
|
||||
<app-infinite-anchor class="mt-6"
|
||||
[disabled]="!nextCursor()"
|
||||
[busy]="busyMore()"
|
||||
(loadMore)="fetchNextPage()"></app-infinite-anchor>
|
||||
@if (busyMore()) {
|
||||
<div class="flex items-center justify-center py-4 text-slate-400">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
|
||||
{{ 'loading.more' | t }}
|
||||
@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>
|
||||
}
|
||||
} @else if (hasQuery()) {
|
||||
<p class="text-slate-400">{{ 'search.noResults' | t }}</p>
|
||||
|
||||
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()"></app-search-result-grid>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal, untracked, ViewChild } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewChild, ElementRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { ActivatedRoute, Router, ParamMap } 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';
|
||||
import { formatRelativeFr } from '../../utils/date.util';
|
||||
import { InstanceService, Provider } from '../../services/instance.service';
|
||||
import { HistoryService } from '../../services/history.service';
|
||||
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 { 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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-search',
|
||||
standalone: true,
|
||||
templateUrl: './search.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent]
|
||||
imports: [CommonModule, TranslatePipe, SearchResultGridComponent]
|
||||
})
|
||||
export class SearchComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
@@ -33,16 +32,6 @@ export class SearchComponent {
|
||||
|
||||
q = signal<string>('');
|
||||
loading = signal<boolean>(false);
|
||||
results = signal<Video[]>([]);
|
||||
busyMore = signal<boolean>(false);
|
||||
nextCursor = signal<string | null>(null);
|
||||
// Twitch-specific dual lists
|
||||
twitchChannels = signal<Video[]>([]);
|
||||
twitchVods = signal<Video[]>([]);
|
||||
twitchCursorChannels = signal<string | null>(null);
|
||||
twitchCursorVods = signal<string | null>(null);
|
||||
twitchBusyChannels = signal<boolean>(false);
|
||||
twitchBusyVods = signal<boolean>(false);
|
||||
notice = signal<string | null>(null);
|
||||
providerParam = signal<Provider | null>(null);
|
||||
themeParam = signal<string | null>(null);
|
||||
@@ -51,7 +40,7 @@ export class SearchComponent {
|
||||
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
|
||||
|
||||
// Unified multi-provider response (grouped suggestions)
|
||||
groups = signal<Record<ProviderId, any[]>>({} as any);
|
||||
groups = signal<Record<string, any[]>>({});
|
||||
showUnified = signal<boolean>(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
@@ -79,20 +68,52 @@ export class SearchComponent {
|
||||
return ['yt','dm','tw','pt','od','ru'];
|
||||
});
|
||||
|
||||
// Subtitle display: codes separated by middot
|
||||
activeProvidersDisplay = computed(() => this.activeProviders().join(' · '));
|
||||
// Map between Provider and ProviderId
|
||||
private readonly providerIdMap: Record<string, Provider> = {
|
||||
'yt': 'youtube',
|
||||
'dm': 'dailymotion',
|
||||
'tw': 'twitch',
|
||||
'pt': 'peertube',
|
||||
'od': 'odysee',
|
||||
'ru': 'rumble'
|
||||
};
|
||||
|
||||
// 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(); }
|
||||
// Get active providers with full info
|
||||
activeProvidersInfo = computed(() => {
|
||||
const activeIds = this.activeProviders();
|
||||
return this.instances.providers().filter(p =>
|
||||
activeIds.some(id => this.providerIdMap[id] === p.id)
|
||||
);
|
||||
});
|
||||
|
||||
// Brand colors for provider badge (inline styles to match History badges)
|
||||
getProviderColors(providerId: string | null | undefined): { [key: string]: string } {
|
||||
const id = (providerId || '').toLowerCase();
|
||||
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 '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 'twitch':
|
||||
return { backgroundColor: 'rgba(168, 85, 247, 0.15)', color: 'rgb(216, 180, 254)', borderColor: 'rgba(168, 85, 247, 0.3)' };
|
||||
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)' };
|
||||
}
|
||||
}
|
||||
// Title: Search ${q ? ` — “${q}”` : ''}
|
||||
|
||||
// Page title with search query
|
||||
pageHeading = computed(() => {
|
||||
const q = this.q();
|
||||
return `Search ${q ? ` — “${q}”` : ''}`;
|
||||
if (q) {
|
||||
return `Résultats pour « ${q} »`;
|
||||
}
|
||||
return 'Recherche';
|
||||
});
|
||||
// Public computed used by the template to avoid referencing private `instances`
|
||||
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
|
||||
@@ -111,46 +132,19 @@ export class SearchComponent {
|
||||
|
||||
canPrev = computed(() => (this.pageParam() || 1) > 1);
|
||||
|
||||
// Helper computed: filtered list based on active tag (non-Twitch only)
|
||||
// Flatten all unified results into a single list of VideoItems
|
||||
allVideos = computed<any[]>(() => {
|
||||
const g = this.groups();
|
||||
if (!g) return [];
|
||||
return Object.values(g).flat();
|
||||
});
|
||||
|
||||
// Helper computed: filtered list based on active tag
|
||||
filteredResults = computed(() => {
|
||||
const tag = this.filterTag();
|
||||
const list = this.allVideos();
|
||||
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;
|
||||
|
||||
@@ -162,84 +156,76 @@ export class SearchComponent {
|
||||
|
||||
// Duration filters
|
||||
if (tag === 'short') return list.filter(v => {
|
||||
const d = Number(v.duration || 0);
|
||||
const d = Number(v.durationSec || 0);
|
||||
return d > 0 && d < 4 * 60;
|
||||
});
|
||||
if (tag === 'medium') return list.filter(v => {
|
||||
const d = Number(v.duration || 0);
|
||||
const d = Number(v.durationSec || 0);
|
||||
return d >= 4 * 60 && d < 20 * 60;
|
||||
});
|
||||
if (tag === 'long') return list.filter(v => {
|
||||
const d = Number(v.duration || 0);
|
||||
const d = Number(v.durationSec || 0);
|
||||
return d >= 20 * 60;
|
||||
});
|
||||
|
||||
// Date filters
|
||||
if (tag === 'today') return list.filter(v => {
|
||||
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
|
||||
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
|
||||
return uploadTime > 0 && (now - uploadTime) <= oneDay;
|
||||
});
|
||||
if (tag === 'this_week') return list.filter(v => {
|
||||
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
|
||||
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
|
||||
return uploadTime > 0 && (now - uploadTime) <= sevenDays;
|
||||
});
|
||||
if (tag === 'this_month') return list.filter(v => {
|
||||
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
|
||||
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
|
||||
return uploadTime > 0 && (now - uploadTime) <= thirtyDays;
|
||||
});
|
||||
if (tag === 'this_year') return list.filter(v => {
|
||||
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
|
||||
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
|
||||
return uploadTime > 0 && (now - uploadTime) <= oneYear;
|
||||
});
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
// Filter each provider group separately for sectioned rendering
|
||||
filteredGroups = computed(() => {
|
||||
const tag = this.filterTag();
|
||||
const g = this.groups();
|
||||
const result: Record<string, any[]> = {};
|
||||
const now = Date.now();
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const sevenDays = 7 * oneDay;
|
||||
const thirtyDays = 30 * oneDay;
|
||||
const oneYear = 365 * oneDay;
|
||||
|
||||
const applyFilter = (list: any[]): any[] => {
|
||||
if (tag === 'all' || this.selectedProviderForView() === 'twitch') return list;
|
||||
if (tag === 'short') return list.filter(v => { const d = Number(v.durationSec || 0); return d > 0 && d < 4 * 60; });
|
||||
if (tag === 'medium') return list.filter(v => { const d = Number(v.durationSec || 0); return d >= 4 * 60 && d < 20 * 60; });
|
||||
if (tag === 'long') return list.filter(v => { const d = Number(v.durationSec || 0); return d >= 20 * 60; });
|
||||
if (tag === 'today') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= oneDay; });
|
||||
if (tag === 'this_week') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= sevenDays; });
|
||||
if (tag === 'this_month') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= thirtyDays; });
|
||||
if (tag === 'this_year') return list.filter(v => { const t = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; return t > 0 && (now - t) <= oneYear; });
|
||||
return list;
|
||||
};
|
||||
|
||||
Object.entries(g || {}).forEach(([pid, list]) => {
|
||||
result[pid] = Array.isArray(list) ? applyFilter(list) : [];
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
// Available tags computed from current results (only show tags that can apply)
|
||||
availableTags = computed(() => {
|
||||
const provider = this.selectedProviderForView();
|
||||
const list = this.allVideos();
|
||||
if (list.length === 0) return [];
|
||||
|
||||
const tags: { key: string; label: string; show: boolean }[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
// 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;
|
||||
// Show the group if at least one of the sections is available
|
||||
if (hasLive || hasVods) {
|
||||
tags.push({ key: 'twitch_all', label: 'Tout', show: hasLive && hasVods });
|
||||
tags.push({ key: 'twitch_live', label: 'En direct', show: hasLive });
|
||||
tags.push({ key: 'twitch_vod', label: 'VOD', show: hasVods });
|
||||
}
|
||||
return tags.filter(t => t.show);
|
||||
}
|
||||
|
||||
// Non-Twitch providers: duration + date filters
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const sevenDays = 7 * oneDay;
|
||||
@@ -250,8 +236,8 @@ export class SearchComponent {
|
||||
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 d = Number(v.durationSec || 0);
|
||||
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
|
||||
const timeDiff = now - uploadTime;
|
||||
|
||||
// Duration filters
|
||||
@@ -290,21 +276,32 @@ export class SearchComponent {
|
||||
});
|
||||
|
||||
// Subscribe once to unified multi-provider results and reflect to UI
|
||||
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.');
|
||||
}
|
||||
});
|
||||
const subscription: Subscription = this.unified.request$
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe({
|
||||
next: (resp: any) => {
|
||||
// Convertir la réponse en un format compatible avec notre modèle
|
||||
const groups: Record<string, any[]> = {};
|
||||
if (resp.groups) {
|
||||
Object.entries(resp.groups).forEach(([key, value]) => {
|
||||
groups[key] = Array.isArray(value) ? value : [];
|
||||
});
|
||||
}
|
||||
this.groups.set(groups);
|
||||
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.pipe(takeUntilDestroyed()).subscribe((pm) => {
|
||||
const paramSubscription: Subscription = this.route.queryParamMap
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((pm: ParamMap) => {
|
||||
const q = (pm.get('q') || '').trim();
|
||||
this.q.set(q);
|
||||
const prov = (pm.get('provider') as Provider) || (pm.get('p') as Provider) || null;
|
||||
@@ -361,8 +358,6 @@ export class SearchComponent {
|
||||
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);
|
||||
@@ -370,139 +365,8 @@ export class SearchComponent {
|
||||
}
|
||||
});
|
||||
|
||||
// React to provider/region/PeerTube instance changes to refresh search automatically
|
||||
effect(() => {
|
||||
const provider = this.instances.selectedProvider();
|
||||
const region = this.instances.region();
|
||||
const ptInstance = this.instances.activePeerTubeInstance();
|
||||
|
||||
untracked(() => {
|
||||
// If provider is explicitly specified in query, do not override it with global changes
|
||||
if (this.providerParam()) return;
|
||||
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();
|
||||
}
|
||||
});
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
reloadSearch() {
|
||||
const readiness = this.instances.getProviderReadiness(this.providerParam() || undefined as any);
|
||||
if (!readiness.ready) {
|
||||
this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
|
||||
this.results.set([]);
|
||||
this.nextCursor.set(null);
|
||||
this.twitchChannels.set([]);
|
||||
this.twitchVods.set([]);
|
||||
this.twitchCursorChannels.set(null);
|
||||
this.twitchCursorVods.set(null);
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
this.loading.set(true);
|
||||
this.results.set([]);
|
||||
this.nextCursor.set(null);
|
||||
this.twitchChannels.set([]);
|
||||
this.twitchVods.set([]);
|
||||
this.twitchCursorChannels.set(null);
|
||||
this.twitchCursorVods.set(null);
|
||||
const provider = this.providerParam() || this.instances.selectedProvider();
|
||||
// Record search term with provider once per reload
|
||||
try {
|
||||
this.history.recordSearch(this.q(), { provider }).subscribe({
|
||||
next: () => {},
|
||||
error: (err) => console.error('Error recording search:', err)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in recordSearch:', err);
|
||||
}
|
||||
// Ensure default tag matches provider
|
||||
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
|
||||
if (provider === 'twitch') {
|
||||
// Load both sections in parallel
|
||||
this.twitchBusyChannels.set(true);
|
||||
this.twitchBusyVods.set(true);
|
||||
this.api.searchTwitchChannelsPage(this.q(), null).subscribe(res => {
|
||||
this.twitchChannels.set(res.items);
|
||||
this.twitchCursorChannels.set(res.nextCursor || null);
|
||||
this.twitchBusyChannels.set(false);
|
||||
this.loading.set(false);
|
||||
});
|
||||
this.api.searchTwitchVodsPage(this.q(), null).subscribe(res => {
|
||||
this.twitchVods.set(res.items);
|
||||
this.twitchCursorVods.set(res.nextCursor || null);
|
||||
this.twitchBusyVods.set(false);
|
||||
this.loading.set(false);
|
||||
});
|
||||
} else {
|
||||
this.fetchNextPage();
|
||||
}
|
||||
}
|
||||
|
||||
fetchNextPage() {
|
||||
if (this.busyMore() || !this.q()) return;
|
||||
const readiness = this.instances.getProviderReadiness(this.providerParam() || undefined as any);
|
||||
if (!readiness.ready) {
|
||||
if (!this.notice()) this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
|
||||
return;
|
||||
}
|
||||
this.busyMore.set(true);
|
||||
const providerOverride = this.providerParam();
|
||||
this.api.searchVideosPage(this.q(), this.nextCursor(), providerOverride as any).subscribe(res => {
|
||||
const merged = [...this.results(), ...res.items];
|
||||
this.results.set(merged);
|
||||
this.nextCursor.set(res.nextCursor || null);
|
||||
this.busyMore.set(false);
|
||||
this.loading.set(false);
|
||||
// Provider/instance availability notices (avoid overriding legitimate no-results unless clear)
|
||||
if (merged.length === 0 && !this.notice()) {
|
||||
const readiness2 = this.instances.getProviderReadiness();
|
||||
if (!readiness2.ready) {
|
||||
this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.');
|
||||
} else {
|
||||
const provider = this.providerParam() || this.instances.selectedProvider();
|
||||
if (provider === 'peertube') {
|
||||
const inst = this.instances.activePeerTubeInstance();
|
||||
this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`);
|
||||
} else if (provider === 'rumble') {
|
||||
const label = this.instances.selectedProviderLabel();
|
||||
this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadMoreTwitchChannels() {
|
||||
if (this.twitchBusyChannels() || !this.twitchCursorChannels()) return;
|
||||
this.twitchBusyChannels.set(true);
|
||||
this.api.searchTwitchChannelsPage(this.q(), this.twitchCursorChannels()).subscribe(res => {
|
||||
this.twitchChannels.set([...this.twitchChannels(), ...res.items]);
|
||||
this.twitchCursorChannels.set(res.nextCursor || null);
|
||||
this.twitchBusyChannels.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
loadMoreTwitchVods() {
|
||||
if (this.twitchBusyVods() || !this.twitchCursorVods()) return;
|
||||
this.twitchBusyVods.set(true);
|
||||
this.api.searchTwitchVodsPage(this.q(), this.twitchCursorVods()).subscribe(res => {
|
||||
this.twitchVods.set([...this.twitchVods(), ...res.items]);
|
||||
this.twitchCursorVods.set(res.nextCursor || null);
|
||||
this.twitchBusyVods.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
formatViews(views: number): string {
|
||||
if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B';
|
||||
@@ -516,40 +380,56 @@ export class SearchComponent {
|
||||
return formatRelativeFr(dateIso);
|
||||
}
|
||||
|
||||
// Build query params for Watch page (provider + optional odysee slug)
|
||||
watchQueryParams(v: Video): Record<string, any> | null {
|
||||
const p = this.providerParam() || this.instances.selectedProvider();
|
||||
const qp: any = { p };
|
||||
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
|
||||
let slug = v.url.substring('https://odysee.com/'.length);
|
||||
if (slug.startsWith('/')) slug = slug.slice(1);
|
||||
qp.slug = slug;
|
||||
|
||||
// Méthode utilitaire pour nettoyer les paramètres de l'URL
|
||||
private cleanUrlParams(params: any): any {
|
||||
const cleanParams = { ...params };
|
||||
// Supprimer explicitement le paramètre theme
|
||||
if ('theme' in cleanParams) {
|
||||
delete cleanParams.theme;
|
||||
}
|
||||
if (p === 'twitch' && v.type === 'channel') {
|
||||
// Extract channel login from uploaderUrl if available
|
||||
const url = v.uploaderUrl || v.url || '';
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const parts = u.pathname.split('/').filter(Boolean);
|
||||
if (parts.length > 0) qp.channel = parts[0];
|
||||
} catch {}
|
||||
}
|
||||
return qp;
|
||||
// Nettoyer les valeurs null/undefined
|
||||
Object.keys(cleanParams).forEach(key => {
|
||||
if (cleanParams[key] === null || cleanParams[key] === undefined) {
|
||||
delete cleanParams[key];
|
||||
}
|
||||
});
|
||||
return cleanParams;
|
||||
}
|
||||
|
||||
// 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' });
|
||||
|
||||
// Créer un nouvel objet de paramètres de requête
|
||||
const queryParams: any = {
|
||||
q,
|
||||
p: this.providerParam() || this.instances.selectedProvider(),
|
||||
page: 1 // reset pagination on new search
|
||||
};
|
||||
|
||||
// Gérer les fournisseurs
|
||||
if (Array.isArray(evt.providers)) {
|
||||
queryParams.providers = evt.providers.join(',');
|
||||
} else if (evt.providers === 'all') {
|
||||
queryParams.providers = 'yt,dm,tw,pt,od,ru';
|
||||
}
|
||||
|
||||
// Nettoyer les paramètres avant la navigation
|
||||
const cleanParams = this.cleanUrlParams(queryParams);
|
||||
|
||||
// Utiliser replaceUrl: true pour éviter d'ajouter une entrée dans l'historique
|
||||
// et forcer le rechargement avec les nouveaux paramètres
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: cleanParams,
|
||||
queryParamsHandling: '',
|
||||
replaceUrl: true
|
||||
});
|
||||
}
|
||||
|
||||
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('searchInput', { static: false }) searchInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
// Legacy input handlers removed; SearchBox handles keyboard and submit
|
||||
|
||||
@@ -574,18 +454,47 @@ export class SearchComponent {
|
||||
// Pagination controls
|
||||
nextPage() {
|
||||
const next = (this.pageParam() || 1) + 1;
|
||||
this.router.navigate([], { relativeTo: this.route, queryParams: { page: next }, queryParamsHandling: 'merge' });
|
||||
const cleanParams = this.cleanUrlParams({
|
||||
...this.route.snapshot.queryParams,
|
||||
page: next
|
||||
});
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: cleanParams,
|
||||
queryParamsHandling: '',
|
||||
replaceUrl: true
|
||||
});
|
||||
}
|
||||
|
||||
prevPage() {
|
||||
const prev = Math.max(1, (this.pageParam() || 1) - 1);
|
||||
this.router.navigate([], { relativeTo: this.route, queryParams: { page: prev }, queryParamsHandling: 'merge' });
|
||||
const cleanParams = this.cleanUrlParams({
|
||||
...this.route.snapshot.queryParams,
|
||||
page: prev
|
||||
});
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: cleanParams,
|
||||
queryParamsHandling: '',
|
||||
replaceUrl: true
|
||||
});
|
||||
}
|
||||
|
||||
// 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' });
|
||||
const cleanParams = this.cleanUrlParams({
|
||||
...this.route.snapshot.queryParams,
|
||||
sort,
|
||||
page: 1
|
||||
});
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: cleanParams,
|
||||
queryParamsHandling: '',
|
||||
replaceUrl: true
|
||||
});
|
||||
}
|
||||
|
||||
// CTA handler: navigate to focus on a single provider while preserving q
|
||||
@@ -593,6 +502,12 @@ export class SearchComponent {
|
||||
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' });
|
||||
const cleanParams = this.cleanUrlParams(qp);
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: cleanParams,
|
||||
queryParamsHandling: '',
|
||||
replaceUrl: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<nav class="sticky top-16 left-0 right-0 z-40 w-full relative"
|
||||
[ngClass]="{
|
||||
'bg-slate-800 border-b border-slate-700': isDarkTheme(),
|
||||
'bg-slate-900 border-b border-slate-800': isBlackTheme(),
|
||||
'bg-slate-700 border-b border-slate-600': isBlueTheme()
|
||||
}">
|
||||
<!-- Left scroll button -->
|
||||
<button type="button" (click)="scrollBy(-320)" aria-label="Scroll left"
|
||||
class="hidden md:flex items-center justify-center absolute left-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
|
||||
[ngClass]="{
|
||||
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
|
||||
}">
|
||||
«
|
||||
</button>
|
||||
|
||||
<!-- Right scroll button -->
|
||||
<button type="button" (click)="scrollBy(320)" aria-label="Scroll right"
|
||||
class="hidden md:flex items-center justify-center absolute right-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
|
||||
[ngClass]="{
|
||||
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
|
||||
}">
|
||||
»
|
||||
</button>
|
||||
|
||||
<div class="w-full overflow-x-auto no-scrollbar" #scroll (wheel)="onWheel($event)">
|
||||
<ul role="tablist" aria-label="Themes" class="flex gap-2 px-12 py-2 min-w-max items-center">
|
||||
<li *ngFor="let t of displayedThemes(); let i = index" class="shrink-0">
|
||||
<button
|
||||
#pill
|
||||
role="tab"
|
||||
type="button"
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
[class.bg-slate-100/10]="activeSlug() === t.slug"
|
||||
[class.text-white]="activeSlug() === t.slug"
|
||||
[class.border-red-500]="activeSlug() === t.slug"
|
||||
[class.text-slate-300]="activeSlug() !== t.slug"
|
||||
[class.border-slate-700]="activeSlug() !== t.slug"
|
||||
[attr.aria-selected]="activeSlug() === t.slug"
|
||||
(click)="goToTheme(t.slug)"
|
||||
(keydown)="onKeydown($event, i)"
|
||||
>
|
||||
<span class="mr-1 select-none">{{ t.emoji }}</span>
|
||||
<span>{{ themesSvc.i18nLabel(t) }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
Reference in New Issue
Block a user