feat(search): repli des rails filtres + boutons sources en icones
CI / build-and-test (push) Successful in 14m10s

- nouveau bouton oeil a droite de 'Filtres' pour replier le bloc
  types + sources (railsOpen, persiste en localStorage) ; pastille d'accent
  quand un filtre de type est actif alors que le rail est replie
- nouveau composant app-provider-icon : marques des 6 sources en trait,
  currentColor pour que l'icone herite de l'etat actif/masque du bouton
- pastilles sources en icone seule (h-8 w-8), libelle et compte passes par
  aria-label / title ; plus de point de couleur ni de nom visible
- suppression de PROVIDER_DOTS et providerDotStyle
This commit is contained in:
2026-09-28 22:28:08 -04:00
parent 7c3471596e
commit dccc1c8eb1
3 changed files with 157 additions and 60 deletions
@@ -0,0 +1,86 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
/** Clés courtes du registre fournisseurs (provider-registry.ts). */
export type ProviderIconKey = 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru';
/**
* Marque d'une source, dessinée au trait et colorée en `currentColor` :
* - l'icône hérite de la couleur du bouton, donc l'état actif / masqué
* reste lisible sans ajouter une pastille de couleur à côté ;
* - un seul style graphique pour les six sources, cohérent avec les icônes
* d'interface (œil, chevron…) de la même barre.
*
* Les formes sont volontairement géométriques et sobres : l'identité vient du
* geste (bouton lecture, bulle de discussion, initiale), pas d'un logo coloré
* qui ne serait plus lisible une fois réduit à 18 px.
*/
@Component({
selector: 'app-provider-icon',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@switch (normalized) {
@case ('yt') {
<!-- YouTube : cadre arrondi + triangle de lecture -->
<svg [attr.width]="size" [attr.height]="size" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2.5" y="5.5" width="19" height="13" rx="4" />
<path d="M10.4 9.4v5.2l4.4-2.6-4.4-2.6Z" fill="currentColor" stroke="none" />
</svg>
}
@case ('tw') {
<!-- Twitch : bulle de discussion + deux yeux -->
<svg [attr.width]="size" [attr.height]="size" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 3h16v11.2L14.8 19.4h-4.3L7.2 22.7V19.4H4V3Z" />
<path d="M9.2 8.4v2.8M14.8 8.4v2.8" />
</svg>
}
@case ('dm') {
<!-- Dailymotion : « D » -->
<svg [attr.width]="size" [attr.height]="size" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5.5 4.5v15" />
<path d="M5.5 4.5h4.2a7.5 7.5 0 0 1 0 15H5.5" />
</svg>
}
@case ('pt') {
<!-- PeerTube : « P » -->
<svg [attr.width]="size" [attr.height]="size" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7.5 20V4.5h5.6a5.4 5.4 0 0 1 0 10.8H7.5" />
</svg>
}
@case ('od') {
<!-- Odysee : « O » barré -->
<svg [attr.width]="size" [attr.height]="size" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="8.2" />
<path d="M8.3 15.7 15.7 8.3" />
</svg>
}
@case ('ru') {
<!-- Rumble : « R » -->
<svg [attr.width]="size" [attr.height]="size" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 20V4.5h6.2a4.6 4.6 0 0 1 0 9.2H6" />
<path d="M12.4 13.7 17.6 20" />
</svg>
}
}
`
})
export class ProviderIconComponent {
/** Clé courte du fournisseur. Inconnue => aucun rendu. */
@Input() key: ProviderIconKey | string | null | undefined;
/** Taille en px (carré). */
@Input() size = 18;
/** Clé normalisée ; seules les six clés connues dessinent quelque chose. */
get normalized(): ProviderIconKey | null {
const k = String(this.key || '').toLowerCase();
return (['yt', 'dm', 'tw', 'pt', 'od', 'ru'] as const).includes(k as ProviderIconKey)
? (k as ProviderIconKey)
: null;
}
}
+50 -28
View File
@@ -14,24 +14,45 @@
}
@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-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="filtersOpen() ? 'border border-transparent bg-slate-200 text-slate-900' : 'bg-slate-800 text-slate-200 hover:bg-slate-700'"
<div class="ml-auto flex shrink-0 items-center gap-1.5">
<button type="button" (click)="toggleFilters()"
class="chip inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="filtersOpen() ? 'border-transparent bg-slate-200 text-slate-900' : 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700'"
[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="M3 6h18M7 12h10M11 18h2" />
</svg>
Filtres
@if (activeFilterChips().length > 0) {
<span class="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold leading-none 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>
<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="M3 6h18M7 12h10M11 18h2" />
</svg>
Filtres
@if (activeFilterChips().length > 0) {
<span class="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold leading-none 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>
<!-- Replie le rail « types + sources » pour ne garder que la grille. -->
<button type="button" (click)="toggleRails()"
class="chip relative inline-flex h-8 w-8 items-center justify-center rounded-full border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="railsOpen() ? 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700' : 'border-slate-700 text-slate-400 hover:bg-slate-800'"
[attr.aria-expanded]="railsOpen()" aria-controls="search-rails"
[attr.title]="railsOpen() ? 'Masquer les filtres rapides' : 'Afficher les filtres rapides'"
[attr.aria-label]="railsOpen() ? 'Masquer les filtres rapides' : 'Afficher les filtres rapides'">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="2.8" />
@if (!railsOpen()) {
<path d="M4 20 20 4" />
}
</svg>
@if (!railsOpen() && typeFilterActive()) {
<span class="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full" style="background-color: var(--accent)"></span>
}
</button>
</div>
}
</div>
</header>
@@ -47,9 +68,9 @@
<p class="mt-2 text-slate-400">{{ 'search.hint' | t }}</p>
}
@if (hasQuery()) {
@if (hasQuery() && railsOpen()) {
<!-- ============ Rail de filtres (types + sources) ============ -->
<div class="mt-2.5 rounded-xl border border-slate-700 bg-slate-800/50 p-1.5">
<div id="search-rails" class="mt-2.5 rounded-xl border border-slate-700 bg-slate-800/50 p-1.5">
<!-- Types de contenu : sélection unique, façon YouTube.
Le rail défile horizontalement (scrollbar masqué) : le fade de droite
signale qu'il reste des pastilles hors champ. -->
@@ -90,21 +111,22 @@
<div class="my-1.5 h-px bg-slate-700"></div>
<!-- Sources : bascules d'affichage. Le point coloré porte l'identité,
le fond neutre reste lisible quel que soit le thème. -->
<div class="flex items-center gap-2">
<!-- Sources : bascules d'affichage. Boutons icône seule — la marque
hérite de la couleur du bouton, donc l'état actif / masqué se lit
sans pastille supplémentaire. Le libellé et le compte passent par
`title` / `aria-label`. -->
<div class="flex items-center gap-1.5">
<span class="shrink-0 pl-2 text-[11px] font-medium uppercase tracking-wide text-slate-400">Sources</span>
<div class="scroll-rail-fade min-w-0 flex-1">
<div class="scroll-rail no-scrollbar flex 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-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-full px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="chip.visible ? 'border border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700' : 'border border-slate-700 text-slate-400 hover:bg-slate-800'"
class="chip inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="chip.visible ? 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700' : 'border-slate-700 text-slate-400 hover:bg-slate-800'"
[attr.aria-pressed]="chip.visible"
[attr.title]="chip.visible ? 'Masquer les résultats ' + chip.displayName : 'Afficher les résultats ' + chip.displayName">
<span class="h-2 w-2 shrink-0 rounded-full" [ngStyle]="providerDotStyle(chip)"></span>
<span>{{ chip.displayName }}</span>
<span class="text-[11px] tabular-nums opacity-60">{{ chip.countLabel }}</span>
[attr.aria-label]="(chip.visible ? 'Masquer ' : 'Afficher ') + chip.displayName + ' (' + chip.countLabel + ')'"
[attr.title]="(chip.visible ? 'Masquer ' : 'Afficher ') + chip.displayName + ' — ' + chip.countLabel + ' résultat(s)'">
<app-provider-icon [key]="chip.id" [size]="18"></app-provider-icon>
</button>
}
@if (!allProvidersVisible()) {
+21 -32
View File
@@ -9,6 +9,7 @@ import { HistoryService } from '../../services/history.service';
import { Title } from '@angular/platform-browser';
import { TranslatePipe } from '../../pipes/translate.pipe';
import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
import { ProviderIconComponent } from 'src/app/shared/components/provider-icon/provider-icon.component';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { SearchService, type SearchResponse } from '../../app/search/search.service';
@@ -29,20 +30,7 @@ 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';
/**
* Couleurs de marque, portées par un simple point dans la pastille. Le fond et
* le texte du bouton restent neutres : l'en-tête suit alors le thème (clair
* compris) au lieu d'imposer des teintes qui ne contrastent plus.
*/
const PROVIDER_DOTS: Record<ProviderId, string> = {
yt: '#ef4444',
dm: '#3b82f6',
tw: '#a855f7',
pt: '#22c55e',
od: '#eab308',
ru: '#f97316',
};
const STORAGE_RAILS_OPEN = 'newtube:search.railsOpen';
/** 1 284 -> « 1.3K » : garde les pastilles étroites sans perdre l'info. */
function formatCount(n: number): string {
@@ -58,7 +46,7 @@ function formatCount(n: number): string {
standalone: true,
templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TranslatePipe, SearchResultGridComponent, InfiniteAnchorComponent]
imports: [CommonModule, TranslatePipe, SearchResultGridComponent, InfiniteAnchorComponent, ProviderIconComponent]
})
export class SearchComponent {
private route = inject(ActivatedRoute);
@@ -161,6 +149,12 @@ export class SearchComponent {
hiddenProviders = signal<ProviderId[]>(this.loadHiddenProviders());
/** Panneau de filtres replié par défaut : les chips suffisent au quotidien. */
filtersOpen = signal(this.loadFiltersOpen());
/**
* Rail « types + sources » visible. Sur un écran chargé, on peut le replier
* pour ne garder que le titre et la grille — c'est un réglage de confort
* d'affichage, d'où la persistance locale.
*/
railsOpen = signal(this.loadRailsOpen());
private readStorage(key: string): string | null {
try { return localStorage.getItem(key); } catch { return null; }
@@ -183,9 +177,11 @@ export class SearchComponent {
} catch { return []; }
}
private loadFiltersOpen(): boolean {
return this.readStorage(STORAGE_FILTERS_OPEN) === '1';
private loadFlags(key: string): boolean {
return this.readStorage(key) === '1';
}
private loadFiltersOpen(): boolean { return this.loadFlags(STORAGE_FILTERS_OPEN); }
private loadRailsOpen(): boolean { return this.loadFlags(STORAGE_RAILS_OPEN); }
private persistHiddenProviders(): void {
const hidden = this.hiddenProviders();
@@ -197,6 +193,14 @@ export class SearchComponent {
this.writeStorage(STORAGE_FILTERS_OPEN, this.filtersOpen() ? '1' : '0');
}
toggleRails(): void {
this.railsOpen.set(!this.railsOpen());
this.writeStorage(STORAGE_RAILS_OPEN, this.railsOpen() ? '1' : '0');
}
/** Un filtre de type est actif : signale son existence même rails repliés. */
typeFilterActive = computed(() => this.filters().type !== 'all');
// ---- Pastilles fournisseurs = filtres d'affichage ----
/** Sources effectivement visibles (celles de la recherche, moins les masquées). */
@@ -247,11 +251,7 @@ export class SearchComponent {
const count = counts.get(id) || 0;
return {
id,
shortLabel: spec?.shortLabel || id.toUpperCase(),
displayName: spec?.displayName || id,
// Couleur de marque portée par le point, pas par le fond du bouton :
// les fonds neutres restent lisibles dans tous les thèmes (clair inclus).
dot: PROVIDER_DOTS[id] || 'rgb(148, 163, 184)',
count,
countLabel: formatCount(count),
visible: !hidden.includes(id),
@@ -277,17 +277,6 @@ export class SearchComponent {
});
}
/**
* Point de la pastille source : plein quand la source est affichée, creux
* (anneau) quand elle est masquée. L'identité de la source reste lisible
* dans les deux états, et le fond du bouton reste neutre (donc thémable).
*/
providerDotStyle(chip: { dot: string; visible: boolean }): { [key: string]: string } {
return chip.visible
? { backgroundColor: chip.dot }
: { backgroundColor: 'transparent', boxShadow: `inset 0 0 0 1.5px ${chip.dot}` };
}
// Merge groups with dedup by id/videoId/url
private mergeGroups(prev: Record<string, any[]>, incoming: Record<string, any[]>): Record<string, any[]> {
const result: Record<string, any[]> = { ...(prev || {}) };