feat(filtres): barre [Filtres] [Tri] unique sur recherche et themes
CI / build-and-test (push) Successful in 14m54s
CI / build-and-test (push) Successful in 14m54s
Nouveau composant partage app-filter-toolbar, utilise tel quel par la page de recherche ET les deux pages de themes (meme visuel, seul le cablage differe : URL pour la recherche, signaux locaux pour les themes). - Boutons : [Filtres] (pastilles de type, ligne Sources, selects periode / duree / langue, filtres actifs) et [Tri] (Pertinence, Date de mise en ligne, Vues, Note (likes), Duree). - Page de recherche : le bouton oeil « filtres rapides » et le panneau de selects disparaissent, tout est sous [Filtres] ; le tri a son bouton. - Sources : boutons Toutes / Aucune. « Aucune » masque tout (la garde « derniere source masquable » est retiree) et un bandeau vide dit de reactiver au moins un fournisseur. - /t/:theme : les 3 pastilles maison sont remplacees par la barre ; les sources masquent des blocs entiers ; filtres et tri appliques sur les blocs deja charges. - /p/:provider/t/:theme : les 5 selects et les pastilles sont remplaces par la barre ; signaux maison unifies sur le modele SearchFilters (defaut relevance) ; les onglets Twitch lisent/ecrivent filters().type. - Tri etendu : FilterSort + rating + duration. Les providers ne connaissent que relevance/date/views (serveur inchange, repli relevance) : Note et Duree sont reordonnes cote client via sortItems(), partage avec les pages themes. - Communs : sortItems / itemViews / itemRating / matchesLanguage / formatCount extraits de filters.ts (3 copies d'heuristiques supprimees) ; cles i18n fr+en (filters.sort.rating, filters.sort.duration, filters.sortHint, filters.localHint, « Date de mise en ligne »). Tests : test:search, test:filters, test:suggest, test:kind, test:section verts + test:api 41/41, build ng OK.
This commit is contained in:
@@ -11,7 +11,13 @@ import { ProviderBadgeComponent } from '../../app/shared/components/provider-bad
|
||||
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||
import { sectionAccepts, classifyOnce, type WithKind } from '../../app/shared/utils/section-policy';
|
||||
import {
|
||||
DEFAULT_FILTERS, formatCount, matchesFilters, matchesLanguage, sortItems,
|
||||
type SearchFilters,
|
||||
} from '../../app/search/filters';
|
||||
import { toShortProviderId } from '../../app/shared/providers/provider-ids';
|
||||
import { providerSupportsLive } from '../../app/core/providers/provider-registry';
|
||||
import { FilterToolbarComponent } from '../../app/shared/components/filter-toolbar/filter-toolbar.component';
|
||||
|
||||
interface ProviderBlock {
|
||||
provider: Provider;
|
||||
@@ -26,7 +32,7 @@ interface ProviderBlock {
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './theme-page.component.html',
|
||||
imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent, ProviderBadgeComponent, DurationPipe, ChannelIdentityComponent]
|
||||
imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent, ProviderBadgeComponent, DurationPipe, ChannelIdentityComponent, FilterToolbarComponent]
|
||||
})
|
||||
export class ThemePageComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
@@ -42,11 +48,68 @@ export class ThemePageComponent {
|
||||
|
||||
blocks = signal<ProviderBlock[]>([]);
|
||||
|
||||
// Filtre d'affichage Vidéos / Shorts (les Shorts ne sont plus mélangés sans contrôle).
|
||||
readonly contentFilter = signal<'all' | 'videos' | 'shorts'>('all');
|
||||
// Filtres + tri : identiques à la page de recherche (barre partagée
|
||||
// app-filter-toolbar), appliqués ici sur les blocs déjà chargés.
|
||||
readonly filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
|
||||
readonly language = signal<'any' | 'en' | 'fr'>('any');
|
||||
/** Fournisseurs masqués (bouton « Aucune » puis re-sélection à la main). */
|
||||
readonly hiddenProviders = signal<Provider[]>([]);
|
||||
|
||||
setContentFilter(v: 'all' | 'videos' | 'shorts'): void {
|
||||
this.contentFilter.set(v);
|
||||
onToolbarFilter(ev: { key: keyof SearchFilters; value: string }): void {
|
||||
this.filters.update(f => ({ ...f, [ev.key]: ev.value } as SearchFilters));
|
||||
}
|
||||
|
||||
onSourceChange(ev: { action: 'all' | 'none' | 'toggle' | 'queryAll'; id?: string }): void {
|
||||
if (ev.action === 'all') { this.hiddenProviders.set([]); return; }
|
||||
if (ev.action === 'none') { this.hiddenProviders.set(this.blocks().map(b => b.provider)); return; }
|
||||
if (ev.action !== 'toggle' || !ev.id) return;
|
||||
const p = toShortProviderId(ev.id);
|
||||
const long = this.blocks().map(b => b.provider).find(x => toShortProviderId(x) === p);
|
||||
if (!long) return;
|
||||
this.hiddenProviders.update(h => (h.includes(long) ? h.filter(x => x !== long) : [...h, long]));
|
||||
}
|
||||
|
||||
/** Blocs visibles (la ligne Sources masque des blocs entiers). */
|
||||
readonly visibleBlocks = computed(() =>
|
||||
this.blocks().filter(b => !this.hiddenProviders().includes(b.provider)));
|
||||
|
||||
/** Ligne Sources de la barre partagée. */
|
||||
readonly sourceChips = computed(() => this.blocks().map(b => ({
|
||||
id: toShortProviderId(b.provider) || b.provider,
|
||||
label: this.labelFor(b.provider),
|
||||
visible: !this.hiddenProviders().includes(b.provider),
|
||||
countLabel: formatCount(b.items.length),
|
||||
})));
|
||||
|
||||
/** Compteurs par type (pastilles), sur les blocs visibles. */
|
||||
readonly typeCounts = computed(() => {
|
||||
let live = 0, video = 0, clip = 0, channel = 0, all = 0;
|
||||
for (const b of this.visibleBlocks()) {
|
||||
for (const v of b.items) {
|
||||
all++;
|
||||
if (this.isChannelCard(v)) { channel++; continue; }
|
||||
if (this.isLiveCard(v)) { live++; continue; }
|
||||
if (this.isClipCard(v)) { clip++; continue; }
|
||||
video++;
|
||||
}
|
||||
}
|
||||
return { all, live, video, clip, channel };
|
||||
});
|
||||
|
||||
/** « X / Y » du panneau Filtres (résultats visibles / chargés). */
|
||||
readonly resultCounts = computed(() => {
|
||||
let shown = 0, total = 0;
|
||||
for (const b of this.visibleBlocks()) {
|
||||
total += b.items.length;
|
||||
shown += this.filteredItems(b).length;
|
||||
}
|
||||
return { shown, total };
|
||||
});
|
||||
|
||||
/** « Tout effacer » du panneau Filtres. */
|
||||
clearFilters(): void {
|
||||
this.filters.set({ ...DEFAULT_FILTERS });
|
||||
this.language.set('any');
|
||||
}
|
||||
|
||||
/** Fournisseurs capables de fournir de vrais directs (source unique = registre). */
|
||||
@@ -63,18 +126,33 @@ export class ThemePageComponent {
|
||||
);
|
||||
}
|
||||
|
||||
/** Items du bloc après filtre Vidéos/Shorts (+ LIVE seulement sur thème live). */
|
||||
/** Items du bloc après les filtres de la barre partagée, puis le tri courant. */
|
||||
filteredItems(b: ProviderBlock): Video[] {
|
||||
let items: Video[] = b.items;
|
||||
// Thème "En direct" : ne jamais mélanger avec des VODs (garde-fou si une API renvoie du non-live).
|
||||
if (this.themeSlug() === 'live') {
|
||||
items = items.filter(v => sectionAccepts(v, 'theme-live'));
|
||||
}
|
||||
const f = this.contentFilter();
|
||||
if (f === 'all') return items;
|
||||
return items.filter(v => f === 'shorts'
|
||||
? sectionAccepts(v, 'theme-shorts')
|
||||
: sectionAccepts(v, 'theme-videos'));
|
||||
const f = this.filters();
|
||||
const lang = this.language();
|
||||
// Le type suit la classification de la page (`_kind`, celle des badges des
|
||||
// cartes) ; période / durée passent par le modèle partagé avec `type`
|
||||
// neutralisé pour ne pas le filtrer deux fois avec deux sémantiques.
|
||||
const rest = { ...f, type: 'all' as const };
|
||||
items = items.filter(v =>
|
||||
this.matchesType(v) && matchesFilters(v, rest) && matchesLanguage(v, lang));
|
||||
return sortItems(items, f.sort);
|
||||
}
|
||||
|
||||
/** Type de contenu selon la classification de la page. */
|
||||
private matchesType(v: Video): boolean {
|
||||
switch (this.filters().type) {
|
||||
case 'channel': return this.isChannelCard(v);
|
||||
case 'live': return sectionAccepts(v, 'theme-live');
|
||||
case 'video': return sectionAccepts(v, 'theme-videos');
|
||||
case 'shorts': return sectionAccepts(v, 'theme-shorts');
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** LIVE seulement si réellement en direct (les chaînes hors-ligne ne le sont pas). */
|
||||
|
||||
Reference in New Issue
Block a user