feat(search): panneau de filtres unifié, clavier/focus du typeahead
CI / build-and-test (push) Successful in 14m10s
CI / build-and-test (push) Successful in 14m10s
Barre de recherche - Panneau de filtres unifié (remplace le ProviderPicker) : sources, type (vidéos/shorts/direct/chaînes), période (heure/jour/semaine/mois/année), durée (<4 / 4-20 / >20 min) et tri. Pastilles + roving tabindex (←/→ dans un groupe, ↑/↓ entre groupes), focus trap, Échap, Ctrl/⌘+Maj+F, recherches récentes, « mémoriser par défaut », « Réinitialiser ». - Pastilles des filtres actifs sous la barre (retrait en un clic) + compteur. - Typeahead : un seul keydown (fin du double Enter), ↑/↓ bouclants, Home/End, PageUp/PageDown, Entrée valide la ligne surlignée (sinon recherche brute), Tab complète sans chercher, Échap ferme puis vide, ligne « Rechercher <q> », défilement de l'option active, aria à jour. - Focus : le panneau se referme dès que le focus quitte la barre (focusout + relatedTarget + activeElement), pointerdown neutralisé sur les lignes pour garder le focus dans l'input (plus de scintillement). - Opérateurs en clair : `linux live:`, `tuto today: long:` deviennent des filtres et sont retirés de la requête (autocomplétion après le `:`). Filtres côté serveur - Modèle partagé : src/app/search/filters.ts + server/search-filters.mjs. - /api/search?type=&duration=&period=&sort= : InnerTube (upload_date, type, duration, features), Data API v3 (type, videoDuration, publishedAfter), yt-dlp --dateafter ; post-filtrage pour les providers sans filtre natif. Un champ manquant ne fait jamais disparaître un résultat. - Filtres dans l'URL (partageables) + adapters + SearchService (clé de cache). - Correction : la recherche Shorts renvoyait 0 (ShortsLockupView/GridShelfView non mappés, id/titre dans l'endpoint) -> mappage + shelf + vignette/vues. Tests / docs - 23 tests unitaires (clavier, focus, opérateurs, panneau de filtres), server/tests/search-filters.test.mjs (npm run test:filters) + CI, scénarios e2e des filtres, README/api/README/todo/MCP à jour.
This commit is contained in:
@@ -25,23 +25,28 @@
|
||||
<p class="text-slate-400">{{ 'search.hint' | t }}</p>
|
||||
}
|
||||
|
||||
@if (hasQuery() && availableTags().length > 0) {
|
||||
<div class="scroll-rail no-scrollbar mb-4 flex items-center gap-2 pb-1">
|
||||
@for (tag of availableTags(); track tag.key) {
|
||||
<button
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="{
|
||||
'bg-slate-200 text-slate-900 border-slate-200': filterTag() === tag.key,
|
||||
'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700': filterTag() !== tag.key
|
||||
}"
|
||||
(click)="setFilterTag(tag.key)">
|
||||
{{ tag.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (hasQuery()) {
|
||||
<!-- Pastilles de filtres actifs : rappel immédiat + retrait en un clic.
|
||||
Elles écrivent l'URL, donc la requête serveur repart filtrée. -->
|
||||
@if (activeFilterChips().length > 0) {
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs uppercase tracking-wide text-slate-400">Filtres</span>
|
||||
@for (chip of activeFilterChips(); track chip.key) {
|
||||
<button type="button" (click)="clearFilter(chip.key)"
|
||||
class="chip inline-flex items-center gap-1 rounded-full border border-red-500/60 bg-red-600/20 px-2.5 py-1 text-xs text-red-200 hover:bg-red-600/40">
|
||||
{{ chip.label }} <span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
}
|
||||
<button type="button" (click)="clearAllFilters()"
|
||||
class="chip rounded-full px-2.5 py-1 text-xs text-slate-400 underline hover:text-slate-200">
|
||||
Tout effacer
|
||||
</button>
|
||||
<span class="text-[11px] text-slate-500">
|
||||
{{ filteredResults().length }} / {{ allVideos().length }} résultats
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Pills de type façon directory Twitch : Directs / Vidéos / Clips / Chaînes -->
|
||||
<div class="scroll-rail no-scrollbar flex items-center gap-2 pb-1" role="tablist" aria-label="Filtrer par type de contenu">
|
||||
<button class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
@@ -59,9 +64,9 @@
|
||||
[attr.aria-pressed]="typePill() === 'video'" (click)="setTypePill('video')">
|
||||
Vidéos ({{ typeCounts().video }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().clip > 0 || typePill() === 'short'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'short' ? 'bg-red-600 text-white border-red-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'short'" (click)="setTypePill('short')">
|
||||
<button *ngIf="typeCounts().clip > 0 || typePill() === 'shorts'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'shorts' ? 'bg-red-600 text-white border-red-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'shorts'" (click)="setTypePill('shorts')">
|
||||
Clips ({{ typeCounts().clip }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
@@ -70,61 +75,65 @@
|
||||
Chaînes ({{ typeCounts().channel }})
|
||||
</button>
|
||||
</div>
|
||||
<!-- Barre de filtrage (même UX que la page thème / fournisseur) -->
|
||||
<!-- Barre de filtrage : les 4 dimensions principales sont appliquées par le
|
||||
serveur (elles vivent dans l'URL), la langue reste locale. -->
|
||||
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-4 my-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<label for="sortSelect" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.sort' | t }}</label>
|
||||
<label for="sortSelect" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.sort' | t: 'Trier par' }}</label>
|
||||
<select id="sortSelect" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
[value]="sortParam()" (change)="onSortChange($event)">
|
||||
<option value="relevance">{{ 'filter.sort.relevance' | t }}</option>
|
||||
<option value="date">{{ 'filter.sort.date' | t }}</option>
|
||||
<option value="views">{{ 'filter.sort.views' | t }}</option>
|
||||
[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>
|
||||
</div>
|
||||
<div>
|
||||
<label for="duration-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.duration' | t }}</label>
|
||||
<select id="duration-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
[value]="durationFilter()" (change)="onDurationChange($event)">
|
||||
<option value="any">{{ 'filter.duration.any' | t }}</option>
|
||||
<option value="short">{{ 'filter.duration.short' | t }}</option>
|
||||
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
|
||||
<option value="long">{{ 'filter.duration.long' | t }}</option>
|
||||
<label for="period-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.period' | t: 'Période' }}</label>
|
||||
<select id="period-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-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>
|
||||
<div>
|
||||
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.type' | t }}</label>
|
||||
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.type' | t: 'Type de contenu' }}</label>
|
||||
<select id="type-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
[value]="typeFilter()" (change)="onTypeChange($event)">
|
||||
<option value="any">{{ 'filter.type.any' | t }}</option>
|
||||
<option value="video">{{ 'filter.type.video' | t }}</option>
|
||||
<option value="live">{{ 'filter.type.live' | t }}</option>
|
||||
<option value="recorded">{{ 'filter.type.recorded' | t }}</option>
|
||||
<option value="short">{{ 'filter.type.short' | t }}</option>
|
||||
<option value="channel">{{ 'filter.type.channel' | t }}</option>
|
||||
[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>
|
||||
<div>
|
||||
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t }}</label>
|
||||
<label for="duration-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.duration' | t: 'Durée' }}</label>
|
||||
<select id="duration-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
[value]="filters().duration" (change)="onDurationChange($event)">
|
||||
<option value="all">{{ 'filters.duration.all' | t: 'Toutes' }}</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>
|
||||
<div>
|
||||
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t: 'Langue' }}</label>
|
||||
<select id="language-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
[value]="languageFilter()" (change)="onLanguageChange($event)">
|
||||
<option value="any">{{ 'filter.language.any' | t }}</option>
|
||||
<option value="en">{{ 'filter.language.en' | t }}</option>
|
||||
<option value="fr">{{ 'filter.language.fr' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.date' | t }}</label>
|
||||
<select id="date-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
[value]="dateFilter()" (change)="onDateChange($event)">
|
||||
<option value="any">{{ 'filter.date.any' | t }}</option>
|
||||
<option value="day">{{ 'filter.date.day' | t }}</option>
|
||||
<option value="week">{{ 'filter.date.week' | t }}</option>
|
||||
<option value="month">{{ 'filter.date.month' | t }}</option>
|
||||
<option value="year">{{ 'filter.date.year' | t }}</option>
|
||||
<option value="any">{{ 'filter.language.any' | t: 'Toutes' }}</option>
|
||||
<option value="en">{{ 'filter.language.en' | t: 'Anglais' }}</option>
|
||||
<option value="fr">{{ 'filter.language.fr' | t: 'Français' }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 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.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
|
||||
Reference in New Issue
Block a user