diff --git a/src/app/shared/components/provider-icon/provider-icon.component.ts b/src/app/shared/components/provider-icon/provider-icon.component.ts new file mode 100644 index 0000000..14c7d0e --- /dev/null +++ b/src/app/shared/components/provider-icon/provider-icon.component.ts @@ -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') { + + + } + @case ('tw') { + + + } + @case ('dm') { + + + } + @case ('pt') { + + + } + @case ('od') { + + + } + @case ('ru') { + + + } + } + ` +}) +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; + } +} diff --git a/src/components/search/search.component.html b/src/components/search/search.component.html index 2665008..9335ae5 100644 --- a/src/components/search/search.component.html +++ b/src/components/search/search.component.html @@ -14,24 +14,45 @@ } @if (hasQuery()) { - + + Filtres + @if (activeFilterChips().length > 0) { + + {{ activeFilterChips().length }} + + } + + + + + + } @@ -47,9 +68,9 @@

{{ 'search.hint' | t }}

} - @if (hasQuery()) { + @if (hasQuery() && railsOpen()) { -
+
@@ -90,21 +111,22 @@
- -
+ +
Sources
@for (chip of providerChips(); track chip.id) { } @if (!allProvidersVisible()) { diff --git a/src/components/search/search.component.ts b/src/components/search/search.component.ts index 19aec8a..e2cbc7d 100644 --- a/src/components/search/search.component.ts +++ b/src/components/search/search.component.ts @@ -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 = { - 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(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, incoming: Record): Record { const result: Record = { ...(prev || {}) };