fix(search): en-tete de resultats thémable et ergonomique
CI / build-and-test (push) Successful in 14m9s

Les pastilles fournisseurs portaient des couleurs de marque en dur ([ngStyle])
qui ignoraient le systeme de themes : rendu illisible en thème clair. Tout
l'en-tete rebati sur les classes que le CSS remappe (bg/text/border-slate-*).

- sources : point de couleur plein (active) / creux (masquee) a la place des
  fonds de marque, nom complet + compte compact tabular-nums
- actions (Tout afficher / Toutes les sources) en style contour, plus de
  bordure pointillee qui ressemblait a un placeholder
- pastilles de type : bg-slate-200 pour l'etat actif, border-transparent
  pour eviter le saut de 2px au changement d'etat
- rails regroupes dans un panneau + filet, fade de droite (scroll-rail-fade)
- selects : appearance-none + chevron SVG, libelles visibles remplaces par
  sr-only (5 lignes de hauteur en moins)
- bannieres notice/error refaites en neutres themables (illisibles en clair)
- anneau focus-visible en var(--accent) sur tous les controles
- suppression de getProviderColors et formatViews (morts)
This commit is contained in:
2026-09-28 21:51:44 -04:00
parent d400c26249
commit 7c3471596e
2 changed files with 238 additions and 166 deletions
+192 -127
View File
@@ -1,28 +1,31 @@
<div class="container mx-auto p-4 sm:p-6">
<!-- ============ En-tête compact ============ -->
<!-- ============ En-tête de résultats ============ -->
<header class="mb-3">
<!-- Titre + compteur + bascule des filtres -->
<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>
<h1 class="min-w-0 flex-1 truncate text-lg font-semibold text-slate-100 sm:text-xl">
{{ pageHeading() }}
</h1>
@if (hasQuery()) {
<span class="shrink-0 text-xs text-slate-500" aria-live="polite">
{{ filteredResults().length }} résultat{{ filteredResults().length === 1 ? '' : 's' }}
<span class="shrink-0 text-[13px] tabular-nums text-slate-400" aria-live="polite">
{{ countLabel(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'"
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'"
[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" />
<path stroke-linecap="round" stroke-linejoin="round" d="M3 6h18M7 12h10M11 18h2" />
</svg>
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>
<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">
@@ -34,157 +37,219 @@
</header>
@if (notice()) {
<div class="mb-3 rounded border border-amber-600 bg-amber-900/40 p-2.5 text-xs text-amber-200">
{{ notice() }}
<div class="mt-2 flex items-start gap-2 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-[13px] text-slate-200">
<span class="mt-1.5 h-2 w-2 shrink-0 rounded-full" style="background-color: #fbbf24"></span>
<span>{{ notice() }}</span>
</div>
}
@if (!hasQuery()) {
<p class="text-slate-400">{{ 'search.hint' | t }}</p>
<p class="mt-2 text-slate-400">{{ 'search.hint' | t }}</p>
}
@if (hasQuery()) {
<!-- ============ 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 de filtres (types + sources) ============ -->
<div 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. -->
<div class="scroll-rail-fade">
<div class="scroll-rail no-scrollbar flex items-center gap-1.5" role="group" aria-label="Filtrer par type de contenu">
<button type="button" (click)="setTypePill('all')"
class="chip inline-flex h-8 shrink-0 items-center 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]="typePill() === 'all' ? 'border border-transparent bg-slate-200 text-slate-900' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'all'">
Tout <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().all) }}</span>
</button>
<button type="button" *ngIf="typeCounts().live > 0 || typePill() === 'live'" (click)="setTypePill('live')"
class="chip inline-flex h-8 shrink-0 items-center 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]="typePill() === 'live' ? 'border border-transparent bg-purple-600 text-white' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'live'">
Directs <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().live) }}</span>
</button>
<button type="button" *ngIf="typeCounts().video > 0 || typePill() === 'video'" (click)="setTypePill('video')"
class="chip inline-flex h-8 shrink-0 items-center 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]="typePill() === 'video' ? 'border border-transparent bg-slate-200 text-slate-900' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'video'">
Vidéos <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().video) }}</span>
</button>
<button type="button" *ngIf="typeCounts().clip > 0 || typePill() === 'shorts'" (click)="setTypePill('shorts')"
class="chip inline-flex h-8 shrink-0 items-center 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]="typePill() === 'shorts' ? 'border border-transparent bg-purple-600 text-white' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'shorts'">
Clips <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().clip) }}</span>
</button>
<button type="button" *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" (click)="setTypePill('channel')"
class="chip inline-flex h-8 shrink-0 items-center 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]="typePill() === 'channel' ? 'border border-transparent bg-slate-200 text-slate-900' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'channel'">
Chaînes <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().channel) }}</span>
</button>
</div>
</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 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">
<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'"
[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>
</button>
}
@if (!allProvidersVisible()) {
<button type="button" (click)="showAllProviders()"
class="chip inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full px-3 text-[13px] text-slate-300 underline decoration-dotted underline-offset-2 hover:text-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]">
Tout afficher
<span class="rounded-full bg-slate-700 px-1.5 text-[11px] tabular-nums text-slate-400 no-underline">{{ hiddenProviderCount() }}</span>
</button>
}
@if (!allSourcesActive()) {
<button type="button" (click)="enableAllSources()"
class="chip inline-flex h-8 shrink-0 items-center gap-1 whitespace-nowrap rounded-full border border-slate-700 px-3 text-[13px] text-slate-300 hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" />
</svg>
Toutes les sources
</button>
}
</div>
</div>
</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">
<div id="search-filters" class="mt-2 rounded-xl border border-slate-700 bg-slate-800/50 p-2.5">
@if (activeFilterChips().length > 0) {
<div class="mb-2 flex flex-wrap items-center gap-1.5">
<span class="text-[11px] uppercase tracking-wide text-slate-400">Actifs</span>
<div class="mb-2.5 flex flex-wrap items-center gap-1.5">
<span class="pl-1 text-[11px] font-medium 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 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>
class="chip inline-flex h-6 items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-700 bg-slate-800 pl-2.5 pr-2 text-[12px] text-slate-200 hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]">
<span class="h-1.5 w-1.5 shrink-0 rounded-full" style="background-color: var(--accent)"></span>
{{ chip.label }}
<svg class="h-3 w-3 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
}
<button type="button" (click)="clearAllFilters()"
class="chip whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] leading-4 text-slate-400 underline hover:text-slate-200">
class="chip h-6 whitespace-nowrap rounded-full px-2 text-[12px] text-slate-400 underline decoration-dotted underline-offset-2 hover:text-slate-200">
Tout effacer
</button>
<span class="text-[11px] text-slate-500">
{{ filteredResults().length }} / {{ scopedVideos().length }} résultats
<span class="ml-auto text-[12px] tabular-nums text-slate-400">
{{ countLabel(filteredResults().length) }} / {{ countLabel(scopedVideos().length) }}
</span>
</div>
}
<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: '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 class="relative">
<label class="sr-only" for="sortSelect">{{ 'filters.sort' | t: 'Trier par' }}</label>
<select id="sortSelect"
class="h-9 appearance-none rounded-lg border border-slate-700 bg-slate-800 pl-2.5 pr-7 text-[13px] text-slate-200 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
[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>
</select>
<svg class="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" 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>
</div>
<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>
<option value="today">{{ 'filters.period.today' | t: "Aujourd'hui" }}</option>
<option value="week">{{ 'filters.period.week' | t: 'Cette semaine' }}</option>
<option value="month">{{ 'filters.period.month' | t: 'Ce mois' }}</option>
<option value="year">{{ 'filters.period.year' | t: 'Cette année' }}</option>
</select>
<div class="relative">
<label class="sr-only" for="period-filter">{{ 'filters.period' | t: 'Période' }}</label>
<select id="period-filter"
class="h-9 appearance-none rounded-lg border border-slate-700 bg-slate-800 pl-2.5 pr-7 text-[13px] text-slate-200 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
[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>
<option value="today">{{ 'filters.period.today' | t: "Aujourd'hui" }}</option>
<option value="week">{{ 'filters.period.week' | t: 'Cette semaine' }}</option>
<option value="month">{{ 'filters.period.month' | t: 'Ce mois' }}</option>
<option value="year">{{ 'filters.period.year' | t: 'Cette année' }}</option>
</select>
<svg class="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" 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>
</div>
<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>
<option value="shorts">{{ 'filters.type.shorts' | t: 'Shorts / Clips' }}</option>
<option value="live">{{ 'filters.type.live' | t: 'En direct' }}</option>
<option value="channel">{{ 'filters.type.channel' | t: 'Chaînes' }}</option>
</select>
<div class="relative">
<label class="sr-only" for="type-filter">{{ 'filters.type' | t: 'Type de contenu' }}</label>
<select id="type-filter"
class="h-9 appearance-none rounded-lg border border-slate-700 bg-slate-800 pl-2.5 pr-7 text-[13px] text-slate-200 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
[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>
<option value="shorts">{{ 'filters.type.shorts' | t: 'Shorts / Clips' }}</option>
<option value="live">{{ 'filters.type.live' | t: 'En direct' }}</option>
<option value="channel">{{ 'filters.type.channel' | t: 'Chaînes' }}</option>
</select>
<svg class="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" 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>
</div>
<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 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 class="relative">
<label class="sr-only" for="duration-filter">{{ 'filters.duration' | t: 'Durée' }}</label>
<select id="duration-filter"
class="h-9 appearance-none rounded-lg border border-slate-700 bg-slate-800 pl-2.5 pr-7 text-[13px] text-slate-200 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
[value]="filters().duration" (change)="onDurationChange($event)">
<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>
<svg class="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" 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>
</div>
<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 langues' }}</option>
<option value="en">{{ 'filter.language.en' | t: 'Anglais' }}</option>
<option value="fr">{{ 'filter.language.fr' | t: 'Français' }}</option>
</select>
<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.' }}
</span>
<div class="relative">
<label class="sr-only" for="language-filter">{{ 'filter.language' | t: 'Langue' }}</label>
<select id="language-filter"
class="h-9 appearance-none rounded-lg border border-slate-700 bg-slate-800 pl-2.5 pr-7 text-[13px] text-slate-200 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
[value]="languageFilter()" (change)="onLanguageChange($event)">
<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>
<svg class="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" 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>
</div>
</div>
<p class="mt-2 pl-1 text-[11px] text-slate-400">
{{ 'filters.serverHint' | t: 'Type, période et durée sont appliqués par les sources ; la langue filtre les résultats affichés.' }}
</p>
</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">
<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 (error()) {
<div class="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-[13px] text-slate-200">
<span class="flex items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" style="background-color: var(--accent)"></span>
{{ error() }}
</span>
<button type="button" class="h-8 shrink-0 rounded-full bg-red-600 px-3 text-[13px] font-medium text-white hover:bg-red-500"
(click)="onSearchBarSubmit({ q: q(), providers: providersListParam() || 'all' })">
Réessayer
</button>
</div>
}
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()" [loadingMore]="loadingMore()"></app-search-result-grid>
+46 -39
View File
@@ -30,6 +30,29 @@ const LOADING_TIMEOUT_MS = 20_000;
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',
};
/** 1 284 -> « 1.3K » : garde les pastilles étroites sans perdre l'info. */
function formatCount(n: number): string {
const v = Number(n);
if (!Number.isFinite(v) || v <= 0) return '0';
if (v < 1000) return String(v);
if (v < 1_000_000) return `${(v / 1000).toFixed(v < 10_000 ? 1 : 0).replace('.0', '')}K`;
return `${(v / 1_000_000).toFixed(1).replace('.0', '')}M`;
}
@Component({
selector: 'app-search',
standalone: true,
@@ -221,16 +244,24 @@ export class SearchComponent {
const hidden = this.hiddenProviders();
return this.activeProviders().map(id => {
const spec = PROVIDERS.find(p => p.id === id);
const count = counts.get(id) || 0;
return {
id,
shortLabel: spec?.shortLabel || id.toUpperCase(),
displayName: spec?.displayName || id,
count: counts.get(id) || 0,
// 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),
};
});
});
/** Nombre de sources masquées (alimente le libellé « Tout afficher »). */
hiddenProviderCount = computed(() => this.hiddenProviders().length);
/** Vrai quand toutes les sources connues sont déjà interrogées. */
allSourcesActive = computed(() => this.activeProviders().length >= PROVIDERS.length);
@@ -246,37 +277,15 @@ export class SearchComponent {
});
}
/** 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();
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)' };
}
/**
* 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
@@ -621,6 +630,11 @@ export class SearchComponent {
return { live, video, clip, channel, all: (list || []).length };
});
/** Nombre compact pour les compteurs de l'en-tête (1 284 -> « 1,3 k »). */
countLabel(n: number): string {
return formatCount(n);
}
/** Pills Tous / Directs / Vidéos / Clips / Chaînes synchronisées avec typeFilter. */
typePill = computed<'all' | 'live' | 'video' | 'shorts' | 'channel'>(() => {
const t = this.filters().type;
@@ -821,13 +835,6 @@ export class SearchComponent {
}
formatViews(views: number): string {
if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B';
if (views >= 1_000_000) return (views / 1_000_000).toFixed(1) + 'M';
if (views >= 1_000) return (views / 1_000).toFixed(1) + 'K';
return views.toString();
}
formatRelative(dateIso?: string): string {
if (!dateIso) return '';
return formatRelativeFr(dateIso);