feat(search): repli des rails filtres + boutons sources en icones
CI / build-and-test (push) Successful in 14m10s
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 || {}) };
|
||||
|
||||
Reference in New Issue
Block a user