fix(search): en-tete de resultats thémable et ergonomique
CI / build-and-test (push) Successful in 14m9s
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user