feat(search): en-tete de resultats compact, fournisseurs en filtres
CI / build-and-test (push) Successful in 14m20s
CI / build-and-test (push) Successful in 14m20s
- rail 1 : pills de type resserrees (h-6/text-xs), role=group coherent - rail 2 : les pastilles fournisseur deviennent des boutons d'affichage (couleur de marque = visible, gris + opacite = masque), avec le compte de resultats par source, aria-pressed, hover/focus - filtre d'affichage local (aucun refetch, aucun squelette) persiste en localStorage ; impossible de masquer la derniere source visible - panneau de filtres repliable par defaut, selects h-8 inline labels sr-only, badge du nombre de filtres actifs sur le bouton - nouveau computed scopedVideos : compteurs de types et tri inter-provider alignes sur les sources visibles - suppression d'activeProvidersInfo devenu inutilise
This commit is contained in:
@@ -1,22 +1,40 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-white mb-2">{{ pageHeading() }}</h1>
|
||||
<!-- ============ En-tête compact ============ -->
|
||||
<header class="mb-3">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<h1 class="min-w-0 truncate text-lg font-semibold text-white sm:text-xl">{{ 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" />
|
||||
@if (hasQuery()) {
|
||||
<span class="shrink-0 text-xs text-slate-500" aria-live="polite">
|
||||
{{ filteredResults().length }} résultat{{ filteredResults().length === 1 ? '' : 's' }}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (hasQuery()) {
|
||||
<button type="button" (click)="toggleFilters()"
|
||||
class="chip ml-auto inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border px-3 text-xs font-medium"
|
||||
[ngClass]="filtersOpen()
|
||||
? 'border-red-500/60 bg-red-600/20 text-red-200'
|
||||
: 'border-slate-700 bg-slate-800/60 text-slate-300 hover:bg-slate-800'"
|
||||
[attr.aria-expanded]="filtersOpen()" aria-controls="search-filters">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" />
|
||||
</svg>
|
||||
{{ provider.label }}
|
||||
</div>
|
||||
Filtres
|
||||
@if (activeFilterChips().length > 0) {
|
||||
<span class="rounded-full bg-red-600 px-1.5 text-[10px] font-bold leading-4 text-white">{{ activeFilterChips().length }}</span>
|
||||
}
|
||||
<svg class="h-3.5 w-3.5 transition-transform duration-150" [class.rotate-180]="filtersOpen()"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (notice()) {
|
||||
<div class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">
|
||||
<div class="mb-3 rounded border border-amber-600 bg-amber-900/40 p-2.5 text-xs text-amber-200">
|
||||
{{ notice() }}
|
||||
</div>
|
||||
}
|
||||
@@ -26,71 +44,98 @@
|
||||
}
|
||||
|
||||
@if (hasQuery()) {
|
||||
<!-- Pastilles de filtres actifs : rappel immédiat + retrait en un clic.
|
||||
Elles écrivent l'URL, donc la requête serveur repart filtrée. -->
|
||||
<!-- ============ Rail 1 : type de contenu ============ -->
|
||||
<div class="scroll-rail no-scrollbar flex items-center gap-1.5" role="group" aria-label="Filtrer par type de contenu">
|
||||
<button class="chip shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs leading-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
|
||||
[ngClass]="typePill() === 'all' ? 'border-slate-200 bg-slate-200 font-semibold text-slate-900' : 'border-slate-700 bg-slate-800/60 text-slate-300 hover:bg-slate-800'"
|
||||
[attr.aria-pressed]="typePill() === 'all'" (click)="setTypePill('all')">
|
||||
Tout <span class="opacity-60">{{ typeCounts().all }}</span>
|
||||
</button>
|
||||
<button *ngIf="typeCounts().live > 0 || typePill() === 'live'" class="chip shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs leading-5"
|
||||
[ngClass]="typePill() === 'live' ? 'border-purple-500 bg-purple-600 font-semibold text-white' : 'border-slate-700 bg-slate-800/60 text-slate-300 hover:bg-slate-800'"
|
||||
[attr.aria-pressed]="typePill() === 'live'" (click)="setTypePill('live')">
|
||||
Directs <span class="opacity-60">{{ typeCounts().live }}</span>
|
||||
</button>
|
||||
<button *ngIf="typeCounts().video > 0 || typePill() === 'video'" class="chip shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs leading-5"
|
||||
[ngClass]="typePill() === 'video' ? 'border-slate-200 bg-slate-200 font-semibold text-slate-900' : 'border-slate-700 bg-slate-800/60 text-slate-300 hover:bg-slate-800'"
|
||||
[attr.aria-pressed]="typePill() === 'video'" (click)="setTypePill('video')">
|
||||
Vidéos <span class="opacity-60">{{ typeCounts().video }}</span>
|
||||
</button>
|
||||
<button *ngIf="typeCounts().clip > 0 || typePill() === 'shorts'" class="chip shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs leading-5"
|
||||
[ngClass]="typePill() === 'shorts' ? 'border-red-500 bg-red-600 font-semibold text-white' : 'border-slate-700 bg-slate-800/60 text-slate-300 hover:bg-slate-800'"
|
||||
[attr.aria-pressed]="typePill() === 'shorts'" (click)="setTypePill('shorts')">
|
||||
Clips <span class="opacity-60">{{ typeCounts().clip }}</span>
|
||||
</button>
|
||||
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="chip shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs leading-5"
|
||||
[ngClass]="typePill() === 'channel' ? 'border-slate-200 bg-slate-200 font-semibold text-slate-900' : 'border-slate-700 bg-slate-800/60 text-slate-300 hover:bg-slate-800'"
|
||||
[attr.aria-pressed]="typePill() === 'channel'" (click)="setTypePill('channel')">
|
||||
Chaînes <span class="opacity-60">{{ typeCounts().channel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ Rail 2 : fournisseurs (filtres d'affichage) ============ -->
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="shrink-0 text-[11px] uppercase tracking-wide text-slate-500">Sources</span>
|
||||
<div class="scroll-rail no-scrollbar flex min-w-0 flex-1 items-center gap-1.5" role="group" aria-label="Afficher ou masquer une source">
|
||||
@for (chip of providerChips(); track chip.id) {
|
||||
<button type="button" (click)="toggleProvider(chip.id)"
|
||||
class="chip inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 text-xs leading-5 hover:brightness-125 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
|
||||
[ngStyle]="providerChipStyle(chip.id, chip.visible)"
|
||||
[class.opacity-60]="!chip.visible"
|
||||
[attr.aria-pressed]="chip.visible"
|
||||
[attr.title]="chip.visible ? 'Masquer les résultats ' + chip.displayName : 'Afficher les résultats ' + chip.displayName">
|
||||
<span class="font-semibold">{{ chip.shortLabel }}</span>
|
||||
<span class="opacity-70">{{ chip.count }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (!allProvidersVisible()) {
|
||||
<button type="button" (click)="showAllProviders()"
|
||||
class="chip shrink-0 whitespace-nowrap rounded-full border border-slate-600 bg-slate-800/60 px-2.5 py-1 text-xs leading-5 text-slate-300 hover:bg-slate-800">
|
||||
Tout afficher
|
||||
</button>
|
||||
}
|
||||
@if (!allSourcesActive()) {
|
||||
<button type="button" (click)="enableAllSources()"
|
||||
class="chip shrink-0 whitespace-nowrap rounded-full border border-dashed border-slate-600 px-2.5 py-1 text-xs leading-5 text-slate-400 hover:bg-slate-800">
|
||||
+ Toutes les sources
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Panneau de filtres (repliable) ============ -->
|
||||
@if (filtersOpen()) {
|
||||
<div id="search-filters" class="mt-2 rounded-lg border border-slate-700/70 bg-slate-800/40 p-2.5">
|
||||
@if (activeFilterChips().length > 0) {
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs uppercase tracking-wide text-slate-400">Filtres</span>
|
||||
<div class="mb-2 flex flex-wrap items-center gap-1.5">
|
||||
<span class="text-[11px] uppercase tracking-wide text-slate-400">Actifs</span>
|
||||
@for (chip of activeFilterChips(); track chip.key) {
|
||||
<button type="button" (click)="clearFilter(chip.key)"
|
||||
class="chip inline-flex items-center gap-1 rounded-full border border-red-500/60 bg-red-600/20 px-2.5 py-1 text-xs text-red-200 hover:bg-red-600/40">
|
||||
class="chip inline-flex items-center gap-1 whitespace-nowrap rounded-full border border-red-500/60 bg-red-600/20 px-2 py-0.5 text-[11px] leading-4 text-red-200 hover:bg-red-600/40">
|
||||
{{ chip.label }} <span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
}
|
||||
<button type="button" (click)="clearAllFilters()"
|
||||
class="chip rounded-full px-2.5 py-1 text-xs text-slate-400 underline hover:text-slate-200">
|
||||
class="chip whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] leading-4 text-slate-400 underline hover:text-slate-200">
|
||||
Tout effacer
|
||||
</button>
|
||||
<span class="text-[11px] text-slate-500">
|
||||
{{ filteredResults().length }} / {{ allVideos().length }} résultats
|
||||
{{ filteredResults().length }} / {{ scopedVideos().length }} résultats
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Pills de type façon directory Twitch : Directs / Vidéos / Clips / Chaînes -->
|
||||
<div class="scroll-rail no-scrollbar flex items-center gap-2 pb-1" role="tablist" aria-label="Filtrer par type de contenu">
|
||||
<button class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'all' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'all'" (click)="setTypePill('all')">
|
||||
Tout ({{ typeCounts().all }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().live > 0 || typePill() === 'live'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'live' ? 'bg-purple-600 text-white border-purple-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'live'" (click)="setTypePill('live')">
|
||||
Directs ({{ typeCounts().live }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().video > 0 || typePill() === 'video'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'video' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'video'" (click)="setTypePill('video')">
|
||||
Vidéos ({{ typeCounts().video }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().clip > 0 || typePill() === 'shorts'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'shorts' ? 'bg-red-600 text-white border-red-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'shorts'" (click)="setTypePill('shorts')">
|
||||
Clips ({{ typeCounts().clip }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'channel' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'channel'" (click)="setTypePill('channel')">
|
||||
Chaînes ({{ typeCounts().channel }})
|
||||
</button>
|
||||
</div>
|
||||
<!-- Barre de filtrage : les 4 dimensions principales sont appliquées par le
|
||||
serveur (elles vivent dans l'URL), la langue reste locale. -->
|
||||
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-4 my-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<label for="sortSelect" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.sort' | t: 'Trier par' }}</label>
|
||||
<select id="sortSelect" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<label class="sr-only" for="sortSelect">{{ 'filters.sort' | t: 'Trier par' }}</label>
|
||||
<select id="sortSelect" class="h-8 rounded-md border border-slate-600 bg-slate-700 px-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
[value]="filters().sort" (change)="onSortChange($event)">
|
||||
<option value="relevance">{{ 'filters.sort.relevance' | t: 'Pertinence' }}</option>
|
||||
<option value="date">{{ 'filters.sort.date' | t: 'Date' }}</option>
|
||||
<option value="views">{{ 'filters.sort.views' | t: 'Vues' }}</option>
|
||||
<option value="relevance">{{ 'filters.sort.relevance' | t: 'Trier par pertinence' }}</option>
|
||||
<option value="date">{{ 'filters.sort.date' | t: 'Trier par date' }}</option>
|
||||
<option value="views">{{ 'filters.sort.views' | t: 'Trier par vues' }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="period-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.period' | t: 'Période' }}</label>
|
||||
<select id="period-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
|
||||
<label class="sr-only" for="period-filter">{{ 'filters.period' | t: 'Période' }}</label>
|
||||
<select id="period-filter" class="h-8 rounded-md border border-slate-600 bg-slate-700 px-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
[value]="filters().period" (change)="onDateChange($event)">
|
||||
<option value="all">{{ 'filters.period.all' | t: "N'importe quand" }}</option>
|
||||
<option value="hour">{{ 'filters.period.hour' | t: 'Dernière heure' }}</option>
|
||||
@@ -99,10 +144,9 @@
|
||||
<option value="month">{{ 'filters.period.month' | t: 'Ce mois' }}</option>
|
||||
<option value="year">{{ 'filters.period.year' | t: 'Cette année' }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.type' | t: 'Type de contenu' }}</label>
|
||||
<select id="type-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
|
||||
<label class="sr-only" for="type-filter">{{ 'filters.type' | t: 'Type de contenu' }}</label>
|
||||
<select id="type-filter" class="h-8 rounded-md border border-slate-600 bg-slate-700 px-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
[value]="filters().type" (change)="onTypeChange($event)">
|
||||
<option value="all">{{ 'filters.type.all' | t: 'Tout' }}</option>
|
||||
<option value="video">{{ 'filters.type.video' | t: 'Vidéos' }}</option>
|
||||
@@ -110,31 +154,30 @@
|
||||
<option value="live">{{ 'filters.type.live' | t: 'En direct' }}</option>
|
||||
<option value="channel">{{ 'filters.type.channel' | t: 'Chaînes' }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="duration-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.duration' | t: 'Durée' }}</label>
|
||||
<select id="duration-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
|
||||
<label class="sr-only" for="duration-filter">{{ 'filters.duration' | t: 'Durée' }}</label>
|
||||
<select id="duration-filter" class="h-8 rounded-md border border-slate-600 bg-slate-700 px-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
[value]="filters().duration" (change)="onDurationChange($event)">
|
||||
<option value="all">{{ 'filters.duration.all' | t: 'Toutes' }}</option>
|
||||
<option value="all">{{ 'filters.duration.all' | t: 'Toutes durées' }}</option>
|
||||
<option value="short">{{ 'filters.duration.short' | t: 'Courte < 4 min' }}</option>
|
||||
<option value="medium">{{ 'filters.duration.medium' | t: 'Moyenne 4-20 min' }}</option>
|
||||
<option value="long">{{ 'filters.duration.long' | t: 'Longue > 20 min' }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t: 'Langue' }}</label>
|
||||
<select id="language-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
|
||||
<label class="sr-only" for="language-filter">{{ 'filter.language' | t: 'Langue' }}</label>
|
||||
<select id="language-filter" class="h-8 rounded-md border border-slate-600 bg-slate-700 px-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
[value]="languageFilter()" (change)="onLanguageChange($event)">
|
||||
<option value="any">{{ 'filter.language.any' | t: 'Toutes' }}</option>
|
||||
<option value="any">{{ 'filter.language.any' | t: 'Toutes langues' }}</option>
|
||||
<option value="en">{{ 'filter.language.en' | t: 'Anglais' }}</option>
|
||||
<option value="fr">{{ 'filter.language.fr' | t: 'Français' }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] text-slate-500">
|
||||
|
||||
<span class="text-[11px] text-slate-500">
|
||||
{{ 'filters.serverHint' | t: 'Type, période et durée sont appliqués par les sources ; la langue filtre les résultats affichés.' }}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@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">
|
||||
|
||||
@@ -26,6 +26,9 @@ import { Observable, Subscription } from 'rxjs';
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
/** Filet de securite : le loading ne doit jamais rester bloque indefiniment. */
|
||||
const LOADING_TIMEOUT_MS = 20_000;
|
||||
/** Clefs de persistance locale des preferences d'affichage de l'en-tete. */
|
||||
const STORAGE_HIDDEN_PROVIDERS = 'newtube:search.hiddenProviders';
|
||||
const STORAGE_FILTERS_OPEN = 'newtube:search.filtersOpen';
|
||||
|
||||
@Component({
|
||||
selector: 'app-search',
|
||||
@@ -125,14 +128,136 @@ export class SearchComponent {
|
||||
'ru': 'rumble'
|
||||
};
|
||||
|
||||
// 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)
|
||||
);
|
||||
// ---- En-tete compact : filtres repliables + fournisseurs en boutons ----
|
||||
/**
|
||||
* Sources masquees dans la grille. C'est un filtre d'AFFICHAGE : il ne
|
||||
* relance aucune requete, donc aucun squelette, aucun saut de mise en page —
|
||||
* on masque ou on réaffiche ce que l'on a déjà chargé. La préférence est
|
||||
* persistée (localStorage) car c'est un réglage de lecture, pas une recherche.
|
||||
*/
|
||||
hiddenProviders = signal<ProviderId[]>(this.loadHiddenProviders());
|
||||
/** Panneau de filtres replié par défaut : les chips suffisent au quotidien. */
|
||||
filtersOpen = signal(this.loadFiltersOpen());
|
||||
|
||||
private readStorage(key: string): string | null {
|
||||
try { return localStorage.getItem(key); } catch { return null; }
|
||||
}
|
||||
private writeStorage(key: string, value: string | null): void {
|
||||
try {
|
||||
if (value == null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private loadHiddenProviders(): ProviderId[] {
|
||||
const raw = this.readStorage(STORAGE_HIDDEN_PROVIDERS);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const arr = JSON.parse(raw);
|
||||
if (!Array.isArray(arr)) return [];
|
||||
const valid = new Set<string>(PROVIDERS.map(p => p.id));
|
||||
return arr.filter((v: unknown) => typeof v === 'string' && valid.has(v)) as ProviderId[];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
private loadFiltersOpen(): boolean {
|
||||
return this.readStorage(STORAGE_FILTERS_OPEN) === '1';
|
||||
}
|
||||
|
||||
private persistHiddenProviders(): void {
|
||||
const hidden = this.hiddenProviders();
|
||||
this.writeStorage(STORAGE_HIDDEN_PROVIDERS, hidden.length ? JSON.stringify(hidden) : null);
|
||||
}
|
||||
|
||||
toggleFilters(): void {
|
||||
this.filtersOpen.set(!this.filtersOpen());
|
||||
this.writeStorage(STORAGE_FILTERS_OPEN, this.filtersOpen() ? '1' : '0');
|
||||
}
|
||||
|
||||
// ---- Pastilles fournisseurs = filtres d'affichage ----
|
||||
|
||||
/** Sources effectivement visibles (celles de la recherche, moins les masquées). */
|
||||
visibleProviders = computed(() => {
|
||||
const hidden = this.hiddenProviders();
|
||||
return this.activeProviders().filter(id => !hidden.includes(id));
|
||||
});
|
||||
|
||||
allProvidersVisible = computed(() => this.hiddenProviders().length === 0);
|
||||
|
||||
isProviderVisible(id: ProviderId): boolean {
|
||||
return !this.hiddenProviders().includes(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule l'affichage d'une source. On refuse de masquer la dernière source
|
||||
* visible : une grille entièrement vide n'est jamais une réponse utile.
|
||||
*/
|
||||
toggleProvider(id: ProviderId): void {
|
||||
const visible = this.visibleProviders();
|
||||
if (visible.includes(id) && visible.length <= 1) return;
|
||||
const next = visible.includes(id)
|
||||
? this.hiddenProviders().concat(id)
|
||||
: this.hiddenProviders().filter(p => p !== id);
|
||||
this.hiddenProviders.set(Array.from(new Set(next)));
|
||||
this.persistHiddenProviders();
|
||||
}
|
||||
|
||||
showAllProviders(): void {
|
||||
if (this.allProvidersVisible()) return;
|
||||
this.hiddenProviders.set([]);
|
||||
this.persistHiddenProviders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pastilles de l'en-tete : une par source interrogée, avec le nombre de
|
||||
* résultats qu'elle a fournis. L'ordre suit celui de la recherche.
|
||||
*/
|
||||
providerChips = computed(() => {
|
||||
const counts = new Map<ProviderId, number>();
|
||||
for (const v of this.allVideos()) {
|
||||
const pid = this.toProviderId((v as any)?.provider);
|
||||
if (pid) counts.set(pid, (counts.get(pid) || 0) + 1);
|
||||
}
|
||||
const hidden = this.hiddenProviders();
|
||||
return this.activeProviders().map(id => {
|
||||
const spec = PROVIDERS.find(p => p.id === id);
|
||||
return {
|
||||
id,
|
||||
shortLabel: spec?.shortLabel || id.toUpperCase(),
|
||||
displayName: spec?.displayName || id,
|
||||
count: counts.get(id) || 0,
|
||||
visible: !hidden.includes(id),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** Vrai quand toutes les sources connues sont déjà interrogées. */
|
||||
allSourcesActive = computed(() => this.activeProviders().length >= PROVIDERS.length);
|
||||
|
||||
/** Élargit la recherche à toutes les sources (écrit `providers` dans l'URL). */
|
||||
enableAllSources(): void {
|
||||
const params = this.cleanUrlParams({
|
||||
...this.route.snapshot.queryParams,
|
||||
providers: PROVIDERS.map(p => p.id).join(','),
|
||||
page: 1,
|
||||
});
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route, queryParams: params, replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Style de pastille : couleurs de la marque quand visible, neutre quand masquée. */
|
||||
providerChipStyle(id: ProviderId, visible: boolean): { [key: string]: string } {
|
||||
if (!visible) {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'rgb(113, 113, 122)',
|
||||
borderColor: 'rgb(63, 63, 70)',
|
||||
};
|
||||
}
|
||||
return this.getProviderColors(this.providerIdMap[id]);
|
||||
}
|
||||
|
||||
// Brand colors for provider badge (inline styles to match History badges)
|
||||
getProviderColors(providerId: string | null | undefined): { [key: string]: string } {
|
||||
const id = (providerId || '').toLowerCase();
|
||||
@@ -228,15 +353,30 @@ export class SearchComponent {
|
||||
return Object.values(g).flat();
|
||||
});
|
||||
|
||||
// Helper computed: filtered list based on active tag + filter bar
|
||||
/** Helper computed: filtered list based on active tag + filter bar */
|
||||
filteredResults = computed(() => {
|
||||
const list = this.allVideos();
|
||||
const list = this.scopedVideos();
|
||||
if (!Array.isArray(list) || list.length === 0) return [];
|
||||
|
||||
const filtered = this.applyFilterBar(list);
|
||||
return this.sortVideos(filtered);
|
||||
});
|
||||
|
||||
/**
|
||||
* Résultats restreints aux sources visibles (pastilles fournisseurs). Filtre
|
||||
* purement local : les données sont déjà là, on ne refetch pas.
|
||||
*/
|
||||
scopedVideos = computed(() => {
|
||||
const list = this.allVideos();
|
||||
const hidden = this.hiddenProviders();
|
||||
if (hidden.length === 0) return list;
|
||||
return list.filter((v: any) => {
|
||||
const pid = this.toProviderId(v?.provider);
|
||||
// Un résultat sans source identifiable reste visible.
|
||||
return !pid || !hidden.includes(pid);
|
||||
});
|
||||
});
|
||||
|
||||
// Filter each provider group separately for sectioned rendering
|
||||
filteredGroups = computed(() => {
|
||||
const barPredicate = this.buildFilterBarPredicate();
|
||||
@@ -470,7 +610,7 @@ export class SearchComponent {
|
||||
|
||||
/** Compteurs par type pour les pills façon directory Twitch. */
|
||||
typeCounts = computed(() => {
|
||||
const list = this.allVideos();
|
||||
const list = this.scopedVideos();
|
||||
let live = 0, video = 0, clip = 0, channel = 0;
|
||||
for (const v of list || []) {
|
||||
if (this.isChannelItem(v)) { channel++; continue; }
|
||||
|
||||
Reference in New Issue
Block a user