Files
NewTube/src/components/search/search.component.html
T
bruno d400c26249
CI / build-and-test (push) Successful in 14m20s
feat(search): en-tete de resultats compact, fournisseurs en filtres
- rail 1 : pills de type resserrees (h-6/text-xs), role=group coherent
- rail 2 : les pastilles fournisseur deviennent des boutons d'affichage
  (couleur de marque = visible, gris + opacite = masque), avec le compte de
  resultats par source, aria-pressed, hover/focus
- filtre d'affichage local (aucun refetch, aucun squelette) persiste en
  localStorage ; impossible de masquer la derniere source visible
- panneau de filtres repliable par defaut, selects h-8 inline labels sr-only,
  badge du nombre de filtres actifs sur le bouton
- nouveau computed scopedVideos : compteurs de types et tri inter-provider
  alignes sur les sources visibles
- suppression d'activeProvidersInfo devenu inutilise
2026-09-28 21:28:01 -04:00

205 lines
13 KiB
HTML

<div class="container mx-auto p-4 sm:p-6">
<!-- ============ En-tête compact ============ -->
<header class="mb-3">
<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>
@if (hasQuery()) {
<span class="shrink-0 text-xs text-slate-500" aria-live="polite">
{{ 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'"
[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" />
</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>
}
<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>
}
</div>
</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>
}
@if (!hasQuery()) {
<p class="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 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>
</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">
@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>
@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>
</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">
Tout effacer
</button>
<span class="text-[11px] text-slate-500">
{{ filteredResults().length }} / {{ scopedVideos().length }} résultats
</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>
<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>
<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>
<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>
<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>
</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>
}
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()" [loadingMore]="loadingMore()"></app-search-result-grid>
<!-- Standard infinite scroll: shared sentinel + bottom loader + end state -->
<div class="flex flex-col items-center justify-center py-6">
@if (loading() || loadingMore()) {
<div class="text-slate-400 text-sm">Chargement…</div>
}
@if (!loading() && !loadingMore() && endReached() && filteredResults().length > 0) {
<div class="text-slate-500 text-xs">Fin des résultats</div>
}
</div>
<app-infinite-anchor [disabled]="endReached() || !hasQuery()"
[busy]="loading() || loadingMore()"
(loadMore)="loadNextPage()"></app-infinite-anchor>
}
</div>