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:
@@ -40,6 +40,9 @@ jobs:
|
||||
- name: Unit tests (search UX)
|
||||
run: npm run test:search
|
||||
|
||||
- name: Search filters (offline)
|
||||
run: npm run test:filters
|
||||
|
||||
- name: E2E scenarios (search UX)
|
||||
run: npm run test:search-e2e
|
||||
|
||||
|
||||
@@ -49,33 +49,35 @@ Agrégez, explorez et regardez des vidéos depuis
|
||||
|
||||
Un seul champ, tous les fournisseurs — avec filtres, raccourcis et deep-links :
|
||||
|
||||
* **Chips providers** dans la barre : `All / YT / DM / TW / PT / OD / RU` (raccourcis **Alt+1..6**)
|
||||
* **Autocomplete `@`** : tapez `@yt` dans le champ pour filtrer sur YouTube (flèches + Enter, Esc pour fermer)
|
||||
* **Provider picker** : bouton `@` ou **Ctrl/⌘+K** — modal avec filtre texte, sélection multiple et option **« Remember as default »** (persistée dans vos préférences utilisateur)
|
||||
* **Quick menu Ctrl/⌘+K** : historique des recherches + toggles providers en un écran
|
||||
* **Deep-links** : `/#/search?q=…&providers=yt,ru` relance la recherche filtrée — partageable
|
||||
* **Panneau de filtres** : bouton **Filtres** (ou **Ctrl/⌘+Maj+F**) — un seul écran pour *toutes* les dimensions : **sources** (multi), **type de contenu** (vidéos / shorts-clips / en direct / chaînes), **période** (dernière heure, aujourd’hui, semaine, mois, année), **durée** (courte < 4 min, moyenne 4-20 min, longue > 20 min) et **tri**. Pastilles, roving tabindex (←/→ dans un groupe, ↑/↓ entre groupes, Entrée pour choisir, Échap pour fermer), focus trap, « Réinitialiser », « Mémoriser comme sources par défaut » et les **recherches récentes** en raccourci
|
||||
* **Filtres appliqués côté serveur** : `?type=…&duration=…&period=…&sort=…` voyagent dans l’URL et dans `/api/search` — YouTube filtre nativement (InnerTube `upload_date`/`type`/`duration`/`features`, Data API `type`/`videoDuration`/`publishedAfter`, yt-dlp `--dateafter`), les autres providers sont affinés en post-traitement (`server/search-filters.mjs`)
|
||||
* **Opérateurs en clair** : `linux live:`, `tuto today: long:`, `concert shorts:`… tapés dans la barre sont convertis en filtres et retirés de la requête (autocomplétion après le `:`)
|
||||
* **Autocomplete `@`** : tapez `@yt` dans le champ pour cocher/décocher une source (↑/↓/Entrée, Échap pour fermer) — raccourcis **Alt+1..6**
|
||||
* **Deep-links** : `/#/search?q=…&providers=yt,ru&period=week&type=live` relance la recherche filtrée — partageable
|
||||
* **Fallback préférence** : URL sans `providers` → préférence `defaultProviders` de l’utilisateur → provider actif
|
||||
* **Accessibilité** : focus trap dans les modals, Esc pour fermer, aria-combobox sur le champ, focus restauré à la fermeture
|
||||
* **Typeahead requête** : sous l’input, suggestions de requêtes (recherches récentes 🕘 + groupes par provider `YT/DM/…`, sous-chaîne surlignée) — `GET /api/search/suggest?q=…&providers=…&limit=…` (min 2 caractères, debounce 250 ms, cache 5 min, dégradation `[]` par provider) ; ↑/↓/Enter/Tab/Esc, priorité au popover `@`
|
||||
* **Accessibilité** : focus trap dans le panneau, Esc pour fermer, aria-combobox + `aria-activedescendant` sur le champ, focus restauré à la fermeture
|
||||
* **Panneau de suggestions** : sous l’input, ligne « Rechercher `<q>` » puis suggestions (recherches récentes 🕘 + groupes par provider `YT/DM/…`, sous-chaîne surlignée) — `GET /api/search/suggest?q=…&providers=…&limit=…` (min 2 caractères, debounce 250 ms, cache 5 min, dégradation `[]` par provider) ; ↑/↓ (bouclants), `Home`/`End`, `PageUp`/`PageDown`, `Entrée` (valide la ligne surlignée, sinon lance la recherche), `Tab` (complète sans chercher), `Échap` (ferme puis vide), priorité au popover `@`
|
||||
* **Focus** : le panneau se referme dès que le focus quitte la barre (`focusout` + `relatedTarget`, clic neutralisé sur les lignes pour garder le focus dans l’input, pas de scintillement au re-clic)
|
||||
|
||||
<!-- TODO: add docs/search-ux.gif (capture des chips, du picker Ctrl+K et du deep-link) -->
|
||||
<!-- TODO: add docs/search-ux.gif (capture du panneau de filtres, du panneau de suggestions et du deep-link) -->
|
||||
|
||||
### Endpoints API concernés
|
||||
|
||||
* `GET /api/search?q=…&providers=yt,dm` — fan-out parallèle, réponse groupée par provider (`page`/`pageSize`/`sort` ; YT sans quota via InnerTube + continuations)
|
||||
* `GET /api/search?q=…&providers=yt,dm&type=live&duration=short&period=week&sort=date` — fan-out parallèle, réponse groupée par provider (`page`/`pageSize`/`sort` + `filters` ; YT sans quota via InnerTube + continuations)
|
||||
* `GET /api/search/suggest?q=…&providers=yt,dm&limit=10` — typeahead `{ q, groups: { yt: string[], … } }` (cache 5 min, rate-limit)
|
||||
* `GET /api/details/youtube/:videoId` — métadonnées + `related[]` (watch-next InnerTube, `?related=0` pour désactiver)
|
||||
* `GET /api/trending?provider=yt&limit=…` — tendances YT sans clé
|
||||
* `GET /healthz` (alias `/api/healthz`) — mode YT, binaire yt-dlp `binOk`, cache, métriques quota/jour, clés
|
||||
* `GET /api/transcript/:provider/:videoId?lang=&instance=&slug=&sourceUrl=` — transcript `{ lang, available, languages, lines: [{ t, dur, text }] }` (cache 24 h, rate-limit 10/min ; absent → 200 `{ available: false }`, échec → 502 ; YouTube : découverte des pistes via InnerTube, `YT_TRANSCRIPT_SOURCE`)
|
||||
* `GET/PATCH /api/user/preferences` — `defaultProviders` (tableau JSON, sanitizé serveur)
|
||||
* `POST /api/telemetry/events` — événements UX anonymes (whitelist : `search_submit`, `provider_picker_open`, `provider_apply`, `at_autocomplete_use`, `quick_menu_open`, `suggest_shown`, `suggest_used`)
|
||||
* `POST /api/telemetry/events` — événements UX anonymes (whitelist : `search_submit`, `provider_picker_open`, `provider_apply`, `at_autocomplete_use`, `quick_menu_open`, `filter_panel_open`, `filter_apply`, `suggest_shown`, `suggest_used`)
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
npm run test:search # unitaires SearchService + parsing @ + picker
|
||||
npm run test:search # unitaires SearchService + clavier/focus du panneau de suggestions + panneau de filtres
|
||||
npm run test:search-e2e # scénarios e2e (serveur réel isolé)
|
||||
npm run test:filters # filtres de recherche : normalisation, bornes, mapping providers (offline)
|
||||
npm run test:suggest # typeahead : parsing/dédup + contrat /api/search/suggest
|
||||
npm run test:transcript # transcripts : parseurs json3/vtt + contrat /api/transcript
|
||||
npm run test:preferences # persistance defaultProviders
|
||||
@@ -241,8 +243,8 @@ Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc.
|
||||
* ✅ Thème (système / dark / light / blue / black)
|
||||
* ✅ Préférences (langue, thème, région, qualité par défaut)
|
||||
* ✅ Auth légère (JWT), rate-limit API
|
||||
* ✅ **Recherche unifiée multi-providers** — chips + autocomplete `@`, picker Ctrl/⌘+K, deep-links `?providers=…`, préférence `defaultProviders` persistée, télémétrie minimale, a11y (focus trap, Esc)
|
||||
* ✅ **Recherche unifiée multi-providers** — chips + autocomplete `@`, picker Ctrl/⌘+K, deep-links `?providers=…`, préférence `defaultProviders` persistée, télémétrie minimale, a11y (focus trap, Esc)
|
||||
* ✅ **Recherche unifiée multi-providers** — panneau de filtres (sources / type / période / durée / tri, Ctrl/⌘+Maj+F), autocomplete `@` + opérateurs `live:`/`today:`, deep-links `?providers=…&type=…&period=…`, préférence `defaultProviders` persistée, filtres serveur, télémétrie minimale, a11y (focus trap, Esc, clavier complet sur le panneau de suggestions)
|
||||
* ✅ **Recherche unifiée multi-providers** — panneau de filtres (sources / type / période / durée / tri, Ctrl/⌘+Maj+F), autocomplete `@` + opérateurs `live:`/`today:`, deep-links `?providers=…&type=…&period=…`, préférence `defaultProviders` persistée, filtres serveur, télémétrie minimale, a11y (focus trap, Esc, clavier complet sur le panneau de suggestions)
|
||||
* ✅ Navigation par thèmes (Trending, Live, Gaming, News, Finance, Tech, Science, Health, Music, Podcasts, Movies/TV, Education, Travel, Food, DIY, Auto…)
|
||||
* ⏳ **Abonnements** (routes + DB)
|
||||
* ⏳ **Tags** & recherche par tags
|
||||
@@ -304,6 +306,7 @@ MIT (voir `LICENSE`)
|
||||
- Créez un fichier `server/providers/<provider>.mjs` qui exporte `default` avec:
|
||||
- `id`, `label`
|
||||
- `async search(q, { limit, page })` → `Promise<Suggestion[]>`
|
||||
- `async search(q, { limit, page, sort, filters })` → `Promise<Suggestion[]>` (`filters` = `{ type, duration, period, sort }` normalisé par `server/search-filters.mjs` ; ignoré par défaut, affiné en post‑traitement si besoin).
|
||||
- Enregistrez-le dans `server/providers/registry.mjs` pour être éligible au fan‑out `/api/search`.
|
||||
|
||||
3) API — Recherche multi‑providers
|
||||
@@ -322,10 +325,11 @@ MIT (voir `LICENSE`)
|
||||
}
|
||||
```
|
||||
|
||||
4) UI — Sélecteur & Chips
|
||||
4) UI — Panneau de filtres
|
||||
|
||||
- Le `SearchBoxComponent` (standalone) rend les chips `All / YT / DM / TW / PT / OD / RU` + menu rapide `@` (ProviderPicker).
|
||||
- Le composant émet `(submitted)` avec `{ q, providers }` et met à jour `SearchService` (RxJS state) pour l’appel API.
|
||||
- Le `SearchBoxComponent` (standalone) rend le champ + le bouton **Filtres** (`SearchFilterPanelComponent` : sources, type, période, durée, tri, recherches récentes) et les pastilles des filtres actifs.
|
||||
- Le composant émet `(submitted)` avec `{ q, providers, filters }` et `(filtersChange)` avec les filtres seuls ; `SearchService` (RxJS state) porte `filters$` et l’envoie à chaque adapter.
|
||||
- Le modèle de filtres est partagé : `src/app/search/filters.ts` (front) et `server/search-filters.mjs` (API) implémentent les mêmes règles.
|
||||
|
||||
5) Deep‑link
|
||||
|
||||
@@ -333,7 +337,7 @@ MIT (voir `LICENSE`)
|
||||
|
||||
6) Tests
|
||||
|
||||
- Unit (`npm run test:search`) : parsing `@yt` dans l’input, toggles ProviderPicker, composition et fan-out de `SearchService`.
|
||||
- Unit (`npm run test:search`) : parsing `@yt`, clavier/focus du panneau de suggestions, opérateurs `live:`, brouillon + apply du panneau de filtres, composition et fan-out de `SearchService. `npm run test:filters` : normalisation/bornes/post-filtrage/mapping providers (offline).
|
||||
- e2e (`npm run test:search-e2e`) : `providers=yt,dm` → réponse ciblée sur [yt,dm]; deep-link `providers=pt`; fallback registry complet; préférence persistée; route SPA.
|
||||
|
||||
Astuce: l’ajout d’un provider ne nécessite pas de modifier les composants — il suffit d’ajouter une entrée dans le registry front + un handler API.
|
||||
|
||||
+18
-3
@@ -14,7 +14,10 @@ Query parameters
|
||||
- providers (string, optional): Comma-separated list of provider ids. Allowed values: yt, dm, tw, pt, od, ru. Defaults to all when omitted or invalid.
|
||||
- page (integer, optional): Page index starting at 1. Default: 1.
|
||||
- pageSize (integer, optional): Page size, max 50. Default: 24.
|
||||
- sort (string, optional): Reserved for future use.
|
||||
- sort (string, optional): relevance | date | views. Default: relevance.
|
||||
- type (string, optional): video | shorts | live | channel. Default: all.
|
||||
- duration (string, optional): short (< 4 min) | medium (4-20 min) | long (> 20 min). Default: all.
|
||||
- period (string, optional): hour | today | week | month | year (upload date). Default: all.
|
||||
|
||||
Response
|
||||
```
|
||||
@@ -32,9 +35,13 @@ Response
|
||||
"ru": []
|
||||
},
|
||||
"page": 1,
|
||||
"pageSize": 24
|
||||
"pageSize": 24,
|
||||
"sort": "relevance",
|
||||
"filters": { "type": "live", "period": "week" }
|
||||
}
|
||||
```
|
||||
`filters` echoes the active filters (default values are omitted). Unknown filter
|
||||
values are ignored (never a 4xx), so a typo silently degrades to "no filter".
|
||||
|
||||
Error responses
|
||||
- 400 { error: "invalid_query", details: "min_length_2" }
|
||||
@@ -51,4 +58,12 @@ Providers
|
||||
Notes
|
||||
- Each provider is queried in parallel with a per-provider limit equal to `pageSize`.
|
||||
- The endpoint currently does not expose a `total` field; the frontend should offer a simple Next page affordance or infinite scroll when appropriate.
|
||||
- Future: support for `sort`.
|
||||
- Filters are applied natively when the provider supports them (YouTube: InnerTube
|
||||
`upload_date` / `type` / `duration` / `features`, Data API v3 `type` /
|
||||
`videoDuration` / `publishedAfter`, yt-dlp `--dateafter`), and refined in
|
||||
post-processing otherwise (see `server/search-filters.mjs`).
|
||||
- A result whose metadata is missing is never dropped by a filter: only values
|
||||
that are present and out of range are filtered out.
|
||||
- The `channel` type is only enforced for YouTube (the only provider that
|
||||
returns channels as search results).
|
||||
- `period=hour` is not a native InnerTube filter: it is applied in post-processing.
|
||||
|
||||
@@ -243,6 +243,9 @@ body {
|
||||
@supports not (height: 100dvh) {
|
||||
.quick-menu-dialog { max-height: 85vh; }
|
||||
}
|
||||
/* Panneau de filtres : même coque que la palette Ctrl+K, plus étroit (les
|
||||
options sont des pastilles, pas des colonnes). */
|
||||
.filter-panel-dialog { max-width: 44rem; }
|
||||
@media (max-width: 639px) {
|
||||
/* On phones the dialog owns the viewport: no floating "modal" look. */
|
||||
.quick-menu-backdrop { padding: 0; }
|
||||
|
||||
+10
-2
@@ -97,7 +97,7 @@ function transcriptToSrt(data) {
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'search_videos',
|
||||
description: 'Recherche unifiée multi-providers (YouTube, Dailymotion, Twitch, PeerTube, Odysee, Rumble). q min 2 caractères.',
|
||||
description: 'Recherche unifiée multi-providers (YouTube, Dailymotion, Twitch, PeerTube, Odysee, Rumble) avec filtres (type de contenu, durée, période de mise en ligne). q min 2 caractères.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -106,6 +106,9 @@ const TOOLS = [
|
||||
page: { type: 'integer', minimum: 1, default: 1 },
|
||||
pageSize: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
|
||||
sort: { type: 'string', enum: ['relevance', 'date', 'views'], default: 'relevance' },
|
||||
type: { type: 'string', enum: ['video', 'shorts', 'live', 'channel'], description: 'Type de contenu attendu' },
|
||||
duration: { type: 'string', enum: ['short', 'medium', 'long'], description: 'Durée : court < 4 min, moyen 4-20 min, long > 20 min' },
|
||||
period: { type: 'string', enum: ['hour', 'today', 'week', 'month', 'year'], description: 'Période de mise en ligne' },
|
||||
},
|
||||
required: ['q'],
|
||||
},
|
||||
@@ -240,10 +243,15 @@ async function callTool(name, args = {}) {
|
||||
page: String(args.page ?? 1),
|
||||
pageSize: String(args.pageSize ?? 10),
|
||||
sort: String(args.sort ?? 'relevance'),
|
||||
// Filtres : ignorés côté API s'ils sont inconnus, donc sûrs à passer.
|
||||
...(args.type ? { type: String(args.type) } : {}),
|
||||
...(args.duration ? { duration: String(args.duration) } : {}),
|
||||
...(args.period ? { period: String(args.period) } : {}),
|
||||
});
|
||||
const data = await apiFetch(`/search?${qs}`);
|
||||
const counts = Object.fromEntries(Object.entries(data.groups || {}).map(([k, v]) => [k, v.length]));
|
||||
return { summary: `${data.groups ? Object.values(data.groups).flat().length : 0} résultats pour "${q}" ${JSON.stringify(counts)}`, data };
|
||||
const filterHint = data.filters && Object.keys(data.filters).length ? ` [${JSON.stringify(data.filters)}]` : '';
|
||||
return { summary: `${data.groups ? Object.values(data.groups).flat().length : 0} résultats pour "${q}" ${JSON.stringify(counts)}${filterHint}`, data };
|
||||
}
|
||||
case 'suggest_queries': {
|
||||
const q = String(args.q || '').trim();
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"test:subscriptions": "node --loader ts-node/esm --experimental-specifier-resolution=node src/services/subscriptions.service.spec.ts",
|
||||
"test:search": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/search.service.spec.ts && node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/search-components.spec.ts",
|
||||
"test:suggest": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/suggest.spec.ts && node ./server/tests/suggest.test.mjs",
|
||||
"test:filters": "node ./server/tests/search-filters.test.mjs",
|
||||
"test:transcript": "node --test server/tests/transcript.test.mjs",
|
||||
"test:history": "node server/tests/history_filters.test.mjs",
|
||||
"test:highlight": "node --loader ts-node/esm --experimental-specifier-resolution=node src/components/account/history/highlight.util.spec.ts",
|
||||
|
||||
+17
-6
@@ -17,6 +17,7 @@ import axios from 'axios';
|
||||
import rumbleRouter from './rumble.mjs';
|
||||
import { providerRegistry, validateProviders } from './providers/registry.mjs';
|
||||
import { dedupeSuggestGroups } from './suggest.mjs';
|
||||
import { parseSearchFilters, activeFilters as activeSearchFilters, applySearchFilters, filtersCacheKey } from './search-filters.mjs';
|
||||
import { fetchWebSuggest, fetchOdyseeLighthouseSuggest } from './suggest-web.mjs';
|
||||
import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError, ensureFmtParam, isEmptyTimedTextBody, isBlobTranscript, mergeTranscriptCandidates } from './transcript.mjs';
|
||||
import {
|
||||
@@ -2522,7 +2523,7 @@ r.post('/telemetry/events', authMiddleware, telemetryLimiter, (req, res) => {
|
||||
const { event, meta } = req.body || {};
|
||||
if (!event || typeof event !== 'string') return res.status(400).json({ error: 'event_required' });
|
||||
// Whitelist known event names to keep the table clean
|
||||
const allowed = new Set(['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'suggest_shown', 'suggest_used']);
|
||||
const allowed = new Set(['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'filter_panel_open', 'filter_apply', 'suggest_shown', 'suggest_used']);
|
||||
if (!allowed.has(event)) return res.status(400).json({ error: 'unknown_event' });
|
||||
const row = insertTelemetryEvent({ userId: req.user.id, event, meta: (meta && typeof meta === 'object') ? meta : null });
|
||||
return res.status(201).json(row || { ok: true });
|
||||
@@ -2542,7 +2543,7 @@ r.get('/telemetry/events', authMiddleware, (req, res) => {
|
||||
r.get('/telemetry/summary', authMiddleware, (req, res) => {
|
||||
const since = typeof req.query.since === 'string' ? req.query.since : undefined;
|
||||
const events = {};
|
||||
for (const name of ['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'suggest_shown', 'suggest_used']) {
|
||||
for (const name of ['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'filter_panel_open', 'filter_apply', 'suggest_shown', 'suggest_used']) {
|
||||
events[name] = countTelemetryEvents({ event: name, since });
|
||||
}
|
||||
return res.json({ events });
|
||||
@@ -3105,10 +3106,15 @@ app.get('/api/search', async (req, res) => {
|
||||
}
|
||||
const pageNum = Math.max(1, Number(req.query.page || 1));
|
||||
const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize || 24)));
|
||||
// Filtres de recherche (type / durée / période / tri). Un filtre inconnu est
|
||||
// ignoré (jamais 400) : `normalizeFilters` retombe sur 'all'.
|
||||
const filters = parseSearchFilters(req.query);
|
||||
// Optional sort parameter (normalized to known set)
|
||||
const allowedSort = new Set(['relevance', 'date', 'views']);
|
||||
let sort = (typeof req.query.sort === 'string' ? req.query.sort.trim().toLowerCase() : 'relevance');
|
||||
if (!allowedSort.has(sort)) sort = 'relevance';
|
||||
// `filters.sort` fait autorité (le front envoie les deux, ils concordent)
|
||||
sort = filters.sort;
|
||||
// Validate and normalize providers list (default to all supported when none/invalid)
|
||||
const requested = typeof providers === 'string' ? String(providers) : '';
|
||||
const validProviders = validateProviders(requested);
|
||||
@@ -3118,8 +3124,8 @@ app.get('/api/search', async (req, res) => {
|
||||
validProviders.map((providerId) => {
|
||||
const mod = providerRegistry[/** @type {any} */(providerId)];
|
||||
if (!mod || typeof mod.search !== 'function') return Promise.resolve([]);
|
||||
// Basic options include pagination and sort hints
|
||||
return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort }));
|
||||
// Basic options include pagination, sort hints and the search filters
|
||||
return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort, filters }));
|
||||
})
|
||||
);
|
||||
|
||||
@@ -3129,7 +3135,8 @@ app.get('/api/search', async (req, res) => {
|
||||
results.forEach((result, index) => {
|
||||
const providerId = validProviders[index];
|
||||
if (result.status === 'fulfilled') {
|
||||
groups[providerId] = Array.isArray(result.value) ? result.value : [];
|
||||
// Filtres non supportés nativement par le provider -> affinage ici.
|
||||
groups[providerId] = applySearchFilters(providerId, Array.isArray(result.value) ? result.value : [], filters);
|
||||
} else {
|
||||
console.warn(`Search failed for provider ${providerId}:`, result.reason?.message || result.reason);
|
||||
groups[providerId] = [];
|
||||
@@ -3144,7 +3151,11 @@ app.get('/api/search', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ q, providers: validProviders, groups, errors, page: pageNum, pageSize, sort });
|
||||
return res.json({
|
||||
q, providers: validProviders, groups, errors,
|
||||
page: pageNum, pageSize, sort,
|
||||
filters: activeSearchFilters(filters),
|
||||
});
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// WEB client : search + continuations (pagination illimitée), watch-next (related),
|
||||
// sans clé API ni quota. Le dispatcher bascule sur scrape/API si indisponible.
|
||||
import { hashSearchKey, ytMetrics } from './youtube-common.mjs';
|
||||
import { innertubeSearchFilters, filtersCacheKey } from '../search-filters.mjs';
|
||||
|
||||
let sessionPromise = null;
|
||||
let LogSilenced = false;
|
||||
@@ -95,22 +96,53 @@ export function mapVideoNode(n) {
|
||||
if (type === 'LockupView') return mapLockupView(n);
|
||||
if (/playlist|channel|gridchannel|shelf|radio|show|album/i.test(type)
|
||||
&& !/playlistpanelvideo|watchcard/i.test(type)) return null;
|
||||
const id = n.id ? String(n.id) : null;
|
||||
// Les Shorts (ShortsLockupView / ReelItem) n'ont pas de `id` : il vit dans
|
||||
// l'endpoint de navigation. Sans cela, une recherche `type=shorts` ne
|
||||
// remonterait aucun résultat.
|
||||
const entityId = n.entity_id ? String(n.entity_id) : '';
|
||||
const id = n.id ? String(n.id) : (
|
||||
n.on_tap_endpoint?.payload?.videoId
|
||||
|| n.inline_player_data?.payload?.videoId
|
||||
|| n.on_tap?.innertubeCommand?.reelWatchEndpoint?.videoId
|
||||
|| n.on_tap?.innertubeCommand?.reelPlayerEndpoint?.videoId
|
||||
|| n.navigation_endpoint?.watchEndpoint?.videoId
|
||||
|| n.innertubeCommand?.reelWatchEndpoint?.videoId
|
||||
// `shorts-shelf-item-<videoId>` : dernier recours.
|
||||
|| (entityId.startsWith('shorts-shelf-item-') ? entityId.slice('shorts-shelf-item-'.length) : '')
|
||||
|| null
|
||||
);
|
||||
if (!id) return null;
|
||||
const title = n.title?.text ?? (typeof n.title === 'string' ? n.title : '');
|
||||
// `??` ne suffit pas : `typeof n.title === 'string' ? … : ''` renvoie ''
|
||||
// (non nullish) et court-circuiterait les replis. On chaîne donc à part.
|
||||
const title = n.title?.text
|
||||
|| (typeof n.title === 'string' ? n.title : '')
|
||||
// ShortsLockupView / ReelItem : le titre est dans les métadonnées superposées.
|
||||
|| n.overlay_metadata?.primary_text?.text
|
||||
|| n.overlay_metadata?.headline?.text
|
||||
|| '';
|
||||
if (!title) return null;
|
||||
const duration = n.duration?.seconds != null ? Number(n.duration.seconds) : undefined;
|
||||
const author = n.author || n.uploader || null;
|
||||
const authorName = author?.name ?? (typeof author === 'string' ? author : undefined);
|
||||
const authorId = author?.id ? String(author.id) : undefined;
|
||||
const views = parseViewsText(n.view_count?.text ?? n.view_count ?? n.views?.text);
|
||||
const views = parseViewsText(
|
||||
n.view_count?.text ?? n.view_count ?? n.views?.text
|
||||
// ShortsLockupView : vues dans la 2e ligne des métadonnées superposées.
|
||||
?? n.overlay_metadata?.secondary_text?.text,
|
||||
);
|
||||
// Vignette : `thumbnails[]` (classique), `thumbnail` (ShortsLockupView :
|
||||
// tableau de Thumbnail) ou la vignette portée par l'endpoint du Short.
|
||||
const thumb = bestThumb(n.thumbnails)
|
||||
|| bestThumb(n.thumbnail)
|
||||
|| bestThumb(n.on_tap_endpoint?.payload?.thumbnail?.thumbnails)
|
||||
|| undefined;
|
||||
const isShort = /short|reel/i.test(type) || (Number.isFinite(duration) && duration > 0 && duration <= 70 && /short/i.test(title) === false && /reel|short/i.test(type));
|
||||
const badges = Array.isArray(n.badges) ? n.badges.map((b) => b?.label).filter(Boolean) : [];
|
||||
return {
|
||||
title: String(title),
|
||||
id,
|
||||
url: `https://www.youtube.com/watch?v=${id}`,
|
||||
thumbnail: bestThumb(n.thumbnails),
|
||||
thumbnail: thumb,
|
||||
uploaderName: authorName,
|
||||
type: 'video',
|
||||
...(Number.isFinite(duration) && duration > 0 ? { duration } : {}),
|
||||
@@ -190,6 +222,55 @@ export function mapNodes(nodes) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplatit un nœud InnerTube (YTNode) en ses feuilles.
|
||||
* `Shelf.content` peut être une `Grid`/`Shelf` imbriquée : les Shorts d'une
|
||||
* recherche « Shorts » vivent là, hors de `feed.videos`.
|
||||
*/
|
||||
function flattenNodes(node, depth = 0, out = []) {
|
||||
if (!node || depth > 5) return out;
|
||||
if (Array.isArray(node)) {
|
||||
for (const n of node) flattenNodes(n, depth, out);
|
||||
return out;
|
||||
}
|
||||
if (typeof node !== 'object') return out;
|
||||
let descended = false;
|
||||
for (const key of ['contents', 'items', 'content']) {
|
||||
const child = node[key];
|
||||
if (child && typeof child === 'object') {
|
||||
descended = true;
|
||||
flattenNodes(child, depth + 1, out);
|
||||
}
|
||||
}
|
||||
if (!descended) out.push(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vidéos contenues dans les `Shelf` d'un feed de recherche (Shorts/clips).
|
||||
* `feed.videos` ne regarde que les rendus directs, donc une recherche
|
||||
* `type=shorts` y renvoie 0 sans ce complément.
|
||||
* Pur et testable offline.
|
||||
*/
|
||||
export function mapShelfNodes(feed) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
try {
|
||||
const results = Array.isArray(feed?.results) ? Array.from(feed.results) : [];
|
||||
for (const node of results) {
|
||||
const type = String(node?.type || '');
|
||||
if (!/shelf/i.test(type)) continue;
|
||||
for (const leaf of flattenNodes(node.content ?? node)) {
|
||||
const mapped = mapVideoNode(leaf);
|
||||
if (!mapped || seen.has(mapped.id)) continue;
|
||||
seen.add(mapped.id);
|
||||
out.push(mapped);
|
||||
}
|
||||
}
|
||||
} catch { return out; }
|
||||
return out;
|
||||
}
|
||||
|
||||
// Chaîne de continuations en mémoire : q_hash -> { feed, pages }.
|
||||
// (SQLite persiste les pages déjà servies ; la chaîne évite de rejouer les pages 1..N-1.)
|
||||
const chainCache = new Map();
|
||||
@@ -212,6 +293,8 @@ function sortParam(sort) {
|
||||
/**
|
||||
* Recherche InnerTube avec vraie pagination (continuations).
|
||||
* Page 1 ~35-40 vidéos ; pages suivantes via getContinuation() fusionnées (façon SmartTube).
|
||||
* `opts.filters` : { type, duration, period, sort } — traduits en filtres
|
||||
* natifs InnerTube (upload_date / type / duration / features).
|
||||
*/
|
||||
export async function searchViaInnerTube(q, opts = {}) {
|
||||
const query = String(q || '').trim();
|
||||
@@ -219,9 +302,16 @@ export async function searchViaInnerTube(q, opts = {}) {
|
||||
const limit = Math.min(50, Math.max(1, Number(opts?.limit || 24)));
|
||||
const page = Math.min(10, Math.max(1, Number(opts?.page || 1)));
|
||||
const sort = String(opts?.sort || 'relevance');
|
||||
const itFilters = innertubeSearchFilters(opts?.filters);
|
||||
// Les Shorts d'une recherche « Shorts » sont servis dans un `Shelf`, hors de
|
||||
// `feed.videos` : on complète le lot dans ce seul cas (une recherche normale
|
||||
// garde exactement son comportement et son ordre actuels).
|
||||
const withShelves = itFilters.type === 'shorts';
|
||||
ytMetrics.innertubeCalls = (ytMetrics.innertubeCalls || 0) + 1;
|
||||
const yt = await getSession();
|
||||
const key = `it|${hashSearchKey(`${query.toLowerCase()}|${sort}`)}`;
|
||||
// La chaîne de continuations est propre à une combinaison
|
||||
// (requête + tri + filtres) : sinon on servirait les pages d'un filtre à l'autre.
|
||||
const key = `it|${hashSearchKey(`${query.toLowerCase()}|${sort}|${filtersCacheKey(opts?.filters)}`)}`;
|
||||
let entry = chainGet(key);
|
||||
let feed = entry?.feed || null;
|
||||
let collected = entry?.items ? [...entry.items] : [];
|
||||
@@ -231,17 +321,23 @@ export async function searchViaInnerTube(q, opts = {}) {
|
||||
// [start, start+limit[ (façon SmartTube qui remplit son écran au fil des
|
||||
// continuations), au lieu d'aligner 1 page API = 1 page UI.
|
||||
const need = page * limit;
|
||||
const harvest = (f) => {
|
||||
const direct = mapNodes(f.videos);
|
||||
if (!withShelves) return direct;
|
||||
const extra = mapShelfNodes(f).filter((m) => !direct.some((d) => d.id === m.id));
|
||||
return direct.concat(extra);
|
||||
};
|
||||
try {
|
||||
if (!feed) {
|
||||
feed = await yt.search(query, { sort_by: sortParam(sort) });
|
||||
collected = mapNodes(feed.videos);
|
||||
feed = await yt.search(query, { ...itFilters, sort_by: sortParam(sort) });
|
||||
collected = harvest(feed);
|
||||
pagesDone = 1;
|
||||
}
|
||||
let guard = 0;
|
||||
while (collected.length < need && feed?.has_continuation && guard < 12) {
|
||||
guard++;
|
||||
feed = await feed.getContinuation();
|
||||
const fresh = mapNodes(feed.videos).filter((m) => !collected.some((c) => c.id === m.id));
|
||||
const fresh = harvest(feed).filter((m) => !collected.some((c) => c.id === m.id));
|
||||
collected = collected.concat(fresh);
|
||||
pagesDone++;
|
||||
if (!fresh.length) break;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { getYtDlpTimeoutMs, buildYtDlpExtraArgs, resolveYtDlpBin } from './youtube-common.mjs';
|
||||
import { periodMs } from '../search-filters.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFileCb);
|
||||
|
||||
@@ -85,7 +86,7 @@ export function classifyScrapeError(e) {
|
||||
return Object.assign(new Error(`YouTube scrape failed: ${msg.slice(0, 200)}`), { ytStatus: 502, code: 'yt_scrape_failed' });
|
||||
}
|
||||
|
||||
async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1 } = {}) {
|
||||
async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1, extraArgs = [] } = {}) {
|
||||
const bin = await resolveYtDlpBin();
|
||||
const timeout = getYtDlpTimeoutMs();
|
||||
const end = playlistStart + Math.max(1, Math.min(50, Number(limit || 10))) - 1;
|
||||
@@ -98,6 +99,7 @@ async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1 } = {})
|
||||
'--no-playlist',
|
||||
'--playlist-start', String(playlistStart),
|
||||
'--playlist-end', String(end),
|
||||
...extraArgs,
|
||||
...buildYtDlpExtraArgs(),
|
||||
queryOrUrl,
|
||||
];
|
||||
@@ -112,7 +114,11 @@ async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1 } = {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Recherche sans clé. sort: relevance|date|views */
|
||||
/**
|
||||
* Recherche sans clé. sort: relevance|date|views
|
||||
* `opts.filters` : seule la période est traduite nativement (`--dateafter`) ;
|
||||
* durée / type sont affinés en post-traitement (côté /api/search).
|
||||
*/
|
||||
export async function searchViaScrape(q, opts = {}) {
|
||||
const query = String(q || '').trim();
|
||||
if (query.length < 2) return [];
|
||||
@@ -125,7 +131,12 @@ export async function searchViaScrape(q, opts = {}) {
|
||||
if (sort === 'date') prefix = 'ytsearchdate';
|
||||
// views : pas de préfixe natif stable -> ytsearch + tri local
|
||||
const target = `${prefix}${perPage}:${query}`;
|
||||
let items = await runYtDlpFlat(target, { limit: perPage, playlistStart: start });
|
||||
// --dateafter : borne basse de mise en ligne (format YYYYMMDD attendu par yt-dlp).
|
||||
const maxAge = periodMs(opts?.filters?.period);
|
||||
const extraArgs = maxAge > 0
|
||||
? ['--dateafter', new Date(Date.now() - maxAge).toISOString().slice(0, 10).replace(/-/g, '')]
|
||||
: [];
|
||||
let items = await runYtDlpFlat(target, { limit: perPage, playlistStart: start, extraArgs });
|
||||
if (sort === 'views') items = [...items].sort((a, b) => (b.views || 0) - (a.views || 0));
|
||||
return items.slice(0, perPage);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './youtube-common.mjs';
|
||||
import { searchViaScrape } from './youtube-scrape.mjs';
|
||||
import { searchViaInnerTube } from './youtube-innertube.mjs';
|
||||
import { apiSearchParams, filtersCacheKey } from '../search-filters.mjs';
|
||||
|
||||
function parseISODurationToSeconds(iso) {
|
||||
if (typeof iso !== 'string' || !iso) return 0;
|
||||
@@ -108,7 +109,7 @@ export function ytScrapeCacheStats() {
|
||||
return { memEntries: memCache.size, memMax: MEM_MAX };
|
||||
}
|
||||
|
||||
async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {}) {
|
||||
async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filters = null } = {}) {
|
||||
const keys = getYouTubeKeys();
|
||||
if (!keys.length) {
|
||||
throw Object.assign(new Error('YOUTUBE_API_KEY not configured'), { ytStatus: 503, code: 'youtube_api_key_unavailable' });
|
||||
@@ -116,6 +117,8 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {}
|
||||
let order = 'relevance';
|
||||
if (sort === 'date') order = 'date';
|
||||
else if (sort === 'views') order = 'viewCount';
|
||||
// Filtres natifs de l'API v3 : type, videoDuration, publishedAfter.
|
||||
const extra = apiSearchParams(filters);
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||
const targetPage = Math.max(1, Number(page || 1));
|
||||
let pageToken = '';
|
||||
@@ -123,8 +126,9 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {}
|
||||
let lastItems = [];
|
||||
while (currentPage <= targetPage) {
|
||||
const params = {
|
||||
part: 'snippet', q, type: 'video', maxResults: String(perPage), order,
|
||||
part: 'snippet', q, maxResults: String(perPage), order,
|
||||
videoEmbeddable: 'true', safeSearch: 'moderate',
|
||||
...extra,
|
||||
};
|
||||
if (pageToken) params.pageToken = pageToken;
|
||||
const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', params);
|
||||
@@ -148,35 +152,51 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {}
|
||||
}
|
||||
return (lastItems || []).map((item) => {
|
||||
const videoId = item?.id?.videoId;
|
||||
// Une recherche de type `channel` renvoie des channelId, pas des videoId.
|
||||
const rawId = videoId || item?.id?.channelId || null;
|
||||
const isChannel = !videoId && !!item?.id?.channelId;
|
||||
if (!rawId) return null;
|
||||
const snippet = item?.snippet || {};
|
||||
const thumb = snippet.thumbnails?.high?.url || snippet.thumbnails?.medium?.url || snippet.thumbnails?.default?.url || undefined;
|
||||
const details = videoId ? detailsMap.get(videoId) : null;
|
||||
const duration = parseISODurationToSeconds(details?.contentDetails?.duration || '');
|
||||
const views = details?.statistics?.viewCount != null ? Number(details.statistics.viewCount) : undefined;
|
||||
const channelId = snippet.channelId || undefined;
|
||||
const channelId = snippet.channelId || item?.id?.channelId || undefined;
|
||||
const embeddable = details?.status ? details.status.embeddable !== false : undefined;
|
||||
return {
|
||||
title: snippet.title || '', id: videoId,
|
||||
url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : undefined,
|
||||
thumbnail: thumb, uploaderName: snippet.channelTitle || undefined, type: 'video',
|
||||
title: snippet.title || '', id: rawId,
|
||||
url: isChannel
|
||||
? `https://www.youtube.com/channel/${rawId}`
|
||||
: `https://www.youtube.com/watch?v=${rawId}`,
|
||||
thumbnail: thumb, uploaderName: snippet.channelTitle || undefined,
|
||||
type: isChannel ? 'channel' : 'video',
|
||||
duration: duration > 0 ? duration : undefined, views,
|
||||
publishedAt: snippet.publishedAt || undefined, channelId,
|
||||
channelHandle: snippet.channelTitle || undefined, channelExternalId: channelId,
|
||||
channelUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : undefined, embeddable,
|
||||
};
|
||||
});
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
/** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number, sort?: 'relevance'|'date'|'views' }) => Promise<Suggestion[]> }} */
|
||||
/**
|
||||
* @typedef {Object} SearchFilters
|
||||
* @property {string} [type] all|video|shorts|live|channel
|
||||
* @property {string} [duration] all|short|medium|long
|
||||
* @property {string} [period] all|hour|today|week|month|year
|
||||
* @property {string} [sort] relevance|date|views
|
||||
*/
|
||||
/** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number, sort?: 'relevance'|'date'|'views', filters?: SearchFilters }) => Promise<Suggestion[]> }} */
|
||||
const handler = {
|
||||
id: 'yt',
|
||||
label: 'YouTube',
|
||||
async search(q, opts) {
|
||||
const { limit = 10, page = 1, sort = 'relevance' } = opts || {};
|
||||
const { limit = 10, page = 1, sort = 'relevance', filters = null } = opts || {};
|
||||
const mode = getSearchMode();
|
||||
const ttl = getScrapeTtlMs();
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||
const key = `yt|${hashSearchKey(`${String(q).toLowerCase().trim()}|${perPage}|${page}|${sort}|${mode}`)}`;
|
||||
// La signature des filtres entre dans la clé de cache : deux recherches
|
||||
// identiques avec des filtres différents ne doivent pas se partager de cache.
|
||||
const key = `yt|${hashSearchKey(`${String(q).toLowerCase().trim()}|${perPage}|${page}|${sort}|${mode}|${filtersCacheKey(filters)}`)}`;
|
||||
// 1) mémoire
|
||||
const memHit = memGet(key, ttl);
|
||||
if (memHit) { ytMetrics.scrapeHits++; return memHit; }
|
||||
@@ -207,10 +227,10 @@ const handler = {
|
||||
const tryScrape = async () => {
|
||||
ytMetrics.scrapeCalls++;
|
||||
try { const { incYoutubeMetrics } = await import('../db.mjs'); incYoutubeMetrics({ scrapeCalls: 1 }); } catch {}
|
||||
return searchViaScrape(q, { limit: perPage, page, sort });
|
||||
return searchViaScrape(q, { limit: perPage, page, sort, filters });
|
||||
};
|
||||
const tryApi = () => searchViaApi(q, { limit: perPage, page, sort });
|
||||
const tryInnerTube = () => searchViaInnerTube(q, { limit: perPage, page, sort });
|
||||
const tryApi = () => searchViaApi(q, { limit: perPage, page, sort, filters });
|
||||
const tryInnerTube = () => searchViaInnerTube(q, { limit: perPage, page, sort, filters });
|
||||
const log = (source, extra = '') => console.log(`[YT search] source=${source} mode=${mode} latency=${Date.now() - t0}ms results=${extra}`);
|
||||
try {
|
||||
if (mode === 'api-only') {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Filtres de recherche — modèle partagé avec le front (`src/app/search/filters.ts`).
|
||||
*
|
||||
* Rôle : valider/normaliser les filtres reçus par `GET /api/search`, les
|
||||
* traduire dans les paramètres natifs de chaque provider (InnerTube, Data API
|
||||
* v3…) et, pour les providers qui ne savent pas filtrer, affiner la liste
|
||||
* renvoyée.
|
||||
*
|
||||
* Principe de sûreté : un filtre inconnu est IGNORÉ (jamais d'erreur 4xx), et
|
||||
* un champ métadonnée absent ne fait jamais disparaître un résultat.
|
||||
*/
|
||||
|
||||
const TYPES = ['all', 'video', 'shorts', 'live', 'channel'];
|
||||
const DURATIONS = ['all', 'short', 'medium', 'long'];
|
||||
const PERIODS = ['all', 'hour', 'today', 'week', 'month', 'year'];
|
||||
const SORTS = ['relevance', 'date', 'views'];
|
||||
|
||||
/** @returns {any} filtres normalisés (toujours les 4 clés) */
|
||||
export function normalizeFilters(raw) {
|
||||
const src = raw && typeof raw === 'object' ? raw : {};
|
||||
const pick = (v, allowed, fallback) => {
|
||||
const s = String(v ?? '').trim().toLowerCase();
|
||||
return allowed.includes(s) ? s : fallback;
|
||||
};
|
||||
return {
|
||||
type: pick(src.type, TYPES, 'all'),
|
||||
duration: pick(src.duration, DURATIONS, 'all'),
|
||||
period: pick(src.period, PERIODS, 'all'),
|
||||
sort: pick(src.sort, SORTS, 'relevance'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Extrait + normalise les filtres depuis une query string Express. */
|
||||
export function parseSearchFilters(query) {
|
||||
const q = query && typeof query === 'object' ? query : {};
|
||||
return normalizeFilters({
|
||||
type: q.type,
|
||||
duration: q.duration,
|
||||
period: q.period,
|
||||
sort: q.sort,
|
||||
});
|
||||
}
|
||||
|
||||
/** Filtres actifs seulement (pour le cache + la réponse JSON). */
|
||||
export function activeFilters(filters) {
|
||||
const n = normalizeFilters(filters);
|
||||
const out = {};
|
||||
if (n.type !== 'all') out.type = n.type;
|
||||
if (n.duration !== 'all') out.duration = n.duration;
|
||||
if (n.period !== 'all') out.period = n.period;
|
||||
if (n.sort !== 'relevance') out.sort = n.sort;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Signature courte et stable (cache mémoire / SQLite). */
|
||||
export function filtersCacheKey(filters) {
|
||||
const n = normalizeFilters(filters);
|
||||
return `${n.type}.${n.duration}.${n.period}.${n.sort}`;
|
||||
}
|
||||
|
||||
const HOUR = 3600 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/** Bornes de la période de mise en ligne, en ms. 0 = pas de borne. */
|
||||
export function periodMs(period) {
|
||||
switch (normalizeFilters({ period }).period) {
|
||||
case 'hour': return HOUR;
|
||||
case 'today': return DAY;
|
||||
case 'week': return 7 * DAY;
|
||||
case 'month': return 30 * DAY;
|
||||
case 'year': return 365 * DAY;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bornes de durée en secondes. */
|
||||
export function durationBounds(duration) {
|
||||
switch (normalizeFilters({ duration }).duration) {
|
||||
case 'short': return { min: 1, max: 240 };
|
||||
case 'medium': return { min: 240, max: 1200 };
|
||||
case 'long': return { min: 1200, max: Infinity };
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-filtrage (providers sans filtre natif)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Dimensions que l'on peut affiner en post-traitement, par provider.
|
||||
* - Twitch : les chaînes n'ont pas de durée exploitable, et `live` est le seul
|
||||
* type fiable (les VOD portent `type: 'video'`).
|
||||
* - Odysee : pas de durée fiable sur la recherche.
|
||||
* - Le type `channel` n'a de sens que pour YouTube (seul provider qui expose
|
||||
* des chaines comme résultats de recherche) : ailleurs on ne filtre pas, sinon
|
||||
* la recherche vide le résultat au lieu de le restreindre.
|
||||
*/
|
||||
const POST_FILTER_DIMS = {
|
||||
yt: ['period', 'duration', 'type'],
|
||||
dm: ['period', 'duration', 'type'],
|
||||
tw: ['period', 'type'],
|
||||
pt: ['period', 'duration', 'type'],
|
||||
od: ['period', 'type'],
|
||||
ru: ['period', 'duration', 'type'],
|
||||
};
|
||||
|
||||
function num(item, keys) {
|
||||
for (const k of keys) {
|
||||
const v = item?.[k];
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||
if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) return Number(v);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function itemDurationSec(item) {
|
||||
const d = num(item, ['duration', 'durationSec', 'length', 'durationSeconds']);
|
||||
if (d > 0) return d;
|
||||
// "1:23:45" / "12:34"
|
||||
const raw = item?.duration ?? item?.length ?? '';
|
||||
const s = String(raw || '');
|
||||
if (s.includes(':')) {
|
||||
const parts = s.split(':').map((p) => Number(p));
|
||||
if (parts.length && parts.every((p) => Number.isFinite(p))) {
|
||||
return parts.reduce((acc, p) => acc * 60 + p, 0);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function itemPublishedTs(item) {
|
||||
const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate
|
||||
?? item?.published_at ?? item?.uploaded ?? item?.timestamp ?? item?.created_time;
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
// Les scrapes yt-dlp donnent un timestamp en secondes.
|
||||
return raw > 1e12 ? raw : raw * 1000;
|
||||
}
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
const asNum = Number(raw);
|
||||
if (Number.isFinite(asNum) && asNum > 0) return asNum > 1e12 ? asNum : asNum * 1000;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isLiveItem(item) {
|
||||
if (!item) return false;
|
||||
if (item.isLive === true) return true;
|
||||
return String(item.type || '').toLowerCase() === 'live';
|
||||
}
|
||||
|
||||
export function isShortItem(item) {
|
||||
if (!item) return false;
|
||||
if (item.isShort === true) return true;
|
||||
if (String(item.kind || '').toLowerCase() === 'clip') return true;
|
||||
const d = itemDurationSec(item);
|
||||
return d > 0 && d <= 70 && !/short/i.test(String(item.title || ''));
|
||||
}
|
||||
|
||||
export function isChannelItem(item) {
|
||||
return String(item?.type || '').toLowerCase() === 'channel' && item?.isLive !== true;
|
||||
}
|
||||
|
||||
/** Le provider expose-t-il un signal de type exploitable ? */
|
||||
function hasTypeSignal(item) {
|
||||
if (!item) return false;
|
||||
if (item.isLive === true || item.isLive === false) return true;
|
||||
if (item.isShort === true || item.isShort === false) return true;
|
||||
if (String(item.type || '').trim()) return true;
|
||||
if (String(item.kind || '').trim()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Un item respecte-t-il les filtres ? (champs absents => jamais écarté) */
|
||||
export function matchesFilters(item, filters) {
|
||||
const n = normalizeFilters(filters);
|
||||
if (n.type === 'all' && n.duration === 'all' && n.period === 'all') return true;
|
||||
|
||||
const bounds = durationBounds(n.duration);
|
||||
if (bounds) {
|
||||
const d = itemDurationSec(item);
|
||||
if (d > 0 && (d < bounds.min || d >= bounds.max)) return false;
|
||||
}
|
||||
|
||||
const maxAge = periodMs(n.period);
|
||||
if (maxAge > 0) {
|
||||
const ts = itemPublishedTs(item);
|
||||
if (ts > 0 && (Date.now() - ts) > maxAge) return false;
|
||||
}
|
||||
|
||||
if (n.type === 'all') return true;
|
||||
if (!hasTypeSignal(item)) return true; // provider muet : on ne peut pas exclure
|
||||
if (n.type === 'live') return isLiveItem(item);
|
||||
if (n.type === 'channel') return isChannelItem(item);
|
||||
if (n.type === 'shorts') return isShortItem(item);
|
||||
if (n.type === 'video') {
|
||||
return !isLiveItem(item) && !isChannelItem(item) && !isShortItem(item);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Affine une liste de résultats pour un provider donné. Renvoie la liste
|
||||
* d'origine quand le provider ne sait pas post-filtrer cette dimension.
|
||||
* @param {string} providerId
|
||||
* @param {any[]} items
|
||||
* @param {any} filters
|
||||
*/
|
||||
export function applySearchFilters(providerId, items, filters) {
|
||||
const n = normalizeFilters(filters);
|
||||
if (!Array.isArray(items) || items.length === 0) return items || [];
|
||||
if (n.type === 'all' && n.duration === 'all' && n.period === 'all') return items;
|
||||
const dims = POST_FILTER_DIMS[providerId] || [];
|
||||
const wanted = ['period', 'duration', 'type'].filter((d) => dims.includes(d) && n[d] && n[d] !== 'all');
|
||||
if (wanted.length === 0) return items;
|
||||
// `channel` : hors YouTube, on ne filtre pas (sinon résultat vide).
|
||||
if (wanted.includes('type') && n.type === 'channel' && providerId !== 'yt') return items;
|
||||
return items.filter((it) => matchesFilters(it, n));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapping provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Filtres youtubei.js (`yt.search`) : les enums protos sont des noms explicites
|
||||
* (UNDER_THREE_MINS…), pas des alias 'short'/'medium'/'long'.
|
||||
* @param {any} filters
|
||||
*/
|
||||
export function innertubeSearchFilters(filters) {
|
||||
const n = normalizeFilters(filters);
|
||||
const out = {};
|
||||
if (n.period !== 'all' && ['today', 'week', 'month', 'year'].includes(n.period)) {
|
||||
out.upload_date = n.period;
|
||||
}
|
||||
if (n.type === 'video' || n.type === 'channel') out.type = n.type;
|
||||
if (n.type === 'shorts') out.type = 'shorts';
|
||||
// `live` n'est pas un SearchType : YouTube l'expose comme feature.
|
||||
if (n.type === 'live') out.features = ['live'];
|
||||
if (n.duration !== 'all') {
|
||||
out.duration = n.duration === 'short'
|
||||
? 'UNDER_THREE_MINS'
|
||||
: n.duration === 'medium' ? 'THREE_TO_TWENTY_MINS' : 'OVER_TWENTY_MINS';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paramètres additionnels pour la Data API v3 (`youtube/v3/search`).
|
||||
* `type` : 'shorts'/'live' n'existent pas côté API -> laissés au post-filtrage.
|
||||
*/
|
||||
export function apiSearchParams(filters) {
|
||||
const n = normalizeFilters(filters);
|
||||
const out = {};
|
||||
if (n.type === 'video' || n.type === 'channel') out.type = n.type;
|
||||
if (n.duration !== 'all') out.videoDuration = n.duration;
|
||||
const maxAge = periodMs(n.period);
|
||||
if (maxAge > 0) out.publishedAfter = new Date(Date.now() - maxAge).toISOString();
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Filtres de recherche — tests unitaires (offline, aucun réseau).
|
||||
// Run with: npm run test:filters
|
||||
// Couverture : normalisation, bornes, post-filtrage par provider, mapping
|
||||
// InnerTube / Data API v3.
|
||||
|
||||
import {
|
||||
normalizeFilters, parseSearchFilters, activeFilters, filtersCacheKey,
|
||||
periodMs, durationBounds, matchesFilters, applySearchFilters,
|
||||
innertubeSearchFilters, apiSearchParams, itemDurationSec, itemPublishedTs,
|
||||
} from '../search-filters.mjs';
|
||||
|
||||
function expect(cond, msg) {
|
||||
if (!cond) throw new Error(`Assertion failed: ${msg}`);
|
||||
}
|
||||
function eq(actual, expected, msg) {
|
||||
const a = JSON.stringify(actual);
|
||||
const b = JSON.stringify(expected);
|
||||
if (a !== b) throw new Error(`Assertion failed: ${msg} (expected ${b}, got ${a})`);
|
||||
}
|
||||
function logOk(msg) { console.log(`✓ ${msg}`); }
|
||||
|
||||
// ---------- Normalisation ----------
|
||||
|
||||
eq(normalizeFilters(null), { type: 'all', duration: 'all', period: 'all', sort: 'relevance' }, 'null filters -> defaults');
|
||||
eq(
|
||||
normalizeFilters({ type: 'LIVE', duration: 'moyen', period: 'nope', sort: 'views' }),
|
||||
{ type: 'live', duration: 'all', period: 'all', sort: 'views' },
|
||||
'values are case-insensitive, unknown values fall back per key (no cross-contamination)',
|
||||
);
|
||||
eq(
|
||||
parseSearchFilters({ type: 'shorts', duration: 'short', period: 'week', sort: 'date', junk: 'x' }),
|
||||
{ type: 'shorts', duration: 'short', period: 'week', sort: 'date' },
|
||||
'parseSearchFilters reads the Express query',
|
||||
);
|
||||
eq(
|
||||
parseSearchFilters({ type: 'plaid' }).type,
|
||||
'all',
|
||||
'unknown type is ignored (never a 400)',
|
||||
);
|
||||
eq(activeFilters({ type: 'live', period: 'all' }), { type: 'live' }, 'activeFilters keeps only non-defaults');
|
||||
eq(
|
||||
filtersCacheKey({ type: 'live', sort: 'date' }),
|
||||
'live.all.all.date',
|
||||
'cache key signature is stable and ordered',
|
||||
);
|
||||
logOk('normalizeFilters / parseSearchFilters / activeFilters / filtersCacheKey');
|
||||
|
||||
// ---------- Bornes ----------
|
||||
|
||||
eq(periodMs('all'), 0, 'period all -> no bound');
|
||||
expect(periodMs('hour') === 3600 * 1000, 'hour bound');
|
||||
expect(periodMs('week') === 7 * 24 * 3600 * 1000, 'week bound');
|
||||
eq(durationBounds('short'), { min: 1, max: 240 }, 'short duration bound');
|
||||
eq(durationBounds('medium'), { min: 240, max: 1200 }, 'medium duration bound');
|
||||
eq(durationBounds('long'), { min: 1200, max: Infinity }, 'long duration bound');
|
||||
eq(durationBounds('all'), null, 'no duration bound');
|
||||
logOk('periodMs / durationBounds');
|
||||
|
||||
// ---------- Lectures de métadonnées ----------
|
||||
|
||||
eq(itemDurationSec({ duration: 120 }), 120, 'numeric duration');
|
||||
eq(itemDurationSec({ duration: 'PT1H2M' }), 0, 'ISO duration ignored (not a field format)');
|
||||
eq(itemDurationSec({ duration: '1:02:03' }), 3723, 'hh:mm:ss duration parsed');
|
||||
eq(itemDurationSec({}), 0, 'missing duration -> 0');
|
||||
expect(itemPublishedTs({ publishedAt: new Date().toISOString() }) > 0, 'ISO publishedAt parsed');
|
||||
expect(itemPublishedTs({ timestamp: 1700000000 }) === 1700000000000, 'yt-dlp seconds timestamps up-scaled');
|
||||
eq(itemPublishedTs({}), 0, 'missing date -> 0');
|
||||
logOk('itemDurationSec / itemPublishedTs');
|
||||
|
||||
// ---------- Post-filtrage ----------
|
||||
|
||||
const now = Date.now();
|
||||
// `type: 'video'` par défaut = payload réaliste d'un provider qui expose le type.
|
||||
const mk = (over) => ({
|
||||
id: Math.random().toString(36).slice(2),
|
||||
title: 'x',
|
||||
type: 'video',
|
||||
duration: 100,
|
||||
publishedAt: new Date(now - 60 * 1000).toISOString(),
|
||||
...over,
|
||||
});
|
||||
|
||||
expect(matchesFilters(mk({ duration: 100 }), { duration: 'short' }), 'short keeps 100s');
|
||||
expect(!matchesFilters(mk({ duration: 300 }), { duration: 'short' }), 'short drops 5min');
|
||||
expect(matchesFilters(mk({ duration: 300 }), { duration: 'medium' }), 'medium keeps 5min');
|
||||
expect(matchesFilters(mk({ duration: 3600 }), { duration: 'long' }), 'long keeps 1h');
|
||||
expect(matchesFilters(mk({ duration: null }), { duration: 'short' }), 'missing duration is never dropped');
|
||||
expect(
|
||||
matchesFilters(mk({ publishedAt: new Date(now - 3 * 3600 * 1000).toISOString() }), { period: 'today' }),
|
||||
'today keeps 3h ago',
|
||||
);
|
||||
expect(
|
||||
!matchesFilters(mk({ publishedAt: new Date(now - 10 * 24 * 3600 * 1000).toISOString() }), { period: 'week' }),
|
||||
'week drops 10 days ago',
|
||||
);
|
||||
expect(
|
||||
matchesFilters(mk({ publishedAt: new Date(now - 3 * 24 * 3600 * 1000).toISOString() }), { period: 'week' }),
|
||||
'week keeps 3 days ago',
|
||||
);
|
||||
expect(matchesFilters(mk({ publishedAt: null }), { period: 'today' }), 'missing date is never dropped');
|
||||
expect(matchesFilters(mk({ isLive: true }), { type: 'live' }), 'live type keeps isLive items');
|
||||
expect(!matchesFilters(mk({}), { type: 'live' }), 'live type drops regular videos');
|
||||
expect(matchesFilters(mk({ isShort: true, duration: 40 }), { type: 'shorts' }), 'shorts keeps flagged items');
|
||||
expect(matchesFilters(mk({ kind: 'clip' }), { type: 'shorts' }), 'shorts keeps clips');
|
||||
// Item sans aucun signal de type : on ne peut pas conclure -> jamais écarté.
|
||||
expect(matchesFilters(mk({ duration: 0, type: undefined }), { type: 'live' }), 'item without type signal is kept');
|
||||
expect(matchesFilters(mk({ duration: 0, type: undefined }), { type: 'shorts' }), 'item without type signal is kept for shorts');
|
||||
expect(
|
||||
matchesFilters(mk({ duration: 500 }), { type: 'video' }),
|
||||
'video type keeps plain videos',
|
||||
);
|
||||
expect(!matchesFilters(mk({ isLive: true }), { type: 'video' }), 'video type drops live items');
|
||||
logOk('matchesFilters (durée / période / type)');
|
||||
|
||||
// ---------- applySearchFilters par provider ----------
|
||||
|
||||
const items = [
|
||||
mk({ duration: 100, isShort: true }),
|
||||
mk({ duration: 1800 }),
|
||||
mk({ isLive: true, duration: 60 }),
|
||||
mk({ duration: 0, publishedAt: null }), // métadonnées absentes : jamais écarté
|
||||
];
|
||||
|
||||
eq(
|
||||
applySearchFilters('yt', items, { duration: 'short' }).length,
|
||||
3,
|
||||
'yt post-filters duration (100s + 60s live kept, 1800s dropped, unknown kept)',
|
||||
);
|
||||
eq(
|
||||
applySearchFilters('tw', items, { duration: 'short' }).length,
|
||||
items.length,
|
||||
'twitch does not post-filter duration (no reliable value)',
|
||||
);
|
||||
eq(
|
||||
applySearchFilters('dm', items, { type: 'channel' }).length,
|
||||
items.length,
|
||||
'channel filter is not applied outside YouTube (would empty results)',
|
||||
);
|
||||
eq(
|
||||
applySearchFilters('yt', items, { type: 'channel' }).length,
|
||||
0,
|
||||
'yt channel filter drops videos',
|
||||
);
|
||||
eq(
|
||||
applySearchFilters('yt', items, {}).length,
|
||||
items.length,
|
||||
'no filter -> list untouched (same reference semantics)',
|
||||
);
|
||||
eq(
|
||||
applySearchFilters('yt', [], { type: 'live' }),
|
||||
[],
|
||||
'empty list stays empty',
|
||||
);
|
||||
logOk('applySearchFilters (dimensions par provider)');
|
||||
|
||||
// ---------- Mapping providers ----------
|
||||
|
||||
eq(innertubeSearchFilters({ period: 'week' }), { upload_date: 'week' }, 'period -> upload_date');
|
||||
eq(innertubeSearchFilters({ period: 'hour' }), {}, 'hour has no InnerTube enum (post-filter only)');
|
||||
eq(innertubeSearchFilters({ type: 'shorts' }), { type: 'shorts' }, 'type shorts');
|
||||
eq(innertubeSearchFilters({ type: 'live' }), { features: ['live'] }, 'live is a feature, not a SearchType');
|
||||
eq(innertubeSearchFilters({ type: 'channel' }), { type: 'channel' }, 'type channel');
|
||||
eq(
|
||||
innertubeSearchFilters({ duration: 'short' }),
|
||||
{ duration: 'UNDER_THREE_MINS' },
|
||||
'short -> UNDER_THREE_MINS proto enum',
|
||||
);
|
||||
eq(
|
||||
innertubeSearchFilters({ duration: 'medium' }),
|
||||
{ duration: 'THREE_TO_TWENTY_MINS' },
|
||||
'medium -> THREE_TO_TWENTY_MINS proto enum',
|
||||
);
|
||||
eq(
|
||||
innertubeSearchFilters({ duration: 'long' }),
|
||||
{ duration: 'OVER_TWENTY_MINS' },
|
||||
'long -> OVER_TWENTY_MINS proto enum',
|
||||
);
|
||||
eq(innertubeSearchFilters({}), {}, 'no filters -> empty object');
|
||||
|
||||
eq(apiSearchParams({ type: 'video' }), { type: 'video' }, 'api type video');
|
||||
eq(apiSearchParams({ duration: 'long' }), { videoDuration: 'long' }, 'api videoDuration');
|
||||
eq(apiSearchParams({ type: 'shorts' }), {}, 'api has no shorts type (post-filter)');
|
||||
expect(
|
||||
typeof apiSearchParams({ period: 'today' }).publishedAfter === 'string',
|
||||
'api publishedAfter ISO date',
|
||||
);
|
||||
logOk('innertubeSearchFilters / apiSearchParams');
|
||||
|
||||
console.log('\nAll search-filters tests passed.');
|
||||
@@ -155,6 +155,33 @@ function scenario(name, fn) {
|
||||
// 2b) Invalid providers fall back to all
|
||||
await scenario('invalid providers param falls back to the full registry', async () => validateProvidersFallback());
|
||||
|
||||
// 2c) Filtres de recherche : ils sont renvoyés tels quels, valeurs inconnues ignorées
|
||||
await scenario('search filters are echoed back and unknown values ignored', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/search?q=test&providers=yt,dm&pageSize=3&type=live&duration=short&period=week&sort=date`);
|
||||
assert(res.ok, `search responds 200 (got ${res.status})`);
|
||||
const body = await res.json();
|
||||
assert(body.filters && body.filters.type === 'live', `type filter echoed (got ${JSON.stringify(body.filters)})`);
|
||||
assert(body.filters.duration === 'short', 'duration filter echoed');
|
||||
assert(body.filters.period === 'week', 'period filter echoed');
|
||||
assert(body.sort === 'date', 'sort comes from the filters');
|
||||
|
||||
// Valeurs inconnues : ignorées, jamais 400.
|
||||
const bad = await fetch(`${baseUrl}/api/search?q=test&providers=dm&type=plaid&period=yesterday&duration=epic`);
|
||||
assert(bad.ok, `unknown filter values do not fail (got ${bad.status})`);
|
||||
const badBody = await bad.json();
|
||||
assert(!badBody.filters || Object.keys(badBody.filters).length === 0,
|
||||
`unknown filters are dropped (got ${JSON.stringify(badBody.filters)})`);
|
||||
assert(badBody.sort === 'relevance', 'sort falls back to relevance');
|
||||
});
|
||||
|
||||
// 2d) Un filtre ne doit jamais renvoyer d'erreur 500 (post-filtrage sûr)
|
||||
await scenario('filters never break the fan-out (200 + groups)', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/search?q=test&pageSize=3&period=today&duration=long`);
|
||||
assert(res.ok, `search responds 200 with filters (got ${res.status})`);
|
||||
const body = await res.json();
|
||||
assert(body.groups && typeof body.groups === 'object', 'groups present with filters');
|
||||
});
|
||||
|
||||
// 3) Preference: defaultProviders persisted and returned after re-login
|
||||
await scenario('default providers preference round-trip (persisted per user)', async () => {
|
||||
await patchPreferences(token, { defaultProviders: ['ru', 'od'] });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Step 18 — tests offline InnerTube (AUCUN réseau : mappers purs + modes).
|
||||
import assert from 'node:assert/strict';
|
||||
import { mapVideoNode, mapLockupView, mapNodes, mapCaptionTracks, parseViewsText, parseDurationLabel } from '../providers/youtube-innertube.mjs';
|
||||
import { mapVideoNode, mapLockupView, mapNodes, mapShelfNodes, mapCaptionTracks, parseViewsText, parseDurationLabel } from '../providers/youtube-innertube.mjs';
|
||||
import { pickTrack, orderedTracks } from '../transcript.mjs';
|
||||
import { getSearchMode, YT_SEARCH_MODES } from '../providers/youtube-common.mjs';
|
||||
|
||||
@@ -96,7 +96,50 @@ function ok(msg) { console.log(`✓ ${msg}`); }
|
||||
ok('mapLockupView watch-next');
|
||||
}
|
||||
|
||||
// 5) modes innertube connus
|
||||
// 5) mapShelfNodes + ShortsLockupView : les Shorts d'une recherche « Shorts »
|
||||
// n'ont ni `id` ni `title` classiques (endpoint + overlay_metadata), et
|
||||
// vivent dans un GridShelfView -> sans ce complément la recherche renvoie 0.
|
||||
{
|
||||
// Forme réelle observée (youtubei.js v18, ShortsLockupView)
|
||||
const shortsNode = (id) => ({
|
||||
type: 'ShortsLockupView',
|
||||
entity_id: `shorts-shelf-item-${id}`,
|
||||
on_tap_endpoint: { name: 'reelWatchEndpoint', payload: { videoId: id } },
|
||||
overlay_metadata: {
|
||||
primary_text: { text: `Short ${id}` },
|
||||
secondary_text: { text: '219 M de vues' },
|
||||
},
|
||||
thumbnail: [{ url: 'http://t/low.jpg', width: 100 }, { url: 'http://t/high.jpg', width: 1080 }],
|
||||
});
|
||||
const mapped = mapVideoNode(shortsNode('s1'));
|
||||
assert.equal(mapped.id, 's1', 'l\'id du Short est lu dans on_tap_endpoint');
|
||||
assert.equal(mapped.title, 'Short s1', 'le titre vient de overlay_metadata');
|
||||
assert.equal(mapped.isShort, true, 'un Short est marqué isShort');
|
||||
assert.equal(mapped.views, 219000000, 'les vues viennent de la 2e ligne d\'overlay');
|
||||
assert.equal(mapped.thumbnail, 'http://t/high.jpg', 'la plus grande vignette est retenue');
|
||||
// Forme « brute » (avant parsing youtubei.js) également gérée
|
||||
assert.equal(mapVideoNode({
|
||||
type: 'ShortsLockupView',
|
||||
on_tap: { innertubeCommand: { reelWatchEndpoint: { videoId: 'raw1' } } },
|
||||
overlay_metadata: { primary_text: { text: 'Raw' } },
|
||||
}).id, 'raw1', 'forme brute (on_tap) gérée');
|
||||
|
||||
const feed = {
|
||||
results: [
|
||||
{ type: 'Video', id: 'regular1', title: { text: 'Vidéo normale' } },
|
||||
{ type: 'GridShelfView', contents: [shortsNode('s1'), shortsNode('s2'), { type: 'ChipCloud' }] },
|
||||
],
|
||||
};
|
||||
const shelf = mapShelfNodes(feed);
|
||||
assert.equal(shelf.length, 2, 'les 2 Shorts du shelf sont mappés');
|
||||
assert.deepEqual(shelf.map((s) => s.id).sort(), ['s1', 's2']);
|
||||
assert.equal(mapShelfNodes({ results: [{ type: 'Video', id: 'v' }] }).length, 0, 'hors shelf, rien à faire');
|
||||
assert.deepEqual(mapShelfNodes(null), []);
|
||||
assert.deepEqual(mapShelfNodes({}), []);
|
||||
ok('mapShelfNodes + ShortsLockupView (Shorts dans un shelf)');
|
||||
}
|
||||
|
||||
// 6) modes innertube connus
|
||||
{
|
||||
assert.ok(YT_SEARCH_MODES.includes('innertube-first'));
|
||||
assert.ok(YT_SEARCH_MODES.includes('innertube-only'));
|
||||
@@ -107,7 +150,7 @@ function ok(msg) { console.log(`✓ ${msg}`); }
|
||||
ok('modes innertube-first/only');
|
||||
}
|
||||
|
||||
// 6) mapCaptionTracks : forme InnerTube -> pseudo dump yt-dlp, interop pickTrack
|
||||
// 7) mapCaptionTracks : forme InnerTube -> pseudo dump yt-dlp, interop pickTrack
|
||||
{
|
||||
const cap = mapCaptionTracks([
|
||||
{ base_url: 'https://x/t?caps=asr&x=1', language_code: 'en', kind: 'asr', name: { text: 'English (auto-generated)' } },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
|
||||
// TODO: Make SearchResult generic
|
||||
export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
@@ -12,6 +13,9 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'dm');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.dm?.message === 'string' ? res.errors.dm.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({
|
||||
@@ -25,7 +29,10 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
publishedAt: undefined, // Dailymotion API does not provide published date in search results
|
||||
channelExternalId: it.channelId || undefined,
|
||||
channelAvatarUrl: it.uploaderAvatar || undefined,
|
||||
}));
|
||||
// Signaux de type pour le raffinement client des filtres.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
isShort: it.isShort === true ? true : undefined,
|
||||
} as VideoItem));
|
||||
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { HttpParams } from '@angular/common/http';
|
||||
import { activeFilters, type SearchFilters } from '../filters';
|
||||
|
||||
/**
|
||||
* Ajoute les filtres de recherche à une `HttpParams` d'adapter.
|
||||
* Les valeurs par défaut ne sont pas envoyées (URL/backend plus lisibles, cache
|
||||
* serveur plus efficace : deux recherches sans filtre partagent la même entrée).
|
||||
*/
|
||||
export function withFilterParams(params: HttpParams, filters: Partial<SearchFilters> | null | undefined): HttpParams {
|
||||
const active = activeFilters(filters);
|
||||
let out = params;
|
||||
if (active.type) out = out.set('type', active.type);
|
||||
if (active.duration) out = out.set('duration', active.duration);
|
||||
if (active.period) out = out.set('period', active.period);
|
||||
if (active.sort) out = out.set('sort', active.sort);
|
||||
return out;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
|
||||
export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'od' as const;
|
||||
@@ -11,6 +12,9 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'od');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.od?.message === 'string' ? res.errors.od.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => {
|
||||
@@ -34,6 +38,9 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
// Nom de chaîne LBRY (ex. @MaChaine) : le backend /api/channels/od
|
||||
// sait le résoudre avec ou sans préfixe @.
|
||||
channelExternalId: it.uploaderName || undefined,
|
||||
// Signaux de type pour le raffinement client des filtres.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
isShort: it.isShort === true ? true : undefined,
|
||||
} as VideoItem;
|
||||
});
|
||||
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0, providerError };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
|
||||
export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'pt' as const;
|
||||
@@ -11,6 +12,9 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'pt');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.pt?.message === 'string' ? res.errors.pt.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => {
|
||||
@@ -31,7 +35,10 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
viewCount: undefined,
|
||||
publishedAt: undefined,
|
||||
channelExternalId,
|
||||
};
|
||||
// Signaux de type pour le raffinement client des filtres.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
isShort: it.isShort === true ? true : undefined,
|
||||
} as VideoItem;
|
||||
});
|
||||
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0, providerError };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
|
||||
export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'ru' as const;
|
||||
@@ -11,6 +12,9 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'ru');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.ru?.message === 'string' ? res.errors.ru.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({
|
||||
@@ -22,7 +26,10 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
}));
|
||||
// Signaux de type (Shorts / direct) pour le raffinement client.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
isShort: it.isShort === true ? true : undefined,
|
||||
} as VideoItem));
|
||||
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
|
||||
export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'tw' as const;
|
||||
@@ -11,6 +12,9 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'tw');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.tw?.message === 'string' ? res.errors.tw.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
|
||||
@@ -41,6 +45,7 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined,
|
||||
kind: typeof it.kind === 'string' ? it.kind : undefined,
|
||||
isShort: it.isShort === true ? true : undefined,
|
||||
game: typeof it.game === 'string' ? it.game : undefined,
|
||||
} as VideoItem;
|
||||
}).filter((v: VideoItem) => v.id != null && String(v.id).length > 0 && (v.thumbnailUrl || v.channelAvatarUrl));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
|
||||
export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'yt' as const;
|
||||
@@ -11,6 +12,9 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'yt');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.yt?.message === 'string' ? res.errors.yt.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({
|
||||
@@ -25,7 +29,12 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
// ID chaîne conservé : rend le logo/nom cliquables vers /channel
|
||||
// (l'avatar est résolu via /api/channels, l'API search n'en fournit pas).
|
||||
channelExternalId: it.channelExternalId || it.channelId || undefined,
|
||||
}));
|
||||
// Signaux de type : indispensables au raffinement client des filtres
|
||||
// (Shorts / direct / chaîne) quand le serveur les a déjà appliqués.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined,
|
||||
isShort: it.isShort === true ? true : undefined,
|
||||
} as VideoItem));
|
||||
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface SearchResponseV1 {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: 'relevance' | 'date' | 'views';
|
||||
/** Filtres appliqués côté serveur (les valeurs par défaut sont omises). */
|
||||
filters?: Record<string, string>;
|
||||
/** Per-provider backend failures, keyed by provider id. */
|
||||
errors?: Partial<Record<ProviderId, string>>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Modèle de filtres de recherche — partagé par la barre de recherche, le panneau
|
||||
* de filtres, la page de résultats et les adapters.
|
||||
*
|
||||
* Les filtres sont *un* objet plat (`SearchFilters`) : ils voyagent dans l'URL
|
||||
* (`?type=…&duration=…&period=…&sort=…`), dans la requête `/api/search` et dans
|
||||
* l'état de `SearchService`. Toute valeur inconnue retombe sur 'all' (jamais de
|
||||
* 400, jamais de liste vide côté rendu : un filtre typo doit être ignoré).
|
||||
*/
|
||||
import { isShortVideo } from '../shared/utils/video-kind';
|
||||
|
||||
/** Type de contenu attendu. */
|
||||
export type FilterType = 'all' | 'video' | 'shorts' | 'live' | 'channel';
|
||||
/** Borne de durée (alignée sur YouTube : court < 4 min, moyen 4-20 min, long > 20 min). */
|
||||
export type FilterDuration = 'all' | 'short' | 'medium' | 'long';
|
||||
/** Période de mise en ligne. */
|
||||
export type FilterPeriod = 'all' | 'hour' | 'today' | 'week' | 'month' | 'year';
|
||||
/** Tri. */
|
||||
export type FilterSort = 'relevance' | 'date' | 'views';
|
||||
|
||||
export interface SearchFilters {
|
||||
type: FilterType;
|
||||
duration: FilterDuration;
|
||||
period: FilterPeriod;
|
||||
sort: FilterSort;
|
||||
}
|
||||
|
||||
export const FILTER_TYPES: FilterType[] = ['all', 'video', 'shorts', 'live', 'channel'];
|
||||
export const FILTER_DURATIONS: FilterDuration[] = ['all', 'short', 'medium', 'long'];
|
||||
export const FILTER_PERIODS: FilterPeriod[] = ['all', 'hour', 'today', 'week', 'month', 'year'];
|
||||
export const FILTER_SORTS: FilterSort[] = ['relevance', 'date', 'views'];
|
||||
|
||||
export const DEFAULT_FILTERS: SearchFilters = {
|
||||
type: 'all',
|
||||
duration: 'all',
|
||||
period: 'all',
|
||||
sort: 'relevance',
|
||||
};
|
||||
|
||||
function pick<T extends string>(raw: unknown, allowed: T[], fallback: T): T {
|
||||
const s = String(raw ?? '').trim().toLowerCase();
|
||||
return (allowed as string[]).includes(s) ? (s as T) : fallback;
|
||||
}
|
||||
|
||||
/** Normalise n'importe quelle entrée (URL, patch, payload serveur) en filtres valides. */
|
||||
export function normalizeFilters(raw: Partial<SearchFilters> | null | undefined): SearchFilters {
|
||||
const src = raw || {};
|
||||
return {
|
||||
['type']: pick(src['type'], FILTER_TYPES, 'all'),
|
||||
['duration']: pick(src['duration'], FILTER_DURATIONS, 'all'),
|
||||
['period']: pick(src['period'], FILTER_PERIODS, 'all'),
|
||||
['sort']: pick(src['sort'], FILTER_SORTS, 'relevance'),
|
||||
} as SearchFilters;
|
||||
}
|
||||
|
||||
/** Nombre de filtres réellement actifs (le tri par défaut ne compte pas). */
|
||||
export function activeFilterCount(f: Partial<SearchFilters> | null | undefined): number {
|
||||
const n = normalizeFilters(f);
|
||||
let count = 0;
|
||||
if (n.type !== 'all') count++;
|
||||
if (n.duration !== 'all') count++;
|
||||
if (n.period !== 'all') count++;
|
||||
if (n.sort !== 'relevance') count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
export function filtersEqual(a: Partial<SearchFilters> | null, b: Partial<SearchFilters> | null): boolean {
|
||||
const x = normalizeFilters(a);
|
||||
const y = normalizeFilters(b);
|
||||
return x.type === y.type && x.duration === y.duration && x.period === y.period && x.sort === y.sort;
|
||||
}
|
||||
|
||||
/** Les filtres « non par défaut » seuls — pour l'URL et le cache. */
|
||||
export function activeFilters(f: Partial<SearchFilters> | null | undefined): Partial<SearchFilters> {
|
||||
const n = normalizeFilters(f);
|
||||
const out: Partial<SearchFilters> = {};
|
||||
if (n.type !== 'all') out.type = n.type;
|
||||
if (n.duration !== 'all') out.duration = n.duration;
|
||||
if (n.period !== 'all') out.period = n.period;
|
||||
if (n.sort !== 'relevance') out.sort = n.sort;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Query params (les valeurs par défaut sont omises : URL lisible et partageable). */
|
||||
export function filtersToQueryParams(f: Partial<SearchFilters> | null | undefined): Record<string, string> {
|
||||
const a = activeFilters(f);
|
||||
const out: Record<string, string> = {};
|
||||
if (a.type) out['type'] = a.type;
|
||||
if (a.duration) out['duration'] = a.duration;
|
||||
if (a.period) out['period'] = a.period;
|
||||
if (a.sort) out['sort'] = a.sort;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Lecture inverse depuis une `ParamMap` (ou un objet de query params). */
|
||||
export function filtersFromQueryParams(params: {
|
||||
get(key: string): string | null;
|
||||
has?(key: string): boolean;
|
||||
} | Record<string, unknown> | null | undefined): SearchFilters {
|
||||
const read = (key: string): string | null => {
|
||||
if (!params) return null;
|
||||
if (typeof (params as any).get === 'function') {
|
||||
try { return (params as any).get(key) ?? null; } catch { return null; }
|
||||
}
|
||||
const rec = params as Record<string, unknown>;
|
||||
const v = rec?.[key];
|
||||
if (v == null) return null;
|
||||
return Array.isArray(v) ? String(v[0] ?? '') : String(v);
|
||||
};
|
||||
return normalizeFilters({
|
||||
type: read('type') as FilterType | undefined,
|
||||
duration: read('duration') as FilterDuration | undefined,
|
||||
period: read('period') as FilterPeriod | undefined,
|
||||
sort: read('sort') as FilterSort | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Durée de la période en ms (0 = pas de borne). */
|
||||
export function periodMs(period: FilterPeriod): number {
|
||||
const HOUR = 3600 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
switch (period) {
|
||||
case 'hour': return HOUR;
|
||||
case 'today': return DAY;
|
||||
case 'week': return 7 * DAY;
|
||||
case 'month': return 30 * DAY;
|
||||
case 'year': return 365 * DAY;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bornes de durée en secondes (null = pas de borne). */
|
||||
export function durationBounds(duration: FilterDuration): { min: number; max: number } | null {
|
||||
switch (duration) {
|
||||
case 'short': return { min: 1, max: 240 };
|
||||
case 'medium': return { min: 240, max: 1200 };
|
||||
case 'long': return { min: 1200, max: Number.POSITIVE_INFINITY };
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Affichage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FilterKey = keyof SearchFilters;
|
||||
|
||||
export interface FilterOption<T extends string = string> {
|
||||
value: T;
|
||||
/** Clé i18n (voir i18n.service.ts) + repli FR. */
|
||||
key: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** Une option d'un groupe de filtres, avec son état sélectionné. */
|
||||
export interface FilterChoice<T extends string = string> extends FilterOption<T> {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
function opt<T extends string>(value: T, key: string, label: string, hint?: string): FilterOption<T> {
|
||||
return hint ? { value, key, label, hint } : { value, key, label };
|
||||
}
|
||||
|
||||
/** Groupes de filtres dans l'ordre d'affichage du panneau. */
|
||||
export function filterGroups(f: Partial<SearchFilters> | null | undefined): Array<{
|
||||
key: string;
|
||||
labelKey: string;
|
||||
label: string;
|
||||
multi: boolean;
|
||||
options: FilterChoice[];
|
||||
}> {
|
||||
const n = normalizeFilters(f);
|
||||
return [
|
||||
{
|
||||
key: 'type', labelKey: 'filters.type', label: 'Type de contenu', multi: false,
|
||||
options: [
|
||||
opt('all', 'filters.type.all', 'Tout'),
|
||||
opt('video', 'filters.type.video', 'Vidéos'),
|
||||
opt('shorts', 'filters.type.shorts', 'Shorts / Clips'),
|
||||
opt('live', 'filters.type.live', 'En direct'),
|
||||
opt('channel', 'filters.type.channel', 'Chaînes'),
|
||||
].map(o => ({ ...o, selected: n.type === o.value })),
|
||||
},
|
||||
{
|
||||
key: 'period', labelKey: 'filters.period', label: 'Période', multi: false,
|
||||
options: [
|
||||
opt('all', 'filters.period.all', "N'importe quand"),
|
||||
opt('hour', 'filters.period.hour', 'Dernière heure'),
|
||||
opt('today', 'filters.period.today', "Aujourd'hui"),
|
||||
opt('week', 'filters.period.week', 'Cette semaine'),
|
||||
opt('month', 'filters.period.month', 'Ce mois'),
|
||||
opt('year', 'filters.period.year', 'Cette année'),
|
||||
].map(o => ({ ...o, selected: n.period === o.value })),
|
||||
},
|
||||
{
|
||||
key: 'duration', labelKey: 'filters.duration', label: 'Durée', multi: false,
|
||||
options: [
|
||||
opt('all', 'filters.duration.all', 'Toutes'),
|
||||
opt('short', 'filters.duration.short', 'Courte < 4 min'),
|
||||
opt('medium', 'filters.duration.medium', 'Moyenne 4-20 min'),
|
||||
opt('long', 'filters.duration.long', 'Longue > 20 min'),
|
||||
].map(o => ({ ...o, selected: n.duration === o.value })),
|
||||
},
|
||||
{
|
||||
key: 'sort', labelKey: 'filters.sort', label: 'Trier par', multi: false,
|
||||
options: [
|
||||
opt('relevance', 'filters.sort.relevance', 'Pertinence'),
|
||||
opt('date', 'filters.sort.date', 'Date'),
|
||||
opt('views', 'filters.sort.views', 'Vues'),
|
||||
].map(o => ({ ...o, selected: n.sort === o.value })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Résumé court (une ligne) des filtres actifs, pour le bouton sur mobile. */
|
||||
export function filtersSummary(f: Partial<SearchFilters> | null | undefined, groups = filterGroups(f)): string {
|
||||
const n = normalizeFilters(f);
|
||||
const parts: string[] = [];
|
||||
if (n.type !== 'all') parts.push(groups[0].options.find(o => o.value === n.type)?.label || n.type);
|
||||
if (n.period !== 'all') parts.push(groups[1].options.find(o => o.value === n.period)?.label || n.period);
|
||||
if (n.duration !== 'all') parts.push(groups[2].options.find(o => o.value === n.duration)?.label || n.duration);
|
||||
return parts.length ? parts.join(' · ') : 'Filtres';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Syntaxe « opérateur » dans la requête : `linux today: long: live:`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Aliases de jetons d'opérateur, tolérés en français et en anglais.
|
||||
* Un jeton n'est consommé que s'il est *connu* : `https:`, `12:30` ou un
|
||||
* pseudo-inconnu restent dans la requête telle quelle.
|
||||
*/
|
||||
const TOKEN_ALIASES: Record<string, { key: FilterKey; value: string }> = {
|
||||
// période
|
||||
'heure': { key: 'period', value: 'hour' },
|
||||
'hour': { key: 'period', value: 'hour' },
|
||||
'aujourdhui': { key: 'period', value: 'today' },
|
||||
'today': { key: 'period', value: 'today' },
|
||||
'jour': { key: 'period', value: 'today' },
|
||||
'day': { key: 'period', value: 'today' },
|
||||
'semaine': { key: 'period', value: 'week' },
|
||||
'week': { key: 'period', value: 'week' },
|
||||
'mois': { key: 'period', value: 'month' },
|
||||
'month': { key: 'period', value: 'month' },
|
||||
'annee': { key: 'period', value: 'year' },
|
||||
'année': { key: 'period', value: 'year' },
|
||||
'year': { key: 'period', value: 'year' },
|
||||
// durée
|
||||
'court': { key: 'duration', value: 'short' },
|
||||
'courte': { key: 'duration', value: 'short' },
|
||||
'short': { key: 'duration', value: 'short' },
|
||||
'moyen': { key: 'duration', value: 'medium' },
|
||||
'moyenne': { key: 'duration', value: 'medium' },
|
||||
'medium': { key: 'duration', value: 'medium' },
|
||||
'long': { key: 'duration', value: 'long' },
|
||||
'longue': { key: 'duration', value: 'long' },
|
||||
// type
|
||||
'live': { key: 'type', value: 'live' },
|
||||
'direct': { key: 'type', value: 'live' },
|
||||
'video': { key: 'type', value: 'video' },
|
||||
'videos': { key: 'type', value: 'video' },
|
||||
'vidéo': { key: 'type', value: 'video' },
|
||||
'vidéos': { key: 'type', value: 'video' },
|
||||
'shorts': { key: 'type', value: 'shorts' },
|
||||
'clip': { key: 'type', value: 'shorts' },
|
||||
'clips': { key: 'type', value: 'shorts' },
|
||||
'chaine': { key: 'type', value: 'channel' },
|
||||
'chaîne': { key: 'type', value: 'channel' },
|
||||
'chaines': { key: 'type', value: 'channel' },
|
||||
'chaînes': { key: 'type', value: 'channel' },
|
||||
'channel': { key: 'type', value: 'channel' },
|
||||
'channels': { key: 'type', value: 'channel' },
|
||||
};
|
||||
|
||||
/** Jeton = mot (lettres accentuées autorisées) suivi de « : » en fin d'espace/mot. */
|
||||
const TOKEN_RE = /(^|\s)([a-zA-Zà-ÿ]{2,12})\s*:(?=\s|$)/g;
|
||||
|
||||
export interface ParsedQuery {
|
||||
/** Requête nettoyée des jetons d'opérateur reconnus. */
|
||||
query: string;
|
||||
/** Jeton inconnu rencontré (l'utilisateur a peut-être fait une faute). */
|
||||
unknownTokens: string[];
|
||||
/** Filtres déduits des jetons. */
|
||||
filters: Partial<SearchFilters>;
|
||||
/** Jeton le plus recently aperçu (pour l'auto-complétion `:`). */
|
||||
activeToken: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les opérateurs de la requête (`live:`, `today:`, `long:`, …) et les
|
||||
* convertit en filtres. Le dernier jeton rencontré gagne, la requête est
|
||||
* renvoyée nettoyée.
|
||||
*/
|
||||
export function parseQueryTokens(input: string): ParsedQuery {
|
||||
const source = String(input ?? '');
|
||||
const filters: Partial<SearchFilters> = {};
|
||||
const unknownTokens: string[] = [];
|
||||
let activeToken: string | null = null;
|
||||
|
||||
// 1) Jeton en cours de frappe (pas encore suivi de « : ») → pas d'extraction,
|
||||
// on garde la requête telle quelle.
|
||||
const trailing = /(^|\s)([a-zA-Zà-ÿ]{1,12})$/.exec(source);
|
||||
if (trailing) activeToken = trailing[2].toLowerCase();
|
||||
|
||||
const cleaned = source.replace(TOKEN_RE, (whole, lead: string, word: string) => {
|
||||
const alias = TOKEN_ALIASES[String(word).toLowerCase()];
|
||||
if (!alias) {
|
||||
unknownTokens.push(String(word).toLowerCase());
|
||||
return whole;
|
||||
}
|
||||
(filters as any)[alias.key] = alias.value;
|
||||
return lead ? ' ' : '';
|
||||
}).replace(/\s+/g, ' ').trim();
|
||||
|
||||
return { query: cleaned, unknownTokens, filters, activeToken };
|
||||
}
|
||||
|
||||
/** Suggestions de jetons d'opérateur (autocomplétion après « mot : »). */
|
||||
export function operatorTokenSuggestions(partial: string, limit = 8): string[] {
|
||||
const p = String(partial ?? '').trim().toLowerCase();
|
||||
const names = Object.keys(TOKEN_ALIASES);
|
||||
const filtered = p ? names.filter(n => n.startsWith(p)) : names;
|
||||
// Un seul alias par couple (clé,valeur) pour éviter 3 variantes de « short ».
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const n of filtered) {
|
||||
const sig = `${TOKEN_ALIASES[n].key}:${TOKEN_ALIASES[n].value}`;
|
||||
if (seen.has(sig)) continue;
|
||||
seen.add(sig);
|
||||
out.push(n);
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prédicat client (réfraction des résultats déjà filtrés par le serveur)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function itemDurationSec(item: any): number {
|
||||
const raw = item?.durationSec ?? item?.duration ?? item?.length ?? 0;
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0;
|
||||
if (typeof raw === 'string') {
|
||||
// "1:23:45" / "12:34"
|
||||
if (raw.includes(':')) {
|
||||
const parts = raw.split(':').map(p => Number(p));
|
||||
if (parts.every(p => Number.isFinite(p))) {
|
||||
const sec = parts.reduce((acc, p) => acc * 60 + p, 0);
|
||||
return sec;
|
||||
}
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function itemPublishedTs(item: any): number {
|
||||
const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate ?? item?.published_at ?? item?.uploaded ?? null;
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0;
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isLiveItem(item: any): boolean {
|
||||
if (!item) return false;
|
||||
if (item.isLive === true) return true;
|
||||
return String(item.type || '').toLowerCase() === 'live';
|
||||
}
|
||||
|
||||
export function isShortItem(item: any): boolean {
|
||||
if (!item) return false;
|
||||
return isShortVideo({
|
||||
duration: itemDurationSec(item),
|
||||
type: typeof item.type === 'string' ? item.type : undefined,
|
||||
provider: typeof item.provider === 'string' ? item.provider : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function isChannelItem(item: any): boolean {
|
||||
return String(item?.type || '').toLowerCase() === 'channel' && item?.isLive !== true;
|
||||
}
|
||||
|
||||
export function isClipItem(item: any): boolean {
|
||||
return String(item?.kind || '').toLowerCase() === 'clip';
|
||||
}
|
||||
|
||||
/**
|
||||
* Le provider expose-t-il un signal de type exploitable ? Si non, on ne peut
|
||||
* pas conclure : mieux vaut garder le résultat (le serveur a déjà filtré) que
|
||||
* de vider la liste.
|
||||
*/
|
||||
function hasTypeSignal(item: any): boolean {
|
||||
if (!item) return false;
|
||||
if (item.isLive === true || item.isLive === false) return true;
|
||||
if (item.isShort === true || item.isShort === false) return true;
|
||||
if (String(item.type || '').trim()) return true;
|
||||
if (String(item.kind || '').trim()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prédicat de réfinement client. Règle importante : un champ *absent* ne fait
|
||||
* jamais disparaître un résultat (les providers ne renvoient pas tous les
|
||||
* métadonnées) ; seule une valeur présente et hors borne est écartée.
|
||||
*/
|
||||
export function matchesFilters(item: any, filters: Partial<SearchFilters> | null | undefined): boolean {
|
||||
const n = normalizeFilters(filters);
|
||||
if (n.type === 'all' && n.duration === 'all' && n.period === 'all') return true;
|
||||
|
||||
const bounds = durationBounds(n.duration);
|
||||
if (bounds) {
|
||||
const d = itemDurationSec(item);
|
||||
if (d > 0 && (d < bounds.min || d >= bounds.max)) return false;
|
||||
}
|
||||
|
||||
const maxAge = periodMs(n.period);
|
||||
if (maxAge > 0) {
|
||||
const ts = itemPublishedTs(item);
|
||||
if (ts > 0 && (Date.now() - ts) > maxAge) return false;
|
||||
}
|
||||
|
||||
if (n.type === 'all') return true;
|
||||
if (!hasTypeSignal(item)) return true; // provider muet : on ne peut pas exclure
|
||||
if (n.type === 'live') return isLiveItem(item);
|
||||
if (n.type === 'channel') return isChannelItem(item);
|
||||
if (n.type === 'shorts') return isShortItem(item) || isClipItem(item);
|
||||
if (n.type === 'video') {
|
||||
return !isLiveItem(item) && !isChannelItem(item) && !isClipItem(item) && !isShortItem(item);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export type ProviderId = 'yt'|'dm'|'tw'|'pt'|'od'|'ru';
|
||||
import type { SearchFilters } from './filters';
|
||||
|
||||
export interface SearchItem {
|
||||
id: string;
|
||||
@@ -26,9 +27,8 @@ export interface ProviderSearchParams {
|
||||
q: string;
|
||||
pageToken?: string; // or page
|
||||
sort?: 'relevance'|'date'|'views'|'duration';
|
||||
time?: 'today'|'7d'|'30d'|'all';
|
||||
length?: 'short'|'medium'|'long'|'all';
|
||||
type?: 'video'|'live'|'shorts'|'short'|'recorded'|'channel'|'all';
|
||||
/** Filtres utilisateur (type de contenu, durée, période). Vides = aucun filtre. */
|
||||
filters?: Partial<SearchFilters>;
|
||||
}
|
||||
|
||||
export interface ProviderAdapter<T = SearchItem> {
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import 'zone.js/node';
|
||||
import '@angular/compiler';
|
||||
// Les schedulers internes d'Angular sont exportés sous leur nom ɵ (le nom
|
||||
// public `ChangeDetectionScheduler` n'existe pas dans le barrel ESM : l'import
|
||||
// échouait au link sur les runtimes ESM stricts).
|
||||
import { Injector, runInInjectionContext, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵEffectScheduler as EffectScheduler } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Step 11 — Unit tests for the unified search UX components.
|
||||
* Step 11 - Unit tests for the unified search UX components.
|
||||
*
|
||||
* ts-node compiles with isolated transpilation (no decorator output => no ɵfac),
|
||||
* ts-node compiles with isolated transpilation (no decorator output => no ?fac),
|
||||
* so TestBed cannot instantiate Angular classes here. We import dynamically and
|
||||
* construct components inside a minimal injection context with fakes.
|
||||
*
|
||||
* Coverage added with the filter panel rework:
|
||||
* - keyboard navigation of the suggestion panel (↑ ↓ Home End PageUp/Dn Tab
|
||||
* Enter Escape, wrapping) and the '@' popover,
|
||||
* - focus-out closes the panels (blur bookkeeping),
|
||||
* - the filter panel (draft, roving tabindex, apply payload),
|
||||
* - operator tokens (`live:`, `today:`, …) folded into filters on submit.
|
||||
*/
|
||||
|
||||
function fmtError(e: unknown): string {
|
||||
@@ -37,15 +47,43 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Faux KeyboardEvent : le composant n'appelle que preventDefault/stopPropagation. */
|
||||
function key(k: string, opts: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean } = {}): any {
|
||||
let defaultPrevented = false;
|
||||
let propagationStopped = false;
|
||||
return {
|
||||
key: k,
|
||||
altKey: !!opts.altKey,
|
||||
ctrlKey: !!opts.ctrlKey,
|
||||
metaKey: !!opts.metaKey,
|
||||
shiftKey: !!opts.shiftKey,
|
||||
preventDefault: () => { defaultPrevented = true; },
|
||||
stopPropagation: () => { propagationStopped = true; },
|
||||
stopImmediatePropagation: () => { propagationStopped = true; },
|
||||
get isDefaultPrevented() { return defaultPrevented; },
|
||||
get isPropagationStopped() { return propagationStopped; },
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const [{ SearchBoxComponent }, { ProviderPickerComponent }, { SearchService }, { UserService }, { HistoryService }, { TelemetryService }, { PROVIDERS }] = await Promise.all([
|
||||
const [
|
||||
{ SearchBoxComponent },
|
||||
{ SearchFilterPanelComponent },
|
||||
{ SearchService },
|
||||
{ UserService },
|
||||
{ HistoryService },
|
||||
{ TelemetryService },
|
||||
{ PROVIDERS },
|
||||
{ normalizeFilters, parseQueryTokens, operatorTokenSuggestions },
|
||||
] = await Promise.all([
|
||||
import('../../components/search/search-box.component'),
|
||||
import('../../components/search/provider-picker.component'),
|
||||
import('../../components/search/search-filter-panel.component'),
|
||||
import('./search.service'),
|
||||
import('../../services/user.service'),
|
||||
import('../../services/history.service'),
|
||||
import('../../services/telemetry.service'),
|
||||
import('../core/providers/provider-registry'),
|
||||
import('./filters'),
|
||||
]);
|
||||
|
||||
// --- Fakes for SearchBox dependencies ---
|
||||
@@ -53,6 +91,7 @@ function sleep(ms: number): Promise<void> {
|
||||
const makeSearchServiceFake = (initialProviders: any = 'all') => ({
|
||||
q$: { value: '' },
|
||||
providers$: { value: initialProviders },
|
||||
filters$: { value: normalizeFilters(null) },
|
||||
});
|
||||
|
||||
const makeUsersServiceFake = (prefs: any = null) => ({
|
||||
@@ -139,59 +178,332 @@ function sleep(ms: number): Promise<void> {
|
||||
const box = newBox();
|
||||
|
||||
const yt = PROVIDERS[0].id;
|
||||
const ev = {
|
||||
key: '1',
|
||||
altKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: () => {},
|
||||
stopImmediatePropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
const ev = key('1', { altKey: true });
|
||||
box.handleKeydown(ev);
|
||||
assertEqual(box.selected(), [yt], 'Alt+1 toggles the first chip (yt) on');
|
||||
box.handleKeydown(ev);
|
||||
box.handleKeydown(key('1', { altKey: true }));
|
||||
assertEqual(box.selected(), [], 'Alt+1 again toggles the chip off');
|
||||
}
|
||||
|
||||
// --- ProviderPicker toggles ---
|
||||
// --- SearchBox: panneau de suggestions au clavier ---
|
||||
|
||||
function testPickerDraftSelection(): void {
|
||||
const picker = new ProviderPickerComponent();
|
||||
picker.selected = 'all';
|
||||
assert(picker.isChecked('yt'), 'All mode checks every provider');
|
||||
/** Force l'ouverture du panneau avec `n` suggestions locales. */
|
||||
function openSuggestions(box: any, query: string, count = 3): void {
|
||||
box.queryUpdate(query);
|
||||
// Le panneau s'ouvre quand `suggestFlat()` n'est pas vide : on l'ouvre à la
|
||||
// main (le réseau est absent dans les tests) puis on vérifie le clavier.
|
||||
box.suggestionsOpen.set(true);
|
||||
void count;
|
||||
}
|
||||
|
||||
picker.toggle('yt');
|
||||
function testSuggestionKeyboardNavigation(): void {
|
||||
const box = newBox();
|
||||
openSuggestions(box, 'lin', 3);
|
||||
const total = box.suggestFlat().length + 1; // +1 = ligne « Rechercher … »
|
||||
assert(total > 1, 'suggestion panel has rows');
|
||||
|
||||
// Rien de sélectionné au départ : ↓ entre par le haut.
|
||||
assertEqual(box.suggestIndex(), -1, 'no row is highlighted on open');
|
||||
box.handleKeydown(key('ArrowDown'));
|
||||
assertEqual(box.suggestIndex(), 0, 'ArrowDown highlights the first row (search as typed)');
|
||||
box.handleKeydown(key('ArrowDown'));
|
||||
assertEqual(box.suggestIndex(), 1, 'ArrowDown moves to the next row');
|
||||
box.handleKeydown(key('ArrowUp'));
|
||||
assertEqual(box.suggestIndex(), 0, 'ArrowUp goes back');
|
||||
// Home / End
|
||||
box.handleKeydown(key('End'));
|
||||
assertEqual(box.suggestIndex(), total - 1, 'End jumps to the last row');
|
||||
box.handleKeydown(key('Home'));
|
||||
assertEqual(box.suggestIndex(), 0, 'Home jumps to the first row');
|
||||
// PageDown / PageUp
|
||||
box.handleKeydown(key('PageDown'));
|
||||
assert(box.suggestIndex() > 0, 'PageDown moves forward');
|
||||
box.handleKeydown(key('PageUp'));
|
||||
assertEqual(box.suggestIndex(), 0, 'PageUp moves back');
|
||||
}
|
||||
|
||||
function testSuggestionKeyboardWrapsAndEscape(): void {
|
||||
const box = newBox();
|
||||
openSuggestions(box, 'lin', 3);
|
||||
const total = box.suggestFlat().length + 1;
|
||||
|
||||
// Wrapping : on remonte de la première ligne -> dernière ligne
|
||||
box.handleKeydown(key('ArrowUp'));
|
||||
assertEqual(box.suggestIndex(), total - 1, 'ArrowUp from the top wraps to the last row');
|
||||
box.handleKeydown(key('ArrowDown'));
|
||||
assertEqual(box.suggestIndex(), 0, 'ArrowDown from the bottom wraps to the first row');
|
||||
|
||||
// Échap ferme le panneau sans vider la saisie
|
||||
const ev = key('Escape');
|
||||
box.handleKeydown(ev);
|
||||
assert(box.suggestionsOpen() === false, 'Escape closes the suggestion panel');
|
||||
assertEqual(box.query(), 'lin', 'Escape keeps the typed text');
|
||||
assert(ev.isDefaultPrevented === true, 'Escape preventDefault (no browser default)');
|
||||
}
|
||||
|
||||
function testSuggestionEnterSubmitsRow(): void {
|
||||
const box = newBox();
|
||||
openSuggestions(box, 'lin', 2);
|
||||
const submitted: any[] = [];
|
||||
box.submitted.subscribe((v: any) => submitted.push(v));
|
||||
|
||||
// Entrée sans surbrillance = recherche brute
|
||||
box.handleKeydown(key('Enter'));
|
||||
assertEqual(submitted.length, 1, 'Enter without highlight runs the typed search');
|
||||
assertEqual(submitted[0].q, 'lin', 'the raw query is submitted');
|
||||
assert(box.suggestionsOpen() === false, 'the panel closes on submit');
|
||||
|
||||
// Entrée sur la ligne 0 = « Rechercher … »
|
||||
openSuggestions(box, 'lin', 2);
|
||||
box.suggestIndex.set(0);
|
||||
box.handleKeydown(key('Enter'));
|
||||
assertEqual(submitted.length, 2, 'Enter on row 0 re-runs the typed search');
|
||||
assertEqual(submitted[1].q, 'lin', 'row 0 submits the query as typed');
|
||||
|
||||
// Entrée sur une suggestion = cette suggestion
|
||||
openSuggestions(box, 'lin', 2);
|
||||
box.suggestIndex.set(1);
|
||||
box.handleKeydown(key('Enter'));
|
||||
assertEqual(submitted.length, 3, 'Enter on a suggestion submits it');
|
||||
assertEqual(submitted[2].q, box.query(), 'the input holds the picked suggestion');
|
||||
}
|
||||
|
||||
function testSuggestionTabCompletes(): void {
|
||||
const box = newBox();
|
||||
openSuggestions(box, 'lin', 3);
|
||||
const submitted: any[] = [];
|
||||
box.submitted.subscribe((v: any) => submitted.push(v));
|
||||
const ev = key('Tab');
|
||||
box.handleKeydown(ev);
|
||||
assert(ev.isDefaultPrevented === true, 'Tab is intercepted while the panel is open');
|
||||
assertEqual(submitted.length, 0, 'Tab completes without searching');
|
||||
assert(box.suggestionsOpen() === true, 'the panel stays open after a Tab completion');
|
||||
}
|
||||
|
||||
function testEscapeClearsQueryWhenNothingOpen(): void {
|
||||
const box = newBox();
|
||||
box.queryUpdate('linux');
|
||||
box.handleKeydown(key('Escape'));
|
||||
assertEqual(box.query(), '', 'Escape with no panel open clears the query');
|
||||
}
|
||||
|
||||
// --- SearchBox: focus (le panneau se ferme quand le focus sort) ---
|
||||
|
||||
async function testFocusOutClosesPanels(): Promise<void> {
|
||||
const box = newBox();
|
||||
openSuggestions(box, 'lin', 3);
|
||||
assert(box.suggestionsOpen() === true, 'panel is open before the focus leaves');
|
||||
|
||||
// relatedTarget hors du formulaire -> fermeture
|
||||
const form = { contains: (el: any) => el === inside };
|
||||
const inside = { id: 'inside' };
|
||||
box.formRef = { nativeElement: form } as any;
|
||||
const ev: any = { relatedTarget: { id: 'elsewhere' } };
|
||||
box.onFocusOut(ev);
|
||||
await sleep(20);
|
||||
assert(box.suggestionsOpen() === false, 'focusout with an outside target closes the panel');
|
||||
}
|
||||
|
||||
async function testFocusOutKeepsPanelInside(): Promise<void> {
|
||||
const box = newBox();
|
||||
openSuggestions(box, 'lin', 3);
|
||||
const inside = { id: 'inside' };
|
||||
box.formRef = { nativeElement: { contains: (el: any) => el === inside } } as any;
|
||||
box.onFocusOut({ relatedTarget: inside } as any);
|
||||
await sleep(20);
|
||||
assert(box.suggestionsOpen() === true, 'focusout toward a node inside the form keeps the panel open');
|
||||
}
|
||||
|
||||
async function testFocusInCancelsPendingClose(): Promise<void> {
|
||||
const box = newBox();
|
||||
openSuggestions(box, 'lin', 3);
|
||||
const inside = { id: 'inside' };
|
||||
box.formRef = { nativeElement: { contains: () => false } } as any;
|
||||
box.onFocusOut({ relatedTarget: null } as any); // blur « sale » -> timer armé
|
||||
box.onFocusIn({} as any); // le focus revient
|
||||
await sleep(20);
|
||||
assert(box.suggestionsOpen() === true, 'focusin cancels the pending close (no flicker)');
|
||||
}
|
||||
|
||||
function testPointerDownKeepsFocusInInput(): void {
|
||||
const box = newBox();
|
||||
const ev: any = { preventDefault: () => { ev.defaultPrevented = true; } };
|
||||
box.onPanelPointerDown(ev);
|
||||
assert(ev.defaultPrevented === true, 'pointerdown inside a panel is neutralized (input keeps focus)');
|
||||
}
|
||||
|
||||
// --- SearchBox: opérateurs et filtres ---
|
||||
|
||||
function testOperatorTokensFoldIntoFilters(): void {
|
||||
const parsed = parseQueryTokens('linux today: long: live:');
|
||||
assertEqual(parsed.query, 'linux', 'operators are stripped from the query');
|
||||
assertEqual(parsed.filters, { period: 'today', duration: 'long', type: 'live' }, 'operators map to filters');
|
||||
// Un mot inconnu reste dans la requête (pas de typo destructive)
|
||||
const unknown = parseQueryTokens('tuto android:');
|
||||
assertEqual(unknown.query, 'tuto android:', 'unknown token stays in the query');
|
||||
assertEqual(unknown.unknownTokens, ['android'], 'unknown token is reported');
|
||||
// Une URL / heure ne doit pas être interprétée
|
||||
assertEqual(parseQueryTokens('visite https://x.com').query, 'visite https://x.com', 'URLs are untouched');
|
||||
assert(operatorTokenSuggestions('li').includes('live'), 'operator autocompletion offers live:');
|
||||
}
|
||||
|
||||
function testSubmitAppliesOperatorTokens(): void {
|
||||
const box = newBox();
|
||||
const submitted: any[] = [];
|
||||
box.submitted.subscribe((v: any) => submitted.push(v));
|
||||
box.queryUpdate('concert live:');
|
||||
box.onSubmit({ preventDefault: () => {} } as any);
|
||||
assertEqual(submitted.length, 1, 'search is submitted');
|
||||
assertEqual(submitted[0].q, 'concert', 'the operator is not part of the query');
|
||||
assertEqual(submitted[0].filters, normalizeFilters({ type: 'live' }), 'the operator became a filter');
|
||||
assertEqual(box.activeFilterCount(), 1, 'one filter is active');
|
||||
}
|
||||
|
||||
function testActiveFilterChipsAndRemoval(): void {
|
||||
const box = newBox();
|
||||
box.queryUpdate('chat');
|
||||
box.applyPanel({
|
||||
selection: 'all',
|
||||
filters: normalizeFilters({ type: 'live', duration: 'short', period: 'week' }),
|
||||
rememberDefault: false,
|
||||
});
|
||||
assertEqual(box.activeFilterCount(), 3, 'three filters are active');
|
||||
assertEqual(box.activeFilterChips().map((c: any) => c.key), ['type', 'period', 'duration'], 'chips list every active filter');
|
||||
box.removeFilter('duration');
|
||||
assertEqual(box.activeFilterCount(), 2, 'removing a chip updates the counter');
|
||||
box.clearAllFilters();
|
||||
assertEqual(box.activeFilterCount(), 0, 'clear-all resets every filter');
|
||||
assertEqual(box.activeFilterChips().length, 0, 'no chip left');
|
||||
}
|
||||
|
||||
function testApplyPanelPersistsProvidersAndFilters(): void {
|
||||
const box = newBox();
|
||||
const submitted: any[] = [];
|
||||
box.submitted.subscribe((v: any) => submitted.push(v));
|
||||
box.queryUpdate('rust');
|
||||
box.applyPanel({
|
||||
selection: ['yt', 'tw'],
|
||||
filters: normalizeFilters({ period: 'month' }),
|
||||
rememberDefault: false,
|
||||
});
|
||||
assertEqual(box.providersForRequest(), ['yt', 'tw'], 'sources are applied');
|
||||
assertEqual(box.filters().period, 'month', 'filters are applied');
|
||||
assert(box.panelOpen() === false, 'the panel closes after apply');
|
||||
assertEqual(submitted.length, 1, 'an in-flight search is re-run with the new filters');
|
||||
assertEqual(submitted[0].filters.period, 'month', 'the submitted payload carries the filters');
|
||||
}
|
||||
|
||||
function testPanelDoesNotRelaunchSearchWithoutQuery(): void {
|
||||
const box = newBox();
|
||||
const submitted: any[] = [];
|
||||
box.submitted.subscribe((v: any) => submitted.push(v));
|
||||
box.applyPanel({
|
||||
selection: 'all',
|
||||
filters: normalizeFilters({ type: 'shorts' }),
|
||||
rememberDefault: false,
|
||||
});
|
||||
assertEqual(submitted.length, 0, 'no search is launched while the query is empty');
|
||||
assertEqual(box.filters().type, 'shorts', 'filters are still remembered');
|
||||
}
|
||||
|
||||
// --- Panneau de filtres ---
|
||||
|
||||
function newPanel(providers: any = 'all', filters: any = null, history: string[] = []): any {
|
||||
const panel = new SearchFilterPanelComponent();
|
||||
panel.open = true;
|
||||
panel.history = history;
|
||||
if (providers !== undefined) panel.selected = providers;
|
||||
if (filters !== undefined) panel.filters = filters;
|
||||
return panel;
|
||||
}
|
||||
|
||||
function testPanelDraftSelection(): void {
|
||||
const panel = newPanel('all');
|
||||
assert(panel.isChecked('yt'), 'All mode checks every provider');
|
||||
|
||||
panel.toggle('yt');
|
||||
const yt = 'yt';
|
||||
assert(picker.isChecked(yt) === false, 'unchecking from All removes only that provider');
|
||||
assert(picker.isChecked('dm'), 'other providers stay checked after unchecking one');
|
||||
assertEqual(picker.allMode(), false, 'All mode turns off after unchecking');
|
||||
assert(panel.isChecked(yt) === false, 'unchecking from All removes only that provider');
|
||||
assert(panel.isChecked('dm'), 'other providers stay checked after unchecking one');
|
||||
assertEqual(panel.allMode(), false, 'All mode turns off after unchecking');
|
||||
|
||||
picker.toggle(yt);
|
||||
assert(picker.isChecked(yt), 're-checking adds the provider back');
|
||||
panel.toggle(yt);
|
||||
assert(panel.isChecked(yt), 're-checking adds the provider back');
|
||||
|
||||
let emitted: any = null;
|
||||
picker.apply.subscribe((v: any) => { emitted = v; });
|
||||
picker.onApply();
|
||||
panel.apply.subscribe((v: any) => { emitted = v; });
|
||||
panel.onApply();
|
||||
assert(Array.isArray(emitted?.selection), 'apply emits a list selection');
|
||||
assertEqual(emitted.selection.length, PROVIDERS.length, 'apply emits every provider when all are checked');
|
||||
assertEqual(emitted.filters, normalizeFilters(null), 'apply emits normalized (default) filters');
|
||||
|
||||
picker.toggleAllMode();
|
||||
picker.onApply();
|
||||
panel.toggleAllMode();
|
||||
panel.onApply();
|
||||
assertEqual(emitted.selection, 'all', 'apply emits all after toggleAllMode');
|
||||
assert(emitted.rememberDefault === false, 'rememberDefault defaults to false');
|
||||
}
|
||||
|
||||
function testPickerRememberDefault(): void {
|
||||
const picker = new ProviderPickerComponent();
|
||||
picker.selected = ['yt'];
|
||||
picker.rememberDefault.set(true);
|
||||
function testPanelFiltersAndRememberDefault(): void {
|
||||
const panel = newPanel(['yt'], null);
|
||||
panel.rememberDefault.set(true);
|
||||
let emitted: any = null;
|
||||
picker.apply.subscribe((v: any) => { emitted = v; });
|
||||
picker.toggle('dm');
|
||||
picker.onApply();
|
||||
panel.apply.subscribe((v: any) => { emitted = v; });
|
||||
panel.toggle('dm');
|
||||
panel.selectFilter('type', 'live');
|
||||
panel.selectFilter('period', 'week');
|
||||
panel.selectFilter('duration', 'short');
|
||||
panel.selectFilter('sort', 'date');
|
||||
panel.onApply();
|
||||
assertEqual(emitted.selection, ['yt', 'dm'], 'draft selection is emitted on apply');
|
||||
assert(emitted.rememberDefault === true, 'rememberDefault flag is emitted');
|
||||
assertEqual(emitted.filters, { type: 'live', duration: 'short', period: 'week', sort: 'date' }, 'every filter dimension is emitted');
|
||||
}
|
||||
|
||||
function testPanelRovingTabindexFollowsSelection(): void {
|
||||
const panel = newPanel('all', normalizeFilters({ type: 'live', period: 'month' }));
|
||||
// L'onglet actif d'un groupe démarre sur l'option sélectionnée.
|
||||
assertEqual(panel.activeIndex('type'), panel.optionIndexOf(panel.groups()[0], 'live'), 'type group starts on the selected option');
|
||||
assertEqual(panel.activeIndex('period'), panel.optionIndexOf(panel.groups()[1], 'month'), 'period group starts on the selected option');
|
||||
// Une sélection déplace l'onglet actif.
|
||||
panel.selectFilter('type', 'shorts');
|
||||
assertEqual(panel.activeIndex('type'), panel.optionIndexOf(panel.groups()[0], 'shorts'), 'selecting moves the roving tabindex');
|
||||
}
|
||||
|
||||
function testPanelKeyboardRovingAndEscape(): void {
|
||||
const panel = newPanel('all');
|
||||
const focused: any[] = [];
|
||||
// Sans DOM, `focusTab` ne trouve rien : on vérifie l'index mis à jour.
|
||||
(panel as any).dialogRef = { nativeElement: { querySelector: () => null, focus: () => {} } };
|
||||
|
||||
const target: any = { closest: () => ({ getAttribute: () => 'type' }) };
|
||||
const ev: any = key('ArrowRight');
|
||||
(ev as any).target = target;
|
||||
panel.onDialogKeydown(ev);
|
||||
assert(ev.isDefaultPrevented === true, 'ArrowRight is handled (no page scroll)');
|
||||
assertEqual(panel.activeIndex('type'), 1, 'ArrowRight moves to the next option of the group');
|
||||
assertEqual(focused.length, 0, 'no crash without a real DOM');
|
||||
void focused;
|
||||
|
||||
// Début / fin de groupe
|
||||
(panel as any).dialogRef = { nativeElement: { querySelector: () => null, focus: () => {} } };
|
||||
const end: any = key('End');
|
||||
(end as any).target = target;
|
||||
panel.onDialogKeydown(end);
|
||||
assert(panel.activeIndex('type') > 1, 'End jumps to the last option');
|
||||
|
||||
// Échap ferme
|
||||
let closed = 0;
|
||||
panel.close.subscribe(() => { closed++; });
|
||||
panel.onDialogKeydown(key('Escape'));
|
||||
assertEqual(closed, 1, 'Escape closes the panel');
|
||||
}
|
||||
|
||||
function testPanelReset(): void {
|
||||
const panel = newPanel(['yt'], normalizeFilters({ type: 'live' }));
|
||||
panel.toggle('dm');
|
||||
panel.reset();
|
||||
assertEqual(panel.allMode(), true, 'reset returns to All sources');
|
||||
assertEqual(panel.draftFilters(), normalizeFilters(null), 'reset clears every filter');
|
||||
}
|
||||
|
||||
const tests: Array<[string, () => void | Promise<void>]> = [
|
||||
@@ -199,18 +511,35 @@ function sleep(ms: number): Promise<void> {
|
||||
['SearchBox applyAtOption strips term and toggles chip', testApplyAtOptionStripsTermAndTogglesChip],
|
||||
['SearchBox applies defaultProviders preference', testDefaultProvidersPreferenceApplied],
|
||||
['SearchBox Alt+1..6 shortcut toggles chips', testAltShortcutsToggleChips],
|
||||
['ProviderPicker draft selection + apply', testPickerDraftSelection],
|
||||
['ProviderPicker rememberDefault flag', testPickerRememberDefault],
|
||||
['SearchBox suggestion panel: ↑ ↓ Home End PageUp/Down', testSuggestionKeyboardNavigation],
|
||||
['SearchBox suggestion panel: wrapping + Escape', testSuggestionKeyboardWrapsAndEscape],
|
||||
['SearchBox suggestion panel: Enter validates the highlighted row', testSuggestionEnterSubmitsRow],
|
||||
['SearchBox suggestion panel: Tab completes without searching', testSuggestionTabCompletes],
|
||||
['SearchBox Escape clears the query when nothing is open', testEscapeClearsQueryWhenNothingOpen],
|
||||
['SearchBox focusout closes the panel when focus leaves', testFocusOutClosesPanels],
|
||||
['SearchBox focusout keeps the panel when focus stays inside', testFocusOutKeepsPanelInside],
|
||||
['SearchBox focusin cancels the pending close', testFocusInCancelsPendingClose],
|
||||
['SearchBox pointerdown inside a panel does not steal focus', testPointerDownKeepsFocusInInput],
|
||||
['SearchBox operator tokens (live:, today:, long:)', testOperatorTokensFoldIntoFilters],
|
||||
['SearchBox submit turns operators into filters', testSubmitAppliesOperatorTokens],
|
||||
['SearchBox active filter chips + removal', testActiveFilterChipsAndRemoval],
|
||||
['SearchBox applyPanel persists sources/filters and re-runs', testApplyPanelPersistsProvidersAndFilters],
|
||||
['SearchBox applyPanel without query does not search', testPanelDoesNotRelaunchSearchWithoutQuery],
|
||||
['FilterPanel draft selection + apply', testPanelDraftSelection],
|
||||
['FilterPanel filters + rememberDefault', testPanelFiltersAndRememberDefault],
|
||||
['FilterPanel roving tabindex follows the selection', testPanelRovingTabindexFollowsSelection],
|
||||
['FilterPanel arrow keys + Escape', testPanelKeyboardRovingAndEscape],
|
||||
['FilterPanel reset', testPanelReset],
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
for (const [name, fn] of tests) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`✓ ${name}`);
|
||||
console.log(`V ${name}`);
|
||||
} catch (error) {
|
||||
failures += 1;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(`? ${name}`);
|
||||
console.error(error instanceof Error ? error.stack : fmtError(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OdAdapter } from './adapters/od';
|
||||
import { RuAdapter } from './adapters/ru';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { DEFAULT_FILTERS, activeFilters, normalizeFilters, type SearchFilters } from './filters';
|
||||
|
||||
export type SuggestionItem = SuggestionItemV1;
|
||||
export type SearchResponse = SearchResponseV1;
|
||||
@@ -28,6 +29,11 @@ export class SearchService {
|
||||
readonly page$ = new BehaviorSubject<number>(1);
|
||||
readonly pageSize$ = new BehaviorSubject<number>(24);
|
||||
readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance');
|
||||
/**
|
||||
* Filtres de recherche (type / durée / période / tri). Source de vérité des
|
||||
* filtres UI : le tri y est aussi reflété (`sort$` reste pour compat).
|
||||
*/
|
||||
readonly filters$ = new BehaviorSubject<SearchFilters>({ ...DEFAULT_FILTERS });
|
||||
|
||||
// In-memory cache 60s per (provider, q, params)
|
||||
private cache = new Map<CacheKey, CacheEntry<VideoItem>>();
|
||||
@@ -43,7 +49,7 @@ export class SearchService {
|
||||
ru: new RuAdapter(this.http)
|
||||
};
|
||||
|
||||
readonly params$ = combineLatest([this.q$, this.providers$, this.page$, this.pageSize$, this.sort$]).pipe(
|
||||
readonly params$ = combineLatest([this.q$, this.providers$, this.page$, this.pageSize$, this.sort$, this.filters$]).pipe(
|
||||
debounceTime(120),
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
|
||||
);
|
||||
@@ -51,11 +57,13 @@ export class SearchService {
|
||||
// Public request stream: aggregates groups by provider, runs adapters in parallel with timeout/abort/retry
|
||||
readonly request$ = this.params$.pipe(
|
||||
filter(([q]) => typeof q === 'string' && q.trim().length >= 2),
|
||||
switchMap(([q, prov, page, pageSize, sort]) => from(this.runAdapters({
|
||||
switchMap(([q, prov, page, pageSize, sort, filters]) => from(this.runAdapters({
|
||||
q: String(q),
|
||||
pageToken: String(page || 1),
|
||||
sort: (sort as any) || 'relevance',
|
||||
// global filter knobs could be added here: time/length/type in the future
|
||||
// Filtres globaux (type / durée / période) : envoyés à chaque adapter,
|
||||
// appliqués nativement par le provider quand c'est possible.
|
||||
filters: normalizeFilters(filters),
|
||||
}, prov)))
|
||||
,
|
||||
// Map provider results to the format expected by the UI
|
||||
@@ -88,7 +96,7 @@ export class SearchService {
|
||||
const tasks = active.map(async (pid) => {
|
||||
const adapter = this.adapters[pid];
|
||||
if (!adapter) return [pid, { items: [] } as SearchResult<VideoItem>] as const;
|
||||
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}`;
|
||||
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}|${this.filterKey(params.filters)}`;
|
||||
const now = Date.now();
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && (now - cached.t) < this.cacheTtlMs) {
|
||||
@@ -127,6 +135,26 @@ export class SearchService {
|
||||
setPageSize(size: number) { this.pageSize$.next(Math.min(50, Math.max(1, Math.floor(size || 24)))); }
|
||||
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') {
|
||||
const allowed: Array<'relevance' | 'date' | 'views' | 'duration'> = ['relevance', 'date', 'views', 'duration'];
|
||||
this.sort$.next(allowed.includes(sort) ? sort : 'relevance');
|
||||
const next = allowed.includes(sort) ? sort : 'relevance';
|
||||
this.sort$.next(next);
|
||||
// Le tri vit aussi dans `filters` (source de vérité des filtres) : on garde
|
||||
// les deux BehaviorSubjects synchronisés sans boucle (next -> next).
|
||||
const current = this.filters$.value;
|
||||
const mapped = next === 'duration' ? 'relevance' : next;
|
||||
if (current.sort !== mapped) this.filters$.next({ ...current, sort: mapped as SearchFilters['sort'] });
|
||||
}
|
||||
/** Applique un patch de filtres (normalisé) et synchronise `sort$`. */
|
||||
setFilters(patch: Partial<SearchFilters> | null | undefined) {
|
||||
const next = normalizeFilters({ ...this.filters$.value, ...(patch || {}) });
|
||||
if (JSON.stringify(next) === JSON.stringify(this.filters$.value)) return;
|
||||
this.filters$.next(next);
|
||||
if (this.sort$.value !== next.sort) this.sort$.next(next.sort as any);
|
||||
}
|
||||
resetFilters() { this.setFilters({ ...DEFAULT_FILTERS }); }
|
||||
private filterKey(filters: Partial<SearchFilters> | null | undefined): string {
|
||||
const n = normalizeFilters(filters);
|
||||
return `${n.type}.${n.duration}.${n.period}.${n.sort}`;
|
||||
}
|
||||
/** Filtres actifs seulement (pour l'URL / le partage). */
|
||||
activeFilters(): Partial<SearchFilters> { return activeFilters(this.filters$.value); }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface VideoItem {
|
||||
// Live status (only providers exposing it, e.g. Twitch, set these)
|
||||
type?: string; // e.g. 'live' | 'channel' | 'video'
|
||||
isLive?: boolean; // true when actually broadcasting now
|
||||
isShort?: boolean; // true when the provider flagged a Short/clip
|
||||
kind?: string; // e.g. Twitch 'vod' | 'clip'
|
||||
game?: string; // e.g. Twitch game/category name
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
<div class="hidden md:flex flex-1 min-w-0 justify-center px-2 lg:px-4">
|
||||
<div class="relative w-full max-w-2xl lg:max-w-4xl">
|
||||
<app-search-box [placeholder]="('search.placeholder' | t)"
|
||||
(submitted)="onSearchBoxSubmit($event)"></app-search-box>
|
||||
(submitted)="onSearchBoxSubmit($event)"
|
||||
(filtersChange)="onSearchFiltersChange($event)"></app-search-box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +133,8 @@
|
||||
[class.hidden]="!mobileSearchOpen()">
|
||||
<div class="relative">
|
||||
<app-search-box [placeholder]="('search.placeholder' | t)"
|
||||
(submitted)="onSearchBoxSubmit($event)"></app-search-box>
|
||||
(submitted)="onSearchBoxSubmit($event)"
|
||||
(filtersChange)="onSearchFiltersChange($event)"></app-search-box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal, Input, Output, EventEmitter, HostListener, ViewChild, ViewChildren, QueryList, ElementRef, DestroyRef, AfterViewInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, NavigationEnd } from '@angular/router';
|
||||
import { Router, RouterLink, NavigationEnd, ActivatedRoute } from '@angular/router';
|
||||
import { InstanceService, Provider } from '../../services/instance.service';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
@@ -11,6 +11,7 @@ import { I18nService } from '../../services/i18n.service';
|
||||
import { ThemesService } from '../../services/themes.service';
|
||||
import { HistoryService, SearchHistoryItem } from '../../services/history.service';
|
||||
import { SearchBoxComponent } from '../search/search-box.component';
|
||||
import { filtersToQueryParams, type SearchFilters } from '../../app/search/filters';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
@@ -28,6 +29,7 @@ import { SearchBoxComponent } from '../search/search-box.component';
|
||||
})
|
||||
export class HeaderComponent implements AfterViewInit {
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
instances = inject(InstanceService);
|
||||
private auth = inject(AuthService);
|
||||
private userService = inject(UserService);
|
||||
@@ -139,8 +141,8 @@ export class HeaderComponent implements AfterViewInit {
|
||||
return items;
|
||||
});
|
||||
|
||||
// New SearchBox submit handler (navigate with providers list)
|
||||
onSearchBoxSubmit(evt: { q: string; providers: ("yt"|"dm"|"tw"|"pt"|"od"|"ru")[] | 'all' }) {
|
||||
// New SearchBox submit handler (navigate with providers list + filters)
|
||||
onSearchBoxSubmit(evt: { q: string; providers: ("yt"|"dm"|"tw"|"pt"|"od"|"ru")[] | 'all'; filters?: SearchFilters }) {
|
||||
const q = (evt?.q || '').trim();
|
||||
if (!q) return;
|
||||
const provider = this.selectedProvider();
|
||||
@@ -152,6 +154,9 @@ export class HeaderComponent implements AfterViewInit {
|
||||
// Explicitly include all providers so SearchComponent uses unified multi-provider mode
|
||||
qp.providers = 'yt,dm,tw,pt,od,ru';
|
||||
}
|
||||
// Filtres (type / durée / période / tri) : seulement les valeurs actives,
|
||||
// pour garder une URL lisible et partageable.
|
||||
Object.assign(qp, filtersToQueryParams(evt?.filters));
|
||||
if (theme) qp.theme = theme;
|
||||
try { console.debug('[Header] onSearchBoxSubmit -> navigate', qp); } catch {}
|
||||
this.suggestionsOpen.set(false);
|
||||
@@ -159,6 +164,25 @@ export class HeaderComponent implements AfterViewInit {
|
||||
this.router.navigate(['/search'], { queryParams: qp });
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtres modifiés sans relancer de recherche : on réécrit l'URL courante
|
||||
* pour que la page /search reste partageable (et se recharge côté serveur).
|
||||
*/
|
||||
onSearchFiltersChange(filters: SearchFilters): void {
|
||||
if (!this.router.url.startsWith('/search')) return;
|
||||
const current: any = { ...this.route.snapshot.queryParams };
|
||||
delete current['type'];
|
||||
delete current['duration'];
|
||||
delete current['period'];
|
||||
delete current['sort'];
|
||||
Object.assign(current, filtersToQueryParams(filters));
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: current,
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mobile chrome actions ----------------------------------------------
|
||||
toggleMobileSearch(): void {
|
||||
const next = !this.mobileSearchOpen();
|
||||
@@ -604,7 +628,8 @@ export class HeaderComponent implements AfterViewInit {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+K focuses search
|
||||
// Ctrl/Cmd+K focuses search. Le panneau de filtres (Ctrl/Cmd+Maj+F) est
|
||||
// entièrement piloté par <app-search-box>.
|
||||
if ((ev.key === 'k' || ev.key === 'K') && (ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault();
|
||||
if (!this.mobileSearchOpen() && typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<div class="fixed inset-0 z-[9999] bg-black/60" (click)="close.emit()">
|
||||
<div class="fixed inset-0 grid place-items-center p-4" [style.paddingBottom]="'max(1rem, var(--safe-bottom))'">
|
||||
<div #dialog
|
||||
class="dialog-scroll flex w-full max-w-lg flex-col overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="providersTitle"
|
||||
(click)="$event.stopPropagation()"
|
||||
tabindex="0"
|
||||
(keydown)="onDialogKeydown($event)">
|
||||
<div class="sticky top-0 z-10 shrink-0 border-b border-slate-700 bg-slate-900 p-4 flex items-center justify-between gap-3">
|
||||
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
|
||||
<button type="button" (click)="close.emit()" class="chip px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer (Esc)">Esc</button>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" aria-label="Filtrer les fournisseurs" class="w-full min-w-0 px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-slate-200 py-1">
|
||||
<input type="checkbox" [checked]="allMode()" (change)="toggleAllMode()"/>
|
||||
<span>All</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label *ngFor="let p of providers()" class="flex items-center gap-2 px-3 py-3 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
||||
<input type="checkbox" class="shrink-0" [checked]="isChecked(p.id)" (change)="toggle(p.id)"/>
|
||||
<span class="text-xs font-semibold shrink-0" [class]="p.colorClass">{{ p.shortLabel }}</span>
|
||||
<span class="min-w-0 truncate text-sm">{{ p.displayName }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="mt-2 inline-flex items-center gap-2 text-slate-300 py-1">
|
||||
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
|
||||
<span>Remember as default</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sticky bottom-0 z-10 shrink-0 border-t border-slate-700 bg-slate-900 p-4 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<button type="button" (click)="close.emit()" class="px-4 py-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
|
||||
<button type="button" (click)="onApply()" class="px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ProviderId, PROVIDERS, PROVIDER_MAP } from '../../app/core/providers/provider-registry';
|
||||
|
||||
@Component({
|
||||
selector: 'app-provider-picker',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './provider-picker.component.html'
|
||||
})
|
||||
export class ProviderPickerComponent implements AfterViewInit, OnDestroy {
|
||||
@Input() open = false;
|
||||
// Incoming selection: 'all' or a list of provider ids
|
||||
@Input() set selected(value: ProviderId[] | 'all') {
|
||||
// Reset the draft each time the picker opens with a new incoming selection
|
||||
this._draft.set(value === 'all' ? [] : Array.isArray(value) ? [...value] : []);
|
||||
this._allMode.set(value === 'all');
|
||||
}
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() apply = new EventEmitter<{ selection: ProviderId[] | 'all'; rememberDefault: boolean }>();
|
||||
|
||||
filter = signal('');
|
||||
rememberDefault = signal(false);
|
||||
|
||||
// Draft state: working copy while the modal is open
|
||||
private _draft = signal<ProviderId[]>([]);
|
||||
private _allMode = signal(true);
|
||||
readonly draft = computed(() => this._draft());
|
||||
readonly allMode = computed(() => this._allMode() && this._draft().length === 0);
|
||||
|
||||
@ViewChild('dialog', { static: false }) dialogRef?: ElementRef<HTMLDivElement>;
|
||||
|
||||
readonly providers = computed(() => {
|
||||
const q = this.filter().toLowerCase();
|
||||
return PROVIDERS.filter(p => !q || p.displayName.toLowerCase().includes(q) || p.shortLabel.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
toggleAllMode() {
|
||||
this._allMode.set(true);
|
||||
this._draft.set([]);
|
||||
}
|
||||
|
||||
isChecked(id: ProviderId): boolean {
|
||||
if (this.allMode()) return true;
|
||||
return this._draft().includes(id);
|
||||
}
|
||||
|
||||
toggle(id: ProviderId) {
|
||||
if (this.allMode()) {
|
||||
// Leaving All mode by unchecking one provider: start from every provider
|
||||
// then remove the unchecked one ("all except this one")
|
||||
const set = new Set(PROVIDERS.map(p => p.id));
|
||||
set.delete(id);
|
||||
this._draft.set(Array.from(set));
|
||||
this._allMode.set(false);
|
||||
return;
|
||||
}
|
||||
const set = new Set(this._draft());
|
||||
if (set.has(id)) {
|
||||
set.delete(id);
|
||||
} else {
|
||||
set.add(id);
|
||||
}
|
||||
this._draft.set(Array.from(set));
|
||||
this._allMode.set(false);
|
||||
}
|
||||
|
||||
onApply() {
|
||||
const selection: ProviderId[] | 'all' = this.allMode() ? 'all' : this._draft();
|
||||
this.apply.emit({ selection, rememberDefault: this.rememberDefault() });
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
// Focus the dialog for accessibility
|
||||
queueMicrotask(() => {
|
||||
try { this.dialogRef?.nativeElement?.focus(); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
try { document.body.style.overflow = ''; } catch {}
|
||||
}
|
||||
|
||||
// Focus trap within the dialog (Tab / Shift+Tab)
|
||||
onDialogKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
this.close.emit();
|
||||
return;
|
||||
}
|
||||
if (ev.key !== 'Tab') return;
|
||||
const root = this.dialogRef?.nativeElement;
|
||||
if (!root) return;
|
||||
const focusables = Array.from(root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
|
||||
.filter(el => !el.hasAttribute('disabled'));
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!ev.shiftKey && active === last) {
|
||||
ev.preventDefault();
|
||||
first.focus();
|
||||
} else if (ev.shiftKey && (active === first || active === root)) {
|
||||
ev.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,153 +1,205 @@
|
||||
<form class="w-full min-w-0 relative" novalidate (submit)="onSubmit($event)" role="search" aria-label="Recherche">
|
||||
<!-- Input row -->
|
||||
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-2 py-1.5 sm:px-3 sm:py-2">
|
||||
<!-- Provider chips: only when there is genuinely room for them (>= lg).
|
||||
On smaller screens the "@" picker below is the equivalent entry point. -->
|
||||
<div class="hidden lg:flex items-center gap-1.5 shrink-0">
|
||||
<button type="button"
|
||||
(click)="toggleAll()"
|
||||
class="chip text-xs px-2.5 rounded border transition-colors"
|
||||
[ngClass]="{
|
||||
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
|
||||
'bg-slate-700 text-slate-100 border-slate-600': providerMode()!=='all'
|
||||
}"
|
||||
[attr.aria-pressed]="providerMode()==='all'"
|
||||
title="Tous les fournisseurs">
|
||||
All
|
||||
</button>
|
||||
<ng-container *ngFor="let c of chips; let i = index">
|
||||
<button type="button" (click)="toggleChip(c.id)" (keydown.space)="$event.preventDefault(); toggleChip(c.id)" (keydown.enter)="$event.preventDefault(); toggleChip(c.id)" tabindex="0" role="button"
|
||||
class="chip text-xs px-2.5 rounded border transition-colors"
|
||||
[ngClass]="{
|
||||
'bg-slate-700 text-slate-100 border-slate-600': !(selected().includes(c.id)),
|
||||
'bg-slate-200 text-slate-800 border-slate-300': selected().includes(c.id)
|
||||
}"
|
||||
[attr.aria-pressed]="selected().includes(c.id)"
|
||||
[title]="'Inclure ' + c.label + ' (Alt+' + (i + 1) + ')'">
|
||||
<span [class]="c.colorClass">{{ c.label }}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
<!-- Barre de recherche.
|
||||
- combobox ARIA : le panneau de suggestions est la listbox contrôlée par
|
||||
l'input (aria-expanded / aria-activedescendant) ;
|
||||
- `focusin` / `focusout` sur le <form> : le panneau se referme dès que le
|
||||
focus sort de la barre (clic ailleurs, Tab au-delà, perte de focus) ;
|
||||
- `pointerdown` neutralisé sur les panneaux : cliquer une suggestion ne
|
||||
vole pas le focus à l'input (sinon la fermeture au blur se déclenche). -->
|
||||
<form #searchForm
|
||||
class="w-full min-w-0 relative"
|
||||
novalidate
|
||||
(submit)="onSubmit($event)"
|
||||
(focusin)="onFocusIn($event)"
|
||||
(focusout)="onFocusOut($event)"
|
||||
role="search"
|
||||
aria-label="Recherche">
|
||||
<!-- Ancre des panneaux : ils se positionnent juste sous le champ, pas sous
|
||||
les pastilles de filtres. -->
|
||||
<div class="relative">
|
||||
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-2 py-1.5 sm:px-3 sm:py-2"
|
||||
[class.ring-2]="suggestionsOpen() || atOpen()"
|
||||
[class.ring-red-500]="suggestionsOpen() || atOpen()">
|
||||
|
||||
<!-- Text input: combobox pattern for the inline '@' autocomplete -->
|
||||
<!-- Champ texte -->
|
||||
<input #qInput type="search"
|
||||
class="min-w-0 flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-1 sm:px-2 py-2"
|
||||
[placeholder]="placeholder"
|
||||
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" (focus)="onSuggestInputFocus()" name="q"
|
||||
autocomplete="off" aria-label="Rechercher des vidéos" enterkeyhint="search"
|
||||
role="combobox" aria-expanded="false" aria-autocomplete="list"
|
||||
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)"
|
||||
(focus)="onSuggestInputFocus()"
|
||||
name="q"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||
aria-label="Rechercher des vidéos" enterkeyhint="search"
|
||||
role="combobox" aria-autocomplete="list" aria-haspopup="listbox"
|
||||
[attr.aria-expanded]="atOpen() || suggestionsOpen()"
|
||||
[attr.aria-controls]="atOpen() ? 'atOptionsList' : (suggestionsOpen() ? 'sgOptionsList' : null)"
|
||||
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : (suggestionsOpen() && suggestIndex() >= 0 ? 'sgOption-' + suggestIndex() : null)"/>
|
||||
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0
|
||||
? 'atOption-' + atIndex()
|
||||
: (suggestionsOpen() && suggestIndex() >= 0 ? 'sgOption-' + suggestIndex() : null)"/>
|
||||
|
||||
<!-- Provider picker trigger: shows the active selection as a summary so the
|
||||
user always knows where they are searching. -->
|
||||
<button type="button" (click)="openPicker()"
|
||||
class="chip shrink-0 text-xs font-medium px-2.5 rounded bg-slate-700 text-slate-100 hover:bg-slate-600"
|
||||
<!-- Effacer la saisie -->
|
||||
<button *ngIf="(query() || '').length > 0" type="button" (click)="clearQuery()"
|
||||
(pointerdown)="onPanelPointerDown($event)"
|
||||
class="chip shrink-0 rounded p-1.5 text-slate-400 hover:bg-slate-700 hover:text-slate-100"
|
||||
aria-label="Effacer la recherche">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Bouton filtres (sources + type + période + durée + tri).
|
||||
Raccourcis : Ctrl/Cmd+K ou Ctrl/Cmd+Maj+F. -->
|
||||
<button type="button" (click)="togglePanel()"
|
||||
(pointerdown)="onPanelPointerDown($event)"
|
||||
class="chip relative inline-flex shrink-0 items-center gap-1.5 rounded bg-slate-700 px-2.5 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-600"
|
||||
aria-haspopup="dialog"
|
||||
[attr.aria-label]="'Fournisseurs : ' + providerSummary()">
|
||||
<span class="max-w-[5.5rem] truncate lg:hidden">{{ providerSummary() }}</span>
|
||||
<span class="hidden lg:inline" aria-hidden="true">@</span>
|
||||
[attr.aria-expanded]="panelOpen()"
|
||||
[attr.aria-label]="'Filtres de recherche' + (activeFilterCount() > 0 ? ' (' + activeFilterCount() + ' actifs)' : '')"
|
||||
[title]="'Filtres : ' + filterSummary() + ' (Ctrl+Maj+F)'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3 17v2h6v-2H3ZM3 5v2h10V5H3Zm10 16v-2h8v-2h-8v-2h-2v6h2ZM7 9v2H3v2h4v2h2V9H7Zm14 4v-2H11v2h10Zm-6-4h2V7h4V5h-4V3h-2v6Z"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Filtres</span>
|
||||
<span *ngIf="activeFilterCount() > 0"
|
||||
class="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold text-white">
|
||||
{{ activeFilterCount() }}
|
||||
</span>
|
||||
<span *ngIf="providerCount() > 0"
|
||||
class="hidden rounded bg-slate-600 px-1 text-[10px] font-bold text-slate-100 lg:inline"
|
||||
[title]="'Sources sélectionnées'">{{ providerCount() }}</span>
|
||||
</button>
|
||||
|
||||
<button type="submit" class="chip ml-auto shrink-0 inline-flex items-center gap-1.5 rounded bg-red-600 px-3 text-sm text-white hover:bg-red-500"
|
||||
(click)="onSubmit($event)"
|
||||
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 lg:hidden" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : 'Rechercher (Entrée)'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5Zm-6 0A4.5 4.5 0 1 1 14 9.5 4.49 4.49 0 0 1 9.5 14Z"/>
|
||||
</svg>
|
||||
<span class="hidden lg:inline">Search</span>
|
||||
<span class="hidden lg:inline">Rechercher</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Provider Picker Modal -->
|
||||
<app-provider-picker *ngIf="pickerOpen()"
|
||||
[open]="pickerOpen()"
|
||||
[selected]="providersForRequest()"
|
||||
(close)="closePicker()"
|
||||
(apply)="applyPicker($event)"></app-provider-picker>
|
||||
<!-- Panneaux ancrés sous le champ (avant les pastilles de filtres) -->
|
||||
|
||||
<!-- Inline @autocomplete popover -->
|
||||
<div *ngIf="atOpen() && atOptions().length > 0"
|
||||
class="absolute z-50 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg shadow-xl"
|
||||
<!-- Autocomplétion des opérateurs (`linux live:`) -->
|
||||
<div *ngIf="operatorSuggestions().length > 0"
|
||||
class="absolute z-40 mt-1 left-0 right-0 rounded-lg border border-slate-700 bg-slate-900 p-1 shadow-xl"
|
||||
style="top: 100%">
|
||||
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs"
|
||||
class="max-h-[60dvh] overflow-y-auto"
|
||||
[ngClass]="{ 'bg-slate-900 border border-slate-700 text-slate-200': true }">
|
||||
<button *ngFor="let op of operatorSuggestions()" type="button"
|
||||
(click)="applyOperatorToken(op)"
|
||||
(pointerdown)="onPanelPointerDown($event)"
|
||||
class="w-full rounded px-3 py-2 text-left font-mono text-xs text-slate-300 hover:bg-slate-800">
|
||||
{{ op }}:
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Popover « @provider » (reste prioritaire sur les suggestions) -->
|
||||
<div *ngIf="atOpen() && atOptions().length > 0"
|
||||
class="absolute z-50 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg border border-slate-700 bg-slate-900 text-slate-200 shadow-xl"
|
||||
style="top: 100%"
|
||||
(pointerdown)="onPanelPointerDown($event)">
|
||||
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs">
|
||||
<li *ngFor="let opt of atOptions(); let i = index" role="option"
|
||||
[attr.aria-selected]="i === atIndex()"
|
||||
[id]="'atOption-' + i">
|
||||
<button type="button" (click)="applyAtOption(opt.id)"
|
||||
class="w-full text-left px-3 py-3 text-sm"
|
||||
[ngClass]="{
|
||||
'bg-slate-800': i === atIndex(),
|
||||
'hover:bg-slate-800': i !== atIndex()
|
||||
}">
|
||||
(mouseenter)="atIndex.set(i)"
|
||||
(keydown)="onAtOptionKeydown($event, i)"
|
||||
class="w-full px-3 py-3 text-left text-sm"
|
||||
[ngClass]="{ 'bg-slate-800': i === atIndex(), 'hover:bg-slate-800': i !== atIndex() }">
|
||||
@ {{ opt.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Step 15: query typeahead panel (hidden while the @ popover is open) -->
|
||||
<!-- Panneau de suggestions : ligne 0 = « Rechercher <q> », puis les suggestions -->
|
||||
<div *ngIf="suggestionsOpen() && !atOpen() && suggestFlat().length > 0"
|
||||
class="absolute z-40 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg shadow-xl bg-slate-900 border border-slate-700 text-slate-200"
|
||||
style="top: 100%">
|
||||
<ul id="sgOptionsList" role="listbox" aria-label="Suggestions de recherche"
|
||||
class="max-h-[60dvh] overflow-y-auto py-1">
|
||||
class="absolute z-40 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg border border-slate-700 bg-slate-900 text-slate-200 shadow-xl"
|
||||
style="top: 100%"
|
||||
(pointerdown)="onPanelPointerDown($event)">
|
||||
<ul id="sgOptionsList" role="listbox" aria-label="Suggestions de recherche" class="py-1">
|
||||
<li *ngIf="(query() || '').trim().length >= 2" role="option"
|
||||
[attr.aria-selected]="suggestIndex() === 0"
|
||||
id="sgOption-0"
|
||||
class="border-b border-slate-800">
|
||||
<button type="button" (click)="searchAsTyped()"
|
||||
(mouseenter)="suggestIndex.set(0)"
|
||||
(keydown)="onSuggestOptionKeydown($event, 0)"
|
||||
class="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm"
|
||||
[ngClass]="suggestIndex() === 0 ? 'bg-slate-800' : 'hover:bg-slate-800'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5Z"/>
|
||||
</svg>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
Rechercher <span class="font-semibold text-slate-100">{{ query() }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li *ngFor="let item of suggestFlat(); let i = index" role="option"
|
||||
[attr.aria-selected]="i === suggestIndex()"
|
||||
[id]="'sgOption-' + i">
|
||||
[attr.aria-selected]="suggestIndex() === i + 1"
|
||||
[id]="'sgOption-' + (i + 1)">
|
||||
<button type="button" (click)="applySuggestion(item.text, true)"
|
||||
(mouseenter)="suggestIndex.set(i)"
|
||||
class="w-full flex items-center gap-2 text-left px-3 py-3 text-sm">
|
||||
<span *ngIf="!item.provider" class="text-slate-500 shrink-0" title="Recherche récente">🕘</span>
|
||||
<span *ngIf="item.provider" class="shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-700 text-slate-300">{{ providerBadge(item.provider) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate" [title]="item.text"><span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent text-inherit font-semibold underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span></span>
|
||||
(mouseenter)="suggestIndex.set(i + 1)"
|
||||
(keydown)="onSuggestOptionKeydown($event, i + 1)"
|
||||
class="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm"
|
||||
[ngClass]="suggestIndex() === i + 1 ? 'bg-slate-800' : 'hover:bg-slate-800'">
|
||||
<span *ngIf="item.kind === 'recent'" class="shrink-0 text-slate-500" title="Recherche récente" aria-hidden="true">🕘</span>
|
||||
<span *ngIf="item.kind === 'provider'" class="shrink-0 rounded bg-slate-700 px-1.5 py-0.5 text-[10px] font-bold text-slate-300">{{ providerBadge(item.provider) }}</span>
|
||||
<span *ngIf="item.kind === 'local'" class="shrink-0 text-slate-600" title="Complétion" aria-hidden="true">+</span>
|
||||
<span class="min-w-0 flex-1 truncate" [title]="item.text">
|
||||
<span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent font-semibold text-inherit underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span>
|
||||
</span>
|
||||
<span *ngIf="suggestIndex() === i + 1" class="hidden shrink-0 text-[10px] text-slate-500 sm:inline">Entrée</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div *ngIf="suggestLoading()" class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">Chargement…</div>
|
||||
<div class="flex items-center justify-between gap-2 border-t border-slate-800 px-3 py-1.5 text-[11px] text-slate-500">
|
||||
<span *ngIf="suggestLoading()">Chargement des suggestions…</span>
|
||||
<span *ngIf="!suggestLoading()">↑ ↓ naviguer · Entrée valider · Échap fermer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /ancre des panneaux -->
|
||||
|
||||
<!-- Pastilles des filtres actifs : rappel visuel + retrait en un clic -->
|
||||
<div *ngIf="activeFilterChips().length > 0 || providerCount() > 0"
|
||||
class="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<span *ngIf="providerCount() > 0" class="text-[11px] text-slate-400">Sources :</span>
|
||||
<ng-container *ngIf="providerCount() > 0">
|
||||
<span *ngFor="let pid of selected()"
|
||||
class="chip inline-flex items-center gap-1 rounded-full border border-slate-600 bg-slate-800 px-2 py-0.5 text-[11px] text-slate-200">
|
||||
{{ providerBadge(pid) }}
|
||||
<button type="button" (click)="toggleChip(pid)"
|
||||
(pointerdown)="onPanelPointerDown($event)"
|
||||
class="rounded-full px-1 text-slate-400 hover:text-red-400" [attr.aria-label]="'Retirer ' + pid">
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</ng-container>
|
||||
<ng-container *ngFor="let chip of activeFilterChips()">
|
||||
<button type="button" (click)="removeFilter(chip.key)"
|
||||
(pointerdown)="onPanelPointerDown($event)"
|
||||
class="chip inline-flex items-center gap-1 rounded-full border border-red-500/60 bg-red-600/20 px-2 py-0.5 text-[11px] text-red-200 hover:bg-red-600/40"
|
||||
[attr.aria-label]="'Retirer le filtre ' + chip.label">
|
||||
{{ chip.label }} <span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
<button *ngIf="activeFilterChips().length > 0" type="button" (click)="clearAllFilters()"
|
||||
(pointerdown)="onPanelPointerDown($event)"
|
||||
class="chip rounded-full px-2 py-0.5 text-[11px] text-slate-400 hover:text-slate-200 underline">
|
||||
Tout effacer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quick Menu (Ctrl/Cmd+K) -->
|
||||
<div *ngIf="quickOpen()" class="quick-menu-backdrop" role="presentation" (click)="closeQuickMenu()">
|
||||
<div #quickDialog class="quick-menu-dialog" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
|
||||
<div class="p-3 border-b border-slate-700 flex items-center justify-between gap-3">
|
||||
<h2 id="quickMenuTitle" class="text-slate-100 font-semibold">Quick Menu</h2>
|
||||
<button type="button" (click)="closeQuickMenu()" class="chip px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer le menu (Esc)">Esc</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 overflow-y-auto min-h-0 flex-1">
|
||||
<!-- History column -->
|
||||
<div class="p-4 md:border-r md:border-slate-700">
|
||||
<div class="text-slate-300 text-sm mb-2">History</div>
|
||||
<ul class="space-y-1 max-h-64 overflow-y-auto">
|
||||
<li *ngFor="let it of recentSearches()">
|
||||
<button type="button" class="w-full text-left px-3 py-3 rounded hover:bg-slate-800 text-slate-200 break-anywhere"
|
||||
(click)="queryUpdate(it.query || ''); closeQuickMenu()">{{ it.query }}</button>
|
||||
</li>
|
||||
<li *ngIf="(recentSearches()?.length || 0) === 0" class="text-slate-500 text-sm">No recent searches.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Providers column -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between gap-3 mb-2">
|
||||
<div class="text-slate-300 text-sm">Providers</div>
|
||||
<button type="button" class="chip text-xs px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100"
|
||||
(click)="quickToggleAll()">All</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label *ngFor="let c of chips" class="flex items-center gap-2 px-3 py-3 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
||||
<input type="checkbox" [checked]="selected().includes(c.id)" (change)="quickToggle(c.id)"/>
|
||||
<span class="text-sm truncate" [class]="c.colorClass">{{ c.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 text-right">
|
||||
<button type="button" class="w-full sm:w-auto px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white"
|
||||
(click)="applyQuickMenu({ selection: selected().length ? selected() : 'all', rememberDefault: false })">Apply filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Panneau de filtres (dialog) -->
|
||||
<app-search-filter-panel *ngIf="panelOpen()"
|
||||
#filterPanel
|
||||
[open]="panelOpen()"
|
||||
[selected]="providersForRequest()"
|
||||
[filters]="filters()"
|
||||
[query]="query()"
|
||||
[history]="historyQueries()"
|
||||
(close)="closePanel()"
|
||||
(apply)="applyPanel($event)"
|
||||
(pickHistory)="pickHistory($event)"></app-search-filter-panel>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener, ViewChild, ElementRef } from '@angular/core';
|
||||
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
|
||||
import { ProviderPickerComponent } from './provider-picker.component';
|
||||
import { SearchFilterPanelComponent, type FilterPanelApply } from './search-filter-panel.component';
|
||||
import { SearchService } from '../../app/search/search.service';
|
||||
import { UserService, type UserPreferences } from '../../services/user.service';
|
||||
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
|
||||
@@ -12,14 +12,27 @@ import { debounceTime, distinctUntilChanged, switchMap, catchError, finalize } f
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { SuggestService } from '../../app/search/suggest.service';
|
||||
import { mergeGroups, highlightParts, dedupeStrings, buildLocalQuerySuggestions, normSuggest } from '../../app/search/suggest.util';
|
||||
// No overlays; we render fixed-position modals with high z-index
|
||||
import {
|
||||
DEFAULT_FILTERS, activeFilterCount, filtersEqual, filtersSummary, filterGroups,
|
||||
normalizeFilters, parseQueryTokens, operatorTokenSuggestions,
|
||||
type SearchFilters,
|
||||
} from '../../app/search/filters';
|
||||
|
||||
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
|
||||
|
||||
/** One row of the Step 15 typeahead panel (provider null = recent search). */
|
||||
/** Une ligne du panneau de suggestions. */
|
||||
export interface SuggestFlatItem {
|
||||
text: string;
|
||||
provider: string | null;
|
||||
/** 'recent' | 'provider' | 'local' — sert à l'icône et au tri d'affichage. */
|
||||
kind: 'recent' | 'provider' | 'local';
|
||||
}
|
||||
|
||||
/** Payload émis quand une recherche est lancée. */
|
||||
export interface SearchSubmit {
|
||||
q: string;
|
||||
providers: ProviderId[] | 'all';
|
||||
filters: SearchFilters;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -30,14 +43,15 @@ export interface SuggestFlatItem {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ProviderPickerComponent
|
||||
SearchFilterPanelComponent
|
||||
],
|
||||
providers: [
|
||||
SearchService, // Fournir le service au niveau du composant
|
||||
UserService, // S'assurer que UserService est fourni
|
||||
HistoryService // S'assurer que HistoryService est fourni
|
||||
],
|
||||
templateUrl: './search-box.component.html'
|
||||
templateUrl: './search-box.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SearchBoxComponent {
|
||||
/**
|
||||
@@ -53,11 +67,15 @@ export class SearchBoxComponent {
|
||||
// Optional: keeps unit tests (which don't provide it) working with a null guard
|
||||
private suggestSvc = inject(SuggestService, { optional: true });
|
||||
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('quickDialog', { static: false }) quickDialogRef?: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('searchForm', { static: false }) formRef?: ElementRef<HTMLFormElement>;
|
||||
@ViewChild('filterPanel', { static: false }) panelRef?: ElementRef<HTMLElement>;
|
||||
|
||||
// Input/Output bindings
|
||||
@Input() placeholder: string = 'Search videos…';
|
||||
@Output() submitted = new EventEmitter<{ q: string; providers: ProviderId[] | 'all' }>();
|
||||
@Output() submitted = new EventEmitter<SearchSubmit>();
|
||||
/** Émis quand les filtres changent (sans recherche) — la page /search peut
|
||||
* réécrire l'URL pour rester partageable. */
|
||||
@Output() filtersChange = new EventEmitter<SearchFilters>();
|
||||
// Emits debounced query changes for autosuggest
|
||||
@Output() searchChange = new EventEmitter<string>();
|
||||
|
||||
@@ -65,8 +83,11 @@ export class SearchBoxComponent {
|
||||
query = signal('');
|
||||
providerMode = signal<'all' | 'custom'>('all');
|
||||
selected = signal<ProviderId[]>([]);
|
||||
pickerOpen = signal(false);
|
||||
/** Panneau de filtres (remplace l'ancien sélecteur de providers). */
|
||||
panelOpen = signal(false);
|
||||
suggestionsOpen = signal(false);
|
||||
/** Filtres courants (type / durée / période / tri). */
|
||||
filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
|
||||
// Inline @autocomplete state (atOpen is derived from the query, atOptions is pure)
|
||||
atOpen = signal(false);
|
||||
atIndex = signal(0);
|
||||
@@ -78,32 +99,40 @@ export class SearchBoxComponent {
|
||||
.filter(p => p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term))
|
||||
.map(p => ({ id: p.id, label: p.displayName }));
|
||||
});
|
||||
// Quick menu modal (Ctrl/Cmd+K)
|
||||
quickOpen = signal(false);
|
||||
recentSearches = signal<SearchHistoryItem[]>([]);
|
||||
|
||||
// Step 15 — query typeahead (suggestions de requêtes, avant validation).
|
||||
// `suggestionsOpen` doubles as the typeahead panel flag (closed when @ popover opens).
|
||||
suggestLoading = signal(false);
|
||||
suggestGroups = signal<Record<string, string[]>>({});
|
||||
recentSuggest = signal<string[]>([]);
|
||||
suggestIndex = signal(-1);
|
||||
/** Rappelé après chaque frappe : sert à détecter une perte de focus. */
|
||||
private blurTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private allRecentCache: string[] = [];
|
||||
private lastRecentFetch = 0;
|
||||
private suggestShownFor: string | null = null;
|
||||
|
||||
/**
|
||||
* Panneau de suggestions : historique → suggestions providers → complétions
|
||||
* locales. Toujours trié « ce qui commence par la frappe d'abord », et jamais
|
||||
* la requête brute elle-même (sinon Entrée ne ferait rien).
|
||||
*/
|
||||
readonly suggestFlat = computed((): SuggestFlatItem[] => {
|
||||
const q = this.query();
|
||||
// Tier 1 — recherches récentes (dédupliquées, une seule fois chacune).
|
||||
const recents = (this.recentSuggest() || []).slice(0, 5).map((t) => ({ text: t, provider: null as string | null }));
|
||||
const recents: SuggestFlatItem[] = (this.recentSuggest() || []).slice(0, 5)
|
||||
.map((t) => ({ text: t, provider: null as string | null, kind: 'recent' as const }));
|
||||
const seen = new Set(recents.map((r) => normSuggest(r.text)));
|
||||
// Tier 2 — suggestions providers, dédupliquées entre providers (mergeGroups)
|
||||
// et contre l'historique.
|
||||
const merged = mergeGroups(this.suggestGroups() || {}).filter((m) => {
|
||||
const merged: SuggestFlatItem[] = mergeGroups(this.suggestGroups() || {})
|
||||
.filter((m) => {
|
||||
const k = normSuggest(m.text);
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.map((m) => ({ text: m.text, provider: m.provider, kind: 'provider' as const }));
|
||||
// Tri « style YouTube/Google » : les complétions qui commencent par la
|
||||
// frappe d'abord, puis le reste (tri stable : l'ordre providers est gardé).
|
||||
const nq = normSuggest(q);
|
||||
@@ -123,7 +152,7 @@ export class SearchBoxComponent {
|
||||
const k = normSuggest(t);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push({ text: t, provider: null });
|
||||
out.push({ text: t, provider: null, kind: 'local' });
|
||||
if (out.length >= 15) break;
|
||||
}
|
||||
}
|
||||
@@ -134,7 +163,6 @@ export class SearchBoxComponent {
|
||||
// Element to restore focus to when a modal closes (a11y)
|
||||
private lastFocused: HTMLElement | null = null;
|
||||
|
||||
readonly chips = PROVIDERS.map(p => ({ id: p.id, label: p.shortLabel, colorClass: p.colorClass }));
|
||||
|
||||
/**
|
||||
* Focus the query input programmatically (Ctrl/Cmd+K, `/`, mobile search
|
||||
@@ -149,21 +177,29 @@ export class SearchBoxComponent {
|
||||
return this.providerMode() === 'all' ? 'all' : (this.selected().length ? this.selected() : 'all');
|
||||
});
|
||||
|
||||
/**
|
||||
* Short human label for the current provider selection. On phones the chip
|
||||
* row is hidden, so this label is the only feedback the user gets about
|
||||
* where the search will be routed.
|
||||
*/
|
||||
readonly providerSummary = computed<string>(() => {
|
||||
if (this.providerMode() === 'all') return 'All';
|
||||
const sel = this.selected();
|
||||
if (!sel.length) return 'All';
|
||||
if (sel.length === 1) {
|
||||
const found = PROVIDERS.find((p) => p.id === sel[0]);
|
||||
return found ? found.shortLabel : String(sel[0]).toUpperCase();
|
||||
readonly activeFilterCount = computed(() => activeFilterCount(this.filters()));
|
||||
/** Résumé court affiché dans le bouton (mobile). */
|
||||
readonly filterSummary = computed(() => filtersSummary(this.filters()));
|
||||
readonly hasActiveFilters = computed(() => this.activeFilterCount() > 0);
|
||||
/** Pastilles des filtres actifs, pour les rappeler sous la barre. */
|
||||
readonly activeFilterChips = computed(() => {
|
||||
const n = normalizeFilters(this.filters());
|
||||
const groups = filterGroups(n);
|
||||
const out: { key: keyof SearchFilters; label: string; i18n: string }[] = [];
|
||||
for (const g of groups) {
|
||||
const k = g.key as keyof SearchFilters;
|
||||
if (n[k] === 'all' || (k === 'sort' && n.sort === 'relevance')) continue;
|
||||
const opt = g.options.find(o => o.value === n[k]);
|
||||
if (opt) out.push({ key: k, label: opt.label, i18n: opt.key });
|
||||
}
|
||||
return `${sel.length} src`;
|
||||
return out;
|
||||
});
|
||||
/** Nombre de sources sélectionnées (pour la pastille du bouton). */
|
||||
readonly providerCount = computed(() => {
|
||||
if (this.providerMode() === 'all') return 0;
|
||||
return this.selected().length;
|
||||
});
|
||||
|
||||
|
||||
constructor() {
|
||||
// Flag to avoid clobbering user input after initial hydration from SearchService
|
||||
@@ -191,7 +227,9 @@ export class SearchBoxComponent {
|
||||
if (hydratedFromService) return; // only hydrate once
|
||||
const q = this.search.q$.value;
|
||||
const prov = this.search.providers$.value;
|
||||
const filters = this.search.filters$?.value;
|
||||
if (typeof q === 'string') this.query.set(q);
|
||||
if (filters) this.filters.set(normalizeFilters(filters));
|
||||
if (prov === 'all') {
|
||||
this.providerMode.set('all');
|
||||
this.selected.set([]);
|
||||
@@ -219,28 +257,26 @@ export class SearchBoxComponent {
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the first focusable element of the quick menu dialog when it opens (a11y)
|
||||
// Lock body scroll when the filter panel is open (a11y UX)
|
||||
effect(() => {
|
||||
if (this.quickOpen()) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const root = this.quickDialogRef?.nativeElement;
|
||||
const first = root?.querySelector<HTMLElement>('button');
|
||||
(first || root)?.focus();
|
||||
} catch {}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Lock body scroll when a modal is open (a11y UX)
|
||||
effect(() => {
|
||||
const open = this.quickOpen() || this.pickerOpen();
|
||||
const open = this.panelOpen();
|
||||
try {
|
||||
document.body.style.overflow = open ? 'hidden' : '';
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saisie
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Écrit dans l'input sans passer par ngModel (focus conservé). */
|
||||
private setQueryValue(value: string): void {
|
||||
this.query.set(value);
|
||||
try { this.search.q$.next(value); } catch {}
|
||||
try { const el = this.qInputRef?.nativeElement; if (el) el.value = value; } catch {}
|
||||
}
|
||||
|
||||
// Update query and refresh inline '@provider' autocomplete state
|
||||
queryUpdate(value: string) {
|
||||
this.query.set(value);
|
||||
@@ -260,6 +296,17 @@ export class SearchBoxComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Efface l'input (croix du champ) et refocus. */
|
||||
clearQuery(): void {
|
||||
this.queryUpdate('');
|
||||
this.setQueryValue('');
|
||||
this.focusInputSafe();
|
||||
}
|
||||
|
||||
private focusInputSafe(): void {
|
||||
try { this.qInputRef?.nativeElement?.focus({ preventScroll: true }); } catch {}
|
||||
}
|
||||
|
||||
// --- Step 15 helpers ---
|
||||
|
||||
private fetchSuggest(value: string) {
|
||||
@@ -279,8 +326,7 @@ export class SearchBoxComponent {
|
||||
private applySuggestResponse(res: { q: string; groups: Record<string, string[]> } | null) {
|
||||
if (!res) {
|
||||
this.suggestGroups.set({});
|
||||
this.suggestionsOpen.set(false);
|
||||
this.suggestIndex.set(-1);
|
||||
this.closeSuggestions();
|
||||
return;
|
||||
}
|
||||
this.suggestGroups.set(res.groups || {});
|
||||
@@ -295,10 +341,15 @@ export class SearchBoxComponent {
|
||||
this.telemetry.track('suggest_shown', { queryLength: key.length, resultCount: flat.length });
|
||||
}
|
||||
} else {
|
||||
this.closeSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
/** Referme le panneau de suggestions et annule la surbrillance. */
|
||||
closeSuggestions(): void {
|
||||
this.suggestionsOpen.set(false);
|
||||
this.suggestIndex.set(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Filter cached history synchronously; refresh the cache at most every 10s. */
|
||||
private refreshRecentSuggest(value: string) {
|
||||
@@ -332,24 +383,55 @@ export class SearchBoxComponent {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Fill the input with a suggestion; submit=true also runs the search. */
|
||||
/** Charge l'historique récent (affiché dans le panneau de filtres). */
|
||||
loadRecentHistory(): void {
|
||||
try {
|
||||
this.history.getSearchHistory(12).subscribe({
|
||||
next: (items) => {
|
||||
const seen = new Set<string>();
|
||||
const clean: SearchHistoryItem[] = [];
|
||||
for (const it of items || []) {
|
||||
const k = normSuggest(it?.query || '');
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
clean.push(it);
|
||||
}
|
||||
this.recentSearches.set(clean);
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Remplit l'input avec une suggestion ; submit=true lance aussi la recherche. */
|
||||
applySuggestion(text: string, submit: boolean) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return;
|
||||
const prov = this.providersForRequest();
|
||||
this.telemetry.track('suggest_used', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: t.length });
|
||||
this.suggestionsOpen.set(false);
|
||||
this.suggestIndex.set(-1);
|
||||
this.query.set(t);
|
||||
try { this.search.q$.next(t); } catch {}
|
||||
try { this.qInputRef?.nativeElement && (this.qInputRef.nativeElement.value = t); } catch {}
|
||||
this.closeSuggestions();
|
||||
this.setQueryValue(t);
|
||||
if (submit) {
|
||||
this.submitted.emit({ q: t, providers: this.providersForRequest() });
|
||||
this.telemetry.track('search_submit', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: t.length });
|
||||
this.runSearch(t);
|
||||
} else {
|
||||
this._suggestReq.next(t);
|
||||
// Complétion (Tab) : on rouvre immédiatement le panneau sur les
|
||||
// suggestions locales, sans attendre le réseau.
|
||||
if (this.suggestFlat().length > 0) {
|
||||
this.suggestionsOpen.set(true);
|
||||
this.suggestIndex.set(-1);
|
||||
}
|
||||
try { this.qInputRef?.nativeElement?.focus(); } catch {}
|
||||
}
|
||||
this.focusInputSafe();
|
||||
}
|
||||
|
||||
/** Première ligne du panneau : lance la recherche telle que saisie. */
|
||||
searchAsTyped(): void {
|
||||
const q = (this.query() || '').trim();
|
||||
if (q.length < 2) return;
|
||||
this.closeSuggestions();
|
||||
this.runSearch(q);
|
||||
this.focusInputSafe();
|
||||
}
|
||||
|
||||
suggestHighlight(text: string): { pre: string; match: string; post: string } {
|
||||
@@ -368,9 +450,158 @@ export class SearchBoxComponent {
|
||||
this.refreshRecentSuggest(this.query());
|
||||
if (!this.atOpen() && this.suggestFlat().length > 0) {
|
||||
this.suggestionsOpen.set(true);
|
||||
this.suggestIndex.set(-1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Focus : le panneau ne vit que tant que le focus est dans la barre
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Le focus sort de la barre (clic ailleurs, Tab au-delà, perte de focus
|
||||
* fenêtre…) -> on referme les panneaux. `relatedTarget` couvre le Tab et le
|
||||
* clic ; le timer couvre le cas où le navigateur ne fournit pas de cible
|
||||
* (clic dans la zone vide de la page, perte de focus de la fenêtre).
|
||||
*/
|
||||
onFocusOut(ev: FocusEvent): void {
|
||||
if (this.panelOpen()) return; // le panneau est un dialogue : il gère son focus
|
||||
const next = ev?.relatedTarget as HTMLElement | null;
|
||||
const host = this.formRef?.nativeElement;
|
||||
if (next && host && host.contains(next)) return;
|
||||
if (this.blurTimer) { clearTimeout(this.blurTimer); this.blurTimer = null; }
|
||||
this.blurTimer = setTimeout(() => {
|
||||
this.blurTimer = null;
|
||||
try {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const root = this.formRef?.nativeElement;
|
||||
if (active && root && root.contains(active)) return;
|
||||
} catch {
|
||||
// Pas de DOM (tests / SSR) : rien à vérifier, on ferme.
|
||||
}
|
||||
this.closeSuggestions();
|
||||
this.atOpen.set(false);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le focus revient dans la barre (re-clic) : on annule le timer pour ne pas
|
||||
* refermer le panneau qui vient d'être rouvert.
|
||||
*/
|
||||
onFocusIn(ev: FocusEvent): void {
|
||||
if (this.blurTimer) { clearTimeout(this.blurTimer); this.blurTimer = null; }
|
||||
void ev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un clic dans le panneau ne doit pas voler le focus à l'input : on neutralise
|
||||
* le `mousedown` (le bouton garde son état actif, l'input garde le focus).
|
||||
*/
|
||||
onPanelPointerDown(ev: MouseEvent): void {
|
||||
try { ev.preventDefault(); } catch {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtres
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
openPanel(): void {
|
||||
this.saveFocus();
|
||||
this.closeSuggestions();
|
||||
this.atOpen.set(false);
|
||||
this.loadRecentHistory();
|
||||
this.panelOpen.set(true);
|
||||
this.telemetry.track('filter_panel_open', { activeFilters: this.activeFilterCount() });
|
||||
}
|
||||
|
||||
closePanel(): void {
|
||||
if (!this.panelOpen()) return;
|
||||
this.panelOpen.set(false);
|
||||
this.restoreFocus();
|
||||
}
|
||||
|
||||
togglePanel(): void {
|
||||
if (this.panelOpen()) this.closePanel();
|
||||
else this.openPanel();
|
||||
}
|
||||
|
||||
/** Application du panneau : filtres + sources. */
|
||||
applyPanel(payload: FilterPanelApply): void {
|
||||
if (payload.selection === 'all') {
|
||||
this.providerMode.set('all');
|
||||
this.selected.set([]);
|
||||
} else if (Array.isArray(payload.selection)) {
|
||||
this.providerMode.set('custom');
|
||||
this.selected.set(payload.selection);
|
||||
}
|
||||
const next = normalizeFilters(payload.filters);
|
||||
const changed = !filtersEqual(next, this.filters());
|
||||
this.filters.set(next);
|
||||
if (payload.rememberDefault) {
|
||||
const body: any = { defaultProviders: (payload.selection === 'all') ? [] : payload.selection };
|
||||
try { this.users.updatePreferences(body).subscribe({ next: () => {}, error: () => {} }); } catch {}
|
||||
}
|
||||
const prov = this.providersForRequest();
|
||||
this.telemetry.track('filter_apply', {
|
||||
providers: Array.isArray(prov) ? prov : ['all'],
|
||||
filters: next,
|
||||
rememberDefault: payload.rememberDefault,
|
||||
});
|
||||
this.closePanel();
|
||||
if (!changed) return;
|
||||
this.applyFiltersAndMaybeSearch(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Point unique après un changement de filtre : si une recherche est déjà
|
||||
* saisie, on la relance (le parent navigue avec les filtres) ; sinon on
|
||||
* n'émet que `filtersChange` pour que la page courante reste partageable.
|
||||
* (Une seule navigation : les deux ne doivent pas se cumuler.)
|
||||
*/
|
||||
private applyFiltersAndMaybeSearch(next: SearchFilters): void {
|
||||
this.filters.set(next);
|
||||
const q = this.strippedQuery();
|
||||
if (q.length >= 2) {
|
||||
this.runSearch(q);
|
||||
return;
|
||||
}
|
||||
this.filtersChange.emit(next);
|
||||
}
|
||||
|
||||
/** Retirer une pastille de filtre (sous la barre). */
|
||||
removeFilter(key: keyof SearchFilters): void {
|
||||
const next = normalizeFilters({ ...this.filters(), [key]: key === 'sort' ? 'relevance' : 'all' });
|
||||
if (filtersEqual(next, this.filters())) return;
|
||||
this.applyFiltersAndMaybeSearch(next);
|
||||
}
|
||||
|
||||
/** Remise à zéro de tous les filtres (sans toucher aux sources). */
|
||||
clearAllFilters(): void {
|
||||
if (filtersEqual(this.filters(), DEFAULT_FILTERS)) return;
|
||||
this.applyFiltersAndMaybeSearch({ ...DEFAULT_FILTERS });
|
||||
}
|
||||
|
||||
/** Recherches récentes (chaînes) affichées dans le panneau de filtres. */
|
||||
historyQueries(): string[] {
|
||||
return (this.recentSearches() || [])
|
||||
.map((it) => String(it?.query || '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Rappel d'une recherche depuis le panneau de filtres. */
|
||||
pickHistory(text: string): void {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return;
|
||||
this.closePanel();
|
||||
this.queryUpdate(t);
|
||||
this.setQueryValue(t);
|
||||
this.runSearch(t);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fournisseurs (raccourcis / popover « @ »)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Pick an option from the inline '@' popover: strip the '@term' and toggle the chip
|
||||
applyAtOption(id: ProviderId) {
|
||||
const q = this.query();
|
||||
@@ -382,7 +613,7 @@ export class SearchBoxComponent {
|
||||
this.atOpen.set(false);
|
||||
this.toggleChip(id);
|
||||
this.telemetry.track('at_autocomplete_use', { providers: [id] });
|
||||
try { this.qInputRef?.nativeElement?.focus(); } catch {}
|
||||
this.focusInputSafe();
|
||||
}
|
||||
|
||||
// Toggle All chip
|
||||
@@ -405,165 +636,259 @@ export class SearchBoxComponent {
|
||||
this.selected.set(Array.from(cur));
|
||||
}
|
||||
|
||||
// Single keyboard handler for the whole form (input events bubble here)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clavier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handler clavier unique de la barre.
|
||||
*
|
||||
* Priorité : 1) popover « @ », 2) panneau de suggestions, 3) surbrillance
|
||||
* d'une option du panneau de filtres (bouton focused), 4) raccourcis globaux.
|
||||
* Entrée n'est jamais traitée deux fois : le formulaire n'a qu'un seul
|
||||
* `keydown` (le `(keydown.enter)` d'avant a été supprimé).
|
||||
*/
|
||||
handleKeydown(ev: KeyboardEvent) {
|
||||
// Inline @autocomplete navigation (highest priority)
|
||||
// --- 1. Popover « @provider » (priorité maximale) ---
|
||||
if (this.atOpen()) {
|
||||
const opts = this.atOptions();
|
||||
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(opts.length - 1, this.atIndex() + 1)); return; }
|
||||
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, this.atIndex() - 1)); return; }
|
||||
const last = Math.max(0, opts.length - 1);
|
||||
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(last, this.atIndex() + 1)); this.scrollActiveIntoView(); return; }
|
||||
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, this.atIndex() - 1)); this.scrollActiveIntoView(); return; }
|
||||
if (ev.key === 'Enter') {
|
||||
// Prevent the sibling (keydown.enter) submit handler from also firing
|
||||
ev.stopImmediatePropagation();
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const pick = opts[this.atIndex()];
|
||||
if (pick) this.applyAtOption(pick.id);
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Escape') { this.atOpen.set(false); return; }
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); this.atOpen.set(false); return; }
|
||||
}
|
||||
// Step 15 — typeahead navigation (second priority after the @ popover)
|
||||
|
||||
// --- 2. Panneau de suggestions ---
|
||||
if (this.suggestionsOpen() && !this.atOpen()) {
|
||||
const flat = this.suggestFlat();
|
||||
if (flat.length > 0) {
|
||||
const last = flat.length - 1;
|
||||
if (ev.key === 'ArrowDown') {
|
||||
ev.preventDefault();
|
||||
this.suggestIndex.set(this.suggestIndex() >= last ? 0 : this.suggestIndex() + 1);
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'ArrowUp') {
|
||||
ev.preventDefault();
|
||||
this.suggestIndex.set(this.suggestIndex() <= 0 ? last : this.suggestIndex() - 1);
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Enter') {
|
||||
// Ligne 0 = « Rechercher <q> », les suivantes = suggestions.
|
||||
const total = flat.length + 1;
|
||||
const last = total - 1;
|
||||
const idx = this.suggestIndex();
|
||||
const move = (next: number) => {
|
||||
ev.preventDefault();
|
||||
this.suggestIndex.set(((next % total) + total) % total);
|
||||
this.scrollActiveIntoView();
|
||||
};
|
||||
if (ev.key === 'ArrowDown') { move(idx >= last ? 0 : idx + 1); return; }
|
||||
if (ev.key === 'ArrowUp') { move(idx <= 0 ? last : idx - 1); return; }
|
||||
if (ev.key === 'Home' && idx >= 0) { ev.preventDefault(); this.suggestIndex.set(0); this.scrollActiveIntoView(); return; }
|
||||
if (ev.key === 'End' && idx >= 0) { ev.preventDefault(); this.suggestIndex.set(last); this.scrollActiveIntoView(); return; }
|
||||
if (ev.key === 'PageDown' && idx >= 0) { move(Math.min(last, idx + 5)); return; }
|
||||
if (ev.key === 'PageUp' && idx >= 0) { move(Math.max(0, idx - 5)); return; }
|
||||
if (ev.key === 'Enter') {
|
||||
// Entrée sur une ligne sélectionnée = validation ; sinon recherche brute.
|
||||
if (idx >= 0 && idx <= last) {
|
||||
ev.stopImmediatePropagation();
|
||||
ev.preventDefault();
|
||||
this.applySuggestion(flat[idx].text, true);
|
||||
ev.stopPropagation();
|
||||
if (idx === 0) this.searchAsTyped();
|
||||
else this.applySuggestion(flat[idx - 1].text, true);
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.onSubmit(ev);
|
||||
return;
|
||||
}
|
||||
// Tab completes the active (or first) suggestion without searching
|
||||
// Tab complète l'option active (ou la première) sans lancer la recherche.
|
||||
if (ev.key === 'Tab') {
|
||||
const idx = this.suggestIndex() >= 0 ? this.suggestIndex() : 0;
|
||||
if (flat[idx]) {
|
||||
const target = idx >= 0 ? idx - 1 : 0;
|
||||
const item = flat[target];
|
||||
if (item) {
|
||||
ev.preventDefault();
|
||||
this.applySuggestion(flat[idx].text, false);
|
||||
this.applySuggestion(item.text, false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Esc closes the panel but keeps the typed text
|
||||
// Échap ferme le panneau mais garde la frappe.
|
||||
if (ev.key === 'Escape') {
|
||||
this.suggestionsOpen.set(false);
|
||||
this.suggestIndex.set(-1);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.closeSuggestions();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Alt+1..6 map to chips by registry order
|
||||
|
||||
// --- 3. Alt+1..6 = cocher/décocher une source ---
|
||||
if (ev.altKey) {
|
||||
const map: Record<string, ProviderId | undefined> = { '1': 'yt', '2': 'dm', '3': 'tw', '4': 'pt', '5': 'od', '6': 'ru' };
|
||||
const pid = map[ev.key];
|
||||
if (pid) { ev.preventDefault(); this.toggleChip(pid); }
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 4. Échap : fermer, puis vider ---
|
||||
if (ev.key === 'Escape') {
|
||||
// Escape closes transient UI; if nothing is open, clear the query
|
||||
const hadAny = this.pickerOpen() || this.suggestionsOpen() || this.atOpen() || this.quickOpen();
|
||||
this.closePicker();
|
||||
this.suggestionsOpen.set(false);
|
||||
ev.stopPropagation();
|
||||
const hadAny = this.panelOpen() || this.suggestionsOpen() || this.atOpen();
|
||||
this.closePanel();
|
||||
this.closeSuggestions();
|
||||
this.atOpen.set(false);
|
||||
if (!hadAny && (this.query() || '').length > 0) {
|
||||
this.queryUpdate('');
|
||||
}
|
||||
if (!hadAny && (this.query() || '').length > 0) this.clearQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// Global shortcut Ctrl/Cmd+K to open the Providers quick menu
|
||||
/** Clavier du popover « @ » (boutons) : mêmes règles que l'input. */
|
||||
onAtOptionKeydown(ev: KeyboardEvent, index: number): void {
|
||||
const opts = this.atOptions();
|
||||
const last = Math.max(0, opts.length - 1);
|
||||
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(last, index + 1)); this.focusAtOption(); return; }
|
||||
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, index - 1)); this.focusAtOption(); return; }
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); this.atOpen.set(false); this.focusInputSafe(); return; }
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const pick = opts[index];
|
||||
if (pick) this.applyAtOption(pick.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clavier d'une ligne du panneau de suggestions (bouton). */
|
||||
onSuggestOptionKeydown(ev: KeyboardEvent, index: number): void {
|
||||
// index 0 = ligne « Rechercher … » ; 1..n = suggestions
|
||||
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
||||
ev.preventDefault();
|
||||
const total = this.suggestFlat().length + 1;
|
||||
const next = (ev.key === 'ArrowDown') ? index + 1 : index - 1;
|
||||
this.suggestIndex.set(Math.max(0, Math.min(total - 1, next)));
|
||||
this.focusInputSafe();
|
||||
this.scrollActiveIntoView();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); this.closeSuggestions(); this.focusInputSafe(); }
|
||||
}
|
||||
|
||||
private focusAtOption(): void {
|
||||
setTimeout(() => {
|
||||
try { document.getElementById('atOption-' + this.atIndex())?.focus(); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
/** Garde l'option surbrillée visible dans la liste (clavier long). */
|
||||
private scrollActiveIntoView(): void {
|
||||
if (this.suggestIndex() < 0 && this.atIndex() < 0) return;
|
||||
const id = this.suggestionsOpen() && this.suggestIndex() >= 0
|
||||
? 'sgOption-' + this.suggestIndex()
|
||||
: 'atOption-' + this.atIndex();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const el = document.getElementById(id);
|
||||
if (el && typeof (el as any).scrollIntoView === 'function') {
|
||||
(el as any).scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
// Raccourcis globaux. Ctrl/Cmd+K reste « focus recherche » (géré par le
|
||||
// header) ; le panneau de filtres prend Ctrl/Cmd+Maj+F.
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onGlobalKey(ev: KeyboardEvent) {
|
||||
if ((ev.key.toLowerCase() === 'k') && (ev.ctrlKey || ev.metaKey)) {
|
||||
const key = String(ev.key || '').toLowerCase();
|
||||
if (key === 'f' && ev.shiftKey && (ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault();
|
||||
this.openQuickMenu();
|
||||
ev.stopPropagation();
|
||||
if (this.panelOpen()) this.closePanel();
|
||||
else this.openPanel();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Escape') {
|
||||
if (this.quickOpen()) {
|
||||
ev.preventDefault();
|
||||
this.closeQuickMenu();
|
||||
}
|
||||
if (this.pickerOpen()) {
|
||||
ev.preventDefault();
|
||||
this.closePicker();
|
||||
}
|
||||
if (this.atOpen()) {
|
||||
ev.preventDefault();
|
||||
this.atOpen.set(false);
|
||||
if (this.panelOpen()) { ev.preventDefault(); ev.stopPropagation(); this.closePanel(); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Soumission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Requête sans les opérateurs `live:`/`today:`… (déjà convertis en filtres). */
|
||||
private strippedQuery(): string {
|
||||
return parseQueryTokens(this.query() || '').query;
|
||||
}
|
||||
|
||||
/** Point d'entrée unique de la recherche (formulaire, Entrée, suggestion). */
|
||||
private runSearch(rawQuery: string): void {
|
||||
const parsed = parseQueryTokens(rawQuery);
|
||||
// Les opérateurs saisis au clavier deviennent des filtres (et disparaissent
|
||||
// de la requête affichée : ils sont « consommés », donc plus de conflit avec
|
||||
// la suppression manuelle d'un filtre).
|
||||
if (Object.keys(parsed.filters).length > 0) {
|
||||
const merged = normalizeFilters({ ...this.filters(), ...parsed.filters });
|
||||
this.filters.set(merged);
|
||||
this.filtersChange.emit(merged);
|
||||
const clean = parsed.query;
|
||||
if (clean && clean !== String(rawQuery || '').trim()) this.setQueryValue(clean);
|
||||
}
|
||||
const q = parsed.query;
|
||||
if (q.length < 2) return;
|
||||
const prov = this.providersForRequest();
|
||||
this.telemetry.track('search_submit', {
|
||||
providers: Array.isArray(prov) ? prov : ['all'],
|
||||
queryLength: q.length,
|
||||
filters: normalizeFilters(this.filters()),
|
||||
});
|
||||
this.submitted.emit({ q, providers: prov, filters: normalizeFilters(this.filters()) });
|
||||
}
|
||||
|
||||
onSubmit(ev: Event) {
|
||||
try { ev.preventDefault(); } catch {}
|
||||
this.suggestionsOpen.set(false);
|
||||
this.suggestIndex.set(-1);
|
||||
let q = (this.query() || '').trim();
|
||||
if ((!q || q.length === 0) && this.qInputRef?.nativeElement) {
|
||||
try {
|
||||
const raw = this.qInputRef.nativeElement.value || '';
|
||||
q = String(raw).trim();
|
||||
} catch {}
|
||||
this.closeSuggestions();
|
||||
this.atOpen.set(false);
|
||||
// On passe la saisie BRUTE à runSearch : c'est lui qui extrait les
|
||||
// opérateurs (`live:`…) et les convertit en filtres.
|
||||
let raw = (this.query() || '').trim();
|
||||
if (!raw && this.qInputRef?.nativeElement) {
|
||||
try { raw = String(this.qInputRef.nativeElement.value || '').trim(); } catch {}
|
||||
}
|
||||
if (q.length < 2) return;
|
||||
|
||||
const prov = this.providersForRequest();
|
||||
this.telemetry.track('search_submit', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: q.length });
|
||||
// Emit payload to parent (Header/Search page)
|
||||
this.submitted.emit({ q, providers: prov });
|
||||
if (parseQueryTokens(raw).query.length < 2) {
|
||||
this.focusInputSafe();
|
||||
return;
|
||||
}
|
||||
this.runSearch(raw);
|
||||
}
|
||||
|
||||
openPicker() {
|
||||
this.saveFocus();
|
||||
this.pickerOpen.set(true);
|
||||
this.telemetry.track('provider_picker_open');
|
||||
}
|
||||
closePicker() {
|
||||
if (!this.pickerOpen()) return;
|
||||
this.pickerOpen.set(false);
|
||||
this.restoreFocus();
|
||||
/**
|
||||
* Autocomplétion des opérateurs : uniquement quand l'utilisateur a tapé un
|
||||
* « : » (`linux live:` -> suggestions live/live-only). Pas de bruit pendant
|
||||
* une saisie normale.
|
||||
*/
|
||||
operatorSuggestions(): string[] {
|
||||
const partial = this.operatorPartial();
|
||||
if (partial === null) return [];
|
||||
return operatorTokenSuggestions(partial, 6);
|
||||
}
|
||||
|
||||
// Quick menu open/close with focus management (a11y)
|
||||
openQuickMenu() {
|
||||
this.saveFocus();
|
||||
this.quickOpen.set(true);
|
||||
this.telemetry.track('quick_menu_open');
|
||||
// Load last 15 searches (deduped: same query shown once)
|
||||
try {
|
||||
this.history.getSearchHistory(15).subscribe({
|
||||
next: (items) => {
|
||||
const seen = new Set<string>();
|
||||
const clean: SearchHistoryItem[] = [];
|
||||
for (const it of items || []) {
|
||||
const k = normSuggest(it?.query || '');
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
clean.push(it);
|
||||
/** Partie saisie après le dernier « : », ou null si aucun jeton en cours. */
|
||||
private operatorPartial(): string | null {
|
||||
const m = /(^|\s)[a-zA-Zà-ÿ]{1,12}:([a-zA-Zà-ÿ]{0,12})$/.exec(this.query() || '');
|
||||
return m ? (m[2] || '') : null;
|
||||
}
|
||||
this.recentSearches.set(clean);
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
} catch {}
|
||||
|
||||
/** Insère un opérateur proposé et applique le filtre correspondant. */
|
||||
applyOperatorToken(token: string): void {
|
||||
const source = String(this.query() || '');
|
||||
const next = source.replace(/([a-zA-Zà-ÿ]{1,12}:)[a-zA-Zà-ÿ]{0,12}$/, (_m, head: string) => `${head}${token}`);
|
||||
this.setQueryValue(next);
|
||||
this.queryUpdate(next);
|
||||
const merged = normalizeFilters({ ...this.filters(), ...parseQueryTokens(next).filters });
|
||||
if (!filtersEqual(merged, this.filters())) {
|
||||
this.filters.set(merged);
|
||||
this.filtersChange.emit(merged);
|
||||
}
|
||||
closeQuickMenu() {
|
||||
if (!this.quickOpen()) return;
|
||||
this.quickOpen.set(false);
|
||||
this.restoreFocus();
|
||||
this.focusInputSafe();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Focus internals (modales)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private saveFocus() {
|
||||
try { this.lastFocused = document.activeElement as HTMLElement | null; } catch { this.lastFocused = null; }
|
||||
}
|
||||
@@ -572,62 +897,6 @@ export class SearchBoxComponent {
|
||||
this.lastFocused = null;
|
||||
}
|
||||
|
||||
// Generic focus trap used by the quick menu dialog (Tab / Shift+Tab)
|
||||
trapFocus(ev: KeyboardEvent, root: HTMLElement | null | undefined) {
|
||||
if (ev.key !== 'Tab' || !root) return;
|
||||
const focusables = Array.from(root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
|
||||
.filter(el => !el.hasAttribute('disabled'));
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!ev.shiftKey && active === last) {
|
||||
ev.preventDefault();
|
||||
first.focus();
|
||||
} else if (ev.shiftKey && (active === first || active === root)) {
|
||||
ev.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
}
|
||||
onQuickMenuKeydown(ev: KeyboardEvent) {
|
||||
this.trapFocus(ev, this.quickDialogRef?.nativeElement);
|
||||
}
|
||||
|
||||
// Accept both legacy payload (array|'all') and new object payload; use any to satisfy strict template binder
|
||||
applyPicker(payload: any) {
|
||||
const isObj = payload && typeof payload === 'object' && !Array.isArray(payload);
|
||||
const prov = (isObj ? payload.selection : payload) as ProviderId[] | 'all';
|
||||
const rememberDefault = isObj ? !!payload.rememberDefault : false;
|
||||
if (prov === 'all') {
|
||||
this.providerMode.set('all');
|
||||
this.selected.set([]);
|
||||
} else if (Array.isArray(prov)) {
|
||||
this.providerMode.set('custom');
|
||||
this.selected.set(prov);
|
||||
}
|
||||
// Persist default selection if requested and user is logged-in (Step 9)
|
||||
if (rememberDefault) {
|
||||
const body: any = { defaultProviders: (prov === 'all') ? [] : prov };
|
||||
try { this.users.updatePreferences(body).subscribe({ next: () => {}, error: () => {} }); } catch {}
|
||||
}
|
||||
this.telemetry.track('provider_apply', { providers: Array.isArray(prov) ? prov : ['all'], rememberDefault });
|
||||
this.closePicker();
|
||||
}
|
||||
|
||||
// Quick menu close/apply (re-using same payload shape as picker)
|
||||
applyQuickMenu(payload: any) {
|
||||
this.applyPicker(payload);
|
||||
this.closeQuickMenu();
|
||||
}
|
||||
|
||||
// Quick menu provider toggles
|
||||
quickToggleAll() { this.applyQuickMenu({ selection: 'all', rememberDefault: false }); }
|
||||
quickToggle(id: ProviderId) {
|
||||
const set = new Set(this.selected());
|
||||
if (set.has(id)) set.delete(id); else set.add(id);
|
||||
this.selected.set(Array.from(set));
|
||||
}
|
||||
|
||||
// Internal subject for debouncing query changes
|
||||
private _qChanges = new Subject<string>();
|
||||
// Internal subject for debounced typeahead fetches (Step 15)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<!-- Panneau de filtres de recherche (remplace le ancien sélecteur de providers).
|
||||
groups/options = mono-sélection (rôle radiogroup), sources = multi-sélection.
|
||||
Chaque option porte data-group/data-index : le roving tabindex et les
|
||||
raccourcis flèches du panel s'appuient dessus. -->
|
||||
<div class="quick-menu-backdrop" role="presentation" (click)="close.emit()">
|
||||
<div #dialog
|
||||
class="quick-menu-dialog filter-panel-dialog"
|
||||
role="dialog" aria-modal="true" aria-labelledby="filterPanelTitle"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown)="onDialogKeydown($event)"
|
||||
tabindex="-1">
|
||||
|
||||
<!-- En-tête -->
|
||||
<div class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-700 p-4">
|
||||
<h2 id="filterPanelTitle" class="text-base font-semibold text-slate-100">
|
||||
{{ 'filters.title' | t: 'Filtres de recherche' }}
|
||||
</h2>
|
||||
<button type="button" (click)="close.emit()"
|
||||
class="chip shrink-0 rounded bg-slate-700 px-3 py-2 text-slate-100 hover:bg-slate-600"
|
||||
[attr.aria-label]="'filters.close' | t: 'Fermer les filtres (Esc)'">
|
||||
Esc
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-scroll min-h-0 flex-1 space-y-5 p-4">
|
||||
<!-- Recherches récentes : raccourci direct vers une recherche passée -->
|
||||
<section *ngIf="(history || []).length > 0" data-group="history"
|
||||
aria-label="Recherches récentes" class="space-y-2">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
{{ 'filters.recent' | t: 'Recherches récentes' }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button *ngFor="let h of history; let i = index" type="button"
|
||||
[attr.data-index]="i"
|
||||
[attr.tabindex]="activeIndex('history') === i ? 0 : -1"
|
||||
(click)="onPickHistory(h)"
|
||||
class="chip max-w-full truncate rounded-full border border-slate-600 bg-slate-800 px-3 py-2 text-left text-sm text-slate-200 hover:bg-slate-700"
|
||||
[title]="h">
|
||||
<span class="truncate">{{ h }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sources (multi-sélection) -->
|
||||
<section class="space-y-2">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
{{ 'filters.sources' | t: 'Sources' }}
|
||||
</h3>
|
||||
<span class="text-[11px] text-slate-500">
|
||||
{{ allMode() ? ('filters.sources.allHint' | t: 'Toutes les plateformes') : (draft().length + ' / ' + providers.length) }}
|
||||
</span>
|
||||
</div>
|
||||
<div data-group="sources" role="group" [attr.aria-label]="'filters.sources' | t: 'Sources'" class="flex flex-wrap gap-2">
|
||||
<button type="button" data-index="0"
|
||||
[attr.tabindex]="activeIndex('sources') === 0 ? 0 : -1"
|
||||
(click)="toggleAllMode()"
|
||||
class="chip rounded-full border px-3 py-2 text-sm"
|
||||
[ngClass]="allMode()
|
||||
? 'border-slate-300 bg-slate-200 text-slate-900'
|
||||
: 'border-slate-600 bg-slate-800 text-slate-200 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="allMode()">
|
||||
{{ 'filters.sources.all' | t: 'Toutes' }}
|
||||
</button>
|
||||
<button *ngFor="let p of providers; let i = index" type="button"
|
||||
[attr.data-index]="i + 1"
|
||||
[attr.tabindex]="activeIndex('sources') === i + 1 ? 0 : -1"
|
||||
(click)="selectSourceTab(i + 1)"
|
||||
class="chip inline-flex items-center gap-1.5 rounded-full border px-3 py-2 text-sm"
|
||||
[ngClass]="isChecked(p.id)
|
||||
? 'border-slate-300 bg-slate-200 text-slate-900'
|
||||
: 'border-slate-600 bg-slate-800 text-slate-200 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="isChecked(p.id)"
|
||||
[title]="p.displayName">
|
||||
<span [class]="isChecked(p.id) ? 'opacity-70' : ''">{{ p.shortLabel }}</span>
|
||||
<span class="hidden sm:inline">{{ p.displayName }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 pt-1 text-xs text-slate-300">
|
||||
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
|
||||
<span>{{ 'filters.sources.remember' | t: 'Mémoriser comme source par défaut' }}</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Filtres (type / période / durée / tri) : un groupe par ligne -->
|
||||
<section *ngFor="let group of groups()" class="space-y-2">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
{{ group.labelKey | t: group.label }}
|
||||
</h3>
|
||||
<div [attr.data-group]="group.key" role="radiogroup"
|
||||
[attr.aria-label]="group.labelKey | t: group.label"
|
||||
class="flex flex-wrap gap-2">
|
||||
<button *ngFor="let opt of group.options; let i = index" type="button"
|
||||
role="radio"
|
||||
[attr.data-index]="i"
|
||||
[attr.tabindex]="activeIndex(group.key) === i ? 0 : -1"
|
||||
(click)="selectFilter(group.key, opt.value)"
|
||||
class="chip rounded-full border px-3 py-2 text-sm"
|
||||
[ngClass]="opt.selected
|
||||
? 'border-red-500 bg-red-600 text-white'
|
||||
: 'border-slate-600 bg-slate-800 text-slate-200 hover:bg-slate-700'"
|
||||
[attr.aria-checked]="opt.selected">
|
||||
{{ opt.key | t: opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="pb-1 text-[11px] leading-relaxed text-slate-500">
|
||||
{{ 'filters.shortcutHint' | t: 'Astuce : les opérateurs' }}
|
||||
<span class="font-mono text-slate-400">live:</span>,
|
||||
<span class="font-mono text-slate-400">shorts:</span>,
|
||||
<span class="font-mono text-slate-400">today:</span>,
|
||||
<span class="font-mono text-slate-400">long:</span>
|
||||
{{ 'filters.shortcutHintEnd' | t: 'écrits dans la barre de recherche sont convertis en filtres.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pied : réinitialiser / appliquer -->
|
||||
<div class="flex shrink-0 flex-col-reverse gap-2 border-t border-slate-700 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button type="button" (click)="reset()"
|
||||
class="chip rounded px-3 py-3 text-sm text-slate-300 hover:bg-slate-800 disabled:opacity-40"
|
||||
[disabled]="!dirty() && allMode()">
|
||||
{{ 'filters.reset' | t: 'Réinitialiser' }}
|
||||
</button>
|
||||
<div class="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
<button type="button" (click)="close.emit()"
|
||||
class="rounded bg-slate-700 px-4 py-3 text-slate-100 hover:bg-slate-600">
|
||||
{{ 'filters.cancel' | t: 'Annuler' }}
|
||||
</button>
|
||||
<button type="button" data-action="apply" (click)="onApply()"
|
||||
class="inline-flex items-center justify-center gap-2 rounded bg-red-600 px-4 py-3 text-white hover:bg-red-500">
|
||||
{{ 'filters.apply' | t: 'Appliquer' }}
|
||||
<span *ngIf="activeCount() > 0" class="rounded bg-red-800 px-1.5 text-xs">{{ activeCount() }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
Component, EventEmitter, Output, Input, signal, computed,
|
||||
ElementRef, ViewChild, AfterViewInit, OnDestroy, ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
|
||||
import {
|
||||
DEFAULT_FILTERS, filterGroups, normalizeFilters, activeFilterCount,
|
||||
type SearchFilters, type FilterChoice,
|
||||
} from '../../app/search/filters';
|
||||
|
||||
export interface FilterPanelApply {
|
||||
selection: ProviderId[] | 'all';
|
||||
filters: SearchFilters;
|
||||
rememberDefault: boolean;
|
||||
}
|
||||
|
||||
/** Identifiant interne d'un groupe de filtres (raccourcis clavier). */
|
||||
type GroupKey = 'sources' | 'type' | 'period' | 'duration' | 'sort' | 'history';
|
||||
|
||||
interface RovingGroup {
|
||||
key: GroupKey;
|
||||
/** Nombre d'onglets (tabindex) dans le groupe. */
|
||||
size: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panneau de filtres de recherche — remplace l'ancien « picker » de providers.
|
||||
*
|
||||
* Un seul panneau pour *tous* les filtres (sources, type de contenu, période,
|
||||
* durée, tri) + les recherches récentes, pilotable entièrement au clavier :
|
||||
* - Tab / Maj+Tab : navigation normale entre les groupes (piégé dans le dialogue)
|
||||
* - ← / → : option précédente / suivante dans le groupe (roving tabindex)
|
||||
* - ↑ / ↓ : groupe précédent / suivant
|
||||
* - Entrée / Espace : sélectionner l'option
|
||||
* - Échap : fermer
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-search-filter-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe],
|
||||
templateUrl: './search-filter-panel.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SearchFilterPanelComponent implements AfterViewInit, OnDestroy {
|
||||
@Input() open = false;
|
||||
/** Sélection de sources courante ('all' ou une liste d'ids). */
|
||||
@Input() set selected(value: ProviderId[] | 'all') {
|
||||
const all = value === 'all' || !Array.isArray(value) || value.length === 0;
|
||||
this._allMode.set(all);
|
||||
this._draft.set(all ? [] : [...value]);
|
||||
// Onglet actif du groupe « sources » : « Toutes » ou la 1re source cochée.
|
||||
const firstChecked = all ? 0 : (PROVIDERS.findIndex((p) => this._draft().includes(p.id)) + 1);
|
||||
this._active.update((a) => ({ ...a, sources: Math.max(0, firstChecked) }));
|
||||
}
|
||||
/** Filtres courants (valeurs déjà appliquées). */
|
||||
@Input() set filters(value: Partial<SearchFilters> | null | undefined) {
|
||||
const n = normalizeFilters(value);
|
||||
this._filters.set(n);
|
||||
// Chaque groupe démarre sur son option sélectionnée (Tab retrouve le bon point).
|
||||
this._active.update((a) => {
|
||||
const next = { ...a };
|
||||
for (const g of filterGroups(n)) {
|
||||
const idx = g.options.findIndex((o) => o.selected);
|
||||
if (idx >= 0) (next as any)[g.key] = idx;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
/** Recherches récentes (raccourci vers une recherche déjà faite). */
|
||||
@Input() history: string[] = [];
|
||||
/** Requête courante (affichée dans le champ de recherche du panneau). */
|
||||
@Input() query = '';
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() apply = new EventEmitter<FilterPanelApply>();
|
||||
/** Rappelé quand l'utilisateur choisit une source de recherche récente. */
|
||||
@Output() pickHistory = new EventEmitter<string>();
|
||||
|
||||
// --- brouillon (rien n'est appliqué avant « Appliquer ») ---
|
||||
private _draft = signal<ProviderId[]>([]);
|
||||
private _allMode = signal(true);
|
||||
private _filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
|
||||
rememberDefault = signal(false);
|
||||
/** Index de l'onglet actif par groupe (roving tabindex). */
|
||||
private _active = signal<Record<GroupKey, number>>({
|
||||
sources: 0, type: 0, period: 0, duration: 0, sort: 0, history: 0,
|
||||
});
|
||||
|
||||
@ViewChild('dialog', { static: false }) dialogRef?: ElementRef<HTMLDivElement>;
|
||||
|
||||
readonly providers = PROVIDERS;
|
||||
readonly draft = computed(() => this._draft());
|
||||
readonly allMode = computed(() => this._allMode() && this._draft().length === 0);
|
||||
/** Filtres en cours d'édition (le brouillon). */
|
||||
readonly draftFilters = computed(() => this._filters());
|
||||
/** Groupes de filtres (type / période / durée / tri) avec l'état sélectionné. */
|
||||
readonly groups = computed(() => filterGroups(this._filters()));
|
||||
readonly activeCount = computed(() => activeFilterCount(this._filters()));
|
||||
/** Le panneau a-t-il été modifié depuis l'ouverture ? */
|
||||
readonly dirty = computed(() => {
|
||||
return JSON.stringify(this._filters()) !== JSON.stringify(DEFAULT_FILTERS) || !this.allMode();
|
||||
});
|
||||
/** Nombre total d'onglets du groupe « sources » (Toutes + N providers). */
|
||||
readonly sourcesSize = PROVIDERS.length + 1;
|
||||
|
||||
isChecked(id: ProviderId): boolean {
|
||||
if (this.allMode()) return true;
|
||||
return this._draft().includes(id);
|
||||
}
|
||||
|
||||
/** Onglet coché/actif du groupe sources (0 = « Toutes »). */
|
||||
isSourceTabActive(index: number): boolean {
|
||||
if (index === 0) return this.allMode();
|
||||
return this.selectedProviderIdAt(index - 1) != null && !this.allMode() && this.isChecked(this.selectedProviderIdAt(index - 1)!);
|
||||
}
|
||||
|
||||
private selectedProviderIdAt(i: number): ProviderId | null {
|
||||
return this.providers[i]?.id ?? null;
|
||||
}
|
||||
|
||||
toggleAllMode(): void {
|
||||
this._allMode.set(true);
|
||||
this._draft.set([]);
|
||||
}
|
||||
|
||||
toggle(id: ProviderId): void {
|
||||
if (this.allMode()) {
|
||||
// Sortir du mode « Toutes » en décochant une source : on part de la liste
|
||||
// complète puis on retire celle-ci (« toutes sauf celle-là »).
|
||||
const set = new Set(PROVIDERS.map((p) => p.id));
|
||||
set.delete(id);
|
||||
this._draft.set(Array.from(set));
|
||||
this._allMode.set(false);
|
||||
return;
|
||||
}
|
||||
const set = new Set(this._draft());
|
||||
if (set.has(id)) set.delete(id);
|
||||
else set.add(id);
|
||||
this._draft.set(Array.from(set));
|
||||
this._allMode.set(false);
|
||||
}
|
||||
|
||||
/** Sélectionne un onglet du groupe « sources » (index 0 = Toutes). */
|
||||
selectSourceTab(index: number): void {
|
||||
this._active.update((a) => ({ ...a, sources: index }));
|
||||
if (index <= 0) { this.toggleAllMode(); return; }
|
||||
const id = this.selectedProviderIdAt(index - 1);
|
||||
if (id) this.toggle(id);
|
||||
}
|
||||
|
||||
/** Sélectionne une option d'un groupe de filtres mono-sélection. */
|
||||
selectFilter(groupKey: string, value: string): void {
|
||||
this._filters.set({ ...this._filters(), [groupKey]: value } as SearchFilters);
|
||||
// Le roving tabindex suit la sélection : Tab retombe sur l'option active.
|
||||
const group = this.groups().find((g) => g.key === groupKey);
|
||||
const idx = group ? this.optionIndexOf(group, value) : 0;
|
||||
if (idx >= 0) this._active.update((a) => ({ ...a, [groupKey as GroupKey]: idx }));
|
||||
}
|
||||
|
||||
/** Onglet actif (roving tabindex) d'un groupe. */
|
||||
activeIndex(key: string): number {
|
||||
return this._active()[key as GroupKey] ?? 0;
|
||||
}
|
||||
|
||||
optionIndexOf(group: { options: readonly FilterChoice[] }, value: string): number {
|
||||
return (group?.options || []).findIndex((o) => o.value === value);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._filters.set({ ...DEFAULT_FILTERS });
|
||||
this._allMode.set(true);
|
||||
this._draft.set([]);
|
||||
this.rememberDefault.set(false);
|
||||
}
|
||||
|
||||
onApply(): void {
|
||||
const selection: ProviderId[] | 'all' = this.allMode() ? 'all' : this._draft();
|
||||
this.apply.emit({ selection, filters: normalizeFilters(this._filters()), rememberDefault: this.rememberDefault() });
|
||||
}
|
||||
|
||||
onPickHistory(text: string): void {
|
||||
this.pickHistory.emit(text);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Clavier
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private groupOf(target: EventTarget | null): GroupKey | null {
|
||||
const el = (target as HTMLElement | null)?.closest?.('[data-group]');
|
||||
const key = el?.getAttribute('data-group');
|
||||
return (key as GroupKey) || null;
|
||||
}
|
||||
|
||||
private groupIndex(key: GroupKey): number {
|
||||
const order: GroupKey[] = ['history', 'sources', 'type', 'period', 'duration', 'sort'];
|
||||
return order.indexOf(key);
|
||||
}
|
||||
|
||||
private focusTab(key: GroupKey, index: number): void {
|
||||
this._active.update((a) => ({ ...a, [key]: index }));
|
||||
// Le DOM n'est repeint qu'après le cycle de détection : on attend un tick.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const el = this.dialogRef?.nativeElement
|
||||
?.querySelector<HTMLElement>(`[data-group="${key}"][data-index="${index}"]`);
|
||||
el?.focus();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
onDialogKeydown(ev: KeyboardEvent): void {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.close.emit();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Tab') { this.trapFocus(ev); return; }
|
||||
const key = this.groupOf(ev.target);
|
||||
if (!key) return;
|
||||
const cur = this._active()[key] ?? 0;
|
||||
const size = this.groupSize(key);
|
||||
const orderIdx = this.groupIndex(key);
|
||||
const move = (delta: number) => {
|
||||
const next = (cur + delta + size) % size;
|
||||
ev.preventDefault();
|
||||
this.focusTab(key, next);
|
||||
};
|
||||
switch (ev.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
// ↓ en milieu de groupe descend d'un onglet, au dernier il passe au
|
||||
// groupe suivant (comportement « menu » classique, moins de tabulations).
|
||||
if (ev.key === 'ArrowDown' && cur < size - 1) { move(1); return; }
|
||||
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.focusGroupByOrder(orderIdx + 1, cur); return; }
|
||||
move(1);
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
if (ev.key === 'ArrowLeft' && cur > 0) { move(-1); return; }
|
||||
move(-1);
|
||||
return;
|
||||
case 'ArrowUp': {
|
||||
ev.preventDefault();
|
||||
this.focusGroupByOrder(orderIdx - 1, cur);
|
||||
return;
|
||||
}
|
||||
case 'Home': ev.preventDefault(); this.focusTab(key, 0); return;
|
||||
case 'End': ev.preventDefault(); this.focusTab(key, size - 1); return;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
/** Saute au groupe voisin en gardant une position d'onglet cohérente. */
|
||||
private focusGroupByOrder(orderIdx: number, fallbackIndex: number): void {
|
||||
const order: GroupKey[] = ['history', 'sources', 'type', 'period', 'duration', 'sort'];
|
||||
if (orderIdx < 0) return;
|
||||
if (orderIdx >= order.length) {
|
||||
// Après le dernier groupe : on cible le bouton « Appliquer ».
|
||||
setTimeout(() => {
|
||||
try { this.dialogRef?.nativeElement?.querySelector<HTMLElement>('[data-action="apply"]')?.focus(); } catch {}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const key = order[orderIdx];
|
||||
const size = this.groupSize(key);
|
||||
this.focusTab(key, Math.min(fallbackIndex, size - 1));
|
||||
}
|
||||
|
||||
private groupSize(key: GroupKey): number {
|
||||
switch (key) {
|
||||
case 'sources': return this.sourcesSize;
|
||||
case 'history': return Math.max(1, (this.history || []).length);
|
||||
default: {
|
||||
const g = this.groups().find((x) => x.key === key);
|
||||
return Math.max(1, g?.options.length || 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Piégeage du focus (Tab / Maj+Tab) à l'intérieur du dialogue. */
|
||||
private trapFocus(ev: KeyboardEvent): void {
|
||||
const root = this.dialogRef?.nativeElement;
|
||||
if (!root) return;
|
||||
const focusables = Array.from(
|
||||
root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
|
||||
).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1');
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!ev.shiftKey && active === last) { ev.preventDefault(); first.focus(); }
|
||||
else if (ev.shiftKey && (active === first || active === root)) { ev.preventDefault(); last.focus(); }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Focus
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Le dialogue reçoit le focus à l'ouverture (pas le premier bouton : on
|
||||
// laisse l'utilisateur choisir où il atterrit avec Tab / flèches).
|
||||
queueMicrotask(() => {
|
||||
try { this.dialogRef?.nativeElement?.focus(); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
try { document.body.style.overflow = ''; } catch {}
|
||||
}
|
||||
}
|
||||
@@ -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 }}
|
||||
@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>
|
||||
}
|
||||
|
||||
@if (hasQuery()) {
|
||||
<!-- 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()) {
|
||||
|
||||
@@ -11,8 +11,13 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
|
||||
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { isShortVideo } from 'src/app/shared/utils/video-kind';
|
||||
import { SearchService, type SearchResponse } from '../../app/search/search.service';
|
||||
import {
|
||||
DEFAULT_FILTERS, activeFilterCount, filtersToQueryParams, filtersFromQueryParams,
|
||||
normalizeFilters, matchesFilters, filterGroups, itemDurationSec, itemPublishedTs,
|
||||
isLiveItem, isShortItem, isChannelItem, isClipItem,
|
||||
type SearchFilters, type FilterDuration, type FilterPeriod, type FilterType, type FilterSort,
|
||||
} from '../../app/search/filters';
|
||||
import { UserService, type UserPreferences } from '../../services/user.service';
|
||||
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
@@ -46,6 +51,13 @@ export class SearchComponent {
|
||||
private defaultProvidersPref = signal<ProviderId[] | null>(null);
|
||||
pageParam = signal<number>(1);
|
||||
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
|
||||
/**
|
||||
* Filtres de recherche. Ils vivent dans l'URL (`?type=&duration=&period=&sort=`)
|
||||
* ET sont poussés dans `SearchService` : le serveur filtre vraiment ses
|
||||
* résultats (InnerTube/Data API quand c'est possible, post-filtrage sinon).
|
||||
* Les filtres locaux ci-dessous ne font plus qu'un dernier raffinement.
|
||||
*/
|
||||
filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
|
||||
|
||||
// Unified multi-provider response (grouped suggestions)
|
||||
groups = signal<Record<string, any[]>>({});
|
||||
@@ -168,16 +180,29 @@ export class SearchComponent {
|
||||
// Public computed used by the template to avoid referencing private `instances`
|
||||
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
|
||||
|
||||
// Active tag filter. For non-Twitch providers: 'all' | 'short' | 'medium' | 'long' | 'recent'.
|
||||
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
|
||||
filterTag = signal<string>('all');
|
||||
|
||||
// Filter bar (same UX as the provider/theme page): client-side selects.
|
||||
// Server-side sort stays on `sortParam` (URL); these four filter locally.
|
||||
durationFilter = signal<'any' | 'short' | 'medium' | 'long'>('any');
|
||||
typeFilter = signal<'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'>('any');
|
||||
// Filter bar (same UX as the provider/theme page): the four selects write the
|
||||
// URL (server-side filtering) and are mirrored locally as a safety net for
|
||||
// providers that ignore a dimension.
|
||||
durationFilter = signal<'any' | FilterDuration>('any');
|
||||
typeFilter = signal<'any' | FilterType>('any');
|
||||
languageFilter = signal<'any' | 'en' | 'fr'>('any');
|
||||
dateFilter = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
|
||||
dateFilter = signal<'any' | FilterPeriod>('any');
|
||||
|
||||
/** Filtres actifs (pour les pastilles + l'indicateur « filtré »). */
|
||||
activeFilterCount = computed(() => activeFilterCount(this.filters()));
|
||||
readonly filterGroupsView = computed(() => filterGroups(this.filters()));
|
||||
/** Étiquettes des filtres actifs, prêtes à afficher. */
|
||||
activeFilterChips = computed(() => {
|
||||
const n = this.filters();
|
||||
const out: { key: keyof SearchFilters; label: string }[] = [];
|
||||
for (const g of this.filterGroupsView()) {
|
||||
const k = g.key as keyof SearchFilters;
|
||||
if (n[k] === 'all' || (k === 'sort' && n.sort === 'relevance')) continue;
|
||||
const opt = g.options.find((o) => o.value === n[k]);
|
||||
if (opt) out.push({ key: k, label: opt.label });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Unified total results count (sum of groups lengths)
|
||||
unifiedTotal = computed(() => {
|
||||
@@ -201,74 +226,22 @@ export class SearchComponent {
|
||||
const list = this.allVideos();
|
||||
if (!Array.isArray(list) || list.length === 0) return [];
|
||||
|
||||
const filtered = this.applyFilterBar(this.applyTagFilter(list));
|
||||
const filtered = this.applyFilterBar(list);
|
||||
return this.sortVideos(filtered);
|
||||
});
|
||||
|
||||
// Filter each provider group separately for sectioned rendering
|
||||
filteredGroups = computed(() => {
|
||||
const tagPredicate = this.buildTagPredicate();
|
||||
const barPredicate = this.buildFilterBarPredicate();
|
||||
const g = this.groups();
|
||||
const result: Record<string, any[]> = {};
|
||||
|
||||
Object.entries(g || {}).forEach(([pid, list]) => {
|
||||
result[pid] = Array.isArray(list) ? list.filter((v) => tagPredicate(v) && barPredicate(v)) : [];
|
||||
result[pid] = Array.isArray(list) ? list.filter((v) => barPredicate(v)) : [];
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
// Available tags computed from current results (only show tags that can apply)
|
||||
availableTags = computed(() => {
|
||||
const list = this.allVideos();
|
||||
if (list.length === 0) return [];
|
||||
|
||||
const tags: { key: string; label: string; show: boolean }[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
// Non-Twitch providers: duration + date filters
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const sevenDays = 7 * oneDay;
|
||||
const thirtyDays = 30 * oneDay;
|
||||
const oneYear = 365 * oneDay;
|
||||
|
||||
let hasShort = false, hasMedium = false, hasLong = false,
|
||||
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
|
||||
|
||||
for (const v of list) {
|
||||
const d = Number(v.durationSec || 0);
|
||||
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
|
||||
const timeDiff = now - uploadTime;
|
||||
|
||||
// Duration filters
|
||||
if (d > 0 && d < 4 * 60) hasShort = true;
|
||||
else if (d >= 4 * 60 && d < 20 * 60) hasMedium = true;
|
||||
else if (d >= 20 * 60) hasLong = true;
|
||||
|
||||
// Date filters
|
||||
if (uploadTime > 0) {
|
||||
if (timeDiff <= oneDay) hasToday = true;
|
||||
if (timeDiff <= sevenDays) hasThisWeek = true;
|
||||
if (timeDiff <= thirtyDays) hasThisMonth = true;
|
||||
if (timeDiff <= oneYear) hasThisYear = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add duration filters
|
||||
tags.push({ key: 'all', label: 'Tout', show: list.length > 0 });
|
||||
tags.push({ key: 'short', label: 'Moins de 4 min', show: hasShort });
|
||||
tags.push({ key: 'medium', label: 'De 4 à 20 min', show: hasMedium });
|
||||
tags.push({ key: 'long', label: '20 min et plus', show: hasLong });
|
||||
|
||||
// Add date filters
|
||||
tags.push({ key: 'today', label: 'Aujourd\'hui', show: hasToday });
|
||||
tags.push({ key: 'this_week', label: 'Cette semaine', show: hasThisWeek });
|
||||
tags.push({ key: 'this_month', label: 'Ce mois-ci', show: hasThisMonth });
|
||||
tags.push({ key: 'this_year', label: 'Cette année', show: hasThisYear });
|
||||
|
||||
return tags.filter(t => t.show);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Update document title when provider or query changes
|
||||
effect(() => {
|
||||
@@ -356,32 +329,38 @@ export class SearchComponent {
|
||||
this.pageParam.set(pageNum);
|
||||
this.unified.setPage(pageNum);
|
||||
if (isFinite(pageSize)) this.unified.setPageSize(pageSize);
|
||||
const sortNorm = (sort === 'date' || sort === 'views') ? sort : 'relevance';
|
||||
this.sortParam.set(sortNorm);
|
||||
this.unified.setSort(sortNorm);
|
||||
// Filtres (type / durée / période / tri) : lus dans l'URL puis poussés
|
||||
// dans le service -> la requête serveur change vraiment de périmètre.
|
||||
const filters = filtersFromQueryParams(pm);
|
||||
this.filters.set(filters);
|
||||
this.sortParam.set(filters.sort as 'relevance' | 'date' | 'views');
|
||||
this.unified.setFilters(filters);
|
||||
// Reflets locaux de la barre de filtres (le `any` = pas de filtre local).
|
||||
this.durationFilter.set(filters.duration);
|
||||
this.typeFilter.set(filters.type);
|
||||
this.dateFilter.set(filters.period);
|
||||
|
||||
if (q) {
|
||||
this.notice.set(null);
|
||||
// Reset active tag + filter bar on new query
|
||||
const provider = prov || this.instances.selectedProvider();
|
||||
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
|
||||
this.durationFilter.set('any');
|
||||
this.typeFilter.set('any');
|
||||
// Reset the language filter on a new query (the other filters are URL
|
||||
// driven and must survive a query change).
|
||||
this.languageFilter.set('any');
|
||||
this.dateFilter.set('any');
|
||||
|
||||
const activeProvider = prov || this.instances.selectedProvider();
|
||||
|
||||
// Use unified search in all cases (URL providers > user default preference > single provider).
|
||||
// Normalized to short ids so every adapter resolves (legacy full names like
|
||||
// 'youtube' would otherwise match no adapter and return nothing).
|
||||
const rawProviders = providersList || this.defaultProvidersPref() || [provider as ProviderId];
|
||||
const rawProviders = providersList || this.defaultProvidersPref() || [activeProvider as ProviderId];
|
||||
const providersToUse: ProviderId[] | 'all' = rawProviders === 'all'
|
||||
? 'all'
|
||||
: (rawProviders as unknown[]).map(p => this.toProviderId(p)).filter((p): p is ProviderId => !!p);
|
||||
this.unified.setQuery(q);
|
||||
this.unified.setProviders(providersToUse);
|
||||
|
||||
// Determine if this is a new search (signature excludes page)
|
||||
const signature = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}|${sortNorm}`;
|
||||
// Determine if this is a new search (signature excludes page but
|
||||
// includes the filters: changing a filter restarts the results).
|
||||
const signature = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}|${filters.sort}|${filters.type}.${filters.duration}.${filters.period}`;
|
||||
const isNewSearch = this.lastSearchSignature !== signature || pageNum === 1;
|
||||
this.lastSearchSignature = signature;
|
||||
|
||||
@@ -390,7 +369,7 @@ export class SearchComponent {
|
||||
const key = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}`;
|
||||
if (this.lastRecordedKey !== key) {
|
||||
this.lastRecordedKey = key;
|
||||
const recFilters: any = { provider, providers: Array.isArray(providersToUse) ? providersToUse : 'all' };
|
||||
const recFilters: any = { provider: activeProvider, providers: Array.isArray(providersToUse) ? providersToUse : 'all' };
|
||||
this.history.recordSearch(q, recFilters).subscribe({
|
||||
next: () => {},
|
||||
error: (err) => console.error('Error recording search:', err)
|
||||
@@ -424,28 +403,11 @@ export class SearchComponent {
|
||||
|
||||
}
|
||||
|
||||
private durationSeconds(item: any): number {
|
||||
const raw = item?.durationSec ?? item?.duration ?? item?.length ?? 0;
|
||||
if (typeof raw === 'number') {
|
||||
return Number.isFinite(raw) ? raw : 0;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
private publishedTimestamp(item: any): number {
|
||||
const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate ?? item?.published_at ?? item?.uploaded ?? null;
|
||||
if (typeof raw === 'number') {
|
||||
return Number.isFinite(raw) ? raw : 0;
|
||||
}
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// Lectures de métadonnées déléguées au modèle de filtres partagé
|
||||
// (server/search-filters.mjs côté API, src/app/search/filters.ts côté front :
|
||||
// même implémentation = mêmes résultats).
|
||||
private durationSeconds(item: any): number { return itemDurationSec(item); }
|
||||
private publishedTimestamp(item: any): number { return itemPublishedTs(item); }
|
||||
|
||||
private viewCount(item: any): number {
|
||||
const raw = item?.viewCount ?? item?.views ?? item?.stats?.views ?? item?.metrics?.views ?? 0;
|
||||
@@ -456,95 +418,13 @@ export class SearchComponent {
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
private buildTagPredicate(): (item: any) => boolean {
|
||||
const tag = this.filterTag();
|
||||
const provider = this.selectedProviderForView();
|
||||
if (provider === 'twitch' || tag === 'all' || tag?.startsWith('twitch')) {
|
||||
return () => true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const sevenDays = 7 * oneDay;
|
||||
const thirtyDays = 30 * oneDay;
|
||||
const oneYear = 365 * oneDay;
|
||||
|
||||
switch (tag) {
|
||||
case 'short':
|
||||
return (item) => {
|
||||
const d = this.durationSeconds(item);
|
||||
return d > 0 && d < 4 * 60;
|
||||
};
|
||||
case 'medium':
|
||||
return (item) => {
|
||||
const d = this.durationSeconds(item);
|
||||
return d >= 4 * 60 && d < 20 * 60;
|
||||
};
|
||||
case 'long':
|
||||
return (item) => {
|
||||
const d = this.durationSeconds(item);
|
||||
return d >= 20 * 60;
|
||||
};
|
||||
case 'today':
|
||||
return (item) => {
|
||||
const ts = this.publishedTimestamp(item);
|
||||
return ts > 0 && (now - ts) <= oneDay;
|
||||
};
|
||||
case 'this_week':
|
||||
return (item) => {
|
||||
const ts = this.publishedTimestamp(item);
|
||||
return ts > 0 && (now - ts) <= sevenDays;
|
||||
};
|
||||
case 'this_month':
|
||||
return (item) => {
|
||||
const ts = this.publishedTimestamp(item);
|
||||
return ts > 0 && (now - ts) <= thirtyDays;
|
||||
};
|
||||
case 'this_year':
|
||||
return (item) => {
|
||||
const ts = this.publishedTimestamp(item);
|
||||
return ts > 0 && (now - ts) <= oneYear;
|
||||
};
|
||||
default:
|
||||
return () => true;
|
||||
}
|
||||
}
|
||||
|
||||
private applyTagFilter(list: any[]): any[] {
|
||||
const predicate = this.buildTagPredicate();
|
||||
return Array.isArray(list) ? list.filter(predicate) : [];
|
||||
}
|
||||
|
||||
// ---- Filter bar (same UX as the provider/theme page) ----
|
||||
|
||||
private isLiveItem(item: any): boolean {
|
||||
if (!item) return false;
|
||||
if (item.isLive === true) return true;
|
||||
// Strict: only an explicit 'live' type counts (unlike the shared util,
|
||||
// offline Twitch channels typed 'channel' must NOT match).
|
||||
return String(item.type || '').toLowerCase() === 'live';
|
||||
}
|
||||
|
||||
private isShortItem(item: any): boolean {
|
||||
if (!item) return false;
|
||||
return isShortVideo({
|
||||
duration: this.durationSeconds(item),
|
||||
type: typeof item.type === 'string' ? item.type : undefined,
|
||||
provider: typeof item.provider === 'string' ? item.provider : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private isChannelItem(item: any): boolean {
|
||||
if (!item) return false;
|
||||
// Chaîne Twitch hors-ligne : type explicite 'channel' et jamais en direct.
|
||||
if (String(item.type || '').toLowerCase() === 'channel' && item.isLive !== true) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private isClipItem(item: any): boolean {
|
||||
if (!item) return false;
|
||||
return String((item as any)?.kind || '').toLowerCase() === 'clip';
|
||||
}
|
||||
// Les 4 dimensions principales vivent dans l'URL (filtres serveur) ; la
|
||||
// langue reste purement locale (aucun provider ne filtre par langue).
|
||||
private isLiveItem(item: any): boolean { return isLiveItem(item); }
|
||||
private isShortItem(item: any): boolean { return isShortItem(item); }
|
||||
private isChannelItem(item: any): boolean { return isChannelItem(item); }
|
||||
private isClipItem(item: any): boolean { return isClipItem(item); }
|
||||
|
||||
/** Compteurs par type pour les pills façon directory Twitch. */
|
||||
typeCounts = computed(() => {
|
||||
@@ -560,71 +440,42 @@ export class SearchComponent {
|
||||
});
|
||||
|
||||
/** Pills Tous / Directs / Vidéos / Clips / Chaînes synchronisées avec typeFilter. */
|
||||
typePill = computed<'all' | 'live' | 'video' | 'short' | 'channel'>(() => {
|
||||
const t = this.typeFilter();
|
||||
typePill = computed<'all' | 'live' | 'video' | 'shorts' | 'channel'>(() => {
|
||||
const t = this.filters().type;
|
||||
if (t === 'live') return 'live';
|
||||
if (t === 'video' || t === 'recorded') return 'video';
|
||||
if (t === 'short') return 'short';
|
||||
if (t === 'video') return 'video';
|
||||
if (t === 'shorts') return 'shorts';
|
||||
if (t === 'channel') return 'channel';
|
||||
return 'all';
|
||||
});
|
||||
|
||||
setTypePill(v: 'all' | 'live' | 'video' | 'short' | 'channel'): void {
|
||||
if (v === 'all') this.typeFilter.set('any');
|
||||
else if (v === 'video') this.typeFilter.set('video');
|
||||
else this.typeFilter.set(v);
|
||||
setTypePill(v: 'all' | 'live' | 'video' | 'shorts' | 'channel'): void {
|
||||
void this.setFilter('type', v === 'all' ? 'all' : v);
|
||||
}
|
||||
|
||||
private isFrText(s: string): boolean {
|
||||
return /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
|
||||
}
|
||||
private isEnText(s: string): boolean {
|
||||
return /\b(the|a|an|and|for)\b/i.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prédicat local : raffine les résultats *déjà filtrés par le serveur* avec
|
||||
* exactement les mêmes règles (durée / période / type) + la langue, qui n'est
|
||||
* pas supportée par les providers.
|
||||
*/
|
||||
private buildFilterBarPredicate(): (item: any) => boolean {
|
||||
const duration = this.durationFilter();
|
||||
const type = this.typeFilter();
|
||||
const filters = this.filters();
|
||||
const lang = this.languageFilter();
|
||||
const date = this.dateFilter();
|
||||
|
||||
if (duration === 'any' && type === 'any' && lang === 'any' && date === 'any') {
|
||||
return () => true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cutoff = date === 'any' ? 0 : {
|
||||
day: now - 24 * 3600 * 1000,
|
||||
week: now - 7 * 24 * 3600 * 1000,
|
||||
month: now - 30 * 24 * 3600 * 1000,
|
||||
year: now - 365 * 24 * 3600 * 1000,
|
||||
}[date] || 0;
|
||||
|
||||
const isFrText = (s: string) => /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
|
||||
const isEnText = (s: string) => /\b(the|a|an|and|for)\b/i.test(s);
|
||||
|
||||
const hasServerFilter = activeFilterCount(filters) > 0;
|
||||
if (!hasServerFilter && lang === 'any') return () => true;
|
||||
return (item: any) => {
|
||||
if (duration !== 'any') {
|
||||
const d = this.durationSeconds(item);
|
||||
if (duration === 'short' && !(d > 0 && d < 240)) return false;
|
||||
if (duration === 'medium' && !(d >= 240 && d < 1200)) return false;
|
||||
if (duration === 'long' && !(d >= 1200)) return false;
|
||||
}
|
||||
if (type !== 'any') {
|
||||
const live = this.isLiveItem(item);
|
||||
const short = this.isShortItem(item);
|
||||
const channel = this.isChannelItem(item);
|
||||
const clip = this.isClipItem(item);
|
||||
if (type === 'live' && !live) return false;
|
||||
if (type === 'channel' && !channel) return false;
|
||||
if (type === 'short' && !(short || clip) && !live && !channel) {
|
||||
// Les clips Twitch (kind='clip', parfois sans durée) comptent comme Shorts.
|
||||
if (!clip) return false;
|
||||
}
|
||||
if (type === 'short' && (live || channel)) return false;
|
||||
// 'video' / 'recorded' = VODs (ni live, ni short/clip, ni chaîne).
|
||||
if ((type === 'video' || type === 'recorded') && (live || short || clip || channel)) return false;
|
||||
}
|
||||
if (lang !== 'any') {
|
||||
if (hasServerFilter && !matchesFilters(item, filters)) return false;
|
||||
if (lang === 'any') return true;
|
||||
const text = `${item?.title || ''} ${item?.channelName || ''}`.toLowerCase();
|
||||
if (lang === 'fr' && !isFrText(text)) return false;
|
||||
if (lang === 'en' && !(isEnText(text) && !isFrText(text))) return false;
|
||||
}
|
||||
if (cutoff > 0 && this.publishedTimestamp(item) < cutoff) return false;
|
||||
return true;
|
||||
if (lang === 'fr') return this.isFrText(text);
|
||||
return this.isEnText(text) && !this.isFrText(text);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -633,19 +484,66 @@ export class SearchComponent {
|
||||
return Array.isArray(list) ? list.filter(predicate) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit un filtre : l'URL devient la source de vérité (donc la requête
|
||||
* serveur repart avec le nouveau périmètre) et le signal local suit.
|
||||
*/
|
||||
setFilter(key: keyof SearchFilters, value: string): void {
|
||||
const next = normalizeFilters({ ...this.filters(), [key]: value } as Partial<SearchFilters>);
|
||||
// Reflets des <select> locaux.
|
||||
if (key === 'duration') this.durationFilter.set(next.duration);
|
||||
if (key === 'type') this.typeFilter.set(next.type);
|
||||
if (key === 'period') this.dateFilter.set(next.period);
|
||||
if (key === 'sort') this.sortParam.set(next.sort as 'relevance' | 'date' | 'views');
|
||||
const cleanParams = this.cleanUrlParams({
|
||||
...this.route.snapshot.queryParams,
|
||||
...filtersToQueryParams(next),
|
||||
page: 1,
|
||||
});
|
||||
// Supprime les clés devenues 'all' (elles ne sont plus réinjectées).
|
||||
for (const k of ['type', 'duration', 'period', 'sort']) {
|
||||
if (!filtersToQueryParams(next)[k]) delete (cleanParams as any)[k];
|
||||
}
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: cleanParams,
|
||||
queryParamsHandling: '',
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Retire un filtre depuis une pastille. */
|
||||
clearFilter(key: keyof SearchFilters): void {
|
||||
void this.setFilter(key, key === 'sort' ? 'relevance' : 'all');
|
||||
}
|
||||
|
||||
clearAllFilters(): void {
|
||||
const next = { ...DEFAULT_FILTERS };
|
||||
this.filters.set(next);
|
||||
this.durationFilter.set('all');
|
||||
this.typeFilter.set('all');
|
||||
this.dateFilter.set('all');
|
||||
this.sortParam.set('relevance');
|
||||
this.unified.setFilters(next);
|
||||
const cleanParams = this.cleanUrlParams({ ...this.route.snapshot.queryParams, page: 1 });
|
||||
for (const k of ['type', 'duration', 'period', 'sort']) delete (cleanParams as any)[k];
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route, queryParams: cleanParams, replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
onDurationChange(event: Event) {
|
||||
this.durationFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
||||
void this.setFilter('duration', ((event.target as HTMLSelectElement)?.value as any) || 'all');
|
||||
}
|
||||
onTypeChange(event: Event) {
|
||||
this.typeFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
||||
void this.setFilter('type', ((event.target as HTMLSelectElement)?.value as any) || 'all');
|
||||
}
|
||||
onLanguageChange(event: Event) {
|
||||
this.languageFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
||||
}
|
||||
onDateChange(event: Event) {
|
||||
this.dateFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
||||
void this.setFilter('period', ((event.target as HTMLSelectElement)?.value as any) || 'all');
|
||||
}
|
||||
|
||||
private sortVideos(list: any[]): any[] {
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
@@ -771,16 +669,23 @@ export class SearchComponent {
|
||||
}
|
||||
|
||||
// Submit handler from the embedded SearchBox: keep URL as source of truth
|
||||
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all' }) {
|
||||
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all'; filters?: SearchFilters }) {
|
||||
const q = (evt?.q || '').trim();
|
||||
if (!q) return;
|
||||
|
||||
// Créer un nouvel objet de paramètres de requête
|
||||
// Les filtres actifs sont conservés : relancer une recherche ne doit pas
|
||||
// perdre le périmètre choisi.
|
||||
const filters = evt?.filters ? normalizeFilters(evt.filters) : this.filters();
|
||||
const queryParams: any = {
|
||||
...this.route.snapshot.queryParams,
|
||||
q,
|
||||
p: this.providerParam() || this.instances.selectedProvider(),
|
||||
page: 1 // reset pagination on new search
|
||||
...filtersToQueryParams(filters),
|
||||
page: 1, // reset pagination on new search
|
||||
};
|
||||
for (const k of ['type', 'duration', 'period', 'sort']) {
|
||||
if (!filtersToQueryParams(filters)[k]) delete queryParams[k];
|
||||
}
|
||||
|
||||
// Gérer les fournisseurs
|
||||
if (Array.isArray(evt.providers)) {
|
||||
@@ -819,11 +724,6 @@ export class SearchComponent {
|
||||
// Vous pouvez ajouter ici la logique de recherche au fur et à mesure si nécessaire
|
||||
}
|
||||
|
||||
// Update active filter tag from the template
|
||||
setFilterTag(key: string) {
|
||||
this.filterTag.set(key);
|
||||
}
|
||||
|
||||
// Infinite-scroll pagination: append the next page WITHOUT touching the
|
||||
// router URL. Navigating (even with replaceUrl) rewrites the location hash
|
||||
// (HashLocationStrategy) and remounts list state, which throws the user
|
||||
@@ -866,21 +766,10 @@ export class SearchComponent {
|
||||
});
|
||||
}
|
||||
|
||||
// Sort control
|
||||
// Sort control -> même chemin que les autres filtres (URL + service)
|
||||
onSortChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement)?.value as 'relevance' | 'date' | 'views' | '';
|
||||
const sort = (value === 'date' || value === 'views') ? value : 'relevance';
|
||||
const cleanParams = this.cleanUrlParams({
|
||||
...this.route.snapshot.queryParams,
|
||||
sort,
|
||||
page: 1
|
||||
});
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: cleanParams,
|
||||
queryParamsHandling: '',
|
||||
replaceUrl: true
|
||||
});
|
||||
const value = (event.target as HTMLSelectElement)?.value as FilterSort | '';
|
||||
void this.setFilter('sort', (value === 'date' || value === 'views') ? value : 'relevance');
|
||||
}
|
||||
|
||||
// CTA handler: navigate to focus on a single provider while preserving q
|
||||
|
||||
@@ -127,6 +127,42 @@ const EN: Record<string, string> = {
|
||||
'filter.date.week': 'Last week',
|
||||
'filter.date.month': 'Last month',
|
||||
'filter.date.year': 'Last year',
|
||||
// Search filters panel (type / period / duration / sort)
|
||||
'filters.title': 'Search filters',
|
||||
'filters.close': 'Close filters (Esc)',
|
||||
'filters.recent': 'Recent searches',
|
||||
'filters.sources': 'Sources',
|
||||
'filters.sources.all': 'All',
|
||||
'filters.sources.allHint': 'All platforms',
|
||||
'filters.sources.remember': 'Remember as default sources',
|
||||
'filters.type': 'Content type',
|
||||
'filters.type.all': 'Anything',
|
||||
'filters.type.video': 'Videos',
|
||||
'filters.type.shorts': 'Shorts / Clips',
|
||||
'filters.type.live': 'Live',
|
||||
'filters.type.channel': 'Channels',
|
||||
'filters.period': 'Upload period',
|
||||
'filters.period.all': 'Any time',
|
||||
'filters.period.hour': 'Last hour',
|
||||
'filters.period.today': 'Today',
|
||||
'filters.period.week': 'This week',
|
||||
'filters.period.month': 'This month',
|
||||
'filters.period.year': 'This year',
|
||||
'filters.duration': 'Duration',
|
||||
'filters.duration.all': 'Any',
|
||||
'filters.duration.short': 'Short < 4 min',
|
||||
'filters.duration.medium': 'Medium 4-20 min',
|
||||
'filters.duration.long': 'Long > 20 min',
|
||||
'filters.sort': 'Sort by',
|
||||
'filters.sort.relevance': 'Relevance',
|
||||
'filters.sort.date': 'Date',
|
||||
'filters.sort.views': 'Views',
|
||||
'filters.reset': 'Reset',
|
||||
'filters.cancel': 'Cancel',
|
||||
'filters.apply': 'Apply',
|
||||
'filters.shortcutHint': 'Tip: the operators',
|
||||
'filters.shortcutHintEnd': 'typed in the search bar become filters.',
|
||||
'filters.serverHint': 'Type, period and duration are applied by the sources; language filters the displayed results.',
|
||||
// Optional per-theme labels
|
||||
'theme.finance-crypto': 'Finance & Crypto',
|
||||
'theme.gaming': 'Gaming',
|
||||
@@ -281,6 +317,42 @@ const FR: Record<string, string> = {
|
||||
'filter.date.week': 'Semaine dernière',
|
||||
'filter.date.month': 'Mois dernier',
|
||||
'filter.date.year': 'Année dernière',
|
||||
// Panneau de filtres de recherche (type / période / durée / tri)
|
||||
'filters.title': 'Filtres de recherche',
|
||||
'filters.close': 'Fermer les filtres (Esc)',
|
||||
'filters.recent': 'Recherches récentes',
|
||||
'filters.sources': 'Sources',
|
||||
'filters.sources.all': 'Toutes',
|
||||
'filters.sources.allHint': 'Toutes les plateformes',
|
||||
'filters.sources.remember': 'Mémoriser comme sources par défaut',
|
||||
'filters.type': 'Type de contenu',
|
||||
'filters.type.all': 'Tout',
|
||||
'filters.type.video': 'Vidéos',
|
||||
'filters.type.shorts': 'Shorts / Clips',
|
||||
'filters.type.live': 'En direct',
|
||||
'filters.type.channel': 'Chaînes',
|
||||
'filters.period': 'Période de mise en ligne',
|
||||
'filters.period.all': "N'importe quand",
|
||||
'filters.period.hour': 'Dernière heure',
|
||||
'filters.period.today': "Aujourd'hui",
|
||||
'filters.period.week': 'Cette semaine',
|
||||
'filters.period.month': 'Ce mois',
|
||||
'filters.period.year': 'Cette année',
|
||||
'filters.duration': 'Durée',
|
||||
'filters.duration.all': 'Toutes',
|
||||
'filters.duration.short': 'Courte < 4 min',
|
||||
'filters.duration.medium': 'Moyenne 4-20 min',
|
||||
'filters.duration.long': 'Longue > 20 min',
|
||||
'filters.sort': 'Trier par',
|
||||
'filters.sort.relevance': 'Pertinence',
|
||||
'filters.sort.date': 'Date',
|
||||
'filters.sort.views': 'Vues',
|
||||
'filters.reset': 'Réinitialiser',
|
||||
'filters.cancel': 'Annuler',
|
||||
'filters.apply': 'Appliquer',
|
||||
'filters.shortcutHint': 'Astuce : les opérateurs',
|
||||
'filters.shortcutHintEnd': 'écrits dans la barre de recherche deviennent des filtres.',
|
||||
'filters.serverHint': "Type, période et durée sont appliqués par les sources ; la langue filtre les résultats affichés.",
|
||||
// Optional per-theme labels
|
||||
'theme.finance-crypto': 'Finance & Crypto',
|
||||
'theme.gaming': 'Jeux vidéo',
|
||||
|
||||
@@ -8,6 +8,8 @@ export type TelemetryEvent =
|
||||
| 'provider_apply'
|
||||
| 'at_autocomplete_use'
|
||||
| 'quick_menu_open'
|
||||
| 'filter_panel_open'
|
||||
| 'filter_apply'
|
||||
| 'suggest_shown'
|
||||
| 'suggest_used';
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
- [x] Step 14: Update README with search UX section, keyboard shortcuts and usage notes
|
||||
- [x] Step 15: Auto-complétion de la **requête** (typeahead) dans la barre de recherche
|
||||
- [x] Step 16: **Transcripts** de vidéos sur la page Watch (API + UI, Phase 1 du doc)
|
||||
- [x] Step 19: Panneau de filtres unifié (remplace le ProviderPicker) + filtres serveur + clavier/focus du panneau de suggestions
|
||||
|
||||
---
|
||||
|
||||
@@ -154,7 +155,7 @@ valeur / coût pour l'existant.
|
||||
|
||||
| # | Fonction | Description | Effort |
|
||||
|---|---|---|---|
|
||||
| 1 | **Filtres de recherche** | Durée (< 4 min / 4-20 / 20+), type (vidéo/chaîne/playlist), date de mise en ligne, langue. `SearchService.sort$` existe déjà, il manque les params + l'UI en chips + le mapping par adapter | + |
|
||||
| 1 | **Filtres de recherche** | ✅ DONE : modèle partagé (`src/app/search/filters.ts` + `server/search-filters.mjs`), panneau unique (sources / type / période / durée / tri) au clavier, filtres dans l'URL + `/api/search`, appliqués nativement (InnerTube, Data API, `--dateafter`) ou en post-traitement, opérateurs `live:`/`today:`/`long:`, langue encore locale | ~ |
|
||||
| 2 | **Pagination / infinite scroll** | ✅ DONE (Step 18) : backend pages illimitées via continuations InnerTube (`page=2,3…`, `pageSize` ≤ 50) + front `loadNextPage()` + `<app-infinite-anchor>` + `mergeGroups` + `endReached` (`search.component.ts`) | ~ |
|
||||
| 3 | **Dédup multi-provider** | La même vidéo existe souvent sur Rumble/Odysee/PeerTube : regrouper par titre+durée+chaîne et afficher « aussi disponible sur… » | + |
|
||||
| 4 | **Recherche dans les résultats** (« search within results ») | Relancer la requête en restreignant au provider/à la chaîne déjà affichée | ~ |
|
||||
@@ -193,7 +194,8 @@ valeur / coût pour l'existant.
|
||||
|
||||
## Test commands
|
||||
```bash
|
||||
npm run test:search # Step 11 — unit tests: SearchService + @ parsing + ProviderPicker
|
||||
npm run test:search # Step 11/19 — unit tests: SearchService, @ parsing, clavier/focus, panneau de filtres
|
||||
npm run test:filters # Step 19 — filtres serveur (offline)
|
||||
npm run test:search-e2e # Step 12 — e2e scenarios against a real isolated server
|
||||
npm run test:preferences # Step 9 — defaultProviders persistence
|
||||
npm run test:telemetry # Step 13 — telemetry events (insert/list/count)
|
||||
|
||||
Reference in New Issue
Block a user